Note 19(第三章). SpringBoot3-账号密码登录:领域模型设计与完整实现

Note 19.3. 账号密码登录:领域模型设计与完整实现

重要说明:本章是在 19.2 已搭建完成的 auth-factory-demo 单模块工程基础上继续扩展的(包含 AuthTypeAuthRequest 多态、AuthStrategyFactory、Sa-Token 等骨架)。因此,本章会严格沿用 19.2 的包名与目录结构,只新增“领域模型 + 持久层 + 账号密码真实实现”,确保代码可以无缝接上去。


19.3.1. 传统设计的弊端:单表走天下

在理解现代化的数据建模方案之前,我们需要先理解传统设计的问题。

单表设计的典型案例

很多初学者在设计用户表时,会采用这种 “看似合理” 的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) COMMENT '账号',
password VARCHAR(100) COMMENT '密码哈希',
mobile VARCHAR(11) COMMENT '手机号',
email VARCHAR(100) COMMENT '邮箱',
wechat_openid VARCHAR(64) COMMENT '微信 OpenID',
github_id VARCHAR(50) COMMENT 'GitHub ID',
nickname VARCHAR(50) COMMENT '昵称',
avatar VARCHAR(255) COMMENT '头像',
status TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

这种设计在业务初期看起来没问题:

  • 所有用户信息都在一张表里,查询方便
  • 不需要关联查询,性能好
  • 表结构简单,易于理解

但随着业务发展,这种设计会陷入三个典型陷阱。


陷阱一:字段爆炸

当需要支持新的登录方式时(如支付宝、抖音、企业微信),你会不断添加新列:

1
2
3
4
5
6
7
8
9
10
11
-- 第一次迭代:支持支付宝登录
ALTER TABLE sys_user ADD COLUMN alipay_uid VARCHAR(50) COMMENT '支付宝 UID';

-- 第二次迭代:支持抖音登录
ALTER TABLE sys_user ADD COLUMN douyin_openid VARCHAR(64) COMMENT '抖音 OpenID';

-- 第三次迭代:支持企业微信登录
ALTER TABLE sys_user ADD COLUMN work_wechat_userid VARCHAR(50) COMMENT '企业微信 UserID';

-- 第四次迭代:支持 Apple 登录
ALTER TABLE sys_user ADD COLUMN apple_id VARCHAR(100) COMMENT 'Apple ID';

最终表结构变成了一个 “万金油” 表,包含几十个字段,其中大部分字段对于单个用户来说都是 NULL

数据示例:

idusernamepasswordmobileemailwechat_openidgithub_idalipay_uiddouyin_openid
1admin$2a$ 10…13812345678NULLNULLNULLNULLNULL
2NULLNULLNULLuser@example.comoX4Gt5k…NULLNULLNULL
3NULLNULLNULLNULLNULL12345678NULLNULL

问题分析:

  • ❌ 每个用户只使用 1-2 种登录方式,但表中有 10+ 个登录字段
  • ❌ 大量 NULL 值浪费存储空间
  • ❌ 每次新增登录方式都需要执行 ALTER TABLE,在大表上非常危险

陷阱二:索引混乱

为了支持 “通过手机号登录”、“通过微信 OpenID 登录”,你需要为每个登录字段建立唯一索引:

1
2
3
4
5
6
7
CREATE UNIQUE INDEX idx_username ON sys_user(username);
CREATE UNIQUE INDEX idx_mobile ON sys_user(mobile);
CREATE UNIQUE INDEX idx_email ON sys_user(email);
CREATE UNIQUE INDEX idx_wechat_openid ON sys_user(wechat_openid);
CREATE UNIQUE INDEX idx_github_id ON sys_user(github_id);
CREATE UNIQUE INDEX idx_alipay_uid ON sys_user(alipay_uid);
-- ... 更多索引

但这些索引会遇到 NULL 值问题:

MySQL 的唯一索引允许多个 NULL 值。例如:

  • 用户 A 只用微信登录,mobile 字段是 NULL
  • 用户 B 也只用微信登录,mobile 字段也是 NULL
  • 这两个 NULL 值不会触发唯一性冲突

看起来没问题,但实际上存在隐患:

假设用户 A 后来绑定了手机号 13812345678,用户 B 也想绑定同一个手机号。此时:

  • 如果用户 B 的 mobile 字段是 NULL,绑定会成功(因为 NULL 不参与唯一性检查)
  • 但如果用户 B 的 mobile 字段已经有值,绑定会失败

这种不一致的行为会导致严重的业务 Bug

索引膨胀问题:

每个唯一索引都会占用存储空间,并且会降低写入性能。当表中有 10+ 个唯一索引时:

  • 每次 INSERT 都需要检查 10+ 个索引
  • 每次 UPDATE 都需要更新 10+ 个索引
  • 索引文件可能比数据文件还大

陷阱三:查询复杂

当用户登录时,你需要根据登录类型执行不同的 SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 账号密码登录
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, username));

// 手机号登录
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getMobile, mobile));

// 微信登录
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getWechatOpenid, openid));

// GitHub 登录
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getGithubId, githubId));

// 支付宝登录
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getAlipayUid, alipayUid));

代码中充满了 if-else 分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public User login(String loginType, String identifier, String credential) {
User user = null;

if ("username".equals(loginType)) {
user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, identifier));
} else if ("mobile".equals(loginType)) {
user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getMobile, identifier));
} else if ("wechat".equals(loginType)) {
user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getWechatOpenid, identifier));
} else if ("github".equals(loginType)) {
user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getGithubId, identifier));
} else {
throw new RuntimeException("不支持的登录方式");
}

// 验证密码...
return user;
}

问题分析:

  • ❌ 违反开闭原则:新增登录方式需要修改代码
  • ❌ 代码重复:每个分支都是类似的查询逻辑
  • ❌ 难以维护:当登录方式增加到 10+ 种时,代码会变得非常臃肿

单表设计的根本问题

问题的本质:将 “用户主体” 和 “登录凭证” 混在一起。

image-20251229123150728

正确的做法:将 “用户主体” 和 “登录凭证” 分离存储。


19.3.2. 现代方案:账号-认证分离模型(1: N)

业界成熟的解决方案是将 “用户主体” 与 “登录凭证” 分离存储,建立一对多关系。

