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

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

系列:Han Menu 外卖系统实践 · P2

面向读者:能阅读 Java 类、接口和 Spring Boot Controller,希望理解 DDD、JPA 与业务一致性的开发者。

本篇依据 P2 提交 3c0bdd9 编写。代码直接展示关键实现,省略导入、只读访问器或无关方法的片段不作为独立完整文件。后续阶段能力会明确标注。

1. “增加一个菜品管理功能”,到底增加了什么

完成员工身份模块后,管理员终于能登录系统了。接下来要维护菜单:分类、菜品、口味、套餐、图片,以及门店的营业状态。

如果只把需求理解为增删改查,代码会变成几组 Controller 和 Repository:保存分类,保存菜品,修改上下架字段,上传图片。

真正的问题往往出现在第二次修改需求时:

  • 分类停用了,其中的商品还能出售吗?
  • 套餐由两份菜品组成,任意一个菜品停售,套餐还能上架吗?
  • 套餐已固定选择“微辣”,管理员能直接删除这款菜品的“微辣”选项吗?
  • 菜品刚下架,为什么顾客手机上还是旧菜单?
  • 图片上传成功,数据库保存失败,这张图片属于谁?
  • 商家打烊了,顾客还能不能浏览菜品?

P2 从这些问题出发,把商品目录与门店经营落实为两个业务模块。本阶段实现商品管理、图片上传、公开菜单与门店资料,顾客身份、购物车、下单不在本篇阶段范围。移动端计划使用 Flutter,当前提供的是后端能力。

2. 从业务规则出发,而不是先画数据库关系图

先用自然语言记录规则,再决定对象和表。

业务规则 涉及什么模型 谁负责判断
商品价格必须为正数,最多精确到分 金额 值对象
菜品有口味组,套餐有组成菜品 可售商品 商品聚合
在售商品不能直接修改内容 商品状态与资料 商品聚合
商品分类必须与商品种类匹配 商品、分类 应用用例协调仓储检查
套餐上架时,组成菜品全部可售 套餐、菜品、分类 跨聚合校验
在售套餐引用的菜品不能下架 菜品及套餐引用 跨聚合校验
店铺电话和地址未填写,不能营业 门店 门店聚合

有些规则只需要对象自己的状态,有些规则需要读取其他对象。

Money 不需要访问数据库就能知道金额是否为负数;套餐是否能上架,却需要知道它所引用的菜品当前状态。

不要让一个对象为了“负责全部业务规则”而直接查询数据库。模型封装自身规则,应用服务组织多个模型协作。

3. 为什么目录和门店是两个模块

“商品正在销售”和“门店正在营业”都影响能否购买,但表达的是不同事实。

  • 菜品上架:运营人员决定这款商品可以出现在销售目录。
  • 门店营业:商家目前接受订单。

门店打烊时,菜单仍可以展示。后续提交订单时,才需要同时检查门店和商品是否满足下单条件。

catalog:维护商品目录、定价、口味、套餐和图片
shop:维护单店资料与营业状态

模块边界不需要跟页面一一对应。一个页面可以同时读取门店信息和菜单,但不能由此推导出二者必须放进同一个聚合。

两个模块内部都延续 P1 的分层方式:

catalog/
├── api/                         对外的目录查询契约与快照
├── domain/                      Category、MenuProduct 及值对象
├── application/                 目录管理、公开查询、图片用例
├── infrastructure/
│   ├── persistence/             JPA 映射和仓储适配器
│   ├── RedisCatalogCache        缓存端口实现
│   └── S3ImageStorage           对象存储端口实现
└── web/
    ├── admin/                   管理员维护入口
    └── app/                     匿名浏览入口

“代码放在哪个包”的判断依据仍然是职责:金额规则属于领域,缓存序列化属于技术实现,上传流程属于应用用例。

P2 通过身份模块的公开接口验证管理员:

@ApplicationModule(
    displayName = "商品目录",
    allowedDependencies = {"identity :: api"})
package com.hanserwei.hanmenu.catalog;

