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

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

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

面向读者:理解 Java 类和接口,知道 Spring Boot 可以接收 HTTP 请求,但还不熟悉 DDD 与 ORM 的开发者。

本篇主线:管理员停用员工后,旧令牌必须失效;重新启用账号,也不能恢复旧令牌。

代码按当前项目实现整理,直接展示关键片段。省略的导入、构造器或无关字段会在对应小节说明。本文展示的是已实现的员工身份模块,Flutter 顾客端和支付宝沙箱支付属于后续阶段。

1. 从一个普通需求中找出真正的业务规则

上一篇建立了九个业务模块和分层骨架。这一篇实现第一块完整业务:员工身份与账号管理。

先不写 Controller,也不建数据库表。我们把需求写成几句话:

  • 管理员可以创建普通员工。
  • 普通员工不能管理其他员工账号。
  • 员工停用后不能登录,既有会话也不能继续使用。
  • 员工重新启用后可以重新登录,但停用前的令牌不能复活。
  • 员工修改本人密码时需要校验当前密码,成功后全部旧会话失效。
  • 两个人同时修改一份员工资料,后提交的人不能无声覆盖先提交的人。
  • 当前阶段只初始化一个管理员,员工管理入口不能把管理员停用。

这些句子已经包含了模型设计的关键线索:

需求中的词 提示我们需要什么
员工账号 有身份、有生命周期的对象
显示名称、联系电话 账号资料,可以作为一组值
创建、停用、启用、改密 对象应当提供的业务行为
普通员工不能管理别人 操作者权限规则
旧会话失效 账号状态和会话之间的有效性约束
不能覆盖别人的修改 并发版本规则

DDD 建模不是把业务名词逐个变成 class。我们需要继续追问:哪些状态必须被一起维护,谁能改变它们?

2. 先限定“员工账号”这个模型的范围

2.1 账号管理不等于人事管理

一张传统员工表可能包含姓名、性别、身份证、住址、入职日期、登录密码、部门等几十个字段。

这些字段都与员工有关,但未必都属于身份模块。

在 P1 中,我们需要知道:

  • 账号标识是什么;
  • 用户名是什么;
  • 界面显示什么名称;
  • 如何验证密码;
  • 具有什么角色;
  • 是否可以使用;
  • 当前安全版本是什么。

因此,本项目没有因为参考源码存在身份证、性别字段,就自动把它们放入身份模型。真正出现排班、劳动关系等需求时,应当重新分析相应业务边界。

“业务有关”只是一个宽泛关系。“由这个模型负责”才是建模要回答的问题。

2.2 本阶段的角色约束要写清楚

当前只有两种角色:

public enum Role {
  ADMIN,
  STAFF
}

enum 是 Java 的枚举类型,用来表达一组有限且有名称的取值。它能避免在代码里散落“1 是管理员、2 是员工”这样的约定。

本项目由初始化流程创建一个管理员,新增员工固定为 STAFF。这是 P1 的业务决定,不是 DDD 要求所有系统都只能有一个管理员。

以后支持多管理员、权限组或门店级授权,需要演进模型和约束,而不是偷偷在创建员工请求里放开一个 role 字段。

3. 从“相同身份”和“相同内容”理解实体与值对象

3.1 员工账号为什么是实体

员工“小王”改名为“王师傅”,还是同一个员工账号。显示名称发生变化,身份没有变化。

因此账号需要稳定标识:

private final UUID id;

UUID 是一种标识表示方式。它本身不让对象成为领域实体;业务上需要跨越变化追踪同一个对象,才是实体的关键特征。

final 表示这个字段的引用初始化后不能被重新赋值。这里有助于表达“账号创建后不应随意换一个身份标识”。

3.2 账号资料为什么可以是值对象

账号资料主要由用户名、显示名称和电话这组值构成。我们不需要给“这一份显示名称和电话”再分配一个独立业务 ID。

它适合用不可变值对象表示:

/** 账号资料值对象,构造时规范化并保护字段约束. */
public record EmployeeProfile(
    String username, String displayName, String phone) {

  /** 每个成功构造的资料对象都应满足基本约束. */
  public EmployeeProfile {
    username = normalizeUsername(username);
    displayName = Objects.requireNonNull(displayName, "显示名称不能为空").strip();
    phone = phone == null ? "" : phone.strip();

    if (displayName.isBlank()
        || displayName.length() > 50
        || !phone.matches("(?:\\+?[1-9][0-9]{6,14})?")) {
      throw new IdentityException(
          IdentityException.Reason.INVALID_INPUT, "账号资料格式不正确");
    }
  }

  /** 用户名不区分大小写,规范化后再存储和查询. */
  public static String normalizeUsername(String value) {
    String result =
        Objects.requireNonNull(value, "用户名不能为空")
            .strip()
            .toLowerCase(Locale.ROOT);

    if (!result.matches("[a-z][a-z0-9_]{2,31}")) {
      throw new IdentityException(
          IdentityException.Reason.INVALID_INPUT, "用户名格式不正确");
    }
    return result;
  }

  /** 防止调试输出暴露个人资料. */
  @Override
  public String toString() {
    return "EmployeeProfile[资料已隐藏]";
  }
}