这里的“认证类型(identity_type)”在概念上对应 19.2 的 AuthType 枚举。为了和 19.2 的工厂/策略体系无缝衔接,我们会统一使用 枚举名风格的字符串(如 PASSWORD/SMS/WECHAT/...)作为数据库存储值,而不是 password/sms/wechat 这种 code 风格。这样 Jackson 多态(authType":"PASSWORD")与数据库存储(identity_type="PASSWORD")可以一条链打通,不再出现“前端传 code,后端用 name”的割裂问题。[1][2]

核心设计理念

mermaid-diagram-2025-12-29-123347

设计理念:

  • 用户主体(sys_user):只存储用户的基本属性,不包含任何登录相关的信息
  • 登录凭证(sys_auth):存储用户的所有登录方式,一个用户可以有多条记录
  • 一对多关系:通过 user_id 关联

用户主体表(sys_user)

这个表只存储用户的基本属性,不包含任何登录相关的信息。

1
2
3
4
5
6
7
8
9
10
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY COMMENT '用户 ID(雪花算法生成)',
nickname VARCHAR(50) NOT NULL COMMENT '用户昵称',
avatar VARCHAR(255) DEFAULT 'https://i.pravatar.cc/300' COMMENT '头像 URL',
status TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用 2-未激活',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户主体表';

关键设计点:

设计点一:ID 生成策略

使用雪花算法(Snowflake)而非数据库自增 ID,确保分布式环境下的全局唯一性。

1
2
@TableId(type = IdType.ASSIGN_ID)  // MyBatis-Plus 自动使用雪花算法
private Long id;

为什么不用自增 ID?

对比维度自增 ID雪花算法
全局唯一性❌ 只在单表内唯一✅ 全局唯一
分布式支持❌ 多数据库实例会冲突✅ 支持分布式
性能✅ 插入性能好⚠️ 需要额外计算
安全性❌ 可以推测用户数量✅ 无法推测

设计点二:极简字段

只保留与业务强相关的字段:

  • nickname:用户昵称(必填)
  • avatar:头像 URL(有默认值)
  • status:账号状态(0-禁用 1-启用 2-未激活)

不包含的字段:

  • username:属于登录凭证,应该在 sys_auth 表
  • password:属于登录凭证,应该在 sys_auth 表
  • mobile:属于登录凭证,应该在 sys_auth 表
  • email:属于登录凭证,应该在 sys_auth 表

设计点三:status 字段的三种状态

状态值含义说明
0禁用管理员手动禁用,无法登录
1启用正常状态,可以登录
2未激活注册后未激活邮箱,无法登录

授权凭证表(sys_auth)

这个表存储用户的所有登录方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE sys_auth (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
user_id BIGINT NOT NULL COMMENT '关联的用户 ID',
identity_type VARCHAR(20) NOT NULL COMMENT '认证类型:PASSWORD/MOBILE/WECHAT/GITHUB',
identifier VARCHAR(100) NOT NULL COMMENT '标识符(账号/手机号/OpenID)',
credential VARCHAR(255) COMMENT '凭证(密码哈希/Token,OAuth 登录可为空)',
verified TINYINT DEFAULT 0 COMMENT '是否已验证:0-未验证 1-已验证',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

-- 联合唯一索引:同一类型的标识符不能重复
UNIQUE KEY uk_type_identifier (identity_type, identifier),

-- 普通索引:方便根据 user_id 查询该用户的所有登录方式
KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户认证凭证表';

关键设计点:

设计点一:identity_type 字段

这个字段标识登录方式的类型,对应 19.2 中定义的 AuthType 枚举。

identity_type含义identifier 示例credential 示例
PASSWORD账号密码登录admin2a10… (BCrypt 哈希)
MOBILE手机号登录13812345678NULL(验证码登录无需存储密码)
EMAIL邮箱登录user@example.comNULL(本章用邮箱做激活与找回,不直接走邮箱密码登录)
WECHAT微信登录oX4Gt5k…NULL(或存储微信 Token)
GITHUBGitHub 登录12345678NULL(GitHub ID 本身就是标识符)

小提醒:你会发现这里的类型比 19.2 的 AuthType 多。这不是推翻 19.2,而是“业务真实落地”必然会扩展类型集合。最优雅的方式是:继续沿用 19.2 的 AuthType 枚举做集中管理,按需补充枚举值(保持 name() 用于协议与存储,code 用于前端展示或兼容)。[1][2]

设计点二:identifier 字段

这个字段存储登录标识符,根据 identity_type 的不同,存储的内容也不同:

  • PASSWORD:存储用户名(如 admin)
  • MOBILE:存储手机号(如 13812345678)
  • EMAIL:存储邮箱(如 user@example.com)
  • WECHAT:存储微信 OpenID(如 oX4Gt5k...)
  • GITHUB:存储 GitHub ID(如 12345678)

设计点三:credential 字段

这个字段存储登录凭证,根据 identity_type 的不同,存储的内容也不同:

  • PASSWORD:存储 BCrypt 加密后的密码哈希
  • MOBILE:通常为 NULL(验证码登录无需存储密码)
  • EMAIL:通常为 NULL(本章用于激活/找回流程,不作为“邮箱+密码”登录凭证)
  • WECHAT:通常为 NULL(或存储微信 Access Token)
  • GITHUB:通常为 NULL(GitHub ID 本身就是标识符)

设计点四:verified 字段

这个字段标识该登录方式是否已验证:

  • 0:未验证(如邮箱未激活、手机号未验证)
  • 1:已验证

为什么需要这个字段?

假设用户注册时填写了邮箱,但还没有点击激活链接。此时:

  • sys_user 表中有一条记录(status = 2 未激活)
  • sys_auth 表中有一条记录(identity_type = EMAIL, verified = 0)

当用户点击激活链接后:

  • sys_user 表的 status 更新为 1(启用)
  • sys_auth 表的 verified 更新为 1(已验证)

数据示例:一个用户的多种登录方式

假设用户 “张三” 先用账号密码注册,后来又绑定了手机号、邮箱、微信和 GitHub。

sys_user 表:

idnicknameavatarstatuscreate_time
1748392847362张三https://cdn…/avatar.jpg12025-01-10 10:00:00

sys_auth 表:

iduser_ididentity_typeidentifiercredentialverifiedcreate_time
11748392847362PASSWORDzhangsan2a​10rQ7R8k…12025-01-10 10:00:00
21748392847362MOBILE13812345678NULL12025-01-10 10:05:00
31748392847362EMAILzhangsan@example.comNULL12025-01-10 10:10:00
41748392847362WECHAToX4Gt5k…NULL12025-01-10 10:15:00
51748392847362GITHUB12345678NULL12025-01-10 10:20:00

通过这种设计:

  • ✅ 新增登录方式只需在 sys_auth 表插入一条记录,无需修改表结构
  • ✅ 每种登录方式都有明确的 identity_type 标记,查询时非常清晰
  • ✅ 用户可以同时拥有多种登录方式,系统会自动关联到同一个 user_id
  • ✅ 没有 NULL 值浪费存储空间

索引与性能优化策略

数据模型设计完成后,索引设计直接决定了查询性能和数据一致性。

索引一:联合唯一索引(核心)

1
UNIQUE KEY uk_type_identifier (identity_type, identifier)

作用:保证 “同一种类型的标识符全局唯一”。

示例:

  • 手机号 13812345678 只能被一个用户绑定(identity_type=MOBILE, identifier=13812345678)
  • 微信 OpenID oX4Gt5k... 也只能被一个用户绑定(identity_type=WECHAT, identifier=oX4Gt5k...)

如果另一个用户尝试绑定已被占用的手机号,数据库会抛出唯一性冲突错误:

1
Duplicate entry 'MOBILE-13812345678' for key 'uk_type_identifier'

业务代码需要捕获这个异常并返回友好提示:

1
2
3
4
5
try {
authMapper.insert(auth);
} catch (DuplicateKeyException e) {
throw new RuntimeException("该手机号已被其他账号绑定");
}

索引二:普通索引

1
KEY idx_user_id (user_id)

作用:方便根据 user_id 查询该用户的所有登录方式。

示例:

1
2
3
// 查询用户的所有登录方式
List<Auth> authList = authMapper.selectList(new LambdaQueryWrapper<Auth>()
.eq(Auth::getUserId, userId));

本节小结

我们完成了领域模型的设计。

核心成果:

步骤操作产出
1分析传统单表设计的弊端理解字段爆炸、索引混乱、查询复杂三大陷阱
2设计 sys_user 表用户主体表(极简字段)
3设计 sys_auth 表登录凭证表(1: N 关系)
4设计索引策略联合唯一索引 + 普通索引 + 覆盖索引
5讨论分库分表策略千万级用户的扩展方案

表结构对比:

对比维度单表设计账号-认证分离
表数量1 张表2 张表
字段数量10+ 个字段sys_user: 5 个字段
sys_auth: 7 个字段
NULL 值大量 NULL 值几乎没有 NULL 值
新增登录方式需要 ALTER TABLE只需 INSERT 一条记录
索引数量10+ 个唯一索引1 个联合唯一索引 + 1 个普通索引
查询复杂度需要 if-else 分支统一查询 sys_auth 表
扩展性❌ 难以扩展✅ 易于扩展

索引速查表:

索引名称索引类型索引列作用
uk_type_identifier联合唯一索引(identity_type, identifier)保证同一类型的标识符全局唯一
idx_user_id普通索引user_id根据用户 ID 查询所有登录方式

现在,我们已经完成了数据建模。在下一节中,我们将实现 MyBatis-Plus 的实体类和 Mapper。


19.3.3. 持久层实现:MyBatis-Plus 快速搭建

在上一节中,我们设计了 sys_user 和 sys_auth 两张表。现在我们需要使用 MyBatis-Plus 快速搭建持久层。

本节会严格沿用 19.2 的单模块工程结构:
auth-factory-demo/src/main/java/com/example/auth/...
我们不会引入 auth-parent/auth-core/auth-web 这种多模块拆分,避免包名、启动类、配置文件路径和 19.2 出现断层。[1]

引入依赖

📄 文件路径:pom.xml(追加/整合)

打开你在 19.2 中已经创建好的 pom.xml。如果你已经引入了 spring-boot-starter-web、Sa-Token、Redis、Validation、Lombok 等依赖,不要重复添加——只需要把 MyBatis-Plus、MySQL 驱动补上即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dependencies>
<!-- ========================= -->
<!-- 19.2 已有依赖(略):Web、Validation、Redis、Sa-Token、Lombok... -->
<!-- ========================= -->

<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>

<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>

<!-- (可选)如果你想更直观地看 SQL 日志,可以先不引入连接池,Spring Boot 默认即可 -->
</dependencies>

配置数据源

📄 文件路径:src/main/resources/application.yml(追加)

在 19.2 的 application.yml 基础上追加 MySQL 与 MyBatis-Plus 配置。注意:Sa-Token 仍然是主会话体系,所以这里不配置 JWT 相关内容,避免和 19.2 的“策略-工厂-SaToken”职责边界冲突。[1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
server:
port: 8080

spring:
application:
name: auth-factory-demo

data:
redis:
host: localhost
port: 6379
password:
database: 0
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms

datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/auth_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
username: root
password: root

# Sa-Token 配置(沿用 19.2)
sa-token:
token-name: Authorization
timeout: 86400
active-timeout: 1800
is-concurrent: true
is-share: false
token-style: uuid
is-log: true

# 认证配置(可选:用于控制启用哪些登录方式)
auth:
enabled-types:
- PASSWORD
- SMS
# - WECHAT # 注释掉即可禁用

# MyBatis-Plus 配置
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: ASSIGN_ID

创建数据库和表

📄 文件路径:src/main/resources/sql/schema.sql(新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 创建数据库
CREATE DATABASE IF NOT EXISTS auth_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE auth_db;

-- 用户主体表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY COMMENT '用户 ID(雪花算法生成)',
nickname VARCHAR(50) NOT NULL COMMENT '用户昵称',
avatar VARCHAR(255) DEFAULT 'https://cdn.example.com/default-avatar.png' COMMENT '头像 URL',
status TINYINT DEFAULT 2 COMMENT '状态:0-禁用 1-启用 2-未激活',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

KEY idx_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户主体表';

-- 用户认证凭证表
CREATE TABLE sys_auth (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键 ID',
user_id BIGINT NOT NULL COMMENT '关联的用户 ID',
identity_type VARCHAR(20) NOT NULL COMMENT '认证类型:PASSWORD/MOBILE/EMAIL/WECHAT/GITHUB',
identifier VARCHAR(100) NOT NULL COMMENT '标识符(账号/手机号/邮箱/OpenID)',
credential VARCHAR(255) COMMENT '凭证(密码哈希/Token,OAuth 登录可为空)',
verified TINYINT DEFAULT 0 COMMENT '是否已验证:0-未验证 1-已验证',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

UNIQUE KEY uk_type_identifier (identity_type, identifier),
KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户认证凭证表';

创建实体类(沿用 19.2 工程结构)

为了与 19.2 的包结构保持一致,我们在同一个模块下新增 entity 包。

📄 文件路径:src/main/java/com/example/auth/entity/User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.example.auth.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

/**
* 用户主体实体类
*/
@Data
@TableName("sys_user")
public class User {

/**
* 用户 ID(雪花算法生成)
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;

/**
* 用户昵称
*/
private String nickname;

/**
* 头像 URL
*/
private String avatar;

/**
* 状态:0-禁用 1-启用 2-未激活
*/
private Integer status;

/**
* 创建时间
*/
private LocalDateTime createTime;

/**
* 更新时间
*/
private LocalDateTime updateTime;
}

📄 文件路径:src/main/java/com/example/auth/entity/Auth.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.example.auth.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.time.LocalDateTime;

/**
* 用户认证凭证实体类
*/
@Data
@TableName("sys_auth")
public class Auth {

/**
* 主键 ID
*/
@TableId(type = IdType.AUTO)
private Long id;

/**
* 关联的用户 ID
*/
private Long userId;

/**
* 认证类型:PASSWORD/MOBILE/EMAIL/WECHAT/GITHUB
* 与 19.2 的 AuthType.name() 对齐(建议存枚举名)
*/
private String identityType;

/**
* 标识符(账号/手机号/邮箱/OpenID)
*/
private String identifier;

/**
* 凭证(密码哈希/Token,OAuth 登录可为空)
*/
private String credential;

/**
* 是否已验证:0-未验证 1-已验证
*/
private Integer verified;

/**
* 创建时间
*/
private LocalDateTime createTime;

/**
* 更新时间
*/
private LocalDateTime updateTime;
}

创建 Mapper 接口(同模块,同包体系)

📄 文件路径:src/main/java/com/example/auth/mapper/UserMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.auth.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.auth.entity.User;
import org.apache.ibatis.annotations.Mapper;

/**
* 用户 Mapper
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

📄 文件路径:src/main/java/com/example/auth/mapper/AuthMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.auth.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.auth.entity.Auth;
import org.apache.ibatis.annotations.Mapper;

/**
* 认证凭证 Mapper
*/
@Mapper
public interface AuthMapper extends BaseMapper<Auth> {
}

配置 Mapper 扫描

📄 文件路径:src/main/java/com/example/auth/AuthFactoryApplication.java(修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.auth;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.example.auth.mapper")
public class AuthFactoryApplication {

public static void main(String[] args) {
SpringApplication.run(AuthFactoryApplication.class, args);
}
}

测试持久层

📄 文件路径:src/test/java/com/example/auth/mapper/UserMapperTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.example.auth.mapper;

import com.example.auth.entity.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class UserMapperTest {

@Autowired
private UserMapper userMapper;

@Test
public void testInsert() {
User user = new User();
user.setNickname("测试用户");
user.setAvatar("https://cdn.example.com/avatar.jpg");
user.setStatus(1);

userMapper.insert(user);
System.out.println("插入成功,用户 ID: " + user.getId());
}

@Test
public void testSelect() {
User user = userMapper.selectById(1L);
System.out.println("查询结果: " + user);
}
}

19.3.4. 密码加密

在上一节中,我们完成了持久层的搭建。现在我们需要实现密码加密功能。

为什么不能用 MD5?

很多初学者在实现密码加密时,会使用 MD5:

1
2
3
String password = "123456";
String md5Hash = DigestUtils.md5Hex(password);
// 输出: e10adc3949ba59abbe56e057f20f883e

这种做法是极其危险的,原因有三:

原因一:MD5 是哈希算法,不是加密算法

  • 哈希算法:单向函数,无法解密
  • 加密算法:双向函数,可以解密

MD5 的设计目的是 数据完整性校验,而不是密码存储。

原因二:彩虹表攻击

彩虹表(Rainbow Table)是一个预先计算好的哈希值数据库。攻击者可以通过查表的方式,快速破解 MD5 哈希。

示例:

假设数据库泄漏,攻击者获取到以下数据:

usernamepassword_md5
admine10adc3949ba59abbe56e057f20f883e
user15f4dcc3b5aa765d61d8327deb882cf99

攻击者只需要在彩虹表中查询这两个哈希值:

1
2
e10adc3949ba59abbe56e057f20f883e -> 123456
5f4dcc3b5aa765d61d8327deb882cf99 -> password

几秒钟内就能破解所有密码


加盐(Salt)能解决问题吗?

有些开发者会在 MD5 的基础上加盐:

1
2
3
4
5
String password = "123456";
String salt = "random_salt_123";
String saltedPassword = password + salt;
String md5Hash = DigestUtils.md5Hex(saltedPassword);
// 输出: 7c6a180b36896a0a8c02787eeafb0e4c

这种做法比纯 MD5 好一些,但仍然存在问题:

问题一:盐值存储

盐值必须存储在数据库中,否则无法验证密码。如果数据库泄漏,攻击者可以获取盐值,然后针对每个用户生成专属的彩虹表。

问题二:盐值固定

如果所有用户使用相同的盐值,攻击者只需要生成一次彩虹表,就能破解所有密码。

问题三:计算速度太快

MD5 的计算速度非常快,攻击者可以使用 GPU 进行暴力破解。现代 GPU 每秒可以计算数十亿次 MD5 哈希。


BCrypt 的三大优势

BCrypt 是一种专门为密码存储设计的哈希算法,它解决了 MD5 的所有问题。

优势一:自动加盐

BCrypt 会自动生成随机盐值,并将盐值嵌入到哈希值中。

1
2
3
String password = "123456";
String bcryptHash = BCrypt.hashpw(password, BCrypt.gensalt());
// 输出: $2a$ 10$rQ7R8kN5J6X7Z8Y9W0V1U.2Q3R4S5T6U7V8W9X0Y1Z2A3B4C5D6E7F

哈希值的结构:

1
2
3
4
5
6
$2a$10$rQ7R8kN5J6X7Z8Y9W0V1U.2Q3R4S5T6U7V8W9X0Y1Z2A3B4C5D6E7F
| | | |
| | | +-- 哈希值(31 字符)
| | +-- 盐值(22 字符)
| +-- 成本因子(10)
+-- 算法版本(2a)

关键点:盐值和哈希值存储在一起,不需要单独存储盐值。

优势二:慢哈希(Slow Hash)

BCrypt 的计算速度非常慢,这是故意设计的。

1
2
3
4
5
// MD5:每秒可以计算数十亿次
String md5Hash = DigestUtils.md5Hex("123456");

// BCrypt:每秒只能计算几十次
String bcryptHash = BCrypt.hashpw("123456", BCrypt.gensalt(10));

为什么要慢?

  • 对于正常用户:登录时只需要计算一次,慢 0.1 秒完全可以接受
  • 对于攻击者:暴力破解需要计算数百万次,慢 0.1 秒意味着破解时间从几小时变成几年

优势三:自适应(Adaptive)

BCrypt 的成本因子(Cost Factor)可以调整,随着硬件性能的提升,可以增加成本因子,保持相同的安全性。

1
2
3
4
5
// 成本因子 10:每次哈希需要 2^10 = 1024 次迭代
String hash10 = BCrypt.hashpw("123456", BCrypt.gensalt(10));

// 成本因子 12:每次哈希需要 2^12 = 4096 次迭代
String hash12 = BCrypt.hashpw("123456", BCrypt.gensalt(12));

成本因子对照表:

成本因子迭代次数计算时间(单核)适用场景
101024~0.1 秒开发环境
124096~0.4 秒生产环境(推荐)
1416384~1.6 秒高安全场景
1665536~6.4 秒极高安全场景

建议:生产环境使用成本因子 12。


BCrypt 实战

引入依赖:

📄 文件路径:pom.xml(追加)

1
2
3
4
5
<!-- Spring Security Crypto(包含 BCrypt) -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>

封装 PasswordEncoder 工具类:

📄 文件路径:src/main/java/com/example/auth/util/PasswordEncoder.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.example.auth.util;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;

/**
* 密码加密工具类
* 封装 BCrypt 算法
*/
@Component
public class PasswordEncoder {

private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);

/**
* 加密密码
*
* @param rawPassword 明文密码
* @return BCrypt 哈希值
*/
public String encode(String rawPassword) {
return encoder.encode(rawPassword);
}

/**
* 验证密码
*
* @param rawPassword 明文密码
* @param encodedPassword BCrypt 哈希值
* @return true 表示密码正确,false 表示密码错误
*/
public boolean matches(String rawPassword, String encodedPassword) {
return encoder.matches(rawPassword, encodedPassword);
}
}

测试 BCrypt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void testBCrypt() {
PasswordEncoder encoder = new PasswordEncoder();

// 加密密码
String rawPassword = "123456";
String hash1 = encoder.encode(rawPassword);
String hash2 = encoder.encode(rawPassword);

System.out.println("哈希值 1: " + hash1);
System.out.println("哈希值 2: " + hash2);

// 验证密码
System.out.println("验证结果 1: " + encoder.matches(rawPassword, hash1));
System.out.println("验证结果 2: " + encoder.matches(rawPassword, hash2));
System.out.println("验证错误密码: " + encoder.matches("wrong", hash1));
}

输出:

1
2
3
4
5
哈希值 1: $2a$12$rQ7R8kN5J6X7Z8Y9W0V1U.2Q3R4S5T6U7V8W9X0Y1Z2A3B4C5D6E7F
哈希值 2: $2a$12$aB3C4d5E6f7G8h9I0j1K2.3L4M5N6o7P8q9R0s1T2u3V4w5X6y7Z8
验证结果 1: true
验证结果 2: true
验证错误密码: false

关键点:

  • 同一个密码,每次加密的哈希值都不同(因为盐值不同)
  • 但验证时都能通过(因为盐值嵌入在哈希值中)

19.3.5. 验证码服务:防止爬虫批量注册

在上一节中,我们实现了密码加密功能。现在我们需要实现验证码服务,防止爬虫批量注册。

为什么需要验证码?

场景一:防止爬虫批量注册

如果没有验证码,攻击者可以编写脚本,批量注册大量账号:

1
2
3
4
5
6
7
8
import requests

for i in range(10000):
requests.post('http://localhost:8080/auth/register', json={
'username': f'user{i}',
'password': '123456',
'email': f'user{i}@example.com'
})

几分钟内就能注册数万个账号,导致:

  • 数据库被垃圾数据填满
  • 邮件服务器被大量激活邮件占用
  • 正常用户无法注册(用户名被占用)

场景二:防止暴力破解登录

如果没有验证码,攻击者可以编写脚本,暴力破解密码:

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests

passwords = ['123456', 'password', '123456789', ...]

for password in passwords:
response = requests.post('http://localhost:8080/auth/login', json={
'authType': 'PASSWORD',
'username': 'admin',
'password': password
})
if response.status_code == 200:
print(f'密码破解成功: {password}')
break

验证码的作用:

  • ✅ 增加自动化攻击的成本
  • ✅ 区分人类用户和机器人
  • ✅ 保护系统资源

验证码类型对比

没问题,这是精简后的版本,只保留了目前 最主流的 4 种 验证码类型,方便你直接放入文档:

常见验证码类型对比

验证码类型安全性用户体验核心适用场景
图形验证码⭐⭐简单的后台系统、低频操作
滑动验证码⭐⭐⭐⭐⭐⭐⭐网站登录、注册(目前的行业标准)
点选验证码⭐⭐⭐⭐支付确认、高风险拦截(如汉字/图标顺序点选)
无感验证⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐全场景防护(通过鼠标轨迹/设备指纹后台判定)

本章只实现最基础的图形验证码。随着安全需求的升级,现代应用通常采用更智能的验证方式。如果你需要进阶方案,请参考以下文章:

《滑动拼图验证》:详解前端 Canvas 抠图与后端坐标校验逻辑。

《点选/旋转验证》:应对 OCR 破解的高安全方案。

Spring Boot 3 滑动/旋转/滑动还原/文字点选验证码集成:Tianai-Captcha 快速接入指南 | Prorise - 博客小栈

《无感/行为验证》:接入 Cloudflare Turnstile

Spring Boot 3 无感验证码集成:Cloudflare Turnstile 快速接入指南


图形验证码生成

引入依赖:

Hutool 提供了封装好的验证码服务,我们仅需要转化为业务功能即可。

📄 文件路径:pom.xml(追加)

1
2
3
4
5
6
<!-- Hutool(包含验证码生成) -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.24</version>
</dependency>

定义 Redis Key 常量:

📄 文件路径:src/main/java/com/example/auth/constant/RedisKeyConstants.java(追加)

1
2
3
4
5
6
7
8
9
10
11
package com.example.auth.constant;

/**
* Redis Key 常量类
*/
public class RedisKeyConstants {
/**
* 验证码相关 Key 前缀
*/
public static final String CAPTCHA_PREFIX = "auth:captcha:";
}

配置验证码参数:

📄 文件路径:src/main/resources/application.yml(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 认证配置(可选:用于控制启用哪些登录方式)
auth:
enabled-types:
- PASSWORD
- SMS
# - WECHAT # 注释掉即可禁用
# 验证码配置
captcha:
# 验证码有效期(分钟)
expire-minutes: 5
# 验证码图片宽度
width: 200
# 验证码图片高度
height: 100
# 验证码字符数量
code-count: 4
# 干扰线数量
line-count: 20

实现验证码服务:

📄 文件路径:src/main/java/com/example/auth/service/CaptchaService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
package com.example.auth.service;

import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.LineCaptcha;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

/**
* 验证码服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CaptchaService {
private final StringRedisTemplate redisTemplate;

@Value("${auth.captcha.expire-minutes:5}")
private long expireMinutes; // 验证码有效期(分钟)
@Value("${auth.captcha.width:200}")
private int width; // 验证码宽度
@Value("${auth.captcha.height:100}")
private int height; // 验证码高度
@Value("${auth.captcha.code-count:4}")
private int codeCount; // 验证码字符数量
@Value("${auth.captcha.line-count:20}")
private int lineCount; // 验证码干扰线数量

/**
* 生成验证码
*
* @param key 验证码 Key(通常是用户的唯一标识,如 sessionId 或 UUID)
* @return 验证码图片的 Base64 编码
*/
public String generateCaptcha(String key) {
// 1. 生成验证码图片
LineCaptcha captcha = CaptchaUtil.createLineCaptcha(width, height, codeCount, lineCount);

// 2. 获取验证码文本
String code = captcha.getCode();

log.info("生成验证码: key={}, code={}", key, code);
// 3. 将验证码存入 Redis
String redisKey = "auth:captcha:" + key;
redisTemplate.opsForValue().set(redisKey, code, expireMinutes, TimeUnit.MINUTES);
// 4. 返回验证码图片的 Base64 编码
return captcha.getImageBase64();
}

/**
* 验证验证码
*
* @param key 验证码 Key
* @param code 用户输入的验证码
* @return true 表示验证通过,false 表示验证失败
*/
public boolean verifyCaptcha(String key, String code) {
// 1. 从 Redis 中获取验证码
String redisKey = "auth:captcha:" + key;
String storedCode = redisTemplate.opsForValue().get(redisKey);
// 2. 验证码不存在或已过期
if (storedCode == null) {
log.warn("验证码不存在或已过期: key={}", key);
return false;
}
// 3. 验证码错误(忽略大小写)
if (!storedCode.equalsIgnoreCase(code)) {
log.warn("验证码错误: key={}, expected={}, actual={}", key, storedCode, code);
return false;
}
// 4. 验证通过,立即删除验证码(一次性使用)
redisTemplate.delete(redisKey);
log.info("验证码验证通过: key={}", key);
return true;
}
}

实现验证码接口:

📄 文件路径:src/main/java/com/example/auth/controller/CaptchaController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.example.auth.controller;

import cn.hutool.core.util.IdUtil;
import com.example.auth.common.Result;
import com.example.auth.service.CaptchaService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

/**
* 验证码控制器
*/
@Slf4j
@RestController
@RequestMapping("/captcha")
@RequiredArgsConstructor
public class CaptchaController {
private final CaptchaService captchaService;

/**
* 生成验证码
*
* @return 验证码图片的 Base64 编码和验证码 Key
*/
@GetMapping("/generate")
public Result<Map<String, String>> generate() {
// 生成唯一的验证码 Key
String key = IdUtil.fastSimpleUUID();

// 生成验证码图片
String imageBase64 = captchaService.generateCaptcha(key);

// 返回验证码 Key 和图片
Map<String, String> data = new HashMap<>();
data.put("key", key);
data.put("image", "data:image/png;base64," + imageBase64);
return Result.ok(data);
}
/**
* 验证验证码(测试接口)
*
* @param key 验证码 Key
* @param code 用户输入的验证码
* @return 验证结果
*/
@GetMapping("/verify")
public Result<Map<String, Boolean>> verify(@RequestParam String key, @RequestParam String code) {
boolean valid = captchaService.verifyCaptcha(key, code);

Map<String, Boolean> data = new HashMap<>();
data.put("valid", valid);

return Result.ok(data);
}
}

Postman 测试:

步骤 1:生成验证码

  • 方法:GET
  • URL:http://localhost:8080/captcha/generate

响应示例:

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"key": "a1b2c3d4e5f6g7h8",
"image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAABkCAYAAADDhn8LAA..."
}
}

步骤 2:在浏览器中查看验证码图片

image 字段的值复制到浏览器地址栏,可以看到验证码图片。

步骤 3:验证验证码

  • 方法:GET
  • URL:http://localhost:8080/captcha/verify?key=a1b2c3d4e5f6g7h8&code=ABCD

响应示例:

1
2
3
4
5
6
7
{
"code": 200,
"message": "操作成功",
"data": {
"valid": true
}
}

19.3.6. 邮箱服务:激活链接与异步发送

在上一节中,我们实现了验证码服务。现在我们需要实现邮箱服务,用于发送激活邮件。

若对于邮件系列感兴趣,可跳转至

邮件生态系列

Spring Mail 配置

引入依赖:

📄 文件路径:pom.xml(追加)

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.simplejavamail</groupId>
<artifactId>simple-java-mail</artifactId>
<version>8.5.1</version>
</dependency>

<dependency>
<groupId>org.simplejavamail</groupId>
<artifactId>spring-module</artifactId>
<version>8.5.1</version>
</dependency>

配置邮件服务:

📄 文件路径:src/main/resources/application.yml(追加)

1
2
3
4
5
6
7
8
9
10
11
12
spring:
mail:
host: smtp.qq.com
port: 587
username: 你的邮箱@qq.com
password: 授权码
properties:
mail:
smtp:
auth: true
starttls:
enable: true

如何获取 QQ 邮箱授权码?

  1. 登录 QQ 邮箱
  2. 点击 “设置” → “账户”
  3. 找到 “POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV 服务”
  4. 开启 “POP3/SMTP 服务” 或 “IMAP/SMTP 服务”
  5. 点击 “生成授权码”
  6. 将授权码复制到配置文件中

其他邮箱配置:

邮箱服务商SMTP 服务器端口
QQ 邮箱smtp.qq.com587
163 邮箱smtp.163.com465
Gmailsmtp.gmail.com587
Outlooksmtp.office365.com587

激活链接生成(HMAC 签名)

为什么需要 HMAC 签名?

假设我们生成的激活链接是这样的:

1
http://localhost:8080/auth/activate?userId=1748392847362

攻击者可以轻易伪造激活链接:

1
2
3
http://localhost:8080/auth/activate?userId=1
http://localhost:8080/auth/activate?userId=2
http://localhost:8080/auth/activate?userId=3

通过遍历 userId,攻击者可以激活所有用户的账号

解决方案:HMAC 签名

我们在激活链接中添加一个签名参数:

1
http://localhost:8080/auth/activate?userId=1748392847362&sign=a1b2c3d4e5f6g7h8

签名的生成逻辑:

1
String sign = HMAC_SHA256(userId + timestamp + secret);

攻击者无法伪造签名,因为他不知道 secret

实现 HMAC 工具类:

📄 文件路径:src/main/java/com/example/auth/util/HmacUtil.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.auth.util;

import cn.hutool.crypto.SecureUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/**
* HMAC 签名工具类
*/
@Component
public class HmacUtil {

/**
* 密钥(从配置文件读取)
*/
@Value("${auth.hmac.secret:default_secret_key_change_in_production}")
private String secret;

/**
* 生成签名
*
* @param data 待签名的数据
* @return 签名字符串
*/
public String sign(String data) {
return SecureUtil.hmacSha256(secret).digestHex(data);
}

/**
* 验证签名
*
* @param data 待验证的数据
* @param sign 签名字符串
* @return true 表示签名有效,false 表示签名无效
*/
public boolean verify(String data, String sign) {
String expectedSign = sign(data);
return expectedSign.equals(sign);
}
}

配置密钥:

📄 文件路径:src/main/resources/application.yml(追加)

1
2
3
4
auth:
hmac:
# HMAC 密钥(生产环境必须修改!)
secret: your_secret_key_change_in_production

Spring Event 异步发送

为什么需要异步发送?

如果同步发送邮件,用户注册时的流程是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sequenceDiagram
participant User as 用户
participant Controller as Controller
participant Service as UserService
participant Mail as MailService

User->>Controller: POST /auth/register
Controller->>Service: 注册用户
Service->>Service: 插入数据库
Service->>Mail: 发送激活邮件
Note over Mail: 发送邮件需要 2-5 秒
Mail-->>Service: 发送成功
Service-->>Controller: 注册成功
Controller-->>User: 返回响应

Note over User: 用户等待 2-5 秒

用户需要等待 2-5 秒才能收到响应,体验很差。

异步发送的流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
sequenceDiagram
participant User as 用户
participant Controller as Controller
participant Service as UserService
participant Event as Spring Event
participant Listener as MailListener
participant Mail as MailService

User->>Controller: POST /auth/register
Controller->>Service: 注册用户
Service->>Service: 插入数据库
Service->>Event: 发布事件
Event-->>Service: 立即返回
Service-->>Controller: 注册成功
Controller-->>User: 返回响应

Note over User: 用户立即收到响应

Event->>Listener: 异步处理事件
Listener->>Mail: 发送激活邮件
Note over Mail: 发送邮件需要 2-5 秒
Mail-->>Listener: 发送成功

用户立即收到响应,邮件在后台异步发送

占位符:Spring Event 机制详解

本章只演示 Spring Event 的基本用法。如果你想深入理解 Spring Event 的原理、最佳实践、事务管理等内容,请参考以下文章:

  • 《Spring Event 事件驱动架构:从入门到精通》
  • 《Spring Event 与事务:如何保证事件发布的可靠性》
  • 《Spring Event 性能优化:异步线程池配置与监控》

定义邮件发送事件

📄 文件路径:src/main/java/com/example/auth/event/EmailEvent.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.auth.event;

import lombok.Getter;
import org.springframework.context.ApplicationEvent;

/**
* 邮件发送事件
*/
@Getter
public class EmailEvent extends ApplicationEvent {

/**
* 收件人邮箱
*/
private final String to;

/**
* 邮件主题
*/
private final String subject;

/**
* 邮件内容(HTML 格式)
*/
private final String content;

public EmailEvent(Object source, String to, String subject, String content) {
super(source);
this.to = to;
this.subject = subject;
this.content = content;
}
}

实现邮件发送监听器

📄 文件路径:src/main/java/com/example/auth/listener/EmailEventListener.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.example.auth.listener;

import com.example.auth.event.EmailEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;

/**
* 邮件发送监听器
* 监听 EmailEvent 事件,异步发送邮件
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailEventListener {

private final JavaMailSender mailSender;

/**
* 处理邮件发送事件
*
* @param event 邮件事件
*/
@Async
@EventListener
public void handleEmailEvent(EmailEvent event) {
try {
log.info("开始发送邮件: to={}, subject={}", event.getTo(), event.getSubject());

// 创建邮件消息
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

// 设置发件人(从配置文件读取)
helper.setFrom("your_email@qq.com");
// 设置收件人
helper.setTo(event.getTo());
// 设置邮件主题
helper.setSubject(event.getSubject());
// 设置邮件内容(HTML 格式)
helper.setText(event.getContent(), true);

// 发送邮件
mailSender.send(message);

log.info("邮件发送成功: to={}", event.getTo());
} catch (MessagingException e) {
log.error("邮件发送失败: to={}, error={}", event.getTo(), e.getMessage(), e);
}
}
}

关键注解:

  • @EventListener:标记这是一个事件监听器
  • @Async:标记这是一个异步方法

配置异步线程池

📄 文件路径:src/main/java/com/example/auth/config/AsyncConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.auth.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

/**
* 异步配置
* 配置 Spring Event 的异步线程池
*/
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数
executor.setCorePoolSize(5);
// 最大线程数
executor.setMaxPoolSize(10);
// 队列容量
executor.setQueueCapacity(100);
// 线程名称前缀
executor.setThreadNamePrefix("async-email-");
// 初始化
executor.initialize();
return executor;
}
}