import org.springframework.modulith.ApplicationModule;
public interface StaffAuthorization {
  void requireAdministrator(StaffIdentity identity);
}

应用服务接收 StaffIdentity,通过 StaffAuthorization 重新确认账号、角色和安全版本。商品模块没有读取员工 JPA 实体或身份表。调用方构造了一份身份快照,也不自动意味着授权有效。

4. 菜品和套餐:什么时候可以用一个聚合类型

4.1 本项目当前的选择

P2 使用 MenuProduct 表达可售商品:

public enum ProductKind {
  DISH,
  SET_MEAL
}

菜品和套餐共享这些行为:属于一个分类,有名称、描述、售价和可选图片,创建后先下架,修改内容前需要下架,上下架和修改需要版本检查。

区别体现在组成:

DISH:包含口味组,不包含套餐组成
SET_MEAL:包含组成菜品,不直接定义自己的口味组

当前使用一个聚合类型,加显式种类约束,没有立即建立复杂的继承体系。如果将来套餐有独立促销、动态组合和计价公式,两个模型的变化方向已经不同,就可以重新评估是否拆分聚合。

是否复用类,要看共享的业务语义,而不是字段相似度。

4.2 把非法组合挡在模型内部

以下片段省略赋值、空值、文本长度及重复项等检查,集中展示状态与种类约束:

public void revise(
    UUID categoryId,
    String name,
    String description,
    Money price,
    UUID imageId,
    List<FlavorGroup> flavors,
    List<MealComponent> components) {
  if (onSale) {
    throw new CatalogException(
        CatalogException.Reason.CONFLICT, "请先下架再编辑商品");
  }

  if (kind == ProductKind.DISH && !components.isEmpty()
      || kind == ProductKind.SET_MEAL
          && (!flavors.isEmpty() || components.isEmpty())) {
    throw new CatalogException(
        CatalogException.Reason.INVALID_INPUT,
        "菜品不能含套餐明细,套餐必须有明细且不直接定义口味");
  }

  this.flavors = List.copyOf(flavors);
  this.components = List.copyOf(components);
}

List.copyOf 可以避免调用方之后从外部修改传入列表,绕过聚合规则。

完整实现还限制最多 10 个口味组、50 个套餐明细,并防止重复口味组和重复组成菜品。一个集合有值,并不表示集合整体已经满足业务约束。

5. 金额、口味与套餐组成:用值对象缩小出错空间

5.1 金额不能随意变成一个 double

商品金额要求精确到分,因此使用 BigDecimal

public record Money(BigDecimal amount) {
  public Money {
    if (amount == null
        || amount.signum() <= 0
        || amount.compareTo(new BigDecimal("999999.99")) > 0) {
      throw new CatalogException(
          CatalogException.Reason.INVALID_INPUT,
          "价格须在 0.01 至 999999.99 元之间");
    }
    try {
      amount = amount.setScale(2, RoundingMode.UNNECESSARY);
    } catch (ArithmeticException exception) {
      throw new CatalogException(
          CatalogException.Reason.INVALID_INPUT, "价格最多支持两位小数");
    }
  }
}

RoundingMode.UNNECESSARY 表示如果需要舍入,就报错。

输入 结果
18 规范化为 18.00
18.50 接受
18.500 可精确缩为 18.50,接受
18.501 需要舍入,拒绝
0 或负数 拒绝

“最多两位小数”在这里指不允许丢失有效精度,并非机械拒绝所有 scale 大于 2 的输入。

创建固定十进制值时用 new BigDecimal("18.50"),避免先转为二进制浮点数再引入精度差异。

这个 Money 位于商品模块,只表示正的人民币售价。它不适用于“允许为零的总价”或“负数调整”等全部金额场景,不必急着搬进全局 common 包。

5.2 口味不是一段随意拼接的字符串

“微辣,少盐”如果只存为一个字符串,就很难判断哪些选项必填,也难以校验客户端提交的规格。

当前口味模型是一组单选项:

