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

上一篇:第3篇 · 下一篇:第5篇

系列: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 则是一次创建时分配的标识,不能拿它判断是否为同一种选择。

可以把合并关系写成:

\operatorname{sameSelection}(a,b) = (a.productId=b.productId) \land (a.selections=b.selections)

不同口味仍保留独立条目,即使商品相同。

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 一个容易漏掉的网络重试场景

  1. App 读取空购物车,版本为 0。
  2. App 提交“增加两份面”,版本为 0。
  3. 数据库保存成功,但响应在网络中丢失。
  4. 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);

数学上可以写为:

\operatorname{estimatedTotal} = \sum_{i \in \text{当前可售且规格有效的条目}} p_i q_i

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) {}

没有 customerIdpriceproductName。严格 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 外卖系统实践 · 从第一篇开始

上一篇:第3篇 · 下一篇:第5篇