实现邮件服务

📄 文件路径:src/main/java/com/example/auth/service/EmailService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
package com.example.auth.service;

import com.example.auth.event.EmailEvent;
import com.example.auth.util.HmacUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

/**
* 邮件服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {

private final ApplicationEventPublisher eventPublisher;
private final HmacUtil hmacUtil;

@Value("${server.port:8080}")
private String serverPort;

/**
* 发送激活邮件
*
* @param email 收件人邮箱
* @param userId 用户 ID
*/
public void sendActivationEmail(String email, Long userId) {
// 1. 生成激活链接
String activationUrl = generateActivationUrl(userId);

// 2. 构建邮件内容(HTML 格式)
String content = buildActivationEmailContent(activationUrl);

// 3. 发布邮件事件(异步发送)
EmailEvent event = new EmailEvent(this, email, "账号激活", content);
eventPublisher.publishEvent(event);

log.info("激活邮件事件已发布: email={}, userId={}", email, userId);
}

/**
* 生成激活链接
*
* @param userId 用户 ID
* @return 激活链接
*/
private String generateActivationUrl(Long userId) {
// 1. 生成时间戳(有效期 24 小时)
long timestamp = System.currentTimeMillis() + 24 * 60 * 60 * 1000;

// 2. 生成签名
String data = userId + ":" + timestamp;
String sign = hmacUtil.sign(data);

// 3. 构建激活链接
return String.format("http://localhost:%s/auth/activate?userId=%d&timestamp=%d&sign=%s",
serverPort, userId, timestamp, sign);
}