这里有几个 Java 知识点:

  • record 自动生成构造参数对应的访问器,如 username(),以及基于组件的 equalshashCode
  • 紧凑构造器 public EmployeeProfile { ... } 可以在字段赋值前规范化参数并进行校验。
  • 修改 record 构造参数,并不是给已经存在的对象设置新值。它发生在构造过程中。
  • record 是浅层不可变。这里的组件是不可变的 String;如果组件是可修改的 List,仍然需要复制和保护。
  • Locale.ROOT 避免用户名大小写转换依赖服务器当前地区设置。

业务需要更新资料时,我们创建新的 EmployeeProfile,交给账号聚合替换。这样不会留下“用户名更新了,电话校验还没完成”的半成品对象。

3.3 值对象约束和唯一约束不是一回事

EmployeeProfile 能检查用户名格式,却不能只靠构造器知道数据库里有没有同名账号。

“用户名格式合法”是对象自身可以判断的规则;“系统内用户名唯一”需要持久化层及数据库约束参与。

不要为了让值对象完成所有校验,就把仓储、数据库或 Spring Bean 塞进它的构造器。

4. 聚合根要让业务状态只能通过正确的门进入

4.1 为什么不直接提供 setEnabled

假设账号类公开了这样的入口:

employee.setEnabled(false);

调用方当然可以停用账号,但谁来保证它同时撤销旧会话?谁来保证不能停用管理员?

如果这些规则分散在不同 Service 中,新增入口很容易只改字段,忘记关联行为。

因此账号聚合提供表达业务含义的方法。下面省略构造器和只读访问器,展示核心状态与行为:

public final class EmployeeAccount {
  private final UUID id;
  private EmployeeProfile profile;
  private String passwordHash;
  private final Role role;
  private boolean enabled;
  private long securityVersion;
  private final long version;
  private final Instant createdAt;
  private Instant updatedAt;

  /** 启停用员工并维护会话撤销版本. */
  public void changeEnabled(boolean replacement, Instant now) {
    if (role == Role.ADMIN && !replacement) {
      throw new IdentityException(
          IdentityException.Reason.CONFLICT, "不能停用管理员账号");
    }
    if (enabled != replacement) {
      enabled = replacement;
      securityVersion++;
      updatedAt = Objects.requireNonNull(now);
    }
  }

  /** 修改密码摘要后,旧会话不再有效. */
  public void changePassword(String replacement, Instant now) {
    passwordHash = Objects.requireNonNull(replacement);
    securityVersion++;
    updatedAt = Objects.requireNonNull(now);
  }

  /** 校验调用方所持安全版本是否仍然有效. */
  public void requireActive(long expectedSecurityVersion) {
    if (!enabled || securityVersion != expectedSecurityVersion) {
      throw new IdentityException(
          IdentityException.Reason.INVALID_CREDENTIALS, "登录状态已失效");
    }
  }
}

“聚合”可以理解为一个需要共同保护业务约束的一组对象,聚合根是对外执行行为的入口。

当前员工聚合规模很小,主要由账号状态和资料值对象组成。聚合不要求必须包含很多子实体,更不要求一个聚合恰好对应一个复杂对象树。

EmployeeAccount 不继承通用 BaseEntity,也没有一个允许修改全部属性的 update(Map)。它只暴露业务已经需要的能力。

4.2 一个对象可以有多个不同用途的版本

这个模型同时存在:

version          保护并发写入
securityVersion  保护旧会话撤销

它们不能混为一谈。

普通资料修改会改变持久化版本,但不一定需要让员工重新登录。停用、重新启用和修改密码则需要推进安全版本。

操作 业务持久化版本 安全版本
修改显示名称 实际更新时由 ORM 增加 保持不变
从启用变为停用 实际更新时由 ORM 增加 增加
从停用变为启用 实际更新时由 ORM 增加 增加
修改密码 实际更新时由 ORM 增加 增加
重复设置为当前状态 无实际字段变化时可以不增加 不增加

两个名字都叫“版本”,但分别对应两类业务问题。字段应该跟随含义命名,而不是因为都是数字就强行合并。

4.3 推演旧令牌为什么不会复活

登录时,会话记录保存账号当时的安全版本。认证时,同时检查账号当前安全版本。

记账号的安全版本为 v_a,会话记录的安全版本为 v_s,过期时刻为 t_e。会话有效至少需要:

\operatorname{valid} = \operatorname{sessionExists} \land \operatorname{accountEnabled} \land (v_s = v_a) \land (t_{now} < t_e)

现在推演一次状态变化:

时间 账号状态 账号安全版本 原会话安全版本 原会话有效吗
登录后 启用 0 0 有效
管理员停用 停用 1 0 无效
管理员重新启用 启用 2 0 仍然无效
员工重新登录 启用 2 新会话为 2 新会话有效

如果只检查 enabled,重新启用之后旧令牌可能再次通过认证。安全版本让“曾经被撤销”这个事实能够在后续验证中体现出来。

这里的“立即失效”指停用事务提交后,新的认证检查应当拒绝旧会话;它不表示系统能够撤回一个已经执行完毕的请求。

