从零重构外卖系统(二):把员工账号规则落地为领域模型、JPA 和 REST API
系列:Han Menu 外卖系统实践 · 从第一篇开始
系列: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(),以及基于组件的equals、hashCode。- 紧凑构造器
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。会话有效至少需要:
现在推演一次状态变化:
| 时间 | 账号状态 | 账号安全版本 | 原会话安全版本 | 原会话有效吗 |
|---|---|---|---|---|
| 登录后 | 启用 | 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, "员工不存在"));
}
}
这个方法可以按顺序读出来:
- 验证当前操作者仍有效,并具备管理员权限。
- 加载目标账号。
- 确认提交的版本没有过期。
- 调用聚合的业务行为。
- 持久化变更。
- 记录审计。
“管理员不能停用”没有再写一遍,它由 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 | 客户端可以提交什么,响应允许暴露什么 | CreateEmployee、EmployeeView |
字段可能相似,但生命周期和责任不同。
如果把 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(...),重建业务对象。create 与 restore 分开,是为了区分“新建账号”和“恢复已经保存的账号”。
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 协议。后续商品、订单与支付模块也会沿用同样的思考顺序:先明确行为与约束,再选择对象和边界,最后完成技术实现。
延伸阅读
- Spring Data JPA 实体持久化
- Spring Data JPA 查询方法
- Spring Security 密码存储
- Spring Modulith 模块测试
- RFC 9457:Problem Details for HTTP APIs
系列:Han Menu 外卖系统实践 · 从第一篇开始