public record FlavorGroup(String name, List<String> options, boolean required) {
  public FlavorGroup {
    name = CatalogText.required(name, 30, "口味名称");
    if (options == null || options.isEmpty() || options.size() > 20) {
      throw new CatalogException(
          CatalogException.Reason.INVALID_INPUT, "每个口味组须有 1 至 20 个选项");
    }
    options = options.stream()
        .map(value -> CatalogText.required(value, 30, "口味选项"))
        .toList();
    if (new HashSet<>(options).size() != options.size()) {
      throw new CatalogException(
          CatalogException.Reason.INVALID_INPUT, "口味选项不能重复");
    }
  }
}

HashSet 会去重,因此比较去重前后数量可以发现重复项。检查发生在文本规范化之后,避免带首尾空白的“微辣”被当成新选项。

stream().map(...).toList() 表示逐项转换后生成结果列表,不会替你自动完成业务校验;规则仍需明确写出。

5.3 套餐引用菜品标识和已确定的选择

public record MealComponent(
    UUID dishId, int quantity, Map<String, String> selections) {
  public MealComponent {
    if (dishId == null
        || quantity < 1
        || quantity > 99
        || selections == null
        || selections.size() > 10) {
      throw new CatalogException(
          CatalogException.Reason.INVALID_INPUT, "套餐明细不合法");
    }
    selections = Map.copyOf(selections);
  }
}

上面省略规格文本长度检查。一份明细可以表示两碗面、固定选择微辣。

它能判断数量不合法,却不能仅凭自身判断 dishId 是否存在、菜品是否提供微辣。外部事实需要用例协调检查,不能在值对象构造器里偷偷访问数据库。

6. 套餐上架为什么不是一次字段更新

套餐上架至少需要满足:分类启用、组成项是真实菜品、菜品和分类可售、固定规格合法、所选图片存在。

应用服务完成检查后,才改变销售状态:

public CatalogViews.ProductView changeSale(
    StaffIdentity actor, UUID id, boolean onSale, long version) {
  begin(actor);
  var value = product(id);
  value.requireVersion(version);

  if (onSale) {
    validate(value, true);
  } else if (value.kind() == ProductKind.DISH && repository.dishUsed(id, true)) {
    conflict("请先下架引用此菜品的套餐");
  }

  value.changeSale(onSale);
  repository.saveProduct(value, false);
  return CatalogMapper.product(product(id));
}

套餐组成的检查会调用菜品自己的规格校验行为:

for (var component : value.components()) {
  var dish = product(component.dishId());
  dish.validateSelections(component.selections());
  if (publishing && (!dish.onSale() || !category(dish.categoryId()).enabled())) {
    conflict("套餐中的菜品未上架");
  }
}

当前 MenuProduct.changeSale 本身只修改销售状态,并不掌握其他聚合的全部信息。P2 把跨聚合校验集中在应用服务,并要求写入从这些用例进入。

未来规则复杂后,可以抽取目录领域策略,或让聚合接收已经验证的销售条件。当前不能声称“直接调用任何领域方法都能自动保证跨聚合约束”。

7. 单行乐观锁解决不了所有竞争

P1 已引入 @Version。这是否足以保证套餐上架正确?

时刻 操作甲:上架套餐 操作乙:下架菜品
1 读取菜品,发现已上架 读取引用套餐,发现尚未上架
2 校验通过 校验通过
3 更新套餐行 更新菜品行
4 套餐在售 组成菜品已下架

两事务更新的是不同商品行。各自版本检查可能都成功,最终却破坏“在售套餐的组成菜品必须可售”的规则。

这类问题通常称为写偏差:事务分别依据读取结果做决定,写入不同对象,组合状态不再合法。

7.1 当前单店采用目录写锁

目录后台写入低频,P2 选择直接方案:所有分类和商品写事务先锁定同一条目录修订记录。

interface RevisionRecords extends JpaRepository<RevisionEntity, Integer> {
  @Lock(LockModeType.PESSIMISTIC_WRITE)
  Optional<RevisionEntity> findLockedById(Integer id);
}