5. 应用服务把业务方法组织成用例

停用规则已经在聚合里,应用服务还需要做什么?

它需要回答“由谁操作哪个账号、在什么事务中完成”。

下面是应用服务的核心片段,省略创建、查询和修改密码等其他方法:

@Service
@Transactional
public class EmployeeAdministration {
  private final EmployeeRepository employees;
  private final PasswordHasher passwords;
  private final AuditTrail audit;
  private final Clock clock;

  public EmployeeAdministration(
      EmployeeRepository employees,
      PasswordHasher passwords,
      AuditTrail audit,
      Clock clock) {
    this.employees = employees;
    this.passwords = passwords;
    this.audit = audit;
    this.clock = clock;
  }

  /** 管理员变更员工状态,审计与状态更新共同提交. */
  public void changeStatus(
      StaffIdentity actor, UUID id, boolean enabled, long version) {
    administrator(actor);
    var account = employee(id);
    account.requireVersion(version);
    account.changeEnabled(enabled, clock.instant());
    employees.update(account);
    audit.record(AuditTrail.Action.CHANGE_STATUS, actor.employeeId(), id, true);
  }

  private void administrator(StaffIdentity identity) {
    var actor = employee(identity.employeeId());
    actor.requireActive(identity.securityVersion());
    actor.requireAdministrator();
  }

  private EmployeeAccount employee(UUID id) {
    return employees.findById(id).orElseThrow(
        () -> new IdentityException(
            IdentityException.Reason.NOT_FOUND, "员工不存在"));
  }
}

这个方法可以按顺序读出来:

  1. 验证当前操作者仍有效,并具备管理员权限。
  2. 加载目标账号。
  3. 确认提交的版本没有过期。
  4. 调用聚合的业务行为。
  5. 持久化变更。
  6. 记录审计。

“管理员不能停用”没有再写一遍,它由 changeEnabled 负责。

Spring Security 在请求入口做权限控制,应用服务仍验证操作者,原因是 HTTP 不是应用用例唯一可能的调用入口。未来有任务、内部调用时,也不应绕过业务授权。

5.1 构造器注入带来的实际收益

构造器把用例需要的依赖列出来。阅读它就能看到:这个服务需要仓储、密码算法、审计和时间。

仓储与算法以接口形式传入,应用服务不必引用具体 JPA 或 BCrypt 实现。

Clock 也是一个明确的依赖。测试可以提供固定时钟,避免“刚好跨过午夜”或“测试运行得太慢”导致时间断言不稳定。

5.2 事务不是写上注解就万事大吉

@Transactional 通过 Spring 代理生效。一般要由其他 Spring Bean 调用这个服务方法,才能进入代理管理的事务。

在同一个对象中直接调用自己的另一个方法,不会凭空经过新的事务代理。阅读事务代码时,要同时看调用关系。

这个用例的成功状态更新与成功审计使用同一个数据库事务:审计保存失败时,状态变化不能独自提交。

后面登录失败审计会有不同约定。事务规则需要围绕用例设计,不能全项目机械使用同一组注解参数。

6. 领域对象、JPA 实体和 HTTP DTO 为什么要分开

6.1 三个对象回答三类问题

对象 关心的问题 例子
领域对象 什么行为合法,规则如何保持 EmployeeAccount
JPA 实体 字段怎样存储,关系怎样加载,版本怎样检查 EmployeeEntity
HTTP DTO 客户端可以提交什么,响应允许暴露什么 CreateEmployeeEmployeeView

字段可能相似,但生命周期和责任不同。

如果把 JPA 实体直接作为请求体,客户端可能提交角色、安全版本或密码摘要等内部字段。如果把实体直接返回,ORM 关联和敏感字段也可能被序列化出去。

本项目选择把它们分开,并把映射集中放在适配器边界。代价是需要维护映射代码,收益是领域行为、数据库机制和外部协议可以分别演进。

这是一项项目设计选择。DDD 并不要求所有系统必须分离领域实体与 JPA 实体;如果采用合并模型,也必须有意识地处理代理、可见性和业务约束。

6.2 JPA 实体只维护持久化所需的结构

下面展示实体中的关键字段,省略其余列与映射方法:

@Entity(name = "IdentityEmployee")
@Table(name = "identity_employee")
public class EmployeeEntity {
  @Id
  private UUID id;

  @Column(nullable = false, unique = true, length = 32)
  private String username;

  @Column(nullable = false, length = 100)
  private String passwordHash;

  @Enumerated(EnumType.STRING)
  @Column(nullable = false, length = 16)
  private EmployeeAccount.Role role;

  @Column(nullable = false)
  private boolean enabled;

  @Column(nullable = false)
  private long securityVersion;

  @Version
  private Long version;

  /** 供 JPA 重建实体,业务用例通过领域对象构造合法状态. */
  protected EmployeeEntity() {}
}

常见注解分别表示:

  • @Entity:这个类由 JPA 管理。
  • @Table:它映射到哪张表。
  • @Id:标识属性。
  • @Column:列映射及相关结构信息。
  • @Enumerated(EnumType.STRING):使用枚举名称存储,避免依赖枚举位置。
  • @Version:让 ORM 参与并发版本检查。

