系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第4篇 · 下一篇:第6篇

系列: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_addresscart_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[收货资料已隐藏]";
  }
}

这里保存 sourceIdsourceVersion,便于说明快照来自哪条地址的哪个版本。同时完整复制收货字段,避免订单详情依赖地址簿里的当前内容。

例如:

提交时:地址 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 的字段引用不能重新赋值,但引用指向的 MapList 仍可能是可变集合。

因此需要 Map.copyOfList.copyOf。套餐组成里的规格也要复制,不能只固定最外层列表。

商品名称、金额与地址字段本身都是不可变值;集合被复制后,外部修改原请求集合不会改写订单快照。

5.3 总额来自明细,不来自 App

订单聚合中的实际求和代码:

this.total =
    lines.stream()
        .map(OrderLine::subtotal)
        .reduce(new BigDecimal("0.00"), BigDecimal::add);

当前没有优惠、包装费和配送费,因此总额公式就是:

A_{order}=\sum_{i=1}^{n}p_iq_i

例如两份 18.50 元的面条加一份 12.00 元的小食,总额为:

18.50\times2+12.00\times1=49.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 仅用于示例,应替换为本人实际地址和购物车条目。

没有 customerIdunitPricetotalrecipientNamestatus

  • 顾客归属来自认证身份。
  • 收货资料来自 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 排序,消除条目顺序对同一意图比较的影响。

摘要可以概念化为:

F=\operatorname{SHA256}(addressId,addressVersion,cartVersion,\operatorname{sort}(itemIds))

真正代码使用固定字段顺序、分隔符和排序后的列表字符串。它不是通用 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_idproduct_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);
}

测试故意在订单与购物车结算完成后抛出异常,再检查:

  1. 没留下订单。
  2. 购物车版本和条目恢复到本事务之前。
  3. 原幂等键没有被失败事务占用。
  4. 再次提交可以正常创建一张订单。

这里检查的不是方法有没有被调用,而是数据库最后保存了什么。

其他关键测试包括:

场景 预期业务事实
两个同键同请求并发提交 一次创建、一次重放,只有一张订单
不同键抢同一购物车版本 最多一次结算成功
报价前后发生并发加购 陈旧订单回滚,新加购保留
只选择部分条目 其他条目保留
地址、门店、商品或规格失效 不建单、不消费购物车
下单后地址/商品被删除 历史详情仍完整
重新加购的后续条目失败 不留下前面已经合并的部分结果
他人订单或员工令牌访问顾客资源 归属与身份边界生效

P4 提交验收记录为全量 92 项测试通过,新增 3 项订单领域测试、16 项数据库集成测试,以及 5 个订单 HTTP 操作。这是阶段验收记录,不是本次编写文章时重新运行的结果。

18. 把实现边界和思考题一起留下

P4 已经能保证一张未支付订单的创建、查询、取消与再次加购。但它还没有支付、履约、自动超时、库存预占、优惠分摊或配送范围计算。

当前服务端会采用结算时的目录价,并没有增加“估价已变化,需要顾客再次确认”的二阶段报价流程。若产品以后要求价格变化必须重新确认,需要设计明确报价版本或确认协议,不能只把旧购物车价格当作可信条件。

练习一:为什么不能先检查购物车,再判断幂等

尝试写出“创建成功但响应丢失”的第二次请求会经历什么。参考思路:购物车已经结算,版本也已经推进;同一次购买意图的正常重试不应因此丢失原结果。

练习二:如果允许同一条目只结算一部分数量

当前 itemIds 是否还足够?请补充输入模型,并说明并发加购与并发减量时,应该拒绝整个请求还是精确扣减。不要只把删除改成减法而忽略版本语义。

练习三:能不能只保留商品 ID,不保存商品名称

假设商品已删除,历史订单还要展示成交内容。请分别说明实时查询模型与历史快照的依赖方向。

练习四:目录锁太繁忙时怎么办

考虑保存报价版本,或为订单建立有期限的报价。重新设计之前,先列出需要同时验证的分类、菜品、口味与套餐事实,并说明任何一项变化时如何检测。

练习五:订单保存成功但支付请求失败

P4 的本地事务方案能直接延伸到外部支付宝网关吗?如果 HTTP 超时,数据库回滚是否能撤销已经发生的支付?

下一篇将从最后这个问题继续:把确定的购买约定交给真实支付渠道,并在重复通知、超时和退款竞争中保留正确的业务事实。

延伸阅读

系列:Han Menu 外卖系统实践 · 从第一篇开始

上一篇:第4篇 · 下一篇:第6篇