/**
* 构建激活邮件内容(HTML 格式)
*
* @param activationUrl 激活链接
* @return 邮件内容
*/
private String buildActivationEmailContent(String activationUrl) {
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #4CAF50; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background-color: #f9f9f9; }
.button { display: inline-block; padding: 10px 20px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 5px; }
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>欢迎注册</h1>
</div>
<div class="content">
<p>您好!</p>
<p>感谢您注册我们的服务。请点击下方按钮激活您的账号:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="%s" class="button">激活账号</a>
</p>
<p>或者复制以下链接到浏览器打开:</p>
<p style="word-break: break-all; color: #666;">%s</p>
<p style="color: #999; font-size: 12px;">此链接 24 小时内有效,请尽快激活。</p>
</div>
<div class="footer">
<p>这是一封自动发送的邮件,请勿回复。</p>
</div>
</div>
</body>
</html>
""".formatted(activationUrl, activationUrl);
}

/**
* 发送找回密码邮件
*
* @param email 收件人邮箱
* @param userId 用户 ID
*/
public void sendResetPasswordEmail(String email, Long userId) {
// 1. 生成重置密码链接
String resetUrl = generateResetPasswordUrl(userId);

// 2. 构建邮件内容(HTML 格式)
String content = buildResetPasswordEmailContent(resetUrl);

// 3. 发布邮件事件(异步发送)
EmailEvent event = new EmailEvent(this, email, "重置密码", content);
eventPublisher.publishEvent(event);

log.info("重置密码邮件事件已发布: email={}, userId={}", email, userId);
}

/**
* 生成重置密码链接
*
* @param userId 用户 ID
* @return 重置密码链接
*/
private String generateResetPasswordUrl(Long userId) {
// 1. 生成时间戳(有效期 1 小时)
long timestamp = System.currentTimeMillis() + 60 * 60 * 1000;

// 2. 生成签名
String data = userId + ":" + timestamp;
String sign = hmacUtil.sign(data);

// 3. 构建重置密码链接
return String.format("http://localhost:%s/auth/reset-password?userId=%d&timestamp=%d&sign=%s",
serverPort, userId, timestamp, sign);
}

/**
* 构建重置密码邮件内容(HTML 格式)
*
* @param resetUrl 重置密码链接
* @return 邮件内容
*/
private String buildResetPasswordEmailContent(String resetUrl) {
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #FF9800; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background-color: #f9f9f9; }
.button { display: inline-block; padding: 10px 20px; background-color: #FF9800; color: white; text-decoration: none; border-radius: 5px; }
.footer { padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>重置密码</h1>
</div>
<div class="content">
<p>您好!</p>
<p>我们收到了您的密码重置请求。请点击下方按钮重置密码:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="%s" class="button">重置密码</a>
</p>
<p>或者复制以下链接到浏览器打开:</p>
<p style="word-break: break-all; color: #666;">%s</p>
<p style="color: #999; font-size: 12px;">此链接 1 小时内有效,请尽快重置。</p>
<p style="color: #f44336; font-size: 12px;">如果这不是您的操作,请忽略此邮件。</p>
</div>
<div class="footer">
<p>这是一封自动发送的邮件,请勿回复。</p>
</div>
</div>
</body>
</html>
""".formatted(resetUrl, resetUrl);
}
}