这些注解不出现在本项目的领域模型上。

6.3 为什么持久化版本使用 Long

领域聚合刚创建时,业务版本是 0。新 JPA 实体里的版本却暂时保留为 null

原因是 Spring Data JPA 会利用非原始类型的版本属性判断实体是否为新对象。在已有 UUID、版本仍为 null 的情况下,仓储可以识别它需要执行 persist

如果没有理解这个机制,给所有实体字段机械填上默认值,可能会让新对象被当成已有对象去 merge

实体的创建映射如下:

static EmployeeEntity create(EmployeeAccount account) {
  var entity = new EmployeeEntity();
  entity.id = account.id();
  entity.role = account.role();
  entity.createdAt = account.createdAt();
  entity.apply(account);
  // version 保持 null,由 Spring Data 识别为新实体。
  return entity;
}

apply 只复制已由聚合决定的状态,不在这里重新制定停用、改密等业务规则。

反方向加载时,由 toDomain() 调用领域对象的 restore(...),重建业务对象。createrestore 分开,是为了区分“新建账号”和“恢复已经保存的账号”。

7. 用 Spring Data JPA 处理查询与保存

7.1 业务需要仓储端口,框架提供仓储实现

领域仓储表达业务需要:

public interface EmployeeRepository {
  boolean hasAdministrator();

  void add(EmployeeAccount account);

  Optional<EmployeeAccount> findById(UUID id);

  Optional<EmployeeAccount> findByUsername(String username);

  void update(EmployeeAccount account);

  EmployeePage search(String displayName, int page, int size);
}

这里省略重复的中文方法注释,以集中观察接口形状。

Optional<T> 表示结果可能不存在,调用方必须决定“找不到员工”时如何处理。它不意味着任何时候都应该直接 get()

基础设施内还有一份 Spring Data 接口:

interface EmployeeRecords extends JpaRepository<EmployeeEntity, UUID> {
  boolean existsByRole(EmployeeAccount.Role role);

  Optional<EmployeeEntity> findByUsername(String username);

  Page<EmployeeEntity> findByDisplayNameContainingIgnoreCase(
      String name, Pageable pageable);
}

JpaRepository<EmployeeEntity, UUID> 的两个泛型参数分别表示实体类型和主键类型。Spring Data 为它提供常规存取实现。

findByDisplayNameContainingIgnoreCase 不是普通的“随便起名”。Spring Data 会解析方法名,为它生成相应查询。

这类 Containing 派生查询还会处理 LIKE 特殊字符,让输入的 %_ 按字面内容匹配。它适合当前简单的名称过滤需求。

复杂条件组合可以进一步使用 Specification;有明确的查询需求时再引入投影或专用查询端口。不要为了避免 SQL,就写出一整行几十个条件的方法名。

7.2 适配器把两种仓储连接起来

@Repository
@Transactional
class JpaEmployeeRepository implements EmployeeRepository {
  private final EmployeeRecords records;

  JpaEmployeeRepository(EmployeeRecords records) {
    this.records = records;
  }

  @Override
  public void add(EmployeeAccount account) {
    records.saveAndFlush(EmployeeEntity.create(account));
  }

  @Override
  @Transactional(readOnly = true)
  public Optional<EmployeeAccount> findById(UUID id) {
    return records.findById(id).map(EmployeeEntity::toDomain);
  }
}

这是部分实现,完整适配器还包括用户名查询、分页和更新。

map(EmployeeEntity::toDomain) 是方法引用,意思是“如果查到实体,就把它转换成领域对象”。

flush 把持久化上下文中的变更同步到数据库,以便及时发现约束冲突。**flush 不等于事务已经提交。**之后外层事务失败,已经 flush 的修改仍然可以回滚。

这一点会在审计与事件登记的事务测试中非常重要。

8. 用两道检查解决并发覆盖

假设两位管理员同时打开员工资料,看到的版本都是 7

sequenceDiagram
  participant A as 管理员甲
  participant B as 管理员乙
  participant API as 应用服务
  participant DB as PostgreSQL
  A->>API: 读取员工
  B->>API: 读取员工
  API-->>A: version = 7
  API-->>B: version = 7
  A->>API: 修改姓名,version = 7
  API->>DB: 条件更新版本 7
  DB-->>API: 成功,版本变为 8
  API-->>A: 204
  B->>API: 修改电话,version = 7
  API-->>B: 409 VERSION_CONFLICT

8.1 第一道:检查用户提交的资源版本

public void requireVersion(long expected) {
  if (expected != version) {
    throw new IdentityException(
        IdentityException.Reason.VERSION_CONFLICT,
        "资料已被修改,请刷新后重试");
  }
}

这解决“用户打开页面后,别人已经修改过资源”的问题。

因此修改请求的版本是必填字段:

record StatusChange(
    @NotNull Status status,
    @NotNull @Min(0) Long version) {}

DTO 中使用 Long,是为了让“没有提交版本”与“提交版本 0”区分开。通过 @NotNull 后,才能安全转换为领域方法需要的 long

8.2 第二道:ORM 检测事务之间的并发竞争

即使业务层检查时版本一致,也可能有另一个事务在检查之后先完成更新。

