diff --git a/db.sql b/db.sql
index c8afb68..b1afb07 100644
--- a/db.sql
+++ b/db.sql
@@ -179,22 +179,22 @@ CREATE TABLE `t_order` (
) COMMENT='订单表';
CREATE TABLE `t_order_file` (
- `Id` INT NOT NULL AUTO_INCREMENT,
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
`OrderId` INT NOT NULL COMMENT '订单Id',
- `FileType` VARCHAR(32) NOT NULL COMMENT 'tradeProof/systemLabel/downstreamLabel/lastMileLabel/infraredImage',
+ `FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片',
`FileName` VARCHAR(255) NULL COMMENT '原始文件名',
- `FileUrl` VARCHAR(512) NOT NULL COMMENT '相对路径或外链',
- `ContentType` VARCHAR(128) NULL,
- `FileSize` BIGINT NULL,
- `Sort` INT NOT NULL DEFAULT 0,
- `Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT 'upload/systemGenerate/downstream/migrate',
- `Remark` VARCHAR(512) NULL,
- `IsDeleted` TINYINT NOT NULL DEFAULT 0,
- `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()),
- `CreateUserId` INT NOT NULL DEFAULT 0,
- `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- `UpdateUserId` INT NULL,
- `UpdateTime` DATETIME NULL,
+ `FileUrl` VARCHAR(512) NOT NULL COMMENT '文件地址(相对路径或外链)',
+ `ContentType` VARCHAR(128) NULL COMMENT '文件MIME类型',
+ `FileSize` BIGINT NULL COMMENT '文件大小(字节)',
+ `Sort` INT NOT NULL DEFAULT 0 COMMENT '同类型内排序',
+ `Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT '来源:upload上传/systemGenerate系统生成/downstream下游回传/migrate迁移',
+ `Remark` 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_ord_file_order_type` (`OrderId`, `FileType`, `IsDeleted`)
) COMMENT='订单附件表';
diff --git a/sql/20260728_order_file.sql b/sql/20260728_order_file.sql
index 79d7970..1e94b5f 100644
--- a/sql/20260728_order_file.sql
+++ b/sql/20260728_order_file.sql
@@ -1,28 +1,28 @@
--- Order files table + migrate LabelUrl into systemLabel, then drop legacy columns.
--- Run on MySQL. Backup first.
+-- 订单附件表;将原 LabelUrl 迁移为 systemLabel 后删除订单旧面单字段。
+-- 在 MySQL 执行前请先备份。
CREATE TABLE IF NOT EXISTS `t_order_file` (
- `Id` INT NOT NULL AUTO_INCREMENT,
- `OrderId` INT NOT NULL COMMENT 'Order Id',
- `FileType` VARCHAR(32) NOT NULL COMMENT 'tradeProof/systemLabel/downstreamLabel/lastMileLabel/infraredImage',
- `FileName` VARCHAR(255) NULL COMMENT 'Original file name',
- `FileUrl` VARCHAR(512) NOT NULL COMMENT 'Relative or absolute URL',
- `ContentType` VARCHAR(128) NULL,
- `FileSize` BIGINT NULL,
- `Sort` INT NOT NULL DEFAULT 0,
- `Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT 'upload/systemGenerate/downstream/migrate',
- `Remark` VARCHAR(512) NULL,
- `IsDeleted` TINYINT NOT NULL DEFAULT 0,
- `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()),
- `CreateUserId` INT NOT NULL DEFAULT 0,
- `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- `UpdateUserId` INT NULL,
- `UpdateTime` DATETIME NULL,
+ `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
+ `OrderId` INT NOT NULL COMMENT '订单Id',
+ `FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片',
+ `FileName` VARCHAR(255) NULL COMMENT '原始文件名',
+ `FileUrl` VARCHAR(512) NOT NULL COMMENT '文件地址(相对路径或外链)',
+ `ContentType` VARCHAR(128) NULL COMMENT '文件MIME类型',
+ `FileSize` BIGINT NULL COMMENT '文件大小(字节)',
+ `Sort` INT NOT NULL DEFAULT 0 COMMENT '同类型内排序',
+ `Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT '来源:upload上传/systemGenerate系统生成/downstream下游回传/migrate迁移',
+ `Remark` 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_ord_file_order_type` (`OrderId`, `FileType`, `IsDeleted`)
-) COMMENT='Order attachment files';
+) COMMENT='订单附件表';
--- Migrate existing system label URL (ignore if column already dropped)
+-- 迁移已有系统面单地址(若 LabelUrl 列已删除可跳过本段)
INSERT INTO `t_order_file` (`OrderId`, `FileType`, `FileName`, `FileUrl`, `ContentType`, `Sort`, `Source`, `CreateUserId`, `CreateTime`)
SELECT
`Id`,
diff --git a/sql/20260728_order_file_comments.sql b/sql/20260728_order_file_comments.sql
new file mode 100644
index 0000000..1934c3c
--- /dev/null
+++ b/sql/20260728_order_file_comments.sql
@@ -0,0 +1,20 @@
+-- 若 t_order_file 已按英文注释建表,执行本脚本改为中文注释(不改结构)。
+
+ALTER TABLE `t_order_file`
+ MODIFY COLUMN `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
+ MODIFY COLUMN `OrderId` INT NOT NULL COMMENT '订单Id',
+ MODIFY COLUMN `FileType` VARCHAR(32) NOT NULL COMMENT '附件类型:tradeProof交易证明/systemLabel系统面单/downstreamLabel下游面单/lastMileLabel末端面单/infraredImage红外图片',
+ MODIFY COLUMN `FileName` VARCHAR(255) NULL COMMENT '原始文件名',
+ MODIFY COLUMN `FileUrl` VARCHAR(512) NOT NULL COMMENT '文件地址(相对路径或外链)',
+ MODIFY COLUMN `ContentType` VARCHAR(128) NULL COMMENT '文件MIME类型',
+ MODIFY COLUMN `FileSize` BIGINT NULL COMMENT '文件大小(字节)',
+ MODIFY COLUMN `Sort` INT NOT NULL DEFAULT 0 COMMENT '同类型内排序',
+ MODIFY COLUMN `Source` VARCHAR(32) NOT NULL DEFAULT 'upload' COMMENT '来源:upload上传/systemGenerate系统生成/downstream下游回传/migrate迁移',
+ MODIFY COLUMN `Remark` VARCHAR(512) NULL COMMENT '备注',
+ MODIFY COLUMN `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
+ MODIFY COLUMN `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
+ MODIFY COLUMN `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
+ MODIFY COLUMN `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人',
+ MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间',
+ COMMENT='订单附件表';
diff --git a/src/Oms.Application/Dtos/Orders/OrderParcelDraftFailData.cs b/src/Oms.Application/Dtos/Orders/OrderParcelDraftFailData.cs
new file mode 100644
index 0000000..d7bc198
--- /dev/null
+++ b/src/Oms.Application/Dtos/Orders/OrderParcelDraftFailData.cs
@@ -0,0 +1,35 @@
+namespace Oms.Application.Dtos.Orders;
+
+///
+/// 小包创建/草稿预报失败且已落库时的结构化错误载荷(写入异常 Data / 响应 Details)。
+///
+public class OrderParcelDraftFailData
+{
+ public const string ErrorCodeValue = "OMS_DRAFT_SAVED";
+ public const string DataKey = "omsDraftFail";
+
+ ///
+ /// 是否已保存为草稿。
+ ///
+ public bool DraftSaved { get; set; } = true;
+
+ ///
+ /// 订单主键。
+ ///
+ public int OrderId { get; set; }
+
+ ///
+ /// 运单号。
+ ///
+ public string ServiceNo { get; set; } = string.Empty;
+
+ ///
+ /// 干净失败原因(不含机器标记)。
+ ///
+ public string Reason { get; set; } = string.Empty;
+
+ ///
+ /// 稳定错误码,供前端识别。
+ ///
+ public string ErrorCode { get; set; } = ErrorCodeValue;
+}
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index d4e9afe..7d93f8f 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -181,16 +181,86 @@ public partial class OrderService
}
catch (BusinessException ex)
{
- await MarkParcelDraftFailedAsync(order, ex.Message);
- throw;
+ var cleanReason = StripDraftSavedMarker(ex.Message);
+ await MarkParcelDraftFailedAsync(order, cleanReason);
+ throw CreateDraftSavedBusinessException(order, cleanReason);
}
catch (Exception ex)
{
- await MarkParcelDraftFailedAsync(order, ex.Message);
- throw new BusinessException(ex.Message);
+ var cleanReason = StripDraftSavedMarker(ex.Message);
+ await MarkParcelDraftFailedAsync(order, cleanReason);
+ throw CreateDraftSavedBusinessException(order, cleanReason);
}
}
+ ///
+ /// 草稿已落库失败时,在业务文案末尾附加机器可读标记,供前端区分「未落库 / 已存草稿」。
+ /// 格式:原因 [[omsDraft:{"draftSaved":true,"orderId":1,"serviceNo":"IDE..."}]]
+ /// 同时写入 Exception.Data / ErrorCode,由 OmsException2ResponseConverter 输出到响应 Details。
+ ///
+ private const string DraftSavedMarkerPrefix = " [[omsDraft:";
+ private const string DraftSavedMarkerSuffix = "]]";
+
+ private static string StripDraftSavedMarker(string? message)
+ {
+ if (string.IsNullOrEmpty(message))
+ {
+ return string.Empty;
+ }
+
+ var idx = message.IndexOf(DraftSavedMarkerPrefix, StringComparison.Ordinal);
+ return idx < 0 ? message : message[..idx].TrimEnd();
+ }
+
+ private static string WrapDraftSavedFail(Order order, string reason)
+ {
+ var clean = StripDraftSavedMarker(reason);
+ if (string.IsNullOrWhiteSpace(clean))
+ {
+ clean = "预报未完成";
+ }
+
+ var payload = global::System.Text.Json.JsonSerializer.Serialize(new Dictionary
+ {
+ ["draftSaved"] = true,
+ ["orderId"] = order.Id,
+ ["serviceNo"] = order.ServiceNo ?? string.Empty,
+ ["reason"] = clean,
+ ["errorCode"] = OrderParcelDraftFailData.ErrorCodeValue,
+ });
+ return $"{clean}{DraftSavedMarkerPrefix}{payload}{DraftSavedMarkerSuffix}";
+ }
+
+ ///
+ /// 抛出「草稿已落库」业务异常:ErrorCode + Data 结构化载荷,msg 仍带兼容标记。
+ ///
+ private static BusinessException CreateDraftSavedBusinessException(Order order, string reason)
+ {
+ var clean = StripDraftSavedMarker(reason);
+ if (string.IsNullOrWhiteSpace(clean))
+ {
+ clean = "预报未完成";
+ }
+
+ var failData = new OrderParcelDraftFailData
+ {
+ DraftSaved = true,
+ OrderId = order.Id,
+ ServiceNo = order.ServiceNo ?? string.Empty,
+ Reason = clean,
+ ErrorCode = OrderParcelDraftFailData.ErrorCodeValue,
+ };
+
+ var ex = new BusinessException(WrapDraftSavedFail(order, clean));
+ ex.ErrorCode = OrderParcelDraftFailData.ErrorCodeValue;
+ ex.Data["draftSaved"] = true;
+ ex.Data["orderId"] = order.Id;
+ ex.Data["serviceNo"] = order.ServiceNo ?? string.Empty;
+ ex.Data[OrderParcelDraftFailData.DataKey] =
+ global::System.Text.Json.JsonSerializer.Serialize(failData);
+ return ex;
+ }
+
private const string ParcelServiceNoPrefix = "IDE";
///
@@ -1536,6 +1606,106 @@ public partial class OrderService
throw new BusinessException("运单号生成失败");
}
+ ///
+ /// 草稿重新预报:按订单当前收件/件重尺/快照重跑黑名单、计重、分区与预报应收。
+ /// 失败时保持草稿并写入 PredeclareFailReason。
+ ///
+ internal async Task CompleteDraftPredeclareAsync(Order order, int? operatorUserId = null)
+ {
+ if (string.IsNullOrWhiteSpace(order.ProductCode))
+ {
+ throw new BusinessException("订单未绑定销售产品,无法预报");
+ }
+
+ var product = await SalesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
+ if (product == null || product.IsActive != EnableStatus.Enabled)
+ {
+ throw new BusinessException("销售产品不存在或未启用");
+ }
+
+ try
+ {
+ var snapshotBinding = await ResolveSnapshotBindingFromOrderAsync(product, order);
+ var address = await ResolveOrderBillingAddressAsync(order);
+
+ if (await IsBlacklistHitAsync(
+ product,
+ snapshotBinding.Rules,
+ address.CountryCode,
+ address.Province,
+ address.City,
+ address.ZipCode))
+ {
+ throw new BusinessException("收件地址命中黑名单区域");
+ }
+
+ order.IsRemote = await IsRemoteHitAsync(
+ product,
+ snapshotBinding.Rules,
+ address.CountryCode,
+ address.Province,
+ address.City,
+ address.ZipCode) ? (byte)1 : (byte)0;
+
+ var weights = (await OrderRepository.GetWeightsByOrderIdsAsync([order.Id]))
+ .OrderBy(x => x.Id)
+ .ToList();
+ var forecast = weights.FirstOrDefault(x =>
+ string.Equals(x.Type, OrderWeightTypeCodes.Forecast, StringComparison.OrdinalIgnoreCase))
+ ?? throw new BusinessException("订单缺少预报件重尺");
+
+ await ApplyChargeableWeightAsync(product, snapshotBinding.Rules, forecast);
+ SyncWeightRows(weights, forecast);
+ await OrderRepository.ReplaceWeightsAsync(order.Id, weights);
+
+ var billing = await ResolveOrderBillingContextAsync(
+ order,
+ product,
+ forecast.ChargeableWeight ?? 0,
+ snapshotBinding);
+
+ await UpsertReceivableFeesAsync(
+ order,
+ billing,
+ OrderFeeChargingStageCodes.Forecast,
+ replaceExistingReceivable: true);
+
+ order.OrderStatus = OrderStatusCodes.Predeclared;
+ order.PredeclareFailReason = string.Empty;
+ order.AllocationPendingReason = string.Empty;
+ order.UpdateTime = DateTime.Now;
+ await OrderRepository.UpdateAsync(order);
+
+ var customer = order.CustomerId.HasValue
+ ? await CustomerRepository.FirstOrDefaultAsync(x => x.Id == order.CustomerId.Value)
+ : null;
+ await EnqueueSmallBagTrackUpsertAsync(order, customer, receiver: null, appendInitialTrack: true);
+ await EnqueueGenerateLabelPdfAsync(order);
+
+ try
+ {
+ var triggerUserId = operatorUserId ?? CurrentUser.GetId();
+ await AutoTriggerRuntimeService.FireAsync(order, AutoTriggerTiming.OrderCreate, triggerUserId);
+ }
+ catch
+ {
+ // 自动触发失败不影响预报主流程
+ }
+ }
+ catch (BusinessException ex)
+ {
+ var cleanReason = StripDraftSavedMarker(ex.Message);
+ await MarkParcelDraftFailedAsync(order, cleanReason);
+ throw CreateDraftSavedBusinessException(order, cleanReason);
+ }
+ catch (Exception ex)
+ {
+ var cleanReason = StripDraftSavedMarker(ex.Message);
+ await MarkParcelDraftFailedAsync(order, cleanReason);
+ throw CreateDraftSavedBusinessException(order, cleanReason);
+ }
+ }
+
private async Task MarkParcelDraftFailedAsync(Order order, string reason)
{
order.OrderStatus = OrderStatusCodes.Draft;
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index b8c8424..14f753e 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -193,8 +193,18 @@ public partial class OrderService : ApplicationService
public async Task PreDeclareAsync(OrderActionInput input)
{
var order = await GetOrderOrThrowAsync(input.Id);
- order.OrderStatus = OrderStatusCodes.Predeclared;
- await UpdateOrderAsync(order);
+ if (string.Equals(order.OrderStatus, OrderStatusCodes.Predeclared, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ if (!string.Equals(order.OrderStatus, OrderStatusCodes.Draft, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new BusinessException(
+ $"订单状态为「{OmsDocHelper.GetOrderStatusName(order.OrderStatus)}」,不允许预报");
+ }
+
+ await CompleteDraftPredeclareAsync(order);
}
///
diff --git a/src/SimpleAdmin.Api/ExceptionHandling/OmsException2ResponseConverter.cs b/src/SimpleAdmin.Api/ExceptionHandling/OmsException2ResponseConverter.cs
new file mode 100644
index 0000000..576a3b2
--- /dev/null
+++ b/src/SimpleAdmin.Api/ExceptionHandling/OmsException2ResponseConverter.cs
@@ -0,0 +1,141 @@
+using System.Collections;
+using Fake.Application;
+using Fake.Application.Dtos;
+using Fake.AspNetCore.ExceptionHandling;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Serialization;
+using Oms.Application.Dtos.Orders;
+
+namespace SimpleAdmin.Api.ExceptionHandling;
+
+///
+/// 在 Fake 默认异常转换之上,把草稿已落库失败的结构化信息写入 Details,并清理 msg 中的机器标记。
+///
+public class OmsException2ResponseConverter : IException2ResponseConverter
+{
+ private static readonly JsonSerializerSettings JsonSettings = new()
+ {
+ ContractResolver = new CamelCasePropertyNamesContractResolver(),
+ NullValueHandling = NullValueHandling.Ignore,
+ };
+
+ private const string DraftSavedMarkerPrefix = " [[omsDraft:";
+
+ private readonly DefaultException2ResponseConverter _inner;
+
+ public OmsException2ResponseConverter(DefaultException2ResponseConverter inner)
+ {
+ _inner = inner;
+ }
+
+ public ApplicationExceptionResponse Convert(Exception exception, FakeExceptionHandlingOptions options)
+ {
+ var response = _inner.Convert(exception, options);
+ response.Message = StripDraftSavedMarker(response.Message);
+
+ if (TryBuildDraftFailData(exception, out var failData))
+ {
+ if (string.IsNullOrWhiteSpace(response.Message))
+ {
+ response.Message = failData.Reason;
+ }
+
+ response.Details = JsonConvert.SerializeObject(failData, JsonSettings);
+ }
+
+ return response;
+ }
+
+ private static bool TryBuildDraftFailData(Exception exception, out OrderParcelDraftFailData failData)
+ {
+ failData = new OrderParcelDraftFailData();
+ if (exception is not BusinessException business)
+ {
+ return false;
+ }
+
+ if (business.Data[OrderParcelDraftFailData.DataKey] is string json
+ && !string.IsNullOrWhiteSpace(json))
+ {
+ try
+ {
+ var parsed = JsonConvert.DeserializeObject(json);
+ if (parsed != null && parsed.DraftSaved && parsed.OrderId > 0)
+ {
+ failData = parsed;
+ return true;
+ }
+ }
+ catch
+ {
+ // fall through to key-based parse
+ }
+ }
+
+ if (!HasDraftSavedFlag(business.Data))
+ {
+ // 兼容旧客户端:仅从 message 尾部标记解析
+ return TryParseMarker(business.Message, out failData);
+ }
+
+ failData.DraftSaved = true;
+ failData.OrderId = ToInt(business.Data["orderId"] ?? business.Data["OrderId"]);
+ failData.ServiceNo = ToStringValue(business.Data["serviceNo"] ?? business.Data["ServiceNo"]);
+ failData.Reason = StripDraftSavedMarker(business.Message);
+ failData.ErrorCode = OrderParcelDraftFailData.ErrorCodeValue;
+ return failData.OrderId > 0;
+ }
+
+ private static bool HasDraftSavedFlag(IDictionary data)
+ {
+ if (data.Contains("draftSaved") && IsTruthy(data["draftSaved"])) return true;
+ if (data.Contains("DraftSaved") && IsTruthy(data["DraftSaved"])) return true;
+ return false;
+ }
+
+ private static bool TryParseMarker(string? message, out OrderParcelDraftFailData failData)
+ {
+ failData = new OrderParcelDraftFailData();
+ if (string.IsNullOrEmpty(message)) return false;
+ var idx = message.IndexOf(DraftSavedMarkerPrefix, StringComparison.Ordinal);
+ if (idx < 0) return false;
+ var jsonStart = idx + DraftSavedMarkerPrefix.Length;
+ var jsonEnd = message.LastIndexOf("]]", StringComparison.Ordinal);
+ if (jsonEnd <= jsonStart) return false;
+ try
+ {
+ var parsed = JsonConvert.DeserializeObject(message[jsonStart..jsonEnd]);
+ if (parsed == null || !parsed.DraftSaved || parsed.OrderId <= 0) return false;
+ parsed.Reason = message[..idx].TrimEnd();
+ parsed.ErrorCode = OrderParcelDraftFailData.ErrorCodeValue;
+ failData = parsed;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static string StripDraftSavedMarker(string? message)
+ {
+ if (string.IsNullOrEmpty(message)) return string.Empty;
+ var idx = message.IndexOf(DraftSavedMarkerPrefix, StringComparison.Ordinal);
+ return idx < 0 ? message : message[..idx].TrimEnd();
+ }
+
+ private static bool IsTruthy(object? value) =>
+ value is true
+ || (value is bool b && b)
+ || string.Equals(global::System.Convert.ToString(value), "true", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(global::System.Convert.ToString(value), "1", StringComparison.OrdinalIgnoreCase);
+
+ private static int ToInt(object? value)
+ {
+ if (value == null) return 0;
+ return int.TryParse(global::System.Convert.ToString(value), out var n) ? n : 0;
+ }
+
+ private static string ToStringValue(object? value) =>
+ global::System.Convert.ToString(value)?.Trim() ?? string.Empty;
+}
diff --git a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
index 9cad2cb..9ae4d2c 100644
--- a/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
+++ b/src/SimpleAdmin.Api/SimpleAdminApiModule.cs
@@ -101,6 +101,20 @@ public class SimpleAdminApiModule : FakeModule
});
services.AddFeiShuExceptionSubscriber();
+
+ // 草稿已落库失败:结构化 Details + 清理 msg 机器标记
+ services.AddTransient();
+ var exceptionConverterDescriptors = services
+ .Where(d => d.ServiceType == typeof(IException2ResponseConverter))
+ .ToList();
+ foreach (var descriptor in exceptionConverterDescriptors)
+ {
+ services.Remove(descriptor);
+ }
+
+ services.AddTransient(sp =>
+ new ExceptionHandling.OmsException2ResponseConverter(
+ sp.GetRequiredService()));
}
public override void ConfigureApplication(ApplicationConfigureContext context)