19.3.7. 注册流程:完整的用户注册

在上一节中,我们实现了邮箱服务。现在我们需要实现完整的用户注册流程。

这一节的目标很明确:让用户从“验证码 → 注册 → 写入双表 → 发送激活邮件”这一条链路跑通。并且要注意,本章的注册只是“身份体系的落地实现”,它必须和 19.2 的认证工厂无缝衔接:登录仍然走 /auth/login + AuthStrategyFactory 分发,策略返回 userId,Sa-Token 登录由工厂统一处理 [1]。


先补一刀:扩展 AuthType 枚举(新增 EMAIL)

在 19.3 的领域模型里,我们已经引入了 sys_auth.identity_type 的概念,并且邮箱会参与“激活/找回”流程。因此这里我们需要在 19.2 的 AuthType 基础上补充一个 EMAIL 类型,用于数据库存储与业务查询。

📄 文件路径:src/main/java/com/example/auth/enums/AuthType.java(修改,追加枚举值)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.example.auth.enums;

import lombok.Getter;

/**
* 认证类型枚举
* 集中管理系统支持的所有登录方式
*/
@Getter
public enum AuthType {
/**
* 账号密码登录
*/
PASSWORD("password", "账号密码登录"),

/**
* 手机验证码登录
*/
SMS("sms", "手机验证码登录"),

/**
* 微信扫码登录
*/
WECHAT("wechat", "微信扫码登录"),

/**
* 邮箱(用于激活/找回/绑定等流程)
*/
EMAIL("email", "邮箱");

private final String code;
private final String description;

AuthType(String code, String description) {
this.code = code;
this.description = description;
}

public static AuthType fromCode(String code) {
for (AuthType type : values()) {
if (type.code.equals(code)) {
return type;
}
}
throw new IllegalArgumentException("不支持的认证方式: " + code);
}
}