PESSIMISTIC_WRITE 请求数据库写锁。它不是 JVM 内的 synchronized,多个应用实例连接同一个数据库时也能遵守这份锁协议。

@Override
public long advanceRevision() {
  var value = revisions.findLockedById(1).orElseThrow();
  value.revision++;
  revisions.flush();
  return value.revision;
}

写用例一开始执行:

private void begin(StaffIdentity actor) {
  authorization.requireAdministrator(actor);
  repository.advanceRevision();
}

外层事务持有锁直到提交或回滚。另一目录写事务等待后,再根据最新事实判断。

sequenceDiagram
  participant A as 上架套餐事务
  participant L as 目录修订行
  participant B as 下架菜品事务
  A->>L: 获取写锁
  B->>L: 申请同一写锁,等待
  A->>A: 校验组成并上架套餐
  A->>L: 提交并释放锁
  L-->>B: 获得写锁
  B->>B: 发现已被在售套餐引用
  B->>L: 拒绝下架并回滚

如果下架事务先拿锁,顺序反过来:菜品下架成功,套餐上架被拒绝。

7.2 代价与适用范围

目录写操作被串行化,方案简单但限制写吞吐量。对当前单店后台合适,不能直接视为大型多店平台的最终设计。

未来可以按门店拆锁或选择更细粒度的约束,但每个方案都要证明竞争条件仍受保护。

所有参与相关约束的写操作都必须遵守相同锁协议。直接改表,不会因为数据库里存在修订行就自动获得这种保护。

8. 同一修订号还可以解决缓存旧值回填

8.1 删除过缓存,为什么还是可能出现旧值

一次旧查询读完数据库,但尚未写入缓存:

  1. 查询甲读到商品旧价。
  2. 管理员乙修改价格并删除缓存。
  3. 查询甲继续,把旧价写回缓存。
  4. 后续顾客再次读到旧价。

8.2 给缓存加上目录代际

公开列表缓存键包含修订号、商品种类、分类、页号和页大小:

String key =
    repository.revision()
        + ":" + kind
        + ":" + categoryId
        + ":" + page
        + ":" + size;

商品更新与修订号推进同事务提交或回滚。旧查询使用 41,新修改提交后修订号为 42:

flowchart LR
  Old[旧查询:修订号 41] --> K41[缓存键 41:筛选:分页]
  Write[业务修改与修订号一起提交] --> R42[当前修订号 42]
  R42 --> New[新查询]
  New --> K42[缓存键 42:筛选:分页]

旧查询迟到回填,也只能写入 41 代。提交后读取到 42 的查询不会再使用它。旧键通过两分钟 TTL 自然过期,不需要 KEYS * 扫描删除。

8.3 缓存不是业务真相来源

方案没有承诺所有在途读取都看到最新提交;读取可能在修改之前开始。它解决的是旧回填污染后续新代际。

缓存命中仍有一次轻量数据库修订号查询。这是以成本换取明确的一致性边界。

未来购物车、订单使用的单品可售查询直接读数据库,不依赖公开列表缓存。缓存不保存图片签名 URL,避免短期链接先于缓存失效。

Redis 故障时公开目录回源数据库:

@Override
public Optional<String> get(String key) {
  try {
    return Optional.ofNullable(redis.opsForValue().get(prefix + key));
  } catch (DataAccessException ignored) {
    return Optional.empty();
  }
}

对比 P1:登录限流依赖 Redis,故障时拒绝登录。两种处理的区别来自业务语义,而非开发者习惯。

缓存失效后查数据库,仍能判断商品事实;限流失效后直接放行,则绕过了保护规则。

9. JPA 集合映射与分页需要一起设计

口味组是规模受限的值集合,当前使用 JSONB;套餐明细需要引用与外键约束,使用关联表。

@JdbcTypeCode(SqlTypes.JSON)
@Column(nullable = false, columnDefinition = "jsonb")
List<FlavorGroup> flavors;

