从零重构外卖系统(四):顾客身份、默认地址与购物车,怎样设计归属和一致性
系列:Han Menu 外卖系统实践 · 从第一篇开始
系列:Han Menu 外卖系统实践 · P3
面向读者:已经理解类、接口、事务和基本 DDD 分层,希望学会从真实交互设计聚合与模块协作的开发者。
本篇依据 P3 提交
19cf3af编写,并衔接 P2 的商品目录。代码直接展示实际关键实现;省略导入、构造器或无关方法的片段不是独立完整文件。P3 完成的是服务端顾客认证、资料、地址簿和购物车。Flutter 工程、短信认证、订单及支付宝沙箱支付尚未实现。
1. 菜单已经能看了,接下来需要解决什么
上一阶段,管理员可以维护商品,匿名顾客可以浏览菜单。接下来顾客需要注册、登录、填写地址和添加购物车。
表面上又是几组增删改查,但业务问题已经开始围绕“属于谁”和“多个请求一起发生时怎么办”展开:
- 管理员的令牌能不能直接访问顾客购物车?
- 知道别人的地址 UUID,是否就能修改那条地址?
- 两个请求同时设置默认地址,会不会出现两条默认?
- 同一道菜选择不同辣度,应合并成一条还是两条?
- 添加成功但响应丢失,App 重试会不会重复加购?
- 商品改价后,购物车显示旧价还是新价?
- 菜品已经下架,购物车应该报错、删除条目,还是保留提示?
本篇沿着这些问题,把模块边界、身份、集合规则、并发控制和展示模型连起来。
2. 划分模块前,先确认每块业务拥有什么
P3 涉及三个已经存在的业务边界:
| 模块 | 拥有什么事实 | 不应该拥有的决定权 |
|---|---|---|
customer |
顾客账号、顾客会话、本人资料和地址归属 | 菜品是否可售、商品当前价格 |
cart |
某位顾客选了什么商品、规格和数量 | 顾客凭证是否合法、商品最终成交价格 |
catalog |
商品当前定义、价格、口味与可售状态 | 顾客选了几份、购物车怎样合并 |
购物车需要顾客授权和商品信息,因此只开放两条编译依赖:
@ApplicationModule(
displayName = "购物车",
allowedDependencies = {"catalog :: api", "customer :: api"})
package com.hanserwei.hanmenu.cart;
import org.springframework.modulith.ApplicationModule;
flowchart LR
Cart[cart.application] --> Auth[customer.api 顾客授权]
Cart --> Catalog[catalog.api 实时商品查询]
Cart --> Port[cart.domain 仓储端口]
Adapter[cart.infrastructure JPA适配器] --> Port
箭头表示代码依赖,不是 HTTP 调用。模块运行在同一个应用里,公开接口是普通 Java 契约,不需要为了“像微服务”而互相请求 localhost。
P3 同时扩展商品查询契约:写用例需要“必须存在的可售商品”,展示用例则需要“找不到时可以返回空”。这两种需求稍后会在购物车展示中用到。
3. 顾客与员工都能登录,为什么不直接共用员工模型
P1 的员工账号承担后台权限。顾客账号承担浏览之后的个人资料和购买行为。
即使同一个人既是店员又是顾客,两种身份也不能因为手机号相同,就自动获得彼此的权限。
3.1 当前把顾客身份放在 customer 模块
P3 没有建立一个所有人共用的“超级 User”。顾客模块独立拥有:
customer/
├── api/ 顾客身份与授权契约
├── domain/ 账号、地址、密码及端口
├── application/ 注册登录、资料、地址簿用例
├── infrastructure/
│ ├── persistence/ 顾客账号、会话、地址的 JPA 实现
│ ├── BcryptCustomerPasswordHasher
│ └── RedisCustomerAttemptLimiter
└── web/ 顾客控制器、安全链与错误适配
基础设施里出现与员工模块相似的代码,并不表示应立即合并业务模型。认证原语有可能共享,但需要先证明共享的是稳定技术机制,而不是把不同主体的生命周期和权限强行捆绑。
当前顾客使用 hmc_ 令牌,员工使用 hme_。二者分别查询自己的会话表、账号状态和安全版本。
前缀只是类型识别手段,真正的权限隔离来自独立安全链、主体类型、会话存储和业务授权,不能只靠字符串开头不同。
3.2 Spring Security 怎样选择顾客安全链
配置核心片段如下,省略过滤器注册和错误响应处理:
@Bean
@Order(0)
SecurityFilterChain customerSecurity(HttpSecurity http) throws Exception {
return http
.securityMatcher(
"/api/v1/customer/**", "/api/v1/cart", "/api/v1/cart/**")
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(rules -> rules
.requestMatchers(
HttpMethod.POST,
"/api/v1/customer/accounts",
"/api/v1/customer/sessions")
.permitAll()
.anyRequest()
.hasRole("CUSTOMER"))
.build();
}
这是讲解路由匹配的片段,完整配置还禁用 Form/Basic/Cookie 会话认证,加入顾客认证过滤器、追踪信息和统一错误响应。
securityMatcher 决定这条安全链接管哪些请求。@Order(0) 让它先于通用员工链匹配。Spring Security 会选择第一条匹配的链,并不是把全部安全链依次拼起来。
因此:
| 请求 | 使用的身份 | 结果 |
|---|---|---|
| 顾客路径 + 顾客令牌 | 有效顾客主体 | 继续按归属授权 |
| 顾客路径 + 员工令牌 | 不属于顾客认证体系 | 401 |
| 后台路径 + 顾客令牌 | 不属于员工认证体系 | 401 |
| 匿名浏览公开菜单 | 不需要认证 | 允许读取 |
公开菜单仍允许匿名访问。Flutter 即使习惯性携带顾客 token,也不应该因为请求进入了员工过滤器而被误判为登录失败;P3 对这一交互做了相应处理。
4. 手机号加密码:它究竟验证了什么
当前个人项目不接入短信平台,采用手机号标识加密码。注册与登录分开:注册创建账号,登录创建会话。
4.1 规范化不等于验证号码归属
手机号规范化方法:
public static String normalizePhone(String value) {
String normalized = Objects.requireNonNull(value, "手机号不能为空").strip();
if (!normalized.matches("\\+?[1-9][0-9]{6,14}")) {
throw new CustomerException(
CustomerException.Reason.INVALID_INPUT, "手机号格式不正确");
}
return normalized.startsWith("+") ? normalized : "+" + normalized;
}
这里完成的是:去除首尾空白,检查数字形式,把有无开头加号规范为相同表示。
例如 13800138000 和 +13800138000 被视为同一标识。它不会猜测国家区号,也不能证明注册者拥有该号码。需要区号时,客户端应明确提交完整号码。
这是当前登录标识约定,不是一个完整国际电话号码验证系统。
因为没有短信验证,P3 不提供“输入手机号即可找回密码”或任意修改手机号的能力。不能把一个尚未证明的号码归属,当作重置账号密码的依据。
4.2 注册同时需要应用检查与数据库约束
@Transactional
public CustomerAccount register(
String phone,
String displayName,
CustomerPassword password,
String clientAddress) {
String normalized = CustomerAccount.normalizePhone(phone);
limiter.check(normalized, clientAddress);
if (customers.findByPhone(normalized).isPresent()) {
throw new CustomerException(
CustomerException.Reason.CONFLICT, "手机号已注册");
}
var customer = CustomerAccount.create(
UUID.randomUUID(), normalized, displayName,
passwords.encode(password), clock.instant());
customers.add(customer);
return customer;
}
“先查有没有”可以给正常重复注册明确反馈,却不能独自阻止两个事务同时注册。数据库里手机号的唯一约束负责最后一道竞争保护。
密码为 8—64 个字符、UTF-8 不超过 72 字节,BCrypt 工作因子为 12;顾客会话默认 24 小时,没有自动刷新 token。这里是顾客策略,不要误套 P1 员工的密码长度和八小时会话约定。
顾客注册、登录使用自己的 Redis 限流键前缀。它们共享顾客配额,但不与员工登录计数混合。Redis 不可用时返回 503,不能绕过限流。
5. 地址归属必须出现在数据读取条件里
“已经登录”只说明我们知道请求者是谁,不说明他能读取任意地址。
5.1 不让客户端提交资源所有者
创建地址时,所属顾客从 CustomerIdentity 取得,不从 JSON 的 customerId 取得。
读取指定地址时,也用顾客标识和地址标识一起查:
private DeliveryAddress own(UUID customerId, UUID id) {
return addresses.findByCustomerAndId(customerId, id)
.orElseThrow(() -> new CustomerException(
CustomerException.Reason.NOT_FOUND, "地址不存在"));
}
对应 Spring Data 查询形状:
Optional<AddressEntity> findByCustomerIdAndId(UUID customerId, UUID id);
这样一来,即使请求者知道别人的地址 UUID,也匹配不到自己的资源。
不存在地址和他人地址统一返回 404,避免通过差异响应探测某个地址是否真实存在。
5.2 地址为什么是实体
顾客把“公司地址”的收货人改了,还是同一条地址;它有稳定 UUID、所属顾客、版本和生命周期。
地址实体封装内容修改、设为默认、取消默认和版本检查,但自身并不知道同一顾客的其他地址状态。
public void makeDefault(Instant now) {
defaultAddress = true;
updatedAt = Objects.requireNonNull(now);
}
public void clearDefault(Instant now) {
defaultAddress = false;
updatedAt = Objects.requireNonNull(now);
}
这两个方法只负责单个实体的状态。“其他地址必须取消默认”则需要地址簿用例协调。
虽然存在 AddressBookService,当前代码并没有一个把全部地址装进内存的 AddressBook 聚合类。我们应按真实实现解释:单个地址有自己的实体与版本,地址集合的约束由应用服务、顾客行锁和数据库索引共同维护。
DDD 概念是分析工具,不必为了词汇完整强行增加一个空的聚合类型。
6. 默认地址:一道集合规则如何落地
规则是“每位顾客最多一个默认地址”,不是“每位顾客必须有一个默认地址”。
P3 允许没有默认地址。删除默认地址后,也不会猜测应当把其他哪一条设成默认。
6.1 两个默认设置请求可以同时发生
如果请求甲设置地址 A 为默认,请求乙设置地址 B 为默认,两者分别操作不同地址行,仅检查各行的 @Version 并不能保证最终只有一个默认。
这和 P2 的套餐上架竞争类似:单行规则满足,不代表集合规则满足。
P3 的解法是按当前顾客串行化地址簿变更。
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<CustomerEntity> findLockedById(UUID id);
所有地址写入先锁顾客账号行,再检查地址集合。顾客 A 和顾客 B 的变更可以并发,同一顾客的地址写入则有共同协调点。
为什么不只锁目标地址?因为新增地址尚不存在,也因为两个请求可能选择不同地址;顾客行在这些操作前已经存在。
6.2 切换用例的顺序很重要
public DeliveryAddress makeDefault(
CustomerIdentity identity, UUID id, long version) {
authentication.current(identity);
addresses.lockOwner(identity.customerId());
var address = own(identity.customerId(), id);
address.requireVersion(version);
addresses.clearDefault(identity.customerId(), address.id());
address.makeDefault(clock.instant());
addresses.update(address);
return own(identity.customerId(), id);
}
外层应用服务使用数据库事务,锁持有到事务结束。
sequenceDiagram
participant App as Flutter请求
participant Service as 地址簿用例
participant DB as PostgreSQL
App->>Service: 目标地址ID与版本
Service->>DB: 锁定当前顾客行
Service->>DB: 按归属读取目标地址
Service->>Service: 校验目标地址版本
Service->>DB: 清除原默认并flush
Service->>DB: 保存新默认地址
DB-->>Service: 同事务提交
Service-->>App: 当前地址及新版本
6.3 部分唯一索引是数据库兜底
CREATE UNIQUE INDEX customer_address_default_idx
ON customer_address (customer_id)
WHERE is_default;
它只把 is_default=true 的行加入唯一索引,因此同一顾客不能有两条默认地址,普通地址则不受这份唯一性限制。
为了避免事务中间状态触发唯一约束,先 flush 清除旧默认,再保存新默认:
@Override
public void clearDefault(UUID customerId, UUID exceptId) {
for (var entity : records.findByCustomerIdAndDefaultAddressTrue(customerId)) {
if (!entity.id.equals(exceptId)) {
entity.defaultAddress = false;
entity.updatedAt = clock.instant();
}
}
records.flush();
}
flush 不是提交。后续写入失败,清除原默认的操作也随事务回滚。
这里修改的是托管实体,Hibernate 负责脏检查和版本递增。被自动取消默认的地址也会推进自己的版本,因此旧页面不能悄悄把状态覆盖回来。
6.4 容量限制也属于同一集合边界
每个顾客最多 20 条地址。如果没有共同锁,两次并发创建都可能看到 19 条,然后各自新增,最终变成 21 条。
因此创建也要在持有顾客行锁之后判断容量:
addresses.lockOwner(identity.customerId());
if (addresses.findByCustomer(identity.customerId()).size() >= 20) {
throw new CustomerException(
CustomerException.Reason.CONFLICT, "地址簿最多 20 条地址");
}
当前最多 20 条,直接读取集合具有明确上界。若以后规模扩大,可以改为计数查询,但锁和判断顺序仍然需要保持。
7. 购物车是一个聚合,不是任意操作的明细表
顾客加购之后,需要同时保护条目合并、数量上限、容量和并发版本。
ShoppingCart
├── customerId
├── version
├── updatedAt
└── items
├── CartItem A:商品 X,微辣,2 份
└── CartItem B:商品 X,不辣,1 份
当前一个顾客只有一个购物车,使用顾客 UUID 作为购物车标识。条目由购物车统一变更,不存在跳过聚合随意改某条数量的公共仓储接口。
7.1 CartItem 使用 record,不代表它没有身份
条目有自己的 ID,用于修改和删除;同时包含不可变规格快照:
public record CartItem(
UUID id,
UUID productId,
String productKind,
String productName,
BigDecimal unitPrice,
int quantity,
Map<String, String> selections) {
public boolean sameSelection(
UUID otherProductId, Map<String, String> otherSelections) {
return productId.equals(otherProductId) && selections.equals(otherSelections);
}
public CartItem changeQuantity(int replacement) {
return new CartItem(
id, productId, productKind, productName,
unitPrice, replacement, selections);
}
}
片段省略构造器的数量、空值和价格检查。完整实现要求数量 1—99,并通过 Map.copyOf 固定规格集合。
Java 的 record 是语言上的数据表示方式,不会自动决定 DDD 分类。这里条目有身份,但用不可变记录表达每次状态快照;不能看到 record 就一律称为“没有身份的值对象”。
7.2 合并依据是商品和规格内容,不是条目 ID
下列两次选择应该合并:
{"辣度":"微辣","餐具":"需要"}
{"餐具":"需要","辣度":"微辣"}
Map.equals 比较键值映射内容,不要求遍历顺序一致。条目 ID 则是一次创建时分配的标识,不能拿它判断是否为同一种选择。
可以把合并关系写成:
不同口味仍保留独立条目,即使商品相同。
8. 集合行为写在购物车里
下面是加购的核心实现:
public ShoppingCart add(CartItem item, int amount, Instant now) {
var next = new ArrayList<>(items);
int index = -1;
for (int i = 0; i < next.size(); i++) {
if (next.get(i).sameSelection(item.productId(), item.selections())) {
index = i;
break;
}
}
if (index >= 0) {
var old = next.get(index);
int quantity = old.addQuantity(amount).quantity();
next.set(index, new CartItem(
old.id(), item.productId(), item.productKind(), item.productName(),
item.unitPrice(), quantity, item.selections()));
} else {
next.add(item.changeQuantity(amount));
}
return new ShoppingCart(customerId, next, version, now);
}
找到相同选择时,保留原条目 ID,增加数量,同时使用本次从目录获得的名称与价格快照。不同选择则建立新条目。
购物车构造时限制最多 50 个不同规格条目;条目自身限制数量不超过 99。因此“加入第 51 种规格”和“同规格累计到 100 份”由不同规则拒绝。
方法返回新的 ShoppingCart 快照,不改变外部可见的原集合。读取条目时也返回不可修改列表:
public List<CartItem> items() {
return List.copyOf(items);
}
本项目 P1 的账号聚合采用受控的内部修改,购物车采用返回新快照的方式。两者都可以封装行为;面向对象并不要求所有聚合必须选择同一种可变性策略。
POST 和 PATCH 的数量语义不能混淆
- POST 加购表示“再增加 N 份”。
- PATCH 表示“把这个条目改成 N 份”。
- 数量 0 使用删除接口,不暗中解释为清空。
接口和领域方法应当保持一致,否则客户端一次重试很容易把加法误当成赋值。
9. 第一次加购的版本为什么必须从 0 变成 1
9.1 读取空购物车不应创建记录
GET 空购物车时,当前返回一个临时空快照:版本 0、空条目,不写数据库。
这样不会因为有人打开页面就创建无意义的购物车行。
第一次添加必须带版本 0,并在成功后返回版本 1。原因是第一次写入同样需要防止重复应用。
9.2 一个容易漏掉的网络重试场景
- App 读取空购物车,版本为 0。
- App 提交“增加两份面”,版本为 0。
- 数据库保存成功,但响应在网络中丢失。
- App 以相同版本 0 重试。
如果第一次创建后的数据库版本也为 0,重试可能再次通过检查,数量变成四份。
P3 的 JPA 适配器先在同一个事务中创建空实体,再应用本次变更:
@Override
public void add(ShoppingCart cart) {
var entity = records.saveAndFlush(
CartEntity.create(
ShoppingCart.empty(cart.customerId(), Instant.EPOCH)));
entity.apply(cart);
records.flush();
}
新实体版本先由 Hibernate 初始化,随后 updatedAt 与条目发生变化,第二次 flush 推进版本。两个步骤在同一事务里,没有对外提交一个半成品空车。
版本时间线如下:
| 状态 | 客户端应携带版本 | 数据库存储结果 |
|---|---|---|
| 尚无持久化购物车 | 0 | 没有记录 |
| 第一次加购成功 | 请求带 0 | 版本 1 |
| 重试第一次请求 | 仍带 0 | 冲突,不能再次增加 |
| 下一次正常变更 | 使用响应里的 1 | 继续推进 |
两个首次请求同时创建时,顾客 ID 主键保证只有一份购物车能成功提交;另一个事务收到冲突,而不是覆盖已有内容。
9.3 这和幂等键不是同一个机制
版本控制防止陈旧重试重复修改,它通常返回 409,不会自动返回第一次成功请求的完整响应。
这不同于“同幂等键返回同一业务结果”的幂等请求协议。Flutter 遇到超时或版本冲突,应先重新获取购物车,而不是自行增加版本号后盲目重发。
下单或支付是否需要单独的幂等键,属于后续阶段的用例设计,不能因为购物车有版本就认为所有重复提交问题已经解决。
10. 清空购物车为什么不能重置版本
假设设备 A 拿着版本 5,设备 B 清空后又加入了新条目。如果清空通过删除购物车主记录实现,新车又从版本 0 开始,客户端很难可靠区分先后生命周期。
P3 清空只移除条目,保留购物车主体和版本。它表达的是“同一购物车内容变空”,不是“这个资源从未存在”。
public ShoppingCart clear(Instant now) {
return new ShoppingCart(customerId, List.of(), version, now);
}
注意此处领域快照仍持有加载时的版本。真正的数据库新版本由 Hibernate 在持久化变更时维护,用例随后重新读取并返回当前快照。
前端应该读取响应版本,不能假定每次版本恰好加一。无实际变化的写入也可能不产生新的持久化版本。
11. JPA 怎样维护聚合内的条目
购物车内部有一对多关系:
@OneToMany(
mappedBy = "cart",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
@OrderBy("position ASC")
List<CartItemEntity> items = new ArrayList<>();
cascade让聚合内条目的持久化跟随购物车。orphanRemoval表示从聚合集合中移除的持久化条目需要删除。position保持展示顺序。- 关联限定在
cart模块内部。
映射时按条目 ID 对已有集合做协调:删除不再需要的条目,更新仍然存在的条目,添加新条目。它不会为了方便,每次把原条目全部删掉再新建。
读取单个购物车可以抓取整个有界集合:
interface CartRecords extends JpaRepository<CartEntity, UUID> {
@Override
@EntityGraph(attributePaths = "items")
Optional<CartEntity> findById(UUID customerId);
}
这里没有商品分页加集合 fetch 的问题,因为一次读取的是一个购物车,且最多 50 个条目。
购物车表仅保存顾客 UUID,条目仅保存商品 UUID。没有跨模块外键,也没有到 CustomerEntity、ProductEntity 的 JPA 关联;存在性与状态通过公开契约检查。
“没有跨模块外键”不等于允许忽略完整性,而是把责任放在清楚的协作入口和后续生命周期规则中。
12. 购物车怎样使用商品模块而不污染领域模型
应用服务的依赖说明了协作范围:
public CartService(
CartRepository carts,
CatalogQuery catalog,
CustomerAuthorization authorization,
Clock clock) {
this.carts = carts;
this.catalog = catalog;
this.authorization = authorization;
this.clock = clock;
}
公开授权接口只有一个明确职责:
public interface CustomerAuthorization {
void requireActive(CustomerIdentity identity);
}
加购时的关键顺序:
authorization.requireActive(identity);
var product = catalog.availableProduct(productId);
requireSelections(product, selections);
var current = carts.findByCustomer(identity.customerId());
var cart = current.orElseGet(
() -> ShoppingCart.empty(identity.customerId(), clock.instant()));
cart.requireVersion(version);
var item = new CartItem(
UUID.randomUUID(), product.id(), product.kind(), product.name(),
product.price(), quantity, selections);
var next = cart.add(item, quantity, clock.instant());
客户端提交的是商品标识、数量、规格和版本。名称、价格、商品种类都来自目录契约,不接受客户端的自报值。
cart.domain 不引用 catalog.domain.Money 或商品聚合。跨模块结果使用公开 DTO,其中金额是 BigDecimal。两个模块有相似的业务词,并不意味着应该直接共享内部对象。
规格校验也发生在应用服务:它持有目录公开的口味快照,可以判断必选项是否缺失、选项是否有效,而购物车聚合专注条目合并和数量。
13. 为什么保存快照后,读取时还要查实时目录
购物车需要处理商品下架、删除、改价和规格调整。
完全只存商品 ID,会导致商品删除后连“我之前加的是什么”都显示不出来;完全只存快照,则可能继续展示已失效的价格和可售状态。
P3 采用展示快照加实时校验:
| 当前商品情况 | 条目名称和价格 | available | 估算总价是否包含 |
|---|---|---|---|
| 可售,规格有效 | 使用实时目录 | true | 包含 |
| 已下架或删除 | 保留原展示快照 | false | 不包含 |
| 可售,但原规格失效 | 仍可获得当前名称和价格 | false | 不包含 |
| 改价后重新上架 | 使用当前价格 | 取决于规格 | 合法时包含 |
无效条目可以删除,但不能继续添加或修改数量。P3 当前规则对数量增加和减少都重新验证可售性;如果未来希望允许减少失效条目的数量,应明确修改用例规则。
13.1 展示允许资源失效,但不能吞掉所有系统错误
公开契约中的 findAvailableProduct 对真正不存在或不可售的商品返回 Optional.empty()。数据库故障不应伪装成“商品下架”,否则客户端会收到错误的业务事实。
相同商品的不同规格在一次请求内复用目录查询结果:
var products = new HashMap<UUID, Optional<CatalogViews.ProductView>>();
var product = products.computeIfAbsent(
item.productId(), catalog::findAvailableProduct);
这是单次请求内的复用,不是全局缓存,也不保证所有不同商品只用一次数据库查询。最大 50 个条目让当前工作量有上界;以后有性能数据时,可以为目录公开接口增加批量查询能力。
13.2 估算金额不是成交金额
展示小计为单价乘数量,总价只累计合法条目:
BigDecimal total = items.stream()
.filter(ItemView::available)
.map(ItemView::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
数学上可以写为:
estimatedTotal 不包含未来尚未实现的配送费、优惠或结算政策。它不是支付请求可以直接采用的金额。
P4 下单时必须重新检查顾客地址、门店状态、商品、规格与价格,生成订单自己的成交快照。展示查询也不是锁住整个目录获得的永久报价;商品在展示后改变完全可能发生。
14. 把这些规则映射为 App 接口
P3 新增 17 个操作,按资源分组理解更容易:
| 资源 | 代表接口 | 主要语义 |
|---|---|---|
| 顾客账号 | POST /api/v1/customer/accounts |
注册,不隐式登录 |
| 顾客会话 | POST /api/v1/customer/sessions |
登录,返回顾客 token |
| 当前会话 | DELETE /api/v1/customer/sessions/current |
撤销当前令牌 |
| 本人资料 | GET/PUT /api/v1/customer/me |
查询、带版本修改昵称 |
| 本人密码 | PUT /api/v1/customer/me/password |
校验当前密码并撤销旧会话 |
| 本人地址 | GET/POST /api/v1/customer/addresses |
列表与创建 |
| 指定地址 | GET/PUT/DELETE /api/v1/customer/addresses/{id} |
按归属读取、修改、删除 |
| 默认地址 | PATCH /api/v1/customer/addresses/{id}/default |
原子切换 |
| 本人购物车 | GET /api/v1/cart |
当前展示快照 |
| 购物车条目 | POST /api/v1/cart/items |
增加并合并 |
| 指定条目 | PATCH/DELETE /api/v1/cart/items/{id} |
替换数量或删除 |
| 所有条目 | DELETE /api/v1/cart/items |
保留版本地清空 |
创建账号和地址返回 201。加购返回 200 与整个新购物车,因为操作可能合并已有条目,并不总是创建一个独立新资源。
购物车请求 DTO:
record AddItem(
@NotNull UUID productId,
@Min(1) @Max(99) int quantity,
@NotNull @Size(max = 10)
Map<@NotBlank @Size(max = 30) String,
@NotBlank @Size(max = 30) String> selections,
@NotNull @Min(0) Long version) {}
没有 customerId、price、productName。严格 JSON 校验会拒绝这些未定义字段,不允许客户端自行指定归属和价格。
15. 走一遍加购前的完整交互
以下是在已有 P2 在售菜品的前提下进行的手工练习。占位 UUID 与令牌需要替换,示例密码仅用于学习。
第一步:注册并登录
POST /api/v1/customer/accounts
Content-Type: application/json
{
"phone": "+8613800138000",
"displayName": "教程顾客",
"password": "Tutorial-Customer-2026!"
}
POST /api/v1/customer/sessions
Content-Type: application/json
{
"phone": "+8613800138000",
"password": "Tutorial-Customer-2026!"
}
后续请求使用返回的 hmc_ 令牌,不使用管理员 token。
第二步:创建收货地址
POST /api/v1/customer/addresses
Authorization: Bearer <顾客令牌>
Content-Type: application/json
{
"label": "家",
"recipientName": "教程收件人",
"phone": "+8613800138000",
"province": "浙江省",
"city": "杭州市",
"district": "西湖区",
"detail": "示例路 1 号",
"defaultAddress": true
}
创建另一条默认地址,重新获取列表,观察第一条不再是默认,其版本也发生变化。
第三步:先读购物车,再增加数量
GET /api/v1/cart
Authorization: Bearer <顾客令牌>
若当前没有持久化购物车,返回版本 0。用它发起加购:
POST /api/v1/cart/items
Authorization: Bearer <顾客令牌>
Content-Type: application/json
{
"productId": "替换为在售菜品UUID",
"quantity": 2,
"selections": {"辣度":"微辣"},
"version": 0
}
响应为整个新购物车。再次用相同版本重试,应该出现冲突,而不是默默又增加两份。
读取最新版本后,再提交相同商品与规格,观察条目 ID 保留且数量合并;换成另一口味则产生独立条目。
第四步:观察改价与失效
管理员按 P2 规则下架、改价、重新上架商品,再查询购物车,观察展示价格更新。
如果商品下架,条目仍存在,但 available=false,不再计入 estimatedTotal。此时可以删除条目,不能把该条目直接当作有效订单提交。
16. 用测试检查身份、集合和竞争
P3 的测试不是把相同 CRUD 换一组路径再执行,而是关注新出现的边界。
| 场景 | 需要证明什么 |
|---|---|
| 员工和顾客互用 token | 身份体系不能串用 |
| 顾客读取他人地址 | 返回 404,不泄露归属信息 |
| 两次并发创建默认地址 | 最终最多一个默认 |
| 切换默认后事务回滚 | 原默认及其版本恢复 |
| 已有 20 条地址再新增 | 不突破集合容量 |
| 并发首次加购 | 不创建两个购物车,不重复应用旧版本 |
| 并发修改数量 | 不丢失已提交修改 |
| 同商品不同规格 | 保留独立条目 |
| 伪造价格或顾客 ID | 请求被拒绝 |
| 商品改价、下架、规格失效 | 实时展示与估算正确 |
| 顾客改密与退出 | 旧会话按约定撤销 |
| 日志输出 | 不包含密码、token、收货资料 |
一个值得注意的测试是:两个默认地址创建请求都可以返回 201,但最终只能有一个默认地址。它们通过顾客锁顺序执行,后执行的合法请求可以切换默认值。
这与购物车两个相同旧版本写请求“只能一个成功”不同。锁和版本服务于各自业务语义,不能要求所有并发操作都以同一种方式失败。
P3 提交中的验收记录是全量 73 项测试通过,新增 17 个 OpenAPI 操作。本文仅根据已提交代码与验收记录讲解,没有重新执行测试或验证 Markdown 渲染。
17. 当前实现的边界也应该教给读者
- 手机号只是登录标识,没有短信证明归属,不提供基于手机号的任意密码重置。
- 地址不做地图定位或配送范围判断,P3 只维护收货资料与归属。
- 账号停用有领域行为和测试,但没有额外实现管理端顾客停用接口。
- 购物车通过版本拒绝陈旧请求,尚不提供支付式幂等键返回机制。
- 购物车视图会实时读取目录,但不是完整目录的锁定报价。
- 同商品查询在请求内复用,不等于已实现全部商品的批量读取优化。
- 当前没有创建 Flutter 项目,也没有下单、库存或支付宝沙箱支付。
初学时容易把“能运行”误认为“规则完整”,也容易把“用了 DDD”误认为“所有对象划分都永远正确”。更有价值的是明确每项设计保护了什么,还留下哪些待演进的问题。
读者练习
地址归属。 如果把仓储查询改成 findById(id),只在 Controller 检查顾客,你认为哪个新入口可能绕过归属限制?
默认地址建模。 如果未来有默认配送地址、默认发票地址两种用途,单个 is_default 是否还足够?请重新写业务不变量,再决定索引和模型。
购物车版本。 服务器已完成版本 4 的变更,但 App 没收到响应。客户端应直接提交版本 5,还是先读取当前状态?为什么?
价格快照。 购物车显示 18 元,顾客点击下单前商品改成 20 元。谁决定成交金额?订单里应该保存什么,以免后续再改价影响历史订单?
下一阶段的订单,就是在这些问题之上建立真正的购买约定:顾客身份、地址、门店与商品经过重新校验,形成属于订单自己的不可变成交快照。
延伸阅读
系列:Han Menu 外卖系统实践 · 从第一篇开始