From d5d6305f9cbb622059d0f4a58025ddcd5616ea94 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 12 Oct 2026 15:31:19 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E8=A7=84=E5=88=99=E6=94=AF=E6=8C=81=20Excel=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E4=B8=8E=E6=A8=A1=E6=9D=BF=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Dtos/Channels/AutoTriggerImportInput.cs | 25 ++ .../Dtos/Channels/AutoTriggerImportOutput.cs | 25 ++ .../Services/Channels/AutoTriggerService.cs | 112 +++++++ .../Shared/AutoTriggerImportHelper.cs | 290 ++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs create mode 100644 src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs create mode 100644 src/Oms.Application/Shared/AutoTriggerImportHelper.cs diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs new file mode 100644 index 0000000..9a63e80 --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/AutoTriggerImportInput.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace Oms.Application.Dtos.Channels; + +/// +/// 自动触发规则 Excel 导入入参。 +/// +public class AutoTriggerImportInput +{ + /// + /// 导入模式:incremental=增量;full=全量(先清空再导入)。 + /// + public string ImportMode { get; set; } = ImportModes.Incremental; + + /// + /// Excel 文件。 + /// + public IFormFile File { get; set; } = null!; + + public static class ImportModes + { + public const string Incremental = "incremental"; + public const string Full = "full"; + } +} diff --git a/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs b/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs new file mode 100644 index 0000000..e73fe1e --- /dev/null +++ b/src/Oms.Application/Dtos/Channels/AutoTriggerImportOutput.cs @@ -0,0 +1,25 @@ +namespace Oms.Application.Dtos.Channels; + +/// +/// 自动触发规则 Excel 导入结果。 +/// +public class AutoTriggerImportOutput +{ + public int SuccessCount { get; set; } + + public int SkipCount { get; set; } + + public int FailCount { get; set; } + + public List Failures { get; set; } = []; +} + +/// +/// 导入失败行。 +/// +public class AutoTriggerImportFailRow +{ + public int RowNumber { get; set; } + + public string Reason { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Services/Channels/AutoTriggerService.cs b/src/Oms.Application/Services/Channels/AutoTriggerService.cs index 97477f5..a5cad73 100644 --- a/src/Oms.Application/Services/Channels/AutoTriggerService.cs +++ b/src/Oms.Application/Services/Channels/AutoTriggerService.cs @@ -38,6 +38,118 @@ public class AutoTriggerService : ApplicationService return new OmsPageResult { Items = items, Total = total }; } + /// + /// 下载自动触发规则导入模板。 + /// + [HttpGet("/channel/auto-trigger/import-template")] + public async Task DownloadImportTemplateAsync() + { + var bytes = await AutoTriggerImportHelper.BuildTemplateBytesAsync(); + return new FileContentResult(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + { + FileDownloadName = AutoTriggerImportHelper.TemplateFileName + }; + } + + /// + /// 导入自动触发规则(Excel)。 + /// + [HttpPost("/channel/auto-trigger/import")] + public async Task ImportAsync([FromForm] AutoTriggerImportInput input) + { + if (input.File == null || input.File.Length == 0) + { + throw new BusinessException("请上传 Excel 文件"); + } + + var importMode = NormalizeImportMode(input.ImportMode); + await using var stream = input.File.OpenReadStream(); + var parsedRows = await AutoTriggerImportHelper.ParseImportRowsAsync(stream); + if (parsedRows.Count == 0) + { + throw new BusinessException("Excel 中无有效数据行"); + } + + if (importMode == AutoTriggerImportInput.ImportModes.Full) + { + var existing = await AutoTriggerRepository.GetListAsync(_ => true); + foreach (var entity in existing) + { + await AutoTriggerRepository.DeleteAsync(entity); + } + } + + var output = new AutoTriggerImportOutput(); + var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (rowNumber, entity) in parsedRows) + { + try + { + AutoTriggerImportHelper.ValidateEntity(entity); + entity.TriggerTiming = AutoTriggerTimingCodes.ToCanonicalLabel(entity.TriggerTiming); + var key = BuildDuplicateKey(entity); + if (!seenKeys.Add(key)) + { + output.SkipCount++; + output.Failures.Add(new AutoTriggerImportFailRow + { + RowNumber = rowNumber, + Reason = "文件内规则重复,已跳过" + }); + continue; + } + + if (importMode == AutoTriggerImportInput.ImportModes.Incremental) + { + var duplicated = await AutoTriggerRepository.FirstOrDefaultAsync(x => + x.RuleName == entity.RuleName + && x.Operation == entity.Operation + && x.TriggerTiming == entity.TriggerTiming + && x.TriggerCondition == entity.TriggerCondition); + if (duplicated != null) + { + output.SkipCount++; + output.Failures.Add(new AutoTriggerImportFailRow + { + RowNumber = rowNumber, + Reason = "与已有规则重复,已跳过" + }); + continue; + } + } + + await AutoTriggerRepository.InsertAsync(entity); + output.SuccessCount++; + } + catch (Exception ex) + { + output.FailCount++; + output.Failures.Add(new AutoTriggerImportFailRow + { + RowNumber = rowNumber, + Reason = ex.Message + }); + } + } + + return output; + } + + private static string NormalizeImportMode(string? mode) + { + if (string.Equals(mode, AutoTriggerImportInput.ImportModes.Full, StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "全量", StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, "replace", StringComparison.OrdinalIgnoreCase)) + { + return AutoTriggerImportInput.ImportModes.Full; + } + + return AutoTriggerImportInput.ImportModes.Incremental; + } + + private static string BuildDuplicateKey(AutoTrigger entity) => + $"{entity.RuleName}|{entity.Operation}|{entity.TriggerTiming}|{entity.TriggerCondition}"; + /// /// 获取公式字段元数据。 /// diff --git a/src/Oms.Application/Shared/AutoTriggerImportHelper.cs b/src/Oms.Application/Shared/AutoTriggerImportHelper.cs new file mode 100644 index 0000000..7c7c66b --- /dev/null +++ b/src/Oms.Application/Shared/AutoTriggerImportHelper.cs @@ -0,0 +1,290 @@ +using MiniExcelLibs; +using Oms.Application.Services.Orders.Issues; +using Oms.Domain.ChannelsAggregate.Entities; +using Oms.Domain.Shared.Enums; + +namespace Oms.Application.Shared; + +/// +/// 自动触发规则 Excel 模板与导入解析。 +/// +public static class AutoTriggerImportHelper +{ + public const string TemplateSheetName = "导入模板"; + public const string EnumSheetName = "枚举值"; + + private static readonly Dictionary HeaderAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["规则名称"] = nameof(AutoTrigger.RuleName), + ["RuleName"] = nameof(AutoTrigger.RuleName), + ["ruleName"] = nameof(AutoTrigger.RuleName), + ["优先级"] = nameof(AutoTrigger.Priority), + ["Priority"] = nameof(AutoTrigger.Priority), + ["priority"] = nameof(AutoTrigger.Priority), + ["状态"] = nameof(AutoTrigger.Status), + ["Status"] = nameof(AutoTrigger.Status), + ["status"] = nameof(AutoTrigger.Status), + ["操作"] = nameof(AutoTrigger.Operation), + ["Operation"] = nameof(AutoTrigger.Operation), + ["operation"] = nameof(AutoTrigger.Operation), + ["触发时机"] = nameof(AutoTrigger.TriggerTiming), + ["TriggerTiming"] = nameof(AutoTrigger.TriggerTiming), + ["triggerTiming"] = nameof(AutoTrigger.TriggerTiming), + ["触发条件"] = nameof(AutoTrigger.TriggerCondition), + ["TriggerCondition"] = nameof(AutoTrigger.TriggerCondition), + ["triggerCondition"] = nameof(AutoTrigger.TriggerCondition), + ["提示语"] = nameof(AutoTrigger.PromptMessage), + ["提示信息"] = nameof(AutoTrigger.PromptMessage), + ["PromptMessage"] = nameof(AutoTrigger.PromptMessage), + ["promptMessage"] = nameof(AutoTrigger.PromptMessage), + ["通知人"] = nameof(AutoTrigger.Notifier), + ["通知对象"] = nameof(AutoTrigger.Notifier), + ["Notifier"] = nameof(AutoTrigger.Notifier), + ["notifier"] = nameof(AutoTrigger.Notifier), + }; + + public static async Task BuildTemplateBytesAsync() + { + var templateRows = new List> + { + new() + { + ["规则名称"] = "英国超尺寸拦截示例", + ["优先级"] = 100, + ["状态"] = "启用", + ["操作"] = "拦截", + ["触发时机"] = "下单", + ["触发条件"] = "{\n \"expression\": \"收件国家 == \\\"GB\\\"\"\n}", + ["提示语"] = "超尺寸,禁止下单", + ["通知人"] = "" + } + }; + + var enumRows = new List> + { + new() { ["枚举类型"] = "状态", ["编码"] = "2", ["名称"] = "启用" }, + new() { ["枚举类型"] = "状态", ["编码"] = "1", ["名称"] = "禁用" }, + new() { ["枚举类型"] = "操作", ["编码"] = "intercept", ["名称"] = "拦截" }, + new() { ["枚举类型"] = "操作", ["编码"] = "warn", ["名称"] = "警告" }, + new() { ["枚举类型"] = "操作", ["编码"] = "problem", ["名称"] = "问题件" }, + new() { ["枚举类型"] = "触发时机", ["编码"] = "order_create", ["名称"] = "下单" }, + new() { ["枚举类型"] = "触发时机", ["编码"] = "inbound", ["名称"] = "入库" }, + new() { ["枚举类型"] = "触发时机", ["编码"] = "outbound", ["名称"] = "出库" }, + }; + + var sheets = new Dictionary + { + [TemplateSheetName] = templateRows, + [EnumSheetName] = enumRows + }; + + await using var stream = new MemoryStream(); + await MiniExcel.SaveAsAsync(stream, sheets); + return stream.ToArray(); + } + + public static string TemplateFileName => "自动触发规则导入模板.xlsx"; + + internal static string? ResolveImportSheetName(IEnumerable sheetNames) + { + var names = sheetNames.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (names.Count == 0) + { + return null; + } + + return names.FirstOrDefault(x => + string.Equals(x, TemplateSheetName, StringComparison.OrdinalIgnoreCase)) + ?? names.FirstOrDefault(x => + !string.Equals(x, EnumSheetName, StringComparison.OrdinalIgnoreCase)) + ?? names[0]; + } + + public static async Task> ParseImportRowsAsync(Stream fileStream) + { + if (fileStream.CanSeek) + { + fileStream.Position = 0; + } + + var sheetNames = MiniExcel.GetSheetNames(fileStream); + var sheetName = ResolveImportSheetName(sheetNames); + + if (fileStream.CanSeek) + { + fileStream.Position = 0; + } + + var rows = await MiniExcel.QueryAsync(fileStream, useHeaderRow: true, sheetName: sheetName); + var result = new List<(int RowNumber, AutoTrigger Entity)>(); + var rowNumber = 1; + + foreach (var row in rows) + { + rowNumber++; + if (row is not IDictionary rowDict) + { + continue; + } + + if (IsEmptyRow(rowDict)) + { + continue; + } + + result.Add((rowNumber, ParseRow(rowDict))); + } + + return result; + } + + public static void ValidateEntity(AutoTrigger entity) + { + if (string.IsNullOrWhiteSpace(entity.RuleName)) + { + throw new InvalidOperationException("规则名称不能为空"); + } + + if (string.IsNullOrWhiteSpace(entity.TriggerCondition)) + { + throw new InvalidOperationException("触发条件不能为空"); + } + + if (string.IsNullOrWhiteSpace(entity.Operation)) + { + throw new InvalidOperationException("操作不能为空"); + } + + if (string.IsNullOrWhiteSpace(entity.TriggerTiming)) + { + throw new InvalidOperationException("触发时机不能为空"); + } + + if (string.IsNullOrWhiteSpace(entity.PromptMessage)) + { + entity.PromptMessage = entity.RuleName; + } + + if (entity.Priority <= 0) + { + entity.Priority = 100; + } + } + + private static AutoTrigger ParseRow(IDictionary rowDict) + { + var mapped = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (header, value) in rowDict) + { + if (string.IsNullOrWhiteSpace(header)) + { + continue; + } + + if (!HeaderAliases.TryGetValue(header.Trim(), out var field)) + { + continue; + } + + mapped[field] = NormalizeCell(value); + } + + var operation = NormalizeOperation(mapped.GetValueOrDefault(nameof(AutoTrigger.Operation))); + var timing = AutoTriggerTimingCodes.ToCanonicalLabel( + mapped.GetValueOrDefault(nameof(AutoTrigger.TriggerTiming))); + var status = NormalizeStatus(mapped.GetValueOrDefault(nameof(AutoTrigger.Status))); + var priorityText = mapped.GetValueOrDefault(nameof(AutoTrigger.Priority)); + var priority = int.TryParse(priorityText, out var p) && p > 0 ? p : 100; + + return new AutoTrigger + { + RuleName = mapped.GetValueOrDefault(nameof(AutoTrigger.RuleName))?.Trim(), + Priority = priority, + Status = status, + Operation = operation, + TriggerTiming = timing, + TriggerCondition = mapped.GetValueOrDefault(nameof(AutoTrigger.TriggerCondition)), + PromptMessage = mapped.GetValueOrDefault(nameof(AutoTrigger.PromptMessage))?.Trim(), + Notifier = mapped.GetValueOrDefault(nameof(AutoTrigger.Notifier))?.Trim() + }; + } + + private static string NormalizeOperation(string? raw) + { + var text = (raw ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + if (ContainsAny(text, "拦截", "intercept", "block")) + { + return "拦截"; + } + + if (ContainsAny(text, "警告", "warn", "warning")) + { + return "警告"; + } + + if (ContainsAny(text, "问题件", "problem", "issue")) + { + return "问题件"; + } + + return text; + } + + private static EnableStatus NormalizeStatus(string? raw) + { + var text = (raw ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(text)) + { + return EnableStatus.Enabled; + } + + if (text is "1" or "禁用" or "停用" or "disabled" or "Disabled" or "false" or "False" or "0") + { + return EnableStatus.Disabled; + } + + return EnableStatus.Enabled; + } + + private static bool ContainsAny(string text, params string[] keywords) + { + foreach (var keyword in keywords) + { + if (text.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string? NormalizeCell(object? value) + { + if (value == null || value is DBNull) + { + return null; + } + + var text = Convert.ToString(value)?.Trim(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + + private static bool IsEmptyRow(IDictionary rowDict) + { + foreach (var value in rowDict.Values) + { + if (!string.IsNullOrWhiteSpace(NormalizeCell(value))) + { + return false; + } + } + + return true; + } +} -- 2.45.2 From 551ac09f78588d37217bdc736c3dc444ccd3b970 Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Tue, 13 Oct 2026 15:20:53 +0800 Subject: [PATCH 02/16] =?UTF-8?q?feat=EF=BC=9A=E5=8D=87=E7=BA=A7=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E6=80=BB=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Build.props | 4 +- Fake.Local.EventBus.props | 13 ++ SimpleAdmin.sln | 1 + src/Oms.Application/Oms.Application.csproj | 28 +++- src/Oms.Tests/Oms.Tests.csproj | 12 +- src/SimpleAdmin.Api/Program.cs | 12 +- src/SimpleAdmin.Api/SimpleAdmin.Api.csproj | 28 +++- src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 16 +- .../Storage/IntegrationEventLogSchema.cs | 48 ------ .../appsettings.Development.json | 158 ------------------ src/SimpleAdmin.Api/appsettings.json | 4 +- 11 files changed, 92 insertions(+), 232 deletions(-) create mode 100644 Fake.Local.EventBus.props delete mode 100644 src/SimpleAdmin.Api/Storage/IntegrationEventLogSchema.cs delete mode 100644 src/SimpleAdmin.Api/appsettings.Development.json diff --git a/Directory.Build.props b/Directory.Build.props index 5ebd994..9220097 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,8 @@ + + - 8.0.5.20 + 8.0.5.21 diff --git a/Fake.Local.EventBus.props b/Fake.Local.EventBus.props new file mode 100644 index 0000000..9e0099b --- /dev/null +++ b/Fake.Local.EventBus.props @@ -0,0 +1,13 @@ + + + + C:\Users\BSI\RiderProjects\fake2 + + false + + false + + diff --git a/SimpleAdmin.sln b/SimpleAdmin.sln index 8816a47..858277c 100644 --- a/SimpleAdmin.sln +++ b/SimpleAdmin.sln @@ -16,6 +16,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "items", "items", "{BE5132AD db.sql = db.sql Directory.Build.props = Directory.Build.props .gitea\workflows\deploy.yml = .gitea\workflows\deploy.yml + Fake.Local.EventBus.props = Fake.Local.EventBus.props EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleAdmin.Api", "src\SimpleAdmin.Api\SimpleAdmin.Api.csproj", "{229ED167-9ECC-4736-8DFB-522EEA8FF680}" diff --git a/src/Oms.Application/Oms.Application.csproj b/src/Oms.Application/Oms.Application.csproj index 72efa2e..515006d 100644 --- a/src/Oms.Application/Oms.Application.csproj +++ b/src/Oms.Application/Oms.Application.csproj @@ -18,13 +18,8 @@ - - - - - @@ -35,6 +30,29 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Oms.Tests/Oms.Tests.csproj b/src/Oms.Tests/Oms.Tests.csproj index 040ca5b..2638958 100644 --- a/src/Oms.Tests/Oms.Tests.csproj +++ b/src/Oms.Tests/Oms.Tests.csproj @@ -14,8 +14,18 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + + + + + + diff --git a/src/SimpleAdmin.Api/Program.cs b/src/SimpleAdmin.Api/Program.cs index 39bf160..4c8fb29 100644 --- a/src/SimpleAdmin.Api/Program.cs +++ b/src/SimpleAdmin.Api/Program.cs @@ -1,10 +1,16 @@ +using Fake.EntityFrameworkCore; using Serilog; using SimpleAdmin.Api; var builder = WebApplication.CreateSlimBuilder(args); var configuration = builder.Configuration; -builder.Configuration.AddConsul("idwms/common", source => source.ReloadOnChange = true); -builder.Configuration.AddConsul("idwms/admin", source => source.ReloadOnChange = true); +var prefix = builder.Environment.EnvironmentName == "Development" ? "dev" : "idwms"; +builder.Configuration.AddConsul($"{prefix}/common", source => source.ReloadOnChange = true); +builder.Configuration.AddConsul($"{prefix}/admin", source => source.ReloadOnChange = true); + +// Fake 框架表前缀(默认 fk_):须在 AddApplication / Migrate 之前生效 +FakeDbProperties.Configure(configuration[FakeDbProperties.TablePrefixConfigurationKey]); + Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(configuration) .CreateLogger(); @@ -26,4 +32,4 @@ catch (Exception ex) finally { Log.CloseAndFlush(); -} \ No newline at end of file +} diff --git a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj index 568c1a4..6b854bb 100644 --- a/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj +++ b/src/SimpleAdmin.Api/SimpleAdmin.Api.csproj @@ -15,15 +15,35 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index 19f0d1f..19726d7 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -7,6 +7,7 @@ using Fake.AspNetCore.Auditing; using Fake.AspNetCore.ExceptionHandling; using Fake.AspNetCore.Newtonsoft; using Fake.AspNetCore.Swagger; +using Fake.EventBus.Dashboard; using Fake.EventBus.IntegrationEventLog; using Fake.FeiShu; using Fake.FileManagement.Application; @@ -40,6 +41,8 @@ namespace SimpleAdmin.Api; typeof(FakeObjectStorageTencentCosModule), // 在 RabbitMQ 物理总线之后 Replace IDistributedEventBus → Outbox typeof(FakeEventBusIntegrationEventLogModule), + // 独立 EventBus Dashboard(本地工程引用调试;默认 /eventbus) + typeof(FakeEventBusDashboardModule), typeof(FakeAspNetCoreModule), typeof(FakeSerilogModule) )] @@ -184,6 +187,9 @@ public class SimpleAdminApiModule : FakeModule // 路由 - 必须在认证和授权之前 app.UseRouting(); + // EventBus Dashboard(CAP 风格;也可依赖 IStartupFilter 自动挂载,这里显式保证顺序) + app.UseFakeEventBusDashboard(); + // OpenAPI 验签需读取原始 Body,允许二次读流 app.Use(async (ctx, next) => { @@ -204,16 +210,6 @@ public class SimpleAdminApiModule : FakeModule // 端点映射 app.MapControllers(); - - // FileManagement / Outbox 与业务同库并行(EF) - using (var scope = app.Services.CreateScope()) - { - var fileDb = scope.ServiceProvider.GetRequiredService(); - fileDb.Database.Migrate(); - - var outboxDb = scope.ServiceProvider.GetRequiredService(); - IntegrationEventLogSchema.EnsureTablesAsync(outboxDb).GetAwaiter().GetResult(); - } app.Services.GetRequiredService().Send("Application started"); } diff --git a/src/SimpleAdmin.Api/Storage/IntegrationEventLogSchema.cs b/src/SimpleAdmin.Api/Storage/IntegrationEventLogSchema.cs deleted file mode 100644 index f44e97c..0000000 --- a/src/SimpleAdmin.Api/Storage/IntegrationEventLogSchema.cs +++ /dev/null @@ -1,48 +0,0 @@ -using Fake.EventBus.IntegrationEventLog; -using Microsoft.EntityFrameworkCore; - -namespace SimpleAdmin.Api.Storage; - -/// -/// Outbox/Inbox 与业务同库时 EnsureCreated 无效(库已存在),用 IF NOT EXISTS 建表。 -/// -internal static class IntegrationEventLogSchema -{ - public static async Task EnsureTablesAsync( - IntegrationEventLogContext db, - CancellationToken cancellationToken = default) - { - await db.Database.ExecuteSqlRawAsync( - """ - CREATE TABLE IF NOT EXISTS `OutboxEventLog` ( - `EventId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, - `EventTypeName` varchar(512) NOT NULL, - `State` int NOT NULL, - `TimesSent` int NOT NULL, - `CreationTime` datetime(6) NOT NULL, - `Content` longtext NOT NULL, - `TransactionId` varchar(64) NOT NULL, - `LockExpiresAt` datetime(6) NULL, - PRIMARY KEY (`EventId`), - KEY `IX_OutboxEventLog_State_CreationTime` (`State`, `CreationTime`), - KEY `IX_OutboxEventLog_TransactionId` (`TransactionId`) - ) CHARACTER SET utf8mb4; - """, - cancellationToken); - - await db.Database.ExecuteSqlRawAsync( - """ - CREATE TABLE IF NOT EXISTS `InboxEventLog` ( - `EventId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, - `EventTypeName` varchar(512) NOT NULL, - `State` int NOT NULL, - `ProcessedTime` datetime(6) NOT NULL, - `Content` longtext NOT NULL, - `ErrorMessage` longtext NULL, - PRIMARY KEY (`EventId`), - KEY `IX_InboxEventLog_ProcessedTime` (`ProcessedTime`) - ) CHARACTER SET utf8mb4; - """, - cancellationToken); - } -} diff --git a/src/SimpleAdmin.Api/appsettings.Development.json b/src/SimpleAdmin.Api/appsettings.Development.json deleted file mode 100644 index 03d2137..0000000 --- a/src/SimpleAdmin.Api/appsettings.Development.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "UseLocalConfigs": false, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Information" - } - }, - "ConnectionStrings": { - "Default": "Server=127.0.0.1;Port=3306;Database=oms;Uid=root;Pwd=你的密码;CharSet=utf8mb4;" - }, - "App": { - "Urls": ["http://localhost:5281"], - "CorsOrigins": [ - "http://localhost:5173", - "http://localhost:5281", - "https://oms.nanafa.com", - "http://oms.nanafa.com" - ], - "SendStartupNotification": false - }, - "Jwt": { - "SecretKey": "local-dev-secret-key-change-me", - "Issuer": "SimpleAdmin.Api", - "Audience": "SimpleAdmin.Api", - "ExpiryInMinutes": "480", - "RefreshExpiryInDays": "7", - "ClockSkewMinutes": "5" - }, - "JwtSettings": { - "SecretKey": "local-dev-secret-key-change-me", - "Issuer": "SimpleAdmin.Api", - "Audience": "SimpleAdmin.Api", - "ExpiryInMinutes": "480", - "RefreshExpiryInDays": "7", - "ClockSkewMinutes": "5" - }, - "FeiShuNotice": { - "Webhook": "https://example.com/webhook", - "EnableFeiShuExceptionSubscribe": false - }, - "Redis": { - "Host": "127.0.0.1:6379", - "Password": "", - "Database": 0, - "MaxPoolSize": 50, - "MinPoolSize": 1, - "IdleTimeout": 20000, - "ConnectTimeout": 5000 - }, - "WeChat": { - "Mp": { - "AppId": "wxfffea62e089ee15f", - "Secret": "379f51a89419dc36fbb9167feef6f89a", - "Token": "c6ca74013df823c0e1da0feaaaf8bf66", - "EncodingAesKey": "", - "OAuthRedirectBase": "https://oms.nanafa.com", - "FollowUrl": "" - } - }, - "ObjectStorage": { - "Provider": "Local", - "KeyPrefix": "", - "SignUrlsByDefault": true, - "DefaultSignedUrlExpiresSeconds": 3600, - "DefaultUploadExpiresSeconds": 600, - "Local": { - "BasePath": "wwwroot", - "BaseUrl": "" - }, - "TencentCos": { - "SecretId": "", - "SecretKey": "", - "Region": "ap-guangzhou", - "Bucket": "your-bucket-1250000000", - "BaseUrl": "", - "SignUrls": true, - "UseHttps": true, - "DurationSecond": 600 - } - }, - "FileManagement": { - "SignUrlsByDefault": true, - "DefaultSignedUrlExpiresSeconds": 3600, - "DefaultMaxSizeBytes": 20971520, - "DefaultStorageSource": "default" - }, - "Oms": { - "TrackService": { - "Enabled": true, - "BaseUrl": "http://api.nanafa.com/track", - "AppKey": "W2606001", - "AppSecret": "ziK8UzlQ39m3kdL346XEBg8nljY8os52", - "TimeoutSeconds": 30 - }, - "Itfx": { - "BaseUrl": "http://api.nanafa.com/itfx", - "TimeoutSeconds": 30, - "Customs9610StandardCode": "customs9610Declare", - "Customs9610ManifestStandardCode": "customs9610Manifest", - "Customs9610DeclarationFormStandardCode": "customs9610DeclarationForm" - }, - "PlatformShop": { - "FrontendBaseUrl": "http://localhost:5173", - "Lazada": { - "AppKey": "123631", - "AppSecret": "7vBz2EaD6rIupeGEfwrgkabsTNsXt2ix", - "AuthorizeUrl": "https://auth.lazada.com/oauth/authorize", - "AccessTokenUrl": "https://auth.lazada.com/rest" - }, - "Shopee": { - "PartnerId": 2004112, - "PartnerKey": "2f385d7ea38b087d5d71f7292e06a09c9f19022ca6c3f60ec0110a8214ef7d65", - "HostUrl": "https://openplatform.shopee.cn", - "HostAuthUrl": "https://partner.shopeemobile.com" - }, - "Tiktok": { - "AppKey": "6hc64lc6vl312", - "AppSecret": "5a691a96ee6d844534769bab5c21de1f848fb678", - "HostAuth": "https://auth.tiktok-shops.com", - "HostApi": "https://open-api.tiktokglobalshop.com" - }, - "Temu": { - "AppKey": "4ebbc9190ae410443d65b4c2faca981f", - "AppSecret": "4782d2d827276688bf4758bed55dbdd4bbe79a79", - "HostAuthUrl": "https://openapi-b-us.temu.com/openapi/router" - } - } - }, - "RabbitMQ": { - "Connections": { - "Default": { - "HostName": "127.0.0.1", - "Port": 5672, - "UserName": "oms", - "Password": "oms123" - } - }, - "EventBus": { - "QueueName": "nanafa.oms", - "RetryCount": 5 - } - }, - "OrderBatch": { - "RabbitMq": { - "Enabled": false - } - }, - "IntegrationEventLog": { - "OutboxPublisher": { - "StartupDelay": "00:00:05", - "ScanInterval": "00:00:10", - "BatchSize": 100, - "MaxDegreeOfParallelism": 8 - } - } -} diff --git a/src/SimpleAdmin.Api/appsettings.json b/src/SimpleAdmin.Api/appsettings.json index 860fa46..06ba18d 100644 --- a/src/SimpleAdmin.Api/appsettings.json +++ b/src/SimpleAdmin.Api/appsettings.json @@ -1,7 +1,7 @@ { "UseLocalConfigs": false, "Consul": { - "Address": "https://consul.nanafa.com", - "Token": "97217125-df0b-5c0a-dbfc-8885def5e471" + "Address": "http://test.ideship.com:30850", + "Token": "c0aea902-dca0-ac29-299b-a0d47e5402aa" } } -- 2.45.2 From 09ca1c96d748980770be63000837eff4a52e05dc Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Tue, 13 Oct 2026 16:25:22 +0800 Subject: [PATCH 03/16] fix --- Directory.Build.props | 2 +- Fake.Local.EventBus.props | 4 +- .../DataPerm/DataPermRepository.cs | 61 +++++++++---------- src/Oms.Application/DataPerm/DataPermStore.cs | 25 ++++---- .../Rbac/CustomerServiceAdvisorResolver.cs | 10 +-- .../Rbac/InternalUserRepository.cs | 27 ++++---- .../Rbac/UserProfileRepository.cs | 31 +++++----- .../Services/Customers/CustomerService.cs | 14 +++-- .../Services/System/NoticeService.cs | 18 +++--- .../Shared/OmsRbacRegistrationGuard.cs | 57 +++++++++-------- .../Shared/OmsRbacUserDisplayNameResolver.cs | 3 +- .../Shared/StaffAdvisorBinder.cs | 5 +- src/SimpleAdmin.Api/Auth/AuthService.cs | 9 +-- src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 2 +- src/SimpleAdmin.Api/SimpleJwtService.cs | 5 +- .../WeChat/WeChatCustomerService.cs | 5 +- 16 files changed, 142 insertions(+), 136 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9220097..3814d0b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,6 @@ - 8.0.5.21 + 8.0.5.22 diff --git a/Fake.Local.EventBus.props b/Fake.Local.EventBus.props index 9e0099b..b6bfbf2 100644 --- a/Fake.Local.EventBus.props +++ b/Fake.Local.EventBus.props @@ -6,8 +6,8 @@ C:\Users\BSI\RiderProjects\fake2 - false + true - false + true diff --git a/src/Oms.Application/DataPerm/DataPermRepository.cs b/src/Oms.Application/DataPerm/DataPermRepository.cs index b0975bb..b59b71c 100644 --- a/src/Oms.Application/DataPerm/DataPermRepository.cs +++ b/src/Oms.Application/DataPerm/DataPermRepository.cs @@ -1,5 +1,6 @@ using Fake.DependencyInjection; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.DataPerm; @@ -11,7 +12,7 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task GetUserSnapshotAsync(Guid userId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $$""" SELECT u.`Id` AS `Id`, u.`userid` AS `MapId`, @@ -19,22 +20,21 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency u.`Name` AS `Name`, u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName` - FROM `user` u - LEFT JOIN `organization` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 + FROM `{{FakeTables.User}}` u + LEFT JOIN `{{FakeTables.Organization}}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 WHERE u.`Id` = {0} AND u.`IsDeleted` = 0 LIMIT 1 """; - return await Db.Database.SqlQueryRaw(sql, userId.ToString()) .FirstOrDefaultAsync(cancellationToken); } public async Task IsPureCustomerUserAsync(Guid userId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $$""" SELECT COUNT(*) AS `Value` - FROM `user_role` ur - INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{{FakeTables.UserRole}}` ur + INNER JOIN `{{FakeTables.Role}}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` = {0} AND r.`Code` <> 'Customer' AND r.`Code` NOT LIKE 'CustomerSub_%' @@ -47,12 +47,11 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency return false; } - const string hasRoleSql = """ + var hasRoleSql = $$""" SELECT COUNT(*) AS `Value` - FROM `user_role` ur + FROM `{{FakeTables.UserRole}}` ur WHERE ur.`UserId` = {0} """; - var roleCount = await Db.Database.SqlQueryRaw(hasRoleSql, userId.ToString()) .FirstAsync(cancellationToken); return roleCount > 0; @@ -60,10 +59,10 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task IsAdminUserAsync(Guid userId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $$""" SELECT COUNT(*) AS `Value` - FROM `user_role` ur - INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{{FakeTables.UserRole}}` ur + INNER JOIN `{{FakeTables.Role}}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` = {0} AND r.`Code` = 'Admin' """; @@ -74,21 +73,20 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task> GetUserRoleIdsAsync(Guid userId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $$""" SELECT ur.`RoleId` AS `Value` - FROM `user_role` ur + FROM `{{FakeTables.UserRole}}` ur WHERE ur.`UserId` = {0} """; - return await Db.Database.SqlQueryRaw(sql, userId.ToString()) .ToListAsync(cancellationToken); } public async Task> GetUserIdsByRoleAsync(Guid roleId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $$""" SELECT ur.`UserId` AS `Value` - FROM `user_role` ur + FROM `{{FakeTables.UserRole}}` ur WHERE ur.`RoleId` = {0} """; @@ -115,7 +113,6 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency WHERE p.`IsDeleted` = 0 AND p.`RoleId` IN ({inClause}) """; - return await Db.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); } @@ -385,14 +382,14 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task> GetOrganizationSubtreeIdsAsync(Guid rootOrganizationId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $$""" WITH RECURSIVE org_tree AS ( SELECT o.`Id` - FROM `organization` o + FROM `{{FakeTables.Organization}}` o WHERE o.`Id` = {0} AND o.`IsDeleted` = 0 UNION ALL SELECT c.`Id` - FROM `organization` c + FROM `{{FakeTables.Organization}}` c INNER JOIN org_tree t ON c.`ParentId` = t.`Id` WHERE c.`IsDeleted` = 0 ) @@ -414,22 +411,21 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency } var inClause = string.Join(",", ids.Select(x => $"'{x}'")); - var sql = $""" + var sql = $$""" SELECT o.`Id` AS `Id`, o.`Name` AS `Name` - FROM `organization` o + FROM `{{FakeTables.Organization}}` o WHERE o.`IsDeleted` = 0 AND o.`Id` IN ({inClause}) """; - var rows = await Db.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); return rows.ToDictionary(x => x.Id, x => x.Name ?? x.Id.ToString()); } public async Task OrganizationExistsAndEnabledAsync(Guid organizationId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $$""" SELECT COUNT(*) AS `Value` - FROM `organization` o + FROM `{{FakeTables.Organization}}` o WHERE o.`Id` = {0} AND o.`IsDeleted` = 0 """; @@ -450,13 +446,12 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency if (dataScope == DataPermConstants.DataScopeTeam) { - const string sql = """ + var sql = $$""" SELECT u.`userid` AS `Value` - FROM `user` u + FROM `{{FakeTables.User}}` u WHERE u.`IsDeleted` = 0 AND u.`OrganizationId` = {0} """; - return await Db.Database.SqlQueryRaw(sql, organizationId.ToString()) .ToListAsync(cancellationToken); } @@ -470,9 +465,9 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency } var inClause = string.Join(",", orgIds.Select(x => $"'{x}'")); - var sql = $""" + var sql = $$""" SELECT u.`userid` AS `Value` - FROM `user` u + FROM `{{FakeTables.User}}` u WHERE u.`IsDeleted` = 0 AND u.`OrganizationId` IN ({inClause}) """; @@ -485,7 +480,7 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task> GetDataResourcesAsync(CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $""" SELECT r.`Code` AS `Code`, r.`Name` AS `Name`, diff --git a/src/Oms.Application/DataPerm/DataPermStore.cs b/src/Oms.Application/DataPerm/DataPermStore.cs index c2afb49..dd8da2c 100644 --- a/src/Oms.Application/DataPerm/DataPermStore.cs +++ b/src/Oms.Application/DataPerm/DataPermStore.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Fake.Application; using Fake.DependencyInjection; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Oms.Application.Dtos.DataPerm; @@ -20,21 +21,21 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task GetUserDataPermAsync(Guid userId, CancellationToken cancellationToken = default) { - var user = await dbContext.Database.SqlQueryRaw(""" + var user = await dbContext.Database.SqlQueryRaw($$""" SELECT u.`Id` AS `UserId`, u.`Name` AS `UserName`, u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName` - FROM `user` u - LEFT JOIN `organization` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 + FROM `{{FakeTables.User}}` u + LEFT JOIN `{{FakeTables.Organization}}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 WHERE u.`Id` = {0} AND u.`IsDeleted` = 0 LIMIT 1 """, userId.ToString()).FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("用户不存在"); var now = DateTime.UtcNow; - var orgRows = await dbContext.Database.SqlQueryRaw(""" + var orgRows = await dbContext.Database.SqlQueryRaw($$""" SELECT p.`Id` AS `Id`, p.`OrganizationId` AS `OrganizationId`, @@ -44,11 +45,11 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac p.`ExpireTime` AS `ExpireTime`, p.`IsTemporary` AS `IsTemporary` FROM `user_org_perm` p - LEFT JOIN `organization` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 + LEFT JOIN `{{FakeTables.Organization}}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 WHERE p.`IsDeleted` = 0 AND p.`UserId` = {0} """, userId.ToString()).ToListAsync(cancellationToken); - var scopeRows = await dbContext.Database.SqlQueryRaw(""" + var scopeRows = await dbContext.Database.SqlQueryRaw($""" SELECT p.`Id` AS `Id`, p.`DataScope` AS `DataScope`, p.`DataResourceCode` AS `DataResourceCode`, p.`ExpireTime` AS `ExpireTime` FROM `user_data_scope` p WHERE p.`IsDeleted` = 0 AND p.`UserId` = {0} @@ -129,16 +130,16 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac { var role = await dbContext.Database.SqlQueryRaw(""" SELECT r.`Id` AS `RoleId`, r.`Name` AS `RoleName` - FROM `role` r + FROM `{{FakeTables.Role}}` r WHERE r.`Id` = {0} AND r.`IsDeleted` = 0 LIMIT 1 """, roleId.ToString()).FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("角色不存在"); - var orgRows = await dbContext.Database.SqlQueryRaw(""" + var orgRows = await dbContext.Database.SqlQueryRaw($$""" SELECT p.`Id` AS `Id`, p.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName`, p.`OrgScope` AS `OrgScope` FROM `role_org_perm` p - LEFT JOIN `organization` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 + LEFT JOIN `{{FakeTables.Organization}}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 WHERE p.`IsDeleted` = 0 AND p.`RoleId` = {0} """, roleId.ToString()).ToListAsync(cancellationToken); @@ -520,7 +521,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task> GetFieldMasksAsync(string dataResourceCode, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $""" SELECT m.`FieldName` AS `FieldName`, m.`SecretLevel` AS `SecretLevel`, m.`MaskType` AS `MaskType`, m.`MaskPattern` AS `MaskPattern` FROM `data_perm_field_mask` m WHERE m.`IsDeleted` = 0 AND m.`IsEnabled` = 1 AND m.`DataResourceCode` = {0} @@ -640,7 +641,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task GetTemplateDetailAsync(Guid templateId, CancellationToken cancellationToken = default) { - const string headerSql = """ + var headerSql = $""" SELECT t.`Id` AS `Id`, t.`Name` AS `Name`, t.`Code` AS `Code`, t.`Description` AS `Description`, t.`RoleCode` AS `RoleCode`, t.`IsBuiltIn` AS `IsBuiltIn` FROM `data_perm_template` t @@ -652,7 +653,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac .FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("模板不存在"); - const string itemSql = """ + var itemSql = $""" SELECT i.`Id` AS `Id`, i.`DimensionType` AS `DimensionType`, CAST(i.`ItemContent` AS CHAR) AS `ItemContentJson`, i.`SortOrder` AS `SortOrder` FROM `data_perm_template_item` i WHERE i.`TemplateId` = {0} AND i.`IsDeleted` = 0 diff --git a/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs b/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs index ea7f768..8cce024 100644 --- a/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs +++ b/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs @@ -1,5 +1,6 @@ using Fake.DependencyInjection; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Customers; @@ -41,18 +42,17 @@ public class CustomerServiceAdvisorResolver(FakeRbacDbContext dbContext) : IScop return null; } - const string sql = """ + var sql = $$""" SELECT u.`Name` AS `Name`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, u.`WecomKfUrl` AS `WecomKfUrl` - FROM `user` u + FROM `{{FakeTables.User}}` u WHERE u.`IsDeleted` = 0 AND u.`userid` = {0} LIMIT 1 """; - return await dbContext.Database .SqlQueryRaw(sql, userId) .FirstOrDefaultAsync(cancellationToken); @@ -60,13 +60,13 @@ public class CustomerServiceAdvisorResolver(FakeRbacDbContext dbContext) : IScop private async Task FindByNameOrAccountAsync(string key, CancellationToken cancellationToken) { - const string sql = """ + var sql = $$""" SELECT u.`Name` AS `Name`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, u.`WecomKfUrl` AS `WecomKfUrl` - FROM `user` u + FROM `{{FakeTables.User}}` u WHERE u.`IsDeleted` = 0 AND (u.`Name` = {0} OR u.`Account` = {0}) ORDER BY u.`CreateTime` ASC diff --git a/src/Oms.Application/Rbac/InternalUserRepository.cs b/src/Oms.Application/Rbac/InternalUserRepository.cs index 2c48839..18f7784 100644 --- a/src/Oms.Application/Rbac/InternalUserRepository.cs +++ b/src/Oms.Application/Rbac/InternalUserRepository.cs @@ -1,5 +1,6 @@ using Fake.DependencyInjection; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Rbac; @@ -7,20 +8,19 @@ namespace Oms.Application.Rbac; public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDependency { - private const string InternalUserFilter = """ + private static readonly string InternalUserFilter = $$""" ( - NOT EXISTS (SELECT 1 FROM `user_role` ur0 WHERE ur0.`UserId` = u.`Id`) + NOT EXISTS (SELECT 1 FROM `{{FakeTables.UserRole}}` ur0 WHERE ur0.`UserId` = u.`Id`) OR EXISTS ( SELECT 1 - FROM `user_role` ur - INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{{FakeTables.UserRole}}` ur + INNER JOIN `{{FakeTables.Role}}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` = u.`Id` AND r.`Code` <> 'Customer' AND r.`Code` NOT LIKE 'CustomerSub\_%' ) ) """; - public async Task GetInternalUsersAsync( string? keyword, Guid? organizationId, @@ -45,16 +45,16 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend args.Add(organizationId.Value.ToString()); } - var countSql = $""" + var countSql = $$""" SELECT COUNT(*) AS `Value` - FROM `user` u + FROM `{{FakeTables.User}}` u {where} """; var total = await dbContext.Database.SqlQueryRaw(countSql, args.ToArray()) .FirstAsync(cancellationToken); var offset = Math.Max(page - 1, 0) * pageSize; - var listSql = $""" + var listSql = $$""" SELECT u.`Id` AS `Id`, u.`Name` AS `Name`, @@ -67,13 +67,12 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName`, u.`CreateTime` AS `CreatedAt` - FROM `user` u - LEFT JOIN `organization` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 + FROM `{{FakeTables.User}}` u + LEFT JOIN `{{FakeTables.Organization}}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 {where} ORDER BY u.`CreateTime` DESC LIMIT {pageSize} OFFSET {offset} """; - var rows = await dbContext.Database.SqlQueryRaw(listSql, args.ToArray()) .ToListAsync(cancellationToken); if (rows.Count == 0) @@ -112,14 +111,14 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend } var inClause = string.Join(",", userIds.Select(x => $"'{x}'")); - var sql = $""" + var sql = $$""" SELECT ur.`UserId` AS `UserId`, r.`Id` AS `RoleId`, r.`Name` AS `RoleName`, r.`Code` AS `RoleCode` - FROM `user_role` ur - INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{{FakeTables.UserRole}}` ur + INNER JOIN `{{FakeTables.Role}}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` IN ({inClause}) ORDER BY r.`Name` """; diff --git a/src/Oms.Application/Rbac/UserProfileRepository.cs b/src/Oms.Application/Rbac/UserProfileRepository.cs index 7115b11..303e4e9 100644 --- a/src/Oms.Application/Rbac/UserProfileRepository.cs +++ b/src/Oms.Application/Rbac/UserProfileRepository.cs @@ -1,6 +1,7 @@ using System.Text.RegularExpressions; using Fake.DependencyInjection; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Rbac; @@ -15,18 +16,17 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public async Task GetAsync(Guid userId, CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $$""" SELECT u.`Phone` AS `Phone`, u.`EmployeeNo` AS `EmployeeNo`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, u.`WecomKfUrl` AS `WecomKfUrl` - FROM `user` u + FROM `{{FakeTables.User}}` u WHERE u.`Id` = {0} AND u.`IsDeleted` = 0 LIMIT 1 """; - var row = await dbContext.Database .SqlQueryRaw(sql, userId.ToString()) .FirstOrDefaultAsync(cancellationToken); @@ -56,7 +56,7 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende } var inClause = string.Join(",", userIds.Select(x => $"'{x}'")); - var sql = $""" + var sql = $$""" SELECT u.`Id` AS `UserId`, u.`Phone` AS `Phone`, @@ -64,7 +64,7 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, u.`WecomKfUrl` AS `WecomKfUrl` - FROM `user` u + FROM `{{FakeTables.User}}` u WHERE u.`Id` IN ({inClause}) AND u.`IsDeleted` = 0 """; @@ -83,8 +83,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public async Task SaveAsync(Guid userId, UserProfileData profile, CancellationToken cancellationToken = default) { - const string sql = """ - UPDATE `user` + var sql = $$""" + UPDATE `{{FakeTables.User}}` SET `Phone` = {1}, `EmployeeNo` = {2}, @@ -93,7 +93,6 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende `WecomKfUrl` = {5} WHERE `Id` = {0} AND `IsDeleted` = 0 """; - await dbContext.Database.ExecuteSqlRawAsync( sql, [ @@ -113,8 +112,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende UserProfileData profile, CancellationToken cancellationToken = default) { - const string sql = """ - UPDATE `user` + var sql = $$""" + UPDATE `{{FakeTables.User}}` SET `Phone` = {1}, `WechatId` = {2}, @@ -142,16 +141,16 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende { var normalized = employeeNo.Trim().ToUpperInvariant(); var sql = excludeUserId.HasValue - ? """ + ? $$""" SELECT COUNT(*) AS `Value` - FROM `user` u + FROM `{{FakeTables.User}}` u WHERE u.`IsDeleted` = 0 AND u.`EmployeeNo` = {0} AND u.`Id` <> {1} """ - : """ + : $$""" SELECT COUNT(*) AS `Value` - FROM `user` u + FROM `{{FakeTables.User}}` u WHERE u.`IsDeleted` = 0 AND u.`EmployeeNo` = {0} """; @@ -166,9 +165,9 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public async Task GenerateNextEmployeeNoAsync(CancellationToken cancellationToken = default) { - const string sql = """ + var sql = $$""" SELECT u.`EmployeeNo` AS `EmployeeNo` - FROM `user` u + FROM `{{FakeTables.User}}` u WHERE u.`IsDeleted` = 0 AND u.`EmployeeNo` IS NOT NULL AND u.`EmployeeNo` <> '' diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index dd14bec..e58bc83 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -5,6 +5,7 @@ using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Services; using Fake.Rbac.Infrastructure; using Fake.Security.Claims; +using Fake.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Customers; @@ -203,8 +204,9 @@ public class CustomerService : ApplicationService // 同步 RBAC 登录邮箱,覆盖注册时的 @noemail.local 占位 try { - await RbacDbContext.Database.ExecuteSqlInterpolatedAsync( - $"UPDATE `user` SET `Email` = {email} WHERE `userid` = {customer.UserId} AND `IsDeleted` = 0"); + await RbacDbContext.Database.ExecuteSqlRawAsync( + $"UPDATE `{FakeTables.User}` SET `Email` = {{0}} WHERE `userid` = {{1}} AND `IsDeleted` = 0", + email, customer.UserId); } catch { @@ -390,8 +392,8 @@ public class CustomerService : ApplicationService private async Task GetRbacUserGuidByMapIdAsync(int mapId) { - return await RbacDbContext.Database.SqlQuery( - $"select `Id` as `Value` from `user` where `userid` = {mapId} and `IsDeleted` = 0") + return await RbacDbContext.Database.SqlQueryRaw( + $"select `Id` as `Value` from `{FakeTables.User}` where `userid` = {{0}} and `IsDeleted` = 0", mapId) .FirstOrDefaultAsync(); } @@ -495,8 +497,8 @@ public class CustomerService : ApplicationService private async Task GetRbacUserMapIdOrThrowAsync(Guid id) { - var userId = await RbacDbContext.Database.SqlQuery( - $"select `userid` as `Value` from `user` where `Id` = {id}") + var userId = await RbacDbContext.Database.SqlQueryRaw( + $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}", id) .FirstOrDefaultAsync(); if (!userId.HasValue || userId.Value <= 0) { diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs index d804de4..b8f30ac 100644 --- a/src/Oms.Application/Services/System/NoticeService.cs +++ b/src/Oms.Application/Services/System/NoticeService.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using Fake.Application; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Shared; @@ -24,20 +25,19 @@ public class NoticeService : ApplicationService private const string ReceiverTypeUser = "user"; private const string ReceiverTypeCustomer = "customer"; - private const string InternalUserFilter = """ + private static readonly string InternalUserFilter = $$""" ( - NOT EXISTS (SELECT 1 FROM `user_role` ur0 WHERE ur0.`UserId` = u.`Id`) + NOT EXISTS (SELECT 1 FROM `{{FakeTables.UserRole}}` ur0 WHERE ur0.`UserId` = u.`Id`) OR EXISTS ( SELECT 1 - FROM `user_role` ur - INNER JOIN `role` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{{FakeTables.UserRole}}` ur + INNER JOIN `{{FakeTables.Role}}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` = u.`Id` AND r.`Code` <> 'Customer' AND r.`Code` NOT LIKE 'CustomerSub\_%' ) ) """; - private INoticeRepository NoticeRepository => LazyServiceProvider.GetRequiredService(); private INoticeReadRepository NoticeReadRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); @@ -239,15 +239,15 @@ public class NoticeService : ApplicationService args.Add(pattern); } - var countSql = $"SELECT COUNT(*) AS `Value` FROM `user` u {where}"; + var countSql = $"SELECT COUNT(*) AS `Value` FROM `{{FakeTables.User}}` u {where}"; var total = await RbacDbContext.Database.SqlQueryRaw(countSql, args.ToArray()).FirstAsync(); var offset = Math.Max(pageIndex - 1, 0) * pageSize; - var listSql = $""" + var listSql = $$""" SELECT u.`userid` AS `Value`, u.`Name` AS `Label`, u.`Account` AS `Extra` - FROM `user` u + FROM `{{FakeTables.User}}` u {where} ORDER BY u.`Name`, u.`userid` LIMIT {pageSize} OFFSET {offset} @@ -465,7 +465,7 @@ public class NoticeService : ApplicationService return []; } - var sql = $"SELECT `userid` AS `UserId` FROM `user` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", userIds)})"; + var sql = $"SELECT `userid` AS `UserId` FROM `{{FakeTables.User}}` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", userIds)})"; return await RbacDbContext.Database.SqlQueryRaw(sql) .Select(x => x.UserId) .ToListAsync(); diff --git a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs index 6241a72..ae9c203 100644 --- a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs +++ b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs @@ -2,6 +2,7 @@ using System.Net.Mail; using System.Text.RegularExpressions; using Fake.Application; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Oms.Application.Shared; @@ -167,13 +168,13 @@ public static class OmsRbacRegistrationGuard } var hit = await rbacDbContext.Database - .SqlQuery( - $""" + .SqlQueryRaw( + $$""" SELECT 1 AS Value - FROM `organization` - WHERE `Id` = {organizationId.Value.ToString()} AND `IsDeleted` = 0 + FROM `{{FakeTables.Organization}}` + WHERE `Id` = {0} AND `IsDeleted` = 0 LIMIT 1 - """) + """, organizationId.Value.ToString()) .FirstOrDefaultAsync(cancellationToken); if (!hit.HasValue) @@ -201,13 +202,13 @@ public static class OmsRbacRegistrationGuard foreach (var roleId in ids) { var hit = await rbacDbContext.Database - .SqlQuery( - $""" + .SqlQueryRaw( + $$""" SELECT 1 AS Value - FROM `role` - WHERE `Id` = {roleId.ToString()} AND `IsDeleted` = 0 + FROM `{{FakeTables.Role}}` + WHERE `Id` = {0} AND `IsDeleted` = 0 LIMIT 1 - """) + """, roleId.ToString()) .FirstOrDefaultAsync(cancellationToken); if (!hit.HasValue) @@ -492,15 +493,16 @@ public static class OmsRbacRegistrationGuard if (excludeUserId is Guid id && id != Guid.Empty) { var hit = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} AND `Id` <> {id.ToString()} LIMIT 1") + .SqlQueryRaw( + $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Account` = {0} AND `Id` <> {1} LIMIT 1", + account, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } var any = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} LIMIT 1") + .SqlQueryRaw( + $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Account` = {0} LIMIT 1", account) .FirstOrDefaultAsync(cancellationToken); return any.HasValue; } @@ -514,15 +516,16 @@ public static class OmsRbacRegistrationGuard if (excludeUserId is Guid id && id != Guid.Empty) { var hit = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} AND `Id` <> {id.ToString()} LIMIT 1") + .SqlQueryRaw( + $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Email` = {0} AND `Id` <> {1} LIMIT 1", + email, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } var any = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} LIMIT 1") + .SqlQueryRaw( + $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Email` = {0} LIMIT 1", email) .FirstOrDefaultAsync(cancellationToken); return any.HasValue; } @@ -536,15 +539,16 @@ public static class OmsRbacRegistrationGuard if (excludeUserId is Guid id && id != Guid.Empty) { var hit = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} AND `Id` <> {id.ToString()} LIMIT 1") + .SqlQueryRaw( + $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Phone` = {0} AND `Id` <> {1} LIMIT 1", + phone, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } var any = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} LIMIT 1") + .SqlQueryRaw( + $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Phone` = {0} LIMIT 1", phone) .FirstOrDefaultAsync(cancellationToken); return any.HasValue; } @@ -558,15 +562,16 @@ public static class OmsRbacRegistrationGuard if (excludeUserId is Guid id && id != Guid.Empty) { var hit = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} AND `Id` <> {id.ToString()} LIMIT 1") + .SqlQueryRaw( + $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `WechatId` = {0} AND `Id` <> {1} LIMIT 1", + wechatId, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } var any = await rbacDbContext.Database - .SqlQuery( - $"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} LIMIT 1") + .SqlQueryRaw( + $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `WechatId` = {0} LIMIT 1", wechatId) .FirstOrDefaultAsync(cancellationToken); return any.HasValue; } diff --git a/src/Oms.Application/Shared/OmsRbacUserDisplayNameResolver.cs b/src/Oms.Application/Shared/OmsRbacUserDisplayNameResolver.cs index 1b5b92d..38c277b 100644 --- a/src/Oms.Application/Shared/OmsRbacUserDisplayNameResolver.cs +++ b/src/Oms.Application/Shared/OmsRbacUserDisplayNameResolver.cs @@ -1,5 +1,6 @@ using Fake.DependencyInjection; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Oms.Application.Shared; @@ -40,7 +41,7 @@ public class OmsRbacUserDisplayNameResolver : IScopedDependency try { var sql = - $"SELECT `userid` AS `{nameof(RbacUserNameRow.UserId)}`, `Name` AS `{nameof(RbacUserNameRow.DisplayName)}` FROM `user` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", ids)})"; + $"SELECT `userid` AS `{nameof(RbacUserNameRow.UserId)}`, `Name` AS `{nameof(RbacUserNameRow.DisplayName)}` FROM `{FakeTables.User}` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", ids)})"; var rows = await _rbacDbContext.Database .SqlQueryRaw(sql) .ToListAsync(cancellationToken); diff --git a/src/Oms.Application/Shared/StaffAdvisorBinder.cs b/src/Oms.Application/Shared/StaffAdvisorBinder.cs index 48c9719..0dafc40 100644 --- a/src/Oms.Application/Shared/StaffAdvisorBinder.cs +++ b/src/Oms.Application/Shared/StaffAdvisorBinder.cs @@ -1,4 +1,5 @@ using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Oms.Domain.CustomersAggregate.Entities; @@ -44,9 +45,9 @@ public static class StaffAdvisorBinder string key, CancellationToken cancellationToken) { - const string sql = """ + var sql = $$""" SELECT u.`Account` AS `Account` - FROM `user` u + FROM `{{FakeTables.User}}` u WHERE u.`IsDeleted` = 0 AND u.`Account` = {0} ORDER BY u.`CreateTime` ASC diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 632f352..d94eccf 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -12,6 +12,7 @@ using Fake.Rbac.Domain.UserAggregate; using Fake.Rbac.Infrastructure; using Fake.Security.Claims; using Fake.Users; +using Fake.EntityFrameworkCore; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -904,15 +905,15 @@ public partial class AuthService( private async Task GetRbacUserGuidByMapIdAsync(int userId, CancellationToken cancellationToken) { - return await rbacDbContext.Database.SqlQuery( - $"select `Id` as `Value` from `user` where `IsDeleted` = 0 and `userid` = {userId}") + return await rbacDbContext.Database.SqlQueryRaw( + $"select `Id` as `Value` from `{FakeTables.User}` where `IsDeleted` = 0 and `userid` = {{0}}", userId) .FirstOrDefaultAsync(cancellationToken); } private async Task GetMapIdByUserGuidAsync(Guid userId, CancellationToken cancellationToken) { - return await rbacDbContext.Database.SqlQuery( - $"select `userid` as `Value` from `user` where `Id` = {userId}") + return await rbacDbContext.Database.SqlQueryRaw( + $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}", userId) .FirstOrDefaultAsync(cancellationToken); } diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index 19726d7..d55ca7b 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -187,7 +187,7 @@ public class SimpleAdminApiModule : FakeModule // 路由 - 必须在认证和授权之前 app.UseRouting(); - // EventBus Dashboard(CAP 风格;也可依赖 IStartupFilter 自动挂载,这里显式保证顺序) + // EventBus Dashboard:必须在 UsePathBase 之后,否则 /admin/eventbus 会 404 app.UseFakeEventBusDashboard(); // OpenAPI 验签需读取原始 Body,允许二次读流 diff --git a/src/SimpleAdmin.Api/SimpleJwtService.cs b/src/SimpleAdmin.Api/SimpleJwtService.cs index adfe75f..9283a2f 100644 --- a/src/SimpleAdmin.Api/SimpleJwtService.cs +++ b/src/SimpleAdmin.Api/SimpleJwtService.cs @@ -5,6 +5,7 @@ using Fake.Rbac.Application.Jwt; using Fake.Rbac.Application.Services; using Fake.Rbac.Infrastructure; using Fake.Security.Claims; +using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -21,8 +22,8 @@ public class SimpleJwtService(IOptions jwtOptions, UserService u CancellationToken cancellationToken) { var claims = await base.GenerateClaimsByUserIdAsync(userId, cancellationToken); - var mapId = await rbacDbContext.Database.SqlQuery( - $"select `userid` as `Value` from `user` where `Id` = {userId}") + var mapId = await rbacDbContext.Database.SqlQueryRaw( + $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}", userId) .FirstOrDefaultAsync(cancellationToken); if (mapId.HasValue) { diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs index f76a3ed..f8fb209 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs @@ -4,6 +4,7 @@ using Fake.Rbac.Application.Dtos.Role; using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Services; using Fake.Rbac.Infrastructure; +using Fake.EntityFrameworkCore; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -323,8 +324,8 @@ public class WeChatCustomerService( private async Task GetRbacUserMapIdOrThrowAsync(Guid id, CancellationToken cancellationToken) { - var userId = await rbacDbContext.Database.SqlQuery( - $"select `userid` as `Value` from `user` where `Id` = {id}") + var userId = await rbacDbContext.Database.SqlQueryRaw( + $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}", id) .FirstOrDefaultAsync(cancellationToken); if (!userId.HasValue || userId.Value <= 0) { -- 2.45.2 From 8cfff8ef81054692302cfd31dd516138008a6c06 Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Tue, 13 Oct 2026 16:46:12 +0800 Subject: [PATCH 04/16] fix --- Directory.Build.props | 3 ++- Fake.Local.EventBus.props | 4 ++-- src/Oms.Application/FakeTables.cs | 12 ++++++++++++ src/SimpleAdmin.Api/Auth/AuthService.cs | 1 + src/SimpleAdmin.Api/SimpleJwtService.cs | 2 +- src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs | 1 + 6 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 src/Oms.Application/FakeTables.cs diff --git a/Directory.Build.props b/Directory.Build.props index 3814d0b..a1f4d20 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,7 @@ - 8.0.5.22 + + 8.0.5.24 diff --git a/Fake.Local.EventBus.props b/Fake.Local.EventBus.props index b6bfbf2..9e0099b 100644 --- a/Fake.Local.EventBus.props +++ b/Fake.Local.EventBus.props @@ -6,8 +6,8 @@ C:\Users\BSI\RiderProjects\fake2 - true + false - true + false diff --git a/src/Oms.Application/FakeTables.cs b/src/Oms.Application/FakeTables.cs new file mode 100644 index 0000000..43f7efe --- /dev/null +++ b/src/Oms.Application/FakeTables.cs @@ -0,0 +1,12 @@ +namespace Oms.Application; + + +public static class FakeTables +{ + public static string User => "fk_user"; + public static string Role => "fk_role"; + public static string Menu => "fk_menu"; + public static string Organization => "fk_organization"; + public static string UserRole => "fk_user_role"; + public static string RolePermission => "fk_role_permission"; +} diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index d94eccf..1069f3b 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Fake.ObjectStorage; +using Oms.Application; using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Domain.CustomersAggregate.Entities; diff --git a/src/SimpleAdmin.Api/SimpleJwtService.cs b/src/SimpleAdmin.Api/SimpleJwtService.cs index 9283a2f..f2aad1a 100644 --- a/src/SimpleAdmin.Api/SimpleJwtService.cs +++ b/src/SimpleAdmin.Api/SimpleJwtService.cs @@ -5,9 +5,9 @@ using Fake.Rbac.Application.Jwt; using Fake.Rbac.Application.Services; using Fake.Rbac.Infrastructure; using Fake.Security.Claims; -using Fake.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Oms.Application; namespace SimpleAdmin.Api; diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs index f8fb209..4911344 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Oms.Application; using Oms.Application.Shared; using Oms.Domain.CustomersAggregate.Entities; using Oms.Domain.CustomersAggregate.Enums; -- 2.45.2 From cba741ea8a67d1802dcfad400088fc276f3816f2 Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Tue, 13 Oct 2026 17:01:53 +0800 Subject: [PATCH 05/16] clear cache --- .gitea/workflows/deploy.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e2f0a9a..0a3a921 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -40,6 +40,10 @@ jobs: IMG="${{ env.TCR_REGISTRY }}/${{ env.TCR_NAMESPACE }}/${{ env.IMAGE_NAME }}:${TAG}" echo "TARGET=$TARGET REF=$REF REPO=$REPO TAG=$TAG host=$(hostname)" + # FakePackageVersion bump 后私服元数据可能仍被 NuGet http-cache 命中旧索引 + echo "==> clear NuGet http-cache (Fake packages / BaGet)" + dotnet nuget locals http-cache --clear + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml unset NAMESPACE cd /data/k3s -- 2.45.2 From ff9c589956bb7dac5a73990accb9d764f2481f86 Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Tue, 13 Oct 2026 17:20:12 +0800 Subject: [PATCH 06/16] fix --- .../DataPerm/DataPermRepository.cs | 92 +++++++++---------- src/Oms.Application/DataPerm/DataPermStore.cs | 42 ++++----- .../Rbac/CustomerServiceAdvisorResolver.cs | 16 ++-- .../Rbac/InternalUserRepository.cs | 32 +++---- .../Rbac/UserProfileRepository.cs | 72 +++++++-------- .../Services/System/NoticeService.cs | 20 ++-- .../Shared/OmsRbacRegistrationGuard.cs | 32 +++---- .../Shared/StaffAdvisorBinder.cs | 8 +- 8 files changed, 157 insertions(+), 157 deletions(-) diff --git a/src/Oms.Application/DataPerm/DataPermRepository.cs b/src/Oms.Application/DataPerm/DataPermRepository.cs index b59b71c..3c4632a 100644 --- a/src/Oms.Application/DataPerm/DataPermRepository.cs +++ b/src/Oms.Application/DataPerm/DataPermRepository.cs @@ -12,7 +12,7 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task GetUserSnapshotAsync(Guid userId, CancellationToken cancellationToken = default) { - var sql = $$""" + var sql = $@" SELECT u.`Id` AS `Id`, u.`userid` AS `MapId`, @@ -20,25 +20,25 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency u.`Name` AS `Name`, u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName` - FROM `{{FakeTables.User}}` u - LEFT JOIN `{{FakeTables.Organization}}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 - WHERE u.`Id` = {0} AND u.`IsDeleted` = 0 + FROM `{FakeTables.User}` u + LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 + WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 LIMIT 1 - """; + "; return await Db.Database.SqlQueryRaw(sql, userId.ToString()) .FirstOrDefaultAsync(cancellationToken); } public async Task IsPureCustomerUserAsync(Guid userId, CancellationToken cancellationToken = default) { - var sql = $$""" + var sql = $@" SELECT COUNT(*) AS `Value` - FROM `{{FakeTables.UserRole}}` ur - INNER JOIN `{{FakeTables.Role}}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 - WHERE ur.`UserId` = {0} + FROM `{FakeTables.UserRole}` ur + INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + WHERE ur.`UserId` = {{0}} AND r.`Code` <> 'Customer' AND r.`Code` NOT LIKE 'CustomerSub_%' - """; + "; var nonCustomerRoleCount = await Db.Database.SqlQueryRaw(sql, userId.ToString()) .FirstAsync(cancellationToken); @@ -47,11 +47,11 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency return false; } - var hasRoleSql = $$""" + var hasRoleSql = $@" SELECT COUNT(*) AS `Value` - FROM `{{FakeTables.UserRole}}` ur - WHERE ur.`UserId` = {0} - """; + FROM `{FakeTables.UserRole}` ur + WHERE ur.`UserId` = {{0}} + "; var roleCount = await Db.Database.SqlQueryRaw(hasRoleSql, userId.ToString()) .FirstAsync(cancellationToken); return roleCount > 0; @@ -59,13 +59,13 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task IsAdminUserAsync(Guid userId, CancellationToken cancellationToken = default) { - var sql = $$""" + var sql = $@" SELECT COUNT(*) AS `Value` - FROM `{{FakeTables.UserRole}}` ur - INNER JOIN `{{FakeTables.Role}}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 - WHERE ur.`UserId` = {0} + FROM `{FakeTables.UserRole}` ur + INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + WHERE ur.`UserId` = {{0}} AND r.`Code` = 'Admin' - """; + "; var count = await Db.Database.SqlQueryRaw(sql, userId.ToString()).FirstAsync(cancellationToken); return count > 0; @@ -73,22 +73,22 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task> GetUserRoleIdsAsync(Guid userId, CancellationToken cancellationToken = default) { - var sql = $$""" + var sql = $@" SELECT ur.`RoleId` AS `Value` - FROM `{{FakeTables.UserRole}}` ur - WHERE ur.`UserId` = {0} - """; + FROM `{FakeTables.UserRole}` ur + WHERE ur.`UserId` = {{0}} + "; return await Db.Database.SqlQueryRaw(sql, userId.ToString()) .ToListAsync(cancellationToken); } public async Task> GetUserIdsByRoleAsync(Guid roleId, CancellationToken cancellationToken = default) { - var sql = $$""" + var sql = $@" SELECT ur.`UserId` AS `Value` - FROM `{{FakeTables.UserRole}}` ur - WHERE ur.`RoleId` = {0} - """; + FROM `{FakeTables.UserRole}` ur + WHERE ur.`RoleId` = {{0}} + "; return await Db.Database.SqlQueryRaw(sql, roleId.ToString()) .ToListAsync(cancellationToken); @@ -382,19 +382,19 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency public async Task> GetOrganizationSubtreeIdsAsync(Guid rootOrganizationId, CancellationToken cancellationToken = default) { - var sql = $$""" + var sql = $@" WITH RECURSIVE org_tree AS ( SELECT o.`Id` - FROM `{{FakeTables.Organization}}` o - WHERE o.`Id` = {0} AND o.`IsDeleted` = 0 + FROM `{FakeTables.Organization}` o + WHERE o.`Id` = {{0}} AND o.`IsDeleted` = 0 UNION ALL SELECT c.`Id` - FROM `{{FakeTables.Organization}}` c + FROM `{FakeTables.Organization}` c INNER JOIN org_tree t ON c.`ParentId` = t.`Id` WHERE c.`IsDeleted` = 0 ) SELECT `Id` AS `Value` FROM org_tree - """; + "; return await Db.Database.SqlQueryRaw(sql, rootOrganizationId.ToString()) .ToListAsync(cancellationToken); @@ -411,24 +411,24 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency } var inClause = string.Join(",", ids.Select(x => $"'{x}'")); - var sql = $$""" + var sql = $@" SELECT o.`Id` AS `Id`, o.`Name` AS `Name` - FROM `{{FakeTables.Organization}}` o + FROM `{FakeTables.Organization}` o WHERE o.`IsDeleted` = 0 AND o.`Id` IN ({inClause}) - """; + "; var rows = await Db.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); return rows.ToDictionary(x => x.Id, x => x.Name ?? x.Id.ToString()); } public async Task OrganizationExistsAndEnabledAsync(Guid organizationId, CancellationToken cancellationToken = default) { - var sql = $$""" + var sql = $@" SELECT COUNT(*) AS `Value` - FROM `{{FakeTables.Organization}}` o - WHERE o.`Id` = {0} + FROM `{FakeTables.Organization}` o + WHERE o.`Id` = {{0}} AND o.`IsDeleted` = 0 - """; + "; var count = await Db.Database.SqlQueryRaw(sql, organizationId.ToString()).FirstAsync(cancellationToken); return count > 0; @@ -446,12 +446,12 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency if (dataScope == DataPermConstants.DataScopeTeam) { - var sql = $$""" + var sql = $@" SELECT u.`userid` AS `Value` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND u.`OrganizationId` = {0} - """; + AND u.`OrganizationId` = {{0}} + "; return await Db.Database.SqlQueryRaw(sql, organizationId.ToString()) .ToListAsync(cancellationToken); } @@ -465,12 +465,12 @@ public class DataPermRepository(FakeRbacDbContext dbContext) : IScopedDependency } var inClause = string.Join(",", orgIds.Select(x => $"'{x}'")); - var sql = $$""" + var sql = $@" SELECT u.`userid` AS `Value` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 AND u.`OrganizationId` IN ({inClause}) - """; + "; return await Db.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); } diff --git a/src/Oms.Application/DataPerm/DataPermStore.cs b/src/Oms.Application/DataPerm/DataPermStore.cs index dd8da2c..1022508 100644 --- a/src/Oms.Application/DataPerm/DataPermStore.cs +++ b/src/Oms.Application/DataPerm/DataPermStore.cs @@ -21,21 +21,21 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task GetUserDataPermAsync(Guid userId, CancellationToken cancellationToken = default) { - var user = await dbContext.Database.SqlQueryRaw($$""" + var user = await dbContext.Database.SqlQueryRaw($@" SELECT u.`Id` AS `UserId`, u.`Name` AS `UserName`, u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName` - FROM `{{FakeTables.User}}` u - LEFT JOIN `{{FakeTables.Organization}}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 - WHERE u.`Id` = {0} AND u.`IsDeleted` = 0 + FROM `{FakeTables.User}` u + LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 + WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 LIMIT 1 - """, userId.ToString()).FirstOrDefaultAsync(cancellationToken) + ", userId.ToString()).FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("用户不存在"); var now = DateTime.UtcNow; - var orgRows = await dbContext.Database.SqlQueryRaw($$""" + var orgRows = await dbContext.Database.SqlQueryRaw($@" SELECT p.`Id` AS `Id`, p.`OrganizationId` AS `OrganizationId`, @@ -45,11 +45,11 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac p.`ExpireTime` AS `ExpireTime`, p.`IsTemporary` AS `IsTemporary` FROM `user_org_perm` p - LEFT JOIN `{{FakeTables.Organization}}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 - WHERE p.`IsDeleted` = 0 AND p.`UserId` = {0} - """, userId.ToString()).ToListAsync(cancellationToken); + LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 + WHERE p.`IsDeleted` = 0 AND p.`UserId` = {{0}} + ", userId.ToString()).ToListAsync(cancellationToken); - var scopeRows = await dbContext.Database.SqlQueryRaw($""" + var scopeRows = await dbContext.Database.SqlQueryRaw(""" SELECT p.`Id` AS `Id`, p.`DataScope` AS `DataScope`, p.`DataResourceCode` AS `DataResourceCode`, p.`ExpireTime` AS `ExpireTime` FROM `user_data_scope` p WHERE p.`IsDeleted` = 0 AND p.`UserId` = {0} @@ -128,20 +128,20 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task GetRoleDataPermAsync(Guid roleId, CancellationToken cancellationToken = default) { - var role = await dbContext.Database.SqlQueryRaw(""" + var role = await dbContext.Database.SqlQueryRaw($@" SELECT r.`Id` AS `RoleId`, r.`Name` AS `RoleName` - FROM `{{FakeTables.Role}}` r - WHERE r.`Id` = {0} AND r.`IsDeleted` = 0 + FROM `{FakeTables.Role}` r + WHERE r.`Id` = {{0}} AND r.`IsDeleted` = 0 LIMIT 1 - """, roleId.ToString()).FirstOrDefaultAsync(cancellationToken) + ", roleId.ToString()).FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("角色不存在"); - var orgRows = await dbContext.Database.SqlQueryRaw($$""" + var orgRows = await dbContext.Database.SqlQueryRaw($@" SELECT p.`Id` AS `Id`, p.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName`, p.`OrgScope` AS `OrgScope` FROM `role_org_perm` p - LEFT JOIN `{{FakeTables.Organization}}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 - WHERE p.`IsDeleted` = 0 AND p.`RoleId` = {0} - """, roleId.ToString()).ToListAsync(cancellationToken); + LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 + WHERE p.`IsDeleted` = 0 AND p.`RoleId` = {{0}} + ", roleId.ToString()).ToListAsync(cancellationToken); var scopeRows = await dbContext.Database.SqlQueryRaw(""" SELECT p.`Id` AS `Id`, p.`DataScope` AS `DataScope`, p.`DataResourceCode` AS `DataResourceCode` @@ -521,7 +521,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task> GetFieldMasksAsync(string dataResourceCode, CancellationToken cancellationToken = default) { - var sql = $""" + var sql = """ SELECT m.`FieldName` AS `FieldName`, m.`SecretLevel` AS `SecretLevel`, m.`MaskType` AS `MaskType`, m.`MaskPattern` AS `MaskPattern` FROM `data_perm_field_mask` m WHERE m.`IsDeleted` = 0 AND m.`IsEnabled` = 1 AND m.`DataResourceCode` = {0} @@ -641,7 +641,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task GetTemplateDetailAsync(Guid templateId, CancellationToken cancellationToken = default) { - var headerSql = $""" + var headerSql = """ SELECT t.`Id` AS `Id`, t.`Name` AS `Name`, t.`Code` AS `Code`, t.`Description` AS `Description`, t.`RoleCode` AS `RoleCode`, t.`IsBuiltIn` AS `IsBuiltIn` FROM `data_perm_template` t @@ -653,7 +653,7 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac .FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("模板不存在"); - var itemSql = $""" + var itemSql = """ SELECT i.`Id` AS `Id`, i.`DimensionType` AS `DimensionType`, CAST(i.`ItemContent` AS CHAR) AS `ItemContentJson`, i.`SortOrder` AS `SortOrder` FROM `data_perm_template_item` i WHERE i.`TemplateId` = {0} AND i.`IsDeleted` = 0 diff --git a/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs b/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs index 8cce024..ed34881 100644 --- a/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs +++ b/src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs @@ -42,17 +42,17 @@ public class CustomerServiceAdvisorResolver(FakeRbacDbContext dbContext) : IScop return null; } - var sql = $$""" + var sql = $@" SELECT u.`Name` AS `Name`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, u.`WecomKfUrl` AS `WecomKfUrl` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND u.`userid` = {0} + AND u.`userid` = {{0}} LIMIT 1 - """; + "; return await dbContext.Database .SqlQueryRaw(sql, userId) .FirstOrDefaultAsync(cancellationToken); @@ -60,18 +60,18 @@ public class CustomerServiceAdvisorResolver(FakeRbacDbContext dbContext) : IScop private async Task FindByNameOrAccountAsync(string key, CancellationToken cancellationToken) { - var sql = $$""" + var sql = $@" SELECT u.`Name` AS `Name`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, u.`WecomKfUrl` AS `WecomKfUrl` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND (u.`Name` = {0} OR u.`Account` = {0}) + AND (u.`Name` = {{0}} OR u.`Account` = {{0}}) ORDER BY u.`CreateTime` ASC LIMIT 1 - """; + "; return await dbContext.Database .SqlQueryRaw(sql, key) diff --git a/src/Oms.Application/Rbac/InternalUserRepository.cs b/src/Oms.Application/Rbac/InternalUserRepository.cs index 18f7784..d3866c0 100644 --- a/src/Oms.Application/Rbac/InternalUserRepository.cs +++ b/src/Oms.Application/Rbac/InternalUserRepository.cs @@ -8,19 +8,19 @@ namespace Oms.Application.Rbac; public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDependency { - private static readonly string InternalUserFilter = $$""" + private static readonly string InternalUserFilter = $@" ( - NOT EXISTS (SELECT 1 FROM `{{FakeTables.UserRole}}` ur0 WHERE ur0.`UserId` = u.`Id`) + NOT EXISTS (SELECT 1 FROM `{FakeTables.UserRole}` ur0 WHERE ur0.`UserId` = u.`Id`) OR EXISTS ( SELECT 1 - FROM `{{FakeTables.UserRole}}` ur - INNER JOIN `{{FakeTables.Role}}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{FakeTables.UserRole}` ur + INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` = u.`Id` AND r.`Code` <> 'Customer' AND r.`Code` NOT LIKE 'CustomerSub\_%' ) ) - """; + "; public async Task GetInternalUsersAsync( string? keyword, Guid? organizationId, @@ -45,16 +45,16 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend args.Add(organizationId.Value.ToString()); } - var countSql = $$""" + var countSql = $@" SELECT COUNT(*) AS `Value` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u {where} - """; + "; var total = await dbContext.Database.SqlQueryRaw(countSql, args.ToArray()) .FirstAsync(cancellationToken); var offset = Math.Max(page - 1, 0) * pageSize; - var listSql = $$""" + var listSql = $@" SELECT u.`Id` AS `Id`, u.`Name` AS `Name`, @@ -67,12 +67,12 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName`, u.`CreateTime` AS `CreatedAt` - FROM `{{FakeTables.User}}` u - LEFT JOIN `{{FakeTables.Organization}}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 + FROM `{FakeTables.User}` u + LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 {where} ORDER BY u.`CreateTime` DESC LIMIT {pageSize} OFFSET {offset} - """; + "; var rows = await dbContext.Database.SqlQueryRaw(listSql, args.ToArray()) .ToListAsync(cancellationToken); if (rows.Count == 0) @@ -111,17 +111,17 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend } var inClause = string.Join(",", userIds.Select(x => $"'{x}'")); - var sql = $$""" + var sql = $@" SELECT ur.`UserId` AS `UserId`, r.`Id` AS `RoleId`, r.`Name` AS `RoleName`, r.`Code` AS `RoleCode` - FROM `{{FakeTables.UserRole}}` ur - INNER JOIN `{{FakeTables.Role}}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{FakeTables.UserRole}` ur + INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` IN ({inClause}) ORDER BY r.`Name` - """; + "; var roleRows = await dbContext.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); return roleRows diff --git a/src/Oms.Application/Rbac/UserProfileRepository.cs b/src/Oms.Application/Rbac/UserProfileRepository.cs index 303e4e9..99949fa 100644 --- a/src/Oms.Application/Rbac/UserProfileRepository.cs +++ b/src/Oms.Application/Rbac/UserProfileRepository.cs @@ -16,17 +16,17 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public async Task GetAsync(Guid userId, CancellationToken cancellationToken = default) { - var sql = $$""" + var sql = $@" SELECT u.`Phone` AS `Phone`, u.`EmployeeNo` AS `EmployeeNo`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, u.`WecomKfUrl` AS `WecomKfUrl` - FROM `{{FakeTables.User}}` u - WHERE u.`Id` = {0} AND u.`IsDeleted` = 0 + FROM `{FakeTables.User}` u + WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 LIMIT 1 - """; + "; var row = await dbContext.Database .SqlQueryRaw(sql, userId.ToString()) .FirstOrDefaultAsync(cancellationToken); @@ -56,7 +56,7 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende } var inClause = string.Join(",", userIds.Select(x => $"'{x}'")); - var sql = $$""" + var sql = $@" SELECT u.`Id` AS `UserId`, u.`Phone` AS `Phone`, @@ -64,9 +64,9 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, u.`WecomKfUrl` AS `WecomKfUrl` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u WHERE u.`Id` IN ({inClause}) AND u.`IsDeleted` = 0 - """; + "; var rows = await dbContext.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); return rows.ToDictionary( @@ -83,16 +83,16 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public async Task SaveAsync(Guid userId, UserProfileData profile, CancellationToken cancellationToken = default) { - var sql = $$""" - UPDATE `{{FakeTables.User}}` + var sql = $@" + UPDATE `{FakeTables.User}` SET - `Phone` = {1}, - `EmployeeNo` = {2}, - `WechatId` = {3}, - `WechatQrCode` = {4}, - `WecomKfUrl` = {5} - WHERE `Id` = {0} AND `IsDeleted` = 0 - """; + `Phone` = {{1}}, + `EmployeeNo` = {{2}}, + `WechatId` = {{3}}, + `WechatQrCode` = {{4}}, + `WecomKfUrl` = {{5}} + WHERE `Id` = {{0}} AND `IsDeleted` = 0 + "; await dbContext.Database.ExecuteSqlRawAsync( sql, [ @@ -112,15 +112,15 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende UserProfileData profile, CancellationToken cancellationToken = default) { - var sql = $$""" - UPDATE `{{FakeTables.User}}` + var sql = $@" + UPDATE `{FakeTables.User}` SET - `Phone` = {1}, - `WechatId` = {2}, - `WechatQrCode` = {3}, - `WecomKfUrl` = {4} - WHERE `Id` = {0} AND `IsDeleted` = 0 - """; + `Phone` = {{1}}, + `WechatId` = {{2}}, + `WechatQrCode` = {{3}}, + `WecomKfUrl` = {{4}} + WHERE `Id` = {{0}} AND `IsDeleted` = 0 + "; await dbContext.Database.ExecuteSqlRawAsync( sql, @@ -141,19 +141,19 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende { var normalized = employeeNo.Trim().ToUpperInvariant(); var sql = excludeUserId.HasValue - ? $$""" + ? $@" SELECT COUNT(*) AS `Value` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND u.`EmployeeNo` = {0} - AND u.`Id` <> {1} - """ - : $$""" + AND u.`EmployeeNo` = {{0}} + AND u.`Id` <> {{1}} + " + : $@" SELECT COUNT(*) AS `Value` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND u.`EmployeeNo` = {0} - """; + AND u.`EmployeeNo` = {{0}} + "; var count = excludeUserId.HasValue ? await dbContext.Database.SqlQueryRaw(sql, normalized, excludeUserId.Value.ToString()) @@ -165,13 +165,13 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public async Task GenerateNextEmployeeNoAsync(CancellationToken cancellationToken = default) { - var sql = $$""" + var sql = $@" SELECT u.`EmployeeNo` AS `EmployeeNo` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 AND u.`EmployeeNo` IS NOT NULL AND u.`EmployeeNo` <> '' - """; + "; var rows = await dbContext.Database.SqlQueryRaw(sql).ToListAsync(cancellationToken); var max = 0; diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs index b8f30ac..f0620b9 100644 --- a/src/Oms.Application/Services/System/NoticeService.cs +++ b/src/Oms.Application/Services/System/NoticeService.cs @@ -25,19 +25,19 @@ public class NoticeService : ApplicationService private const string ReceiverTypeUser = "user"; private const string ReceiverTypeCustomer = "customer"; - private static readonly string InternalUserFilter = $$""" + private static readonly string InternalUserFilter = $@" ( - NOT EXISTS (SELECT 1 FROM `{{FakeTables.UserRole}}` ur0 WHERE ur0.`UserId` = u.`Id`) + NOT EXISTS (SELECT 1 FROM `{FakeTables.UserRole}` ur0 WHERE ur0.`UserId` = u.`Id`) OR EXISTS ( SELECT 1 - FROM `{{FakeTables.UserRole}}` ur - INNER JOIN `{{FakeTables.Role}}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 + FROM `{FakeTables.UserRole}` ur + INNER JOIN `{FakeTables.Role}` r ON r.`Id` = ur.`RoleId` AND r.`IsDeleted` = 0 WHERE ur.`UserId` = u.`Id` AND r.`Code` <> 'Customer' AND r.`Code` NOT LIKE 'CustomerSub\_%' ) ) - """; + "; private INoticeRepository NoticeRepository => LazyServiceProvider.GetRequiredService(); private INoticeReadRepository NoticeReadRepository => LazyServiceProvider.GetRequiredService(); private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService(); @@ -239,19 +239,19 @@ public class NoticeService : ApplicationService args.Add(pattern); } - var countSql = $"SELECT COUNT(*) AS `Value` FROM `{{FakeTables.User}}` u {where}"; + var countSql = $"SELECT COUNT(*) AS `Value` FROM `{FakeTables.User}` u {where}"; var total = await RbacDbContext.Database.SqlQueryRaw(countSql, args.ToArray()).FirstAsync(); var offset = Math.Max(pageIndex - 1, 0) * pageSize; - var listSql = $$""" + var listSql = $@" SELECT u.`userid` AS `Value`, u.`Name` AS `Label`, u.`Account` AS `Extra` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u {where} ORDER BY u.`Name`, u.`userid` LIMIT {pageSize} OFFSET {offset} - """; + "; var items = await RbacDbContext.Database.SqlQueryRaw(listSql, args.ToArray()) .Select(x => new NoticeReceiverOptionOutput { @@ -465,7 +465,7 @@ public class NoticeService : ApplicationService return []; } - var sql = $"SELECT `userid` AS `UserId` FROM `{{FakeTables.User}}` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", userIds)})"; + var sql = $"SELECT `userid` AS `UserId` FROM `{FakeTables.User}` WHERE `IsDeleted` = 0 AND `userid` IN ({string.Join(",", userIds)})"; return await RbacDbContext.Database.SqlQueryRaw(sql) .Select(x => x.UserId) .ToListAsync(); diff --git a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs index ae9c203..fa1f7f1 100644 --- a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs +++ b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs @@ -169,12 +169,12 @@ public static class OmsRbacRegistrationGuard var hit = await rbacDbContext.Database .SqlQueryRaw( - $$""" + $@" SELECT 1 AS Value - FROM `{{FakeTables.Organization}}` - WHERE `Id` = {0} AND `IsDeleted` = 0 + FROM `{FakeTables.Organization}` + WHERE `Id` = {{0}} AND `IsDeleted` = 0 LIMIT 1 - """, organizationId.Value.ToString()) + ", organizationId.Value.ToString()) .FirstOrDefaultAsync(cancellationToken); if (!hit.HasValue) @@ -203,12 +203,12 @@ public static class OmsRbacRegistrationGuard { var hit = await rbacDbContext.Database .SqlQueryRaw( - $$""" + $@" SELECT 1 AS Value - FROM `{{FakeTables.Role}}` - WHERE `Id` = {0} AND `IsDeleted` = 0 + FROM `{FakeTables.Role}` + WHERE `Id` = {{0}} AND `IsDeleted` = 0 LIMIT 1 - """, roleId.ToString()) + ", roleId.ToString()) .FirstOrDefaultAsync(cancellationToken); if (!hit.HasValue) @@ -494,7 +494,7 @@ public static class OmsRbacRegistrationGuard { var hit = await rbacDbContext.Database .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Account` = {0} AND `Id` <> {1} LIMIT 1", + $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Account` = {{0}} AND `Id` <> {{1}} LIMIT 1", account, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; @@ -502,7 +502,7 @@ public static class OmsRbacRegistrationGuard var any = await rbacDbContext.Database .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Account` = {0} LIMIT 1", account) + $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Account` = {{0}} LIMIT 1", account) .FirstOrDefaultAsync(cancellationToken); return any.HasValue; } @@ -517,7 +517,7 @@ public static class OmsRbacRegistrationGuard { var hit = await rbacDbContext.Database .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Email` = {0} AND `Id` <> {1} LIMIT 1", + $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Email` = {{0}} AND `Id` <> {{1}} LIMIT 1", email, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; @@ -525,7 +525,7 @@ public static class OmsRbacRegistrationGuard var any = await rbacDbContext.Database .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Email` = {0} LIMIT 1", email) + $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Email` = {{0}} LIMIT 1", email) .FirstOrDefaultAsync(cancellationToken); return any.HasValue; } @@ -540,7 +540,7 @@ public static class OmsRbacRegistrationGuard { var hit = await rbacDbContext.Database .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Phone` = {0} AND `Id` <> {1} LIMIT 1", + $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Phone` = {{0}} AND `Id` <> {{1}} LIMIT 1", phone, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; @@ -548,7 +548,7 @@ public static class OmsRbacRegistrationGuard var any = await rbacDbContext.Database .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `Phone` = {0} LIMIT 1", phone) + $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Phone` = {{0}} LIMIT 1", phone) .FirstOrDefaultAsync(cancellationToken); return any.HasValue; } @@ -563,7 +563,7 @@ public static class OmsRbacRegistrationGuard { var hit = await rbacDbContext.Database .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `WechatId` = {0} AND `Id` <> {1} LIMIT 1", + $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `WechatId` = {{0}} AND `Id` <> {{1}} LIMIT 1", wechatId, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; @@ -571,7 +571,7 @@ public static class OmsRbacRegistrationGuard var any = await rbacDbContext.Database .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{{FakeTables.User}}` WHERE `WechatId` = {0} LIMIT 1", wechatId) + $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `WechatId` = {{0}} LIMIT 1", wechatId) .FirstOrDefaultAsync(cancellationToken); return any.HasValue; } diff --git a/src/Oms.Application/Shared/StaffAdvisorBinder.cs b/src/Oms.Application/Shared/StaffAdvisorBinder.cs index 0dafc40..ac4f465 100644 --- a/src/Oms.Application/Shared/StaffAdvisorBinder.cs +++ b/src/Oms.Application/Shared/StaffAdvisorBinder.cs @@ -45,14 +45,14 @@ public static class StaffAdvisorBinder string key, CancellationToken cancellationToken) { - var sql = $$""" + var sql = $@" SELECT u.`Account` AS `Account` - FROM `{{FakeTables.User}}` u + FROM `{FakeTables.User}` u WHERE u.`IsDeleted` = 0 - AND u.`Account` = {0} + AND u.`Account` = {{0}} ORDER BY u.`CreateTime` ASC LIMIT 1 - """; + "; var row = await rbacDbContext.Database .SqlQueryRaw(sql, key) -- 2.45.2 From e85dfb80b5d0ad5f794ba701b046eeeb650fbdf8 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 13 Oct 2026 17:53:00 +0800 Subject: [PATCH 07/16] =?UTF-8?q?feat:=20=E4=BB=93=E9=85=8D=E5=BC=80?= =?UTF-8?q?=E6=94=BE=20API=20=E4=B8=8E=E5=90=8E=E5=8F=B0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=90=AF=E5=81=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 warehouseOpenApi(Token/入库/件重尺/红外图),并在 fk_user 支持 IsEnabled 禁用登录。 Co-authored-by: Cursor --- sql/migrate_user_is_enabled.sql | 24 ++ .../Dtos/OpenApi/OpenApiTokenDtos.cs | 108 +++++++ .../Dtos/Rbac/InternalUserListDtos.cs | 5 + .../Dtos/Rbac/OmsUserProfileDtos.cs | 31 ++ .../Rbac/InternalUserRepository.cs | 9 +- .../Rbac/OmsUserProfileMapper.cs | 1 + .../Rbac/UserProfileRepository.cs | 63 +++- .../Services/OpenApi/OpenApiService.cs | 5 +- .../OpenApi/WarehouseOpenApiService.cs | 201 +++++++++++++ .../Operations/InboundRecordService.cs | 2 +- .../Services/Orders/OrderFileManager.cs | 270 +++++++++++++++--- .../Services/Rbac/OmsUserService.cs | 47 ++- .../Shared/OpenApiMeasureUnitConverter.cs | 34 +++ .../Shared/OpenApiTokenIssuer.cs | 47 +++ src/SimpleAdmin.Api/Auth/AuthService.cs | 2 + 15 files changed, 790 insertions(+), 59 deletions(-) create mode 100644 sql/migrate_user_is_enabled.sql create mode 100644 src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs create mode 100644 src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs create mode 100644 src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs create mode 100644 src/Oms.Application/Shared/OpenApiTokenIssuer.cs diff --git a/sql/migrate_user_is_enabled.sql b/sql/migrate_user_is_enabled.sql new file mode 100644 index 0000000..e3094cc --- /dev/null +++ b/sql/migrate_user_is_enabled.sql @@ -0,0 +1,24 @@ +-- 后台用户启用/禁用(可重复执行)。 +-- 表名:fk_user(Fake RBAC)。默认启用;禁用后拒绝账号密码登录与仓配 Token 换取。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 'fk_user' + AND COLUMN_NAME = 'IsEnabled' +); + +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `fk_user` ADD COLUMN `IsEnabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:1启用 0禁用'' AFTER `IsDeleted`', + 'SELECT ''fk_user.IsEnabled already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `fk_user` SET `IsEnabled` = 1 WHERE `IsEnabled` IS NULL; diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs new file mode 100644 index 0000000..203e1ce --- /dev/null +++ b/src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs @@ -0,0 +1,108 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.OpenApi; + +/// +/// 开放接口获取 Token 入参。 +/// +public class OpenApiGetTokenInput +{ + /// + /// 用户名(OMS 后台账号)。 + /// + [Required] + [StringLength(64)] + public string Username { get; set; } = string.Empty; + + /// + /// 密码。 + /// + [Required] + [StringLength(128)] + public string Password { get; set; } = string.Empty; +} + +/// +/// 开放接口简易结果(件重尺上传等)。 +/// +public class OpenApiSimpleResult +{ + public bool Success { get; set; } + + public string Msg { get; set; } = string.Empty; + + public static OpenApiSimpleResult Ok(string msg = "success") => + new() { Success = true, Msg = msg }; + + public static OpenApiSimpleResult Fail(string msg) => + new() { Success = false, Msg = msg }; +} + +/// +/// 仓配上传件重尺入参(入库接口可忽略 mode,固定走入库扫描「入库」逻辑)。 +/// +public class OpenApiUploadWeightInput +{ + /// + /// 订单号(服务单号 / 跟踪号 / 下游单号,同入库扫描扫码框)。 + /// + [Required] + [StringLength(64)] + public string OrderNo { get; set; } = string.Empty; + + /// + /// 收货实重(同入库扫描「实重」)。 + /// + public decimal Weight { get; set; } + + /// + /// 长(同入库扫描尺寸)。 + /// + public decimal Length { get; set; } + + /// + /// 宽。 + /// + public decimal Width { get; set; } + + /// + /// 高。 + /// + public decimal Height { get; set; } + + /// + /// 模式:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。调用 /inbound 时可省略。 + /// + public int Mode { get; set; } + + /// + /// 重量单位:KG(默认)/ G / LB。 + /// + [StringLength(8)] + public string? WeightUnit { get; set; } + + /// + /// 长度单位:CM(默认)/ MM / IN / M。 + /// + [StringLength(8)] + public string? LengthUnit { get; set; } +} + +/// +/// 仓配上传红外图片入参(落订单附件 infraredImage,转存 COS)。 +/// +public class OpenApiUploadImageInput +{ + /// + /// 订单号(服务单号 / 跟踪号 / 下游单号)。 + /// + [Required] + [StringLength(64)] + public string OrderNo { get; set; } = string.Empty; + + /// + /// 图片 Base64(可带 data:image/...;base64, 前缀;jpg/png/gif/webp/bmp/pdf)。 + /// + [Required] + public string ImageBase64 { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs b/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs index 55fd380..851a240 100644 --- a/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs +++ b/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs @@ -26,6 +26,11 @@ public class InternalUserListItemDto public DateTime? CreatedAt { get; set; } + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + public List Roles { get; set; } = []; } diff --git a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs index 415831c..febeaee 100644 --- a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs +++ b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs @@ -16,6 +16,11 @@ public class UserProfileData /// 企业微信客服接入链接(微信外接入)。 /// public string? WecomKfUrl { get; set; } + + /// + /// 是否启用;缺省按启用处理。 + /// + public bool IsEnabled { get; set; } = true; } public class OmsUserCreateInput : UserCreateDto @@ -29,6 +34,11 @@ public class OmsUserCreateInput : UserCreateDto public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + /// + /// 是否启用,默认 true。 + /// + public bool? IsEnabled { get; set; } } public class OmsUserUpdateInput : UserUpdateDto @@ -40,6 +50,11 @@ public class OmsUserUpdateInput : UserUpdateDto public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + /// + /// 是否启用;不传则不改。 + /// + public bool? IsEnabled { get; set; } } public class OmsUserDetailDto : UserDto @@ -53,6 +68,11 @@ public class OmsUserDetailDto : UserDto public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; } /// @@ -65,3 +85,14 @@ public class ResetUserPasswordInput /// public string NewPassword { get; set; } = string.Empty; } + +/// +/// 设置用户启用状态入参。 +/// +public class SetUserEnabledInput +{ + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } +} diff --git a/src/Oms.Application/Rbac/InternalUserRepository.cs b/src/Oms.Application/Rbac/InternalUserRepository.cs index d3866c0..3d6cc05 100644 --- a/src/Oms.Application/Rbac/InternalUserRepository.cs +++ b/src/Oms.Application/Rbac/InternalUserRepository.cs @@ -1,6 +1,6 @@ using Fake.DependencyInjection; -using Fake.Rbac.Infrastructure; using Fake.EntityFrameworkCore; +using Fake.Rbac.Infrastructure; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Rbac; @@ -21,6 +21,7 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend ) ) "; + public async Task GetInternalUsersAsync( string? keyword, Guid? organizationId, @@ -66,7 +67,8 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend u.`WechatId` AS `WechatId`, u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName`, - u.`CreateTime` AS `CreatedAt` + u.`CreateTime` AS `CreatedAt`, + COALESCE(u.`IsEnabled`, 1) AS `IsEnabled` FROM `{FakeTables.User}` u LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 {where} @@ -95,6 +97,7 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend OrganizationId = row.OrganizationId?.ToString(), OrganizationName = row.OrganizationName, CreatedAt = row.CreatedAt, + IsEnabled = row.IsEnabled != 0, Roles = roleMap.TryGetValue(row.Id, out var roles) ? roles : [] }).ToList(); @@ -159,6 +162,8 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend public string? OrganizationName { get; set; } public DateTime? CreatedAt { get; set; } + + public int IsEnabled { get; set; } = 1; } private sealed class UserRoleRow diff --git a/src/Oms.Application/Rbac/OmsUserProfileMapper.cs b/src/Oms.Application/Rbac/OmsUserProfileMapper.cs index 463a194..56a3013 100644 --- a/src/Oms.Application/Rbac/OmsUserProfileMapper.cs +++ b/src/Oms.Application/Rbac/OmsUserProfileMapper.cs @@ -33,6 +33,7 @@ internal static class OmsUserProfileMapper detail.WechatId = profile.WechatId; detail.WechatQrCode = profile.WechatQrCode; detail.WecomKfUrl = profile.WecomKfUrl; + detail.IsEnabled = profile.IsEnabled; return detail; } } diff --git a/src/Oms.Application/Rbac/UserProfileRepository.cs b/src/Oms.Application/Rbac/UserProfileRepository.cs index 99949fa..817d8b1 100644 --- a/src/Oms.Application/Rbac/UserProfileRepository.cs +++ b/src/Oms.Application/Rbac/UserProfileRepository.cs @@ -1,7 +1,8 @@ using System.Text.RegularExpressions; +using Fake.Application; using Fake.DependencyInjection; -using Fake.Rbac.Infrastructure; using Fake.EntityFrameworkCore; +using Fake.Rbac.Infrastructure; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Rbac; @@ -22,7 +23,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende u.`EmployeeNo` AS `EmployeeNo`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, - u.`WecomKfUrl` AS `WecomKfUrl` + u.`WecomKfUrl` AS `WecomKfUrl`, + COALESCE(u.`IsEnabled`, 1) AS `IsEnabled` FROM `{FakeTables.User}` u WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 LIMIT 1 @@ -43,6 +45,7 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende WechatId = row.WechatId, WechatQrCode = row.WechatQrCode, WecomKfUrl = row.WecomKfUrl, + IsEnabled = row.IsEnabled != 0, }; } @@ -63,7 +66,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende u.`EmployeeNo` AS `EmployeeNo`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, - u.`WecomKfUrl` AS `WecomKfUrl` + u.`WecomKfUrl` AS `WecomKfUrl`, + COALESCE(u.`IsEnabled`, 1) AS `IsEnabled` FROM `{FakeTables.User}` u WHERE u.`Id` IN ({inClause}) AND u.`IsDeleted` = 0 "; @@ -78,6 +82,7 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende WechatId = x.WechatId, WechatQrCode = x.WechatQrCode, WecomKfUrl = x.WecomKfUrl, + IsEnabled = x.IsEnabled != 0, }); } @@ -90,9 +95,11 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende `EmployeeNo` = {{2}}, `WechatId` = {{3}}, `WechatQrCode` = {{4}}, - `WecomKfUrl` = {{5}} + `WecomKfUrl` = {{5}}, + `IsEnabled` = {{6}} WHERE `Id` = {{0}} AND `IsDeleted` = 0 "; + await dbContext.Database.ExecuteSqlRawAsync( sql, [ @@ -103,10 +110,52 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende (object?)NormalizeOptional(profile.WechatId), (object?)NormalizeOptional(profile.WechatQrCode), (object?)NormalizeOptional(profile.WecomKfUrl), + profile.IsEnabled ? 1 : 0, ], cancellationToken); } + public async Task SetEnabledAsync(Guid userId, bool isEnabled, CancellationToken cancellationToken = default) + { + var sql = $@" + UPDATE `{FakeTables.User}` + SET `IsEnabled` = {{1}} + WHERE `Id` = {{0}} AND `IsDeleted` = 0 + "; + + var affected = await dbContext.Database.ExecuteSqlRawAsync( + sql, + [userId.ToString(), isEnabled ? 1 : 0], + cancellationToken); + if (affected <= 0) + { + throw new BusinessException("用户不存在"); + } + } + + public async Task EnsureEnabledOrThrowAsync(Guid userId, CancellationToken cancellationToken = default) + { + var sql = $@" + SELECT COALESCE(u.`IsEnabled`, 1) AS `Value` + FROM `{FakeTables.User}` u + WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 + LIMIT 1 + "; + + var rows = await dbContext.Database + .SqlQueryRaw(sql, userId.ToString()) + .ToListAsync(cancellationToken); + if (rows.Count == 0) + { + throw new BusinessException("用户不存在"); + } + + if (rows[0] == 0) + { + throw new BusinessException("账号已禁用"); + } + } + public async Task UpdateContactAsync( Guid userId, UserProfileData profile, @@ -186,8 +235,6 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende if (int.TryParse(match.Groups[1].Value, out var number)) { - - max = Math.Max(max, number); } } @@ -219,6 +266,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + public int IsEnabled { get; set; } = 1; } private sealed class UserProfileListRow @@ -234,6 +283,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + public int IsEnabled { get; set; } = 1; } private sealed class EmployeeNoRow diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs index bba414f..cfe2f49 100644 --- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs +++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs @@ -14,9 +14,10 @@ using Oms.Domain.OrdersAggregate.Consts; namespace Oms.Application.Services.OpenApi; /// -/// 对外开放 API(Swagger 文档分组:openApi)。 +/// 客户对外开放 API(Swagger 文档分组:openApi)。 /// 统一响应:{ code, msg, data }。 -/// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。 +/// 鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。 +/// 仓配 Token 接口见分组 warehouseOpenApi。 /// [ApiExplorerSettings(GroupName = "openApi")] public class OpenApiService : ApplicationService diff --git a/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs b/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs new file mode 100644 index 0000000..35a81be --- /dev/null +++ b/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs @@ -0,0 +1,201 @@ +using Fake.Application; +using Fake.Rbac.Application.Jwt; +using Fake.Rbac.Domain.Managers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.OpenApi; +using Oms.Application.Dtos.Operations; +using Oms.Application.Rbac; +using Oms.Application.Services.Operations; +using Oms.Application.Services.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Services.OpenApi; + +/// +/// 仓配对外开放 API(Swagger 文档分组:warehouseOpenApi)。 +/// 鉴权:Authorization: Bearer {token}(账号密码换取,有效期 48 小时)。 +/// 与客户 HMAC 分组 openApi 相互独立。 +/// +[ApiExplorerSettings(GroupName = "warehouseOpenApi")] +public class WarehouseOpenApiService : ApplicationService +{ + private AccountManager AccountManager => + LazyServiceProvider.GetRequiredService(); + + private IJwtService JwtService => + LazyServiceProvider.GetRequiredService(); + + private OpenApiTokenIssuer TokenIssuer => + LazyServiceProvider.GetRequiredService(); + + private IOrderRepository OrderRepository => + LazyServiceProvider.GetRequiredService(); + + private OrderFileManager OrderFileManager => + LazyServiceProvider.GetRequiredService(); + + private InboundRecordService InboundRecordService => + LazyServiceProvider.GetRequiredService(); + + private OutboundRecordService OutboundRecordService => + LazyServiceProvider.GetRequiredService(); + + /// + /// 获取 Token(有效期 48 小时)。后续请求 Header:Authorization: Bearer {token}。 + /// + [AllowAnonymous] + [HttpPost("/openapi/warehouse/token")] + [Consumes("application/json")] + [Produces("application/json")] + public async Task GetTokenAsync( + [FromBody] OpenApiGetTokenInput input, + CancellationToken cancellationToken = default) + { + if (input == null + || string.IsNullOrWhiteSpace(input.Username) + || string.IsNullOrWhiteSpace(input.Password)) + { + throw new BusinessException("用户名或密码不能为空"); + } + + var user = await AccountManager.ValidateCredentialsAsync( + input.Username.Trim(), + input.Password, + cancellationToken); + var profileRepository = LazyServiceProvider.GetRequiredService(); + await profileRepository.EnsureEnabledOrThrowAsync(user.Id, cancellationToken); + var claims = await JwtService.GenerateClaimsByUserIdAsync(user.Id, cancellationToken); + return TokenIssuer.Issue(claims); + } + + /// + /// 入库上传件重尺。与操作台「入库扫描」页「入库」按钮同源(POST /operation/inbound/create)。 + /// 写入收货件重尺、入库记录,并触发入库计费/自动触发。重量默认 kg,长度默认 cm。 + /// Header:Authorization: Bearer {token}。 + /// + [Authorize] + [HttpPost("/openapi/warehouse/inbound")] + [Consumes("application/json")] + [Produces("application/json")] + public Task InboundAsync([FromBody] OpenApiUploadWeightInput input) => + UploadWeightCoreAsync(input, forceMode: 0); + + /// + /// 上传件重尺。mode:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。 + /// Header:Authorization: Bearer {token}。 + /// + [Authorize] + [HttpPost("/openapi/warehouse/weight/upload")] + [Consumes("application/json")] + [Produces("application/json")] + public Task UploadWeightAsync([FromBody] OpenApiUploadWeightInput input) => + UploadWeightCoreAsync(input, forceMode: null); + + private async Task UploadWeightCoreAsync( + OpenApiUploadWeightInput? input, + int? forceMode) + { + try + { + if (input == null || string.IsNullOrWhiteSpace(input.OrderNo)) + { + return OpenApiSimpleResult.Fail("订单号不能为空"); + } + + var mode = forceMode ?? input.Mode; + var weightKg = OpenApiMeasureUnitConverter.ToKilograms(input.Weight, input.WeightUnit); + var lengthCm = OpenApiMeasureUnitConverter.ToCentimeters(input.Length, input.LengthUnit); + var widthCm = OpenApiMeasureUnitConverter.ToCentimeters(input.Width, input.LengthUnit); + var heightCm = OpenApiMeasureUnitConverter.ToCentimeters(input.Height, input.LengthUnit); + var orderNo = input.OrderNo.Trim(); + + if (weightKg <= 0 || lengthCm <= 0 || widthCm <= 0 || heightCm <= 0) + { + return OpenApiSimpleResult.Fail("实重与长宽高须大于 0"); + } + + if (mode == 1) + { + await OutboundRecordService.CreateAsync(new CreateOutboundRecordInput + { + WaybillNo = orderNo, + Weight = weightKg, + Length = lengthCm, + Width = widthCm, + Height = heightCm + }); + return OpenApiSimpleResult.Ok("出库成功"); + } + + if (mode == 0) + { + // 与入库扫描页「入库」按钮同一实现:InboundRecordService.CreateAsync + var result = await InboundRecordService.CreateAsync(new CreateInboundRecordInput + { + WaybillNo = orderNo, + Weight = weightKg, + Length = lengthCm, + Width = widthCm, + Height = heightCm + }); + var statusName = string.IsNullOrWhiteSpace(result.OrderStatusName) + ? "入库成功" + : $"入库成功({result.OrderStatusName})"; + return OpenApiSimpleResult.Ok(statusName); + } + + return OpenApiSimpleResult.Fail("mode 仅支持 0(入库)或 1(出库)"); + } + catch (BusinessException ex) + { + return OpenApiSimpleResult.Fail(ex.Message); + } + catch (Exception ex) + { + return OpenApiSimpleResult.Fail( + string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message); + } + } + + /// + /// 上传红外图片。Base64 解码后转存 COS,写入订单附件 fileType=infraredImage(Source=upload), + /// 与订单详情「附件」Tab →「红外图片」→「点击上传」(POST /order/files/upload)同源展示。 + /// 成功无响应体。Header:Authorization: Bearer {token}。 + /// + [Authorize] + [HttpPost("/openapi/warehouse/image/upload")] + [Consumes("application/json")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task UploadImageAsync( + [FromBody] OpenApiUploadImageInput input, + CancellationToken cancellationToken = default) + { + if (input == null || string.IsNullOrWhiteSpace(input.OrderNo)) + { + throw new BusinessException("订单号不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.ImageBase64)) + { + throw new BusinessException("图片内容不能为空"); + } + + var order = await OrderRepository.GetByWaybillNoAsync(input.OrderNo.Trim(), cancellationToken) + ?? throw new BusinessException("未找到对应订单,请检查单号"); + + var operatorUserId = CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0; + // 固定红外图片类型,路径 uploads/order-files/{orderId}/infraredImage/... + await OrderFileManager.AddFromBase64Async( + order.Id, + OrderFileTypeCodes.InfraredImage, + input.ImageBase64, + operatorUserId, + cancellationToken); + + return new NoContentResult(); + } +} diff --git a/src/Oms.Application/Services/Operations/InboundRecordService.cs b/src/Oms.Application/Services/Operations/InboundRecordService.cs index 0770e99..30ead03 100644 --- a/src/Oms.Application/Services/Operations/InboundRecordService.cs +++ b/src/Oms.Application/Services/Operations/InboundRecordService.cs @@ -82,7 +82,7 @@ public class InboundRecordService : ApplicationService } /// - /// 新增入库记录。 + /// 新增入库记录(入库扫描页「入库」按钮;仓配开放接口 POST /openapi/warehouse/inbound 同源)。 /// /// 入参:入库记录创建参数。 /// 入库记录结果。 diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs index 356230a..89cdeb9 100644 --- a/src/Oms.Application/Services/Orders/OrderFileManager.cs +++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs @@ -363,6 +363,221 @@ public sealed class OrderFileManager( return ".pdf"; } + /// + /// 从 Base64 写入订单附件:先转存对象存储(COS),再落 t_order_file。 + /// 与订单详情附件 Tab「点击上传」(POST /order/files/upload)同源校验与路径约定。 + /// + public async Task AddFromBase64Async( + int orderId, + string fileType, + string imageBase64, + int operatorUserId, + CancellationToken cancellationToken = default) + { + if (!OrderFileTypeCodes.IsValid(fileType)) + { + throw new BusinessException("不支持的附件类型"); + } + + var type = OrderFileTypeCodes.Normalize(fileType); + if (string.IsNullOrWhiteSpace(imageBase64)) + { + throw new BusinessException("图片内容不能为空"); + } + + var (bytes, contentType, ext) = DecodeBase64Image(imageBase64); + if (bytes.Length == 0) + { + throw new BusinessException("图片内容不能为空"); + } + + var existing = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken); + var max = OrderFileTypeCodes.GetMaxCount(type); + if (existing.Count >= max) + { + throw new BusinessException($"类型 {type} 最多 {max} 个文件"); + } + + var fileName = $"{type}_{DateTime.Now:yyyyMMddHHmmss}{ext}"; + var savedPath = await SaveBytesAsync(bytes, fileName, contentType, orderId, type, cancellationToken); + + var sort = existing.Count == 0 ? 0 : existing.Max(x => x.Sort) + 1; + var entity = new OrderFile + { + OrderId = orderId, + FileType = type, + FileName = savedPath.FileName, + FileUrl = savedPath.Url, + ContentType = savedPath.ContentType, + FileSize = savedPath.Size, + Sort = sort, + Source = OrderFileSourceCodes.Upload, + CreateUserId = operatorUserId, + CreateTime = DateTime.Now + }; + var saved = await orderFileRepository.InsertAsync(entity, cancellationToken); + return MapItem(saved); + } + + private static (byte[] Bytes, string ContentType, string Ext) DecodeBase64Image(string imageBase64) + { + var raw = imageBase64.Trim(); + string? headerContentType = null; + + if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var comma = raw.IndexOf(','); + if (comma <= 0) + { + throw new BusinessException("图片 Base64 格式错误"); + } + + var header = raw[..comma]; + raw = raw[(comma + 1)..]; + var mimeStart = header.IndexOf(':'); + var mimeEnd = header.IndexOf(';'); + if (mimeStart >= 0 && mimeEnd > mimeStart) + { + headerContentType = header[(mimeStart + 1)..mimeEnd].Trim(); + } + } + + byte[] bytes; + try + { + bytes = Convert.FromBase64String(raw); + } + catch (FormatException) + { + throw new BusinessException("图片 Base64 解码失败"); + } + + var sniffed = SniffImageFormat(bytes); + var contentType = headerContentType ?? sniffed.ContentType; + var ext = sniffed.Ext; + if (!string.IsNullOrWhiteSpace(headerContentType)) + { + ext = headerContentType.ToLowerInvariant() switch + { + "image/png" => ".png", + "image/webp" => ".webp", + "image/gif" => ".gif", + "image/bmp" => ".bmp", + "application/pdf" => ".pdf", + "image/jpeg" or "image/jpg" => ".jpg", + _ => sniffed.Ext + }; + } + + return (bytes, contentType, ext); + } + + private static (string ContentType, string Ext) SniffImageFormat(byte[] bytes) + { + if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + { + return ("image/jpeg", ".jpg"); + } + + if (bytes.Length >= 8 + && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) + { + return ("image/png", ".png"); + } + + if (bytes.Length >= 6 + && bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) + { + return ("image/gif", ".gif"); + } + + if (bytes.Length >= 12 + && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 + && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) + { + return ("image/webp", ".webp"); + } + + if (bytes.Length >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D) + { + return ("image/bmp", ".bmp"); + } + + if (bytes.Length >= 5 + && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46) + { + return ("application/pdf", ".pdf"); + } + + return ("image/jpeg", ".jpg"); + } + + /// + /// 与 multipart 上传共用:校验扩展名/大小后写入 COS,返回逻辑 object key。 + /// + private async Task<(string Url, string FileName, string? ContentType, long Size)> SaveBytesAsync( + byte[] bytes, + string fileName, + string? contentType, + int orderId, + string fileType, + CancellationToken cancellationToken = default) + { + if (bytes == null || bytes.Length <= 0) + { + throw new BusinessException("上传文件不能为空"); + } + + var ext = Path.GetExtension(fileName); + if (string.IsNullOrWhiteSpace(ext)) + { + ext = ".bin"; + } + + var isLabelType = fileType is OrderFileTypeCodes.SystemLabel + or OrderFileTypeCodes.DownstreamLabel + or OrderFileTypeCodes.LastMileLabel; + var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage + or OrderFileTypeCodes.TradeProof + or OrderFileTypeCodes.Pod; + + if (isLabelType) + { + if (!LabelExt.Contains(ext)) + { + throw new BusinessException("面单仅支持 pdf / jpg / png"); + } + + if (string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && bytes.Length > MaxPdfBytes) + { + throw new BusinessException("PDF 不能超过 10MB"); + } + + if (!string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && bytes.Length > MaxImageBytes) + { + throw new BusinessException("图片不能超过 5MB"); + } + } + else if (isInfraredOrProof) + { + if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("交易证明/红外图片/POD 仅支持图片或 pdf"); + } + + if (bytes.Length > MaxPdfBytes) + { + throw new BusinessException("文件不能超过 10MB"); + } + } + + var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; + 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, Path.GetFileName(fileName), contentType, bytes.Length); + } + public async Task> UploadAsync( int orderId, string fileType, @@ -469,54 +684,15 @@ public sealed class OrderFileManager( throw new BusinessException("上传文件不能为空"); } - var ext = Path.GetExtension(file.FileName); - if (string.IsNullOrWhiteSpace(ext)) - { - ext = ".bin"; - } - - var isLabelType = fileType is OrderFileTypeCodes.SystemLabel - or OrderFileTypeCodes.DownstreamLabel - or OrderFileTypeCodes.LastMileLabel; - var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage - or OrderFileTypeCodes.TradeProof - or OrderFileTypeCodes.Pod; - - if (isLabelType) - { - if (!LabelExt.Contains(ext)) - { - throw new BusinessException("面单仅支持 pdf / jpg / png"); - } - - if (string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && file.Length > MaxPdfBytes) - { - throw new BusinessException("PDF 不能超过 10MB"); - } - - if (!string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && file.Length > MaxImageBytes) - { - throw new BusinessException("图片不能超过 5MB"); - } - } - else if (isInfraredOrProof) - { - if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase)) - { - throw new BusinessException("交易证明/红外图片/POD 仅支持图片或 pdf"); - } - - if (file.Length > MaxPdfBytes) - { - throw new BusinessException("文件不能超过 10MB"); - } - } - - var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; - var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}"; await using var stream = file.OpenReadStream(); - var key = await fileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType); - return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length); + await using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + return await SaveBytesAsync( + ms.ToArray(), + file.FileName, + file.ContentType, + orderId, + fileType); } private OrderFileItemOutput MapItem(OrderFile x) => diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs index 46a3439..15d945c 100644 --- a/src/Oms.Application/Services/Rbac/OmsUserService.cs +++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs @@ -1,5 +1,7 @@ +using System.Security.Claims; using Fake.Application; using Fake.Domain.Exceptions; +using Fake.ObjectStorage; using Fake.Rbac.Application.Dtos.Common; using Fake.Rbac.Application.Dtos.Role; using Fake.Rbac.Application.Dtos.User; @@ -8,7 +10,6 @@ using Fake.Rbac.Domain.Managers; using Fake.Rbac.Infrastructure; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Fake.ObjectStorage; using Oms.Application.Dtos.Rbac; using Oms.Application.Rbac; using Oms.Application.Shared; @@ -157,6 +158,7 @@ public class OmsUserService( WechatId = input.WechatId, WechatQrCode = input.WechatQrCode, WecomKfUrl = input.WecomKfUrl, + IsEnabled = input.IsEnabled ?? true, }, cancellationToken); @@ -242,6 +244,12 @@ public class OmsUserService( }, cancellationToken); + if (input.IsEnabled.HasValue) + { + EnsureCanChangeEnabled(id, input.IsEnabled.Value); + await profileRepository.SetEnabledAsync(id, input.IsEnabled.Value, cancellationToken); + } + var profile = await profileRepository.GetAsync(id, cancellationToken); return MapUserDetail(user, profile); } @@ -251,6 +259,29 @@ public class OmsUserService( } } + /// + /// 启用或禁用用户。禁用后无法登录后台。 + /// + [HttpPost("/rbac/user/set-enabled")] + public async Task SetEnabledAsync( + [FromQuery] Guid id, + [FromBody] SetUserEnabledInput input, + CancellationToken cancellationToken = default) + { + if (id == Guid.Empty) + { + throw new BusinessException("用户 Id 无效"); + } + + if (input == null) + { + throw new BusinessException("请求体不能为空"); + } + + EnsureCanChangeEnabled(id, input.IsEnabled); + await profileRepository.SetEnabledAsync(id, input.IsEnabled, cancellationToken); + } + /// /// 修改用户密码。 /// @@ -391,4 +422,18 @@ public class OmsUserService( detail.Avatar = fileStorage.GetPublicUrl(detail.Avatar); return detail; } + + private void EnsureCanChangeEnabled(Guid targetUserId, bool isEnabled) + { + if (isEnabled) + { + return; + } + + var currentIdText = CurrentUser.FindClaimOrNull(ClaimTypes.NameIdentifier)?.Value; + if (Guid.TryParse(currentIdText, out var currentId) && currentId == targetUserId) + { + throw new BusinessException("不能禁用当前登录账号"); + } + } } diff --git a/src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs b/src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs new file mode 100644 index 0000000..b372aa8 --- /dev/null +++ b/src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs @@ -0,0 +1,34 @@ +using Fake.Application; + +namespace Oms.Application.Shared; + +/// +/// 开放接口件重尺单位换算为系统内部单位(kg / cm)。 +/// +public static class OpenApiMeasureUnitConverter +{ + public static decimal ToKilograms(decimal weight, string? weightUnit) + { + var unit = string.IsNullOrWhiteSpace(weightUnit) ? "KG" : weightUnit.Trim().ToUpperInvariant(); + return unit switch + { + "KG" or "KGS" or "KILOGRAM" or "KILOGRAMS" => weight, + "G" or "GRAM" or "GRAMS" => weight / 1000m, + "LB" or "LBS" or "POUND" or "POUNDS" => weight * 0.45359237m, + _ => throw new BusinessException($"不支持的重量单位:{weightUnit}") + }; + } + + public static decimal ToCentimeters(decimal length, string? lengthUnit) + { + var unit = string.IsNullOrWhiteSpace(lengthUnit) ? "CM" : lengthUnit.Trim().ToUpperInvariant(); + return unit switch + { + "CM" or "CMS" or "CENTIMETER" or "CENTIMETERS" => length, + "MM" or "MILLIMETER" or "MILLIMETERS" => length / 10m, + "M" or "METER" or "METERS" => length * 100m, + "IN" or "INCH" or "INCHES" => length * 2.54m, + _ => throw new BusinessException($"不支持的长度单位:{lengthUnit}") + }; + } +} diff --git a/src/Oms.Application/Shared/OpenApiTokenIssuer.cs b/src/Oms.Application/Shared/OpenApiTokenIssuer.cs new file mode 100644 index 0000000..b1e2cba --- /dev/null +++ b/src/Oms.Application/Shared/OpenApiTokenIssuer.cs @@ -0,0 +1,47 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Fake.AspNetCore.Authentication; +using Fake.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Oms.Application.Shared; + +/// +/// 仓配开放接口 JWT 签发(固定 48 小时有效期,签名密钥与门户共用)。 +/// +public class OpenApiTokenIssuer(IOptions jwtOptions) : ITransientDependency +{ + public static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(48); + + public const string TokenKindClaim = "token_kind"; + public const string TokenKindOpenApi = "openapi"; + + public string Issue(IEnumerable claims) + { + var options = jwtOptions.Value; + if (string.IsNullOrWhiteSpace(options.SecretKey)) + { + throw new InvalidOperationException("Jwt SecretKey 未配置"); + } + + var list = claims + .Where(x => !string.Equals(x.Type, TokenKindClaim, StringComparison.OrdinalIgnoreCase)) + .Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer)) + .ToList(); + list.Add(new Claim(TokenKindClaim, TokenKindOpenApi)); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SecretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var now = DateTime.UtcNow; + var token = new JwtSecurityToken( + issuer: options.Issuer, + audience: options.Audience, + claims: list, + notBefore: now, + expires: now.Add(TokenLifetime), + signingCredentials: credentials); + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 1069f3b..449f59f 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -433,6 +433,8 @@ public partial class AuthService( private async Task LoginAdminAsync(string account, string password, CancellationToken cancellationToken) { var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken); + var profileRepository = LazyServiceProvider.GetRequiredService(); + await profileRepository.EnsureEnabledOrThrowAsync(user.Id, cancellationToken); return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken); } -- 2.45.2 From b7be74204674e1ec12bc74c93fc126d807d16a9c Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 14 Oct 2026 15:39:31 +0800 Subject: [PATCH 08/16] =?UTF-8?q?feat:=20=E5=BA=97=E9=93=BA=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E6=94=AF=E6=8C=81=E4=BB=93=E5=82=A8=E4=BB=A3=E5=8F=91?= =?UTF-8?q?(wms)=E5=BB=BA=E5=8D=95=EF=BC=8C=E6=94=BE=E5=AE=BD=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E7=8A=B6=E6=80=81=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- sql/migrate_sales_product_group_wms.sql | 24 ++++++ src/Oms.Application/DataPerm/DataPermStore.cs | 28 ++++--- .../Dtos/PlatformStore/PlatformStoreDtos.cs | 8 +- .../Rbac/UserProfileRepository.cs | 22 ++--- .../Services/Customers/CustomerService.cs | 16 ++-- .../OpenApi/WarehouseOpenApiService.cs | 10 +-- .../Orders/ParcelBillingCalculator.cs | 4 +- .../Orders/PlatformStoreOrderService.cs | 79 ++++++++++++++--- .../Services/System/NoticeService.cs | 2 +- .../Shared/OmsRbacRegistrationGuard.cs | 84 ++++++++++--------- .../Consts/SalesProductGroupCodes.cs | 10 +++ .../Consts/OrderFileTypeCodes.cs | 2 +- .../Customers/CustomerRepository.cs | 2 +- src/SimpleAdmin.Api/Auth/AuthService.cs | 8 +- .../WarehouseOpenApiAuthHeaderMiddleware.cs | 36 ++++++++ src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 3 + src/SimpleAdmin.Api/SimpleJwtService.cs | 4 +- .../WeChat/WeChatCustomerService.cs | 4 +- 18 files changed, 245 insertions(+), 101 deletions(-) create mode 100644 sql/migrate_sales_product_group_wms.sql create mode 100644 src/Oms.Domain/ChannelsAggregate/Consts/SalesProductGroupCodes.cs create mode 100644 src/SimpleAdmin.Api/Auth/WarehouseOpenApiAuthHeaderMiddleware.cs diff --git a/sql/migrate_sales_product_group_wms.sql b/sql/migrate_sales_product_group_wms.sql new file mode 100644 index 0000000..08470da --- /dev/null +++ b/sql/migrate_sales_product_group_wms.sql @@ -0,0 +1,24 @@ +-- 销售产品分组字典:仓储代发(wms)。可重复执行。 +-- 用于店铺订单「创建代发订单」选品过滤;销售产品 ProductGroup 填 wms。 + +SET @db := DATABASE(); + +-- 确保字典类型存在 +INSERT INTO `t_dict_type` (`DictType`, `Remark`, `TypeSort`, `IsDeleted`, `CreateUserId`) +SELECT 'sales_product_group', '销售产品分组', 0, 0, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_dict_type` + WHERE `DictType` = 'sales_product_group' AND `IsDeleted` = 0 +); + +-- 写入 wms 字典项 +INSERT INTO `t_dict` (`DictType`, `DictSort`, `DictCode`, `DictName`, `IsDeleted`, `CreateUserId`) +SELECT 'sales_product_group', 90, 'wms', '仓储代发', 0, 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `t_dict` + WHERE `DictType` = 'sales_product_group' + AND `DictCode` = 'wms' + AND `IsDeleted` = 0 +); diff --git a/src/Oms.Application/DataPerm/DataPermStore.cs b/src/Oms.Application/DataPerm/DataPermStore.cs index 1022508..fce007d 100644 --- a/src/Oms.Application/DataPerm/DataPermStore.cs +++ b/src/Oms.Application/DataPerm/DataPermStore.cs @@ -16,12 +16,12 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac PropertyNameCaseInsensitive = true }; - // EF Core 原始 SQL 参数不接受 DBNull,空值直接传 null - private static object? SqlParam(object? value) => value; + // EF Core 原始 SQL 参数不接受 DBNull;object[] 元素类型为 object,运行时仍可传 null + private static object SqlParam(object? value) => value ?? null!; public async Task GetUserDataPermAsync(Guid userId, CancellationToken cancellationToken = default) { - var user = await dbContext.Database.SqlQueryRaw($@" + var userSql = $@" SELECT u.`Id` AS `UserId`, u.`Name` AS `UserName`, @@ -31,11 +31,13 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 LIMIT 1 - ", userId.ToString()).FirstOrDefaultAsync(cancellationToken) + "; + var user = await dbContext.Database.SqlQueryRaw(userSql, userId.ToString()) + .FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("用户不存在"); var now = DateTime.UtcNow; - var orgRows = await dbContext.Database.SqlQueryRaw($@" + var orgSql = $@" SELECT p.`Id` AS `Id`, p.`OrganizationId` AS `OrganizationId`, @@ -47,7 +49,9 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac FROM `user_org_perm` p LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 WHERE p.`IsDeleted` = 0 AND p.`UserId` = {{0}} - ", userId.ToString()).ToListAsync(cancellationToken); + "; + var orgRows = await dbContext.Database.SqlQueryRaw(orgSql, userId.ToString()) + .ToListAsync(cancellationToken); var scopeRows = await dbContext.Database.SqlQueryRaw(""" SELECT p.`Id` AS `Id`, p.`DataScope` AS `DataScope`, p.`DataResourceCode` AS `DataResourceCode`, p.`ExpireTime` AS `ExpireTime` @@ -128,20 +132,24 @@ public class DataPermStore(FakeRbacDbContext dbContext, DataPermContextCache cac public async Task GetRoleDataPermAsync(Guid roleId, CancellationToken cancellationToken = default) { - var role = await dbContext.Database.SqlQueryRaw($@" + var roleSql = $@" SELECT r.`Id` AS `RoleId`, r.`Name` AS `RoleName` FROM `{FakeTables.Role}` r WHERE r.`Id` = {{0}} AND r.`IsDeleted` = 0 LIMIT 1 - ", roleId.ToString()).FirstOrDefaultAsync(cancellationToken) + "; + var role = await dbContext.Database.SqlQueryRaw(roleSql, roleId.ToString()) + .FirstOrDefaultAsync(cancellationToken) ?? throw new BusinessException("角色不存在"); - var orgRows = await dbContext.Database.SqlQueryRaw($@" + var orgSql = $@" SELECT p.`Id` AS `Id`, p.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName`, p.`OrgScope` AS `OrgScope` FROM `role_org_perm` p LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = p.`OrganizationId` AND o.`IsDeleted` = 0 WHERE p.`IsDeleted` = 0 AND p.`RoleId` = {{0}} - ", roleId.ToString()).ToListAsync(cancellationToken); + "; + var orgRows = await dbContext.Database.SqlQueryRaw(orgSql, roleId.ToString()) + .ToListAsync(cancellationToken); var scopeRows = await dbContext.Database.SqlQueryRaw(""" SELECT p.`Id` AS `Id`, p.`DataScope` AS `DataScope`, p.`DataResourceCode` AS `DataResourceCode` diff --git a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs index cdab609..92ab5b9 100644 --- a/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs +++ b/src/Oms.Application/Dtos/PlatformStore/PlatformStoreDtos.cs @@ -11,6 +11,9 @@ public class PlatformStorePageInput public int? CustomerId { get; set; } + /// + /// 平台编码:tiktok / temu / shopee / lazada;空或 all 表示全部。 + /// public string? PlatformCode { get; set; } public string? StoreNickname { get; set; } @@ -147,11 +150,6 @@ public class PlatformStoreOrderListInput : PlatformStorePageInput { public int? StoreId { get; set; } - /// - /// 平台编码:tiktok / temu / shopee / lazada;空或 all 表示全部。 - /// - public string? PlatformCode { get; set; } - /// /// 履约状态:pending / toShip / shipped / canceled / suspended / completed;空或 all 表示全部。 /// diff --git a/src/Oms.Application/Rbac/UserProfileRepository.cs b/src/Oms.Application/Rbac/UserProfileRepository.cs index 817d8b1..a02fa3f 100644 --- a/src/Oms.Application/Rbac/UserProfileRepository.cs +++ b/src/Oms.Application/Rbac/UserProfileRepository.cs @@ -104,12 +104,12 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende sql, [ userId.ToString(), - // EF Core 参数不接受 DBNull,空值直接传 null 即可映射为 SQL NULL - (object?)NormalizeOptional(profile.Phone), - (object?)NormalizeOptional(profile.EmployeeNo), - (object?)NormalizeOptional(profile.WechatId), - (object?)NormalizeOptional(profile.WechatQrCode), - (object?)NormalizeOptional(profile.WecomKfUrl), + // EF Core 参数不接受 DBNull;object[] 元素类型为 object,运行时仍可传 null + SqlParam(NormalizeOptional(profile.Phone)), + SqlParam(NormalizeOptional(profile.EmployeeNo)), + SqlParam(NormalizeOptional(profile.WechatId)), + SqlParam(NormalizeOptional(profile.WechatQrCode)), + SqlParam(NormalizeOptional(profile.WecomKfUrl)), profile.IsEnabled ? 1 : 0, ], cancellationToken); @@ -175,10 +175,10 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende sql, [ userId.ToString(), - (object?)NormalizeOptional(profile.Phone), - (object?)NormalizeOptional(profile.WechatId), - (object?)NormalizeOptional(profile.WechatQrCode), - (object?)NormalizeOptional(profile.WecomKfUrl), + SqlParam(NormalizeOptional(profile.Phone)), + SqlParam(NormalizeOptional(profile.WechatId)), + SqlParam(NormalizeOptional(profile.WechatQrCode)), + SqlParam(NormalizeOptional(profile.WecomKfUrl)), ], cancellationToken); } @@ -255,6 +255,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende return value.Trim(); } + private static object SqlParam(object? value) => value ?? null!; + private sealed class UserProfileRow { public string? Phone { get; set; } diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs index e58bc83..32cd9e8 100644 --- a/src/Oms.Application/Services/Customers/CustomerService.cs +++ b/src/Oms.Application/Services/Customers/CustomerService.cs @@ -204,9 +204,9 @@ public class CustomerService : ApplicationService // 同步 RBAC 登录邮箱,覆盖注册时的 @noemail.local 占位 try { - await RbacDbContext.Database.ExecuteSqlRawAsync( - $"UPDATE `{FakeTables.User}` SET `Email` = {{0}} WHERE `userid` = {{1}} AND `IsDeleted` = 0", - email, customer.UserId); + var syncEmailSql = + $"UPDATE `{FakeTables.User}` SET `Email` = {{0}} WHERE `userid` = {{1}} AND `IsDeleted` = 0"; + await RbacDbContext.Database.ExecuteSqlRawAsync(syncEmailSql, email, customer.UserId); } catch { @@ -392,8 +392,8 @@ public class CustomerService : ApplicationService private async Task GetRbacUserGuidByMapIdAsync(int mapId) { - return await RbacDbContext.Database.SqlQueryRaw( - $"select `Id` as `Value` from `{FakeTables.User}` where `userid` = {{0}} and `IsDeleted` = 0", mapId) + var sql = $"select `Id` as `Value` from `{FakeTables.User}` where `userid` = {{0}} and `IsDeleted` = 0"; + return await RbacDbContext.Database.SqlQueryRaw(sql, mapId) .FirstOrDefaultAsync(); } @@ -442,7 +442,7 @@ public class CustomerService : ApplicationService (string.IsNullOrWhiteSpace(keyword) || customer.CustomerCode.Contains(keyword) || customer.CustomerName.Contains(keyword) || - customer.Phone.Contains(keyword) || + (customer.Phone ?? string.Empty).Contains(keyword) || (customer.Email ?? string.Empty).Contains(keyword)) && (!hasStatusFilter || customer.Status == statusValue) && (!hasCustomerGroupFilter || customer.CustomerGroup == customerGroupValue) && @@ -497,8 +497,8 @@ public class CustomerService : ApplicationService private async Task GetRbacUserMapIdOrThrowAsync(Guid id) { - var userId = await RbacDbContext.Database.SqlQueryRaw( - $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}", id) + var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}"; + var userId = await RbacDbContext.Database.SqlQueryRaw(sql, id) .FirstOrDefaultAsync(); if (!userId.HasValue || userId.Value <= 0) { diff --git a/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs b/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs index 35a81be..3788f08 100644 --- a/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs +++ b/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs @@ -17,7 +17,7 @@ namespace Oms.Application.Services.OpenApi; /// /// 仓配对外开放 API(Swagger 文档分组:warehouseOpenApi)。 -/// 鉴权:Authorization: Bearer {token}(账号密码换取,有效期 48 小时)。 +/// 鉴权:Authorization: {token}(账号密码换取,有效期 48 小时;无需 Bearer 前缀)。 /// 与客户 HMAC 分组 openApi 相互独立。 /// [ApiExplorerSettings(GroupName = "warehouseOpenApi")] @@ -45,7 +45,7 @@ public class WarehouseOpenApiService : ApplicationService LazyServiceProvider.GetRequiredService(); /// - /// 获取 Token(有效期 48 小时)。后续请求 Header:Authorization: Bearer {token}。 + /// 获取 Token(有效期 48 小时)。后续请求 Header:Authorization: {token}(无需 Bearer)。 /// [AllowAnonymous] [HttpPost("/openapi/warehouse/token")] @@ -75,7 +75,7 @@ public class WarehouseOpenApiService : ApplicationService /// /// 入库上传件重尺。与操作台「入库扫描」页「入库」按钮同源(POST /operation/inbound/create)。 /// 写入收货件重尺、入库记录,并触发入库计费/自动触发。重量默认 kg,长度默认 cm。 - /// Header:Authorization: Bearer {token}。 + /// Header:Authorization: {token}(无需 Bearer)。 /// [Authorize] [HttpPost("/openapi/warehouse/inbound")] @@ -86,7 +86,7 @@ public class WarehouseOpenApiService : ApplicationService /// /// 上传件重尺。mode:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。 - /// Header:Authorization: Bearer {token}。 + /// Header:Authorization: {token}(无需 Bearer)。 /// [Authorize] [HttpPost("/openapi/warehouse/weight/upload")] @@ -164,7 +164,7 @@ public class WarehouseOpenApiService : ApplicationService /// /// 上传红外图片。Base64 解码后转存 COS,写入订单附件 fileType=infraredImage(Source=upload), /// 与订单详情「附件」Tab →「红外图片」→「点击上传」(POST /order/files/upload)同源展示。 - /// 成功无响应体。Header:Authorization: Bearer {token}。 + /// 成功无响应体。Header:Authorization: {token}(无需 Bearer)。 /// [Authorize] [HttpPost("/openapi/warehouse/image/upload")] diff --git a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs index e73d78d..1f836a7 100644 --- a/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs +++ b/src/Oms.Application/Services/Orders/ParcelBillingCalculator.cs @@ -525,12 +525,12 @@ internal static class ParcelBillingCalculator priceSnapshot.Prices.Select(p => (p.Status, p.Details))); private static IReadOnlyList CollectDistinctDetailZoneKeys( - IEnumerable<(byte Status, List? Details)> prices) + IEnumerable<(byte Status, List Details)> prices) { var zones = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var price in prices.Where(x => EnableStatusRules.IsEnabled(x.Status))) { - foreach (var detail in price.Details ?? []) + foreach (var detail in price.Details) { var factors = ParseFactorDictionary(detail.FactorValuesJson); if (TryGetZoneFactorText(factors, out var zone)) diff --git a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs index d812708..e949a82 100644 --- a/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs +++ b/src/Oms.Application/Services/Orders/PlatformStoreOrderService.cs @@ -5,6 +5,7 @@ using Oms.Application.Dtos.Orders; using Oms.Application.Dtos.PlatformStore; using Oms.Application.Dtos.Shared; using Oms.Application.Shared; +using Oms.Domain.ChannelsAggregate.Consts; using Oms.Domain.ChannelsAggregate.Repositories; using Oms.Domain.CustomersAggregate.Repositories; using Oms.Domain.OrdersAggregate.Consts; @@ -409,7 +410,8 @@ public class PlatformStoreOrderService : ApplicationService input.ProductCode, input.WarehouseId, input.Items, - requireBind: false); + requireBind: false, + requireWmsProductGroup: false); } /// @@ -429,7 +431,30 @@ public class PlatformStoreOrderService : ApplicationService input.ProductCode, input.WarehouseId, input.Items, - requireBind: true); + requireBind: true, + requireWmsProductGroup: false); + } + + /// + /// 批量创建仓储代发订单:与代发运单同链路(建单+绑定+产品计费), + /// 但产品必须属于分组 wms(仓储代发)。 + /// + [HttpPost("/order/platform-store-order/batch-create-fulfill-wms")] + public async Task BatchCreateFulfillWmsAsync( + PlatformStoreOrderBatchCreateFulfillInput input) + { + if (input.WarehouseId <= 0) + { + throw new BusinessException("请指定发货仓库"); + } + + return await BatchCreateLogisticsInternalAsync( + input.CustomerId, + input.ProductCode, + input.WarehouseId, + input.Items, + requireBind: true, + requireWmsProductGroup: true); } private async Task BatchCreateLogisticsInternalAsync( @@ -437,7 +462,8 @@ public class PlatformStoreOrderService : ApplicationService string? productCode, int? warehouseId, List items, - bool requireBind) + bool requireBind, + bool requireWmsProductGroup = false) { if (items == null || items.Count == 0) { @@ -451,14 +477,14 @@ public class PlatformStoreOrderService : ApplicationService if (string.IsNullOrWhiteSpace(productCode)) { - throw new BusinessException("请选择运输方式"); + throw new BusinessException(requireWmsProductGroup ? "请选择仓储代发产品" : "请选择运输方式"); } var customerId = await WarehouseCustomerHelper.ResolveRequiredCustomerIdAsync( CustomerRepository, CurrentUser, customerIdInput); - var resolvedProductCode = await ResolveProductCodeRequiredAsync(productCode); + var resolvedProductCode = await ResolveProductCodeRequiredAsync(productCode, requireWmsProductGroup); var batchNo = $"PSL{DateTime.Now:yyyyMMddHHmmss}"; var output = new PlatformStoreOrderBatchCreateLogisticsOutput { BatchNo = batchNo }; @@ -488,9 +514,11 @@ public class PlatformStoreOrderService : ApplicationService itemInput.Receiver, requireBind ? itemInput.BindItems : null); item.Success = true; - item.Message = requireBind - ? $"已创建物流单 {result.ServiceNo} 并绑定" - : $"已创建物流单 {result.ServiceNo}"; + item.Message = requireWmsProductGroup + ? $"已创建代发订单 {result.ServiceNo} 并绑定" + : requireBind + ? $"已创建物流单 {result.ServiceNo} 并绑定" + : $"已创建物流单 {result.ServiceNo}"; item.LinkedOrderId = result.OrderId; item.ServiceNo = result.ServiceNo; output.SuccessCount++; @@ -523,7 +551,7 @@ public class PlatformStoreOrderService : ApplicationService List? bindItems) { var order = await GetOrderOrThrowAsync(customerId, storeOrderId); - await EnsureMatchableAsync(order); + await EnsureCanCreateLogisticsAsync(order); var lines = await StoreOrderLineRepository.GetListAsync(x => x.StoreOrderId == order.Id && !x.IsShipExcluded); if (lines.Count == 0) @@ -639,7 +667,9 @@ public class PlatformStoreOrderService : ApplicationService await OrderRepository.UpdateAsync(logisticsOrder); } - private async Task ResolveProductCodeRequiredAsync(string productCode) + private async Task ResolveProductCodeRequiredAsync( + string productCode, + bool requireWmsProductGroup = false) { var normalized = productCode.Trim(); var product = await SalesProductRepository.GetByProductCodeAsync(normalized); @@ -648,6 +678,15 @@ public class PlatformStoreOrderService : ApplicationService throw new BusinessException("销售产品不存在或未启用"); } + if (requireWmsProductGroup + && !string.Equals( + product.ProductGroup?.Trim(), + SalesProductGroupCodes.Wms, + StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("仓储代发订单仅可选择产品分组为「仓储代发(wms)」的销售产品"); + } + return normalized; } @@ -734,6 +773,26 @@ public class PlatformStoreOrderService : ApplicationService } } + /// + /// 创建运单/代发:不限制履约状态;拦截搁置与未删除的已关联物流单。 + /// + private async Task EnsureCanCreateLogisticsAsync(SalesStoreOrder order) + { + if (order.IsSuspended) + { + throw new BusinessException("订单已搁置,不能创建"); + } + + if (order.LinkedOrderId is > 0) + { + var linked = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.LinkedOrderId.Value); + if (linked != null && linked.OrderStatus != OrderStatusCodes.Deleted) + { + throw new BusinessException($"已关联小包订单 {linked.ServiceNo},不能重复创建"); + } + } + } + private async Task EnsureMatchableAsync(SalesStoreOrder order) { if (order.IsSuspended) diff --git a/src/Oms.Application/Services/System/NoticeService.cs b/src/Oms.Application/Services/System/NoticeService.cs index f0620b9..ca82fe3 100644 --- a/src/Oms.Application/Services/System/NoticeService.cs +++ b/src/Oms.Application/Services/System/NoticeService.cs @@ -277,7 +277,7 @@ public class NoticeService : ApplicationService x.ParentCustomerId == null && (x.CustomerName.Contains(pattern) || x.CustomerCode.Contains(pattern) || - x.Phone.Contains(pattern)); + (x.Phone != null && x.Phone.Contains(pattern))); } var total = await CustomerRepository.CountAsync(predicate); diff --git a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs index fa1f7f1..0971ad4 100644 --- a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs +++ b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs @@ -167,14 +167,14 @@ public static class OmsRbacRegistrationGuard return; } - var hit = await rbacDbContext.Database - .SqlQueryRaw( - $@" + var sql = $@" SELECT 1 AS Value FROM `{FakeTables.Organization}` WHERE `Id` = {{0}} AND `IsDeleted` = 0 LIMIT 1 - ", organizationId.Value.ToString()) + "; + var hit = await rbacDbContext.Database + .SqlQueryRaw(sql, organizationId.Value.ToString()) .FirstOrDefaultAsync(cancellationToken); if (!hit.HasValue) @@ -201,14 +201,14 @@ public static class OmsRbacRegistrationGuard foreach (var roleId in ids) { - var hit = await rbacDbContext.Database - .SqlQueryRaw( - $@" + var sql = $@" SELECT 1 AS Value FROM `{FakeTables.Role}` WHERE `Id` = {{0}} AND `IsDeleted` = 0 LIMIT 1 - ", roleId.ToString()) + "; + var hit = await rbacDbContext.Database + .SqlQueryRaw(sql, roleId.ToString()) .FirstOrDefaultAsync(cancellationToken); if (!hit.HasValue) @@ -492,19 +492,20 @@ public static class OmsRbacRegistrationGuard // 唯一索引不按 IsDeleted 区分,软删除账号仍会触发 Duplicate entry。 if (excludeUserId is Guid id && id != Guid.Empty) { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Account` = {{0}} AND `Id` <> {{1}} LIMIT 1"; var hit = await rbacDbContext.Database - .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Account` = {{0}} AND `Id` <> {{1}} LIMIT 1", - account, id.ToString()) + .SqlQueryRaw(sql, account, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } - var any = await rbacDbContext.Database - .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Account` = {{0}} LIMIT 1", account) - .FirstOrDefaultAsync(cancellationToken); - return any.HasValue; + { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Account` = {{0}} LIMIT 1"; + var any = await rbacDbContext.Database + .SqlQueryRaw(sql, account) + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; + } } private static async Task ExistsUserByEmailAsync( @@ -515,19 +516,20 @@ public static class OmsRbacRegistrationGuard { if (excludeUserId is Guid id && id != Guid.Empty) { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Email` = {{0}} AND `Id` <> {{1}} LIMIT 1"; var hit = await rbacDbContext.Database - .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Email` = {{0}} AND `Id` <> {{1}} LIMIT 1", - email, id.ToString()) + .SqlQueryRaw(sql, email, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } - var any = await rbacDbContext.Database - .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Email` = {{0}} LIMIT 1", email) - .FirstOrDefaultAsync(cancellationToken); - return any.HasValue; + { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Email` = {{0}} LIMIT 1"; + var any = await rbacDbContext.Database + .SqlQueryRaw(sql, email) + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; + } } private static async Task ExistsUserByPhoneAsync( @@ -538,19 +540,20 @@ public static class OmsRbacRegistrationGuard { if (excludeUserId is Guid id && id != Guid.Empty) { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Phone` = {{0}} AND `Id` <> {{1}} LIMIT 1"; var hit = await rbacDbContext.Database - .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Phone` = {{0}} AND `Id` <> {{1}} LIMIT 1", - phone, id.ToString()) + .SqlQueryRaw(sql, phone, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } - var any = await rbacDbContext.Database - .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Phone` = {{0}} LIMIT 1", phone) - .FirstOrDefaultAsync(cancellationToken); - return any.HasValue; + { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `Phone` = {{0}} LIMIT 1"; + var any = await rbacDbContext.Database + .SqlQueryRaw(sql, phone) + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; + } } private static async Task ExistsUserByWechatIdAsync( @@ -561,19 +564,20 @@ public static class OmsRbacRegistrationGuard { if (excludeUserId is Guid id && id != Guid.Empty) { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `WechatId` = {{0}} AND `Id` <> {{1}} LIMIT 1"; var hit = await rbacDbContext.Database - .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `WechatId` = {{0}} AND `Id` <> {{1}} LIMIT 1", - wechatId, id.ToString()) + .SqlQueryRaw(sql, wechatId, id.ToString()) .FirstOrDefaultAsync(cancellationToken); return hit.HasValue; } - var any = await rbacDbContext.Database - .SqlQueryRaw( - $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `WechatId` = {{0}} LIMIT 1", wechatId) - .FirstOrDefaultAsync(cancellationToken); - return any.HasValue; + { + var sql = $"SELECT 1 AS Value FROM `{FakeTables.User}` WHERE `WechatId` = {{0}} LIMIT 1"; + var any = await rbacDbContext.Database + .SqlQueryRaw(sql, wechatId) + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; + } } private static string ExtractDeepMessage(Exception ex) diff --git a/src/Oms.Domain/ChannelsAggregate/Consts/SalesProductGroupCodes.cs b/src/Oms.Domain/ChannelsAggregate/Consts/SalesProductGroupCodes.cs new file mode 100644 index 0000000..ff419d1 --- /dev/null +++ b/src/Oms.Domain/ChannelsAggregate/Consts/SalesProductGroupCodes.cs @@ -0,0 +1,10 @@ +namespace Oms.Domain.ChannelsAggregate.Consts; + +/// +/// 销售产品分组编码(字典 sales_product_group.DictCode)。 +/// +public static class SalesProductGroupCodes +{ + /// 仓储代发(贴单出库 / 仓内履约计费)。 + public const string Wms = "wms"; +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs index 986fd4c..9507109 100644 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderFileTypeCodes.cs @@ -43,7 +43,7 @@ public static class OrderFileTypeCodes return false; } - return !string.Equals(fileType.Trim(), DownstreamLabel, StringComparison.OrdinalIgnoreCase); + return !string.Equals(fileType!.Trim(), DownstreamLabel, StringComparison.OrdinalIgnoreCase); } public static string Normalize(string fileType) => fileType.Trim(); diff --git a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs index 6215bd9..34baceb 100644 --- a/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs +++ b/src/Oms.SqlSugarCore/Customers/CustomerRepository.cs @@ -140,7 +140,7 @@ public class CustomerRepository : OmsSqlSugarRepository, ICustomerRepo query = query.Where((g, c) => c.CustomerCode.Contains(kw) || c.CustomerName.Contains(kw) || - c.Phone.Contains(kw)); + (c.Phone != null && c.Phone.Contains(kw))); } var total = await query.CountAsync(cancellationToken); diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 449f59f..5bef285 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -908,15 +908,15 @@ public partial class AuthService( private async Task GetRbacUserGuidByMapIdAsync(int userId, CancellationToken cancellationToken) { - return await rbacDbContext.Database.SqlQueryRaw( - $"select `Id` as `Value` from `{FakeTables.User}` where `IsDeleted` = 0 and `userid` = {{0}}", userId) + var sql = $"select `Id` as `Value` from `{FakeTables.User}` where `IsDeleted` = 0 and `userid` = {{0}}"; + return await rbacDbContext.Database.SqlQueryRaw(sql, userId) .FirstOrDefaultAsync(cancellationToken); } private async Task GetMapIdByUserGuidAsync(Guid userId, CancellationToken cancellationToken) { - return await rbacDbContext.Database.SqlQueryRaw( - $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}", userId) + var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}"; + return await rbacDbContext.Database.SqlQueryRaw(sql, userId) .FirstOrDefaultAsync(cancellationToken); } diff --git a/src/SimpleAdmin.Api/Auth/WarehouseOpenApiAuthHeaderMiddleware.cs b/src/SimpleAdmin.Api/Auth/WarehouseOpenApiAuthHeaderMiddleware.cs new file mode 100644 index 0000000..f418a24 --- /dev/null +++ b/src/SimpleAdmin.Api/Auth/WarehouseOpenApiAuthHeaderMiddleware.cs @@ -0,0 +1,36 @@ +namespace SimpleAdmin.Api.Auth; + +/// +/// 仓配开放 API:Authorization 直接传 JWT(不要求 Bearer 前缀)。 +/// 在进入 JwtBearer 前补上 Bearer,以便复用现有鉴权。 +/// +public sealed class WarehouseOpenApiAuthHeaderMiddleware(RequestDelegate next) +{ + public Task InvokeAsync(HttpContext context) + { + if (context.Request.Path.StartsWithSegments("/openapi/warehouse")) + { + NormalizeAuthorizationHeader(context.Request.Headers); + } + + return next(context); + } + + private static void NormalizeAuthorizationHeader(IHeaderDictionary headers) + { + var auth = headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(auth)) + { + return; + } + + auth = auth.Trim(); + if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // 无 scheme:Authorization: {token} + headers.Authorization = "Bearer " + auth; + } +} diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index d55ca7b..170067d 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -201,6 +201,9 @@ public class SimpleAdminApiModule : FakeModule await next(); }); + // 仓配开放 API:Authorization 直接传 token(可不带 Bearer) + app.UseMiddleware(); + // 认证和授权 app.UseAuthentication(); app.UseAuthorization(); diff --git a/src/SimpleAdmin.Api/SimpleJwtService.cs b/src/SimpleAdmin.Api/SimpleJwtService.cs index f2aad1a..09a7ad9 100644 --- a/src/SimpleAdmin.Api/SimpleJwtService.cs +++ b/src/SimpleAdmin.Api/SimpleJwtService.cs @@ -22,8 +22,8 @@ public class SimpleJwtService(IOptions jwtOptions, UserService u CancellationToken cancellationToken) { var claims = await base.GenerateClaimsByUserIdAsync(userId, cancellationToken); - var mapId = await rbacDbContext.Database.SqlQueryRaw( - $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}", userId) + var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}"; + var mapId = await rbacDbContext.Database.SqlQueryRaw(sql, userId) .FirstOrDefaultAsync(cancellationToken); if (mapId.HasValue) { diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs index 4911344..e5c6ec2 100644 --- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs +++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs @@ -325,8 +325,8 @@ public class WeChatCustomerService( private async Task GetRbacUserMapIdOrThrowAsync(Guid id, CancellationToken cancellationToken) { - var userId = await rbacDbContext.Database.SqlQueryRaw( - $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}", id) + var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}"; + var userId = await rbacDbContext.Database.SqlQueryRaw(sql, id) .FirstOrDefaultAsync(cancellationToken); if (!userId.HasValue || userId.Value <= 0) { -- 2.45.2 From 38d36eb812f59196ff920c812cc1c8e269c0366a Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Wed, 14 Oct 2026 16:40:31 +0800 Subject: [PATCH 09/16] =?UTF-8?q?=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Build.props | 2 +- src/SimpleAdmin.Api/SimpleAdminApiModule.cs | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index a1f4d20..4676e0a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,6 +4,6 @@ - 8.0.5.24 + 8.0.5.26 diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs index d55ca7b..d995727 100644 --- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs +++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs @@ -184,12 +184,9 @@ public class SimpleAdminApiModule : FakeModule // 静态文件服务(用于头像等上传文件) app.UseStaticFiles(); - // 路由 - 必须在认证和授权之前 + // 路由 - 必须在认证和授权之前;且必须在 UsePathBase 之后 app.UseRouting(); - // EventBus Dashboard:必须在 UsePathBase 之后,否则 /admin/eventbus 会 404 - app.UseFakeEventBusDashboard(); - // OpenAPI 验签需读取原始 Body,允许二次读流 app.Use(async (ctx, next) => { @@ -208,9 +205,11 @@ public class SimpleAdminApiModule : FakeModule // 角色/菜单写操作后失效登录用户信息缓存 app.UseMiddleware(); - // 端点映射 + // 端点映射(Dashboard 用 MapGroup,依赖 PathBase 已剥离前缀) app.MapControllers(); - + // 必须在 UsePathBase 之后:完整对外路径为 /admin/eventbus + app.UseFakeEventBusDashboard(); + app.Services.GetRequiredService().Send("Application started"); } } -- 2.45.2 From 7b69bbf207f73554f1eb6c4596cf49b8d5483728 Mon Sep 17 00:00:00 2001 From: xiaolipro <2357729423@qq.com> Date: Wed, 14 Oct 2026 17:07:35 +0800 Subject: [PATCH 10/16] test --- Directory.Build.props | 2 +- .../OrderLifecycleTrackAppendEventHandler.cs | 7 +- ...rderParcelCustoms9610UpsertEventHandler.cs | 7 +- .../OrderParcelTrackUpsertEventHandler.cs | 4 +- .../Orders/OrderService.ParcelBilling.cs | 87 ++++++++++++++++--- .../Services/Orders/OrderService.cs | 33 ++----- 6 files changed, 93 insertions(+), 47 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 4676e0a..b45eadd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,6 +4,6 @@ - 8.0.5.26 + 8.0.5.27 diff --git a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs index f963a48..ddc7e64 100644 --- a/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs +++ b/src/Oms.Application/Messaging/OrderSideEffects/OrderLifecycleTrackAppendEventHandler.cs @@ -1,7 +1,5 @@ using Fake.DependencyInjection; using Fake.EventBus; -using Fake.UnitOfWork; -using Oms.Application.Messaging; using Microsoft.Extensions.Logging; using Oms.Application.Integrations.SmallBagTrack; @@ -9,7 +7,6 @@ namespace Oms.Application.Messaging.OrderSideEffects; public sealed class OrderLifecycleTrackAppendEventHandler( ISmallBagTrackOrderClient trackClient, - IUnitOfWorkManager unitOfWorkManager, ILogger logger) : IEventHandler, ITransientDependency { @@ -25,7 +22,7 @@ public sealed class OrderLifecycleTrackAppendEventHandler( return; } - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); + // 纯外部 HTTP,无需事务;占 TX 只会拖长连接占用。 try { await trackClient.AppendLifecycleTrackAsync( @@ -44,7 +41,5 @@ public sealed class OrderLifecycleTrackAppendEventHandler( @event.OrderNo, @event.TrackCode); } - - await unitOfWork.CompleteAsync(cancellationToken); } } diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs index 5ce44e7..b188506 100644 --- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelCustoms9610UpsertEventHandler.cs @@ -4,6 +4,7 @@ using Fake.UnitOfWork; using Oms.Application.Messaging; using Microsoft.Extensions.Logging; using Oms.Application.Services.Orders; +using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Repositories; namespace Oms.Application.Messaging.ParcelCreate; @@ -24,9 +25,11 @@ public sealed class OrderParcelCustoms9610UpsertEventHandler( { using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == @event.OrderId, cancellationToken); - if (order == null) + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { - logger.LogWarning("9610 upsert skipped, order missing OrderId={OrderId}", @event.OrderId); + logger.LogWarning("9610 upsert skipped, order missing/deleted OrderId={OrderId}", @event.OrderId); + await unitOfWork.CompleteAsync(cancellationToken); return; } diff --git a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs index 0fb3f89..c2572b2 100644 --- a/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs +++ b/src/Oms.Application/Messaging/ParcelCreate/OrderParcelTrackUpsertEventHandler.cs @@ -2,7 +2,6 @@ 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; @@ -25,7 +24,8 @@ public sealed class OrderParcelTrackUpsertEventHandler( OrderParcelTrackUpsertIntegrationEvent @event, CancellationToken cancellationToken) { - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); + // 仅读库 + 外部 HTTP,不要占事务连接;失败抛出让总线重试。 + using var unitOfWork = unitOfWorkManager.Begin(new UnitOfWorkAttribute { IsTransactional = false }); var request = await ResolveRequestAsync(@event, cancellationToken); request.AppendInitialTrackOnSuccess = @event.AppendInitialTrackOnSuccess; diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 9d3b15a..1085937 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using Fake.Application; +using Fake.UnitOfWork; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -76,6 +77,41 @@ public partial class OrderService public string? InboundMethod { get; init; } } + private static ParcelCreateContext? BuildParcelCreateContext(OrderParcelCreateInput input) + { + var inbound = string.IsNullOrWhiteSpace(input.InboundMethod) + ? null + : input.InboundMethod.Trim(); + if (inbound == null) + { + return null; + } + + var isDoorPickup = string.Equals( + inbound, + OrderInboundMethodCodes.DoorPickup, + StringComparison.OrdinalIgnoreCase); + return new ParcelCreateContext + { + InboundMethod = inbound, + PickupTime = isDoorPickup ? input.ExpectPickupTime : null, + CustomerSenderAddressId = isDoorPickup ? input.SenderAddressId : null + }; + } + + /// + /// 业务写库前先打开 Outbox DbContext,让 EF 先占环境事务连接, + /// 避免 SqlSugar 先开事务后 Outbox UseTransaction 失败而落入「短事务提前 Commit」。 + /// + private async Task EnsureOutboxReadyAsync() + { + var uowManager = LazyServiceProvider.GetRequiredService(); + if (OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager)) + { + await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(uowManager); + } + } + private async Task CreateParcelCoreAsync( OrderParcelCreateInput input, int? operatorUserId = null, @@ -92,6 +128,7 @@ public partial class OrderService var bindTime = DateTime.Now; NormalizeParcelCreateInput(input); + createContext ??= BuildParcelCreateContext(input); var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId); input.CustomerId = sessionCustomer.Id; @@ -130,6 +167,9 @@ public partial class OrderService Order? created = null; try { + // 必须在任何 SqlSugar 写之前:保证 9610 / ForecastBilling / Pickup Outbox 与建单同事务提交。 + await EnsureOutboxReadyAsync(); + var order = BuildDraftOrderEntity( input, product, @@ -148,7 +188,9 @@ public partial class OrderService if (input.SaveAsDraft) { await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode); - return await BuildParcelCreateOutputAsync(order); + var draftOutput = await BuildParcelCreateOutputAsync(order); + await TryAttachPickupAfterParcelCreateAsync(input, draftOutput); + return draftOutput; } // 偏远标记轻量同步;计费重/应收/排货走 ForecastBilling MQ(失败不回滚建单)。 @@ -176,22 +218,18 @@ public partial class OrderService order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal); + await TryAttachPickupAfterParcelCreateAsync(input, output); return output; } + catch (BusinessException) + { + await TryRollbackFailedParcelCreateAsync(created); + throw; + } catch (Exception ex) { // 创建失败不落库草稿:回滚已插入订单,由前端让客户选择继续修改或主动保存草稿。 - if (created != null) - { - try - { - await RollbackFailedParcelCreateAsync(created); - } - catch - { - // 回滚失败不覆盖原始业务错误;半成品订单需运维清理 - } - } + await TryRollbackFailedParcelCreateAsync(created); var cleanReason = StripDraftSavedMarker(ex.Message); if (string.IsNullOrWhiteSpace(cleanReason)) @@ -203,6 +241,23 @@ public partial class OrderService } } + private async Task TryRollbackFailedParcelCreateAsync(Order? created) + { + if (created == null) + { + return; + } + + try + { + await RollbackFailedParcelCreateAsync(created); + } + catch + { + // 回滚失败不覆盖原始业务错误;半成品订单需运维清理 + } + } + /// /// 创建流程中途失败时删除已落库的半成品订单,避免自动变成草稿。 /// @@ -2271,6 +2326,8 @@ public partial class OrderService try { + await EnsureOutboxReadyAsync(); + var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); var address = await ResolveOrderBillingAddressAsync(order); @@ -2627,6 +2684,12 @@ public partial class OrderService return; } + // 创单失败回滚 / 软删后,可能仍有短事务提前投递的孤儿任务 + if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + // 已预报成功则跳过(幂等) if (string.Equals(order.OrderStatus, OrderStatusCodes.Predeclared, StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(order.PredeclareFailReason)) diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 6bcbac0..eaccbe2 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -611,30 +611,7 @@ public partial class OrderService : ApplicationService [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public async Task CreateParcelAsync(OrderParcelCreateInput input) { - var inbound = string.IsNullOrWhiteSpace(input.InboundMethod) - ? null - : input.InboundMethod.Trim(); - var createContext = inbound == null - ? null - : new ParcelCreateContext - { - InboundMethod = inbound, - PickupTime = string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase) - ? input.ExpectPickupTime - : null, - CustomerSenderAddressId = string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase) - ? input.SenderAddressId - : null - }; - - var output = await CreateParcelCoreAsync(input, createContext: createContext); - - if (string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase)) - { - await TryAttachPickupAfterParcelCreateAsync(input, output); - } - - return output; + return await CreateParcelCoreAsync(input, createContext: BuildParcelCreateContext(input)); } /// @@ -687,6 +664,14 @@ public partial class OrderService : ApplicationService return; } + var inbound = string.IsNullOrWhiteSpace(input.InboundMethod) + ? null + : input.InboundMethod.Trim(); + if (!string.Equals(inbound, OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase)) + { + return; + } + if (input.SenderAddressId is not > 0) { output.PickupCreateError = "上门揽收需选择揽收地址"; -- 2.45.2 From 79fa20a9ca3abd1bbe1e8ac3fcb2409643f1e180 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 14 Oct 2026 19:23:07 +0800 Subject: [PATCH 11/16] =?UTF-8?q?fix:=20=E5=88=9B=E5=8D=95=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E5=90=8E=E6=8A=8A=E9=A2=84=E6=8A=A5=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E5=92=8C=E5=90=8E=E7=BB=AD=E4=BA=8B=E4=BB=B6=E6=8B=86=E5=BC=80?= =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 预报计费失败不再把订单留在处理中,轨迹、面单和自动触发在创单提交后各自入队。 Co-authored-by: Cursor --- .../Labeling/LabelPdfGenerationService.cs | 3 +- .../Messaging/OmsAfterCommit.cs | 49 +++ .../Messaging/OmsOutboxUnitOfWork.cs | 10 + .../Messaging/OrderBatchJobEventHandler.cs | 2 +- .../Orders/Batch/OrderBatchJobProcessor.cs | 40 +++ .../Orders/OrderService.ParcelBilling.cs | 302 +++++++++++++----- .../Repositories/IOrderRepository.cs | 5 + .../Orders/OrderRepository.cs | 9 + 8 files changed, 329 insertions(+), 91 deletions(-) create mode 100644 src/Oms.Application/Messaging/OmsAfterCommit.cs diff --git a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs index a6da67b..7e3287b 100644 --- a/src/Oms.Application/Labeling/LabelPdfGenerationService.cs +++ b/src/Oms.Application/Labeling/LabelPdfGenerationService.cs @@ -53,8 +53,7 @@ public sealed class LabelPdfGenerationService( var relativeUrl = await SavePdfAsync(order.ServiceNo, pdfBytes, cancellationToken); order.LabelTemplateId = templateId; - order.UpdateTime = DateTime.Now; - await orderRepository.UpdateAsync(order); + await orderRepository.UpdateLabelTemplateIdAsync(order.Id, templateId.Value, cancellationToken); await orderFileManager.ReplaceSystemLabelAsync( order.Id, diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs new file mode 100644 index 0000000..66f07fb --- /dev/null +++ b/src/Oms.Application/Messaging/OmsAfterCommit.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Fake.UnitOfWork; + +namespace Oms.Application.Messaging; + +/// +/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。 +/// +internal static class OmsAfterCommit +{ + public static async Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func action) + { + var current = unitOfWorkManager.Current; + if (current != null && !current.IsCompleted) + { + current.OnCompleted(action); + return; + } + + await action(); + } + + /// + /// 新开独立事务执行一步。失败只记日志,不抛回调用方。 + /// + public static async Task RunStepAsync( + IServiceScopeFactory scopeFactory, + ILogger logger, + string step, + Func work) + { + try + { + using var scope = scopeFactory.CreateScope(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); + await work(scope.ServiceProvider); + await unitOfWork.CompleteAsync(); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "订单后置步骤失败 Step={Step}", step); + return false; + } + } +} diff --git a/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs b/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs index dd1c83b..8d868d5 100644 --- a/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs +++ b/src/Oms.Application/Messaging/OmsOutboxUnitOfWork.cs @@ -17,6 +17,16 @@ public static class OmsOutboxUnitOfWork IsolationLevel = IsolationLevel.ReadCommitted }; + /// + /// 与当前业务事务脱开的短事务,用于创单/预报成功之后的独立事件。 + /// + public static UnitOfWorkAttribute Independent() => new() + { + IsTransactional = true, + RequiresNew = true, + IsolationLevel = IsolationLevel.ReadCommitted + }; + public static bool IsAmbientTransactional(IUnitOfWorkManager unitOfWorkManager) => unitOfWorkManager.Current?.Context.IsTransactional == true; } diff --git a/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs b/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs index 95b3ad4..32451db 100644 --- a/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs +++ b/src/Oms.Application/Messaging/OrderBatchJobEventHandler.cs @@ -29,7 +29,7 @@ public sealed class OrderBatchJobEventHandler( return; } - // ForecastBilling 成功后继续写轨迹/面单/自动触发 Outbox,须事务性 UoW + // 业务结果在本事务提交;轨迹/面单/自动触发由处理过程登记到提交后单独执行。 using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Transactional()); await processor.ProcessAsync(@event.ToMessage(), cancellationToken); await unitOfWork.CompleteAsync(cancellationToken); diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs index 9b04635..071ad64 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Oms.Application.Dtos.Orders; using Oms.Application.Messaging; +using Oms.Application.Services.Orders; using Oms.Domain.OrdersAggregate.Consts; using Oms.Domain.OrdersAggregate.Entities; using Oms.Domain.OrdersAggregate.Repositories; @@ -94,6 +95,7 @@ public sealed class OrderBatchJobProcessor( await OrderBatchJobItemFeedback.EnrichIdentifiersAsync(item, action, orderRepository, cancellationToken); OrderBatchJobItemFeedback.ApplyBusinessFailure(item, result, action); await batchJobRepository.UpdateItemAsync(item, cancellationToken); + await TryStampForecastBillingFailureAsync(message, item.FailReason, cancellationToken); await RefreshJobSummaryAsync(item.JobId, cancellationToken); return; @@ -111,6 +113,7 @@ public sealed class OrderBatchJobProcessor( action, item.RetryCount, item.FailReason); + await TryStampForecastBillingFailureAsync(message, item.FailReason, cancellationToken); await RefreshJobSummaryAsync(item.JobId, cancellationToken); return; } @@ -148,6 +151,43 @@ public sealed class OrderBatchJobProcessor( return null; } + /// + /// 预报计费重试耗尽或业务失败时,把原因写回订单,避免一直显示「处理中」。 + /// 更具体的失败原因已经写上时不再覆盖。 + /// + private async Task TryStampForecastBillingFailureAsync( + OrderBatchJobMessage message, + string? reason, + CancellationToken cancellationToken) + { + if (!string.Equals(message.Action, OrderBatchActionCodes.ForecastBilling, StringComparison.OrdinalIgnoreCase) + || message.OrderId <= 0) + { + return; + } + + var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == message.OrderId, cancellationToken); + if (order == null + || !string.Equals( + order.PredeclareFailReason, + OrderService.ForecastBillingProcessingReason, + StringComparison.Ordinal)) + { + return; + } + + var text = string.IsNullOrWhiteSpace(reason) ? "预报计费失败" : reason.Trim(); + if (text.Length > 512) + { + text = text[..512]; + } + + order.OrderStatus = OrderStatusCodes.Draft; + order.PredeclareFailReason = text; + order.UpdateTime = DateTime.Now; + await orderRepository.UpdateAsync(order, cancellationToken); + } + private async Task RefreshJobSummaryAsync(int jobId, CancellationToken cancellationToken) { var job = await batchJobRepository.GetByIdAsync(jobId, cancellationToken); diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 1085937..afe1617 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -3,6 +3,7 @@ using Fake.Application; using Fake.UnitOfWork; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Oms.Application.Dtos.Channels; @@ -39,7 +40,7 @@ public partial class OrderService private const int ParcelBatchMaxCount = 2000; private const string InboundBillingProcessingReason = "入库后计费/排货处理中"; private const string InboundBillingEnqueueFailReason = "入库计费任务投递失败,请重跑入库计费"; - private const string ForecastBillingProcessingReason = "预报计费/排货处理中"; + internal const string ForecastBillingProcessingReason = "预报计费/排货处理中"; private const string ForecastBillingEnqueueFailReason = "预报计费任务投递失败,请重新预报"; private static readonly ConcurrentDictionary InboundBillingInFlight = new(); @@ -99,19 +100,6 @@ public partial class OrderService }; } - /// - /// 业务写库前先打开 Outbox DbContext,让 EF 先占环境事务连接, - /// 避免 SqlSugar 先开事务后 Outbox UseTransaction 失败而落入「短事务提前 Commit」。 - /// - private async Task EnsureOutboxReadyAsync() - { - var uowManager = LazyServiceProvider.GetRequiredService(); - if (OmsOutboxUnitOfWork.IsAmbientTransactional(uowManager)) - { - await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(uowManager); - } - } - private async Task CreateParcelCoreAsync( OrderParcelCreateInput input, int? operatorUserId = null, @@ -164,12 +152,10 @@ public partial class OrderService country.ChineseName); var serviceNo = await ResolveServiceNoAsync(); + var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); Order? created = null; try { - // 必须在任何 SqlSugar 写之前:保证 9610 / ForecastBilling / Pickup Outbox 与建单同事务提交。 - await EnsureOutboxReadyAsync(); - var order = BuildDraftOrderEntity( input, product, @@ -184,12 +170,11 @@ public partial class OrderService var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); - // 客户主动「先保存为草稿」:落库草稿后直接返回,不跑预报计费。 + // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。 if (input.SaveAsDraft) { - await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode); var draftOutput = await BuildParcelCreateOutputAsync(order); - await TryAttachPickupAfterParcelCreateAsync(input, draftOutput); + await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId); return draftOutput; } @@ -207,18 +192,8 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, input.PlatformCode); - await EnqueueForecastBillingAsync( - order, - input.CouponInstanceId, - operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); - var output = await BuildParcelCreateOutputAsync(order); - output.ForecastBillingQueued = string.Equals( - order.PredeclareFailReason, - ForecastBillingProcessingReason, - StringComparison.Ordinal); - await TryAttachPickupAfterParcelCreateAsync(input, output); + await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId); return output; } catch (BusinessException) @@ -2326,8 +2301,6 @@ public partial class OrderService try { - await EnsureOutboxReadyAsync(); - var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order); var address = await ResolveOrderBillingAddressAsync(order); @@ -2355,10 +2328,24 @@ public partial class OrderService order.UpdateTime = DateTime.Now; await OrderRepository.UpdateAsync(order); - await EnqueueForecastBillingAsync( - order, - order.CouponInstanceId, - operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); + var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); + var publisher = LazyServiceProvider.GetRequiredService(); + if (!publisher.IsEnabled) + { + var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload + { + CouponInstanceId = order.CouponInstanceId + }); + await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorId); + } + else + { + var orderId = order.Id; + var couponInstanceId = order.CouponInstanceId; + await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId); + } + + await ScheduleForecastSideEffectsAsync(order.Id, operatorId); // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。 var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); @@ -2681,10 +2668,10 @@ public partial class OrderService var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); if (order == null) { - return; + // 创单事务尚未提交时消息可能先到。抛出让总线重试,避免当成成功 Ack 后订单永远停在处理中。 + throw new InvalidOperationException($"预报计费时订单尚未可见,稍后重试 OrderId={orderId}"); } - // 创单失败回滚 / 软删后,可能仍有短事务提前投递的孤儿任务 if (string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) { return; @@ -2852,21 +2839,6 @@ public partial class OrderService operatorName: null, operatorType: "staff", source: "job"); - - var customer = order.CustomerId.HasValue - ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) - : null; - await PublishParcelCreateTrackAndLabelAsync( - order, - customer, - receiver: null, - appendInitialTrack: true, - operatorUserId); - - await AutoTriggerRuntimeService.EnqueueFireAsync( - order.Id, - AutoTriggerTiming.OrderCreate, - operatorUserId); } private sealed class ForecastBillingJobPayload @@ -2874,6 +2846,188 @@ public partial class OrderService public int? CouponInstanceId { get; set; } } + /// + /// 创单事务提交后,9610、预报计费、揽收、轨迹、面单、自动触发各自提交,互不等待。 + /// + private async Task ScheduleCreateSideEffectsAsync( + int orderId, + OrderParcelCreateInput input, + OrderParcelCreateOutput output, + bool includeForecast, + int operatorUserId) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderParcelCreate"); + var platformCode = input.PlatformCode; + var couponInstanceId = input.CouponInstanceId; + + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => + { + try + { + var customsTask = OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp => + sp.GetRequiredService().PublishCustomsForOrderAsync(orderId, platformCode)); + var forecastTask = includeForecast + ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => + { + var service = sp.GetRequiredService(); + await service.EnqueueForecastBillingByIdAsync(orderId, couponInstanceId, operatorUserId); + var latest = await sp.GetRequiredService() + .FirstOrDefaultAsync(x => x.Id == orderId); + if (latest != null) + { + output.OrderStatus = latest.OrderStatus; + output.PredeclareFailReason = latest.PredeclareFailReason; + output.ForecastBillingQueued = string.Equals( + latest.PredeclareFailReason, + ForecastBillingProcessingReason, + StringComparison.Ordinal); + } + }) + : Task.FromResult(true); + var pickupTask = OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp => + sp.GetRequiredService().TryAttachPickupAfterParcelCreateAsync(input, output)); + var trackTask = includeForecast + ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => + sp.GetRequiredService().PublishForecastTrackAsync(orderId)) + : Task.FromResult(true); + var labelTask = includeForecast + ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => + sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)) + : Task.FromResult(true); + var triggerTask = includeForecast + ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => + sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)) + : Task.FromResult(true); + + await Task.WhenAll(customsTask, forecastTask, pickupTask, trackTask, labelTask, triggerTask); + + if (includeForecast && !await forecastTask) + { + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp => + sp.GetRequiredService().MarkForecastEnqueueFailedIfStillProcessingAsync(orderId)); + output.ForecastBillingQueued = false; + output.PredeclareFailReason = ForecastBillingEnqueueFailReason; + } + } + catch (Exception ex) + { + logger.LogError(ex, "创单后置步骤调度失败 OrderId={OrderId}", orderId); + } + }); + } + + private async Task ScheduleForecastEnqueueAsync(int orderId, int? couponInstanceId, int operatorUserId) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => + OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", sp => + sp.GetRequiredService().EnqueueForecastBillingByIdAsync( + orderId, + couponInstanceId, + operatorUserId))); + } + + /// + /// 创单或草稿重新预报提交后,轨迹、面单、下单自动触发各自入队,不等预报计费。 + /// + private async Task ScheduleForecastSideEffectsAsync(int orderId, int operatorUserId) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => + { + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => + sp.GetRequiredService().PublishForecastTrackAsync(orderId)); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => + sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => + sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)); + }); + } + + private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode); + } + + private async Task EnqueueForecastBillingByIdAsync(int orderId, int? couponInstanceId, int operatorUserId) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId); + } + + private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || !string.Equals(order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal)) + { + return; + } + + await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); + } + + private async Task PublishForecastTrackAsync(int orderId) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var customer = order.CustomerId.HasValue + ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) + : null; + var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); + request.AppendInitialTrackOnSuccess = true; + ApplyTrackAuthorization(request); + await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); + } + + private async Task PublishForecastLabelAsync(int orderId, int operatorUserId) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await ParcelCreateEventPublisher.PublishLabelGenerateAsync(order, operatorUserId); + } + + private async Task PublishForecastAutoTriggerAsync(int orderId, int operatorUserId) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await AutoTriggerRuntimeService.EnqueueFireAsync(orderId, AutoTriggerTiming.OrderCreate, operatorUserId); + } + private async Task EnqueueForecastBillingAsync( Order order, int? couponInstanceId, @@ -3206,40 +3360,8 @@ public partial class OrderService private IOrderSideEffectEventPublisher SideEffectEventPublisher => LazyServiceProvider.GetRequiredService(); - /// - /// 创单/草稿预报成功后:轨迹订单+A0101 合并事件,系统面单独立事件(Fake.EventBus.RabbitMQ)。 - /// - private async Task PublishParcelCreateTrackAndLabelAsync( - Order order, - Customer? customer, - OrderParcelReceiverInput? receiver, - bool appendInitialTrack, - int? operatorUserId = null) - { - var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); - request.AppendInitialTrackOnSuccess = appendInitialTrack; - var authorization = LazyServiceProvider - .GetRequiredService() - .HttpContext? - .Request - .Headers - .Authorization - .ToString(); - if (!string.IsNullOrWhiteSpace(authorization)) - { - request.Authorization = authorization; - } - - await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); - await ParcelCreateEventPublisher.PublishLabelGenerateAsync( - order, - operatorUserId - ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0)); - } - /// /// 小包预报成功后异步同步轨迹订单(POST /ProviderService/Order/UpsertSmallBag)。 - /// 入库等非创单路径仍可调用;创单路径请走 。 /// private async Task EnqueueSmallBagTrackUpsertAsync( Order order, @@ -3249,6 +3371,12 @@ public partial class OrderService { var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiver); request.AppendInitialTrackOnSuccess = appendInitialTrack; + ApplyTrackAuthorization(request); + await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); + } + + private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request) + { var authorization = LazyServiceProvider .GetRequiredService() .HttpContext? @@ -3260,8 +3388,6 @@ public partial class OrderService { request.Authorization = authorization; } - - await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } /// diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index 1cd7722..c5d8ab0 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -132,4 +132,9 @@ public interface IOrderRepository : IRepository DateTime startInclusive, DateTime endExclusive, CancellationToken cancellationToken = default); + + /// + /// 只更新标签模板 Id,避免面单生成覆盖预报计费正在写入的订单字段。 + /// + Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default); } diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index b7d0884..c85d43f 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1021,6 +1021,15 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I } } + public async Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Updateable() + .SetColumns(x => new Order { LabelTemplateId = labelTemplateId, UpdateTime = DateTime.Now }) + .Where(x => x.Id == orderId) + .ExecuteCommandAsync(cancellationToken); + } + public async Task DeleteReceivableFeesAsync(int orderId, CancellationToken cancellationToken = default) { var db = await GetDbContextAsync(cancellationToken); -- 2.45.2 From 61b6f9a9b9504222e0a8d94b9a1d85a6281d7c32 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 14 Oct 2026 19:37:56 +0800 Subject: [PATCH 12/16] =?UTF-8?q?fix:=20=E5=88=9B=E5=8D=95=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=E9=87=8A=E6=94=BE=E5=90=8E=E5=86=8D=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=209610=E3=80=81=E8=BD=A8=E8=BF=B9=E5=92=8C=E6=8F=BD=E6=94=B6?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免在创单事务里写 Outbox 征用不到连接,导致这三类事件被丢掉。 Co-authored-by: Cursor --- .../Messaging/OmsAfterCommit.cs | 16 ++++-- .../Messaging/OmsIntegrationEventPublisher.cs | 16 ++++-- .../Orders/OrderService.ParcelBilling.cs | 51 +++++++------------ 3 files changed, 40 insertions(+), 43 deletions(-) diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs index 66f07fb..9b31ee7 100644 --- a/src/Oms.Application/Messaging/OmsAfterCommit.cs +++ b/src/Oms.Application/Messaging/OmsAfterCommit.cs @@ -9,16 +9,24 @@ namespace Oms.Application.Messaging; /// internal static class OmsAfterCommit { - public static async Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func action) + public static Task ScheduleAsync(IUnitOfWorkManager unitOfWorkManager, Func action) { var current = unitOfWorkManager.Current; if (current != null && !current.IsCompleted) { - current.OnCompleted(action); - return; + // OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。 + // 等工作单元释放后再开独立事务。 + EventHandler handler = null!; + handler = (_, _) => + { + current.Disposed -= handler; + _ = Task.Run(action); + }; + current.Disposed += handler; + return Task.CompletedTask; } - await action(); + return action(); } /// diff --git a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs index 956bc1e..b572be4 100644 --- a/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs +++ b/src/Oms.Application/Messaging/OmsIntegrationEventPublisher.cs @@ -33,9 +33,9 @@ internal static class OmsIntegrationEventPublisher @event.Id); return; } - catch (Exception ex) when (IsTxConnectionMismatch(ex)) + catch (Exception ex) when (ShouldUseIndependentOutbox(ex)) { - // SqlSugar 已在另一条连接上开事务时,EF 无法征用该 DbTransaction。 + // 环境事务已提交、连接对不上,或还没有可征用的数据库事务时,改走独立短事务。 logger.LogWarning( ex, "Ambient UoW TX incompatible with EF Outbox; using short outbox TX. Event={EventName} Id={EventId}", @@ -97,12 +97,18 @@ internal static class OmsIntegrationEventPublisher return dbProvider.GetDbContextAsync(); } - private static bool IsTxConnectionMismatch(Exception ex) + private static bool ShouldUseIndependentOutbox(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)) + var message = e.Message; + if (message.Contains("not associated with the current connection", StringComparison.OrdinalIgnoreCase) + || message.Contains("associated with the current connection", StringComparison.OrdinalIgnoreCase) + || message.Contains("可征用的数据库事务", StringComparison.Ordinal) + || message.Contains("returned null", StringComparison.OrdinalIgnoreCase) + || message.Contains("No ambient transaction", StringComparison.OrdinalIgnoreCase) + || message.Contains("已经回滚", StringComparison.Ordinal) + || message.Contains("已被调用", StringComparison.Ordinal)) { return true; } diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index afe1617..2d66718 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2866,49 +2866,32 @@ public partial class OrderService { try { - var customsTask = OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp => + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp => sp.GetRequiredService().PublishCustomsForOrderAsync(orderId, platformCode)); - var forecastTask = includeForecast - ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => + + var forecastQueued = true; + if (includeForecast) + { + forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => { var service = sp.GetRequiredService(); await service.EnqueueForecastBillingByIdAsync(orderId, couponInstanceId, operatorUserId); - var latest = await sp.GetRequiredService() - .FirstOrDefaultAsync(x => x.Id == orderId); - if (latest != null) - { - output.OrderStatus = latest.OrderStatus; - output.PredeclareFailReason = latest.PredeclareFailReason; - output.ForecastBillingQueued = string.Equals( - latest.PredeclareFailReason, - ForecastBillingProcessingReason, - StringComparison.Ordinal); - } - }) - : Task.FromResult(true); - var pickupTask = OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp => + }); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => + sp.GetRequiredService().PublishForecastTrackAsync(orderId)); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => + sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => + sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)); + } + + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp => sp.GetRequiredService().TryAttachPickupAfterParcelCreateAsync(input, output)); - var trackTask = includeForecast - ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => - sp.GetRequiredService().PublishForecastTrackAsync(orderId)) - : Task.FromResult(true); - var labelTask = includeForecast - ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => - sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)) - : Task.FromResult(true); - var triggerTask = includeForecast - ? OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => - sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)) - : Task.FromResult(true); - await Task.WhenAll(customsTask, forecastTask, pickupTask, trackTask, labelTask, triggerTask); - - if (includeForecast && !await forecastTask) + if (includeForecast && !forecastQueued) { await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp => sp.GetRequiredService().MarkForecastEnqueueFailedIfStillProcessingAsync(orderId)); - output.ForecastBillingQueued = false; - output.PredeclareFailReason = ForecastBillingEnqueueFailReason; } } catch (Exception ex) -- 2.45.2 From 34c47a3173be00b21bd908880c682928f128696f Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Wed, 14 Oct 2026 20:07:51 +0800 Subject: [PATCH 13/16] =?UTF-8?q?fix:=20=E5=88=9B=E5=8D=95=E5=90=8E?= =?UTF-8?q?=E7=BD=AE=E6=AD=A5=E9=AA=A4=E9=9A=8F=E8=AE=A2=E5=8D=95=E8=90=BD?= =?UTF-8?q?=E5=BA=93=EF=BC=8C=E6=8F=90=E4=BA=A4=E5=90=8E=E5=86=8D=E5=8D=95?= =?UTF-8?q?=E7=8B=AC=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进程重启或事务回滚不再丢掉 9610、轨迹和揽收;排货完成后再补轨迹、面单和计费相关触发。 Co-authored-by: Cursor --- db.sql | 21 ++ sql/migrate_order_side_effect.sql | 39 +++ .../Messaging/OmsAfterCommit.cs | 59 ++-- .../Messaging/OrderSideEffectExecutor.cs | 202 +++++++++++ .../Messaging/OrderSideEffectPayload.cs | 34 ++ .../Messaging/OrderSideEffectQueue.cs | 83 +++++ src/Oms.Application/OmsApplicationModule.cs | 1 + .../Orders/Batch/OrderBatchJobProcessor.cs | 5 +- .../Issues/AutoTriggerRuntimeService.cs | 94 +++++- .../Issues/OrderExceptionWorkflowService.cs | 52 +-- .../Orders/OrderService.ParcelBilling.cs | 176 ++-------- .../Orders/OrderService.SideEffects.cs | 317 ++++++++++++++++++ .../Services/Orders/OrderService.cs | 4 +- .../Workers/OrderSideEffectWorker.cs | 41 +++ .../Consts/OrderSideEffectCodes.cs | 44 +++ .../Entities/OrderSideEffect.cs | 30 ++ .../Repositories/IOrderRepository.cs | 19 ++ .../IOrderSideEffectRepository.cs | 55 +++ .../Orders/OrderRepository.cs | 81 +++++ .../Orders/OrderSideEffectRepository.cs | 153 +++++++++ 20 files changed, 1307 insertions(+), 203 deletions(-) create mode 100644 sql/migrate_order_side_effect.sql create mode 100644 src/Oms.Application/Messaging/OrderSideEffectExecutor.cs create mode 100644 src/Oms.Application/Messaging/OrderSideEffectPayload.cs create mode 100644 src/Oms.Application/Messaging/OrderSideEffectQueue.cs create mode 100644 src/Oms.Application/Services/Orders/OrderService.SideEffects.cs create mode 100644 src/Oms.Application/Workers/OrderSideEffectWorker.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs create mode 100644 src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs create mode 100644 src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs diff --git a/db.sql b/db.sql index 3f1111e..199ec6e 100644 --- a/db.sql +++ b/db.sql @@ -101,6 +101,7 @@ DROP TABLE IF EXISTS `t_order_weight`; DROP TABLE IF EXISTS `t_order_declaration`; DROP TABLE IF EXISTS `t_order_recipient`; DROP TABLE IF EXISTS `t_order_sender`; +DROP TABLE IF EXISTS `t_order_side_effect`; DROP TABLE IF EXISTS `t_order_batch_job_item`; DROP TABLE IF EXISTS `t_order_batch_job`; DROP TABLE IF EXISTS `t_order_template`; @@ -306,6 +307,26 @@ CREATE TABLE `t_order_template` ( KEY `idx_ord_tpl_customer_type` (`CustomerId`, `Type`) ) COMMENT='下单模板表'; +CREATE TABLE `t_order_side_effect` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `OrderId` INT NOT NULL COMMENT '订单 Id', + `Step` VARCHAR(64) NOT NULL COMMENT '步骤:customs-9610/forecast-billing/track-upsert 等', + `Status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/running/done/failed', + `PayloadJson` TEXT NULL COMMENT '步骤入参', + `AttemptCount` INT NOT NULL DEFAULT 0 COMMENT '已尝试次数', + `NextRunTime` DATETIME NULL COMMENT '下次可执行时间', + `LastError` VARCHAR(512) NULL COMMENT '最近一次失败原因', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + KEY `idx_order_side_effect_due` (`Status`, `NextRunTime`), + KEY `idx_order_side_effect_order` (`OrderId`, `Step`) +) COMMENT='创单后置步骤'; + CREATE TABLE `t_order_batch_job` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '批量操作类型:pay/pre-declare/intercept/parcel-create 等', diff --git a/sql/migrate_order_side_effect.sql b/sql/migrate_order_side_effect.sql new file mode 100644 index 0000000..20fbf7a --- /dev/null +++ b/sql/migrate_order_side_effect.sql @@ -0,0 +1,39 @@ +-- 创单后置步骤表:与订单同一事务写入,提交后由后台执行。 +-- 可重复执行。不建唯一索引。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_side_effect' +); + +SET @sql := IF( + @exists = 0, + 'CREATE TABLE `t_order_side_effect` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', + `OrderId` INT NOT NULL COMMENT ''订单 Id'', + `Step` VARCHAR(64) NOT NULL COMMENT ''步骤:customs-9610/forecast-billing/track-upsert 等'', + `Status` VARCHAR(32) NOT NULL DEFAULT ''pending'' COMMENT ''pending/running/done/failed'', + `PayloadJson` TEXT NULL COMMENT ''步骤入参,含轨迹授权头,日志中不要打印全文'', + `AttemptCount` INT NOT NULL DEFAULT 0 COMMENT ''已尝试次数'', + `NextRunTime` DATETIME NULL COMMENT ''下次可执行时间'', + `LastError` VARCHAR(512) NULL COMMENT ''最近一次失败原因'', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', + `UpdateUserId` INT NULL COMMENT ''更新人'', + `UpdateTime` DATETIME NULL COMMENT ''更新时间'', + PRIMARY KEY (`Id`), + KEY `idx_order_side_effect_due` (`Status`, `NextRunTime`), + KEY `idx_order_side_effect_order` (`OrderId`, `Step`) + ) COMMENT=''创单后置步骤''', + 'SELECT ''t_order_side_effect already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs index 9b31ee7..deda445 100644 --- a/src/Oms.Application/Messaging/OmsAfterCommit.cs +++ b/src/Oms.Application/Messaging/OmsAfterCommit.cs @@ -1,11 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Fake.UnitOfWork; namespace Oms.Application.Messaging; /// -/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。 +/// 当前事务提交并释放后再执行。回滚不会执行。 /// internal static class OmsAfterCommit { @@ -14,13 +12,36 @@ internal static class OmsAfterCommit var current = unitOfWorkManager.Current; if (current != null && !current.IsCompleted) { - // OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。 - // 等工作单元释放后再开独立事务。 + // OnCompleted 仍处在业务事务里,此时写 Outbox 征用不到连接。 + // 等工作单元成功释放后再开独立事务。回滚时 IsCompleted 为 false,不跑。 EventHandler handler = null!; handler = (_, _) => { current.Disposed -= handler; - _ = Task.Run(action); + if (!current.IsCompleted) + { + return; + } + + var suppressed = ExecutionContext.SuppressFlow(); + try + { + _ = Task.Run(async () => + { + try + { + await action(); + } + catch + { + // 具体步骤在执行器里记日志。这里避免未观察的任务异常。 + } + }); + } + finally + { + suppressed.Dispose(); + } }; current.Disposed += handler; return Task.CompletedTask; @@ -28,30 +49,4 @@ internal static class OmsAfterCommit return action(); } - - /// - /// 新开独立事务执行一步。失败只记日志,不抛回调用方。 - /// - public static async Task RunStepAsync( - IServiceScopeFactory scopeFactory, - ILogger logger, - string step, - Func work) - { - try - { - using var scope = scopeFactory.CreateScope(); - var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); - await work(scope.ServiceProvider); - await unitOfWork.CompleteAsync(); - return true; - } - catch (Exception ex) - { - logger.LogError(ex, "订单后置步骤失败 Step={Step}", step); - return false; - } - } } diff --git a/src/Oms.Application/Messaging/OrderSideEffectExecutor.cs b/src/Oms.Application/Messaging/OrderSideEffectExecutor.cs new file mode 100644 index 0000000..bf46bc5 --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffectExecutor.cs @@ -0,0 +1,202 @@ +using Fake.Application; +using Fake.DependencyInjection; +using Fake.UnitOfWork; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Oms.Application.Services.Orders; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Messaging; + +/// +/// 每一步后置任务单独开事务执行。失败按次数重试,超过次数记失败。 +/// +public sealed class OrderSideEffectExecutor( + IOrderSideEffectRepository repository, + IUnitOfWorkManager unitOfWorkManager, + OrderService orderService, + ILogger logger) : IScopedDependency +{ + private static readonly TimeSpan StaleRunning = TimeSpan.FromMinutes(2); + + /// + /// 当前事务提交成功后再执行到期步骤。回滚不会触发。 + /// + public static Task ScheduleAfterCommitAsync( + IUnitOfWorkManager unitOfWorkManager, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + return OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => ProcessDueAsync(scopeFactory, logger)); + } + + /// + /// 捞出到期步骤并逐条执行。可重复调用,抢占失败的步骤会被跳过。 + /// + public static async Task ProcessDueAsync( + IServiceScopeFactory scopeFactory, + ILogger logger, + CancellationToken cancellationToken = default) + { + List ids; + try + { + using var scope = scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.Now; + await repository.ResetStaleRunningAsync(now.Subtract(StaleRunning), now, cancellationToken); + ids = await repository.ListDueIdsAsync(30, now, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "捞取创单后置步骤失败"); + return; + } + + foreach (var id in ids) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + using var scope = scopeFactory.CreateScope(); + var executor = scope.ServiceProvider.GetRequiredService(); + await executor.ExecuteAsync(id, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "订单后置步骤执行失败 SideEffectId={SideEffectId}", id); + } + } + } + + /// + /// 仍停在「预报计费/排货处理中」且没有未完成预报步骤的订单,补一条预报计费步骤。 + /// + public static async Task RecoverStuckForecastAsync( + IServiceScopeFactory scopeFactory, + ILogger logger, + CancellationToken cancellationToken = default) + { + try + { + using var scope = scopeFactory.CreateScope(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + var repository = scope.ServiceProvider.GetRequiredService(); + var queue = scope.ServiceProvider.GetRequiredService(); + var olderThan = DateTime.Now.AddSeconds(-90); + var orderIds = await repository.ListStuckForecastOrderIdsAsync( + OrderService.ForecastBillingProcessingReason, + olderThan, + 20, + cancellationToken); + foreach (var orderId in orderIds) + { + await queue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.ForecastBilling, + new OrderSideEffectPayload(), + cancellationToken); + } + + await unitOfWork.CompleteAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "补投递卡住的预报计费失败"); + } + } + + private async Task ExecuteAsync(int id, CancellationToken cancellationToken) + { + var claimed = await ClaimAsync(id, cancellationToken); + if (!claimed) + { + return; + } + + var row = await repository.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (row == null) + { + return; + } + + try + { + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); + await orderService.ExecuteSideEffectAsync(row, cancellationToken); + await repository.MarkDoneAsync(row.Id, DateTime.Now, cancellationToken); + await unitOfWork.CompleteAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "订单后置步骤失败 Step={Step} OrderId={OrderId}", row.Step, row.OrderId); + var exhausted = await MarkRetryAsync(row, ex, cancellationToken); + if (exhausted + && string.Equals(row.Step, OrderSideEffectSteps.ForecastBilling, StringComparison.Ordinal)) + { + await StampForecastEnqueueFailureAsync(row.OrderId, cancellationToken); + } + } + } + + private async Task ClaimAsync(int id, CancellationToken cancellationToken) + { + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + var claimed = await repository.TryClaimAsync(id, DateTime.Now, cancellationToken); + await unitOfWork.CompleteAsync(cancellationToken); + return claimed; + } + + private async Task MarkRetryAsync( + Domain.OrdersAggregate.Entities.OrderSideEffect row, + Exception ex, + CancellationToken cancellationToken) + { + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + var latest = await repository.FirstOrDefaultAsync(x => x.Id == row.Id, cancellationToken); + var attempts = latest?.AttemptCount ?? row.AttemptCount; + var exhausted = attempts >= OrderSideEffectQueue.MaxAttempts; + var message = ex is BusinessException business ? business.Message : ex.Message; + if (string.IsNullOrWhiteSpace(message)) + { + message = ex.GetType().Name; + } + + if (message.Length > 512) + { + message = message[..512]; + } + + var now = DateTime.Now; + await repository.MarkRetryAsync( + row.Id, + exhausted ? OrderSideEffectStatusCodes.Failed : OrderSideEffectStatusCodes.Pending, + message, + exhausted ? null : now.AddSeconds(15 * Math.Max(1, attempts)), + now, + cancellationToken); + await unitOfWork.CompleteAsync(cancellationToken); + return exhausted; + } + + private async Task StampForecastEnqueueFailureAsync(int orderId, CancellationToken cancellationToken) + { + try + { + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + await orderService.MarkForecastEnqueueFailedIfStillProcessingAsync(orderId); + await unitOfWork.CompleteAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "回写预报计费投递失败原因失败 OrderId={OrderId}", orderId); + } + } +} diff --git a/src/Oms.Application/Messaging/OrderSideEffectPayload.cs b/src/Oms.Application/Messaging/OrderSideEffectPayload.cs new file mode 100644 index 0000000..8f629c5 --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffectPayload.cs @@ -0,0 +1,34 @@ +namespace Oms.Application.Messaging; + +/// +/// 后置步骤入参。轨迹授权头只在执行时使用,不要写进日志。 +/// +public sealed class OrderSideEffectPayload +{ + public int OperatorUserId { get; set; } + + public string? PlatformCode { get; set; } + + public int? CouponInstanceId { get; set; } + + /// 为 true 时轨迹创建成功后追加 A0101。 + public bool AppendInitialTrack { get; set; } + + public string? Authorization { get; set; } + + public int? CustomerId { get; set; } + + public int? SenderAddressId { get; set; } + + public int? WarehouseId { get; set; } + + public string? PickupDirection { get; set; } + + public string? CarrierCode { get; set; } + + public string? CarrierService { get; set; } + + public DateTime? ExpectPickupTime { get; set; } + + public string? InboundMethod { get; set; } +} diff --git a/src/Oms.Application/Messaging/OrderSideEffectQueue.cs b/src/Oms.Application/Messaging/OrderSideEffectQueue.cs new file mode 100644 index 0000000..1bd78a4 --- /dev/null +++ b/src/Oms.Application/Messaging/OrderSideEffectQueue.cs @@ -0,0 +1,83 @@ +using Fake.DependencyInjection; +using Newtonsoft.Json; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Messaging; + +/// +/// 把创单后置步骤写入当前事务。事务回滚时这些行一起消失。 +/// +public sealed class OrderSideEffectQueue(IOrderSideEffectRepository repository) : IScopedDependency +{ + public const int MaxAttempts = 5; + + /// + /// 同一步骤已有 pending、running 或 done 时不再插入。用于 A0101、揽收和创单当下的自动触发。 + /// + public Task EnqueueOnceAsync( + int orderId, + string step, + OrderSideEffectPayload? payload, + CancellationToken cancellationToken = default) => + EnqueueAsync(orderId, step, payload, includeDone: true, cancellationToken); + + /// + /// 没有 pending 或 running 时才插入。已完成的步骤允许再入队,例如重新预报、排货后刷新轨迹。 + /// + public Task EnqueueIfIdleAsync( + int orderId, + string step, + OrderSideEffectPayload? payload, + CancellationToken cancellationToken = default) => + EnqueueAsync(orderId, step, payload, includeDone: false, cancellationToken); + + /// + /// 读取同一步骤最近一次入参。没有记录时返回 null。 + /// + public async Task FindLatestPayloadAsync( + int orderId, + string step, + CancellationToken cancellationToken = default) + { + var json = await repository.FindLatestPayloadJsonAsync(orderId, step, cancellationToken); + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + return JsonConvert.DeserializeObject(json); + } + + private async Task EnqueueAsync( + int orderId, + string step, + OrderSideEffectPayload? payload, + bool includeDone, + CancellationToken cancellationToken) + { + if (orderId <= 0 || string.IsNullOrWhiteSpace(step)) + { + return; + } + + if (await repository.ExistsAsync(orderId, step, includeDone, cancellationToken)) + { + return; + } + + var now = DateTime.Now; + await repository.InsertAsync(new OrderSideEffect + { + OrderId = orderId, + Step = step, + Status = OrderSideEffectStatusCodes.Pending, + PayloadJson = payload == null ? null : JsonConvert.SerializeObject(payload), + AttemptCount = 0, + NextRunTime = now, + CreateTime = now, + UpdateTime = now + }, cancellationToken); + } +} diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs index c24e699..1df839c 100644 --- a/src/Oms.Application/OmsApplicationModule.cs +++ b/src/Oms.Application/OmsApplicationModule.cs @@ -65,5 +65,6 @@ public class OmsApplicationModule : FakeModule context.Services.AddAiLlmClient(); context.Services.AddHostedService(); context.Services.AddHostedService(); + context.Services.AddHostedService(); } } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs index 071ad64..8930e13 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs @@ -182,10 +182,7 @@ public sealed class OrderBatchJobProcessor( text = text[..512]; } - order.OrderStatus = OrderStatusCodes.Draft; - order.PredeclareFailReason = text; - order.UpdateTime = DateTime.Now; - await orderRepository.UpdateAsync(order, cancellationToken); + await orderRepository.UpdatePredeclareFailureAsync(order.Id, text, cancellationToken: cancellationToken); } private async Task RefreshJobSummaryAsync(int jobId, CancellationToken cancellationToken) diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs index 34d536e..69243c9 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs @@ -17,6 +17,7 @@ public class AutoTriggerRuntimeService( OrderExceptionWorkflowService exceptionWorkflowService, OrderIssueNotificationService issueNotificationService, IOrderSideEffectEventPublisher sideEffectEventPublisher, + IOrderExceptionEventRepository orderExceptionEventRepository, ILogger logger) : IScopedDependency { private const string InterceptOperation = "拦截"; @@ -81,6 +82,97 @@ public class AutoTriggerRuntimeService( await FireAsync(order, timing, operatorUserId); } + /// + /// 预报计费完成后再评估。只跑依赖计费/渠道/费用的规则,以及草稿阶段没能打上的问题件。 + /// + public async Task FireSettledByOrderIdAsync(int orderId, int operatorUserId) + { + var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null) + { + return; + } + + var context = await orderAutoTriggerService.BuildPostCommitContextAsync(order); + var triggers = await autoTriggerRepository.GetListAsync(x => x.Status == EnableStatus.Enabled); + var orderedTriggers = triggers + .Where(x => AutoTriggerTimingCodes.Matches(x.TriggerTiming, AutoTriggerTiming.OrderCreate)) + .Select(x => (Trigger: x, Priority: AutoTriggerRuleResolver.GetPriority(x))) + .OrderBy(x => x.Priority) + .ThenBy(x => x.Trigger.Id) + .Select(x => x.Trigger) + .ToList(); + + foreach (var trigger in orderedTriggers) + { + if (string.Equals(trigger.Operation?.Trim(), InterceptOperation, StringComparison.Ordinal)) + { + continue; + } + + var isProblem = string.Equals(trigger.Operation?.Trim(), "问题件", StringComparison.Ordinal); + if (!isProblem && !ReferencesSettlementFields(trigger.TriggerCondition)) + { + continue; + } + + if (isProblem && await AlreadyMarkedByTriggerAsync(order.Id, trigger.Id)) + { + continue; + } + + if (!AutoTriggerConditionMatcher.Matches(context, trigger.TriggerCondition)) + { + continue; + } + + try + { + await ExecuteTriggerAsync(order, trigger, operatorUserId); + } + catch (Exception ex) + { + logger.LogWarning(ex, "计费后自动触发失败 OrderId={OrderId} TriggerId={TriggerId}", order.Id, trigger.Id); + } + } + } + + private static readonly string[] SettlementFieldTokens = + [ + "计费重", + "体积重", + "收货渠道", + "下游服务商", + "渠道预报编码", + "预报失败原因", + "费用类型", + "费用项", + "费用金额", + "费用币种", + "费用下游服务商", + "价格编号", + "计费阶段", + "价格生效时间" + ]; + + private static bool ReferencesSettlementFields(string? condition) + { + if (string.IsNullOrWhiteSpace(condition)) + { + return false; + } + + return SettlementFieldTokens.Any(token => condition.Contains(token, StringComparison.Ordinal)); + } + + private async Task AlreadyMarkedByTriggerAsync(int orderId, int triggerId) + { + var remark = $"自动触发#{triggerId}"; + var existed = await orderExceptionEventRepository.FirstOrDefaultAsync(x => + x.OrderId == orderId && x.Remark == remark); + return existed != null; + } + private async Task ExecuteTriggerAsync( Order order, AutoTrigger trigger, @@ -92,7 +184,7 @@ public class AutoTriggerRuntimeService( if (order.IsIntercept == 0) { order.IsIntercept = 1; - await orderRepository.UpdateAsync(order); + await orderRepository.UpdateInterceptFlagAsync(order.Id, 1); } return; diff --git a/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs b/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs index 374b18a..430e184 100644 --- a/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs +++ b/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs @@ -42,9 +42,10 @@ public class OrderExceptionWorkflowService( int operatorUserId, bool sendNotification) { - if (!OrderExceptionStatusRules.CanMarkException(order.OrderStatus)) + var current = await orderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order; + if (!OrderExceptionStatusRules.CanMarkException(current.OrderStatus)) { - return OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus); + return OrderExceptionStatusRules.GetForbiddenReason(current.OrderStatus); } IssueTemplate? template = null; @@ -56,14 +57,14 @@ public class OrderExceptionWorkflowService( IssueHandlerResolution handlerResolution; try { - handlerResolution = await issueHandlerResolver.ResolveAsync(order, template, request.HandlerNames); + handlerResolution = await issueHandlerResolver.ResolveAsync(current, template, request.HandlerNames); } catch (BusinessException ex) { return ex.Message; } - var context = IssuePlaceholderContext.FromOrder(order, request.IssueType, request.Title); + var context = IssuePlaceholderContext.FromOrder(current, request.IssueType, request.Title); var renderedTitle = IssuePlaceholderRenderer.Render(request.Title, context); var renderedContent = IssuePlaceholderRenderer.Render(request.Content, context); @@ -73,28 +74,37 @@ public class OrderExceptionWorkflowService( notifyMethod = IssueNotifyMethodCodes.InApp; } - var fromStatus = order.OrderStatus; + var fromStatus = current.OrderStatus; var isAppend = string.Equals(fromStatus, OrderStatusCodes.Problematic, StringComparison.OrdinalIgnoreCase); if (!isAppend) { - order.PreviousOrderStatus = fromStatus; - order.OrderStatus = OrderStatusCodes.Problematic; + current.PreviousOrderStatus = fromStatus; + current.OrderStatus = OrderStatusCodes.Problematic; } - order.HandleStatus = OrderHandleStatusCodes.Pending; - order.IssueType = request.IssueType; - order.IssueTitle = renderedTitle; - order.IssueContent = renderedContent; - order.IssueTemplateId = request.IssueTemplateId; - order.IssueRemark = request.Remark; - order.IssueTime = DateTime.Now; - order.ClaimType = null; - order.ClaimAmount = null; - order.ClaimCurrency = null; - order.ClaimReason = null; - order.ClaimTime = null; + current.HandleStatus = OrderHandleStatusCodes.Pending; + current.IssueType = request.IssueType; + current.IssueTitle = renderedTitle; + current.IssueContent = renderedContent; + current.IssueTemplateId = request.IssueTemplateId; + current.IssueRemark = request.Remark; + current.IssueTime = DateTime.Now; + current.ClaimType = null; + current.ClaimAmount = null; + current.ClaimCurrency = null; + current.ClaimReason = null; + current.ClaimTime = null; - await orderRepository.UpdateAsync(order); + await orderRepository.UpdateAsync(current); + order.OrderStatus = current.OrderStatus; + order.PreviousOrderStatus = current.PreviousOrderStatus; + order.HandleStatus = current.HandleStatus; + order.IssueType = current.IssueType; + order.IssueTitle = current.IssueTitle; + order.IssueContent = current.IssueContent; + order.IssueTemplateId = current.IssueTemplateId; + order.IssueRemark = current.IssueRemark; + order.IssueTime = current.IssueTime; await orderExceptionEventRepository.InsertAsync(new OrderExceptionEvent { OrderId = order.Id, @@ -120,7 +130,7 @@ public class OrderExceptionWorkflowService( ? handlerResolution.NotifyUserIds : null; await issueNotificationService.DispatchAsync( - order, + current, renderedTitle, renderedContent, notifyMethod, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 2d66718..3270344 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -170,11 +170,12 @@ public partial class OrderService var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); - // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。 + // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收与订单同一事务记账,提交后各自执行。 if (input.SaveAsDraft) { var draftOutput = await BuildParcelCreateOutputAsync(order); - await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId); + ApplyPickupPlan(input, draftOutput); + await EnqueueCreateSideEffectsAsync(order.Id, input, includeForecast: false, operatorId); return draftOutput; } @@ -193,7 +194,8 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); var output = await BuildParcelCreateOutputAsync(order); - await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId); + ApplyPickupPlan(input, output); + await EnqueueCreateSideEffectsAsync(order.Id, input, includeForecast: true, operatorId); return output; } catch (BusinessException) @@ -2284,7 +2286,7 @@ public partial class OrderService } /// - /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货投递 ForecastBilling MQ。 + /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货写入后置步骤,提交后执行。 /// internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null) { @@ -2329,38 +2331,7 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); - var publisher = LazyServiceProvider.GetRequiredService(); - if (!publisher.IsEnabled) - { - var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload - { - CouponInstanceId = order.CouponInstanceId - }); - await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorId); - } - else - { - var orderId = order.Id; - var couponInstanceId = order.CouponInstanceId; - await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId); - } - - await ScheduleForecastSideEffectsAsync(order.Id, operatorId); - - // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。 - var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); - if (refreshed != null - && string.Equals(refreshed.OrderStatus, OrderStatusCodes.Draft, StringComparison.OrdinalIgnoreCase) - && !string.IsNullOrWhiteSpace(refreshed.PredeclareFailReason) - && !string.Equals( - refreshed.PredeclareFailReason, - ForecastBillingProcessingReason, - StringComparison.Ordinal)) - { - throw CreateDraftSavedBusinessException( - refreshed, - StripDraftSavedMarker(refreshed.PredeclareFailReason)); - } + await EnqueueDraftPredeclareSideEffectsAsync(order.Id, order.CouponInstanceId, operatorId); } catch (BusinessException ex) { @@ -2387,10 +2358,11 @@ public partial class OrderService private async Task MarkParcelDraftFailedAsync(Order order, string reason) { + var text = TrimPendingReason(reason); order.OrderStatus = OrderStatusCodes.Draft; - order.PredeclareFailReason = reason; + order.PredeclareFailReason = text; order.UpdateTime = DateTime.Now; - await OrderRepository.UpdateAsync(order); + await OrderRepository.UpdatePredeclareFailureAsync(order.Id, text, order.IsRemote); } /// @@ -2823,10 +2795,23 @@ public partial class OrderService // ignore } - order.OrderStatus = OrderStatusCodes.Predeclared; order.PredeclareFailReason = string.Empty; order.UpdateTime = DateTime.Now; - await OrderRepository.UpdateAsync(order); + await OrderRepository.UpdateForecastCompletionAsync(order); + var persisted = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); + if (persisted != null) + { + order.OrderStatus = persisted.OrderStatus; + order.PredeclareFailReason = persisted.PredeclareFailReason; + order.PreviousOrderStatus = persisted.PreviousOrderStatus; + order.LabelTemplateId = persisted.LabelTemplateId; + } + else + { + order.OrderStatus = OrderStatusCodes.Predeclared; + } + + await EnqueueSettlementSideEffectsAsync(order.Id, operatorUserId); var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); await OperationLogWriter.TryWriteAsync( @@ -2846,93 +2831,6 @@ public partial class OrderService public int? CouponInstanceId { get; set; } } - /// - /// 创单事务提交后,9610、预报计费、揽收、轨迹、面单、自动触发各自提交,互不等待。 - /// - private async Task ScheduleCreateSideEffectsAsync( - int orderId, - OrderParcelCreateInput input, - OrderParcelCreateOutput output, - bool includeForecast, - int operatorUserId) - { - var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); - var scopeFactory = LazyServiceProvider.GetRequiredService(); - var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderParcelCreate"); - var platformCode = input.PlatformCode; - var couponInstanceId = input.CouponInstanceId; - - await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => - { - try - { - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp => - sp.GetRequiredService().PublishCustomsForOrderAsync(orderId, platformCode)); - - var forecastQueued = true; - if (includeForecast) - { - forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => - { - var service = sp.GetRequiredService(); - await service.EnqueueForecastBillingByIdAsync(orderId, couponInstanceId, operatorUserId); - }); - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => - sp.GetRequiredService().PublishForecastTrackAsync(orderId)); - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => - sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => - sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)); - } - - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp => - sp.GetRequiredService().TryAttachPickupAfterParcelCreateAsync(input, output)); - - if (includeForecast && !forecastQueued) - { - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp => - sp.GetRequiredService().MarkForecastEnqueueFailedIfStillProcessingAsync(orderId)); - } - } - catch (Exception ex) - { - logger.LogError(ex, "创单后置步骤调度失败 OrderId={OrderId}", orderId); - } - }); - } - - private async Task ScheduleForecastEnqueueAsync(int orderId, int? couponInstanceId, int operatorUserId) - { - var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); - var scopeFactory = LazyServiceProvider.GetRequiredService(); - var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); - await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => - OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", sp => - sp.GetRequiredService().EnqueueForecastBillingByIdAsync( - orderId, - couponInstanceId, - operatorUserId))); - } - - /// - /// 创单或草稿重新预报提交后,轨迹、面单、下单自动触发各自入队,不等预报计费。 - /// - private async Task ScheduleForecastSideEffectsAsync(int orderId, int operatorUserId) - { - var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); - var scopeFactory = LazyServiceProvider.GetRequiredService(); - var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); - await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => - { - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => - sp.GetRequiredService().PublishForecastTrackAsync(orderId)); - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => - sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); - await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => - sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)); - }); - } - private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); @@ -2945,19 +2843,7 @@ public partial class OrderService await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode); } - private async Task EnqueueForecastBillingByIdAsync(int orderId, int? couponInstanceId, int operatorUserId) - { - var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null - || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId); - } - - private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) + internal async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null @@ -2969,7 +2855,7 @@ public partial class OrderService await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); } - private async Task PublishForecastTrackAsync(int orderId) + private async Task PublishForecastTrackAsync(int orderId, bool appendInitialTrack, string? authorization) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null @@ -2982,8 +2868,12 @@ public partial class OrderService ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) : null; var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); - request.AppendInitialTrackOnSuccess = true; - ApplyTrackAuthorization(request); + request.AppendInitialTrackOnSuccess = appendInitialTrack; + if (!string.IsNullOrWhiteSpace(authorization)) + { + request.Authorization = authorization.Trim(); + } + await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } diff --git a/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs b/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs new file mode 100644 index 0000000..204230f --- /dev/null +++ b/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs @@ -0,0 +1,317 @@ +using Fake.Application; +using Fake.UnitOfWork; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Oms.Application.Dtos.Orders; +using Oms.Application.Messaging; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Application.Services.Orders; + +public partial class OrderService +{ + private OrderSideEffectQueue SideEffectQueue => + LazyServiceProvider.GetRequiredService(); + + /// + /// 创单事务内写入后置步骤。保存草稿只写 9610 和揽收;确认下单再写预报、轨迹、面单和自动触发。 + /// + private async Task EnqueueCreateSideEffectsAsync( + int orderId, + OrderParcelCreateInput input, + bool includeForecast, + int operatorUserId) + { + var authorization = CaptureRequestAuthorization(); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.Customs9610, + new OrderSideEffectPayload { PlatformCode = input.PlatformCode }); + + if (includeForecast) + { + await SideEffectQueue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.ForecastBilling, + new OrderSideEffectPayload + { + CouponInstanceId = input.CouponInstanceId, + OperatorUserId = operatorUserId + }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.TrackUpsert, + new OrderSideEffectPayload + { + AppendInitialTrack = true, + Authorization = authorization, + OperatorUserId = operatorUserId + }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.LabelGenerate, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.AutoTrigger, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + } + + if (IsDoorPickup(input) && ValidateDoorPickup(input) == null) + { + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.PickupAttach, + BuildPickupPayload(input)); + } + + await KickSideEffectsAsync(); + } + + /// + /// 草稿重新预报:预报计费可再次入队;轨迹 A0101、面单和创单触发已做过则跳过。 + /// + private async Task EnqueueDraftPredeclareSideEffectsAsync( + int orderId, + int? couponInstanceId, + int operatorUserId) + { + var authorization = CaptureRequestAuthorization(); + await SideEffectQueue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.ForecastBilling, + new OrderSideEffectPayload + { + CouponInstanceId = couponInstanceId, + OperatorUserId = operatorUserId + }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.TrackUpsert, + new OrderSideEffectPayload + { + AppendInitialTrack = true, + Authorization = authorization, + OperatorUserId = operatorUserId + }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.LabelGenerate, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + await SideEffectQueue.EnqueueOnceAsync( + orderId, + OrderSideEffectSteps.AutoTrigger, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + await KickSideEffectsAsync(); + } + + /// + /// 预报计费成功后补轨迹渠道、系统面单,以及依赖计费结果的自动触发。 + /// + private async Task EnqueueSettlementSideEffectsAsync(int orderId, int operatorUserId) + { + var previousTrack = await SideEffectQueue.FindLatestPayloadAsync(orderId, OrderSideEffectSteps.TrackUpsert); + await SideEffectQueue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.TrackRefresh, + new OrderSideEffectPayload + { + AppendInitialTrack = false, + Authorization = previousTrack?.Authorization, + OperatorUserId = operatorUserId + }); + await SideEffectQueue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.LabelRefresh, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + await SideEffectQueue.EnqueueIfIdleAsync( + orderId, + OrderSideEffectSteps.AutoTriggerSettled, + new OrderSideEffectPayload { OperatorUserId = operatorUserId }); + await KickSideEffectsAsync(); + } + + private Task KickSideEffectsAsync() + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderSideEffect"); + return OrderSideEffectExecutor.ScheduleAfterCommitAsync(unitOfWorkManager, scopeFactory, logger); + } + + /// + /// 执行一条已抢占的后置步骤。由独立事务调用,失败抛出以便重试。 + /// + internal async Task ExecuteSideEffectAsync(OrderSideEffect row, CancellationToken cancellationToken = default) + { + var payload = string.IsNullOrWhiteSpace(row.PayloadJson) + ? new OrderSideEffectPayload() + : JsonConvert.DeserializeObject(row.PayloadJson) ?? new OrderSideEffectPayload(); + + switch (row.Step) + { + case OrderSideEffectSteps.Customs9610: + await PublishCustomsForOrderAsync(row.OrderId, payload.PlatformCode); + break; + case OrderSideEffectSteps.ForecastBilling: + await ExecuteForecastBillingStepAsync(row.OrderId, payload); + break; + case OrderSideEffectSteps.TrackUpsert: + await PublishForecastTrackAsync(row.OrderId, appendInitialTrack: true, payload.Authorization); + break; + case OrderSideEffectSteps.TrackRefresh: + await PublishForecastTrackAsync(row.OrderId, appendInitialTrack: false, payload.Authorization); + break; + case OrderSideEffectSteps.LabelGenerate: + case OrderSideEffectSteps.LabelRefresh: + await PublishForecastLabelAsync(row.OrderId, payload.OperatorUserId); + break; + case OrderSideEffectSteps.AutoTrigger: + await PublishForecastAutoTriggerAsync(row.OrderId, payload.OperatorUserId); + break; + case OrderSideEffectSteps.AutoTriggerSettled: + await PublishSettledAutoTriggerAsync(row.OrderId, payload.OperatorUserId); + break; + case OrderSideEffectSteps.PickupAttach: + await ExecutePickupSideEffectAsync(row.OrderId, payload); + break; + default: + LazyServiceProvider.GetRequiredService() + .CreateLogger("Oms.OrderSideEffect") + .LogWarning("未知的订单后置步骤 Step={Step} OrderId={OrderId}", row.Step, row.OrderId); + break; + } + } + + private async Task ExecuteForecastBillingStepAsync(int orderId, OrderSideEffectPayload payload) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!string.Equals(order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal)) + { + return; + } + + await EnqueueForecastBillingAsync(order, payload.CouponInstanceId, payload.OperatorUserId); + } + + private async Task PublishSettledAutoTriggerAsync(int orderId, int operatorUserId) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await AutoTriggerRuntimeService.FireSettledByOrderIdAsync(orderId, operatorUserId); + } + + private async Task ExecutePickupSideEffectAsync(int orderId, OrderSideEffectPayload payload) + { + var input = new OrderParcelCreateInput + { + CustomerId = payload.CustomerId, + InboundMethod = payload.InboundMethod, + SenderAddressId = payload.SenderAddressId, + WarehouseId = payload.WarehouseId, + PickupDirection = payload.PickupDirection, + CarrierCode = payload.CarrierCode, + CarrierService = payload.CarrierService, + ExpectPickupTime = payload.ExpectPickupTime + }; + var output = new OrderParcelCreateOutput { OrderId = orderId }; + await TryAttachPickupAfterParcelCreateAsync(input, output); + if (string.IsNullOrWhiteSpace(output.PickupCreateError)) + { + return; + } + + if (output.PickupCreateError.Contains("已绑定", StringComparison.Ordinal) + || output.PickupCreateError.Contains("已在当前揽收单", StringComparison.Ordinal)) + { + return; + } + + throw new BusinessException(output.PickupCreateError); + } + + private void ApplyPickupPlan(OrderParcelCreateInput input, OrderParcelCreateOutput output) + { + if (!IsDoorPickup(input)) + { + return; + } + + var error = ValidateDoorPickup(input); + if (error != null) + { + output.PickupCreateError = error; + return; + } + + output.PickupQueued = true; + } + + private static bool IsDoorPickup(OrderParcelCreateInput input) => + string.Equals(input.InboundMethod?.Trim(), OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase); + + private static string? ValidateDoorPickup(OrderParcelCreateInput input) + { + if (input.SenderAddressId is not > 0) + { + return "上门揽收需选择揽收地址"; + } + + if (input.ExpectPickupTime is null || input.ExpectPickupTime == default) + { + return "上门揽收需填写期望揽收时间"; + } + + if (input.WarehouseId is not > 0) + { + return "上门揽收需选择仓库"; + } + + return null; + } + + private static OrderSideEffectPayload BuildPickupPayload(OrderParcelCreateInput input) => + new() + { + CustomerId = input.CustomerId, + SenderAddressId = input.SenderAddressId, + WarehouseId = input.WarehouseId, + PickupDirection = input.PickupDirection, + CarrierCode = input.CarrierCode, + CarrierService = input.CarrierService, + ExpectPickupTime = input.ExpectPickupTime, + InboundMethod = input.InboundMethod + }; + + private string? CaptureRequestAuthorization() + { + try + { + return LazyServiceProvider + .GetRequiredService() + .HttpContext? + .Request + .Headers + .Authorization + .ToString(); + } + catch (ObjectDisposedException) + { + return null; + } + } +} diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index eaccbe2..ff7c50b 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -262,10 +262,10 @@ public partial class OrderService : ApplicationService } /// - /// 执行订单预报。 + /// 执行订单预报。校验通过后订单保持草稿并标记处理中,计费和排货在事务提交后执行。 /// /// 入参:订单操作参数。 - /// 无返回体,仅完成订单预报处理。 + /// 无返回体。预报结果随后写回订单状态和失败原因。 [HttpPost("/order/pre-declare")] [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public async Task PreDeclareAsync(OrderActionInput input) diff --git a/src/Oms.Application/Workers/OrderSideEffectWorker.cs b/src/Oms.Application/Workers/OrderSideEffectWorker.cs new file mode 100644 index 0000000..9f28943 --- /dev/null +++ b/src/Oms.Application/Workers/OrderSideEffectWorker.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Oms.Application.Messaging; + +namespace Oms.Application.Workers; + +/// +/// 轮询已提交的创单后置步骤。进程在事务提交后、步骤执行前退出时,靠这里补跑。 +/// +public sealed class OrderSideEffectWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await OrderSideEffectExecutor.RecoverStuckForecastAsync(scopeFactory, logger, stoppingToken); + await OrderSideEffectExecutor.ProcessDueAsync(scopeFactory, logger, stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "创单后置步骤轮询失败"); + } + + try + { + await Task.Delay(PollInterval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } +} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs new file mode 100644 index 0000000..9fc3d01 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs @@ -0,0 +1,44 @@ +namespace Oms.Domain.OrdersAggregate.Consts; + +/// +/// 创单后置步骤状态。同一订单、同一步骤不建唯一索引,是否重复入队由业务判断。 +/// +public static class OrderSideEffectStatusCodes +{ + public const string Pending = "pending"; + + public const string Running = "running"; + + public const string Done = "done"; + + public const string Failed = "failed"; +} + +/// +/// 创单/预报提交后要单独执行的步骤。 +/// +public static class OrderSideEffectSteps +{ + public const string Customs9610 = "customs-9610"; + + public const string ForecastBilling = "forecast-billing"; + + /// 创建轨迹订单并追加 A0101,同一订单只做一次。 + public const string TrackUpsert = "track-upsert"; + + /// 排货完成后按最新渠道再同步一次轨迹,不追加 A0101。 + public const string TrackRefresh = "track-refresh"; + + public const string LabelGenerate = "label-generate"; + + /// 排货完成后重做系统面单,覆盖创单时还没有渠道的那张。 + public const string LabelRefresh = "label-refresh"; + + /// 下单时机的自动触发。此时还没有计费重、应收和渠道。 + public const string AutoTrigger = "auto-trigger"; + + /// 预报计费成功后再跑依赖计费结果的规则,以及草稿阶段没能打上的问题件。 + public const string AutoTriggerSettled = "auto-trigger-settled"; + + public const string PickupAttach = "pickup-attach"; +} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs new file mode 100644 index 0000000..feb5471 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs @@ -0,0 +1,30 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.OrdersAggregate.Entities; + +/// +/// 与订单同一事务写入的后置步骤。事务回滚则步骤一起消失;提交后由后台执行,进程重启仍可继续。 +/// +[SugarTable("t_order_side_effect")] +public class OrderSideEffect : OmsAuditedAggregateRoot +{ + public int OrderId { get; set; } + + [SugarColumn(Length = 64)] + public string Step { get; set; } = string.Empty; + + [SugarColumn(Length = 32)] + public string Status { get; set; } = string.Empty; + + [SugarColumn(ColumnDataType = "text", IsNullable = true)] + public string? PayloadJson { get; set; } + + public int AttemptCount { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? NextRunTime { get; set; } + + [SugarColumn(Length = 512, IsNullable = true)] + public string? LastError { get; set; } +} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index c5d8ab0..cf35dfd 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -137,4 +137,23 @@ public interface IOrderRepository : IRepository /// 只更新标签模板 Id,避免面单生成覆盖预报计费正在写入的订单字段。 /// Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default); + + /// + /// 预报失败时只写状态和原因,不覆盖渠道、面单模板、问题件字段。 + /// + Task UpdatePredeclareFailureAsync( + int orderId, + string reason, + byte? isRemote = null, + CancellationToken cancellationToken = default); + + /// + /// 预报计费成功时只写计费/排货字段。订单已是问题件或理赔件时不改状态,只把异常前状态改成已预报。 + /// + Task UpdateForecastCompletionAsync(Order order, CancellationToken cancellationToken = default); + + /// + /// 只更新拦截标记。 + /// + Task UpdateInterceptFlagAsync(int orderId, byte isIntercept, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs new file mode 100644 index 0000000..7d7e724 --- /dev/null +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs @@ -0,0 +1,55 @@ +using Fake.Domain.Repositories; +using Oms.Domain.OrdersAggregate.Entities; + +namespace Oms.Domain.OrdersAggregate.Repositories; + +public interface IOrderSideEffectRepository : IRepository +{ + /// + /// 是否已有未结束(以及可选的已完成)步骤。用于避免重复入队。 + /// + Task ExistsAsync( + int orderId, + string step, + bool includeDone, + CancellationToken cancellationToken = default); + + Task> ListDueIdsAsync(int take, DateTime now, CancellationToken cancellationToken = default); + + /// + /// 取同一步骤最近一条入参,用于把创单时捕获的轨迹授权头带到排货后的刷新。 + /// + Task FindLatestPayloadJsonAsync( + int orderId, + string step, + CancellationToken cancellationToken = default); + + /// + /// 仅当当前仍是 pending 时改为 running,并增加尝试次数。 + /// + Task TryClaimAsync(int id, DateTime now, CancellationToken cancellationToken = default); + + Task MarkDoneAsync(int id, DateTime now, CancellationToken cancellationToken = default); + + Task MarkRetryAsync( + int id, + string status, + string? lastError, + DateTime? nextRunTime, + DateTime now, + CancellationToken cancellationToken = default); + + /// + /// 执行过程中进程退出时,把长时间停在 running 的步骤放回 pending。 + /// + Task ResetStaleRunningAsync(DateTime staleBefore, DateTime now, CancellationToken cancellationToken = default); + + /// + /// 仍显示「预报计费/排货处理中」、且没有待执行、执行中或已完成预报步骤的订单。 + /// + Task> ListStuckForecastOrderIdsAsync( + string processingReason, + DateTime olderThan, + int take, + CancellationToken cancellationToken = default); +} diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index c85d43f..f9ad549 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1505,4 +1505,85 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I .Where(x => x.OrderStatus != OrderStatusCodes.Deleted) .ToListAsync(cancellationToken); } + + public async Task UpdatePredeclareFailureAsync( + int orderId, + string reason, + byte? isRemote = null, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var now = DateTime.Now; + var update = db.Updateable() + .SetColumns(x => x.OrderStatus == OrderStatusCodes.Draft) + .SetColumns(x => x.PredeclareFailReason == reason) + .SetColumns(x => x.UpdateTime == now); + if (isRemote.HasValue) + { + var remote = isRemote.Value; + update = update.SetColumns(x => x.IsRemote == remote); + } + + await update.Where(x => x.Id == orderId).ExecuteCommandAsync(cancellationToken); + } + + public async Task UpdateForecastCompletionAsync(Order order, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var now = DateTime.Now; + await db.Updateable() + .SetColumns(x => x.IsRemote == order.IsRemote) + .SetColumns(x => x.ChannelCode == order.ChannelCode) + .SetColumns(x => x.DownstreamSupplierCode == order.DownstreamSupplierCode) + .SetColumns(x => x.DownstreamSupplierName == order.DownstreamSupplierName) + .SetColumns(x => x.DownstreamAppKey == order.DownstreamAppKey) + .SetColumns(x => x.DownstreamAppId == order.DownstreamAppId) + .SetColumns(x => x.ChannelForecastCode == order.ChannelForecastCode) + .SetColumns(x => x.DownstreamServiceNo == order.DownstreamServiceNo) + .SetColumns(x => x.TrackNo == order.TrackNo) + .SetColumns(x => x.AllocationPendingReason == order.AllocationPendingReason) + .SetColumns(x => x.AllocationScheme == order.AllocationScheme) + .SetColumns(x => x.AllocationTraceJson == order.AllocationTraceJson) + .SetColumns(x => x.AllocationTraceTime == order.AllocationTraceTime) + .SetColumns(x => x.PredeclareFailReason == string.Empty) + .SetColumns(x => x.MemberLevelId == order.MemberLevelId) + .SetColumns(x => x.PriceTierKey == order.PriceTierKey) + .SetColumns(x => x.ReceivableSubtotal == order.ReceivableSubtotal) + .SetColumns(x => x.LevelDiscountAmount == order.LevelDiscountAmount) + .SetColumns(x => x.CouponInstanceId == order.CouponInstanceId) + .SetColumns(x => x.CouponDiscountAmount == order.CouponDiscountAmount) + .SetColumns(x => x.PayableAmount == order.PayableAmount) + .SetColumns(x => x.UpdateTime == now) + .Where(x => x.Id == order.Id) + .ExecuteCommandAsync(cancellationToken); + + await db.Updateable() + .SetColumns(x => x.OrderStatus == OrderStatusCodes.Predeclared) + .SetColumns(x => x.UpdateTime == now) + .Where(x => x.Id == order.Id + && x.OrderStatus != OrderStatusCodes.Problematic + && x.OrderStatus != OrderStatusCodes.Claimed) + .ExecuteCommandAsync(cancellationToken); + + await db.Updateable() + .SetColumns(x => x.PreviousOrderStatus == OrderStatusCodes.Predeclared) + .SetColumns(x => x.UpdateTime == now) + .Where(x => x.Id == order.Id + && (x.OrderStatus == OrderStatusCodes.Problematic + || x.OrderStatus == OrderStatusCodes.Claimed)) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task UpdateInterceptFlagAsync( + int orderId, + byte isIntercept, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Updateable() + .SetColumns(x => x.IsIntercept == isIntercept) + .SetColumns(x => x.UpdateTime == DateTime.Now) + .Where(x => x.Id == orderId) + .ExecuteCommandAsync(cancellationToken); + } } diff --git a/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs new file mode 100644 index 0000000..4d6290a --- /dev/null +++ b/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs @@ -0,0 +1,153 @@ +using Fake.DependencyInjection; +using Oms.Domain.OrdersAggregate.Consts; +using SqlSugar; +using Oms.Domain.OrdersAggregate.Entities; +using Oms.Domain.OrdersAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Orders; + +public class OrderSideEffectRepository : OmsSqlSugarRepository, IOrderSideEffectRepository, IScopedDependency +{ + public async Task ExistsAsync( + int orderId, + string step, + bool includeDone, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var statuses = includeDone + ? new[] + { + OrderSideEffectStatusCodes.Pending, + OrderSideEffectStatusCodes.Running, + OrderSideEffectStatusCodes.Done + } + : new[] + { + OrderSideEffectStatusCodes.Pending, + OrderSideEffectStatusCodes.Running + }; + + return await db.Queryable() + .AnyAsync( + x => x.OrderId == orderId + && x.Step == step + && statuses.Contains(x.Status) + && x.IsDeleted == false, + cancellationToken); + } + + public async Task> ListDueIdsAsync(int take, DateTime now, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(x => x.Status == OrderSideEffectStatusCodes.Pending + && x.IsDeleted == false + && (x.NextRunTime == null || x.NextRunTime <= now)) + .OrderBy(x => x.Id) + .Take(take) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + } + + public async Task FindLatestPayloadJsonAsync( + int orderId, + string step, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var rows = await db.Queryable() + .Where(x => x.OrderId == orderId && x.Step == step && x.IsDeleted == false) + .OrderByDescending(x => x.Id) + .Take(1) + .Select(x => x.PayloadJson) + .ToListAsync(cancellationToken); + return rows.FirstOrDefault(); + } + + public async Task TryClaimAsync(int id, DateTime now, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var affected = await db.Updateable() + .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Running) + .SetColumns(x => x.UpdateTime == now) + .SetColumns(x => x.AttemptCount == x.AttemptCount + 1) + .Where(x => x.Id == id + && x.Status == OrderSideEffectStatusCodes.Pending + && x.IsDeleted == false) + .ExecuteCommandAsync(cancellationToken); + return affected == 1; + } + + public async Task MarkDoneAsync(int id, DateTime now, CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Updateable() + .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Done) + .SetColumns(x => x.UpdateTime == now) + .SetColumns(x => x.LastError == null) + .Where(x => x.Id == id) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task MarkRetryAsync( + int id, + string status, + string? lastError, + DateTime? nextRunTime, + DateTime now, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + await db.Updateable() + .SetColumns(x => x.Status == status) + .SetColumns(x => x.LastError == lastError) + .SetColumns(x => x.NextRunTime == nextRunTime) + .SetColumns(x => x.UpdateTime == now) + .Where(x => x.Id == id) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task ResetStaleRunningAsync( + DateTime staleBefore, + DateTime now, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Updateable() + .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Pending) + .SetColumns(x => x.NextRunTime == now) + .SetColumns(x => x.UpdateTime == now) + .Where(x => x.Status == OrderSideEffectStatusCodes.Running + && x.IsDeleted == false + && x.UpdateTime < staleBefore) + .ExecuteCommandAsync(cancellationToken); + } + + public async Task> ListStuckForecastOrderIdsAsync( + string processingReason, + DateTime olderThan, + int take, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + return await db.Queryable() + .Where(order => order.IsDeleted == false + && order.PredeclareFailReason == processingReason + && order.UpdateTime < olderThan + && order.UpdateTime > DateTime.MinValue) + .Where(order => !SqlSugar.SqlFunc.Subqueryable() + .Where(effect => effect.OrderId == order.Id + && effect.Step == OrderSideEffectSteps.ForecastBilling + && effect.IsDeleted == false + && (effect.Status == OrderSideEffectStatusCodes.Pending + || effect.Status == OrderSideEffectStatusCodes.Running + || effect.Status == OrderSideEffectStatusCodes.Done)) + .Any()) + .OrderBy(order => order.Id) + .Take(take) + .Select(order => order.Id) + .ToListAsync(cancellationToken); + } +} -- 2.45.2 From 5fd25d479c10ff06535947505d26c4b13d2ccd87 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 15 Oct 2026 09:05:26 +0800 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20=E5=8E=BB=E6=8E=89=E5=88=9B?= =?UTF-8?q?=E5=8D=95=E5=90=8E=E7=BD=AE=E6=AD=A5=E9=AA=A4=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E8=AE=A2=E5=8D=95=E6=94=B9=E5=9C=A8=E6=8E=92?= =?UTF-8?q?=E8=B4=A7=E6=88=90=E5=8A=9F=E5=90=8E=E5=88=9B=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回到创单提交后各自投递;并在下单接口注释同步与 MQ 步骤。 Co-authored-by: Cursor --- db.sql | 21 -- sql/drop_order_side_effect.sql | 20 ++ sql/migrate_order_side_effect.sql | 39 --- .../Messaging/OmsAfterCommit.cs | 59 ++-- .../Messaging/OrderSideEffectExecutor.cs | 202 ----------- .../Messaging/OrderSideEffectPayload.cs | 34 -- .../Messaging/OrderSideEffectQueue.cs | 83 ----- src/Oms.Application/OmsApplicationModule.cs | 1 - .../Orders/Batch/OrderBatchJobProcessor.cs | 5 +- .../Issues/AutoTriggerRuntimeService.cs | 94 +----- .../Issues/OrderExceptionWorkflowService.cs | 52 ++- .../Orders/OrderService.ParcelBilling.cs | 195 +++++++++-- .../Orders/OrderService.SideEffects.cs | 317 ------------------ .../Services/Orders/OrderService.cs | 34 +- .../Workers/OrderSideEffectWorker.cs | 41 --- .../Consts/OrderSideEffectCodes.cs | 44 --- .../Entities/OrderSideEffect.cs | 30 -- .../Repositories/IOrderRepository.cs | 19 -- .../IOrderSideEffectRepository.cs | 55 --- .../Orders/OrderRepository.cs | 81 ----- .../Orders/OrderSideEffectRepository.cs | 153 --------- 21 files changed, 268 insertions(+), 1311 deletions(-) create mode 100644 sql/drop_order_side_effect.sql delete mode 100644 sql/migrate_order_side_effect.sql delete mode 100644 src/Oms.Application/Messaging/OrderSideEffectExecutor.cs delete mode 100644 src/Oms.Application/Messaging/OrderSideEffectPayload.cs delete mode 100644 src/Oms.Application/Messaging/OrderSideEffectQueue.cs delete mode 100644 src/Oms.Application/Services/Orders/OrderService.SideEffects.cs delete mode 100644 src/Oms.Application/Workers/OrderSideEffectWorker.cs delete mode 100644 src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs delete mode 100644 src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs delete mode 100644 src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs delete mode 100644 src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs diff --git a/db.sql b/db.sql index 199ec6e..3f1111e 100644 --- a/db.sql +++ b/db.sql @@ -101,7 +101,6 @@ DROP TABLE IF EXISTS `t_order_weight`; DROP TABLE IF EXISTS `t_order_declaration`; DROP TABLE IF EXISTS `t_order_recipient`; DROP TABLE IF EXISTS `t_order_sender`; -DROP TABLE IF EXISTS `t_order_side_effect`; DROP TABLE IF EXISTS `t_order_batch_job_item`; DROP TABLE IF EXISTS `t_order_batch_job`; DROP TABLE IF EXISTS `t_order_template`; @@ -307,26 +306,6 @@ CREATE TABLE `t_order_template` ( KEY `idx_ord_tpl_customer_type` (`CustomerId`, `Type`) ) COMMENT='下单模板表'; -CREATE TABLE `t_order_side_effect` ( - `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', - `OrderId` INT NOT NULL COMMENT '订单 Id', - `Step` VARCHAR(64) NOT NULL COMMENT '步骤:customs-9610/forecast-billing/track-upsert 等', - `Status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/running/done/failed', - `PayloadJson` TEXT NULL COMMENT '步骤入参', - `AttemptCount` INT NOT NULL DEFAULT 0 COMMENT '已尝试次数', - `NextRunTime` DATETIME NULL COMMENT '下次可执行时间', - `LastError` VARCHAR(512) NULL COMMENT '最近一次失败原因', - `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', - `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', - `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', - `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `UpdateUserId` INT NULL COMMENT '更新人', - `UpdateTime` DATETIME NULL COMMENT '更新时间', - PRIMARY KEY (`Id`), - KEY `idx_order_side_effect_due` (`Status`, `NextRunTime`), - KEY `idx_order_side_effect_order` (`OrderId`, `Step`) -) COMMENT='创单后置步骤'; - CREATE TABLE `t_order_batch_job` ( `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1', `Action` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '批量操作类型:pay/pre-declare/intercept/parcel-create 等', diff --git a/sql/drop_order_side_effect.sql b/sql/drop_order_side_effect.sql new file mode 100644 index 0000000..04869f6 --- /dev/null +++ b/sql/drop_order_side_effect.sql @@ -0,0 +1,20 @@ +-- 删除创单后置步骤表(已不再使用)。可重复执行。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_order_side_effect' +); + +SET @sql := IF( + @exists > 0, + 'DROP TABLE `t_order_side_effect`', + 'SELECT ''t_order_side_effect already dropped'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/sql/migrate_order_side_effect.sql b/sql/migrate_order_side_effect.sql deleted file mode 100644 index 20fbf7a..0000000 --- a/sql/migrate_order_side_effect.sql +++ /dev/null @@ -1,39 +0,0 @@ --- 创单后置步骤表:与订单同一事务写入,提交后由后台执行。 --- 可重复执行。不建唯一索引。 - -SET @db := DATABASE(); - -SET @exists := ( - SELECT COUNT(1) - FROM information_schema.TABLES - WHERE TABLE_SCHEMA = @db - AND TABLE_NAME = 't_order_side_effect' -); - -SET @sql := IF( - @exists = 0, - 'CREATE TABLE `t_order_side_effect` ( - `Id` INT NOT NULL AUTO_INCREMENT COMMENT ''主键'', - `OrderId` INT NOT NULL COMMENT ''订单 Id'', - `Step` VARCHAR(64) NOT NULL COMMENT ''步骤:customs-9610/forecast-billing/track-upsert 等'', - `Status` VARCHAR(32) NOT NULL DEFAULT ''pending'' COMMENT ''pending/running/done/failed'', - `PayloadJson` TEXT NULL COMMENT ''步骤入参,含轨迹授权头,日志中不要打印全文'', - `AttemptCount` INT NOT NULL DEFAULT 0 COMMENT ''已尝试次数'', - `NextRunTime` DATETIME NULL COMMENT ''下次可执行时间'', - `LastError` VARCHAR(512) NULL COMMENT ''最近一次失败原因'', - `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除:0-否,1-是'', - `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT ''并发戳'', - `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT ''创建人'', - `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ''创建时间'', - `UpdateUserId` INT NULL COMMENT ''更新人'', - `UpdateTime` DATETIME NULL COMMENT ''更新时间'', - PRIMARY KEY (`Id`), - KEY `idx_order_side_effect_due` (`Status`, `NextRunTime`), - KEY `idx_order_side_effect_order` (`OrderId`, `Step`) - ) COMMENT=''创单后置步骤''', - 'SELECT ''t_order_side_effect already exists'' AS info' -); - -PREPARE stmt FROM @sql; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Messaging/OmsAfterCommit.cs b/src/Oms.Application/Messaging/OmsAfterCommit.cs index deda445..9b31ee7 100644 --- a/src/Oms.Application/Messaging/OmsAfterCommit.cs +++ b/src/Oms.Application/Messaging/OmsAfterCommit.cs @@ -1,9 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Fake.UnitOfWork; namespace Oms.Application.Messaging; /// -/// 当前事务提交并释放后再执行。回滚不会执行。 +/// 把创单/预报之后的副作用延后到当前事务提交成功再执行,每一步单独提交。 /// internal static class OmsAfterCommit { @@ -12,36 +14,13 @@ internal static class OmsAfterCommit var current = unitOfWorkManager.Current; if (current != null && !current.IsCompleted) { - // OnCompleted 仍处在业务事务里,此时写 Outbox 征用不到连接。 - // 等工作单元成功释放后再开独立事务。回滚时 IsCompleted 为 false,不跑。 + // OnCompleted 仍处在创单事务里,此时再写 Outbox 征用不到连接,事件会丢。 + // 等工作单元释放后再开独立事务。 EventHandler handler = null!; handler = (_, _) => { current.Disposed -= handler; - if (!current.IsCompleted) - { - return; - } - - var suppressed = ExecutionContext.SuppressFlow(); - try - { - _ = Task.Run(async () => - { - try - { - await action(); - } - catch - { - // 具体步骤在执行器里记日志。这里避免未观察的任务异常。 - } - }); - } - finally - { - suppressed.Dispose(); - } + _ = Task.Run(action); }; current.Disposed += handler; return Task.CompletedTask; @@ -49,4 +28,30 @@ internal static class OmsAfterCommit return action(); } + + /// + /// 新开独立事务执行一步。失败只记日志,不抛回调用方。 + /// + public static async Task RunStepAsync( + IServiceScopeFactory scopeFactory, + ILogger logger, + string step, + Func work) + { + try + { + using var scope = scopeFactory.CreateScope(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); + await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); + await work(scope.ServiceProvider); + await unitOfWork.CompleteAsync(); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "订单后置步骤失败 Step={Step}", step); + return false; + } + } } diff --git a/src/Oms.Application/Messaging/OrderSideEffectExecutor.cs b/src/Oms.Application/Messaging/OrderSideEffectExecutor.cs deleted file mode 100644 index bf46bc5..0000000 --- a/src/Oms.Application/Messaging/OrderSideEffectExecutor.cs +++ /dev/null @@ -1,202 +0,0 @@ -using Fake.Application; -using Fake.DependencyInjection; -using Fake.UnitOfWork; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Oms.Application.Services.Orders; -using Oms.Domain.OrdersAggregate.Consts; -using Oms.Domain.OrdersAggregate.Repositories; - -namespace Oms.Application.Messaging; - -/// -/// 每一步后置任务单独开事务执行。失败按次数重试,超过次数记失败。 -/// -public sealed class OrderSideEffectExecutor( - IOrderSideEffectRepository repository, - IUnitOfWorkManager unitOfWorkManager, - OrderService orderService, - ILogger logger) : IScopedDependency -{ - private static readonly TimeSpan StaleRunning = TimeSpan.FromMinutes(2); - - /// - /// 当前事务提交成功后再执行到期步骤。回滚不会触发。 - /// - public static Task ScheduleAfterCommitAsync( - IUnitOfWorkManager unitOfWorkManager, - IServiceScopeFactory scopeFactory, - ILogger logger) - { - return OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => ProcessDueAsync(scopeFactory, logger)); - } - - /// - /// 捞出到期步骤并逐条执行。可重复调用,抢占失败的步骤会被跳过。 - /// - public static async Task ProcessDueAsync( - IServiceScopeFactory scopeFactory, - ILogger logger, - CancellationToken cancellationToken = default) - { - List ids; - try - { - using var scope = scopeFactory.CreateScope(); - var repository = scope.ServiceProvider.GetRequiredService(); - var now = DateTime.Now; - await repository.ResetStaleRunningAsync(now.Subtract(StaleRunning), now, cancellationToken); - ids = await repository.ListDueIdsAsync(30, now, cancellationToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogError(ex, "捞取创单后置步骤失败"); - return; - } - - foreach (var id in ids) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - try - { - using var scope = scopeFactory.CreateScope(); - var executor = scope.ServiceProvider.GetRequiredService(); - await executor.ExecuteAsync(id, cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "订单后置步骤执行失败 SideEffectId={SideEffectId}", id); - } - } - } - - /// - /// 仍停在「预报计费/排货处理中」且没有未完成预报步骤的订单,补一条预报计费步骤。 - /// - public static async Task RecoverStuckForecastAsync( - IServiceScopeFactory scopeFactory, - ILogger logger, - CancellationToken cancellationToken = default) - { - try - { - using var scope = scopeFactory.CreateScope(); - var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - var repository = scope.ServiceProvider.GetRequiredService(); - var queue = scope.ServiceProvider.GetRequiredService(); - var olderThan = DateTime.Now.AddSeconds(-90); - var orderIds = await repository.ListStuckForecastOrderIdsAsync( - OrderService.ForecastBillingProcessingReason, - olderThan, - 20, - cancellationToken); - foreach (var orderId in orderIds) - { - await queue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.ForecastBilling, - new OrderSideEffectPayload(), - cancellationToken); - } - - await unitOfWork.CompleteAsync(cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "补投递卡住的预报计费失败"); - } - } - - private async Task ExecuteAsync(int id, CancellationToken cancellationToken) - { - var claimed = await ClaimAsync(id, cancellationToken); - if (!claimed) - { - return; - } - - var row = await repository.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - if (row == null) - { - return; - } - - try - { - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - await OmsIntegrationEventPublisher.EnsureOutboxDbContextAsync(unitOfWorkManager); - await orderService.ExecuteSideEffectAsync(row, cancellationToken); - await repository.MarkDoneAsync(row.Id, DateTime.Now, cancellationToken); - await unitOfWork.CompleteAsync(cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "订单后置步骤失败 Step={Step} OrderId={OrderId}", row.Step, row.OrderId); - var exhausted = await MarkRetryAsync(row, ex, cancellationToken); - if (exhausted - && string.Equals(row.Step, OrderSideEffectSteps.ForecastBilling, StringComparison.Ordinal)) - { - await StampForecastEnqueueFailureAsync(row.OrderId, cancellationToken); - } - } - } - - private async Task ClaimAsync(int id, CancellationToken cancellationToken) - { - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - var claimed = await repository.TryClaimAsync(id, DateTime.Now, cancellationToken); - await unitOfWork.CompleteAsync(cancellationToken); - return claimed; - } - - private async Task MarkRetryAsync( - Domain.OrdersAggregate.Entities.OrderSideEffect row, - Exception ex, - CancellationToken cancellationToken) - { - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - var latest = await repository.FirstOrDefaultAsync(x => x.Id == row.Id, cancellationToken); - var attempts = latest?.AttemptCount ?? row.AttemptCount; - var exhausted = attempts >= OrderSideEffectQueue.MaxAttempts; - var message = ex is BusinessException business ? business.Message : ex.Message; - if (string.IsNullOrWhiteSpace(message)) - { - message = ex.GetType().Name; - } - - if (message.Length > 512) - { - message = message[..512]; - } - - var now = DateTime.Now; - await repository.MarkRetryAsync( - row.Id, - exhausted ? OrderSideEffectStatusCodes.Failed : OrderSideEffectStatusCodes.Pending, - message, - exhausted ? null : now.AddSeconds(15 * Math.Max(1, attempts)), - now, - cancellationToken); - await unitOfWork.CompleteAsync(cancellationToken); - return exhausted; - } - - private async Task StampForecastEnqueueFailureAsync(int orderId, CancellationToken cancellationToken) - { - try - { - using var unitOfWork = unitOfWorkManager.Begin(OmsOutboxUnitOfWork.Independent()); - await orderService.MarkForecastEnqueueFailedIfStillProcessingAsync(orderId); - await unitOfWork.CompleteAsync(cancellationToken); - } - catch (Exception ex) - { - logger.LogError(ex, "回写预报计费投递失败原因失败 OrderId={OrderId}", orderId); - } - } -} diff --git a/src/Oms.Application/Messaging/OrderSideEffectPayload.cs b/src/Oms.Application/Messaging/OrderSideEffectPayload.cs deleted file mode 100644 index 8f629c5..0000000 --- a/src/Oms.Application/Messaging/OrderSideEffectPayload.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace Oms.Application.Messaging; - -/// -/// 后置步骤入参。轨迹授权头只在执行时使用,不要写进日志。 -/// -public sealed class OrderSideEffectPayload -{ - public int OperatorUserId { get; set; } - - public string? PlatformCode { get; set; } - - public int? CouponInstanceId { get; set; } - - /// 为 true 时轨迹创建成功后追加 A0101。 - public bool AppendInitialTrack { get; set; } - - public string? Authorization { get; set; } - - public int? CustomerId { get; set; } - - public int? SenderAddressId { get; set; } - - public int? WarehouseId { get; set; } - - public string? PickupDirection { get; set; } - - public string? CarrierCode { get; set; } - - public string? CarrierService { get; set; } - - public DateTime? ExpectPickupTime { get; set; } - - public string? InboundMethod { get; set; } -} diff --git a/src/Oms.Application/Messaging/OrderSideEffectQueue.cs b/src/Oms.Application/Messaging/OrderSideEffectQueue.cs deleted file mode 100644 index 1bd78a4..0000000 --- a/src/Oms.Application/Messaging/OrderSideEffectQueue.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Fake.DependencyInjection; -using Newtonsoft.Json; -using Oms.Domain.OrdersAggregate.Consts; -using Oms.Domain.OrdersAggregate.Entities; -using Oms.Domain.OrdersAggregate.Repositories; - -namespace Oms.Application.Messaging; - -/// -/// 把创单后置步骤写入当前事务。事务回滚时这些行一起消失。 -/// -public sealed class OrderSideEffectQueue(IOrderSideEffectRepository repository) : IScopedDependency -{ - public const int MaxAttempts = 5; - - /// - /// 同一步骤已有 pending、running 或 done 时不再插入。用于 A0101、揽收和创单当下的自动触发。 - /// - public Task EnqueueOnceAsync( - int orderId, - string step, - OrderSideEffectPayload? payload, - CancellationToken cancellationToken = default) => - EnqueueAsync(orderId, step, payload, includeDone: true, cancellationToken); - - /// - /// 没有 pending 或 running 时才插入。已完成的步骤允许再入队,例如重新预报、排货后刷新轨迹。 - /// - public Task EnqueueIfIdleAsync( - int orderId, - string step, - OrderSideEffectPayload? payload, - CancellationToken cancellationToken = default) => - EnqueueAsync(orderId, step, payload, includeDone: false, cancellationToken); - - /// - /// 读取同一步骤最近一次入参。没有记录时返回 null。 - /// - public async Task FindLatestPayloadAsync( - int orderId, - string step, - CancellationToken cancellationToken = default) - { - var json = await repository.FindLatestPayloadJsonAsync(orderId, step, cancellationToken); - if (string.IsNullOrWhiteSpace(json)) - { - return null; - } - - return JsonConvert.DeserializeObject(json); - } - - private async Task EnqueueAsync( - int orderId, - string step, - OrderSideEffectPayload? payload, - bool includeDone, - CancellationToken cancellationToken) - { - if (orderId <= 0 || string.IsNullOrWhiteSpace(step)) - { - return; - } - - if (await repository.ExistsAsync(orderId, step, includeDone, cancellationToken)) - { - return; - } - - var now = DateTime.Now; - await repository.InsertAsync(new OrderSideEffect - { - OrderId = orderId, - Step = step, - Status = OrderSideEffectStatusCodes.Pending, - PayloadJson = payload == null ? null : JsonConvert.SerializeObject(payload), - AttemptCount = 0, - NextRunTime = now, - CreateTime = now, - UpdateTime = now - }, cancellationToken); - } -} diff --git a/src/Oms.Application/OmsApplicationModule.cs b/src/Oms.Application/OmsApplicationModule.cs index 1df839c..c24e699 100644 --- a/src/Oms.Application/OmsApplicationModule.cs +++ b/src/Oms.Application/OmsApplicationModule.cs @@ -65,6 +65,5 @@ public class OmsApplicationModule : FakeModule context.Services.AddAiLlmClient(); context.Services.AddHostedService(); context.Services.AddHostedService(); - context.Services.AddHostedService(); } } diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs index 8930e13..071ad64 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchJobProcessor.cs @@ -182,7 +182,10 @@ public sealed class OrderBatchJobProcessor( text = text[..512]; } - await orderRepository.UpdatePredeclareFailureAsync(order.Id, text, cancellationToken: cancellationToken); + order.OrderStatus = OrderStatusCodes.Draft; + order.PredeclareFailReason = text; + order.UpdateTime = DateTime.Now; + await orderRepository.UpdateAsync(order, cancellationToken); } private async Task RefreshJobSummaryAsync(int jobId, CancellationToken cancellationToken) diff --git a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs index 69243c9..34d536e 100644 --- a/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs +++ b/src/Oms.Application/Services/Orders/Issues/AutoTriggerRuntimeService.cs @@ -17,7 +17,6 @@ public class AutoTriggerRuntimeService( OrderExceptionWorkflowService exceptionWorkflowService, OrderIssueNotificationService issueNotificationService, IOrderSideEffectEventPublisher sideEffectEventPublisher, - IOrderExceptionEventRepository orderExceptionEventRepository, ILogger logger) : IScopedDependency { private const string InterceptOperation = "拦截"; @@ -82,97 +81,6 @@ public class AutoTriggerRuntimeService( await FireAsync(order, timing, operatorUserId); } - /// - /// 预报计费完成后再评估。只跑依赖计费/渠道/费用的规则,以及草稿阶段没能打上的问题件。 - /// - public async Task FireSettledByOrderIdAsync(int orderId, int operatorUserId) - { - var order = await orderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null) - { - return; - } - - var context = await orderAutoTriggerService.BuildPostCommitContextAsync(order); - var triggers = await autoTriggerRepository.GetListAsync(x => x.Status == EnableStatus.Enabled); - var orderedTriggers = triggers - .Where(x => AutoTriggerTimingCodes.Matches(x.TriggerTiming, AutoTriggerTiming.OrderCreate)) - .Select(x => (Trigger: x, Priority: AutoTriggerRuleResolver.GetPriority(x))) - .OrderBy(x => x.Priority) - .ThenBy(x => x.Trigger.Id) - .Select(x => x.Trigger) - .ToList(); - - foreach (var trigger in orderedTriggers) - { - if (string.Equals(trigger.Operation?.Trim(), InterceptOperation, StringComparison.Ordinal)) - { - continue; - } - - var isProblem = string.Equals(trigger.Operation?.Trim(), "问题件", StringComparison.Ordinal); - if (!isProblem && !ReferencesSettlementFields(trigger.TriggerCondition)) - { - continue; - } - - if (isProblem && await AlreadyMarkedByTriggerAsync(order.Id, trigger.Id)) - { - continue; - } - - if (!AutoTriggerConditionMatcher.Matches(context, trigger.TriggerCondition)) - { - continue; - } - - try - { - await ExecuteTriggerAsync(order, trigger, operatorUserId); - } - catch (Exception ex) - { - logger.LogWarning(ex, "计费后自动触发失败 OrderId={OrderId} TriggerId={TriggerId}", order.Id, trigger.Id); - } - } - } - - private static readonly string[] SettlementFieldTokens = - [ - "计费重", - "体积重", - "收货渠道", - "下游服务商", - "渠道预报编码", - "预报失败原因", - "费用类型", - "费用项", - "费用金额", - "费用币种", - "费用下游服务商", - "价格编号", - "计费阶段", - "价格生效时间" - ]; - - private static bool ReferencesSettlementFields(string? condition) - { - if (string.IsNullOrWhiteSpace(condition)) - { - return false; - } - - return SettlementFieldTokens.Any(token => condition.Contains(token, StringComparison.Ordinal)); - } - - private async Task AlreadyMarkedByTriggerAsync(int orderId, int triggerId) - { - var remark = $"自动触发#{triggerId}"; - var existed = await orderExceptionEventRepository.FirstOrDefaultAsync(x => - x.OrderId == orderId && x.Remark == remark); - return existed != null; - } - private async Task ExecuteTriggerAsync( Order order, AutoTrigger trigger, @@ -184,7 +92,7 @@ public class AutoTriggerRuntimeService( if (order.IsIntercept == 0) { order.IsIntercept = 1; - await orderRepository.UpdateInterceptFlagAsync(order.Id, 1); + await orderRepository.UpdateAsync(order); } return; diff --git a/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs b/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs index 430e184..374b18a 100644 --- a/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs +++ b/src/Oms.Application/Services/Orders/Issues/OrderExceptionWorkflowService.cs @@ -42,10 +42,9 @@ public class OrderExceptionWorkflowService( int operatorUserId, bool sendNotification) { - var current = await orderRepository.FirstOrDefaultAsync(x => x.Id == order.Id) ?? order; - if (!OrderExceptionStatusRules.CanMarkException(current.OrderStatus)) + if (!OrderExceptionStatusRules.CanMarkException(order.OrderStatus)) { - return OrderExceptionStatusRules.GetForbiddenReason(current.OrderStatus); + return OrderExceptionStatusRules.GetForbiddenReason(order.OrderStatus); } IssueTemplate? template = null; @@ -57,14 +56,14 @@ public class OrderExceptionWorkflowService( IssueHandlerResolution handlerResolution; try { - handlerResolution = await issueHandlerResolver.ResolveAsync(current, template, request.HandlerNames); + handlerResolution = await issueHandlerResolver.ResolveAsync(order, template, request.HandlerNames); } catch (BusinessException ex) { return ex.Message; } - var context = IssuePlaceholderContext.FromOrder(current, request.IssueType, request.Title); + var context = IssuePlaceholderContext.FromOrder(order, request.IssueType, request.Title); var renderedTitle = IssuePlaceholderRenderer.Render(request.Title, context); var renderedContent = IssuePlaceholderRenderer.Render(request.Content, context); @@ -74,37 +73,28 @@ public class OrderExceptionWorkflowService( notifyMethod = IssueNotifyMethodCodes.InApp; } - var fromStatus = current.OrderStatus; + var fromStatus = order.OrderStatus; var isAppend = string.Equals(fromStatus, OrderStatusCodes.Problematic, StringComparison.OrdinalIgnoreCase); if (!isAppend) { - current.PreviousOrderStatus = fromStatus; - current.OrderStatus = OrderStatusCodes.Problematic; + order.PreviousOrderStatus = fromStatus; + order.OrderStatus = OrderStatusCodes.Problematic; } - current.HandleStatus = OrderHandleStatusCodes.Pending; - current.IssueType = request.IssueType; - current.IssueTitle = renderedTitle; - current.IssueContent = renderedContent; - current.IssueTemplateId = request.IssueTemplateId; - current.IssueRemark = request.Remark; - current.IssueTime = DateTime.Now; - current.ClaimType = null; - current.ClaimAmount = null; - current.ClaimCurrency = null; - current.ClaimReason = null; - current.ClaimTime = null; + order.HandleStatus = OrderHandleStatusCodes.Pending; + order.IssueType = request.IssueType; + order.IssueTitle = renderedTitle; + order.IssueContent = renderedContent; + order.IssueTemplateId = request.IssueTemplateId; + order.IssueRemark = request.Remark; + order.IssueTime = DateTime.Now; + order.ClaimType = null; + order.ClaimAmount = null; + order.ClaimCurrency = null; + order.ClaimReason = null; + order.ClaimTime = null; - await orderRepository.UpdateAsync(current); - order.OrderStatus = current.OrderStatus; - order.PreviousOrderStatus = current.PreviousOrderStatus; - order.HandleStatus = current.HandleStatus; - order.IssueType = current.IssueType; - order.IssueTitle = current.IssueTitle; - order.IssueContent = current.IssueContent; - order.IssueTemplateId = current.IssueTemplateId; - order.IssueRemark = current.IssueRemark; - order.IssueTime = current.IssueTime; + await orderRepository.UpdateAsync(order); await orderExceptionEventRepository.InsertAsync(new OrderExceptionEvent { OrderId = order.Id, @@ -130,7 +120,7 @@ public class OrderExceptionWorkflowService( ? handlerResolution.NotifyUserIds : null; await issueNotificationService.DispatchAsync( - current, + order, renderedTitle, renderedContent, notifyMethod, diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index 3270344..f1f1a5f 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -170,12 +170,11 @@ public partial class OrderService var weights = BuildInitialWeightRows(order.Id, input); await SaveParcelChildrenAsync(order, input, weights, country); - // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收与订单同一事务记账,提交后各自执行。 + // 客户主动「先保存为草稿」:落库后返回。9610 / 揽收在事务提交后各自执行。 if (input.SaveAsDraft) { var draftOutput = await BuildParcelCreateOutputAsync(order); - ApplyPickupPlan(input, draftOutput); - await EnqueueCreateSideEffectsAsync(order.Id, input, includeForecast: false, operatorId); + await ScheduleCreateSideEffectsAsync(order.Id, input, draftOutput, includeForecast: false, operatorId); return draftOutput; } @@ -194,8 +193,7 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); var output = await BuildParcelCreateOutputAsync(order); - ApplyPickupPlan(input, output); - await EnqueueCreateSideEffectsAsync(order.Id, input, includeForecast: true, operatorId); + await ScheduleCreateSideEffectsAsync(order.Id, input, output, includeForecast: true, operatorId); return output; } catch (BusinessException) @@ -2286,7 +2284,7 @@ public partial class OrderService } /// - /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货写入后置步骤,提交后执行。 + /// 草稿重新预报:同步做黑名单等硬校验,计重/应收/排货投递 ForecastBilling MQ。 /// internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null) { @@ -2331,7 +2329,38 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); - await EnqueueDraftPredeclareSideEffectsAsync(order.Id, order.CouponInstanceId, operatorId); + var publisher = LazyServiceProvider.GetRequiredService(); + if (!publisher.IsEnabled) + { + var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload + { + CouponInstanceId = order.CouponInstanceId + }); + await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorId); + } + else + { + var orderId = order.Id; + var couponInstanceId = order.CouponInstanceId; + await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId); + } + + await ScheduleForecastSideEffectsAsync(order.Id, operatorId); + + // 同步回落失败时给出明确错误;入队成功则保持「处理中」由列表观察结果。 + var refreshed = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); + if (refreshed != null + && string.Equals(refreshed.OrderStatus, OrderStatusCodes.Draft, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(refreshed.PredeclareFailReason) + && !string.Equals( + refreshed.PredeclareFailReason, + ForecastBillingProcessingReason, + StringComparison.Ordinal)) + { + throw CreateDraftSavedBusinessException( + refreshed, + StripDraftSavedMarker(refreshed.PredeclareFailReason)); + } } catch (BusinessException ex) { @@ -2358,11 +2387,10 @@ public partial class OrderService private async Task MarkParcelDraftFailedAsync(Order order, string reason) { - var text = TrimPendingReason(reason); order.OrderStatus = OrderStatusCodes.Draft; - order.PredeclareFailReason = text; + order.PredeclareFailReason = reason; order.UpdateTime = DateTime.Now; - await OrderRepository.UpdatePredeclareFailureAsync(order.Id, text, order.IsRemote); + await OrderRepository.UpdateAsync(order); } /// @@ -2769,7 +2797,7 @@ public partial class OrderService } // 排货失败不阻断预报成功(与原创单同步路径一致) - await TryConfirmServiceChannelAndPayableAsync( + var allocated = await TryConfirmServiceChannelAndPayableAsync( order, product, billing, @@ -2795,23 +2823,16 @@ public partial class OrderService // ignore } + order.OrderStatus = OrderStatusCodes.Predeclared; order.PredeclareFailReason = string.Empty; order.UpdateTime = DateTime.Now; - await OrderRepository.UpdateForecastCompletionAsync(order); - var persisted = await OrderRepository.FirstOrDefaultAsync(x => x.Id == order.Id); - if (persisted != null) - { - order.OrderStatus = persisted.OrderStatus; - order.PredeclareFailReason = persisted.PredeclareFailReason; - order.PreviousOrderStatus = persisted.PreviousOrderStatus; - order.LabelTemplateId = persisted.LabelTemplateId; - } - else - { - order.OrderStatus = OrderStatusCodes.Predeclared; - } + await OrderRepository.UpdateAsync(order); - await EnqueueSettlementSideEffectsAsync(order.Id, operatorUserId); + // 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。 + if (allocated && !string.IsNullOrWhiteSpace(order.ChannelCode)) + { + await ScheduleTrackAfterAllocationAsync(order.Id); + } var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); await OperationLogWriter.TryWriteAsync( @@ -2831,6 +2852,103 @@ public partial class OrderService public int? CouponInstanceId { get; set; } } + /// + /// 创单事务提交后,9610、预报计费、揽收、面单、自动触发各自提交。 + /// 轨迹订单等排货成功后再创建。 + /// + private async Task ScheduleCreateSideEffectsAsync( + int orderId, + OrderParcelCreateInput input, + OrderParcelCreateOutput output, + bool includeForecast, + int operatorUserId) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderParcelCreate"); + var platformCode = input.PlatformCode; + var couponInstanceId = input.CouponInstanceId; + + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => + { + try + { + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "customs-9610", sp => + sp.GetRequiredService().PublishCustomsForOrderAsync(orderId, platformCode)); + + var forecastQueued = true; + if (includeForecast) + { + forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => + { + var service = sp.GetRequiredService(); + await service.EnqueueForecastBillingByIdAsync(orderId, couponInstanceId, operatorUserId); + }); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => + sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => + sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)); + } + + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "pickup-attach", sp => + sp.GetRequiredService().TryAttachPickupAfterParcelCreateAsync(input, output)); + + if (includeForecast && !forecastQueued) + { + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing-fail", sp => + sp.GetRequiredService().MarkForecastEnqueueFailedIfStillProcessingAsync(orderId)); + } + } + catch (Exception ex) + { + logger.LogError(ex, "创单后置步骤调度失败 OrderId={OrderId}", orderId); + } + }); + } + + private async Task ScheduleForecastEnqueueAsync(int orderId, int? couponInstanceId, int operatorUserId) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => + OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", sp => + sp.GetRequiredService().EnqueueForecastBillingByIdAsync( + orderId, + couponInstanceId, + operatorUserId))); + } + + /// + /// 草稿重新预报提交后,面单和下单自动触发各自入队。轨迹等排货成功后再创建。 + /// + private async Task ScheduleForecastSideEffectsAsync(int orderId, int operatorUserId) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => + { + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => + sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); + await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "auto-trigger", sp => + sp.GetRequiredService().PublishForecastAutoTriggerAsync(orderId, operatorUserId)); + }); + } + + /// + /// 预报计费事务提交后,再创建轨迹订单(此时已有渠道和 ApiCode)。 + /// + private async Task ScheduleTrackAfterAllocationAsync(int orderId) + { + var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); + var scopeFactory = LazyServiceProvider.GetRequiredService(); + var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); + await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => + OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => + sp.GetRequiredService().PublishForecastTrackAsync(orderId))); + } + private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); @@ -2843,7 +2961,19 @@ public partial class OrderService await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode); } - internal async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) + private async Task EnqueueForecastBillingByIdAsync(int orderId, int? couponInstanceId, int operatorUserId) + { + var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); + if (order == null + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId); + } + + private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null @@ -2855,11 +2985,12 @@ public partial class OrderService await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); } - private async Task PublishForecastTrackAsync(int orderId, bool appendInitialTrack, string? authorization) + private async Task PublishForecastTrackAsync(int orderId) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null - || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) + || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase) + || string.IsNullOrWhiteSpace(order.ChannelCode)) { return; } @@ -2868,12 +2999,8 @@ public partial class OrderService ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value) : null; var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); - request.AppendInitialTrackOnSuccess = appendInitialTrack; - if (!string.IsNullOrWhiteSpace(authorization)) - { - request.Authorization = authorization.Trim(); - } - + request.AppendInitialTrackOnSuccess = true; + ApplyTrackAuthorization(request); await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } diff --git a/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs b/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs deleted file mode 100644 index 204230f..0000000 --- a/src/Oms.Application/Services/Orders/OrderService.SideEffects.cs +++ /dev/null @@ -1,317 +0,0 @@ -using Fake.Application; -using Fake.UnitOfWork; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Oms.Application.Dtos.Orders; -using Oms.Application.Messaging; -using Oms.Domain.OrdersAggregate.Consts; -using Oms.Domain.OrdersAggregate.Entities; - -namespace Oms.Application.Services.Orders; - -public partial class OrderService -{ - private OrderSideEffectQueue SideEffectQueue => - LazyServiceProvider.GetRequiredService(); - - /// - /// 创单事务内写入后置步骤。保存草稿只写 9610 和揽收;确认下单再写预报、轨迹、面单和自动触发。 - /// - private async Task EnqueueCreateSideEffectsAsync( - int orderId, - OrderParcelCreateInput input, - bool includeForecast, - int operatorUserId) - { - var authorization = CaptureRequestAuthorization(); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.Customs9610, - new OrderSideEffectPayload { PlatformCode = input.PlatformCode }); - - if (includeForecast) - { - await SideEffectQueue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.ForecastBilling, - new OrderSideEffectPayload - { - CouponInstanceId = input.CouponInstanceId, - OperatorUserId = operatorUserId - }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.TrackUpsert, - new OrderSideEffectPayload - { - AppendInitialTrack = true, - Authorization = authorization, - OperatorUserId = operatorUserId - }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.LabelGenerate, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.AutoTrigger, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - } - - if (IsDoorPickup(input) && ValidateDoorPickup(input) == null) - { - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.PickupAttach, - BuildPickupPayload(input)); - } - - await KickSideEffectsAsync(); - } - - /// - /// 草稿重新预报:预报计费可再次入队;轨迹 A0101、面单和创单触发已做过则跳过。 - /// - private async Task EnqueueDraftPredeclareSideEffectsAsync( - int orderId, - int? couponInstanceId, - int operatorUserId) - { - var authorization = CaptureRequestAuthorization(); - await SideEffectQueue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.ForecastBilling, - new OrderSideEffectPayload - { - CouponInstanceId = couponInstanceId, - OperatorUserId = operatorUserId - }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.TrackUpsert, - new OrderSideEffectPayload - { - AppendInitialTrack = true, - Authorization = authorization, - OperatorUserId = operatorUserId - }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.LabelGenerate, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - await SideEffectQueue.EnqueueOnceAsync( - orderId, - OrderSideEffectSteps.AutoTrigger, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - await KickSideEffectsAsync(); - } - - /// - /// 预报计费成功后补轨迹渠道、系统面单,以及依赖计费结果的自动触发。 - /// - private async Task EnqueueSettlementSideEffectsAsync(int orderId, int operatorUserId) - { - var previousTrack = await SideEffectQueue.FindLatestPayloadAsync(orderId, OrderSideEffectSteps.TrackUpsert); - await SideEffectQueue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.TrackRefresh, - new OrderSideEffectPayload - { - AppendInitialTrack = false, - Authorization = previousTrack?.Authorization, - OperatorUserId = operatorUserId - }); - await SideEffectQueue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.LabelRefresh, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - await SideEffectQueue.EnqueueIfIdleAsync( - orderId, - OrderSideEffectSteps.AutoTriggerSettled, - new OrderSideEffectPayload { OperatorUserId = operatorUserId }); - await KickSideEffectsAsync(); - } - - private Task KickSideEffectsAsync() - { - var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); - var scopeFactory = LazyServiceProvider.GetRequiredService(); - var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderSideEffect"); - return OrderSideEffectExecutor.ScheduleAfterCommitAsync(unitOfWorkManager, scopeFactory, logger); - } - - /// - /// 执行一条已抢占的后置步骤。由独立事务调用,失败抛出以便重试。 - /// - internal async Task ExecuteSideEffectAsync(OrderSideEffect row, CancellationToken cancellationToken = default) - { - var payload = string.IsNullOrWhiteSpace(row.PayloadJson) - ? new OrderSideEffectPayload() - : JsonConvert.DeserializeObject(row.PayloadJson) ?? new OrderSideEffectPayload(); - - switch (row.Step) - { - case OrderSideEffectSteps.Customs9610: - await PublishCustomsForOrderAsync(row.OrderId, payload.PlatformCode); - break; - case OrderSideEffectSteps.ForecastBilling: - await ExecuteForecastBillingStepAsync(row.OrderId, payload); - break; - case OrderSideEffectSteps.TrackUpsert: - await PublishForecastTrackAsync(row.OrderId, appendInitialTrack: true, payload.Authorization); - break; - case OrderSideEffectSteps.TrackRefresh: - await PublishForecastTrackAsync(row.OrderId, appendInitialTrack: false, payload.Authorization); - break; - case OrderSideEffectSteps.LabelGenerate: - case OrderSideEffectSteps.LabelRefresh: - await PublishForecastLabelAsync(row.OrderId, payload.OperatorUserId); - break; - case OrderSideEffectSteps.AutoTrigger: - await PublishForecastAutoTriggerAsync(row.OrderId, payload.OperatorUserId); - break; - case OrderSideEffectSteps.AutoTriggerSettled: - await PublishSettledAutoTriggerAsync(row.OrderId, payload.OperatorUserId); - break; - case OrderSideEffectSteps.PickupAttach: - await ExecutePickupSideEffectAsync(row.OrderId, payload); - break; - default: - LazyServiceProvider.GetRequiredService() - .CreateLogger("Oms.OrderSideEffect") - .LogWarning("未知的订单后置步骤 Step={Step} OrderId={OrderId}", row.Step, row.OrderId); - break; - } - } - - private async Task ExecuteForecastBillingStepAsync(int orderId, OrderSideEffectPayload payload) - { - var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null - || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (!string.Equals(order.PredeclareFailReason, ForecastBillingProcessingReason, StringComparison.Ordinal)) - { - return; - } - - await EnqueueForecastBillingAsync(order, payload.CouponInstanceId, payload.OperatorUserId); - } - - private async Task PublishSettledAutoTriggerAsync(int orderId, int operatorUserId) - { - var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); - if (order == null - || string.Equals(order.OrderStatus, OrderStatusCodes.Deleted, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - await AutoTriggerRuntimeService.FireSettledByOrderIdAsync(orderId, operatorUserId); - } - - private async Task ExecutePickupSideEffectAsync(int orderId, OrderSideEffectPayload payload) - { - var input = new OrderParcelCreateInput - { - CustomerId = payload.CustomerId, - InboundMethod = payload.InboundMethod, - SenderAddressId = payload.SenderAddressId, - WarehouseId = payload.WarehouseId, - PickupDirection = payload.PickupDirection, - CarrierCode = payload.CarrierCode, - CarrierService = payload.CarrierService, - ExpectPickupTime = payload.ExpectPickupTime - }; - var output = new OrderParcelCreateOutput { OrderId = orderId }; - await TryAttachPickupAfterParcelCreateAsync(input, output); - if (string.IsNullOrWhiteSpace(output.PickupCreateError)) - { - return; - } - - if (output.PickupCreateError.Contains("已绑定", StringComparison.Ordinal) - || output.PickupCreateError.Contains("已在当前揽收单", StringComparison.Ordinal)) - { - return; - } - - throw new BusinessException(output.PickupCreateError); - } - - private void ApplyPickupPlan(OrderParcelCreateInput input, OrderParcelCreateOutput output) - { - if (!IsDoorPickup(input)) - { - return; - } - - var error = ValidateDoorPickup(input); - if (error != null) - { - output.PickupCreateError = error; - return; - } - - output.PickupQueued = true; - } - - private static bool IsDoorPickup(OrderParcelCreateInput input) => - string.Equals(input.InboundMethod?.Trim(), OrderInboundMethodCodes.DoorPickup, StringComparison.OrdinalIgnoreCase); - - private static string? ValidateDoorPickup(OrderParcelCreateInput input) - { - if (input.SenderAddressId is not > 0) - { - return "上门揽收需选择揽收地址"; - } - - if (input.ExpectPickupTime is null || input.ExpectPickupTime == default) - { - return "上门揽收需填写期望揽收时间"; - } - - if (input.WarehouseId is not > 0) - { - return "上门揽收需选择仓库"; - } - - return null; - } - - private static OrderSideEffectPayload BuildPickupPayload(OrderParcelCreateInput input) => - new() - { - CustomerId = input.CustomerId, - SenderAddressId = input.SenderAddressId, - WarehouseId = input.WarehouseId, - PickupDirection = input.PickupDirection, - CarrierCode = input.CarrierCode, - CarrierService = input.CarrierService, - ExpectPickupTime = input.ExpectPickupTime, - InboundMethod = input.InboundMethod - }; - - private string? CaptureRequestAuthorization() - { - try - { - return LazyServiceProvider - .GetRequiredService() - .HttpContext? - .Request - .Headers - .Authorization - .ToString(); - } - catch (ObjectDisposedException) - { - return null; - } - } -} diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index ff7c50b..2e5fe6d 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -262,10 +262,10 @@ public partial class OrderService : ApplicationService } /// - /// 执行订单预报。校验通过后订单保持草稿并标记处理中,计费和排货在事务提交后执行。 + /// 执行订单预报。 /// /// 入参:订单操作参数。 - /// 无返回体。预报结果随后写回订单状态和失败原因。 + /// 无返回体,仅完成订单预报处理。 [HttpPost("/order/pre-declare")] [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public async Task PreDeclareAsync(OrderActionInput input) @@ -603,10 +603,34 @@ public partial class OrderService : ApplicationService } /// - /// 创建订单包裹。 + /// 创建订单包裹(PC/H5 确认下单或保存草稿)。 + /// + /// 【同步,本接口事务内】RequestId 幂等 → 校验入参/销售产品 → 黑名单 → + /// 自动触发「拦截」规则(命中直接失败)→ 生成运单号 → 落库订单与子表。 + /// 确认下单:同步写偏远标记,状态草稿 +「预报计费/排货处理中」后返回; + /// 保存草稿:仅落库,不做预报计费。失败会回滚订单,不落草稿。 + /// + /// + /// 【创单事务提交后各自投递 MQ(互不等待;失败不回滚订单)】 + /// 1) 9610 订单 Upsert(customs-9610)— 草稿与确认下单都会投; + /// 2) 预报计费/排货任务(forecast-billing 批量任务)— 仅确认下单; + /// 队列关闭时在后台同步跑同一套计费逻辑; + /// 3) 系统面单 PDF(label-generate)— 仅确认下单; + /// 4) 下单自动触发(OrderCreate:警告/问题件等,不含拦截)— 仅确认下单; + /// 5) 上门揽收挂载(pickup-attach)— 仅上门揽收且地址/时间/仓库齐全时; + /// 投递失败则同步回落建揽收单。 + /// + /// + /// 【预报计费消费后】计费重、应收、排货应付、营销快照 → 订单改已预报。 + /// 排货成功且已有 ChannelCode 时,再投递轨迹订单 + A0101(track-upsert); + /// 排货失败不阻断预报,也不建轨迹(可页面补做)。 + /// /// - /// 入参:订单包裹创建参数。 - /// 订单包裹创建结果。 + /// 入参:订单包裹创建参数。SaveAsDraft=true 为仅保存草稿。 + /// + /// 订单包裹创建结果。确认下单时 ForecastBillingQueued 表示预报计费已排队; + /// 上门揽收时 PickupQueued / PickupNo 视投递或同步回落结果而定。 + /// [HttpPost("/order/parcel/create")] [UnitOfWork(IsTransactional = true, IsolationLevel = IsolationLevel.ReadCommitted)] public async Task CreateParcelAsync(OrderParcelCreateInput input) diff --git a/src/Oms.Application/Workers/OrderSideEffectWorker.cs b/src/Oms.Application/Workers/OrderSideEffectWorker.cs deleted file mode 100644 index 9f28943..0000000 --- a/src/Oms.Application/Workers/OrderSideEffectWorker.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Oms.Application.Messaging; - -namespace Oms.Application.Workers; - -/// -/// 轮询已提交的创单后置步骤。进程在事务提交后、步骤执行前退出时,靠这里补跑。 -/// -public sealed class OrderSideEffectWorker( - IServiceScopeFactory scopeFactory, - ILogger logger) : BackgroundService -{ - private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2); - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - try - { - await OrderSideEffectExecutor.RecoverStuckForecastAsync(scopeFactory, logger, stoppingToken); - await OrderSideEffectExecutor.ProcessDueAsync(scopeFactory, logger, stoppingToken); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - logger.LogError(ex, "创单后置步骤轮询失败"); - } - - try - { - await Task.Delay(PollInterval, stoppingToken); - } - catch (OperationCanceledException) - { - break; - } - } - } -} diff --git a/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs b/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs deleted file mode 100644 index 9fc3d01..0000000 --- a/src/Oms.Domain/OrdersAggregate/Consts/OrderSideEffectCodes.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace Oms.Domain.OrdersAggregate.Consts; - -/// -/// 创单后置步骤状态。同一订单、同一步骤不建唯一索引,是否重复入队由业务判断。 -/// -public static class OrderSideEffectStatusCodes -{ - public const string Pending = "pending"; - - public const string Running = "running"; - - public const string Done = "done"; - - public const string Failed = "failed"; -} - -/// -/// 创单/预报提交后要单独执行的步骤。 -/// -public static class OrderSideEffectSteps -{ - public const string Customs9610 = "customs-9610"; - - public const string ForecastBilling = "forecast-billing"; - - /// 创建轨迹订单并追加 A0101,同一订单只做一次。 - public const string TrackUpsert = "track-upsert"; - - /// 排货完成后按最新渠道再同步一次轨迹,不追加 A0101。 - public const string TrackRefresh = "track-refresh"; - - public const string LabelGenerate = "label-generate"; - - /// 排货完成后重做系统面单,覆盖创单时还没有渠道的那张。 - public const string LabelRefresh = "label-refresh"; - - /// 下单时机的自动触发。此时还没有计费重、应收和渠道。 - public const string AutoTrigger = "auto-trigger"; - - /// 预报计费成功后再跑依赖计费结果的规则,以及草稿阶段没能打上的问题件。 - public const string AutoTriggerSettled = "auto-trigger-settled"; - - public const string PickupAttach = "pickup-attach"; -} diff --git a/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs b/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs deleted file mode 100644 index feb5471..0000000 --- a/src/Oms.Domain/OrdersAggregate/Entities/OrderSideEffect.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Oms.Domain.Shared; -using SqlSugar; - -namespace Oms.Domain.OrdersAggregate.Entities; - -/// -/// 与订单同一事务写入的后置步骤。事务回滚则步骤一起消失;提交后由后台执行,进程重启仍可继续。 -/// -[SugarTable("t_order_side_effect")] -public class OrderSideEffect : OmsAuditedAggregateRoot -{ - public int OrderId { get; set; } - - [SugarColumn(Length = 64)] - public string Step { get; set; } = string.Empty; - - [SugarColumn(Length = 32)] - public string Status { get; set; } = string.Empty; - - [SugarColumn(ColumnDataType = "text", IsNullable = true)] - public string? PayloadJson { get; set; } - - public int AttemptCount { get; set; } - - [SugarColumn(IsNullable = true)] - public DateTime? NextRunTime { get; set; } - - [SugarColumn(Length = 512, IsNullable = true)] - public string? LastError { get; set; } -} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index cf35dfd..c5d8ab0 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -137,23 +137,4 @@ public interface IOrderRepository : IRepository /// 只更新标签模板 Id,避免面单生成覆盖预报计费正在写入的订单字段。 /// Task UpdateLabelTemplateIdAsync(int orderId, int labelTemplateId, CancellationToken cancellationToken = default); - - /// - /// 预报失败时只写状态和原因,不覆盖渠道、面单模板、问题件字段。 - /// - Task UpdatePredeclareFailureAsync( - int orderId, - string reason, - byte? isRemote = null, - CancellationToken cancellationToken = default); - - /// - /// 预报计费成功时只写计费/排货字段。订单已是问题件或理赔件时不改状态,只把异常前状态改成已预报。 - /// - Task UpdateForecastCompletionAsync(Order order, CancellationToken cancellationToken = default); - - /// - /// 只更新拦截标记。 - /// - Task UpdateInterceptFlagAsync(int orderId, byte isIntercept, CancellationToken cancellationToken = default); } diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs deleted file mode 100644 index 7d7e724..0000000 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderSideEffectRepository.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Fake.Domain.Repositories; -using Oms.Domain.OrdersAggregate.Entities; - -namespace Oms.Domain.OrdersAggregate.Repositories; - -public interface IOrderSideEffectRepository : IRepository -{ - /// - /// 是否已有未结束(以及可选的已完成)步骤。用于避免重复入队。 - /// - Task ExistsAsync( - int orderId, - string step, - bool includeDone, - CancellationToken cancellationToken = default); - - Task> ListDueIdsAsync(int take, DateTime now, CancellationToken cancellationToken = default); - - /// - /// 取同一步骤最近一条入参,用于把创单时捕获的轨迹授权头带到排货后的刷新。 - /// - Task FindLatestPayloadJsonAsync( - int orderId, - string step, - CancellationToken cancellationToken = default); - - /// - /// 仅当当前仍是 pending 时改为 running,并增加尝试次数。 - /// - Task TryClaimAsync(int id, DateTime now, CancellationToken cancellationToken = default); - - Task MarkDoneAsync(int id, DateTime now, CancellationToken cancellationToken = default); - - Task MarkRetryAsync( - int id, - string status, - string? lastError, - DateTime? nextRunTime, - DateTime now, - CancellationToken cancellationToken = default); - - /// - /// 执行过程中进程退出时,把长时间停在 running 的步骤放回 pending。 - /// - Task ResetStaleRunningAsync(DateTime staleBefore, DateTime now, CancellationToken cancellationToken = default); - - /// - /// 仍显示「预报计费/排货处理中」、且没有待执行、执行中或已完成预报步骤的订单。 - /// - Task> ListStuckForecastOrderIdsAsync( - string processingReason, - DateTime olderThan, - int take, - CancellationToken cancellationToken = default); -} diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index f9ad549..c85d43f 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1505,85 +1505,4 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I .Where(x => x.OrderStatus != OrderStatusCodes.Deleted) .ToListAsync(cancellationToken); } - - public async Task UpdatePredeclareFailureAsync( - int orderId, - string reason, - byte? isRemote = null, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - var now = DateTime.Now; - var update = db.Updateable() - .SetColumns(x => x.OrderStatus == OrderStatusCodes.Draft) - .SetColumns(x => x.PredeclareFailReason == reason) - .SetColumns(x => x.UpdateTime == now); - if (isRemote.HasValue) - { - var remote = isRemote.Value; - update = update.SetColumns(x => x.IsRemote == remote); - } - - await update.Where(x => x.Id == orderId).ExecuteCommandAsync(cancellationToken); - } - - public async Task UpdateForecastCompletionAsync(Order order, CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - var now = DateTime.Now; - await db.Updateable() - .SetColumns(x => x.IsRemote == order.IsRemote) - .SetColumns(x => x.ChannelCode == order.ChannelCode) - .SetColumns(x => x.DownstreamSupplierCode == order.DownstreamSupplierCode) - .SetColumns(x => x.DownstreamSupplierName == order.DownstreamSupplierName) - .SetColumns(x => x.DownstreamAppKey == order.DownstreamAppKey) - .SetColumns(x => x.DownstreamAppId == order.DownstreamAppId) - .SetColumns(x => x.ChannelForecastCode == order.ChannelForecastCode) - .SetColumns(x => x.DownstreamServiceNo == order.DownstreamServiceNo) - .SetColumns(x => x.TrackNo == order.TrackNo) - .SetColumns(x => x.AllocationPendingReason == order.AllocationPendingReason) - .SetColumns(x => x.AllocationScheme == order.AllocationScheme) - .SetColumns(x => x.AllocationTraceJson == order.AllocationTraceJson) - .SetColumns(x => x.AllocationTraceTime == order.AllocationTraceTime) - .SetColumns(x => x.PredeclareFailReason == string.Empty) - .SetColumns(x => x.MemberLevelId == order.MemberLevelId) - .SetColumns(x => x.PriceTierKey == order.PriceTierKey) - .SetColumns(x => x.ReceivableSubtotal == order.ReceivableSubtotal) - .SetColumns(x => x.LevelDiscountAmount == order.LevelDiscountAmount) - .SetColumns(x => x.CouponInstanceId == order.CouponInstanceId) - .SetColumns(x => x.CouponDiscountAmount == order.CouponDiscountAmount) - .SetColumns(x => x.PayableAmount == order.PayableAmount) - .SetColumns(x => x.UpdateTime == now) - .Where(x => x.Id == order.Id) - .ExecuteCommandAsync(cancellationToken); - - await db.Updateable() - .SetColumns(x => x.OrderStatus == OrderStatusCodes.Predeclared) - .SetColumns(x => x.UpdateTime == now) - .Where(x => x.Id == order.Id - && x.OrderStatus != OrderStatusCodes.Problematic - && x.OrderStatus != OrderStatusCodes.Claimed) - .ExecuteCommandAsync(cancellationToken); - - await db.Updateable() - .SetColumns(x => x.PreviousOrderStatus == OrderStatusCodes.Predeclared) - .SetColumns(x => x.UpdateTime == now) - .Where(x => x.Id == order.Id - && (x.OrderStatus == OrderStatusCodes.Problematic - || x.OrderStatus == OrderStatusCodes.Claimed)) - .ExecuteCommandAsync(cancellationToken); - } - - public async Task UpdateInterceptFlagAsync( - int orderId, - byte isIntercept, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - await db.Updateable() - .SetColumns(x => x.IsIntercept == isIntercept) - .SetColumns(x => x.UpdateTime == DateTime.Now) - .Where(x => x.Id == orderId) - .ExecuteCommandAsync(cancellationToken); - } } diff --git a/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs deleted file mode 100644 index 4d6290a..0000000 --- a/src/Oms.SqlSugarCore/Orders/OrderSideEffectRepository.cs +++ /dev/null @@ -1,153 +0,0 @@ -using Fake.DependencyInjection; -using Oms.Domain.OrdersAggregate.Consts; -using SqlSugar; -using Oms.Domain.OrdersAggregate.Entities; -using Oms.Domain.OrdersAggregate.Repositories; -using Oms.SqlSugarCore.Repositories; - -namespace Oms.SqlSugarCore.Orders; - -public class OrderSideEffectRepository : OmsSqlSugarRepository, IOrderSideEffectRepository, IScopedDependency -{ - public async Task ExistsAsync( - int orderId, - string step, - bool includeDone, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - var statuses = includeDone - ? new[] - { - OrderSideEffectStatusCodes.Pending, - OrderSideEffectStatusCodes.Running, - OrderSideEffectStatusCodes.Done - } - : new[] - { - OrderSideEffectStatusCodes.Pending, - OrderSideEffectStatusCodes.Running - }; - - return await db.Queryable() - .AnyAsync( - x => x.OrderId == orderId - && x.Step == step - && statuses.Contains(x.Status) - && x.IsDeleted == false, - cancellationToken); - } - - public async Task> ListDueIdsAsync(int take, DateTime now, CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - return await db.Queryable() - .Where(x => x.Status == OrderSideEffectStatusCodes.Pending - && x.IsDeleted == false - && (x.NextRunTime == null || x.NextRunTime <= now)) - .OrderBy(x => x.Id) - .Take(take) - .Select(x => x.Id) - .ToListAsync(cancellationToken); - } - - public async Task FindLatestPayloadJsonAsync( - int orderId, - string step, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - var rows = await db.Queryable() - .Where(x => x.OrderId == orderId && x.Step == step && x.IsDeleted == false) - .OrderByDescending(x => x.Id) - .Take(1) - .Select(x => x.PayloadJson) - .ToListAsync(cancellationToken); - return rows.FirstOrDefault(); - } - - public async Task TryClaimAsync(int id, DateTime now, CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - var affected = await db.Updateable() - .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Running) - .SetColumns(x => x.UpdateTime == now) - .SetColumns(x => x.AttemptCount == x.AttemptCount + 1) - .Where(x => x.Id == id - && x.Status == OrderSideEffectStatusCodes.Pending - && x.IsDeleted == false) - .ExecuteCommandAsync(cancellationToken); - return affected == 1; - } - - public async Task MarkDoneAsync(int id, DateTime now, CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - await db.Updateable() - .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Done) - .SetColumns(x => x.UpdateTime == now) - .SetColumns(x => x.LastError == null) - .Where(x => x.Id == id) - .ExecuteCommandAsync(cancellationToken); - } - - public async Task MarkRetryAsync( - int id, - string status, - string? lastError, - DateTime? nextRunTime, - DateTime now, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - await db.Updateable() - .SetColumns(x => x.Status == status) - .SetColumns(x => x.LastError == lastError) - .SetColumns(x => x.NextRunTime == nextRunTime) - .SetColumns(x => x.UpdateTime == now) - .Where(x => x.Id == id) - .ExecuteCommandAsync(cancellationToken); - } - - public async Task ResetStaleRunningAsync( - DateTime staleBefore, - DateTime now, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - return await db.Updateable() - .SetColumns(x => x.Status == OrderSideEffectStatusCodes.Pending) - .SetColumns(x => x.NextRunTime == now) - .SetColumns(x => x.UpdateTime == now) - .Where(x => x.Status == OrderSideEffectStatusCodes.Running - && x.IsDeleted == false - && x.UpdateTime < staleBefore) - .ExecuteCommandAsync(cancellationToken); - } - - public async Task> ListStuckForecastOrderIdsAsync( - string processingReason, - DateTime olderThan, - int take, - CancellationToken cancellationToken = default) - { - var db = await GetDbContextAsync(cancellationToken); - return await db.Queryable() - .Where(order => order.IsDeleted == false - && order.PredeclareFailReason == processingReason - && order.UpdateTime < olderThan - && order.UpdateTime > DateTime.MinValue) - .Where(order => !SqlSugar.SqlFunc.Subqueryable() - .Where(effect => effect.OrderId == order.Id - && effect.Step == OrderSideEffectSteps.ForecastBilling - && effect.IsDeleted == false - && (effect.Status == OrderSideEffectStatusCodes.Pending - || effect.Status == OrderSideEffectStatusCodes.Running - || effect.Status == OrderSideEffectStatusCodes.Done)) - .Any()) - .OrderBy(order => order.Id) - .Take(take) - .Select(order => order.Id) - .ToListAsync(cancellationToken); - } -} -- 2.45.2 From a30958843bc005231cc1bbe68d56ee569bb1c936 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 15 Oct 2026 09:14:52 +0800 Subject: [PATCH 15/16] =?UTF-8?q?fix:=20=E9=A6=96=E6=AC=A1=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E8=BD=A8=E8=BF=B9=E8=AE=A2=E5=8D=95=E6=97=A0=E8=B7=9F?= =?UTF-8?q?=E8=B8=AA=E5=8F=B7=E6=97=B6=E7=94=A8=E5=AE=A2=E6=88=B7=E5=8D=95?= =?UTF-8?q?=E5=8F=B7=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 轨迹侧 TrackNo 必填,后续有真实跟踪号再 Upsert 更新。 Co-authored-by: Cursor --- .../SmallBagTrackUpsertRequestBuilder.cs | 14 +++++++++++--- .../SmallBagTrackUpsertRequestBuilderTests.cs | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs index 19eef09..53a5d17 100644 --- a/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs +++ b/src/Oms.Application/Integrations/SmallBagTrack/SmallBagTrackUpsertRequestBuilder.cs @@ -68,10 +68,18 @@ internal sealed class SmallBagTrackUpsertRequestBuilder( } /// - /// 仅取运单跟踪号;无值则空串,不回落运单号/客户单号等。 + /// 轨迹侧 TrackNo 必填。有真实跟踪号用跟踪号;尚未下发时先用客户单号占位,后续有 TrackNo 再 Upsert 更新。 /// - internal static string ResolveTrackNo(Order order) => - order.TrackNo?.Trim() ?? string.Empty; + internal static string ResolveTrackNo(Order order) + { + var trackNo = order.TrackNo?.Trim() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(trackNo)) + { + return trackNo; + } + + return order.ClientNo?.Trim() ?? string.Empty; + } internal static string? PickSupplierCodeFromProviders( IEnumerable providers, diff --git a/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs b/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs index df70e79..47147c0 100644 --- a/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs +++ b/src/Oms.Tests/SmallBagTrackUpsertRequestBuilderTests.cs @@ -20,14 +20,28 @@ public class SmallBagTrackUpsertRequestBuilderTests } [Fact] - public void ResolveTrackNo_EmptyWhenTrackNoMissing() + public void ResolveTrackNo_FallsBackToClientNoWhenTrackNoMissing() { var order = new Order { ServiceNo = "IDE2607221859123456", + ClientNo = "CNO-001", TrackNo = null }; + Assert.Equal("CNO-001", SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order)); + } + + [Fact] + public void ResolveTrackNo_EmptyWhenTrackNoAndClientNoMissing() + { + var order = new Order + { + ServiceNo = "IDE2607221859123456", + TrackNo = null, + ClientNo = null + }; + Assert.Equal(string.Empty, SmallBagTrackUpsertRequestBuilder.ResolveTrackNo(order)); } -- 2.45.2 From aa4c4a28e637a086141e4a033123e70b954890f0 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Thu, 15 Oct 2026 09:23:15 +0800 Subject: [PATCH 16/16] =?UTF-8?q?fix:=20=E6=8E=92=E8=B4=A7=E5=90=8E?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E8=BD=A8=E8=BF=B9=E8=AE=A2=E5=8D=95=E8=BD=AC?= =?UTF-8?q?=E5=8F=91=E5=88=9B=E5=8D=95=E6=97=B6=E7=9A=84=20Authorization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 消费端没有登录态会导致轨迹网关 401;创单时写入预报任务再带到轨迹事件。 Co-authored-by: Cursor --- .../Batch/OrderBatchOrderActionHandler.cs | 3 +- .../Orders/OrderService.ParcelBilling.cs | 116 ++++++++++++++---- .../Services/Orders/OrderService.cs | 1 + 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs index f363cd8..664bb91 100644 --- a/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs +++ b/src/Oms.Application/Services/Orders/Batch/OrderBatchOrderActionHandler.cs @@ -72,7 +72,8 @@ public sealed class OrderBatchOrderActionHandler(IServiceScopeFactory scopeFacto message.OrderId, message.PayloadJson, message.OperatorUserId, - cancellationToken); + cancellationToken, + message.Authorization); return OrderBatchActionExecuteResult.Ok(); case OrderBatchActionCodes.Reallocate: diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs index f1f1a5f..56ef739 100644 --- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs +++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs @@ -2329,20 +2329,26 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); var operatorId = operatorUserId ?? (CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0); + var authorization = CaptureRequestAuthorization(); var publisher = LazyServiceProvider.GetRequiredService(); if (!publisher.IsEnabled) { var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload { - CouponInstanceId = order.CouponInstanceId + CouponInstanceId = order.CouponInstanceId, + Authorization = authorization }); - await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorId); + await ProcessForecastBillingForBatchAsync( + order.Id, + payloadJson, + operatorId, + authorization: authorization); } else { var orderId = order.Id; var couponInstanceId = order.CouponInstanceId; - await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId); + await ScheduleForecastEnqueueAsync(orderId, couponInstanceId, operatorId, authorization); } await ScheduleForecastSideEffectsAsync(order.Id, operatorId); @@ -2663,7 +2669,8 @@ public partial class OrderService int orderId, string? payloadJson = null, int operatorUserId = 0, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + string? authorization = null) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken); if (order == null) @@ -2687,6 +2694,7 @@ public partial class OrderService var beforeSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); int? couponInstanceId = order.CouponInstanceId; + string? trackAuthorization = authorization; if (!string.IsNullOrWhiteSpace(payloadJson)) { try @@ -2696,6 +2704,12 @@ public partial class OrderService { couponInstanceId = payload.CouponInstanceId; } + + if (string.IsNullOrWhiteSpace(trackAuthorization) + && !string.IsNullOrWhiteSpace(payload?.Authorization)) + { + trackAuthorization = payload.Authorization.Trim(); + } } catch { @@ -2829,9 +2843,10 @@ public partial class OrderService await OrderRepository.UpdateAsync(order); // 轨迹订单依赖排货后的渠道/ApiCode,只有排货成功才创建。 + // 消费端没有 HttpContext,须用创单时捕获的 Authorization,否则轨迹网关返回 401。 if (allocated && !string.IsNullOrWhiteSpace(order.ChannelCode)) { - await ScheduleTrackAfterAllocationAsync(order.Id); + await ScheduleTrackAfterAllocationAsync(order.Id, trackAuthorization); } var afterSnap = await CaptureOrderAuditSnapshotAsync(order, includeFees: true, includeWeights: true); @@ -2850,6 +2865,11 @@ public partial class OrderService private sealed class ForecastBillingJobPayload { public int? CouponInstanceId { get; set; } + + /// + /// 创单/预报请求的 Authorization,供排货后异步创建轨迹订单转发到轨迹网关。 + /// + public string? Authorization { get; set; } } /// @@ -2868,6 +2888,8 @@ public partial class OrderService var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.OrderParcelCreate"); var platformCode = input.PlatformCode; var couponInstanceId = input.CouponInstanceId; + // 事务提交后 HttpContext 已不可用,这里先捕获登录态供轨迹网关使用。 + var authorization = CaptureRequestAuthorization(); await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, async () => { @@ -2882,7 +2904,11 @@ public partial class OrderService forecastQueued = await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "forecast-billing", async sp => { var service = sp.GetRequiredService(); - await service.EnqueueForecastBillingByIdAsync(orderId, couponInstanceId, operatorUserId); + await service.EnqueueForecastBillingByIdAsync( + orderId, + couponInstanceId, + operatorUserId, + authorization); }); await OmsAfterCommit.RunStepAsync(scopeFactory, logger, "label-generate", sp => sp.GetRequiredService().PublishForecastLabelAsync(orderId, operatorUserId)); @@ -2906,7 +2932,11 @@ public partial class OrderService }); } - private async Task ScheduleForecastEnqueueAsync(int orderId, int? couponInstanceId, int operatorUserId) + private async Task ScheduleForecastEnqueueAsync( + int orderId, + int? couponInstanceId, + int operatorUserId, + string? authorization) { var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); var scopeFactory = LazyServiceProvider.GetRequiredService(); @@ -2916,7 +2946,8 @@ public partial class OrderService sp.GetRequiredService().EnqueueForecastBillingByIdAsync( orderId, couponInstanceId, - operatorUserId))); + operatorUserId, + authorization))); } /// @@ -2939,14 +2970,14 @@ public partial class OrderService /// /// 预报计费事务提交后,再创建轨迹订单(此时已有渠道和 ApiCode)。 /// - private async Task ScheduleTrackAfterAllocationAsync(int orderId) + private async Task ScheduleTrackAfterAllocationAsync(int orderId, string? authorization) { var unitOfWorkManager = LazyServiceProvider.GetRequiredService(); var scopeFactory = LazyServiceProvider.GetRequiredService(); var logger = LazyServiceProvider.GetRequiredService().CreateLogger("Oms.ForecastBilling"); await OmsAfterCommit.ScheduleAsync(unitOfWorkManager, () => OmsAfterCommit.RunStepAsync(scopeFactory, logger, "track-upsert", sp => - sp.GetRequiredService().PublishForecastTrackAsync(orderId))); + sp.GetRequiredService().PublishForecastTrackAsync(orderId, authorization))); } private async Task PublishCustomsForOrderAsync(int orderId, string? platformCode) @@ -2961,7 +2992,11 @@ public partial class OrderService await ParcelCreateEventPublisher.PublishCustoms9610UpsertAsync(order, platformCode); } - private async Task EnqueueForecastBillingByIdAsync(int orderId, int? couponInstanceId, int operatorUserId) + private async Task EnqueueForecastBillingByIdAsync( + int orderId, + int? couponInstanceId, + int operatorUserId, + string? authorization) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null @@ -2970,7 +3005,7 @@ public partial class OrderService return; } - await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId); + await EnqueueForecastBillingAsync(order, couponInstanceId, operatorUserId, authorization); } private async Task MarkForecastEnqueueFailedIfStillProcessingAsync(int orderId) @@ -2985,7 +3020,7 @@ public partial class OrderService await MarkParcelDraftFailedAsync(order, ForecastBillingEnqueueFailReason); } - private async Task PublishForecastTrackAsync(int orderId) + private async Task PublishForecastTrackAsync(int orderId, string? authorization = null) { var order = await OrderRepository.FirstOrDefaultAsync(x => x.Id == orderId); if (order == null @@ -3000,7 +3035,7 @@ public partial class OrderService : null; var request = await BuildSmallBagTrackUpsertRequestAsync(order, receiverInput: null); request.AppendInitialTrackOnSuccess = true; - ApplyTrackAuthorization(request); + ApplyTrackAuthorization(request, authorization); await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } @@ -3031,13 +3066,16 @@ public partial class OrderService private async Task EnqueueForecastBillingAsync( Order order, int? couponInstanceId, - int operatorUserId) + int operatorUserId, + string? authorization = null) { var logger = LazyServiceProvider.GetRequiredService() .CreateLogger("Oms.ForecastBilling"); + authorization ??= CaptureRequestAuthorization(); var payloadJson = JsonConvert.SerializeObject(new ForecastBillingJobPayload { - CouponInstanceId = couponInstanceId + CouponInstanceId = couponInstanceId, + Authorization = authorization }); try @@ -3047,7 +3085,11 @@ public partial class OrderService { try { - await ProcessForecastBillingForBatchAsync(order.Id, payloadJson, operatorUserId); + await ProcessForecastBillingForBatchAsync( + order.Id, + payloadJson, + operatorUserId, + authorization: authorization); } catch (Exception ex) { @@ -3073,7 +3115,8 @@ public partial class OrderService RequestId = requestId, PayloadJson = payloadJson }, - operatorUserId); + operatorUserId, + authorization); } catch (Exception ex) { @@ -3375,18 +3418,37 @@ public partial class OrderService await ParcelCreateEventPublisher.PublishTrackUpsertAsync(order, request, customer?.Id); } - private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request) + private void ApplyTrackAuthorization(SmallBagTrackUpsertRequest request, string? authorization = null) { - var authorization = LazyServiceProvider - .GetRequiredService() - .HttpContext? - .Request - .Headers - .Authorization - .ToString(); if (!string.IsNullOrWhiteSpace(authorization)) { - request.Authorization = authorization; + request.Authorization = authorization.Trim(); + return; + } + + var fromRequest = CaptureRequestAuthorization(); + if (!string.IsNullOrWhiteSpace(fromRequest)) + { + request.Authorization = fromRequest; + } + } + + private string? CaptureRequestAuthorization() + { + try + { + var authorization = LazyServiceProvider + .GetRequiredService() + .HttpContext? + .Request + .Headers + .Authorization + .ToString(); + return string.IsNullOrWhiteSpace(authorization) ? null : authorization.Trim(); + } + catch (ObjectDisposedException) + { + return null; } } diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs index 2e5fe6d..1932297 100644 --- a/src/Oms.Application/Services/Orders/OrderService.cs +++ b/src/Oms.Application/Services/Orders/OrderService.cs @@ -623,6 +623,7 @@ public partial class OrderService : ApplicationService /// /// 【预报计费消费后】计费重、应收、排货应付、营销快照 → 订单改已预报。 /// 排货成功且已有 ChannelCode 时,再投递轨迹订单 + A0101(track-upsert); + /// 轨迹网关需要登录 Authorization,创单时写入预报任务 Payload,消费端原样转发; /// 排货失败不阻断预报,也不建轨迹(可页面补做)。 /// /// -- 2.45.2