@ElementCollection
@CollectionTable(
    name = "catalog_meal_component",
    joinColumns = @JoinColumn(name = "meal_id"))
@OrderColumn(name = "position")
List<ComponentValue> components = new ArrayList<>();

@JdbcTypeCode 是基础设施层的 Hibernate 映射注解,名称含 JDBC 不表示业务直接操作连接或手写存取 SQL。

@ElementCollection 表达没有独立聚合生命周期的值集合。组成明细属于套餐,不能脱离套餐单独发布。

9.1 集合关联行不等于商品数量

一份套餐有五个明细,关联查询可能返回五行。直接在集合 fetch join 上分页,数据库行数与想分页的商品数并不相同。

当前分两步:先确定本页商品,再抓取本页集合。

var result = products.findAll(
    filters,
    PageRequest.of(page, size,
        Sort.by("createdAt").descending().and(Sort.by("id"))));

var ids = result.getContent().stream().map(entity -> entity.id).toList();
if (!ids.isEmpty()) {
  products.findByIdIn(ids);
}

return new ProductPage(
    result.getContent().stream().map(ProductEntity::domain).toList(),
    result.getTotalElements());

第二次查询明确抓取组成集合:

@EntityGraph(attributePaths = "components")
List<ProductEntity> findByIdIn(List<UUID> ids);

两次查询在同一事务和持久化上下文中执行,JPA 维护实体身份一致性。第二次抓取的关联因此可以用于本页实体后续的映射。

这避免每个商品再查一次,也避免直接对扩张后的集合行分页。具体查询数量要通过测试证明,不能仅看到 @EntityGraph 就宣布已经解决性能问题。

10. 图片上传面对两个不同的存储系统

数据库保存图片 ID、对象键、类型、大小、创建时间,RustFS 私有桶保存字节。两者不共享同一个本地数据库事务。

10.1 文件名不能证明文件类型

P2 检查实际内容:字节数非零且不超过 5 MiB,PNG/JPEG 格式,边长不超过 4096,像素总量不超过 1600 万,并真实解码。

服务端生成唯一对象键,不信任文件扩展名或浏览器 Content-Type。

压缩字节很小的文件可能解码为巨大位图,因此字节限制与像素限制各有职责。

10.2 上传不占用数据库长事务

以下省略授权与图片检查,保留存储协调:

UUID id = UUID.randomUUID();
String key = prefix + id + (type.equals("image/png") ? ".png" : ".jpg");
var image = new CatalogImage(id, key, type, bytes.length, clock.instant());

try {
  storage.put(key, bytes, type);
  transactions.executeWithoutResult(status -> repository.addImage(image));
} catch (RuntimeException failure) {
  try {
    storage.delete(key);
  } catch (RuntimeException ignored) {
    LOGGER.warn("image_cleanup_required imageId={}", id);
  }
  throw failure;
}

上传完成后,用短事务存元数据。失败时尽量删除本次独立对象。

flowchart TD
  Start[校验图片并生成对象键] --> Store[上传私有对象存储]
  Store --> Save[短事务保存元数据]
  Save -->|成功| Done[返回图片资源ID]
  Save -->|失败| Delete[补偿删除本次对象]
  Store -->|失败| Delete
  Delete --> Fail[返回失败]
  Delete -->|补偿失败| Log[记录待清理资源ID]

补偿不等于分布式原子事务。进程在上传后崩溃,可能根本来不及执行 catch。当前有失败记录,尚未实现后台自动垃圾回收,不能把“有补偿删除”解释为“绝无孤立对象”。

10.3 访问权限与图片生命周期

管理员可以获取已上传图片的五分钟签名链接。匿名获取链接要求图片已被在售商品引用。

数据库保存对象键,不保存临时链接。发出的链接在过期前仍可能访问,下架不承诺瞬间撤销所有旧链接。图片也可能被商品共享,删除商品不自动删除图片。

Flutter 真机的 127.0.0.1 是手机自己。联调时要区分后端访问存储的地址和客户端可达的签名地址,这需要实际网络配置。