仓储适配器仍要在持久化层保护更新:

@Override
public void update(EmployeeAccount account) {
  var entity = records.findById(account.id()).orElseThrow(
      () -> new ObjectOptimisticLockingFailureException(
          EmployeeEntity.class, account.id()));

  if (entity.version() != account.version()) {
    throw new ObjectOptimisticLockingFailureException(
        EmployeeEntity.class, account.id());
  }

  entity.apply(account);
  records.flush();
}

@Version 让 Hibernate 生成带版本条件的更新。下面仅用于解释数据库行为,不是项目里的手写业务 SQL:

UPDATE identity_employee
SET display_name = ?, version = version + 1
WHERE id = ? AND version = ?;

如果版本已变化,更新无法命中预期行,ORM 抛出乐观锁异常,接口映射为 409。

实体处于托管状态时,修改字段后 Hibernate 会通过脏检查识别变化,不必每次再手工拼 UPDATE。

9. 设计登录时,先区分密码与令牌

9.1 密码不能直接用 SHA-256 存储

用户密码通常有规律,攻击者可以尝试大量候选值。因此项目使用 BCrypt,并设置工作因子 12。

工作因子影响密码计算成本,需要结合部署资源测量。它不是一个无条件越大越好的数字。

新密码先经过值对象约束:

public record NewPassword(String value) {
  public NewPassword {
    Objects.requireNonNull(value, "密码不能为空");
    if (value.length() < 12
        || value.length() > 64
        || value.isBlank()
        || value.getBytes(StandardCharsets.UTF_8).length > 72) {
      throw new IdentityException(
          IdentityException.Reason.INVALID_INPUT,
          "密码须为 12 至 64 个字符,且 UTF-8 编码不超过 72 字节");
    }
  }

  @Override
  public String toString() {
    return "NewPassword[已隐藏]";
  }
}

这里同时检查字符串长度和 UTF-8 字节长度,是为了避免超过 BCrypt 的有效输入范围后发生静默截断。

Java String.length() 计算的是 UTF-16 代码单元数量,并不严格等于用户看到的字符数。处理 emoji 等字符时尤其要理解这一点;当前接口按上述代码规则执行校验。

9.2 随机会话令牌为什么可以保存摘要

令牌由安全随机数生成器产生,不来自用户容易猜测的短口令:

@Component
public final class TokenFactory {
  private final SecureRandom random = new SecureRandom();

  public String newToken() {
    return "hme_" + randomSecret(32);
  }

  public String digest(String token) {
    return Hashing.sha256()
        .hashString(token, StandardCharsets.UTF_8)
        .toString();
  }

  private String randomSecret(int length) {
    byte[] bytes = new byte[length];
    random.nextBytes(bytes);
    return BaseEncoding.base64Url().omitPadding().encode(bytes);
  }
}

32 字节是 256 位随机数据。Base64 URL 编码让它适合放进请求头,hme_ 前缀用于识别员工会话类型。

Guava 在这里简化摘要和编码调用,随机性仍然由 SecureRandom 提供。

数据库保存令牌摘要。客户端每次携带原始令牌,服务端计算相同摘要进行查找。数据库中不需要保存原始令牌,也不需要把它写进日志。

本项目采用的是不透明令牌,不是 JWT。JWT 与不透明令牌是不同的会话设计选项;当前我们希望简单地查询账号最新状态、立即撤销会话,因此选择数据库会话。

10. 把一次登录串起来

sequenceDiagram
  participant Client as 管理端客户端
  participant Web as SessionController
  participant App as EmployeeAuthentication
  participant Redis as Redis
  participant DB as PostgreSQL / JPA
  Client->>Web: POST /api/v1/sessions
  Web->>App: 用户名、密码、直连地址
  App->>Redis: 账号和来源地址计数
  Redis-->>App: 是否允许继续
  App->>DB: 读取账号
  App->>App: 校验 BCrypt 和启用状态
  App->>DB: 保存令牌摘要、过期时间和安全版本
  App->>DB: 写入最小审计记录
  DB-->>App: 提交成功
  App-->>Web: 原始令牌与过期时间
  Web-->>Client: 201 Created

登录用例的关键片段如下,省略构造器和其他方法:

@Transactional(noRollbackFor = IdentityException.class)
public LoginResult login(
    String username, String password, String clientAddress) {
  String normalized = EmployeeProfile.normalizeUsername(username);

  try {
    limiter.check(normalized, clientAddress);
  } catch (IdentityException exception) {
    audit.record(AuditTrail.Action.LOGIN_LIMITED, null, null, false);
    throw exception;
  }

  var found = employees.findByUsername(normalized);
  boolean matches =
      passwords.matches(password, found.map(EmployeeAccount::passwordHash).orElse(null));

  if (!matches || found.isEmpty() || !found.orElseThrow().enabled()) {
    audit.record(AuditTrail.Action.LOGIN, null, null, false);
    throw new IdentityException(
        IdentityException.Reason.INVALID_CREDENTIALS, "用户名或密码错误");
  }

  var account = found.orElseThrow();
  String token = tokens.newToken();
  Instant expires = clock.instant().plus(ttl);

  sessions.deleteExpired(clock.instant());
  sessions.add(tokens.digest(token), account.id(), account.securityVersion(), expires);
  audit.record(AuditTrail.Action.LOGIN, account.id(), account.id(), true);

  return new LoginResult(identity(account), token, expires);
}