注意:这里我们只是扩展类型集合,并没有推翻 19.2 的策略模式与工厂模式。工厂仍然只负责“选择策略 + Sa-Token 登录”,策略仍然只负责“验证身份并返回 userId” [1]。


定义注册请求

注册请求与“登录请求(AuthRequest 多态)”不冲突。登录请求属于认证工厂的统一入口;注册请求是一个独立业务接口,我们用普通 DTO 即可。

📄 文件路径:src/main/java/com/example/auth/model/request/RegisterRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.example.auth.model.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;

/**
* 注册请求
*/
@Data
public class RegisterRequest {

/**
* 用户名
* 4-20 位,只能包含字母、数字、下划线
*/
@NotBlank(message = "用户名不能为空")
@Pattern(regexp = "^[a-zA-Z0-9_]{4,20}$", message = "用户名格式不正确(4-20位,只能包含字母、数字、下划线)")
private String username;

/**
* 密码
* 8-20 位,必须包含大小写字母、数字
*/
@NotBlank(message = "密码不能为空")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,20}$",
message = "密码格式不正确(8-20位,必须包含大小写字母、数字)")
private String password;

/**
* 邮箱
*/
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;

/**
* 验证码 Key
*/
@NotBlank(message = "验证码 Key 不能为空")
private String captchaKey;

/**
* 验证码
*/
@NotBlank(message = "验证码不能为空")
private String captchaCode;
}

实现用户服务

用户服务负责注册、激活、找回密码等“账号体系基础能力”。我们会严格基于 19.3 的领域模型:注册时同时写入 sys_usersys_auth 两张表 [2]。

📄 文件路径:src/main/java/com/example/auth/service/UserService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
package com.example.auth.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.auth.entity.Auth;
import com.example.auth.entity.User;
import com.example.auth.enums.AuthType;
import com.example.auth.mapper.AuthMapper;
import com.example.auth.mapper.UserMapper;
import com.example.auth.model.request.RegisterRequest;
import com.example.auth.util.HmacUtil;
import com.example.auth.util.PasswordEncoder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 用户服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

private final UserMapper userMapper;
private final AuthMapper authMapper;
private final PasswordEncoder passwordEncoder;
private final CaptchaService captchaService;
private final EmailService emailService;
private final HmacUtil hmacUtil;

/**
* 用户注册
*
* @param request 注册请求
* @return 用户 ID
*/
@Transactional(rollbackFor = Exception.class)
public Long register(RegisterRequest request) {
log.info("开始注册用户: username={}, email={}", request.getUsername(), request.getEmail());

// 1. 验证验证码
if (!captchaService.verifyCaptcha(request.getCaptchaKey(), request.getCaptchaCode())) {
throw new RuntimeException("验证码错误或已过期");
}

// 2. 检查用户名是否已存在(sys_auth + PASSWORD)
Auth existingAuth = authMapper.selectOne(new LambdaQueryWrapper<Auth>()
.eq(Auth::getIdentityType, AuthType.PASSWORD.name())
.eq(Auth::getIdentifier, request.getUsername()));
if (existingAuth != null) {
throw new RuntimeException("用户名已存在");
}

// 3. 检查邮箱是否已存在(sys_auth + EMAIL)
Auth existingEmail = authMapper.selectOne(new LambdaQueryWrapper<Auth>()
.eq(Auth::getIdentityType, AuthType.EMAIL.name())
.eq(Auth::getIdentifier, request.getEmail()));
if (existingEmail != null) {
throw new RuntimeException("邮箱已被注册");
}

// 4. 创建用户主体(sys_user)
User user = new User();
user.setNickname(request.getUsername());
user.setAvatar("https://cdn.example.com/default-avatar.png");
user.setStatus(2); // 未激活
userMapper.insert(user);

// 5. 创建账号密码凭证(sys_auth + PASSWORD)
Auth passwordAuth = new Auth();
passwordAuth.setUserId(user.getId());
passwordAuth.setIdentityType(AuthType.PASSWORD.name());
passwordAuth.setIdentifier(request.getUsername());
passwordAuth.setCredential(passwordEncoder.encode(request.getPassword()));
passwordAuth.setVerified(0); // 未验证

try {
authMapper.insert(passwordAuth);
} catch (DuplicateKeyException e) {
throw new RuntimeException("用户名已存在");
}

// 6. 创建邮箱凭证(sys_auth + EMAIL)
Auth emailAuth = new Auth();
emailAuth.setUserId(user.getId());
emailAuth.setIdentityType(AuthType.EMAIL.name());
emailAuth.setIdentifier(request.getEmail());
emailAuth.setCredential(null); // 邮箱不存密码(这里只做激活/找回)
emailAuth.setVerified(0); // 未验证

try {
authMapper.insert(emailAuth);
} catch (DuplicateKeyException e) {
throw new RuntimeException("邮箱已被注册");
}

// 7. 发送激活邮件(异步)
emailService.sendActivationEmail(request.getEmail(), user.getId());

log.info("用户注册成功: userId={}, username={}", user.getId(), request.getUsername());
return user.getId();
}

