从零重构外卖系统(五):订单快照、幂等提交与购物车结算,怎样保证一次下单只发生一次
系列:Han Menu 外卖系统实践 · 从第一篇开始
系列:Han Menu 外卖系统实践 · P4
面向读者:能阅读 Java、Spring 事务和 JPA 代码,已经了解购物车聚合,希望学会处理下单一致性、请求重试和历史快照的开发者。
本篇依据 P4 提交
4142888编写,承接 P3 的顾客身份、地址簿与购物车。关键代码直接摘自这一提交;省略导入、构造器或外围方法的片段不是独立完整文件。本篇只讨论待付款订单。P5 后来加入了支付、超时和履约状态,不能把当前仓库的全部行为倒算为 P4 已经实现的能力。
1. 购物车里有商品,为什么还不能直接保存成订单
上一篇已经能让顾客选菜、选口味和修改数量。购物车页面还会根据当前目录计算 estimatedTotal。
接下来很容易写出这样的代码:把购物车复制到订单表,然后清空购物车。
但在顾客按下“提交订单”的前后,业务事实仍然会变化:
- 地址可能被修改或删除,也可能根本不属于当前顾客。
- 门店可能已经打烊。
- 菜品可能下架、改价,原来选择的口味可能不再存在。
- 顾客可能在另一台设备上继续加购。
- 服务器保存成功,App 却因为断网没有收到响应。
- 一个月后商品名称变了,历史订单仍要说明当时买了什么。
因此,下单不是一次普通复制。我们需要先确认哪些当前事实有效,再把这次购买约定保存成订单自己的历史事实。
本篇围绕四个问题展开:
| 问题 | 主要机制 |
|---|---|
| 谁决定成交内容和金额 | 服务端重验与重新计价 |
| 后来修改资料是否影响旧订单 | 不可变地址、商品与套餐快照 |
| 同一次提交重试是否重复建单 | 顾客作用域内的幂等键 |
| 下单时能否误删后来的加购 | 购物车版本与同事务结算 |
2. 先把几个容易混淆的概念分开
2.1 当前商品事实与历史成交事实
catalog 保存商品现在的名称、价格、规格和可售状态。ordering 保存这张订单当时采用的名称、价格、规格和数量。
它们可以不同,而且应该允许不同。
例如同一道面条昨天卖 18.50 元,今天卖 21.30 元。商品详情应该展示今天的价格,昨天的订单仍然应该展示 18.50 元。
2.2 购物车条目 ID 与商品 ID
同一个商品选择不同辣度,可以在购物车中形成两条记录。所以提交订单的 itemIds 是购物车条目 UUID,不是商品 UUID。
商品 P:面条
├── 购物车条目 A:微辣,2 份
└── 购物车条目 B:不辣,1 份
如果只提交商品 P 的 ID,服务器无法知道顾客希望结算哪种口味。
2.3 版本与幂等键
版本检查回答的是:“你基于的资源快照还是最新的吗?”
幂等键回答的是:“这个提交意图是否已经成功处理过?”
假设购物车版本 4 已成功结算,服务器把版本推进为 5。原请求重试时,版本 4 当然已经陈旧,但服务器仍然应该能够告诉顾客原订单是什么。
这正是幂等键不能被一个 version 字段简单替代的原因。
3. 下单需要多个模块,但不能拿走其他模块的数据所有权
P4 的 ordering 模块开放以下依赖:
@ApplicationModule(
displayName = "订单管理",
allowedDependencies = {
"customer :: api", "shop :: api", "catalog :: api", "cart :: api"
})
package com.hanserwei.hanmenu.ordering;
这是同一声明的排版整理。箭头表示 Java 编译依赖,不代表内部模块互相发送 HTTP:
flowchart LR
O[ordering.application] --> CU[customer.api 身份与地址]
O --> S[shop.api 营业事实]
O --> CA[catalog.api 当前报价]
O --> C[cart.api 选择与结算]
O --> R[ordering.domain 订单仓储端口]
J[ordering.infrastructure.persistence] --> R
J --> DB[(PostgreSQL)]
具体责任如下:
| 模块 | 提供什么 | 仍由自己保护什么 |
|---|---|---|
| customer | 当前顾客校验、指定版本地址快照 | 账号安全版本、地址归属、地址簿变更 |
| shop | 结算时的营业状态 | 门店资料和开关店规则 |
| catalog | 当前可售商品及套餐组成报价 | 分类、商品、口味和套餐引用规则 |
| cart | 所选商品、数量、规格及版本化结算 | 条目归属、集合变更、合并和容量 |
| ordering | 订单创建、查询、取消与再来一单编排 | 成交快照、总金额、订单生命周期 |
订单模块不会查询 customer_address 或 cart_item,也不会把 ProductEntity 放进订单聚合。
一个值得先看的公开契约是 CartCheckout,核心声明如下:
public interface CartCheckout {
List<Selection> selected(
CustomerIdentity identity, long version, List<UUID> itemIds);
long settle(
CustomerIdentity identity, long version, List<UUID> itemIds);
long reorder(
CustomerIdentity identity, long version, List<Selection> items);
record Selection(
UUID itemId, UUID productId, int quantity, Map<String, String> selections) {
public Selection {
selections = Map.copyOf(selections);
}
}
}
片段省略 Javadoc。请注意 Selection 没有价格:购物车里保存的展示价格不被提升为订单的可信报价。
4. 收货地址进入订单后,就有了新的职责
地址簿里的 DeliveryAddress 是可以修改的实体。订单需要的是成交时的收货资料。
P4 定义了独立值对象:
/** 不可变收货资料,与地址簿后续修改或删除隔离. */
public record AddressSnapshot(
UUID sourceId,
long sourceVersion,
String recipientName,
String phone,
String province,
String city,
String district,
String detail) {
/** 校验快照完整性,不保存地址簿领域对象引用. */
public AddressSnapshot {
Objects.requireNonNull(sourceId);
if (sourceVersion < 0) {
throw new IllegalArgumentException("地址版本不可为负");
}
for (String value : new String[] {recipientName, phone, province, city, district, detail}) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("收货快照不完整");
}
}
}
@Override
public String toString() {
return "AddressSnapshot[收货资料已隐藏]";
}
}
这里保存 sourceId 和 sourceVersion,便于说明快照来自哪条地址的哪个版本。同时完整复制收货字段,避免订单详情依赖地址簿里的当前内容。
例如:
提交时:地址 A,version=2,详细地址为“示例路 1 号”
提交后:顾客将地址 A 改为“另一条路 8 号”,version=3
订单中:仍保留“示例路 1 号”和 sourceVersion=2
这不是“同步失败”,而是两个模型职责不同的正常结果。
toString() 隐藏收货资料,只防止常见调试输出意外暴露个人信息。它不能替代接口归属检查,也不意味着数据库中的地址已经加密。
5. 商品快照与金额计算放在订单自己的模型里
5.1 OrderLine 保存什么
下面直接展示条目类型,包含套餐组成的构造检查;只省略 package 和 import:
/** 订单条目保留成交时商品、规格、套餐组成及人民币价格,后续不可重新计价. */
public record OrderLine(
UUID id,
UUID productId,
String kind,
String name,
BigDecimal unitPrice,
int quantity,
Map<String, String> selections,
List<Component> components) {
/** 验证正金额和数量,并固定全部集合. */
public OrderLine {
Objects.requireNonNull(id);
Objects.requireNonNull(productId);
if ((!"DISH".equals(kind) && !"SET_MEAL".equals(kind))
|| name == null
|| name.isBlank()
|| unitPrice == null
|| unitPrice.signum() <= 0
|| quantity < 1
|| quantity > 99) {
throw new OrderException(OrderException.Reason.INVALID_INPUT, "订单条目不合法");
}
unitPrice = unitPrice.setScale(2, RoundingMode.UNNECESSARY);
selections = Map.copyOf(selections);
components = List.copyOf(components);
if ((kind.equals("DISH") && !components.isEmpty())
|| (kind.equals("SET_MEAL") && (components.isEmpty() || !selections.isEmpty()))) {
throw new OrderException(OrderException.Reason.INVALID_INPUT, "套餐快照不合法");
}
}
/** 使用精确十进制金额计算小计. */
public BigDecimal subtotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
/** 套餐固定组成不随原菜品名称或口味修改. */
public record Component(
UUID productId, String name, int quantity, Map<String, String> selections) {
/** 校验组成并固定规格. */
public Component {
Objects.requireNonNull(productId);
if (name == null || name.isBlank() || quantity < 1 || quantity > 99) {
throw new IllegalArgumentException("套餐组成快照不合法");
}
selections = Map.copyOf(selections);
}
}
}
RoundingMode.UNNECESSARY 的意思不是“自动保留两位”,而是“如果必须舍入,就拒绝”。18.501 不能悄悄变成 18.50。
本项目的价格由目录校验后传入订单。这里再次要求精确金额,避免订单模型接受不符合自身金额约定的数据。
5.2 record 并不自动让嵌套集合不可变
record 的字段引用不能重新赋值,但引用指向的 Map 或 List 仍可能是可变集合。
因此需要 Map.copyOf 和 List.copyOf。套餐组成里的规格也要复制,不能只固定最外层列表。
商品名称、金额与地址字段本身都是不可变值;集合被复制后,外部修改原请求集合不会改写订单快照。
5.3 总额来自明细,不来自 App
订单聚合中的实际求和代码:
this.total =
lines.stream()
.map(OrderLine::subtotal)
.reduce(new BigDecimal("0.00"), BigDecimal::add);
当前没有优惠、包装费和配送费,因此总额公式就是:
例如两份 18.50 元的面条加一份 12.00 元的小食,总额为:
金额统一采用 CNY 元。以后加入费用或优惠时,应明确扩展计价规则和快照字段,不能偷偷改变这个求和公式的含义。
5.4 套餐不能只保存一个套餐 ID
套餐以后可能调整组成,所以条目还保存当时各组成菜品的名称、数量和固定规格。
套餐的成交价使用套餐自身的 unitPrice。组成快照用于说明买到了什么,不把各组成菜品的零售价再次累加到套餐总额上。
6. 下单请求表达选择,不表达服务器应当相信的结果
P4 的提交请求只有四个字段:
{
"addressId": "11111111-1111-4111-8111-111111111111",
"addressVersion": 2,
"cartVersion": 4,
"itemIds": [
"22222222-2222-4222-8222-222222222222"
]
}
这些 UUID 仅用于示例,应替换为本人实际地址和购物车条目。
没有 customerId、unitPrice、total、recipientName 或 status:
- 顾客归属来自认证身份。
- 收货资料来自 customer 模块。
- 数量和规格来自指定版本的购物车。
- 价格和商品定义来自 catalog 模块。
- 初始订单状态由领域创建行为决定。
严格 JSON 校验会拒绝这些未定义字段,而不是默默忽略客户端传来的伪造价格。
7. 服务端重验需要一个明确的事务顺序
P4 的 OrderService 使用类级 @Transactional。一次新的建单按照下面的顺序执行:
sequenceDiagram
participant A as 顾客请求
participant O as OrderService
participant C as customer.api
participant S as shop.api
participant K as cart.api
participant M as catalog.api
participant D as PostgreSQL
A->>O: 幂等键、地址版本、购物车版本、条目ID
O->>C: 锁顾客并重验身份
O->>D: 按顾客与幂等键查原订单
alt 已成功提交同一请求
D-->>O: 原订单
O-->>A: 200,原订单当前状态
else 新提交
O->>C: 读取本人指定版本地址
O->>S: 锁定并读取营业状态
O->>K: 验证版本并读取所选条目
O->>M: 锁目录代际,重验并报价
O->>D: 保存订单及明细快照
O->>K: 按原版本移除已结算条目
Note over O,D: 所有数据库变更同事务提交或回滚
O-->>A: 201,新订单
end
这不是多个微服务之间的分布式事务。各模块运行在同一个应用里,使用相同事务管理器;公开 Java 契约可以参与调用方的本地事务。
结算适配服务使用 Propagation.MANDATORY:调用方没有事务就拒绝执行,避免把“保存订单”和“清理购物车”意外拆开提交。
8. 校验地址时,为什么先锁顾客
CustomerCheckoutService 的两个关键方法:
public void lockActive(CustomerIdentity identity) {
addresses.lockOwner(identity.customerId());
authentication.current(identity);
}
public AddressSnapshot address(CustomerIdentity identity, UUID addressId, long version) {
var address = book.get(identity, addressId);
address.requireVersion(version);
return new AddressSnapshot(
address.id(),
address.version(),
address.recipientName(),
address.phone(),
address.province(),
address.city(),
address.district(),
address.detail());
}
lockActive 通过地址仓储端口锁定顾客账号行,再重验账号当前状态与安全版本。
这里复用了 P3 的地址簿协调点:同一顾客的地址写操作也必须先持有该顾客行锁。
因此,在订单持锁复制地址期间,另一个地址修改请求不能提交新的地址内容。addressVersion 又能发现提交请求之前地址就已经发生的变更。
两者解决不同时间段的问题:
| 机制 | 主要保护 |
|---|---|
| 地址版本 | 客户端拿着旧地址快照提交 |
| 顾客行锁 | 地址校验之后、订单提交之前的并发地址修改 |
前提是所有相关写入口都遵守同一锁约定。仅在下单代码里加锁,而地址修改代码绕开这个协调点,就无法得到同样的保证。
9. 门店与目录:读到有效状态后,还要防止它被并发改掉
9.1 门店结算读取使用共享悲观锁
门店仓储的声明:
@Lock(LockModeType.PESSIMISTIC_READ)
Optional<ShopEntity> findLockedById(Integer id);
当前 PostgreSQL/Hibernate 组合下,结算读持有锁直到事务结束,修改同一门店行的写入需要等待。它保护的是门店事实,不是给整个数据库加锁。
普通展示查询 current() 不等于结算查询 forCheckout()。不能因为某个接口“读的是数据库”,就推断它在后续写入发生前一直有效。
9.2 目录使用 P2 已有的修订行作为协调点
结算时锁修订行,但不递增修订号:
public void lockForCheckout() {
revisions.findLockedById(1).orElseThrow();
}
P2 的目录写入也持有该行锁。这样一来,订单报价期间,分类、商品和套餐组成不会被遵守该协议的目录写事务并发改写。
为什么不递增?因为订单结算没有改变目录,不应产生新的缓存代际。
9.3 校验套餐时还要验证组成菜品
public QuotedProduct quote(UUID productId, Map<String, String> selections) {
repository.lockForCheckout();
var product = catalog.availableProduct(productId);
requireSelections(product, selections);
var components = new ArrayList<ComponentSnapshot>();
for (var component : product.components()) {
var dish = catalog.availableProduct(component.dishId());
repository.product(dish.id()).orElseThrow().validateSelections(component.selections());
components.add(
new ComponentSnapshot(
dish.id(), dish.name(), component.quantity(), component.selections()));
}
return new QuotedProduct(product, components);
}
这段代码直接读取当前可售商品,并对组成菜品再次检查可售性和固定口味。
requireSelections 对菜品复用 MenuProduct.validateSelections,没有在订单里重写一套“哪些口味有效”的规则。套餐自己的规格映射必须为空,组成里的规格由目录定义。
9.4 锁带来的收益也有成本
目录修订锁会串行化共享该目录的结算与目录编辑。这是当前单店、最多 50 个结算条目下采用的明确取舍。
它不应被描述为适用于所有规模的“最佳方案”。更大规模可以研究版本化报价或更细粒度协调,但必须重新证明跨分类、商品、套餐的校验仍然一致。
本阶段事务中没有支付、图片存储等外部网络调用,有助于控制锁的持有时间。
10. 幂等键:不要只防双击,还要处理响应丢失
10.1 最危险的重试发生在服务器已经成功之后
sequenceDiagram
participant A as App
participant O as 订单服务
participant D as 数据库
A->>O: Key=checkout-a,购物车版本4
O->>D: 保存订单O1并结算购物车
D-->>O: 提交成功,购物车版本5
O--xA: 响应在网络中丢失
A->>O: 原Key、原请求再次提交
O->>D: 查到Key对应O1
O-->>A: 返回O1,而不是新建O2
仅在按钮上做防抖不能解决这个问题;响应丢失也不是非法请求。
10.2 请求先规范化,再计算摘要
以下是 SubmitCommand 的实际实现:
public record SubmitCommand(
UUID addressId, long addressVersion, long cartVersion, List<UUID> itemIds) {
/** 校验选择集合,并固定请求供事务重试比较. */
public SubmitCommand {
if (addressId == null
|| addressVersion < 0
|| cartVersion < 0
|| itemIds == null
|| itemIds.isEmpty()
|| itemIds.size() > 50
|| itemIds.stream().anyMatch(Objects::isNull)
|| new HashSet<>(itemIds).size() != itemIds.size()) {
throw new OrderException(OrderException.Reason.INVALID_INPUT, "下单标识、版本或条目集合不合法");
}
itemIds = itemIds.stream().sorted().toList();
}
/** 固定字段边界和排序,避免 JSON 字段或条目顺序导致错误幂等冲突. */
String fingerprint() {
return Hashing.sha256()
.hashString(
addressId + ":" + addressVersion + ":" + cartVersion + ":" + itemIds,
StandardCharsets.UTF_8)
.toString();
}
}
选择集合不能为空、不能包含空元素或重复 UUID,也不能超过 50 条。随后按 UUID 排序,消除条目顺序对同一意图比较的影响。
摘要可以概念化为:
真正代码使用固定字段顺序、分隔符和排序后的列表字符串。它不是通用 JSON 规范化算法;如果未来增加自由文本或新字段,需要重新检查摘要编码是否无歧义。
SHA-256 在这里用于比较请求内容,不是请求签名,也不能证明顾客身份。授权仍由独立身份校验完成。
10.3 查重必须早于重新读取购物车
提交方法前半部分如下,摘自真实方法;后面的新建分支在下一节继续:
customers.lockActive(identity);
var fingerprint = command.fingerprint();
var previous = orders.findSubmitted(identity.customerId(), key);
if (previous.isPresent()) {
var order = previous.orElseThrow();
order.requireSameRequest(fingerprint);
return new Submission(OrderViews.detail(order), true);
}
如果先检查购物车版本,成功提交后的原请求一定会被新版本挡住;如果先查地址,已删除地址也会阻止正常重放。
已经成功处理的提交,应该返回它自己的业务结果。当前门店、目录、地址和购物车是否变化,不再决定这次重试能不能查到原订单。
但当前身份仍要有效,所以顾客重验在幂等查重之前。
10.4 两个请求都查不到记录时怎么办
数据库还提供唯一约束:
CONSTRAINT ordering_submit_key UNIQUE (customer_id, idempotency_key)
只做“查不到就插入”仍然有竞争。P4 在查重之前锁定已经存在的顾客行,同一顾客的两个提交会顺序执行。
第二个事务在第一个提交之后查询,就能够读到原订单并返回重放结果。若第一笔回滚,第二笔看到的仍然是一个尚未成功使用的键。
唯一约束负责兜底;顾客行锁则让正常并发重试可以得到明确的同一业务结果。这个锁来自数据库,不依赖某一个 JVM 的 synchronized。
10.5 重放响应不是永久冻结的第一次 JSON
| 场景 | 结果 |
|---|---|
| 同顾客、同键、同规范化请求 | 同一个订单 ID,返回当前订单状态 |
| 同顾客、同键、不同内容 | 409 ORDER_IDEMPOTENCY_CONFLICT |
| 两个顾客使用同样的键 | 各自独立,不冲突 |
| 原请求回滚 | 没有占用成功结果,可重试 |
| 原订单后来已取消 | 返回原来的已取消订单,不生成新订单 |
响应丢失后,客户端应保留原幂等键和原请求重试。若决定采用新版本的购物车提交另一个意图,应生成新的键。
11. 把通过重验的数据转成订单快照
下面展示提交方法中新建订单的关键片段,省略前面的幂等处理和部分逐字段映射:
var address = customers.address(
identity, command.addressId(), command.addressVersion());
if (!shop.forCheckout().status().equals("OPEN")) {
throw new OrderException(OrderException.Reason.SHOP_CLOSED, "门店已打烊");
}
var selected = carts.selected(
identity, command.cartVersion(), command.itemIds());
var lines = new ArrayList<OrderLine>();
for (var item : selected) {
var quote = catalog.quote(item.productId(), item.selections());
var product = quote.product();
lines.add(new OrderLine(
UUID.randomUUID(), product.id(), product.kind(), product.name(),
product.price(), item.quantity(), item.selections(),
quote.components().stream()
.map(component -> new OrderLine.Component(
component.productId(), component.name(),
component.quantity(), component.selections()))
.toList()));
}
var order = Order.submit(
identity.customerId(), key, fingerprint,
new AddressSnapshot(
address.id(), address.version(), address.recipientName(), address.phone(),
address.province(), address.city(), address.district(), address.detail()),
lines, clock.instant());
orders.add(order);
carts.settle(identity, command.cartVersion(), command.itemIds());
每个模块的结果被转换成 ordering 自己的值对象。它们不是共享的可变聚合,也不把其他模块的 ORM 实体挂到订单对象上。
保存顺序是先订单、后结算。这个顺序只有放在同一个事务里才安全:结算失败时,前面已经 flush 的订单也必须回滚。
12. 购物车结算隔离:只删所选条目还不够
12.1 聚合先验证选择集合
public List<CartItem> selected(List<UUID> itemIds) {
if (itemIds == null
|| itemIds.isEmpty()
|| itemIds.size() > 50
|| new java.util.HashSet<>(itemIds).size() != itemIds.size()) {
throw new CartException(CartException.Reason.INVALID_INPUT, "结算条目必须为 1 至 50 个不同标识");
}
var selected = items.stream().filter(item -> itemIds.contains(item.id())).toList();
if (selected.size() != itemIds.size()) {
throw new CartException(CartException.Reason.NOT_FOUND, "结算条目不存在");
}
return selected;
}
所有选择必须属于当前购物车。不能在“筛选结果只有两条”时,忽略请求中第三个不存在的 ID 然后继续建单。
这会把客户端的错误选择悄悄变成另一张订单。
12.2 结算行为只移除已验证的条目
public ShoppingCart settle(List<UUID> itemIds, Instant now) {
selected(itemIds);
return new ShoppingCart(
customerId,
items.stream().filter(item -> !itemIds.contains(item.id())).toList(),
version,
now);
}
这与 P3 的 clear() 不同:顾客可以只结算部分条目,其他条目要保留。
当前按所选条目的完整数量结算,不支持同一条目的部分数量结算。
12.3 为什么同时还要检查整个购物车版本
考虑这一竞争:
| 时刻 | 提交订单的事务 A | 另一设备的事务 B |
|---|---|---|
| T1 | 读取购物车版本 4,条目 X 有 2 份 | |
| T2 | 按 2 份建立订单快照 | 把 X 增加到 3 份并提交版本 5 |
| T3 | 试图移除 X 并保存版本 4 |
如果 A 只根据条目 ID 删除 X,就会把 B 新增的那一份一起删除。
P4 不尝试在这个时刻推测应当扣除几份,而是拒绝整个陈旧结算。已提交的 B 保留,A 的订单随事务回滚。
结算应用服务的核心实现:
public long settle(CustomerIdentity identity, long version, List<UUID> itemIds) {
authorization.requireActive(identity);
var cart = cart(identity);
cart.requireVersion(version);
carts.update(cart.settle(itemIds, clock.instant()));
return cart(identity).version();
}
仓储最终还会依赖 Hibernate 的 @Version 执行数据库级并发检查。应用层 requireVersion() 与 ORM 版本条件不能互相替代。
即使同一持久化上下文里仍持有之前读到的版本 4,数据库更新使用的旧版本条件也会在 B 已提交版本 5 后失败。
这里保证的是“并发加购不会被误删”。并不是“并发加购与原下单一定可以同时成功”。P4 的选择是让陈旧下单整体失败,客户端读取新状态后重新表达意图。
13. JPA 负责存取,但历史快照不提供更新入口
订单实体把地址嵌入自身,把明细映射为模块内部关联。关键映射片段:
@Entity(name = "CustomerOrder")
@Table(name = "ordering_order")
public class OrderEntity {
@Id UUID id;
UUID customerId;
String idempotencyKey;
String requestFingerprint;
@Embedded AddressValue address;
@OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST)
@OrderBy("position ASC")
List<OrderLineEntity> lines = new ArrayList<>();
@Column(precision = 16, scale = 2)
BigDecimal total;
@Enumerated(EnumType.STRING)
Order.Status status;
@Version Long version;
Instant createdAt;
Instant cancelledAt;
// 创建映射、领域重建及 ORM 构造器省略。
}
AddressValue 是持久化模型,AddressSnapshot 是领域值对象,HTTP 又使用独立的 OrderViews.Address。三种类型分别服务于 ORM、业务规则和接口协议。
P4 的生命周期更新方法只有:
void applyState(Order value) {
status = value.status();
cancelledAt = value.cancelledAt();
}
名称、价格、规格和地址只在创建映射里复制,不进入后续状态更新。
这里说的不可变,是由领域模型及应用写入路径保护的历史语义。它并不等于数据库管理员无法执行 SQL 修改历史列;项目没有声称使用了不可篡改账本。
数据库如何兜底
V5 的关键约束摘录:
-- ordering_order 的部分定义
id uuid PRIMARY KEY,
customer_id uuid NOT NULL,
total numeric(16,2) NOT NULL CHECK (total > 0),
status varchar(20) NOT NULL CHECK (status IN ('UNPAID', 'CANCELLED')),
version bigint NOT NULL CHECK (version >= 0),
CONSTRAINT ordering_submit_key UNIQUE (customer_id, idempotency_key)
-- ordering_line 的部分定义
order_id uuid NOT NULL REFERENCES ordering_order(id),
product_id uuid NOT NULL,
quantity integer NOT NULL CHECK (quantity BETWEEN 1 AND 99),
UNIQUE (order_id, position)
这是 DDL 摘录,不是可以单独执行的建表脚本。
明细指向本模块订单的外键是允许的;customer_id、product_id 和地址源标识没有跨模块外键。运行时通过公开契约验证当前事实,历史记录则靠自己的快照继续存在。
14. 详情与历史列表不必使用同一种加载策略
详情要展示地址和条目,仓储查询使用实体图一次加载明细:
@EntityGraph(attributePaths = "lines")
Optional<OrderEntity> findByCustomerIdAndId(UUID customerId, UUID id);
历史列表只需要摘要,直接使用构造投影:
/** 历史派生查询的构造投影,只选择摘要列,不加载地址或订单实体. */
record OrderSummaryValue(
UUID id,
Order.Status status,
BigDecimal total,
long version,
Instant createdAt,
Instant cancelledAt) {
OrderPage.Summary domain() {
return new OrderPage.Summary(id, status, total, version, createdAt, cancelledAt);
}
}
Spring Data 声明:
Page<OrderSummaryValue> findByCustomerId(UUID customerId, Pageable pageable);
有几个容易混淆的细节:
- 列表不需要加载地址和明细,不要为复用详情 DTO 而读取全部聚合。
- 不把集合实体图和分页随意组合,避免订单行与明细行的数量被混淆。
- 当前排序是
createdAt DESC, id DESC,相同创建时间仍有稳定次序。 - 这仍然是偏移分页,不保证并发插入时多个翻页请求共享同一个固定快照。
open-in-view=false,详情映射在事务内完成,JSON 序列化不再偷偷查库。
“写入通过聚合维护规则”和“列表使用投影提高查询针对性”可以同时成立,不需要把所有读取都包装成完整聚合。
15. 未支付取消与再来一单,是两个不同的用例
15.1 P4 的取消只允许 UNPAID
public void cancel(long expectedVersion, Instant now) {
requireVersion(expectedVersion);
if (status != Status.UNPAID) {
throw new OrderException(OrderException.Reason.STATE_CONFLICT, "只有待付款订单可以取消");
}
if (now.isBefore(createdAt)) {
throw new IllegalArgumentException("取消时间早于订单创建时间");
}
status = Status.CANCELLED;
cancelledAt = Objects.requireNonNull(now);
}
版本检查在状态检查之前。因此旧版本取消首先表现为版本冲突;使用最新版本再次取消已取消订单,则表现为状态冲突。
取消只改订单状态和取消时刻,不自动恢复购物车。否则,顾客后来对购物车做过的选择可能被一张旧订单影响。
15.2 再来一单不是把历史订单直接复制为新订单
OrderService.reorder() 的职责是把旧订单条目转换成购物车选择,再交给 cart 模块原子合并。
购物车重新加购的实际实现:
public long reorder(CustomerIdentity identity, long version, List<Selection> items) {
authorization.requireActive(identity);
if (items.isEmpty() || items.size() > 50) {
throw new CartException(CartException.Reason.INVALID_INPUT, "重新加购条目数量不合法");
}
var current = carts.findByCustomer(identity.customerId());
var cart = current.orElseGet(() -> ShoppingCart.empty(identity.customerId(), clock.instant()));
cart.requireVersion(version);
for (var item : items) {
var product = catalog.quote(item.productId(), item.selections()).product();
cart =
cart.add(
new CartItem(
UUID.randomUUID(),
product.id(),
product.kind(),
product.name(),
product.price(),
item.quantity(),
item.selections()),
item.quantity(),
clock.instant());
}
if (current.isPresent()) {
carts.update(cart);
} else {
carts.add(cart);
}
return cart(identity).version();
}
重点看循环里的 catalog.quote():使用当前商品和价格,而不是旧订单的 unitPrice。
假设旧订单中面条成交价为 18.50 元,现在是 23.00 元,再来一单后的购物车按 23.00 元展示;原订单仍保持 18.50 元。
任一条目失效、口味无效、同规格合并后超过 99 份,或不同条目超过 50 个,都使本次重新加购整体失败。
代码先在领域快照上完成全部合并,再统一保存。它不会先把第一条保存成功,再跳过失败的第二条。
再来一单不要求门店正在营业,也不复制旧地址。它只是帮助顾客重新准备购物车;再次提交订单时,仍要按本篇全部规则重验。
16. 用五个 HTTP 操作把交互接起来
| 方法与路径 | 成功语义 |
|---|---|
POST /api/v1/orders |
新建 201;同键重放 200 |
GET /api/v1/orders/{id} |
本人订单详情 |
GET /api/v1/orders?page=0&size=20 |
本人历史,单页最多 50 条 |
POST /api/v1/orders/{id}/cancellation |
当前版本的未支付取消 |
POST /api/v1/orders/{id}/reorder |
按当前目录加回购物车,返回新 cartVersion |
一个可以复现的操作顺序
首先按 P3 创建顾客会话、本人地址和购物车,按 P2 让门店营业并准备可售商品。
然后提交:
POST /api/v1/orders
Authorization: Bearer <顾客令牌>
Idempotency-Key: tutorial-checkout-001
Content-Type: application/json
{
"addressId": "替换为本人地址UUID",
"addressVersion": 0,
"cartVersion": 1,
"itemIds": ["替换为购物车条目UUID"]
}
版本值也要替换成实际读到的值,不能只照抄示例。
保留完全相同的键和请求再提交一次,观察订单 ID 不变,响应从 201 变为 200,Idempotency-Replayed 为 true。
接着修改地址或商品,再读取原订单,观察历史快照保持不变。若要修改商品,仍需遵循 P2 的下架编辑规则。
最后读取订单版本再取消,然后读取最新订单和购物车版本执行重新加购:
POST /api/v1/orders/<订单UUID>/reorder
Authorization: Bearer <顾客令牌>
Content-Type: application/json
{
"version": 1,
"cartVersion": 2
}
返回的新购物车版本用于后续编辑;它不是新订单号,也不意味着新订单已经提交。
当前 P5 仓库已经扩展了取消语义。要在现有代码体验本篇的直接未支付取消,应选择尚未创建支付意图的订单;有支付意图时的处理将在下一篇解释。
17. 测试要证明失败后留下了什么
下面的真实数据库测试很适合用来理解事务边界:
void failedTransactionRollsBackOrderIdempotencyAndCartSettlementTogether() {
var cart = add(1, "微辣", 0);
var request = command(cart.version(), List.of(cart.items().getFirst().id()));
assertThatThrownBy(
() ->
transactions.executeWithoutResult(
status -> {
orders.submit(customer, "rollback", request);
throw new IllegalStateException("模拟提交失败");
}))
.isInstanceOf(IllegalStateException.class);
assertThat(orderCount()).isZero();
assertThat(carts.get(customer).version()).isEqualTo(cart.version());
assertThat(carts.get(customer).items()).hasSize(1);
assertThat(orders.submit(customer, "rollback", request).replayed()).isFalse();
assertThat(orderCount()).isEqualTo(1);
}
测试故意在订单与购物车结算完成后抛出异常,再检查:
- 没留下订单。
- 购物车版本和条目恢复到本事务之前。
- 原幂等键没有被失败事务占用。
- 再次提交可以正常创建一张订单。
这里检查的不是方法有没有被调用,而是数据库最后保存了什么。
其他关键测试包括:
| 场景 | 预期业务事实 |
|---|---|
| 两个同键同请求并发提交 | 一次创建、一次重放,只有一张订单 |
| 不同键抢同一购物车版本 | 最多一次结算成功 |
| 报价前后发生并发加购 | 陈旧订单回滚,新加购保留 |
| 只选择部分条目 | 其他条目保留 |
| 地址、门店、商品或规格失效 | 不建单、不消费购物车 |
| 下单后地址/商品被删除 | 历史详情仍完整 |
| 重新加购的后续条目失败 | 不留下前面已经合并的部分结果 |
| 他人订单或员工令牌访问顾客资源 | 归属与身份边界生效 |
P4 提交验收记录为全量 92 项测试通过,新增 3 项订单领域测试、16 项数据库集成测试,以及 5 个订单 HTTP 操作。这是阶段验收记录,不是本次编写文章时重新运行的结果。
18. 把实现边界和思考题一起留下
P4 已经能保证一张未支付订单的创建、查询、取消与再次加购。但它还没有支付、履约、自动超时、库存预占、优惠分摊或配送范围计算。
当前服务端会采用结算时的目录价,并没有增加“估价已变化,需要顾客再次确认”的二阶段报价流程。若产品以后要求价格变化必须重新确认,需要设计明确报价版本或确认协议,不能只把旧购物车价格当作可信条件。
练习一:为什么不能先检查购物车,再判断幂等
尝试写出“创建成功但响应丢失”的第二次请求会经历什么。参考思路:购物车已经结算,版本也已经推进;同一次购买意图的正常重试不应因此丢失原结果。
练习二:如果允许同一条目只结算一部分数量
当前 itemIds 是否还足够?请补充输入模型,并说明并发加购与并发减量时,应该拒绝整个请求还是精确扣减。不要只把删除改成减法而忽略版本语义。
练习三:能不能只保留商品 ID,不保存商品名称
假设商品已删除,历史订单还要展示成交内容。请分别说明实时查询模型与历史快照的依赖方向。
练习四:目录锁太繁忙时怎么办
考虑保存报价版本,或为订单建立有期限的报价。重新设计之前,先列出需要同时验证的分类、菜品、口味与套餐事实,并说明任何一项变化时如何检测。
练习五:订单保存成功但支付请求失败
P4 的本地事务方案能直接延伸到外部支付宝网关吗?如果 HTTP 超时,数据库回滚是否能撤销已经发生的支付?
下一篇将从最后这个问题继续:把确定的购买约定交给真实支付渠道,并在重复通知、超时和退款竞争中保留正确的业务事实。
延伸阅读
系列:Han Menu 外卖系统实践 · 从第一篇开始