从本章开始,我们将承接《16.项目架构设计》中的服务拆分与通信策略,把交易链路的关键环节补齐到"可跑通的端到端流程"。上篇我们已经落地了用户、认证授权与商品服务,这篇将聚焦订单、支付与通知三个服务,并把它们放在 .NET Aspire 的分布式应用编排之下运行,通过服务发现、韧性与可观测性把"能跑"升级为"可诊断、可扩展、可演进"。
需要提前说明的是,本章示例仍以 Minimal API + EF Core 为主线,结合消息队列实现跨服务事件流转,以最终一致性为目标。为了不让示例变成抽象讨论,我们会给出每个服务的核心代码片段,并解释为什么这样写、运行时发生了什么、以及生产环境需要补齐哪些工程化细节。
一、订单服务实现
订单服务是交易链路的中枢,它要把"用户下单"这种面向外部的同步请求,转化为一系列可控的内部状态迁移:创建订单、等待支付、支付成功、支付失败或取消。在《16.项目架构设计》中,我们强调订单服务要有自己的数据存储,同时通过 API 与事件与其他服务协作,本节将围绕"订单状态机 + 事件驱动"的实现方式展开。
订单模型通常包含订单主表与订单明细表。订单主表承担状态与金额的权威来源,订单明细表记录商品项的快照信息,避免后续商品信息变化影响历史订单的可追溯性。下面的代码展示一个极简但可扩展的领域模型:通过 Status 表示订单状态,通过 TotalAmount 表示下单时的应付金额,通过 CreatedAt 与 UpdatedAt 便于审计与排查。
csharp
// Domain/Entities/Order.cs
public enum OrderStatus
{
PendingPayment = 1,
Paid = 2,
Cancelled = 3,
PaymentFailed = 4
}
public class Order
{
public long Id { get; set; }
public long UserId { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.PendingPayment;
public decimal TotalAmount { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public List<OrderItem> Items { get; set; } = new();
}
public class OrderItem
{
public long Id { get; set; }
public long OrderId { get; set; }
public long ProductId { get; set; }
public string ProductNameSnapshot { get; set; } = default!;
public decimal UnitPriceSnapshot { get; set; }
public int Quantity { get; set; }
}
这里的关键点不是字段数量,而是字段的语义边界。OrderStatus 的枚举让状态迁移一眼可见,并且为后续添加"已发货""已完成""已退款"等状态预留空间。ProductNameSnapshot 与 UnitPriceSnapshot 的存在是为了避免订单展示依赖商品服务的实时数据,尤其是在商品改名、调价、下架之后,订单依然要保持可解释性。只要把"快照是下单时事实"的原则固定下来,后续围绕订单的对账、退款与审计都会变得更可控。
接下来是 EF Core 的持久化配置。订单服务应拥有独立数据库;在 Aspire 下,数据库连接字符串通常由 AppHost 的资源引用注入到服务中,服务端只需要按连接名读取即可。下面的 OrderDbContext 把订单与订单明细映射到表,并且通过关系配置保证明细随订单聚合存取。
csharp
// Infrastructure/Data/OrderDbContext.cs
public class OrderDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
public OrderDbContext(DbContextOptions<OrderDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(b =>
{
b.ToTable("Orders");
b.HasKey(x => x.Id);
b.Property(x => x.TotalAmount).HasPrecision(18, 2);
b.Property(x => x.Status).HasConversion<int>();
b.HasMany(x => x.Items)
.WithOne()
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<OrderItem>(b =>
{
b.ToTable("OrderItems");
b.HasKey(x => x.Id);
b.Property(x => x.ProductNameSnapshot).HasMaxLength(200);
b.Property(x => x.UnitPriceSnapshot).HasPrecision(18, 2);
});
}
}
这里最容易被忽略但又非常关键的是金额精度。HasPrecision(18, 2) 是为了让数据库层面的数值精度与 C# 的 decimal 语义一致,避免出现不同环境下的舍入差异。Status 使用 HasConversion<int>() 将枚举存为整数,既节省存储也利于索引,同时也要求你在未来演进枚举时保持"既有值不变"的兼容性,这一点在生产系统中尤为重要。
当用户提交订单时,订单服务需要完成两类动作:其一是本地事务内创建订单与明细;其二是向支付服务发起支付请求。根据《16.项目架构设计》的通信建议,我们把跨服务协作放到"事件"上,以降低同步链路的耦合与延迟。实践中最常见的模式是:订单创建成功后发布 PaymentRequested 事件,支付服务订阅该事件并创建支付单。
为了让事件发布在故障场景下仍然可靠,本章示例采用"事务内写入 Outbox,再由后台任务投递到消息队列"的思路。你可以把 Outbox 理解成订单服务自己的"待发送事件表",它与订单写入同一个数据库事务里,从而避免"订单写入成功但事件没发出去"的不一致。
csharp
// Infrastructure/Outbox/OutboxMessage.cs
public class OutboxMessage
{
public long Id { get; set; }
public string Type { get; set; } = default!;
public string Payload { get; set; } = default!;
public DateTime OccurredAt { get; set; } = DateTime.UtcNow;
public DateTime? PublishedAt { get; set; }
}
Type 用于区分事件类型,Payload 存放序列化后的事件内容。这样做的意义在于:即便消息队列短暂不可用,订单服务也不会把请求直接失败给用户,而是把事件留在本地待投递,系统可以在恢复后继续推进最终一致性。
下面展示一个订单创建端点的核心写法。代码刻意把"写库"与"发消息"分离:端点只负责落库与写 Outbox,真正投递由后台任务完成。
csharp
// Api/Program.cs (片段)
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddDbContext<OrderDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("orders")));
builder.Services.AddHostedService<OutboxPublisherBackgroundService>();
var app = builder.Build();
app.MapPost("/api/orders", async (OrderDbContext db, ClaimsPrincipal user, CreateOrderRequest req) =>
{
var userId = long.Parse(user.FindFirstValue(ClaimTypes.NameIdentifier)!);
var order = new Order
{
UserId = userId,
Status = OrderStatus.PendingPayment,
TotalAmount = req.Items.Sum(i => i.UnitPrice * i.Quantity),
Items = req.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
ProductNameSnapshot = i.ProductName,
UnitPriceSnapshot = i.UnitPrice,
Quantity = i.Quantity
}).ToList()
};
var evt = new PaymentRequested
{
OrderId = order.Id,
UserId = userId,
Amount = order.TotalAmount
};
await using var tx = await db.Database.BeginTransactionAsync();
db.Orders.Add(order);
await db.SaveChangesAsync();
// 注意:这里要在 SaveChanges 后才能拿到 order.Id
evt.OrderId = order.Id;
db.Set<OutboxMessage>().Add(new OutboxMessage
{
Type = nameof(PaymentRequested),
Payload = JsonSerializer.Serialize(evt)
});
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Accepted($"/api/orders/{order.Id}", new { order.Id, order.Status });
});
app.Run();
这段代码有几个细节值得逐段理解。首先,ClaimsPrincipal user 的注入意味着该端点默认假设在网关或本服务中启用了 JWT 认证,用户身份通过 NameIdentifier 传入。其次,TotalAmount 计算采用请求里的快照价格,这并不意味着你可以信任客户端价格;真实系统需要在订单服务内部再次调用商品服务验证价格与库存,本章为了突出编排与事件链路,用简化输入来说明结构。
更关键的是事务边界。我们显式开启本地事务,把 Orders 的写入与 OutboxMessage 的写入包在一起。这样只要事务提交成功,订单与待发布事件一定同时存在;如果事务失败,二者都不会落库。随后后台任务会扫描未发布的 Outbox 记录并投递到消息队列,成功后写回 PublishedAt,实现可重试与可观测。
订单服务还需要订阅支付结果事件,并更新订单状态。支付成功时订单从 PendingPayment 进入 Paid,支付失败则进入 PaymentFailed。这里同样强调幂等性:消息队列可能重复投递,更新逻辑必须能够安全重复执行,例如"如果已经是 Paid,再次收到 PaymentSucceeded 不应改变最终结果"。
作为本节的收尾,我们完成了订单服务"本地强一致 + 跨服务最终一致"的基础骨架:订单数据自治、状态机可演进、Outbox 保证事件可靠投递。接下来我们将实现支付服务,承接 PaymentRequested 事件并发布支付结果事件,让整个交易链路真正闭环。
二、支付服务实现
支付服务的职责不是"替代第三方支付平台",而是为系统提供一个稳定的支付领域边界:创建支付单、跟踪支付状态、处理第三方回调、对外发布支付结果事件。在《16.项目架构设计》中,支付服务拥有独立数据存储,并在支付完成后通知订单服务更新状态,这意味着支付服务需要把"外部不确定性"(回调延迟、重复通知、失败重试)封装在自己的边界内。
支付领域模型通常包含支付单与支付流水。为了示例清晰,我们聚焦支付单:它关联订单、金额与状态,并记录外部支付平台返回的交易号。状态机一般包含 Created、Succeeded、Failed 等,且必须考虑回调重复与乱序。
csharp
// Domain/Entities/Payment.cs
public enum PaymentStatus
{
Created = 1,
Succeeded = 2,
Failed = 3
}
public class Payment
{
public long Id { get; set; }
public long OrderId { get; set; }
public long UserId { get; set; }
public decimal Amount { get; set; }
public PaymentStatus Status { get; set; } = PaymentStatus.Created;
public string? ProviderTradeNo { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
支付服务接收到 PaymentRequested 事件后,会创建一条 Payment 记录,并向外部支付渠道发起请求。在真实系统中,这一步会涉及签名、下单、跳转或二维码等流程;本章侧重服务间协作,因此我们把"发起外部请求"抽象为一个 IPaymentGateway 接口,便于在不同支付渠道之间切换,也便于在开发环境中用 Mock 实现。
csharp
public interface IPaymentGateway
{
Task<string> CreateAsync(long orderId, decimal amount, CancellationToken ct);
}
public class FakePaymentGateway : IPaymentGateway
{
public Task<string> CreateAsync(long orderId, decimal amount, CancellationToken ct)
=> Task.FromResult($"FAKE-{orderId}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}");
}
这里的 CreateAsync 返回一个模拟的外部交易号。你可以把它类比为支付宝的 trade_no 或微信的 transaction_id,它是支付对账与回调处理的重要索引。
支付服务同样建议采用 Outbox 可靠发布事件,原因与订单服务一致:支付状态更新与事件发布应尽量在同一事务边界内完成,避免"支付已成功但订单服务没收到事件"。当外部回调到达时,支付服务更新 Payment.Status,随后写入 Outbox 一条 PaymentSucceeded 或 PaymentFailed,由后台任务投递到队列。
下面展示一个"回调处理"的端点示例。注意回调必须支持幂等:同一笔支付可能多次通知;代码必须在重复请求下保持相同结果。
csharp
// Api/Program.cs (片段)
app.MapPost("/api/payments/callback", async (PaymentDbContext db, PaymentCallbackRequest req) =>
{
var payment = await db.Payments.FirstOrDefaultAsync(x => x.ProviderTradeNo == req.ProviderTradeNo);
if (payment is null) return Results.NotFound();
if (payment.Status == PaymentStatus.Succeeded)
return Results.Ok(new { status = "already_succeeded" });
await using var tx = await db.Database.BeginTransactionAsync();
payment.Status = req.Success ? PaymentStatus.Succeeded : PaymentStatus.Failed;
payment.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
if (req.Success)
{
var evt = new PaymentSucceeded { OrderId = payment.OrderId, PaymentId = payment.Id };
db.Set<OutboxMessage>().Add(new OutboxMessage
{
Type = nameof(PaymentSucceeded),
Payload = JsonSerializer.Serialize(evt)
});
}
else
{
var evt = new PaymentFailed { OrderId = payment.OrderId, PaymentId = payment.Id, Reason = req.Reason };
db.Set<OutboxMessage>().Add(new OutboxMessage
{
Type = nameof(PaymentFailed),
Payload = JsonSerializer.Serialize(evt)
});
}
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new { status = payment.Status.ToString() });
});
这一段的核心是"先判重、再落库、再写 Outbox"。判重用 payment.Status == Succeeded 来实现最简单的幂等门闩:如果已经成功,则直接返回相同语义而不重复发布事件。落库与写 Outbox 放在事务中,确保状态变化与事件记录一致。
你可能会注意到这里没有直接把事件发到消息队列,这不是偷懒,而是把"外部不稳定因素"隔离到后台投递器里。支付回调往往要求快速响应,否则第三方会不断重试;如果你在回调里同步投递消息,一旦队列抖动就会拖慢回调,进而放大重试风暴。Outbox 的价值就在于把回调与投递解耦。
在 .NET Aspire 的视角下,支付服务与订单服务一样,会从 AppHost 资源引用获得数据库与消息队列连接,并通过 ServiceDefaults 自动获得 OpenTelemetry、健康检查、HttpClient 韧性与服务发现能力。这意味着当你在 Dashboard 里观察"支付回调 → 写库 → 投递事件 → 订单状态更新"的链路时,可以看到跨服务的 Trace 串起来,定位延迟与失败的成本显著降低。
作为本节的结尾,我们把支付服务建设为一个可演进的"支付领域边界":它吸收外部回调的不确定性,通过幂等与 Outbox 保证内部一致性,并对外发布稳定的支付结果事件。接下来我们将实现通知服务,订阅订单与支付相关事件,把异步链路的最终状态反馈给用户,从而完成交易体验的闭环。
三、通知服务实现
通知服务的价值在电商系统里常被低估,但它是用户体验与运营转化的重要组成。通知服务通常订阅多个业务事件:订单创建、支付成功、支付失败、发货、退款等,然后选择合适的渠道触达用户,例如站内信、短信、邮件、Push 等。本章下篇选择通知服务,是因为它天然适合展示《16.项目架构设计》中"异步通信 + 幂等消费 + 可观测性"的设计原则。
通知服务的核心难点不在"怎么发短信",而在"怎么保证消息消费可靠且不会重复骚扰用户"。消息队列的投递语义通常是至少一次(at-least-once),因此通知服务必须做好幂等控制。最常见的做法是为每条业务事件计算一个稳定的幂等键(例如 eventId 或 OrderId + EventType),把已处理的键落库,后续重复消费时直接跳过。
下面给出一个简化的幂等表与处理逻辑。示例里我们用数据库表 NotificationConsumed 记录消费过的消息键,并在事务内保证"写入消费记录 + 写入通知记录"同时成功。
csharp
public class NotificationConsumed
{
public long Id { get; set; }
public string MessageKey { get; set; } = default!;
public DateTime ConsumedAt { get; set; } = DateTime.UtcNow;
}
public class Notification
{
public long Id { get; set; }
public long UserId { get; set; }
public string Channel { get; set; } = "inbox";
public string Title { get; set; } = default!;
public string Content { get; set; } = default!;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
当通知服务消费到 PaymentSucceeded 事件时,它需要把事件转换成可展示的通知内容。这里的"转换"本质上是把技术事件映射为业务语言,例如"订单已支付成功,可以开始备货"。
csharp
public async Task HandlePaymentSucceededAsync(NotificationDbContext db, PaymentSucceeded evt, CancellationToken ct)
{
var messageKey = $"PaymentSucceeded:{evt.OrderId}:{evt.PaymentId}";
if (await db.Set<NotificationConsumed>().AnyAsync(x => x.MessageKey == messageKey, ct))
return;
await using var tx = await db.Database.BeginTransactionAsync(ct);
db.Set<NotificationConsumed>().Add(new NotificationConsumed { MessageKey = messageKey });
db.Set<Notification>().Add(new Notification
{
UserId = evt.UserId,
Title = "支付成功",
Content = $"订单 {evt.OrderId} 已支付成功,我们将尽快为你处理发货。"
});
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
这段处理逻辑的关键点在于幂等键设计与事务边界。幂等键要满足"同一业务事实重复到达时键相同",这样才能可靠去重。事务边界确保记录消费与写入通知同时成功;否则你可能出现"已记录消费但通知没写入",导致用户永远收不到消息。
在 Aspire 运行时层面,通知服务会自动获得遥测与健康检查能力。由于通知链路通常是异步的,用户不会直接感知通知延迟,但运维必须感知。通过 OpenTelemetry 的指标与追踪,你可以观察队列消费速率、失败率、重试次数,并在 Dashboard 中把"支付成功事件 → 通知落库"作为一条独立链路查看,这比传统"翻日志猜测"要高效得多。
作为本节的收尾,我们用通知服务把异步事件消费落地为可见的用户反馈,并通过幂等消费保证至少一次投递下的正确性。至此,订单创建、支付回调、状态更新与用户通知形成闭环,系统具备了构建更复杂业务流程的基础能力。
四、总结
本章"核心服务实现(下)"承接《16.项目架构设计》的拆分原则与通信策略,完成了订单、支付与通知三个关键服务的落地实现。订单服务通过本地事务与 Outbox 模式把"同步下单"转化为"可持续推进的状态机",并以事件驱动的方式降低了与支付等下游服务的同步耦合;支付服务把外部支付平台的不确定性封装在自身边界内,以幂等回调与可靠事件发布为核心,向外提供稳定的支付结果;通知服务订阅业务事件并以幂等消费保障用户触达的可靠性,从而让异步链路变得可观测、可控制。
在 .NET Aspire 的支撑下,三个服务的运行不再依赖手工拼接配置:AppHost 负责声明数据库与消息队列等资源并注入连接信息,ServiceDefaults 负责统一接入 OpenTelemetry、健康检查、服务发现与 HttpClient 韧性策略。你可以在 Aspire Dashboard 里用追踪与指标验证链路是否闭环,定位失败点与性能瓶颈,并把这种"本地调试期就可观测"的优势延续到生产环境。
如果要把本章示例进一步推向生产级实践,下一步通常会围绕三个方向演进:其一是把订单创建时的价格与库存校验补齐为服务端权威校验,并引入库存预占/回退的完整流程;其二是为支付加入刷新、对账、退款等逆向链路,并完善签名验证与安全审计;其三是把事件契约治理起来(版本化、兼容性、死信与补偿),让 Saga 的补偿与最终一致性在更多异常场景下仍然可控。完成这些演进后,你将得到一条真正贴近生产的交易链路,而 .NET Aspire 的编排与可观测性会持续降低你在分布式系统里定位与治理问题的成本。