/**
* 激活账号
*
* @param userId 用户 ID
* @param timestamp 时间戳
* @param sign 签名
*/
@Transactional(rollbackFor = Exception.class)
public void activateAccount(Long userId, Long timestamp, String sign) {
log.info("开始激活账号: userId={}", userId);

// 1. 验证签名
String data = userId + ":" + timestamp;
if (!hmacUtil.verify(data, sign)) {
throw new RuntimeException("激活链接无效");
}

// 2. 验证时间戳(24 小时有效期)
if (System.currentTimeMillis() > timestamp) {
throw new RuntimeException("激活链接已过期");
}

// 3. 查询用户
User user = userMapper.selectById(userId);
if (user == null) {
throw new RuntimeException("用户不存在");
}

// 4. 检查是否已激活
if (user.getStatus() == 1) {
throw new RuntimeException("账号已激活,无需重复激活");
}

// 5. 更新用户状态
user.setStatus(1); // 启用
userMapper.updateById(user);

// 6. 更新该用户所有凭证的验证状态为已验证
authMapper.update(null, new LambdaQueryWrapper<Auth>()
.eq(Auth::getUserId, userId)
.set(Auth::getVerified, 1));

log.info("账号激活成功: userId={}", userId);
}

/**
* 根据用户 ID 查询用户
*
* @param userId 用户 ID
* @return 用户信息
*/
public User getUserById(Long userId) {
return userMapper.selectById(userId);
}
}

实现注册接口

我们不会另起一个“auth-web 模块”的 Controller,而是在 19.2 已经存在的 AuthController 中继续追加注册与激活接口,保持同一个工程、同一个启动类、同一个接口域名。

📄 文件路径:src/main/java/com/example/auth/controller/AuthController.java(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package com.example.auth.controller;

import com.example.auth.common.Result;
import com.example.auth.model.request.RegisterRequest;
import com.example.auth.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

/**
* 认证控制器
* 提供登录、注销、注册、激活、找回密码等接口
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final UserService userService;

/**
* 用户注册接口
*
* @param request 注册请求
* @return 统一响应格式
*/
@PostMapping("/register")
public Result<Map<String, Object>> register(@Valid @RequestBody RegisterRequest request) {
log.info("收到注册请求: username={}, email={}", request.getUsername(), request.getEmail());

try {
Long userId = userService.register(request);

Map<String, Object> data = new HashMap<>();
data.put("userId", userId);
data.put("message", "注册成功,请查收激活邮件");

return Result.ok(data);
} catch (Exception e) {
log.error("注册失败", e);
return Result.fail(e.getMessage());
}
}

/**
* 激活账号接口
*
* @param userId 用户 ID
* @param timestamp 时间戳
* @param sign 签名
* @return 统一响应格式
*/
@GetMapping("/activate")
public Result<String> activate(@RequestParam Long userId,
@RequestParam Long timestamp,
@RequestParam String sign) {
log.info("收到激活请求: userId={}", userId);

try {
userService.activateAccount(userId, timestamp, sign);
return Result.ok("账号激活成功,请登录");
} catch (Exception e) {
log.error("激活失败", e);
return Result.fail(e.getMessage());
}
}
}

说明:这里展示的是“追加部分”。你原来的 /auth/login/auth/logout/auth/supported-types 仍然保留,并且登录继续走认证工厂链路 [1]。


Postman 测试

步骤 1:生成验证码

  • 方法:GET
  • URL:http://localhost:8080/captcha/generate

响应示例:

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"key": "a1b2c3d4e5f6g7h8",
"image": "data:image/png;base64,iVBORw0KGg..."
}
}

步骤 2:注册用户

  • 方法:POST
  • URL:http://localhost:8080/auth/register
  • Body:
1
2
3
4
5
6
7
{
"username": "testuser",
"password": "Test1234",
"email": "test@example.com",
"captchaKey": "a1b2c3d4e5f6g7h8",
"captchaCode": "ABCD"
}

响应示例:

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"userId": 1748392847362,
"message": "注册成功,请查收激活邮件"
}
}

步骤 3:查收激活邮件

登录邮箱,查看激活邮件,点击激活链接。

步骤 4:激活账号

  • 方法:GET
  • URL:http://localhost:8080/auth/activate?userId=1748392847362&timestamp=1735372800000&sign=a1b2c3d4e5f6g7h8

响应示例:

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": "账号激活成功,请登录"
}

19.3.8. 登录流程:实现 PasswordAuthStrategy

在上一节中,我们实现了用户注册流程。现在我们需要实现真正的 PasswordAuthStrategy,替换 19.2 中的 MockAuthStrategy

这一节最容易写崩的点有两个:

  1. 策略不要生成 Token,只返回 userId。Token 与会话由 Sa-Token 统一管理,并且由工厂统一调用 StpUtil.login(userId) [1]。
  2. 登录不要再查 sys_user.username/password,因为我们已经把“登录凭证”拆到了 sys_auth 表中 [2]。

实现 PasswordAuthStrategy

📄 文件路径:src/main/java/com/example/auth/strategy/impl/PasswordAuthStrategy.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
package com.example.auth.strategy.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.auth.entity.Auth;
import com.example.auth.entity.User;
import com.example.auth.enums.AuthType;
import com.example.auth.mapper.AuthMapper;
import com.example.auth.mapper.UserMapper;
import com.example.auth.model.request.AuthRequest;
import com.example.auth.model.request.PasswordAuthRequest;
import com.example.auth.strategy.AuthStrategy;
import com.example.auth.util.PasswordEncoder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

/**
* 账号密码登录策略
* 替换 MockAuthStrategy
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PasswordAuthStrategy implements AuthStrategy {

private final AuthMapper authMapper;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;

@Override
public Long authenticate(AuthRequest request) {
log.info("开始执行账号密码登录");

// 1. 将请求转换为具体类型
PasswordAuthRequest passwordRequest = (PasswordAuthRequest) request;

// 2. 提取用户名和密码
String username = passwordRequest.getUsername();
String password = passwordRequest.getPassword();
log.info("账号密码登录: username={}", username);

// 3. 根据用户名查询 sys_auth 表(PASSWORD)
Auth auth = authMapper.selectOne(new LambdaQueryWrapper<Auth>()
.eq(Auth::getIdentityType, AuthType.PASSWORD.name())
.eq(Auth::getIdentifier, username));

// 4. 检查凭证是否存在
if (auth == null) {
log.warn("用户不存在: username={}", username);
throw new RuntimeException("用户名或密码错误");
}

// 5. 验证密码
if (!passwordEncoder.matches(password, auth.getCredential())) {
log.warn("密码错误: username={}", username);
throw new RuntimeException("用户名或密码错误");
}

// 6. 根据 user_id 查询 sys_user 表
User user = userMapper.selectById(auth.getUserId());
if (user == null) {
log.error("用户主体不存在: userId={}", auth.getUserId());
throw new RuntimeException("用户数据异常");
}

// 7. 检查账号状态
if (user.getStatus() == 0) {
throw new RuntimeException("账号已被禁用,请联系管理员");
}
if (user.getStatus() == 2) {
throw new RuntimeException("账号未激活,请先激活邮箱");
}

// 8. 返回 userId(策略到此结束)
log.info("账号密码登录校验通过: userId={}, username={}", user.getId(), username);
return user.getId();
}

@Override
public AuthType getSupportedType() {
return AuthType.PASSWORD;
}
}

到这里,你会发现策略类非常“克制”:它只做验证,只返回 userId。后续 Sa-Token 登录与 Token 返回完全由 AuthStrategyFactory 负责 [1]。


删除 MockAuthStrategy

📄 文件路径:src/main/java/com/example/auth/strategy/impl/MockAuthStrategy.java(删除)

删除这个文件,因为我们已经有了真正的 PasswordAuthStrategy


Postman 测试

步骤 1:测试登录(账号未激活)

  • 方法:POST
  • URL:http://localhost:8080/auth/login
  • Body:
1
2
3
4
5
{
"authType": "PASSWORD",
"username": "testuser",
"password": "Test1234"
}

响应示例:

1
2
3
4
5
{
"code": 500,
"message": "账号未激活,请先激活邮箱",
"data": null
}

步骤 2:激活账号

点击邮件中的激活链接,或者调用激活接口。

步骤 3:测试登录(账号已激活)

  • 方法:POST
  • URL:http://localhost:8080/auth/login
  • Body:
1
2
3
4
5
{
"authType": "PASSWORD",
"username": "testuser",
"password": "Test1234"
}

响应示例(Sa-Token 统一返回,字段与 19.2 保持一致):

1
2
3
4
5
6
7
8
9
{
"code": 200,
"message": "操作成功",
"data": {
"tokenName": "Authorization",
"tokenValue": "e2f0f3b1-2b4c-4a0e-9c3b-1a2b3c4d5e6f",
"loginId": 1748392847362
}
}

步骤 4:测试登录(密码错误)

  • 方法:POST
  • URL:http://localhost:8080/auth/login
  • Body:
1
2
3
4
5
{
"authType": "PASSWORD",
"username": "testuser",
"password": "WrongPassword"
}

响应示例:

1
2
3
4
5
{
"code": 500,
"message": "用户名或密码错误",
"data": null
}

19.3.9. 找回密码:重置密码流程

在上一节中,我们实现了登录流程。现在我们需要实现找回密码功能。

找回密码的核心目标只有一个:用户忘记密码时,不需要登录也能完成“验证身份 → 重置密码”。但它还有两个隐藏要求:

  • 不能暴露“某个邮箱是否存在”(防止用户枚举攻击)
  • 重置链接必须防篡改,并且要有有效期(我们继续沿用 HMAC + timestamp 的思路)

定义找回密码请求

📄 文件路径:src/main/java/com/example/auth/model/request/ForgotPasswordRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.example.auth.model.request;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

/**
* 找回密码请求
*/
@Data
public class ForgotPasswordRequest {

/**
* 邮箱
*/
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;

/**
* 验证码 Key
*/
@NotBlank(message = "验证码 Key 不能为空")
private String captchaKey;

/**
* 验证码
*/
@NotBlank(message = "验证码不能为空")
private String captchaCode;
}

