从零重构外卖系统(三):商品、套餐与营业状态,怎样从 CRUD 走向业务建模
系列:Han Menu 外卖系统实践 · 从第一篇开始
系列: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 删除过缓存,为什么还是可能出现旧值
一次旧查询读完数据库,但尚未写入缓存:
- 查询甲读到商品旧价。
- 管理员乙修改价格并删除缓存。
- 查询甲继续,把旧价写回缓存。
- 后续顾客再次读到旧价。
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。数据库集成测试验证引用、版本、事务以及跨聚合竞争;缓存和对象存储还需要真实服务测试。
上架套餐与下架菜品的并发测试应同时启动两个事务,得到一个成功、一个冲突,再检查最终数据库满足:
只断言出现过 409 不够,最终组合状态才是业务规则的证据。
缓存测试验证修订号回滚、损坏快照回源、Redis 失联降级。ORM 测试观察查询数量,防止分页形成 N+1。
图片测试实际上传合法文件、获取签名链接,并模拟元数据失败验证补偿。测试使用专用桶和随机前缀,只清理自己创建的对象。
P2 提交中的验收记录是 53 项测试通过。本文写作没有重新运行测试,也不把图形渲染视为业务验证。
14. 从规则到代码的设计顺序
P2 的过程可以概括为:识别金额与规格约束,建立值对象;识别销售状态行为,建立聚合;识别外部事实,设计用例协调;识别并发竞争,明确锁协议;识别多个存储系统,设计失败补偿。
读者练习
套餐自动下架。 如果需求改为“下架菜品时自动下架相关套餐”,哪个用例应该协调?事务要覆盖哪些修改?为什么不能只改一个布尔值?
缓存代际。 修订号已提交为 12,但旧查询正在写入 11 代缓存。后续查询使用哪个键?旧查询自身返回的是不是一定为最新状态?
图片清理。 上传后进程退出,catch 未执行。设计孤立对象清理时,如何避免误删仍被商品引用的文件?
聚合演化。 套餐开始支持动态组合计价,你会继续使用统一商品聚合还是拆开?先列出改变的行为,再决定类结构。
下一篇讨论顾客身份、默认地址与购物车:重点是资源属于谁、集合规则怎样保护,以及购物车为何不能决定最终支付金额。
延伸阅读
系列:Han Menu 外卖系统实践 · 从第一篇开始