这里的 noRollbackFor 是一个必须解释的设计点。

通常运行时异常会导致事务回滚。但错误密码等登录失败也需要保留失败审计。当前流程保证:预期认证失败发生在会话创建之前,因此允许这类 IdentityException 不回滚失败审计。

它不表示数据库异常也应该提交,更不能全项目照抄。以后如果在创建会话之后又增加可能抛出同类业务异常的步骤,就必须重新审查这个事务约定。

另一个细节是未知账号也会进行虚拟摘要比较,减少“账号不存在立即返回、密码错误却耗时更长”的明显差异。但这并不能单独保证系统不存在所有形式的账号枚举风险。

11. Redis 只负责限流,不负责决定账号是否有效

项目有两道限流计数:

  • 每个账号每分钟默认 10 次;
  • 每个直连来源地址每分钟默认 50 次。

只限制账号,攻击者可能不断换账号尝试;只限制来源,共享网络里的合法用户也会受到同一来源额度影响。两道限制要结合业务流量调整。

计数和设置过期时间需要作为一个不可被其他命令插入的操作完成,因此使用 Lua 脚本:

local counts = {}
for index, key in ipairs(KEYS) do
  counts[index] = redis.call('INCR', key)
  if counts[index] == 1 or redis.call('PTTL', key) < 0 then
    redis.call('PEXPIRE', key, ARGV[1])
  end
end
if counts[1] > tonumber(ARGV[2]) or counts[2] > tonumber(ARGV[3]) then
  return 0
end
return 1

这是从第一次计数开始计算的固定 TTL 窗口,不是滑动窗口算法。成功和失败的登录请求都会消耗计数。

当前部署使用单个 Redis 实例。未来换成 Redis Cluster,需要重新设计多键脚本的槽位策略,不能直接假定两个键一定可在同一个脚本里操作。

键中的用户名和地址使用摘要,不直接写原文。不过对低熵输入做普通哈希不等于匿名化或加密,它只是减少原文直接暴露。

Redis 不可用时,创建会话返回 503。已有会话仍由 PostgreSQL 检查账号状态;Redis 故障既不能让未认证请求通过,也不意味着必须把全部数据库会话立即删除。

12. JPA 会话查询为什么要设计抓取方式

认证时同时需要会话和账号状态。如果先查会话,再访问懒加载的账号,可能额外产生一次数据库查询。

项目通过实体图明确本次查询需要账号关联:

interface SessionRecords
    extends JpaRepository<SessionEntity, String>,
        JpaSpecificationExecutor<SessionEntity> {

  @EntityGraph(attributePaths = "employee")
  Optional<SessionEntity> findByTokenHashAndExpiresAtAfterAndEmployeeEnabledTrue(
      String tokenHash, Instant now);
}

实体图说明本次查询需要抓取 employee。具体生成的 SQL 由 ORM 决定;本项目为实际运行版本增加了“认证只执行一次查询”的集成测试,而不只靠注解名称猜测行为。

适配器继续检查安全版本:

@Override
@Transactional(readOnly = true)
public Optional<EmployeeAccount> findActive(String tokenHash, Instant now) {
  return sessions
      .findByTokenHashAndExpiresAtAfterAndEmployeeEnabledTrue(tokenHash, now)
      .filter(SessionEntity::matchesSecurityVersion)
      .map(session -> session.employee().toDomain());
}

当这个事务结束时,应用拿到的是普通领域对象,Web 层不需要继续触发懒加载。

Open-in-View 关闭后,合理设计抓取计划就成为必要工作。ORM 帮我们生成 SQL,并不会自动替我们理解一次用例应该读取哪些数据。

13. 让 HTTP 接口反映资源和权限

当前 P1 有九个操作:

操作 方法与路径 成功状态
创建会话 POST /api/v1/sessions 201
撤销当前会话 DELETE /api/v1/sessions/current 204
查询本人身份 GET /api/v1/me 200
修改本人密码 PUT /api/v1/me/password 204
创建员工 POST /api/v1/employees 201
员工分页查询 GET /api/v1/employees 200
查询员工详情 GET /api/v1/employees/{id} 200
更新员工资料 PUT /api/v1/employees/{id} 204
变更员工状态 PATCH /api/v1/employees/{id}/status 204

创建返回 Location,指明新资源的位置;更新或撤销成功可以返回 204,不必为了统一结构再包一层“操作成功”。

管理员可以维护员工。普通员工只能访问自己的身份、修改自己的密码和撤销当前会话。

13.1 Controller 把请求翻译成用例输入

状态修改的接口片段:

@PatchMapping("/{id}/status")
ResponseEntity<Void> status(
    @AuthenticationPrincipal StaffIdentity actor,
    @PathVariable UUID id,
    @Valid @RequestBody StatusChange body) {
  administration.changeStatus(
      actor, id, body.status() == Status.ACTIVE, body.version());
  return ResponseEntity.noContent().build();
}