11. 门店聚合表达“怎样才能营业”

单店初始状态为 CLOSED,资料完整后才能营业:

public void changeOpen(boolean value) {
  if (value && (phone.isBlank() || address.isBlank())) {
    throw new ShopException(
        ShopException.Reason.CONFLICT, "请先配置联系电话和地址再营业");
  }
  open = value;
}

编辑营业中的店铺,也不能清空联系电话和地址。同一个不变量需要在所有可能破坏它的入口维护。

营业状态保存在 PostgreSQL。清空 Redis,不应让门店忘记自己已营业。当前没有营业时间段、配送范围、费用或库存,这些都留给明确的后续需求。

12. 让 API 对应业务操作

能力 代表接口 返回含义
创建分类 POST /api/v1/catalog/categories 新分类
创建商品 POST /api/v1/catalog/products 默认下架的新商品
上下架 PATCH /api/v1/catalog/products/{id}/status 操作后的快照与版本
上传图片 POST /api/v1/catalog/images 图片元数据
浏览目录 GET /api/v1/menu/products 仅公开可售商品
修改营业状态 PATCH /api/v1/shop/status 门店快照与版本
公开门店 GET /api/v1/storefront 当前店铺信息

创建一道菜的请求:

{
  "kind": "DISH",
  "details": {
    "categoryId": "替换为菜品分类UUID",
    "name": "番茄鸡蛋面",
    "description": "现做面食",
    "price": 18.50,
    "imageId": null,
    "flavors": [
      {"name": "辣度", "options": ["不辣", "微辣"], "required": true}
    ],
    "components": []
  }
}

随后需要显式上架:

{"status":"ON_SALE","version":0}

版本 0 仅代表刚创建且未经其他修改的示例。实际必须读取最新版本。

P2 的更新返回 200 和新快照,便于客户端继续操作。P1 部分更新返回 204;根据是否返回资源表示选择状态码,是明确契约选择,并非强制所有更新使用同一个返回值。

13. 测试证明的是最终业务事实

领域测试验证精度、口味、非法套餐和门店规则,不需要 Spring。数据库集成测试验证引用、版本、事务以及跨聚合竞争;缓存和对象存储还需要真实服务测试。

上架套餐与下架菜品的并发测试应同时启动两个事务,得到一个成功、一个冲突,再检查最终数据库满足:

\operatorname{mealOnSale} \Rightarrow \operatorname{dishOnSale}

只断言出现过 409 不够,最终组合状态才是业务规则的证据。

缓存测试验证修订号回滚、损坏快照回源、Redis 失联降级。ORM 测试观察查询数量,防止分页形成 N+1。

图片测试实际上传合法文件、获取签名链接,并模拟元数据失败验证补偿。测试使用专用桶和随机前缀,只清理自己创建的对象。

P2 提交中的验收记录是 53 项测试通过。本文写作没有重新运行测试,也不把图形渲染视为业务验证。

14. 从规则到代码的设计顺序

P2 的过程可以概括为:识别金额与规格约束,建立值对象;识别销售状态行为,建立聚合;识别外部事实,设计用例协调;识别并发竞争,明确锁协议;识别多个存储系统,设计失败补偿。

读者练习

套餐自动下架。 如果需求改为“下架菜品时自动下架相关套餐”,哪个用例应该协调?事务要覆盖哪些修改?为什么不能只改一个布尔值?

缓存代际。 修订号已提交为 12,但旧查询正在写入 11 代缓存。后续查询使用哪个键?旧查询自身返回的是不是一定为最新状态?

图片清理。 上传后进程退出,catch 未执行。设计孤立对象清理时,如何避免误删仍被商品引用的文件?

聚合演化。 套餐开始支持动态组合计价,你会继续使用统一商品聚合还是拆开?先列出改变的行为,再决定类结构。

下一篇讨论顾客身份、默认地址与购物车:重点是资源属于谁、集合规则怎样保护,以及购物车为何不能决定最终支付金额。

延伸阅读

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

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