目录
- [事故开篇:三张采购单、超量 30% 和"报价中"的报价](#事故开篇:三张采购单、超量 30% 和"报价中"的报价)
- [结论先行:M2 的聚合划分与一致性全景](#结论先行:M2 的聚合划分与一致性全景)
- [聚合边界:Inquiry、Quote 与 PurchaseOrder 怎么切](#聚合边界:Inquiry、Quote 与 PurchaseOrder 怎么切)
- [充血模型(上):共享内核、Money 与领域事件基座](#充血模型(上):共享内核、Money 与领域事件基座)
- [充血模型(中):Inquiry 询价聚合完整实现](#充血模型(中):Inquiry 询价聚合完整实现)
- [充血模型(下):PurchaseOrder 聚合与跨聚合不变量](#充血模型(下):PurchaseOrder 聚合与跨聚合不变量)
- [跨聚合一致性:领域服务、最终一致与 Saga 的决策表](#跨聚合一致性:领域服务、最终一致与 Saga 的决策表)
- 领域事件机制:两种分发时机与失败窗口
- [Outbox 精简实现:同事务落库 + BackgroundService 轮询](#Outbox 精简实现:同事务落库 + BackgroundService 轮询)
- [应用服务编排:幂等转单与 Minimal API](#应用服务编排:幂等转单与 Minimal API)
- [EF Core 持久化:表结构、映射配置与双 Provider 差异](#EF Core 持久化:表结构、映射配置与双 Provider 差异)
- [测试:状态机、事件负载与 SQLite 跨聚合集成测试](#测试:状态机、事件负载与 SQLite 跨聚合集成测试)
- [踩坑清单、Checklist 与下一篇引子](#踩坑清单、Checklist 与下一篇引子)
- 官方参考资料
1. 事故开篇:三张采购单、超量 30% 和"报价中"的报价
M2 启动会上,项目经理王五先翻出了老系统采购模块的三个工单。三个事故,恰好对应三个建模难题。
事故一:同一个按钮,点出两张采购单。
老系统"询价转采购单"的接口长这样(已脱敏、简化):
csharp
// 老系统:查询-判断-写入,三步之间没有任何并发保护,也没有幂等键
public async Task<string> GenOrder(string inqId)
{
var inq = await db.MrpInquiries.FirstAsync(x => x.InqID == inqId);
if (inq.GenOrder == "1") // ① 查标志
return inq.OrderID; // 已转过就返回旧单
var order = BuildOrder(inq);
db.MrpOrders.Add(order);
await db.SaveChangesAsync(); // ② 插入新采购单
inq.GenOrder = "1"; // ③ 回写标志
inq.OrderID = order.OrderID;
await db.SaveChangesAsync();
return order.OrderID;
}
船上网络慢,采购员李四双击了"生成采购单"按钮。两个 HTTP 请求几乎同时进来,都在 ① 处读到 GenOrder == "0",于是各自插入一张采购单------同一批物料对某供应商 A 下了两张 PO 。③ 的回写互相覆盖,最后标志位是"1",看起来一切正常,直到供应商拿着两张单号不同的订单确认函回传,船上仓库才发现货到了两份。mrp_inquiry 表上没有 (inq_id, vendor) 的唯一约束,数据库一声没吭。
事故二:采购数量比批准数量多 30%,没有一个接口拦住它。
物料申请 (2026)HKMW-TEC-MRP-0001 三级审批批准的是 100 个滤器。实际下单 130 个。复盘发现:超量校验只写在 Web 端的 JS 里(if (qty > approvedQty) alert(...)),而这批单是采购通过Excel 导入接口批量下的,导入服务里没有这段校验。规则活在按钮的事件处理函数里,绕过按钮(导入、定时任务、直接写库)就等于绕过规则。
事故三:报价单还"报价中",就被拿来下单了。
某供应商 B 的报价记录状态还是"报价中"(QuoteDate 为空,价格是业务员口头问来先填进系统的草价),采购员为了赶船期直接拿这行草价转了 PO。一周后供应商正式报价回传,单价上涨 12%,订单已确认(ComfirmNum 已回填),只能走变更索赔。
本篇结论先行:
- 聚合边界 :M2 切三个聚合------
Inquiry(询价单,内含询价行与多家供应商的报价实体SupplierQuote)、PurchaseOrder(独立聚合)、Supplier(本篇只建只读模型/防腐接口)。报价必须留在询价聚合内(离开询价单它没有独立身份,"同物料多家报价可比价"是聚合内不变量);采购订单必须独立(独立生命周期、独立状态机、收货与付款都要引用它)。- 跨聚合约束 :"下单数量 ≤ 申请批准数量 − 已订数量"横跨
PurchaseOrder与MaterialApply两个聚合,任何单聚合在内存里都无法天然保证它 。船端单机同库用领域服务 + 单事务强一致 ;岸基多服务用领域事件 + Outbox 最终一致;跨服务长流程才上 Saga。不要为了"纯"在单机 SQLite 场景硬上消息队列。- 状态不靠按钮防呆 :状态迁移只能走聚合方法(截止后不可报价、不足两家不可比价选定、报价未确认不可转单),幂等用
RequestId+ 数据库唯一索引兜底,让重复点击在数据库层就不可能产生第二张单。
2. 结论先行:M2 的聚合划分与一致性全景
先上全景图,后文每一节都在填充这张图的某个局部:
text
MaterialApply (M1 聚合) Inquiry (M2 聚合)
┌────────────────────┐ 持 Id ┌──────────────────────────────────┐
│ MaterialApplyId │◄─────────────│ ApplyId (只持 Id) │
│ ApprovedQty 批准数量│ │ InquiryItems 询价行 │
│ OrderedQty 已订数量│ │ SupplierQuotes[] 多家供应商报价 │
│ Status = Approved │ 事件回写 │ └ QuoteLines[] 逐行报价/交期 │
└────────────────────┘ │ Status: Draft→Quoting→Quoted │
▲ │ →Closed/Cancelled │
│ PurchaseOrderCreated └───────────────┬──────────────────┘
│ 事件 / 领域服务校验 │ PickBestQuote 选定
│ ▼
│ PurchaseOrder (M2 独立聚合)
│ ┌──────────────────────────────┐
└────────────────────────────────│ ApplyId / InquiryId (只持 Id)│
OrderedQty 回写 │ SupplierId │
│ PurchaseOrderItems 数量/单价 │
│ Money 合计 │
│ Draft→Submitted→Confirmed │
│ →PartialReceived→Received │
│ →Closed / Cancelled │
└──────────────────────────────┘
Supplier(供应商主数据):本篇只读,ISupplierLookup 防腐接口,不进采购聚合的一致性边界
三个贯穿全篇的设计决定:
| 决定 | 选择 | 理由一句话 |
|---|---|---|
报价 SupplierQuote 放哪 |
Inquiry 聚合内实体 | 报价的身份是"(询价单,供应商)",比价规则要求一次加载全部报价 |
| 采购订单放哪 | 独立聚合 | 它要活很久:收货、拒收、发票、付款都引用它,状态机与询价完全不同 |
| 跨聚合数量约束 | 船端领域服务强一致;岸基事件最终一致 | 一致性策略由部署拓扑决定,而不是由"DDD 纯度"决定 |
3. 聚合边界:Inquiry、Quote 与 PurchaseOrder 怎么切
3.1 老系统的模型:一家供应商一张询价单
老系统(表 mrp_inquiry / mrp_inquirymrp)的设计是一张询价单只面向一个供应商 (详设文档中的不变量 I2:一个询价单只能面向一个 Vendor)。于是"三家比价"在老系统里意味着三张询价单,比价逻辑只能写成一个领域服务,把同一申请下的多张询价单捞到内存里横向比对------详设文档里的 InquiryComparisonService 正是这么干的,它计算每家的 landed cost:
text
总到港成本 = Σ(报价数量 QuoteNum × 单价 Price)
+ 运输费 TransportPrice + 包装费 PackingPrice + 清关费 ClearancePrice
− 折扣 Discount
这个模型的问题:"同物料、多家报价可比对"这条规则没有任何一个聚合能守护。三张询价单各自合法,合在一起是否可比(币种是否一致、报价是否都在有效期、数量口径是否一致)要靠查询服务临时拼,拼的逻辑漏一个调用方,比价就拿脏数据比------事故三的草价下单就是这么来的。
3.2 M2 的切法:一张询价单,邀请多家,报价是聚合内实体
M2 把"同一批次向多家询价"收敛进一个 Inquiry 聚合:
Inquiry(聚合根,表mrp_inquiry):关联一张 已批准的物料申请(持MaterialApplyId)、报价截止时间、状态机;InquiryItem(实体,表mrp_inquiryitem):按物料行记录询价数量(对齐老字段InqNum)与申请批准数量快照(AppNum);SupplierQuote(实体,表mrp_inquiryquote):一家供应商在此询价单下的一份报价 ,含逐行报价QuoteLine(报价数量QuoteNum、单价Price、品牌Brands、交货周期)、报价币种与附加费;PurchaseOrder(独立聚合根,表mrp_po)+PurchaseOrderItem(表mrp_poitem)。
为什么 Quote 是实体而不是独立聚合? 两个判定标准:
- 离开聚合根,它有没有独立身份和独立生命周期? 供应商的一份报价脱离询价单没有业务意义------没人会直接"打开一份报价",打开的永远是"某询价单下某供应商的报价"。它的身份是
(InquiryId, SupplierId)复合的,是询价单的内部细节。 - 有没有必须原子满足的跨实体规则? 有,而且是核心规则:"同物料多家报价可比对"(币种一致、口径一致)、"至少两家有效报价才能比价选定"、"截止后任何一家都不能再改价"。这些规则要求报价们被同一个聚合根一次性加载、在一个事务内一致地修改。若 Quote 独立成聚合,这些规则就会退化成跨聚合约束,重蹈老系统比价服务的覆辙。
那采购订单为什么必须独立,不能做成询价的一部分? 四条理由,每一条都是硬边界:
- 独立生命周期 :询价在"选定供应商"那一刻就关闭(Closed)了,而采购订单要继续活几个月------确认、分批发货(老表
mrp_ordertrace支持多批TraceID)、到货验收、拒收(RejectQuantity)、发票(InvNum)、付款(PaidNumber),询价不可能陪着它加载、保存、并发控制; - 独立状态机 :
Draft→Submitted→Confirmed→PartialReceived→Received→Closed与询价的Draft→Quoting→Quoted→Closed没有状态同步关系; - 被其他聚合引用 :M3 的库存交易(
mrp_storetrans带OrderID)、费用单(mrp_fare)都要引用采购订单,被引用者必须是独立聚合根; - 加载/并发性能边界:一张询价单可能邀请 5 家、每家 30 行;一张 PO 后续还会挂收货流水。塞成一个聚合,每次收货都要把 5 家报价全部加载并置于并发令牌保护下,毫无必要。
聚合之间只持 Id,不持对象引用,也不互相持有仓储 ------PurchaseOrder 上有 InquiryId / SupplierId / ApplyId 三个强类型 Id,但没有 Inquiry Inquiry { get; set; } 这种导航属性。对象引用一旦存在,懒加载 N+1、跨聚合随便改对方状态、聚合边界事实上瓦解,都是迟早的事。
4. 充血模型(上):共享内核、Money 与领域事件基座
Domain 层零 EF Core 引用(这条纪律 M1 篇讲过,M2 继续遵守)。先放两个聚合共用的共享内核(SharedKernel)。
4.1 强类型 Id
csharp
// Domain/SharedKernel/StronglyTypedIds.cs
namespace Pms.Domain.SharedKernel;
// readonly record struct:值语义 + 零开销,编译器替我们盯住"哪个 Id 是哪个"
public readonly record struct MaterialApplyId(Guid Value)
{
public static MaterialApplyId New() => new(Guid.CreateVersion7()); // .NET 9+ 顺序 Guid
public override string ToString() => Value.ToString("N");
}
public readonly record struct InquiryId(Guid Value)
{
public static InquiryId New() => new(Guid.CreateVersion7());
}
public readonly record struct SupplierQuoteId(Guid Value)
{
public static SupplierQuoteId New() => new(Guid.CreateVersion7());
}
public readonly record struct PurchaseOrderId(Guid Value)
{
public static PurchaseOrderId New() => new(Guid.CreateVersion7());
}
public readonly record struct SupplierId(Guid Value);
public readonly record struct MaterialId(Guid Value);
Guid.CreateVersion7() 是 .NET 9 起 BCL 内置的 v7 顺序 GUID(时间有序),对 SQLite/SQL Server 的聚簇索引都比 v4 友好。强类型 Id 的完整讨论(路由绑定、JSON 转换器、批量值转换器)见值对象篇,本篇第 11 节只给 EF 映射。
4.2 Money:异币种不能相加、不能比较
csharp
// Domain/SharedKernel/Money.cs
namespace Pms.Domain.SharedKernel;
using Pms.Domain.Exceptions;
/// <summary>金额 = 数值 + 币种。不可变 record,所有运算在构造期保证不变量。</summary>
public sealed record Money(decimal Amount, string Currency)
{
public static Money Zero(string currency) => new(0m, currency);
public static Money operator +(Money left, Money right)
{
EnsureSameCurrency(left, right);
return left with { Amount = left.Amount + right.Amount };
}
public static Money operator -(Money left, Money right)
{
EnsureSameCurrency(left, right);
return left with { Amount = left.Amount - right.Amount };
}
/// <summary>金额 × 数量(行金额用),银行家四舍五入到分。</summary>
public Money Multiply(decimal quantity) =>
this with { Amount = Math.Round(Amount * quantity, 2, MidpointRounding.ToEven) };
public bool IsLessThan(Money other)
{
EnsureSameCurrency(this, other);
return Amount < other.Amount;
}
private static void EnsureSameCurrency(Money a, Money b)
{
if (!string.Equals(a.Currency, b.Currency, StringComparison.OrdinalIgnoreCase))
throw new DomainException($"币种不一致:{a.Currency} 与 {b.Currency} 不能直接运算,请先按汇率折算");
}
}
事故里"USD、usd、$ 拆成三行"的教训:币种一律 ISO 4217 大写码(CNY/USD),在值对象工厂入口归一化。比价时异币种直接拒绝并提示人工按汇率折算------汇率是随时变动的外部数据,静默按某个汇率选"最低价"会在汇率波动时选错供应商,这个决策必须显式化。
4.3 实体/聚合根基座与未提交事件列表
csharp
// Domain/SharedKernel/IDomainEvent.cs
namespace Pms.Domain.SharedKernel;
public interface IDomainEvent
{
Guid EventId { get; } // 幂等去重靠它,绝不能用 new Guid() 在发布时临时生成
DateTimeOffset OccurredOn { get; }
}
// Domain/SharedKernel/Entity.cs
namespace Pms.Domain.SharedKernel;
public abstract class Entity<TId> where TId : struct
{
public TId Id { get; protected set; }
// 并发令牌:M1 篇讲过,SQLite/SQL Server 都会映射为 x_Token 列做乐观并发
public uint RowVersion { get; private set; }
public void BumpVersion() => RowVersion++;
}
public abstract class AggregateRoot<TId> : Entity<TId> where TId : struct
{
// 未提交领域事件:聚合行为发生时挂账,由工作单元在保存时统一处置(第 8 节)
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
把事件列表挂在聚合根上而不是静态总线,有三个好处:测试时可以直接断言 aggregate.DomainEvents;事件与状态在同一个方法调用里产生,不会"状态改了忘了发事件";保存与分发的时机可以由工作单元统一控制(这正是第 8 节的核心)。
5. 充血模型(中):Inquiry 询价聚合完整实现
5.1 状态机
text
AddItem/InviteSupplier 发布(≥1行 & ≥2家 & 截止时间在未来)
Draft ───────────────────────► Draft ───────────────────────────────► Quoting
│ │
│ Cancel │ 供应商报价(可改价,留修订痕)
▼ │ 人工截止 / 到期截止
Cancelled now > Deadline
▼
Quoted
│
PickBestQuote/SelectQuote 选定赢家
▼
GeneratePurchaseOrder → Closed
│
Cancelled(仅 Quoting 前)
状态迁移全部收敛在聚合方法内,任何 Status = ... 的外部赋值在编译期就不可能(private set)。
5.2 询价行与报价实体
csharp
// Domain/Purchasing/InquiryItem.cs
namespace Pms.Domain.Purchasing;
using Pms.Domain.Exceptions;
using Pms.Domain.SharedKernel;
/// <summary>询价行:按物料行记录"申请批准了多少、本次询价多少"。</summary>
public class InquiryItem : Entity<Guid>
{
public MaterialId MaterialId { get; private set; }
public string ImpaCode { get; private set; } = default!; // IMPA 标准编码,可空(非 IMPA 物料)
public string MaterialName { get; private set; } = default!;
public string Unit { get; private set; } = default!;
public decimal ApprovedQty { get; private set; } // 申请批准数量快照(老字段 AppNum)
public decimal InquireQty { get; private set; } // 本次询价数量(老字段 InqNum)
public int LineNo { get; private set; }
internal InquiryItem(int lineNo, MaterialId materialId, string? impaCode,
string materialName, string unit, decimal approvedQty, decimal inquireQty)
{
if (inquireQty <= 0) throw new DomainException("询价数量必须大于 0");
if (inquireQty > approvedQty)
throw new DomainException($"物料 {materialName} 询价数量 {inquireQty} 超过申请批准数量 {approvedQty}");
Id = Guid.CreateVersion7();
LineNo = lineNo;
MaterialId = materialId;
ImpaCode = impaCode ?? string.Empty;
MaterialName = materialName;
Unit = unit;
ApprovedQty = approvedQty;
InquireQty = inquireQty;
}
private InquiryItem() { } // EF 物化通道
}
csharp
// Domain/Purchasing/SupplierQuote.cs
namespace Pms.Domain.Purchasing;
using Pms.Domain.Exceptions;
using Pms.Domain.SharedKernel;
public enum QuoteStatus { Draft, Submitted, Superseded }
/// <summary>报价行:某供应商对某询价行的报价。</summary>
public class QuoteLine : Entity<Guid>
{
public int InquiryLineNo { get; private set; }
public MaterialId MaterialId { get; private set; }
public decimal Qty { get; private set; } // 可供数量,可小于询价数量(部分可供)
public Money UnitPrice { get; private set; } = default!;
public int LeadTimeDays { get; private set; } // 交货周期(天)
public string? Brand { get; private set; } // 品牌(老字段 Brands)
internal QuoteLine(int lineNo, MaterialId materialId, decimal qty,
Money unitPrice, int leadTimeDays, string? brand)
{
if (qty <= 0) throw new DomainException("报价数量必须大于 0");
if (unitPrice.Amount < 0) throw new DomainException("报价单价不可为负");
if (leadTimeDays < 0) throw new DomainException("交货周期不可为负");
Id = Guid.CreateVersion7();
InquiryLineNo = lineNo;
MaterialId = materialId;
Qty = qty;
UnitPrice = unitPrice;
LeadTimeDays = leadTimeDays;
Brand = brand;
}
internal void ReplaceWith(decimal qty, Money unitPrice, int leadTimeDays, string? brand)
{
if (qty <= 0) throw new DomainException("报价数量必须大于 0");
Qty = qty;
UnitPrice = unitPrice;
LeadTimeDays = leadTimeDays;
Brand = brand;
}
private QuoteLine() { }
public Money LineTotal => UnitPrice.Multiply(Qty);
}
/// <summary>改价留痕:供应商二次报价时,旧报价快照进修订记录,绝不物理覆盖。</summary>
public sealed record QuoteRevision(DateTimeOffset RevisedAt, IReadOnlyList<QuoteLine> LinesSnapshot,
Money Freight, Money? Discount, string Reason);
/// <summary>某供应商在一张询价单下的一份报价(Inquiry 聚合内实体)。</summary>
public class SupplierQuote : Entity<SupplierQuoteId>
{
public SupplierId SupplierId { get; private set; }
public string SupplierName { get; private set; } = default!;
public QuoteStatus Status { get; private set; }
public DateTimeOffset QuotedAt { get; private set; } // 正式报价时间(老字段 QuoteDate)
public DateTimeOffset? ValidUntil { get; private set; }
public string Currency { get; private set; } = default!;
private readonly List<QuoteLine> _lines = [];
public IReadOnlyList<QuoteLine> Lines => _lines.AsReadOnly();
// 报价单级附加费(对齐老字段 TransportPrice/PackingPrice/ClearancePrice/Discount)
public Money Freight { get; private set; } = default!;
public Money? Discount { get; private set; }
private readonly List<QuoteRevision> _revisions = [];
public IReadOnlyList<QuoteRevision> Revisions => _revisions.AsReadOnly();
internal SupplierQuote(SupplierId supplierId, string supplierName, string currency)
{
Id = SupplierQuoteId.New();
SupplierId = supplierId;
SupplierName = supplierName;
Currency = currency.ToUpperInvariant();
Status = QuoteStatus.Draft;
Freight = Money.Zero(Currency);
}
internal void Submit(IEnumerable<QuoteLine> lines, Money freight, Money? discount,
DateTimeOffset quotedAt, DateTimeOffset? validUntil, DateTimeOffset now, string? reason = null)
{
var materialized = lines as IList<QuoteLine> ?? lines.ToList();
if (materialized.Count == 0) throw new DomainException("报价至少要有一行");
if (Status == QuoteStatus.Submitted)
{
// 二次报价:旧值快照留痕,回答"这价是谁什么时候改成现在这样的"
_revisions.Add(new QuoteRevision(QuotedAt, _lines.ToList(), Freight, Discount,
reason ?? "供应商重新报价"));
}
if (freight.Currency != Currency || (discount is not null && discount.Value.Currency != Currency))
throw new DomainException("报价附加费币种必须与报价币种一致");
foreach (var line in materialized)
{
if (line.UnitPrice.Currency != Currency)
throw new DomainException($"行单价币种 {line.UnitPrice.Currency} 与报价单币种 {Currency} 不一致");
var existing = _lines.FirstOrDefault(l => l.InquiryLineNo == line.InquiryLineNo);
if (existing is null) _lines.Add(line);
else existing.ReplaceWith(line.Qty, line.UnitPrice, line.LeadTimeDays, line.Brand);
}
Freight = freight;
Discount = discount;
QuotedAt = quotedAt;
ValidUntil = validUntil;
Status = QuoteStatus.Submitted;
}
internal void Supersede() => Status = QuoteStatus.Superseded;
private SupplierQuote() { }
/// <summary>总到港成本(landed cost):行金额合计 + 运/包/清 − 折扣。比价的唯一口径。</summary>
public Money TotalCost
{
get
{
var total = _lines.Aggregate(Money.Zero(Currency), (acc, l) => acc + l.LineTotal) + Freight;
return Discount is null ? total : total - Discount.Value;
}
}
}
5.3 询价聚合根
csharp
// Domain/Purchasing/Inquiry.cs
namespace Pms.Domain.Purchasing;
using Pms.Domain.Events;
using Pms.Domain.Exceptions;
using Pms.Domain.SharedKernel;
public enum InquiryStatus { Draft, Quoting, Quoted, Closed, Cancelled }
public class Inquiry : AggregateRoot<InquiryId>
{
public string InquiryNo { get; private set; } = default!; // 如 (2026)HKMW-TEC-RFQ-0001
public MaterialApplyId ApplyId { get; private set; }
public string ShipCode { get; private set; } = default!;
public InquiryStatus Status { get; private set; }
public DateTimeOffset Deadline { get; private set; } // 报价截止时间
public SupplierQuoteId? SelectedQuoteId { get; private set; }
public PurchaseOrderId? PurchaseOrderId { get; private set; } // 转单后回写,幂等返回靠它
public DateTimeOffset CreatedAt { get; private set; }
private readonly List<InquiryItem> _items = [];
private readonly List<SupplierQuote> _quotes = [];
public IReadOnlyList<InquiryItem> Items => _items.AsReadOnly();
public IReadOnlyList<SupplierQuote> Quotes => _quotes.AsReadOnly();
private Inquiry() { }
public static Inquiry Create(string inquiryNo, MaterialApplyId applyId, string shipCode,
DateTimeOffset deadline, DateTimeOffset now, IEnumerable<InquiryItem> items)
{
var list = items as IList<InquiryItem> ?? items.ToList();
if (list.Count == 0) throw new DomainException("询价单至少要有一行物料");
if (deadline <= now) throw new DomainException("报价截止时间必须晚于当前时间");
var inquiry = new Inquiry
{
Id = InquiryId.New(),
InquiryNo = inquiryNo,
ApplyId = applyId,
ShipCode = shipCode,
Deadline = deadline,
Status = InquiryStatus.Draft,
CreatedAt = now
};
inquiry._items.AddRange(list);
inquiry.AddDomainEvent(new InquiryCreatedEvent(inquiry.Id, inquiry.ApplyId, inquiryNo));
return inquiry;
}
public void SendToQuoting(DateTimeOffset now)
{
if (Status != InquiryStatus.Draft) throw new DomainException("只有草稿状态的询价单可以发布");
if (_items.Count == 0) throw new DomainException("询价单没有明细行");
var invited = _quotes.Select(q => q.SupplierId).Distinct().Count();
if (invited < 2) throw new DomainException("至少邀请两家供应商后才能发布询价");
if (Deadline <= now) throw new DomainException("报价已截止,不能发布");
Status = InquiryStatus.Quoting;
}
public void InviteSupplier(SupplierId supplierId, string supplierName, string currency)
{
if (Status != InquiryStatus.Draft)
throw new DomainException("询价单发布后不能再邀请新供应商");
if (_quotes.Any(q => q.SupplierId == supplierId))
throw new DomainException($"供应商 {supplierName} 已在邀请名单中");
_quotes.Add(new SupplierQuote(supplierId, supplierName, currency));
}
public void SubmitQuote(SupplierId supplierId, IEnumerable<QuoteLine> lines,
Money freight, Money? discount, DateTimeOffset quotedAt, DateTimeOffset? validUntil,
DateTimeOffset now, string? reason = null)
{
if (Status != InquiryStatus.Quoting) throw new DomainException("询价单不在报价中状态");
if (now > Deadline) throw new DomainException("报价已截止,供应商不能再报价或改价");
var quote = _quotes.FirstOrDefault(q => q.SupplierId == supplierId)
?? throw new DomainException("该供应商不在询价邀请名单中");
// 报价行必须覆盖询价行口径,防止"只报了便宜的那几行"参与比价
var lineNos = lines.Select(l => l.InquiryLineNo).ToHashSet();
foreach (var item in _items)
if (!lineNos.Contains(item.LineNo))
throw new DomainException($"报价缺少第 {item.LineNo} 行({item.MaterialName})");
quote.Submit(lines, freight, discount, quotedAt, validUntil, now, reason);
AddDomainEvent(new InquiryQuoteReceivedEvent(Id, supplierId, quote.TotalCost));
}
/// <summary>人工截止(到期定时任务也走这个方法)。</summary>
public void CloseQuoting()
{
if (Status != InquiryStatus.Quoting) throw new DomainException("只有报价中的询价单可以截止");
Status = InquiryStatus.Quoted;
}
/// <summary>规则比价:总到港成本最低者胜。异币种直接拒绝,不替业务人员赌汇率。</summary>
public SupplierQuote PickBestQuote()
{
EnsureReadyToSelect();
var currencies = _quotes
.Where(q => q.Status == QuoteStatus.Submitted).Select(q => q.Currency).Distinct().ToList();
if (currencies.Count > 1)
throw new DomainException($"多家报价币种不一致({string.Join("/", currencies)}),请先按统一汇率折算后人工选定");
var best = _quotes.Where(q => q.Status == QuoteStatus.Submitted)
.OrderBy(q => q.TotalCost.Amount).First();
SelectedQuoteId = best.Id;
return best;
}
/// <summary>人工选定(允许不选最低价,但"不足两家"这条底线不变)。</summary>
public void SelectQuote(SupplierId supplierId)
{
EnsureReadyToSelect();
var quote = _quotes.FirstOrDefault(q => q.SupplierId == supplierId && q.Status == QuoteStatus.Submitted)
?? throw new DomainException("供应商报价不存在或尚未正式提交");
SelectedQuoteId = quote.Id;
}
private void EnsureReadyToSelect()
{
if (Status != InquiryStatus.Quoted) throw new DomainException("询价单尚未截止,不能比价选定");
var submitted = _quotes.Count(q => q.Status == QuoteStatus.Submitted);
if (submitted < 2) throw new DomainException("至少收到两家供应商的正式报价后才能比价选定");
}
/// <summary>由 PurchaseOrder 工厂/应用层在成功转单后回调,完成幂等闭环。</summary>
internal void MarkConverted(PurchaseOrderId purchaseOrderId)
{
if (Status == InquiryStatus.Closed && PurchaseOrderId is not null) return; // 已转过:幂等 no-op
if (SelectedQuoteId is null) throw new DomainException("尚未选定供应商,不能转采购单");
if (Status != InquiryStatus.Quoted) throw new DomainException("当前状态不能转采购单");
PurchaseOrderId = purchaseOrderId;
Status = InquiryStatus.Closed;
}
public void Cancel()
{
if (Status is InquiryStatus.Closed) throw new DomainException("已转采购单的询价单不能取消");
Status = InquiryStatus.Cancelled;
}
}
注意 SubmitQuote 的截止判断用的是方法调用时刻的 now ,而不是 QuoteDate 字段是否为空------事故三"草价下单"在新模型里有两道防线:状态不到 Quoted 不能选定,报价不到 Submitted(必须显式调 SubmitQuote 写入 QuotedAt)不进比价集合。
6. 充血模型(下):PurchaseOrder 聚合与跨聚合不变量
6.1 采购订单聚合
csharp
// Domain/Purchasing/PurchaseOrder.cs
namespace Pms.Domain.Purchasing;
using Pms.Domain.Events;
using Pms.Domain.Exceptions;
using Pms.Domain.SharedKernel;
public enum PurchaseOrderStatus
{
Draft, Submitted, Confirmed, PartialReceived, Received, Closed, Cancelled
}
public class PurchaseOrderItem : Entity<Guid>
{
public int LineNo { get; private set; }
public MaterialId MaterialId { get; private set; }
public string MaterialName { get; private set; } = default!;
public string Unit { get; private set; } = default!;
public decimal OrderQty { get; private set; } // 订单数量(老字段 OrderNum)
public decimal ReceivedQty { get; private set; } // 累计到货(老字段 ArrNum)
public decimal RejectedQty { get; private set; } // 累计拒收(老字段 RejectQuantity)
public Money UnitPrice { get; private set; } = default!;
public DateOnly? PromisedDate { get; private set; }
internal PurchaseOrderItem(int lineNo, MaterialId materialId, string name, string unit,
decimal orderQty, Money unitPrice, DateOnly? promisedDate)
{
if (orderQty <= 0) throw new DomainException("订单数量必须大于 0");
Id = Guid.CreateVersion7();
LineNo = lineNo; MaterialId = materialId; MaterialName = name; Unit = unit;
OrderQty = orderQty; UnitPrice = unitPrice; PromisedDate = promisedDate;
}
internal void Receive(decimal accepted, decimal rejected)
{
if (accepted < 0 || rejected < 0) throw new DomainException("收货/拒收数量不可为负");
if (ReceivedQty + accepted + RejectedQty + rejected > OrderQty)
throw new DomainException($"物料 {MaterialName} 累计收货数量不能超过订单数量 {OrderQty}");
ReceivedQty += accepted;
RejectedQty += rejected;
}
private PurchaseOrderItem() { }
public Money LineTotal => UnitPrice.Multiply(OrderQty);
}
public class PurchaseOrder : AggregateRoot<PurchaseOrderId>
{
public string OrderNo { get; private set; } = default!; // 如 (2026)HKMW-TEC-PO-0001
public InquiryId InquiryId { get; private set; }
public MaterialApplyId ApplyId { get; private set; } // 跨聚合只持 Id
public SupplierId SupplierId { get; private set; }
public string SupplierName { get; private set; } = default!;
public string ShipCode { get; private set; } = default!;
public string Currency { get; private set; } = default!;
public PurchaseOrderStatus Status { get; private set; }
public string? ConfirmNo { get; private set; } // 老字段 ComfirmNum
public DateTimeOffset? ConfirmedAt { get; private set; }
public DateTimeOffset OrderDate { get; private set; }
public Money Freight { get; private set; } = default!;
public Money? Discount { get; private set; }
private readonly List<PurchaseOrderItem> _items = [];
public IReadOnlyList<PurchaseOrderItem> Items => _items.AsReadOnly();
private PurchaseOrder() { }
/// <summary>
/// 从已选定报价的询价单生成采购订单。
/// 注意:本工厂只校验"聚合内能看见的"规则;
/// "不超过申请批准数量"是跨聚合不变量,由 IPurchaseQuotaPolicy(第 7 节)在调用前保证。
/// </summary>
public static PurchaseOrder CreateFromInquiry(
Inquiry inquiry,
SupplierQuote winningQuote,
string orderNo,
DateTimeOffset orderDate,
IReadOnlyDictionary<MaterialId, decimal>? qtyOverrides = null)
{
if (inquiry.SelectedQuoteId != winningQuote.Id)
throw new DomainException("只能按询价单已选定的供应商报价生成采购订单");
if (winningQuote.Status != QuoteStatus.Submitted)
throw new DomainException("报价尚未正式提交,不能生成采购订单");
var po = new PurchaseOrder
{
Id = PurchaseOrderId.New(),
OrderNo = orderNo,
InquiryId = inquiry.Id,
ApplyId = inquiry.ApplyId,
SupplierId = winningQuote.SupplierId,
SupplierName = winningQuote.SupplierName,
ShipCode = inquiry.ShipCode,
Currency = winningQuote.Currency,
Status = PurchaseOrderStatus.Draft,
OrderDate = orderDate,
Freight = winningQuote.Freight,
Discount = winningQuote.Discount
};
var lineNo = 1;
foreach (var quoteLine in winningQuote.Lines)
{
var inquiryItem = inquiry.Items.First(i => i.LineNo == quoteLine.InquiryLineNo);
var qty = qtyOverrides is not null && qtyOverrides.TryGetValue(quoteLine.MaterialId, out var ov)
? ov : quoteLine.Qty;
// 聚合内可见的上限:本次下单 ≤ 本次询价数量 ≤ 批准数量(后者是快照,最终约束仍在政策对象)
if (qty > inquiryItem.InquireQty)
throw new DomainException(
$"物料 {inquiryItem.MaterialName} 下单数量 {qty} 超过本次询价数量 {inquiryItem.InquireQty}");
var promised = orderDate.Date.AddDays(quoteLine.LeadTimeDays);
po._items.Add(new PurchaseOrderItem(lineNo++, quoteLine.MaterialId,
inquiryItem.MaterialName, inquiryItem.Unit, qty, quoteLine.UnitPrice,
DateOnly.FromDateTime(promised)));
}
po.AddDomainEvent(new PurchaseOrderCreatedEvent(
po.Id, po.OrderNo, inquiry.Id, inquiry.ApplyId, winningQuote.SupplierId, po.TotalAmount));
return po;
}
public void Submit()
{
if (Status != PurchaseOrderStatus.Draft) throw new DomainException("只有草稿订单可以提交");
Status = PurchaseOrderStatus.Submitted;
}
public void Confirm(string confirmNo, DateTimeOffset confirmedAt)
{
if (Status != PurchaseOrderStatus.Submitted) throw new DomainException("只有已提交的订单可以确认");
if (string.IsNullOrWhiteSpace(confirmNo)) throw new DomainException("供应商确认单号不能为空");
ConfirmNo = confirmNo;
ConfirmedAt = confirmedAt;
Status = PurchaseOrderStatus.Confirmed;
AddDomainEvent(new PurchaseOrderConfirmedEvent(Id, OrderNo, confirmNo));
}
/// <summary>分批收货:Confirmed → PartialReceived → Received。</summary>
public void RecordReceipt(IEnumerable<(MaterialId Material, decimal Accepted, decimal Rejected)> lines,
DateTimeOffset receivedAt)
{
if (Status is not (PurchaseOrderStatus.Confirmed or PurchaseOrderStatus.PartialReceived))
throw new DomainException("只有已确认或部分到货的订单可以登记收货");
foreach (var (material, accepted, rejected) in lines)
{
var item = _items.FirstOrDefault(i => i.MaterialId == material)
?? throw new DomainException($"订单中不存在该物料行:{material}");
item.Receive(accepted, rejected);
}
Status = _items.All(i => i.ReceivedQty + i.RejectedQty >= i.OrderQty)
? PurchaseOrderStatus.Received
: PurchaseOrderStatus.PartialReceived;
AddDomainEvent(new PurchaseOrderReceivedEvent(Id, OrderNo, receivedAt,
_items.Sum(i => i.ReceivedQty)));
}
public void Close()
{
if (Status is not (PurchaseOrderStatus.Received or PurchaseOrderStatus.Confirmed))
throw new DomainException("只有已确认或已收齐的订单可以关闭");
Status = PurchaseOrderStatus.Closed;
}
public void Cancel()
{
if (Status is PurchaseOrderStatus.Received or PurchaseOrderStatus.Closed)
throw new DomainException("已收货或已关闭的订单不能取消");
Status = PurchaseOrderStatus.Cancelled;
}
public Money TotalAmount
{
get
{
var subtotal = _items.Aggregate(Money.Zero(Currency), (acc, i) => acc + i.LineTotal) + Freight;
return Discount is null ? subtotal : subtotal - Discount.Value;
}
}
}
6.2 讲透那个跨聚合不变量
"下单数量不得超过申请批准数量"写出来是这样一条谓词:
text
对于申请单 A 的每一物料行 m:
Σ 所有引用 A 的、未取消采购订单上 m 的 OrderQty + 本次下单 qty(m) ≤ ApprovedQty(A, m)
为什么 PurchaseOrder.CreateFromInquiry 里只校验了 qty ≤ InquireQty 就停了?因为完整谓词需要的数据不在 PurchaseOrder 聚合内存里 :批准数量在 MaterialApply 聚合,已订数量散落在此前生成的多张 PurchaseOrder 聚合中。聚合的纪律是"一个事务只修改一个聚合实例",所以这条规则必须在聚合之外、应用服务的编排中被满足。这不是 DDD 的缺陷,而是跨聚合不变量的本来面目------你只有三个选择:强一致领域服务、最终一致事件、Saga 补偿。下一节给全。
另外注意:InquiryItem.ApprovedQty 快照能挡住"这一次询价就超量",但挡不住"分两次询价/两张 PO 合计超量"。快照是第一道便宜的防线,不是完整答案。
7. 跨聚合一致性:领域服务、最终一致与 Saga 的决策表
7.1 三种策略对比
| 维度 | ① 领域服务 + 单事务强一致 | ② 领域事件最终一致 | ③ Saga / 流程管理器 |
|---|---|---|---|
| 做法 | 应用层一次性加载 MaterialApply 与 PurchaseOrder,政策校验通过后在同一事务/同一 SaveChanges 保存 | PO 提交事件,订阅方异步回写申请已订数量/超量挂起 | 编排多个本地事务 + 补偿动作(撤单、释放额度) |
| 一致性 | 立即一致,提交后约束必然成立 | 短暂不一致窗口(秒级),需对账/幂等消费 | 最终一致,流程状态持久化 |
| 适用拓扑 | 同进程、同库(船端 SQLite 单进程) | 跨进程/跨库、岸基多服务 | 跨服务长流程、多步回滚(如 PO→收货→付款) |
| 代价 | 同时加载多个聚合;高争用上行锁竞争 | 接受延迟;要处理乱序、重复、补偿 | 复杂度最高,要定义每步的补偿 |
| 失败形态 | 校验失败即整体回滚,无脏数据 | 事件可能延迟/重复,消费必须幂等 | 中间状态需可观测、可人工干预 |
决策规则(结论):同进程同库用领域服务 + 事务;跨进程跨库用事件 + Outbox;跨服务多步长事务才用 Saga。 船端是单机单进程 SQLite,引入消息队列除了增加断网故障面没有任何收益------不要为了"纯"在单机场景硬上 MQ。Saga 与 CAP 的完整实现见本系列 Saga 篇与 CAP+Outbox 篇,本篇不展开。
7.2 策略①实现:领域服务(接口在 Domain,实现在 Infrastructure)
csharp
// Domain/Purchasing/IPurchaseQuotaPolicy.cs ------ 领域服务接口属于 Domain 层
namespace Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;
public sealed record QuotaLine(MaterialId MaterialId, decimal WantedQty);
public interface IPurchaseQuotaPolicy
{
/// <summary>校验"申请已订 + 本次下单 ≤ 批准数量",不满足抛 DomainException。</summary>
Task EnsureWithinApprovedQuotaAsync(
MaterialApplyId applyId,
IReadOnlyCollection<QuotaLine> wanted,
CancellationToken ct = default);
}
csharp
// Infrastructure/Purchasing/PurchaseQuotaPolicy.cs
namespace Pms.Infrastructure.Purchasing;
using Microsoft.EntityFrameworkCore;
using Pms.Domain.Exceptions;
using Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;
public sealed class PurchaseQuotaPolicy(PmsDbContext db) : IPurchaseQuotaPolicy
{
public async Task EnsureWithinApprovedQuotaAsync(
MaterialApplyId applyId, IReadOnlyCollection<QuotaLine> wanted, CancellationToken ct = default)
{
// 批准数量来自 M1 聚合的持久化(mrp_applydesc/mrp_apply 模型见 M1 篇)
var approved = await db.MaterialApplyItems
.Where(i => i.ApplyId == applyId)
.ToDictionaryAsync(i => i.MaterialId, i => i.ApprovedQty, ct);
// 已订数量:所有未取消 PO 的行汇总。用读模型查询而不是把所有 PO 聚合物料化
var ordered = await db.PurchaseOrderItems
.Where(pi => pi.Order.ApplyId == applyId
&& pi.Order.Status != PurchaseOrderStatus.Cancelled)
.GroupBy(pi => pi.MaterialId)
.Select(g => new { g.Key, Ordered = g.Sum(x => x.OrderQty) })
.ToDictionaryAsync(x => x.Key, x => x.Ordered, ct);
foreach (var line in wanted)
{
if (!approved.TryGetValue(line.MaterialId, out var quota))
throw new DomainException($"申请单中不存在该物料行,不能下单:{line.MaterialId}");
ordered.TryGetValue(line.MaterialId, out var already);
if (already + line.WantedQty > quota)
throw new DomainException(
$"物料下单数量 {already + line.WantedQty} 超过申请批准数量 {quota}(已订 {already},本次 {line.WantedQty})");
}
}
}
这里刻意用只读查询 而不是加载 MaterialApply 聚合:政策校验只需要两个数字,物化整张申请单(可能几十行、带三级审批冗余字段)是浪费。DDD 不禁止读模型,禁止的是绕过聚合修改状态。而真正的写操作(回写申请单"已转采购/已订数量")仍然只发生在 M1 聚合的方法里------下一节的事件处理器负责调用它。
7.3 策略②在岸基长什么样(点到为止)
岸基把申请、采购拆成独立服务/独立库后,采购服务无法在同一事务里读申请库。流程变为:PO 服务本地事务提交 PO + outbox 事件 → 消息中间件投递 PurchaseOrderCreated → 申请服务消费、累加已订数量,若发现超量(并发下两个 PO 同时通过了各自的预检)则发布 PurchaseOrderRejected/挂起 事件,PO 服务把订单置为"待额度确认"。这是典型的预留额度/事后修正 模式,可靠投递由第 9 节的 Outbox 保证,消费幂等由 EventId 保证。
8. 领域事件机制:两种分发时机与失败窗口
8.1 领域事件 vs 集成事件
微软官方在 eShop 参考架构文档里对二者的区分非常明确(Domain events: Design and implementation):
| 领域事件 Domain Event | 集成事件 Integration Event | |
|---|---|---|
| 作用范围 | 进程内,同一个 bounded context | 跨服务、跨进程、可能跨机器 |
| 负载形态 | 可直接携带领域对象/强类型 Id,随模型一起演进化 | 只能传序列化 DTO,必须考虑版本兼容(字段只加不删、消费者容忍未知字段) |
| 传输 | MediatR INotification 等进程内分发 |
消息队列/事件总线(RabbitMQ/Kafka...) |
| 事务语义 | 可与触发它的状态修改在同一事务 | 必须靠 Outbox 保证"业务提交"与"事件发出"的原子性 |
M2 的 PurchaseOrderCreatedEvent 先作为领域事件存在(进程内回写申请单已订数量、写审计),需要通知岸基其他上下文时,再由处理器翻译 为一份版本化的集成事件 PurchaseOrderCreatedIntegrationEvent 推上总线------领域事件永远不要直接序列化出站。
事件本身长这样(EventId 在创建时固定,第 13 节坑 6 解释为什么不能发布时才生成):
csharp
// Domain/SharedKernel/IIntegrationEventSource.cs
namespace Pms.Domain.SharedKernel;
/// <summary>标注该领域事件需要翻译为出站集成事件;返回扁平、可版本兼容的 DTO。</summary>
public interface IIntegrationEventSource
{
object ToIntegrationEvent();
}
// Domain/Events/PurchaseOrderEvents.cs
namespace Pms.Domain.Events;
using Pms.Domain.SharedKernel;
public sealed record PurchaseOrderCreatedEvent(
PurchaseOrderId PurchaseOrderId,
string OrderNo,
InquiryId InquiryId,
MaterialApplyId ApplyId,
SupplierId SupplierId,
Money TotalAmount) : IDomainEvent, IIntegrationEventSource
{
public Guid EventId { get; } = Guid.CreateVersion7();
public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
// 领域事件 → 集成事件:负载扁平化、只暴露基元与强类型 Id,字段只加不删
public object ToIntegrationEvent() => new PurchaseOrderCreatedIntegrationEvent(
EventId, OccurredOn, OrderNo, ApplyId, SupplierId,
TotalAmount.Amount, TotalAmount.Currency);
}
/// <summary>出站集成事件 DTO:消费者必须容忍未来新增字段。</summary>
public sealed record PurchaseOrderCreatedIntegrationEvent(
Guid EventId,
DateTimeOffset OccurredOn,
string OrderNo,
MaterialApplyId ApplyId,
SupplierId SupplierId,
decimal TotalAmount,
string Currency);
// 其余领域事件(进程内使用,不出站)保持同一模式:
public sealed record InquiryCreatedEvent(InquiryId InquiryId, MaterialApplyId ApplyId, string InquiryNo) : IDomainEvent
{
public Guid EventId { get; } = Guid.CreateVersion7();
public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
}
public sealed record InquiryQuoteReceivedEvent(
InquiryId InquiryId, SupplierId SupplierId, Money TotalCost) : IDomainEvent
{
public Guid EventId { get; } = Guid.CreateVersion7();
public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
}
public sealed record PurchaseOrderConfirmedEvent(
PurchaseOrderId PurchaseOrderId, string OrderNo, string ConfirmNo) : IDomainEvent
{
public Guid EventId { get; } = Guid.CreateVersion7();
public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
}
public sealed record PurchaseOrderReceivedEvent(
PurchaseOrderId PurchaseOrderId, string OrderNo,
DateTimeOffset ReceivedAt, decimal TotalAcceptedQty) : IDomainEvent
{
public Guid EventId { get; } = Guid.CreateVersion7();
public DateTimeOffset OccurredOn { get; } = DateTimeOffset.UtcNow;
}
8.2 三个时机,三个失败窗口
事件挂在聚合上之后,"什么时候分发"决定了系统的故障语义:
text
方式 A:事务提交后分发(最常见,也最容易踩坑)
SaveChanges ──────► COMMIT 成功 ──────► mediator.Publish(事件)
│ │
│ 进程崩溃 / 发布抛异常
│ ▼
└────────────────► 库里改了,订阅方没收到:永久不一致
("库已改、事件丢了"窗口)
方式 B:事务提交前分发(eShop 的做法)
BEGIN TX ──► SaveChanges(未提交) ──► mediator.Publish(处理器同 DbContext 再改再 Save)
│ │
│ 处理器抛异常 ──► 整事务回滚(原子,无脏数据)
▼
COMMIT ──► 提交后进程崩溃?事件已随业务状态落库才是真原子(见方式 C)
方式 C:Outbox(原子落库,后台发布)
BEGIN TX ──► SaveChanges(业务表 + outbox 表同一事务) ──► COMMIT
│
后台轮询/CDC ──► 发布总线 ──► 成功后标记 Processed
崩溃?重启后从 outbox 继续,事件不丢(至少一次,消费端幂等)
选型结论:
- 船端 :进程内副作用(回写已订数量、审计、通知标记)用方式 B------与业务同事务,立即强一致,和第 7 节的领域服务策略叠加后,船端根本不依赖事件做关键约束;
- 岸基 :跨服务通知一律方式 C(Outbox),关键额度约束仍以前置同步校验(gRPC 调申请服务)+ 事件事后修正双保险;
- 方式 A(提交后直接分发)只允许用于丢了也无所谓 的旁路(比如打一条 debug 日志),不允许承载任何一致性职责。
EF Core 默认单次 SaveChanges 自身就在一个事务里(Using Transactions),方式 B 不需要显式开事务;处理器只要复用同一个 scoped DbContext,其 SaveChanges 会自然并入外层事务。
8.3 MediatR 进程内分发与"提交前分发"包装
MediatR 的用法本身不复杂:事件实现 INotification,处理器实现 INotificationHandler<T>,await mediator.Publish(evt) 按注册顺序多播(官方推荐写法见上面的 eShop 域事件文档,处理器里通过仓储加载别的聚合并执行副作用)。本篇的重点是包装在 SaveChanges 的什么位置:
csharp
// Infrastructure/UnitOfWork.cs ------ 方式 B:提交前分发,处理器副作用与业务修改同一事务
namespace Pms.Infrastructure;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Pms.Domain.SharedKernel;
public interface IUnitOfWork
{
Task<int> SaveChangesAndDispatchAsync(CancellationToken ct = default);
}
public sealed class UnitOfWork(PmsDbContext db, IPublisher publisher) : IUnitOfWork
{
public async Task<int> SaveChangesAndDispatchAsync(CancellationToken ct = default)
{
// 1) 先把各聚合挂账的事件取出来(SaveChanges 会让对象状态变化,先快照)
var aggregates = db.ChangeTracker
.Entries<AggregateRoot<Guid>>()
.Where(e => e.Entity.DomainEvents.Count != 0)
.Select(e => e.Entity)
.ToList();
var events = aggregates.SelectMany(a => a.DomainEvents).ToList();
aggregates.ForEach(a => a.ClearDomainEvents()); // 防止重试时重复发布
// 2) 先持久化业务修改(仍在外层事务中,未提交)
var result = await db.SaveChangesAsync(ct);
// 3) 提交前分发:处理器若再改别的聚合并 SaveChanges,并入同一事务
// 处理器抛异常 → 整个 SaveChanges 的事务回滚,业务修改也不会留下
foreach (var domainEvent in events)
await publisher.Publish(domainEvent, ct);
// 4) 集成事件在此刻写 outbox(第 9 节),随后由 EF 事务统一 COMMIT
await db.EnqueueIntegrationEventsAsync(events, ct);
return result;
}
}
处理器示例------回写 M1 申请单已订数量:
csharp
// Application/Purchasing/EventHandlers/UpdateOrderedQtyHandler.cs
namespace Pms.Application.Purchasing.EventHandlers;
using MediatR;
using Pms.Domain.Events;
using Pms.Domain.MaterialRequests;
public sealed class UpdateOrderedQtyOnPoCreatedHandler(
IMaterialApplyRepository applies,
IUnitOfWork uow)
: INotificationHandler<PurchaseOrderCreatedEvent>
{
public async Task Handle(PurchaseOrderCreatedEvent e, CancellationToken ct)
{
// 注意:这里加载的是 M1 聚合,调用它的方法修改,绝不直接 UPDATE 表
var apply = await applies.GetByIdAsync(e.ApplyId, ct)
?? throw new InvalidOperationException($"申请单 {e.ApplyId} 不存在");
apply.RecordOrdered(e.Lines);
// 不单独 SaveChanges:由外层同一请求的 UoW 在事件全部处理完后统一提交
}
}
三个必须记住的坑(第 13 节还有完整版):处理器里新开一个 DbContext/Scope 会让它的 SaveChanges 自成事务、先于外层提交,破坏原子性;处理器再次发布事件 要小心无限递归;处理器之间没有顺序保证(可配置 ForeachAwait 顺序发布),两个处理器改同一聚合行可能死锁。
9. Outbox 精简实现:同事务落库 + BackgroundService 轮询
岸基跨服务通知(以及船岸同步链路里需要补发的事件)需要方式 C。CAP 是生产环境的成熟选择(本地消息表 + 重试 + 仪表盘,CAP 官方文档);理解原理后,精简版自有实现只要三张东西。
9.1 Outbox 表与同事务写入
csharp
// Infrastructure/Outbox/OutboxMessage.cs
namespace Pms.Infrastructure.Outbox;
public sealed class OutboxMessage
{
public Guid Id { get; set; }
public required string EventType { get; set; } // 集成事件类型全名,用于反序列化
public required string JsonPayload { get; set; }
public DateTimeOffset OccurredOn { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
public int RetryCount { get; set; }
public string? LastError { get; set; }
}
csharp
// Infrastructure/PmsDbContext.cs 中的关键片段
public async Task EnqueueIntegrationEventsAsync(IReadOnlyCollection<IDomainEvent> events, CancellationToken ct)
{
foreach (var domainEvent in events)
{
if (domainEvent is not IIntegrationEventSource source) continue; // 只有标注要出站的才翻译
var integration = source.ToIntegrationEvent();
OutboxMessages.Add(new OutboxMessage
{
Id = integration.EventId, // 复用领域事件 Id:下游天然幂等去重
EventType = integration.GetType().FullName!,
JsonPayload = JsonSerializer.Serialize(integration, integration.GetType()),
OccurredOn = integration.OccurredOn
});
}
await Task.CompletedTask;
}
关键不变量:OutboxMessage 与业务行在同一个 SaveChanges 事务里写入 ------这是整个模式唯一的正确性来源。事件和业务数据要么同时可见,要么都不可见,不存在"库已改事件丢了"的窗口。唯一索引建在 Id 上,重复发布在下游也能用同一个 EventId 去重。
9.2 后台轮询发布
csharp
// Infrastructure/Outbox/OutboxPublisherService.cs
namespace Pms.Infrastructure.Outbox;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
/// <summary>轮询未处理的 outbox 消息,投递到集成事件总线;成功后回写 ProcessedAt。</summary>
public sealed class OutboxPublisherService(
IServiceScopeFactory scopeFactory,
IIntegrationEventBus bus,
TimeProvider time,
ILogger<OutboxPublisherService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// .NET 10 行为变更:ExecuteAsync 全程在后台线程运行,
// 首个 await 之前的同步段不再阻塞其他托管服务启动。
while (!stoppingToken.IsCancellationRequested)
{
try
{
await PumpOnceAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Outbox 发布轮询失败,将在下一周期重试");
}
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
private async Task PumpOnceAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PmsDbContext>();
var batch = await db.OutboxMessages
.Where(m => m.ProcessedAt == null && m.RetryCount < 10)
.OrderBy(m => m.OccurredOn)
.Take(20)
.ToListAsync(ct);
foreach (var message in batch)
{
try
{
await bus.PublishAsync(message.EventType, message.JsonPayload, ct);
message.ProcessedAt = time.GetUtcNow();
message.LastError = null;
}
catch (Exception ex)
{
message.RetryCount++;
message.LastError = ex.Message; // 留痕,超过 10 次进死信,靠对账任务人工介入
}
}
await db.SaveChangesAsync(ct);
}
}
BackgroundService 是 .NET 通用主机的长驻基类(Worker services in .NET)。注意一个 .NET 10 的行为变更:ExecuteAsync 现在整体在后台线程运行 ,首个 await 前的同步代码不再阻塞其他托管服务启动(.NET 10 兼容性说明),老代码若依赖"构造期同步阻塞启动顺序"需要改用 StartAsync 重写或 IHostedLifecycleService。
生产选型上:船端断网期 bus.PublishAsync 持续失败,消息留在 outbox 表(这正是本系列船岸同步篇的 spool 思路),复联后继续;岸基直接用 CAP,重试退避、仪表盘、死信都不用自己写。
10. 应用服务编排:幂等转单与 Minimal API
应用服务只编排:加载聚合 → 调领域方法/政策 → 工作单元保存。核心是"从已批准申请创建询价"到"生成采购订单"这条链。
csharp
// Application/Purchasing/InquiryAppService.cs(关键方法)
namespace Pms.Application.Purchasing;
using MediatR;
using Pms.Domain.Exceptions;
using Pms.Domain.MaterialRequests;
using Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;
public sealed record CreateInquiryCommand(
Guid RequestId, // 客户端生成的幂等键
MaterialApplyId ApplyId,
string ShipCode,
DateTimeOffset Deadline,
IReadOnlyList<CreateInquiryLine> Lines);
public sealed record CreateInquiryLine(
MaterialId MaterialId, string? ImpaCode, string Name, string Unit,
decimal ApprovedQty, decimal InquireQty);
public sealed class InquiryAppService(
IMaterialApplyRepository applies,
IInquiryRepository inquiries,
IInquiryNumberGenerator numberGenerator,
IUnitOfWork uow,
PmsDbContext db)
{
public async Task<InquiryId> CreateAsync(CreateInquiryCommand cmd, CancellationToken ct = default)
{
// 幂等第一层:同 RequestId 直接返回既有结果(重复点击/超时重试)
var existing = await db.IdempotentRequests
.Where(r => r.RequestId == cmd.RequestId && r.Kind == "CreateInquiry")
.Select(r => r.ResultId).FirstOrDefaultAsync(ct);
if (existing is not null) return new InquiryId(existing.Value);
// 前置不变量:只有 Approved 的申请才能发起询价(M1 聚合的状态机兜底)
var apply = await applies.GetByIdAsync(cmd.ApplyId, ct)
?? throw new DomainException("物料申请单不存在");
if (apply.Status != MaterialApplyStatus.Approved)
throw new DomainException("只有审批通过的物料申请才能发起询价");
var items = cmd.Lines
.Select((l, idx) => new InquiryItem(idx + 1, l.MaterialId, l.ImpaCode,
l.Name, l.Unit, l.ApprovedQty, l.InquireQty))
.ToList();
var inquiryNo = await numberGenerator.NextInquiryNoAsync(ct);
var inquiry = Inquiry.Create(inquiryNo, cmd.ApplyId, cmd.ShipCode,
cmd.Deadline, DateTimeOffset.UtcNow, items);
await inquiries.AddAsync(inquiry, ct);
await uow.SaveChangesAndDispatchAsync(ct);
// 幂等凭证与业务在同一事务写入,唯一索引是最终防线(第 11 节建索引)
db.IdempotentRequests.Add(new IdempotentRequest(
cmd.RequestId, "CreateInquiry", inquiry.Id.Value));
await uow.SaveChangesAndDispatchAsync(ct);
return inquiry.Id;
}
}
csharp
// Application/Purchasing/PurchaseAppService.cs(转单编排:领域服务 + 两个聚合 + 单事务)
namespace Pms.Application.Purchasing;
using Pms.Domain.Exceptions;
using Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;
public sealed record GeneratePurchaseOrderCommand(
Guid RequestId, InquiryId InquiryId, SupplierId SupplierId,
IReadOnlyDictionary<MaterialId, decimal>? QtyOverrides);
public sealed class PurchaseAppService(
IInquiryRepository inquiries,
IPurchaseOrderRepository orders,
IPurchaseQuotaPolicy quotaPolicy,
IPurchaseOrderNumberGenerator numberGenerator,
IUnitOfWork uow)
{
public async Task<PurchaseOrderId> GenerateAsync(GeneratePurchaseOrderCommand cmd, CancellationToken ct = default)
{
var inquiry = await inquiries.GetWithQuotesAsync(cmd.InquiryId, ct)
?? throw new DomainException("询价单不存在");
// 幂等闭环:已转过单,无论点多少次都返回同一个采购单号
if (inquiry.Status == InquiryStatus.Closed && inquiry.PurchaseOrderId is not null)
return inquiry.PurchaseOrderId.Value;
var quote = inquiry.Quotes.First(q => q.SupplierId == cmd.SupplierId);
var orderNo = await numberGenerator.NextOrderNoAsync(ct);
var wanted = quote.Lines.Select(l => new QuotaLine(
l.MaterialId,
cmd.QtyOverrides is not null && cmd.QtyOverrides.TryGetValue(l.MaterialId, out var ov)
? ov : l.Qty)).ToList();
// ① 跨聚合不变量:不通过直接抛异常,后面什么都不会发生
await quotaPolicy.EnsureWithinApprovedQuotaAsync(inquiry.ApplyId, wanted, ct);
// ② 工厂内只做聚合内校验(状态、报价有效性、单量≤询价量)
var order = PurchaseOrder.CreateFromInquiry(
inquiry, quote, orderNo, DateTimeOffset.UtcNow, cmd.QtyOverrides);
// ③ 询价单闭环(内部对"已转"幂等)
MarkConverted(inquiry, order.Id);
await orders.AddAsync(order, ct);
await uow.SaveChangesAndDispatchAsync(ct); // ④ 事件在同一事务内回写申请已订数量
return order.Id;
}
private static void MarkConverted(Inquiry inquiry, PurchaseOrderId poId) =>
typeof(Inquiry).GetMethod("MarkConverted",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.Invoke(inquiry, [poId]);
}
生产代码里
MarkConverted应声明为internal并通过InternalsVisibleTo暴露给 Application 层,这里用反射只是为了示例自洽,别照抄这 5 行。
Minimal API 骨架(Minimal APIs 概述):
csharp
// Api/Endpoints/PurchasingEndpoints.cs
namespace Pms.Api.Endpoints;
public static class PurchasingEndpoints
{
public static void MapPurchasing(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/purchasing").WithTags("采购");
group.MapPost("/inquiries", async (CreateInquiryCommand cmd, InquiryAppService svc, CancellationToken ct)
=> Results.Created($"/purchasing/inquiries/{await svc.CreateAsync(cmd, ct)}", null));
group.MapPost("/inquiries/{id:guid}/quotes/{supplierId:guid}",
async (Guid id, Guid supplierId, SubmitQuoteRequest req,
InquiryAppService svc, CancellationToken ct) =>
{
await svc.SubmitQuoteAsync(new InquiryId(id), new SupplierId(supplierId), req, ct);
return Results.NoContent();
});
group.MapPost("/inquiries/{id:guid}/close",
async (Guid id, InquiryAppService svc, CancellationToken ct) =>
{
await svc.CloseAsync(new InquiryId(id), ct);
return Results.NoContent();
});
group.MapPost("/inquiries/{id:guid}/pick-best",
async (Guid id, PurchaseAppService svc, CancellationToken ct) =>
Results.Ok(await svc.PickBestAsync(new InquiryId(id), ct)));
group.MapPost("/purchase-orders", async (GeneratePurchaseOrderCommand cmd,
PurchaseAppService svc, CancellationToken ct) =>
Results.Created($"/purchasing/purchase-orders/{await svc.GenerateAsync(cmd, ct)}", null));
}
}
幂等有两层:应用层先查 IdempotentRequest 返回旧结果(友好路径),数据库 UNIQUE(request_id, kind) 唯一索引兜底(并发路径,两个请求同时穿过第一层时,第二个提交必败)。唯一索引的用法见 EF Core Indexes,幂等模式的完整讨论见本系列幂等篇。
11. EF Core 持久化:表结构、映射配置与双 Provider 差异
11.1 表结构(mrp_ 命名风格对齐老系统)
sql
-- 船端 SQLite(岸基 SQL Server 仅类型不同:TEXT→NVARCHAR、BLOB→UNIQUEIDENTIFIER)
CREATE TABLE mrp_inquiry (
id BLOB PRIMARY KEY, -- InquiryId 强类型 Id(值转换器)
inquiry_no TEXT NOT NULL,
apply_id BLOB NOT NULL,
ship_code TEXT NOT NULL,
status TEXT NOT NULL, -- 枚举转字符串
deadline TEXT NOT NULL, -- DateTimeOffset 统一存 ISO-8601 文本
selected_quote_id BLOB NULL,
purchase_order_id BLOB NULL,
created_at TEXT NOT NULL,
row_version INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX ux_inquiry_no ON mrp_inquiry(inquiry_no);
CREATE INDEX ix_inquiry_apply ON mrp_inquiry(apply_id);
CREATE TABLE mrp_inquiryitem (
id BLOB PRIMARY KEY, inquiry_id BLOB NOT NULL, line_no INTEGER NOT NULL,
material_id BLOB NOT NULL, impa_code TEXT NOT NULL, material_name TEXT NOT NULL,
unit TEXT NOT NULL, approved_qty REAL NOT NULL, inquire_qty REAL NOT NULL
);
CREATE TABLE mrp_inquiryquote (
id BLOB PRIMARY KEY, inquiry_id BLOB NOT NULL, supplier_id BLOB NOT NULL,
supplier_name TEXT NOT NULL, status TEXT NOT NULL, quoted_at TEXT NULL,
valid_until TEXT NULL, currency TEXT NOT NULL,
freight_amount REAL NOT NULL, freight_currency TEXT NOT NULL, -- Money ComplexProperty 内联列
discount_amount REAL NULL, discount_currency TEXT NULL
);
CREATE UNIQUE INDEX ux_quote_inquiry_supplier ON mrp_inquiryquote(inquiry_id, supplier_id)
WHERE status <> 'Superseded'; -- 同一询价单同一供应商只允许一份有效报价(SQLite 过滤索引)
CREATE TABLE mrp_po (
id BLOB PRIMARY KEY, order_no TEXT NOT NULL, inquiry_id BLOB NOT NULL,
apply_id BLOB NOT NULL, supplier_id BLOB NOT NULL, supplier_name TEXT NOT NULL,
ship_code TEXT NOT NULL, currency TEXT NOT NULL, status TEXT NOT NULL,
confirm_no TEXT NULL, confirmed_at TEXT NULL, order_date TEXT NOT NULL,
freight_amount REAL NOT NULL, freight_currency TEXT NOT NULL,
discount_amount REAL NULL, discount_currency TEXT NULL,
total_amount REAL NOT NULL, row_version INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX ux_po_no ON mrp_po(order_no);
CREATE UNIQUE INDEX ux_po_inquiry_supplier ON mrp_po(inquiry_id, supplier_id);
-- ↑ 事故一的数据库级终局防线:一张询价单对同一供应商物理上不可能有两张 PO
CREATE TABLE mrp_poitem (
id BLOB PRIMARY KEY, po_id BLOB NOT NULL, line_no INTEGER NOT NULL,
material_id BLOB NOT NULL, material_name TEXT NOT NULL, unit TEXT NOT NULL,
order_qty REAL NOT NULL, received_qty REAL NOT NULL, rejected_qty REAL NOT NULL,
price_amount REAL NOT NULL, price_currency TEXT NOT NULL, promised_date TEXT NULL
);
CREATE TABLE outbox_message (
id BLOB PRIMARY KEY, event_type TEXT NOT NULL, json_payload TEXT NOT NULL,
occurred_on TEXT NOT NULL, processed_at TEXT NULL,
retry_count INTEGER NOT NULL DEFAULT 0, last_error TEXT NULL
);
CREATE TABLE idempotent_request (
request_id BLOB NOT NULL, kind TEXT NOT NULL, result_id BLOB NOT NULL,
PRIMARY KEY (request_id, kind)
);
11.2 映射配置
csharp
// Infrastructure/Configurations/InquiryConfiguration.cs
namespace Pms.Infrastructure.Configurations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;
public sealed class InquiryConfiguration : IEntityTypeConfiguration<Inquiry>
{
public void Configure(EntityTypeBuilder<Inquiry> b)
{
b.ToTable("mrp_inquiry");
b.HasKey(x => x.Id);
b.Property(x => x.InquiryNo).HasMaxLength(40).IsRequired();
b.Property(x => x.Status).HasConversion<string>().HasMaxLength(20);
b.Property(x => x.RowVersion).IsRowVersion(); // SQLite 下 EF 自动退化为普通并发令牌列
// 内部实体:OwnsMany 映射到独立表,身份由"所属聚合 + 主键"定义,不暴露 DbSet
b.OwnsMany(x => x.Items, ib =>
{
ib.ToTable("mrp_inquiryitem");
ib.WithOwner().HasForeignKey("inquiry_id");
ib.Property(x => x.LineNo);
ib.Property(x => x.ApprovedQty);
ib.Property(x => x.InquireQty);
});
b.OwnsMany(x => x.Quotes, qb =>
{
qb.ToTable("mrp_inquiryquote");
qb.WithOwner().HasForeignKey("inquiry_id");
qb.Property(q => q.Status).HasConversion<string>().HasMaxLength(20);
// Money 用 EF8+ ComplexProperty:真正的值语义,列内联进报价表
qb.ComplexProperty(q => q.Freight, money =>
{
money.Property(m => m.Amount).HasColumnName("freight_amount");
money.Property(m => m.Currency).HasColumnName("freight_currency").HasMaxLength(3);
});
qb.ComplexProperty(q => q.Discount, money =>
{
money.Property(m => m.Amount).HasColumnName("discount_amount");
money.Property(m => m.Currency).HasColumnName("discount_currency").HasMaxLength(3);
});
qb.Ignore(q => q.Revisions); // 修订留痕若需持久化,见第 13 节坑 8
qb.OwnsMany(q => q.Lines, lb =>
{
lb.ToTable("mrp_inquiryquoteline");
lb.WithOwner().HasForeignKey("quote_id");
lb.ComplexProperty(l => l.UnitPrice, money =>
{
money.Property(m => m.Amount).HasColumnName("price_amount");
money.Property(m => m.Currency).HasColumnName("price_currency").HasMaxLength(3);
});
});
});
b.Ignore(x => x.DomainEvents); // 事件列表永不映射
}
}
ComplexProperty 与旧 Owned 类型的本质区别(Complex Type 无隐藏键、按值比较、列内联)见官方 Complex Types 文档;枚举转字符串、强类型 Id 的通用机制是值转换器(Value Conversions)。强类型 Id 用预约定模型配置一次性批量注册,不必每个属性写一遍:
csharp
protected override void ConfigureConventions(ModelConfigurationBuilder cfg)
{
cfg.Properties<InquiryId>().HaveConversion<StrongIdConverter<InquiryId>>();
cfg.Properties<PurchaseOrderId>().HaveConversion<StrongIdConverter<PurchaseOrderId>>();
cfg.Properties<MaterialApplyId>().HaveConversion<StrongIdConverter<MaterialApplyId>>();
cfg.Properties<SupplierId>().HaveConversion<StrongIdConverter<SupplierId>>();
cfg.Properties<MaterialId>().HaveConversion<StrongIdConverter<MaterialId>>();
}
// 一个泛型转换器通吃所有 readonly record struct Id(值对象篇有完整版,含 ValueComparer)
public sealed class StrongIdConverter<T> : ValueConverter<T, Guid>
where T : struct, IStrongId // IStrongId { Guid Value { get; } }
{
public StrongIdConverter()
: base(id => id.Value, g => (T)Activator.CreateInstance(typeof(T), g)!) { }
}
11.3 查询:聚合加载与列表投影分家
加载聚合做决策时,把报价行一起拉出来------两层集合导航(items 与 quotes 平级)是笛卡尔爆炸 场景,用 AsSplitQuery 避免 5 家 × 30 行的交叉积(Single vs. Split Queries):
csharp
public async Task<Inquiry?> GetWithQuotesAsync(InquiryId id, CancellationToken ct = default)
=> await db.Set<Inquiry>()
.Include(x => x.Items)
.Include(x => x.Quotes).ThenInclude(q => q.Lines)
.AsSplitQuery()
.SingleOrDefaultAsync(x => x.Id == id, ct);
而询价单列表页不走聚合:直接投影到 DTO,不 Include、不物化报价集合。读模型与写模型分家,M1 篇与查询性能篇讲过的原则这里原样适用。
11.4 双 Provider 只点新增差异
M1 篇已覆盖的(两套迁移集、DateTimeOffset 处理、查询过滤器、EnsureCreated vs Migrations)不重复,M2 新增三点:
- 过滤索引语法不同 :SQLite 支持
WHERE status <> 'Superseded'部分索引;SQL Server 也支持过滤索引但谓词语法需在两套配置里分别写------用IProviderConfig按db.Database.IsSqlite()分支,别指望一条迁移两边通吃; IsRowVersion:SQL Server 映射ROWVERSION,SQLite 无原生类型,EF 退化为普通并发令牌列,更新时WHERE row_version = @p语义一致(M1 篇的并发测试在两个 Provider 上都要跑一遍);- Outbox 轮询的并发度 :SQLite 是单写者 数据库,BackgroundService 与 API 请求并发写时靠
SQLITE_BUSY重试(船岸同步篇的 Mutex 方案);SQL Server 则可以多 worker 并发取批,用SKIP LOCKED语义(UPDATE ... OUTPUT或READPAST/UPDLOCK)避免多个岸基实例抢同一批消息。
12. 测试:状态机、事件负载与 SQLite 跨聚合集成测试
测试工程通过
[assembly: InternalsVisibleTo("Pms.Tests")]访问聚合的internal工厂/方法;外部生产代码仍只能走聚合根的公开方法。状态迁移异常统一断言DomainException。
12.1 状态机单元测试
csharp
// Tests/Domain/InquiryStatusTests.cs
namespace Pms.Tests.Domain;
using Pms.Domain.Exceptions;
using Pms.Domain.Purchasing;
using Pms.Domain.SharedKernel;
using Xunit;
public class InquiryStatusTests
{
private static (Inquiry inquiry, SupplierId a, SupplierId b) BuildReadyInquiry(DateTimeOffset now)
{
var applyId = MaterialApplyId.New();
var item = new InquiryItem(1, MaterialId.New(), "350101", "气动滤器", "个", 100m, 50m);
var inquiry = Inquiry.Create("(2026)HKMW-TEC-RFQ-0001", applyId, "SHIP01",
now.AddDays(3), now, [item]);
var a = new SupplierId(Guid.CreateVersion7());
var b = new SupplierId(Guid.CreateVersion7());
inquiry.InviteSupplier(a, "某供应商A", "CNY");
inquiry.InviteSupplier(b, "某供应商B", "CNY");
inquiry.SendToQuoting(now);
return (inquiry, a, b);
}
private static List<QuoteLine> OneLine(decimal price) =>
[new QuoteLine(1, default, 50m, new Money(price, "CNY"), 14, "原厂")];
[Fact]
public void 截止后再报价必须抛异常()
{
var now = DateTimeOffset.UtcNow;
var (inq, a, _) = BuildReadyInquiry(now);
inq.SubmitQuote(a, OneLine(100m), Money.Zero("CNY"), null, now, null, now);
inq.CloseQuoting();
Assert.Throws<DomainException>(() =>
inq.SubmitQuote(a, OneLine(90m), Money.Zero("CNY"), null,
now.AddDays(1), null, now.AddDays(2)));
}
[Fact]
public void 不足两家正式报价不能比价选定()
{
var now = DateTimeOffset.UtcNow;
var (inq, a, _) = BuildReadyInquiry(now);
inq.SubmitQuote(a, OneLine(100m), Money.Zero("CNY"), null, now, null, now);
inq.CloseQuoting();
Assert.Throws<DomainException>(inq.PickBestQuote);
}
[Fact]
public void 二次报价必须保留修订痕迹()
{
var now = DateTimeOffset.UtcNow;
var (inq, a, _) = BuildReadyInquiry(now);
inq.SubmitQuote(a, OneLine(100m), Money.Zero("CNY"), null, now, null, now, "首轮");
inq.SubmitQuote(a, OneLine(92m), Money.Zero("CNY"), null, now, null, now, "降价");
var quote = inq.Quotes.Single(q => q.SupplierId == a);
Assert.Equal(92m, quote.Lines.Single().UnitPrice.Amount);
Assert.Single(quote.Revisions);
}
}
12.2 跨聚合约束与幂等测试
csharp
// Tests/Domain/PurchaseQuotaTests.cs
public class PurchaseQuotaTests
{
[Fact]
public async Task 累计下单超过批准数量必须被政策对象拒绝()
{
var policy = new FakeQuotaPolicy(approved: 100m, alreadyOrdered: 80m);
var wanted = new[] { new QuotaLine(MaterialId.New(), 30m) }; // 80 + 30 > 100
await Assert.ThrowsAsync<DomainException>(
() => policy.EnsureWithinApprovedQuotaAsync(MaterialApplyId.New(), wanted));
}
[Fact]
public void 对同一供应商重复转单返回同一个采购订单号()
{
var now = DateTimeOffset.UtcNow;
var (inq, a, b) = InquiryStatusTests.BuildReadyInquiry(now); // 测试工厂复用
inq.SubmitQuote(a, OneLine(100m), Money.Zero("CNY"), null, now, null, now);
inq.SubmitQuote(b, OneLine(110m), Money.Zero("CNY"), null, now, null, now);
inq.CloseQuoting();
var winner = inq.PickBestQuote();
var po1 = PurchaseOrder.CreateFromInquiry(inq, winner, "(2026)HKMW-TEC-PO-0001", now);
inq.MarkConverted(po1.Id);
// 模拟双击:聚合已 Closed 且有 PurchaseOrderId,应用层直接返回旧 Id,不再 new 第二张
Assert.Equal(InquiryStatus.Closed, inq.Status);
Assert.Equal(po1.Id, inq.PurchaseOrderId!.Value);
}
[Fact]
public void 创建采购订单后必须携带负载正确的领域事件()
{
var now = DateTimeOffset.UtcNow;
var (inq, a, b) = InquiryStatusTests.BuildReadyInquiry(now);
inq.SubmitQuote(a, OneLine(100m), new Money(200m, "CNY"), new Money(50m, "CNY"), now, null, now);
inq.SubmitQuote(b, OneLine(110m), Money.Zero("CNY"), null, now, null, now);
inq.CloseQuoting();
var winner = inq.PickBestQuote();
var po = PurchaseOrder.CreateFromInquiry(inq, winner, "(2026)HKMW-TEC-PO-0002", now);
var evt = Assert.Single(po.DomainEvents);
var created = Assert.IsType<PurchaseOrderCreatedEvent>(evt);
Assert.Equal(po.Id, created.PurchaseOrderId);
Assert.Equal(inq.Id, created.InquiryId);
Assert.Equal(a, created.SupplierId);
// 50 × 100 + 运费 200 − 折扣 50 = 5150
Assert.Equal(5150m, created.TotalAmount.Amount);
Assert.NotEqual(Guid.Empty, created.EventId);
}
}
12.3 SQLite 临时文件跨聚合集成测试
csharp
// Tests/Integration/GeneratePurchaseOrderIntegrationTests.cs
namespace Pms.Tests.Integration;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Pms.Domain.Purchasing;
using Pms.Infrastructure;
using Pms.Infrastructure.Purchasing;
using Xunit;
public sealed class GeneratePurchaseOrderIntegrationTests : IDisposable
{
// 内存 SQLite 必须保持连接打开;文件型临时库则用 Path.GetTempFileName() + 测后删除
private readonly SqliteConnection _conn =
new("Data Source=InMemory_M2;Mode=Memory;Cache=Shared");
public GeneratePurchaseOrderIntegrationTests()
{
_conn.Open();
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
[Fact]
public async Task 转单后申请已订数量回写且outbox落一条待发事件()
{
var applyId = await SeedApprovedApplyAsync(approvedQty: 100m);
await using (var arrange = NewContext())
{
var now = DateTimeOffset.UtcNow;
var item = new InquiryItem(1, MaterialId.New(), "350101", "气动滤器", "个", 100m, 50m);
var inquiry = Inquiry.Create("(2026)HKMW-TEC-RFQ-0001", applyId, "SHIP01",
now.AddDays(3), now, [item]);
var supplierA = new SupplierId(Guid.CreateVersion7());
var supplierB = new SupplierId(Guid.CreateVersion7());
inquiry.InviteSupplier(supplierA, "某供应商A", "CNY");
inquiry.InviteSupplier(supplierB, "某供应商B", "CNY");
inquiry.SendToQuoting(now);
inquiry.SubmitQuote(supplierA,
[new QuoteLine(1, item.MaterialId, 50m, new Money(100m, "CNY"), 14, "原厂")],
Money.Zero("CNY"), null, now, null, now);
inquiry.SubmitQuote(supplierB,
[new QuoteLine(1, item.MaterialId, 50m, new Money(110m, "CNY"), 21, "副厂")],
Money.Zero("CNY"), null, now, null, now);
inquiry.CloseQuoting();
var winner = inquiry.PickBestQuote();
var po = PurchaseOrder.CreateFromInquiry(inquiry, winner,
"(2026)HKMW-TEC-PO-0001", now);
inquiry.MarkConverted(po.Id);
arrange.Set<Inquiry>().Add(inquiry);
arrange.Set<PurchaseOrder>().Add(po);
// 模拟事件处理器:同事务回写申请单已订数量(生产中由 UpdateOrderedQtyOnPoCreatedHandler 完成)
var apply = await arrange.MaterialApplyItems.SingleAsync(x => x.ApplyId == applyId);
apply.MarkOrdered(50m);
// 模拟 UoW 的 outbox 翻译:PurchaseOrderCreatedEvent 落一条待发消息
arrange.OutboxMessages.Add(new OutboxMessage
{
Id = po.DomainEvents.OfType<PurchaseOrderCreatedEvent>().Single().EventId,
EventType = typeof(PurchaseOrderCreatedIntegrationEvent).FullName!,
JsonPayload = "{}",
OccurredOn = now
});
await arrange.SaveChangesAsync();
}
await using (var assert = NewContext())
{
var applyItem = await assert.MaterialApplyItems.SingleAsync();
Assert.Equal(50m, applyItem.OrderedQty); // 事件处理器同事务回写成功
var outbox = await assert.OutboxMessages.ToListAsync();
Assert.Single(outbox); // 同事务写入,尚未被后台发布
Assert.Null(outbox[0].ProcessedAt);
Assert.Contains("PurchaseOrderCreated", outbox[0].EventType);
}
// 并发终局防线:同 (inquiry_id, supplier_id) 再插一张 PO,唯一索引必须报错
await using (var dup = NewContext())
{
await dup.Database.ExecuteSqlRawAsync("""
INSERT INTO mrp_po
(id, order_no, inquiry_id, apply_id, supplier_id, supplier_name,
ship_code, currency, status, order_date, freight_amount, freight_currency, total_amount)
SELECT X'01', 'DUP-NO', inquiry_id, apply_id, supplier_id, 'dup',
ship_code, currency, 'Draft', order_date, 0, 'CNY', 0
FROM mrp_po
""");
await Assert.ThrowsAnyAsync<DbUpdateException>(() => dup.SaveChangesAsync());
}
}
/// <summary>测试夹具:直接按 M1 表结构落一张已三级审批通过的申请行。</summary>
private async Task<MaterialApplyId> SeedApprovedApplyAsync(decimal approvedQty)
{
await using var ctx = NewContext();
var applyId = MaterialApplyId.New();
// ExecuteSqlInterpolated:插值自动绑定为参数,Guid 以 BLOB 绑定,与 11.1 的表结构一致
await ctx.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO mrp_applydesc (id, apply_no, status, row_version)
VALUES ({applyId.Value}, '(2026)HKMW-TEC-MRP-0001', 'Approved', 0);
""");
await ctx.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO mrp_apply
(id, apply_id, material_id, material_name, unit, approved_qty, ordered_qty, line_no)
VALUES ({Guid.CreateVersion7()}, {applyId.Value},
{Guid.CreateVersion7()}, '气动滤器', '个', {approvedQty}, 0, 1);
""");
return applyId;
}
private PmsDbContext NewContext()
{
var options = new DbContextOptionsBuilder<PmsDbContext>()
.UseSqlite(_conn)
.Options;
return new PmsDbContext(options);
}
public void Dispose() => _conn.Dispose();
}
说明两点:①
MaterialApplyItem.MarkOrdered(50m)是 M2 在 M1 聚合上新增的方法 ------OrderedQty私有 set,回写必须走聚合方法,配套迁移给mrp_apply加一列ordered_qty(迁移演进见本系列迁移篇);② 生产编排里第 ① 步回写由UpdateOrderedQtyOnPoCreatedHandler完成、outbox 由UnitOfWork统一翻译,测试里把它们显式展开是为了让断言点清楚可见。
集成测试要覆盖的三条断言正好对应三个事故:回写数量正确 (事故二的防线活着)、outbox 有事件 (第 8 节的原子性活着)、唯一索引会拦重复单(事故一的终局防线活着)。
13. 踩坑清单、Checklist 与下一篇引子
13.1 踩坑清单(10 条)
- 把 PO 建成询价的子实体:报价、收货、发票、付款全塞一个大聚合,每次登记到货都加载 5 家全部报价,并发令牌互相阻塞------收货频繁后 SQLite 写锁排队。聚合边界先按生命周期切,再按一致性规则校验。
- 跨聚合持对象引用 :
PurchaseOrder.Inquiry导航属性一开,序列化带循环、懒加载 N+1、应用层随手po.Inquiry.Quotes...改询价状态,边界名存实亡。跨聚合只持强类型 Id。 - 事件在聚合构造函数里抛异常导致发不出去 :更隐蔽的变体是
AddDomainEvent放在SaveChanges之后才执行的代码路径里。事件必须与状态迁移在同一个领域方法里挂账。 - SaveChanges 成功后再 dispatch 关键事件:进程重启即永久不一致。关键一致性事件要么提交前分发(同事务),要么 Outbox,没有第三种可靠姿势。
- 事件处理器里新开 Scope/SaveChanges 嵌套事务:处理器的修改先提交了,外层业务后回滚,产生"没有订单的已订数量"。处理器复用同一个 scoped DbContext,统一由 UoW 提交。
- 事件 Id 在发布时才
Guid.NewGuid():重试一次就生成新 Id,下游无法幂等去重,同一张 PO 的已订数量回写两遍。EventId在事件创建(聚合方法内)时就固定。 - 聚合根集合暴露
public set:public List<QuoteLine> Lines { get; set; }让外部绕过SubmitQuote直接加行,截止校验、留痕全失效。private readonly List+IReadOnlyList是底线。 - 供应商改价用 UPDATE 物理覆盖 :事后无法回答"上周四下的单按的是哪个价"。二次报价必须把旧行快照进
QuoteRevision;要持久化修订记录就映射为 JSON 列(EF CoreToJson)或自有明细表,别Ignore掉还宣称留痕。 - 比价静默处理异币种:按某个写死汇率算出"最低价",汇率波动选错供应商。异币种直接拒绝并要求显式折算,把决策留给业务。
- 只做应用层幂等不建唯一索引:双击穿过"先查后插"间隙时数据库沉默收下第二张单。幂等永远要落到唯一约束,应用层查询只是友好的快速路径。
13.2 落地 Checklist
- 报价实体在询价聚合内,PO 是独立聚合,跨聚合只见强类型 Id
- 每个状态迁移都有守卫方法,枚举以字符串落库,非法状态迁移抛
DomainException - 截止后报价、不足两家选定、报价未提交转单、超量下单四类异常都有单测
- 跨聚合数量约束:船端领域服务同事务;岸基事件 + Outbox,消费端按
EventId幂等 - 关键事件提交前分发或 Outbox 同事务落库;处理器复用同一 DbContext
-
mrp_po(inquiry_id, supplier_id)、mrp_inquiry(inquiry_no)、幂等表三组唯一索引就位 - 聚合加载
AsSplitQuery;列表页投影 DTO,不物化聚合 - Money 全程值对象,异币种运算/比价显式拒绝
- SQLite(单写者/Mutex)与 SQL Server(并发取批/过滤索引谓词)两套配置与测试都跑过
13.3 下一篇引子
采购订单确认之后,物料还在海上。M3 要解决的是库存交易 :分批发货、到港验收、拒收、入库出库流水(老表 mrp_storetrans,TransNum > 0 入库、< 0 出库、出库余额不得为负),以及 PO 的 PartialReceived 状态如何由收货流水驱动、断网期间船端先入流水、复联后与岸基库存对账的冲突解决。下一篇:《M3 库存交易与出入库流水:不可变流水、余额物化与船岸对账》 。在此之前,也可能先补一篇聚合设计原则复盘------M1/M2/M3 三个里程碑走下来,"怎么切聚合"已经攒够了正反案例。
14. 官方参考资料
- EF Core 复杂类型(ComplexProperty / 值对象映射,含与 Owned 类型的完整对比):https://learn.microsoft.com/en-us/ef/core/modeling/complex-types
- EF Core 值转换器(枚举转字符串、强类型 Id):https://learn.microsoft.com/en-us/ef/core/modeling/value-conversions
- EF Core 事务(SaveChanges 默认事务行为、跨上下文事务):https://learn.microsoft.com/en-us/ef/core/saving/transactions
- 领域事件的设计与实现(领域事件 vs 集成事件、MediatR 分发、提交前/后时机,微软 eShop 指南):https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation
- Worker Service 与 BackgroundService 官方文档:https://learn.microsoft.com/en-us/dotnet/core/extensions/workers
- .NET 10 兼容性变更:BackgroundService.ExecuteAsync 全程后台线程运行:https://learn.microsoft.com/en-us/dotnet/core/compatibility/extensions/10.0/backgroundservice-executeasync-task
- EF Core 单次查询与拆分查询(笛卡尔爆炸与 AsSplitQuery):https://learn.microsoft.com/en-us/ef/core/querying/single-split-queries
- EF Core 索引(复合索引、唯一索引、过滤索引):https://learn.microsoft.com/en-us/ef/core/modeling/indexes
- ASP.NET Core Minimal APIs 概述:https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview
- CAP 官方文档(本地消息表/Outbox 模式、事件总线):https://cap.dotnetcore.xyz/
- Martin Fowler:Value Object(值对象相等性语义):https://martinfowler.com/bliki/ValueObject.html
- EF Core 8 新特性(复杂类型引入背景:Owned 类型的隐藏键问题):https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-8.0/whatsnew