理解几个注解:

  • @PathVariable 从路径读取资源标识。
  • @RequestBody 把 JSON 转为请求对象。
  • @Valid 触发请求字段校验。
  • @AuthenticationPrincipal 取得安全链已验证的身份。

actor 不来自客户端提交的任意员工 ID。客户端可以选择目标资源,但不能自己声明“我是管理员”。

13.2 格式校验和业务校验各有位置

@NotNull 可以检查有没有提交状态,但不能决定是否可以停用管理员。

因此两层校验都需要:

HTTP 校验:请求是否完整、格式是否正确
领域校验:当前业务状态是否允许这个行为

领域对象也不能只依赖 Controller 已经校验过。未来应用用例可能由别的入口调用。

13.3 未知字段直接拒绝

如果客户端创建员工时偷偷加上:

{
  "username": "staff_01",
  "displayName": "厨房员工",
  "password": "Only-For-Tutorial-2026!",
  "role": "ADMIN"
}

请求应当失败。创建 DTO 不包含 role,服务端还启用了严格未知字段检查:

spring:
  jackson:
    deserialization:
      fail-on-unknown-properties: true

这样可以防止调用方以为提交了一个字段就改变了行为,也能帮助发现字段拼写错误。

14. 错误响应应该让客户端知道怎么处理

所有错误统一采用 RFC 9457 Problem Details。客户端既可以看 HTTP 状态,也可以根据稳定的业务错误码选择交互方式。

例如并发冲突:

{
  "type": "urn:han-menu:problem:version-conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "资源已被修改,请刷新后重试",
  "instance": "/api/v1/employees/3f4feabe-722f-4f3f-9be2-d6bc42a352d1",
  "code": "VERSION_CONFLICT",
  "traceId": "a41616a7-3405-453f-af2f-42410e119b4d"
}

这里的 UUID 是示例值。

  • 400:请求字段不正确。
  • 401:未认证或认证失效。
  • 403:身份有效,但没有执行权限。
  • 404:目标员工不存在。
  • 409:版本或业务约束冲突。
  • 429:请求过于频繁。
  • 503:认证依赖暂时不可用。

detail 不能直接使用底层异常的全部内容,否则 SQL、字段原值或内部路径可能进入响应。

安全过滤链中的认证失败发生在 Controller 之前,因此还需要在安全链中写出相同的错误格式。只写一个 @RestControllerAdvice,并不能自动覆盖所有过滤器里的失败。

15. 审计要保留事实,不要顺手保留秘密

审计接口只接受固定动作、内部标识和结果:

public interface AuditTrail {
  void record(Action action, UUID actorId, UUID subjectId, boolean success);

  enum Action {
    BOOTSTRAP,
    LOGIN,
    LOGOUT,
    CREATE_EMPLOYEE,
    UPDATE_EMPLOYEE,
    CHANGE_STATUS,
    CHANGE_PASSWORD,
    AUTHORIZATION_DENIED,
    LOGIN_LIMITED
  }
}

它没有一个“随便写点什么”的 String requestBody 参数。

这让调用方更难把密码、token 或完整员工资料误传进去。审计记录也更容易统计,因为事件类型有明确取值。

成功业务与审计同事务提交;控制台诊断信息只用于定位,不能因为看到一行成功动作日志,就认定事务已经成功提交。

同样,record 自动生成的 toString() 会输出组件值。凭证 DTO 和密码值对象应显式覆盖它。对象类型本身也需要承担避免意外泄露的责任。

16. 从数据库约束再看一遍领域规则

领域行为和数据库约束并不互相替代。

当前员工表有用户名唯一约束,还通过部分唯一索引保证 P1 至多只有一个管理员:

CREATE UNIQUE INDEX identity_single_administrator_idx
    ON identity_employee (role)
    WHERE role = 'ADMIN';

这是一条 Flyway DDL,不是运行时的手写业务存取 SQL。

应用启动时先检查管理员是否存在,可以处理正常重复启动;数据库约束则保护并发初始化等竞争情况下的最终状态。

同理,领域层检查不能停用管理员,数据库也有相应状态约束。前者产生明确业务语义,后者防止异常写入破坏持久化事实。

P1 已有的三张业务表分别承载账号、会话和审计。它们都属于 identity 模块。会话可以建立到员工表的外键,因为关系在同一个模块内。

这不意味着未来的订单表可以随意通过 JPA 关联身份模块内部实体。跨模块关系仍需要使用稳定业务标识与公开契约。

17. 怎样证明模型真的保护了规则

17.1 领域测试验证行为,不需要启动 Spring

下面按实际规则整理了一段独立测试:

@Test
void reenableDoesNotReviveOldSession() {
  Instant now = Instant.parse("2026-09-18T00:00:00Z");
  var employee = EmployeeAccount.create(
      UUID.randomUUID(),
      new EmployeeProfile("staff", "测试员工", ""),
      "hash-used-only-in-domain-test",
      EmployeeAccount.Role.STAFF,
      now);

  employee.changeEnabled(false, now.plusSeconds(1));
  employee.changeEnabled(true, now.plusSeconds(2));

  assertThat(employee.securityVersion()).isEqualTo(2);
  assertThatThrownBy(() -> employee.requireActive(0))
      .isInstanceOf(IdentityException.class);
  employee.requireActive(2);
}