📄 文件路径:src/main/java/com/example/auth/model/request/ResetPasswordRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.auth.model.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;

/**
* 重置密码请求
*/
@Data
public class ResetPasswordRequest {

/**
* 用户 ID
*/
private Long userId;

/**
* 时间戳
*/
private Long timestamp;

/**
* 签名
*/
private String sign;

/**
* 新密码
* 8-20 位,必须包含大小写字母、数字
*/
@NotBlank(message = "新密码不能为空")
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d]{8,20}$",
message = "密码格式不正确(8-20位,必须包含大小写字母、数字)")
private String newPassword;
}

实现找回密码服务

📄 文件路径:src/main/java/com/example/auth/service/UserService.java(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
package com.example.auth.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.auth.entity.Auth;
import com.example.auth.entity.User;
import com.example.auth.enums.AuthType;
import com.example.auth.mapper.AuthMapper;
import com.example.auth.mapper.UserMapper;
import com.example.auth.model.request.ForgotPasswordRequest;
import com.example.auth.model.request.ResetPasswordRequest;
import com.example.auth.util.HmacUtil;
import com.example.auth.util.PasswordEncoder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 用户服务(追加找回密码能力)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

private final UserMapper userMapper;
private final AuthMapper authMapper;
private final PasswordEncoder passwordEncoder;
private final CaptchaService captchaService;
private final EmailService emailService;
private final HmacUtil hmacUtil;

/**
* 找回密码(发送重置密码邮件)
*
* @param request 找回密码请求
*/
public void forgotPassword(ForgotPasswordRequest request) {
log.info("开始找回密码: email={}", request.getEmail());

// 1. 验证验证码
if (!captchaService.verifyCaptcha(request.getCaptchaKey(), request.getCaptchaCode())) {
throw new RuntimeException("验证码错误或已过期");
}

// 2. 根据邮箱查询 sys_auth 表(EMAIL)
Auth emailAuth = authMapper.selectOne(new LambdaQueryWrapper<Auth>()
.eq(Auth::getIdentityType, AuthType.EMAIL.name())
.eq(Auth::getIdentifier, request.getEmail()));

// 3. 检查邮箱是否存在
if (emailAuth == null) {
// 防止用户枚举:邮箱不存在也当作成功处理
log.warn("邮箱不存在: email={}", request.getEmail());
return;
}

// 4. 发送重置密码邮件(异步)
emailService.sendResetPasswordEmail(request.getEmail(), emailAuth.getUserId());

log.info("重置密码邮件已发送: email={}, userId={}", request.getEmail(), emailAuth.getUserId());
}

/**
* 重置密码
*
* @param request 重置密码请求
*/
@Transactional(rollbackFor = Exception.class)
public void resetPassword(ResetPasswordRequest request) {
log.info("开始重置密码: userId={}", request.getUserId());

// 1. 验证签名
String data = request.getUserId() + ":" + request.getTimestamp();
if (!hmacUtil.verify(data, request.getSign())) {
throw new RuntimeException("重置链接无效");
}

// 2. 验证时间戳(1 小时有效期)
if (System.currentTimeMillis() > request.getTimestamp()) {
throw new RuntimeException("重置链接已过期");
}

// 3. 查询用户
User user = userMapper.selectById(request.getUserId());
if (user == null) {
throw new RuntimeException("用户不存在");
}

// 4. 查询账号密码凭证(PASSWORD)
Auth passwordAuth = authMapper.selectOne(new LambdaQueryWrapper<Auth>()
.eq(Auth::getUserId, request.getUserId())
.eq(Auth::getIdentityType, AuthType.PASSWORD.name()));

if (passwordAuth == null) {
throw new RuntimeException("该账号未设置密码");
}

// 5. 更新密码
passwordAuth.setCredential(passwordEncoder.encode(request.getNewPassword()));
authMapper.updateById(passwordAuth);

log.info("密码重置成功: userId={}", request.getUserId());
}
}

实现找回密码接口

仍然追加到同一个 AuthController 里,避免工程与包名割裂。

📄 文件路径:src/main/java/com/example/auth/controller/AuthController.java(追加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.example.auth.controller;

import com.example.auth.common.Result;
import com.example.auth.model.request.ForgotPasswordRequest;
import com.example.auth.model.request.ResetPasswordRequest;
import com.example.auth.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

/**
* 认证控制器(追加找回密码能力)
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final UserService userService;

/**
* 找回密码接口(发送重置密码邮件)
*
* @param request 找回密码请求
* @return 统一响应格式
*/
@PostMapping("/forgot-password")
public Result<String> forgotPassword(@Valid @RequestBody ForgotPasswordRequest request) {
log.info("收到找回密码请求: email={}", request.getEmail());

try {
userService.forgotPassword(request);
return Result.ok("重置密码邮件已发送,请查收邮件");
} catch (Exception e) {
log.error("找回密码失败", e);
return Result.fail(e.getMessage());
}
}

/**
* 重置密码接口
*
* @param request 重置密码请求
* @return 统一响应格式
*/
@PostMapping("/reset-password")
public Result<String> resetPassword(@Valid @RequestBody ResetPasswordRequest request) {
log.info("收到重置密码请求: userId={}", request.getUserId());

try {
userService.resetPassword(request);
return Result.ok("密码重置成功,请使用新密码登录");
} catch (Exception e) {
log.error("重置密码失败", e);
return Result.fail(e.getMessage());
}
}
}

Postman 测试

步骤 1:生成验证码

  • 方法:GET
  • URL:http://localhost:8080/captcha/generate

步骤 2:找回密码(发送重置密码邮件)

  • 方法:POST
  • URL:http://localhost:8080/auth/forgot-password
  • Body:
1
2
3
4
5
{
"email": "test@example.com",
"captchaKey": "a1b2c3d4e5f6g7h8",
"captchaCode": "ABCD"
}

响应示例:

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": "重置密码邮件已发送,请查收邮件"
}

步骤 3:查收重置密码邮件

登录邮箱,查看重置密码邮件,点击重置密码链接。

步骤 4:重置密码

  • 方法:POST
  • URL:http://localhost:8080/auth/reset-password
  • Body:
1
2
3
4
5
6
{
"userId": 1748392847362,
"timestamp": 1735372800000,
"sign": "a1b2c3d4e5f6g7h8",
"newPassword": "NewPass1234"
}

响应示例:

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": "密码重置成功,请使用新密码登录"
}

步骤 5:使用新密码登录

  • 方法:POST
  • URL:http://localhost:8080/auth/login
  • Body:
1
2
3
4
5
{
"authType": "PASSWORD",
"username": "testuser",
"password": "NewPass1234"
}

响应示例:

1
2
3
4
5
6
7
8
9
{
"code": 200,
"message": "操作成功",
"data": {
"tokenName": "Authorization",
"tokenValue": "f6a1c2d3-4e5f-6789-abcd-ef0123456789",
"loginId": 1748392847362
}
}