这段测试中的摘要只是测试夹具,不进入真实认证系统。测试关注的是状态与安全版本规则,完全不需要数据库或 Spring 容器。

17.2 JPA 测试验证框架行为

只测试领域对象,无法证明 @Version 和实体映射配置正确。因此需要真实 PostgreSQL 测试:

  • 用两个 EntityManager 同时加载同一个版本。
  • 第一个事务更新并提交。
  • 第二个事务 flush,断言乐观锁异常。
  • 再次读取,确认第一个事务的结果没有被覆盖。

还需要验证 @EntityGraph 的实际查询数量,避免未来修改映射后认证悄悄变成多次查询。

17.3 模块测试验证完整用例

@ApplicationModuleTest 可以围绕身份模块装配测试。HTTP 契约测试覆盖:

场景 应当证明什么
普通员工访问员工列表 返回 403
停用后使用原 token 返回 401
重新启用后使用原 token 仍然返回 401
当前密码错误 不改变密码,不生成新会话
密码修改成功 该账号所有旧会话失效
提交旧 version 返回 409,数据不被覆盖
Redis 限流不可用 登录失败,不绕过检查
未知 JSON 字段 返回 400
创建重复账号 返回 409,不泄露数据库错误详情
输出日志与审计 不包含密码、token 或个人资料

测试使用隔离 PostgreSQL schema 和 Redis 键前缀,不污染开发数据。

17.4 事件事务测试验证后续模块的基础

P1 还验证了一个基础设施性质:在同一事务内更新账号并写入 Modulith JPA 事件登记,随后主动回滚,二者都不应保留。

这项测试用的是基础设施测试事件,不表示已经实现“员工停用跨模块广播”,更不表示订单和支付业务已经完成。

区分“基础设施能够支持”和“具体业务已经落地”,是阅读项目教程时很重要的一步。

18. 亲手走一遍这个用例

以下操作适用于本项目已经启动的开发环境。不要把示例密码用于真实部署。

先通过 Swagger 的会话接口,使用本地 .env 中的初始化管理员信息登录。拿到 accessToken 后,在 Authorize 中使用 Bearer 认证。

然后创建一个测试员工:

POST /api/v1/employees
Authorization: Bearer <管理员令牌>
Content-Type: application/json

{
  "username": "staff_tutorial",
  "displayName": "教程测试员工",
  "phone": "",
  "password": "Only-For-Tutorial-2026!"
}

响应为 201,记录员工的 UUID 和 version

使用新员工账号创建会话,保存它的令牌。然后管理员执行:

PATCH /api/v1/employees/<员工UUID>/status
Authorization: Bearer <管理员令牌>
Content-Type: application/json

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

这里的 0 仅适用于刚创建且尚未修改的员工。实际操作总应使用最近一次读取到的版本。

使用员工原令牌请求 /api/v1/me,应当得到 401。

管理员重新读取员工,取得最新版本,再把状态改为 ACTIVE。继续使用旧令牌,仍应得到 401。员工重新创建会话后,新令牌才有效。

这次练习把文章最开始的业务句子,完整地走到了 HTTP 行为。

19. 这次实现中有哪些有意识的取舍

模型不是越复杂越好。当前实现保留了一些明确的限制:

  • 角色固定为 ADMIN/STAFF,还没有动态授权体系。
  • 身份模块有独立 JPA 实体映射,增加了少量转换代码,换取领域与持久化边界。
  • 认证每次查询数据库,获得即时撤销语义;未来有性能数据后再评估缓存策略。
  • 当前登录事务包含密码验证及限流步骤,后续如果并发规模上升,需要测量连接占用并重新审视事务范围。
  • P1 的安全审计覆盖既定事件,并不意味着所有失败请求都有完整审计。
  • Session 目前通过端口和持久化模型管理,没有为了形式强行新增一个复杂会话聚合。

这些限制应当随需求和数据演进,不能通过一句“采用了 DDD”就自动消失。

读者练习

练习一:资料变更是否应该让员工掉线?

如果修改显示名称也增加 securityVersion,会带来什么体验?你会把“修改登录用户名”与“修改显示名称”视为相同的安全变化吗?请先定义业务规则,再决定版本行为。

练习二:为什么管理员保护要有两层?

领域方法已经禁止停用管理员,为什么还要数据库约束?试着分别从正常调用、并发竞争和运维误操作分析。

练习三:学习领域测试。

编写测试证明:重复将启用账号设置为启用,不会不断增加安全版本。再编写测试证明:修改密码必须增加安全版本。

练习四:检查依赖方向。

如果 EmployeeAccount 引入了 StringRedisTemplate,它会变得难测在哪里?应该把 Redis 操作移动到哪个层,通过什么端口与用例连接?

到这里,我们已经把一条业务规则落实到值对象、聚合行为、事务、ORM、认证和 HTTP 协议。后续商品、订单与支付模块也会沿用同样的思考顺序:先明确行为与约束,再选择对象和边界,最后完成技术实现。

延伸阅读

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

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