Note 19(第二章). SpringBoot3-手动实现一个最佳规范的策略模式登录注册认证工厂

Note 19.2. 逻辑引擎:基于策略模式的认证工厂

19.2.1. 问题引入

在开始设计认证工厂之前,我们需要先理解一个问题:为什么不能用 if-else 实现多种登录方式?

场景模拟:五种登录方式的传统实现

假设我们现在要支持 5 种登录方式:

  1. 账号密码登录:用户输入用户名和密码
  2. 手机验证码登录:用户输入手机号和验证码
  3. 微信扫码登录:用户扫描二维码,微信返回授权码
  4. GitHub OAuth2 登录:用户授权后,GitHub 返回授权码
  5. Google OAuth2 登录:用户授权后,Google 返回授权码

在没有设计模式的情况下,我们的 Controller 会是这样的:

📄 文件路径:auth-web/src/main/java/com/example/auth/web/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
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
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserService userService;
private final SmsService smsService;
private final WechatService wechatService;
private final GithubService githubService;
private final GoogleService googleService;

@PostMapping("/login")
public Result<AuthToken> login(@RequestBody Map<String, Object> request) {
String type = (String) request.get("type");

if ("password".equals(type)) {
// ==== 账号密码登录逻辑(约 50 行代码) ====
String username = (String) request.get("username");
String password = (String) request.get("password");

// 1. 查询用户
User user = userService.getByUsername(username);
if (user == null) {
throw new RuntimeException("用户名或密码错误");
}

// 2. 检查账号状态
if (user.getStatus() == 0) {
throw new RuntimeException("账号已被禁用");
}

// 3. 验证密码
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new RuntimeException("用户名或密码错误");
}

// 4. Sa-Token 登录
StpUtil.login(user.getId());
return Result.ok(buildAuthToken());

} else if ("sms".equals(type)) {
// ==== 手机验证码登录逻辑(约 50 行代码) ====
String phone = (String) request.get("phone");
String code = (String) request.get("code");

// 1. 验证验证码
if (!smsService.verifyCode(phone, code)) {
throw new RuntimeException("验证码错误或已过期");
}

// 2. 查询或创建用户
User user = userService.getByPhone(phone);
if (user == null) {
user = userService.createByPhone(phone);
}

// 3. Sa-Token 登录
StpUtil.login(user.getId());
return Result.ok(buildAuthToken());

} else if ("wechat".equals(type)) {
// ==== 微信扫码登录逻辑(约 50 行代码) ====
String code = (String) request.get("code");

// 1. 调用微信 API 获取 openId
String openId = wechatService.getOpenId(code);

// 2. 查询或创建用户
User user = userService.getByWechatOpenId(openId);
if (user == null) {
user = userService.createByWechatOpenId(openId);
}

// 3. Sa-Token 登录
StpUtil.login(user.getId());
return Result.ok(buildAuthToken());

} else if ("github".equals(type)) {
// ==== GitHub OAuth2 登录逻辑(约 50 行代码) ====
String code = (String) request.get("code");

// 1. 调用 GitHub API 获取 access_token
String accessToken = githubService.getAccessToken(code);

// 2. 调用 GitHub API 获取用户信息
GithubUser githubUser = githubService.getUserInfo(accessToken);

// 3. 查询或创建用户
User user = userService.getByGithubId(githubUser.getId());
if (user == null) {
user = userService.createByGithubUser(githubUser);
}

// 4. Sa-Token 登录
StpUtil.login(user.getId());
return Result.ok(buildAuthToken());

} else if ("google".equals(type)) {
// ==== Google OAuth2 登录逻辑(约 50 行代码) ====
String code = (String) request.get("code");

// 1. 调用 Google API 获取 access_token
String accessToken = googleService.getAccessToken(code);

// 2. 调用 Google API 获取用户信息
GoogleUser googleUser = googleService.getUserInfo(accessToken);

// 3. 查询或创建用户
User user = userService.getByGoogleId(googleUser.getId());
if (user == null) {
user = userService.createByGoogleUser(googleUser);
}

// 4. Sa-Token 登录
StpUtil.login(user.getId());
return Result.ok(buildAuthToken());

} else {
throw new RuntimeException("不支持的登录方式: " + type);
}
}

private AuthToken buildAuthToken() {
return AuthToken.builder()
.tokenName(StpUtil.getTokenName())
.tokenValue(StpUtil.getTokenValue())
.loginId(StpUtil.getLoginIdAsLong())
.build();
}
}

传统写法的五大致命问题

问题一:代码膨胀

这个 Controller 的 login 方法已经超过 250 行代码。如果再加上异常处理、日志记录、参数校验,代码量会超过 400 行

想象一下,当你需要修改某个登录方式的逻辑时,你需要在这 400 行代码中找到对应的 if-else 分支,然后小心翼翼地修改,生怕影响到其他分支。这种体验就像在一个巨大的迷宫中寻找出口。

问题二:违反开闭原则

当我们需要新增一个登录方式(如 “Apple 登录”)时,必须修改 AuthController 的代码,增加一个新的 else if 分支。这违反了开闭原则(Open-Closed Principle):对扩展开放,对修改关闭

在传统写法中,每次新增登录方式都需要修改 Controller,这意味着:

  • 需要重新测试所有登录方式(因为修改了 Controller)
  • 可能引入新的 Bug(因为修改了现有代码)
  • 代码越来越臃肿(每次新增都会增加 50+ 行代码)

问题三:难以测试

我们无法单独测试某个登录方式的逻辑。如果要测试 “微信登录”,必须:

  1. 启动整个 Spring Boot 应用
  2. 模拟所有依赖(UserService、WechatService 等)
  3. 构造完整的 HTTP 请求
  4. 验证响应结果

这种测试方式效率极低,而且容易受到其他登录方式的干扰。

问题四:职责不清

Controller 层不应该包含业务逻辑。它的职责应该是:

  • 接收请求
  • 参数校验
  • 调用 Service 层
  • 返回响应

但现在 Controller 层包含了大量的业务逻辑(密码验证、用户创建、Token 生成等),违反了单一职责原则(Single Responsibility Principle)。

在传统写法中,Controller 承担了太多职责:

  • 路由分发(根据 type 选择登录方式)
  • 参数解析(从 Map 中提取参数)
  • 业务逻辑(验证密码、调用第三方 API)
  • 异常处理(捕获并转换异常)

问题五:无法动态管理

我们无法在运行时动态禁用某个登录方式。如果要禁用 “微信登录”,必须:

  1. 修改代码(注释掉对应的 if-else 分支)
  2. 重新编译
  3. 重新部署

这在生产环境中是不可接受的。想象一下,如果微信登录接口出现故障,我们需要紧急禁用这个功能,但却需要重新部署整个应用,这会导致服务中断。

解决方案:策略模式 + 工厂模式

我们需要一个更优雅的设计:

  • 策略模式:将每种登录方式封装为一个独立的策略类
  • 工厂模式:使用工厂类根据登录类型自动选择对应的策略

架构对比:

对比维度传统 if-else策略模式 + 工厂模式
Controller 代码量250+ 行10 行
新增登录方式修改 Controller只需实现 AuthStrategy 接口
可测试性难以单独测试可以单独测试每个策略
职责分离Controller 包含业务逻辑Controller 只负责调度
动态管理不支持支持(配置化管理)
符合设计原则❌ 违反开闭原则、单一职责原则✅ 符合开闭原则、单一职责原则

在接下来的章节中,我们将一步步构建这个认证工厂,让你亲眼见证代码从 250 行减少到 10 行的过程。


19.2.2. 架构设计

在上一节中,我们已经看到了传统 if-else 登录的五大致命问题。现在,让我们设计一个更优雅的解决方案。

在开始编写代码之前,我们需要先设计整个认证工厂的架构。这就像盖房子之前要先画设计图一样,架构设计决定了代码的可维护性和可扩展性。

核心设计理念

我们的认证工厂基于三个核心理念:

理念类比系统实现
统一入口机场安检口:无论国内航班还是国际航班,都从同一个入口进入,然后根据航班类型被引导到不同的登机口- 统一入口:AuthController/auth/login 接口
- 多态分发:AuthStrategyFactory 根据 authType 选择对应的策略
策略独立餐厅的不同厨师:川菜师傅和粤菜师傅各司其职,互不干扰- 账号密码登录:PasswordAuthStrategy
- 手机验证码登录:SmsAuthStrategy
- 微信扫码登录:WechatAuthStrategy
- 每个策略类只负责自己的验证逻辑,不需要知道其他策略的存在
工厂管理公司的人力资源部门:自动发现和管理所有员工,不需要手动登记- AuthStrategyFactory 在启动时自动扫描所有实现了 AuthStrategy 接口的 Bean
- 将这些策略注册到 Map<AuthType, AuthStrategy>
- 后续根据 authType 自动选择对应的策略

架构全景图

让我们先看一下整个认证工厂的架构全景图:

mermaid-diagram-2025-12-28-201205

架构说明

从上到下,整个系统分为六层:

第一层:客户端层

  • 前端应用(Web、移动端、小程序等)发送登录请求
  • 请求中必须包含 authType 字段,标识登录方式

第二层:接入层

  • AuthController 接收请求,这是系统的统一入口
  • Controller 不包含任何业务逻辑,只负责接收请求和返回响应

第三层:工厂层

  • AuthStrategyFactory 根据 authType 选择对应的策略
  • 这是整个系统的 “中枢神经”,负责策略的管理和分发

第四层:策略层

  • 每种登录方式都是一个独立的策略类
  • 策略类只负责验证身份,返回用户 ID

第五层:服务层

  • 策略类调用各种服务完成业务逻辑
  • UserService(查询用户)、SmsService(验证验证码)、WechatService(调用微信 API)

第六层:数据层

  • MySQL 存储用户数据
  • Redis 存储会话信息(由 Sa-Token 管理)

请求流转时序图

现在让我们看一下一个完整的登录请求是如何在系统中流转的:

mermaid-diagram-2025-12-28-201325

时序说明

让我们逐步分析这个流程:

步骤 1:前端发送登录请求

1
2
3
4
5
{
"authType": "PASSWORD",
"username": "admin",
"password": "123456"
}

前端必须在请求中携带 authType 字段,标识这是哪种登录方式。

步骤 2:Controller 接收请求

AuthController 接收到请求后,不做任何业务逻辑处理,直接委托给 AuthStrategyFactory

1
2
3
4
5
@PostMapping("/login")
public Result<AuthTokenVO> login(@Valid @RequestBody AuthRequest request) {
AuthTokenVO authToken = authStrategyFactory.authenticate(request);
return Result.ok(authToken);
}

步骤 3:工厂选择策略

AuthStrategyFactory 根据 authType 字段,从 Map<AuthType, AuthStrategy> 中获取对应的策略:

1
AuthStrategy strategy = strategyMap.get(request.getAuthType());

步骤 4:策略执行认证

策略类执行具体的认证逻辑(查询用户、验证密码等),返回用户 ID:

1
Long userId = strategy.authenticate(request);

步骤 5:工厂调用 Sa-Token 登录

工厂类拿到用户 ID 后,调用 Sa-Token 的登录方法:

1
StpUtil.login(userId);

Sa-Token 会自动生成 Token 并存入 Redis。

步骤 6:返回 Token

工厂类构建响应对象,包含 Token 信息:

1
2
3
4
5
return AuthTokenVO.builder()
.tokenName(StpUtil.getTokenName())
.tokenValue(StpUtil.getTokenValue())
.loginId(userId)
.build();

步骤 7:层层返回

响应对象层层返回,最终到达前端。

策略模式与 Sa-Token 的协同关系

现在让我们深入理解策略模式与 Sa-Token 是如何协同工作的。

Sa-Token 的职责边界

Sa-Token 只负责 “会话管理”,不负责 “身份验证”。具体来说:

Sa-Token 负责的事情:

  • ✅ 生成 Token(StpUtil.login(userId) 会自动生成)
  • ✅ 验证 Token(StpUtil.checkLogin() 会自动验证)
  • ✅ 管理会话(将会话信息存入 Redis)
  • ✅ 权限验证(StpUtil.checkPermission() 等)
  • ✅ 踢人下线(StpUtil.kickout(userId)

Sa-Token 不负责的事情:

  • ❌ 验证用户名和密码
  • ❌ 验证手机验证码
  • ❌ 调用微信 API 获取 openId
  • ❌ 查询或创建用户

策略模式的职责边界

策略模式负责 “身份验证”,不负责 “会话管理”。具体来说:

策略类负责的事情:

  • ✅ 验证用户名和密码
  • ✅ 验证手机验证码
  • ✅ 调用第三方 API 获取用户信息
  • ✅ 查询或创建用户
  • ✅ 返回用户 ID

策略类不负责的事情:

  • ❌ 生成 Token(由 Sa-Token 负责)
  • ❌ 管理会话(由 Sa-Token 负责)
  • ❌ 权限验证(由 Sa-Token 负责)

协同工作流程

让我们用一个具体的例子来说明它们是如何协同工作的。假设用户使用账号密码登录:

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
// 步骤 1:策略类验证身份
@Component
public class PasswordAuthStrategy implements AuthStrategy {
@Override
public Long authenticate(AuthRequest request) {
// 1. 查询用户
User user = userService.getByUsername(username);

// 2. 验证密码
if (!BCrypt.checkpw(password, user.getPassword())) {
throw new RuntimeException("密码错误");
}

// 3. 返回用户 ID(策略类的职责到此结束)
return user.getId();
}
}

// 步骤 2:工厂类调用 Sa-Token 登录
@Component
public class AuthStrategyFactory {
public AuthTokenVO authenticate(AuthRequest request) {
// 1. 选择策略
AuthStrategy strategy = strategyMap.get(request.getAuthType());

// 2. 执行认证,获取用户 ID
Long userId = strategy.authenticate(request);

// 3. Sa-Token 登录(Sa-Token 的职责从这里开始)
StpUtil.login(userId);

// 4. 返回 Token
return AuthTokenVO.builder()
.tokenName(StpUtil.getTokenName())
.tokenValue(StpUtil.getTokenValue())
.loginId(userId)
.build();
}
}

关键设计点

策略类只返回 userId,不返回 Token。这是因为:

  • Token 的生成是 Sa-Token 的职责,策略类不应该关心
  • 这样做可以让策略类保持职责单一,易于测试

工厂类负责调用 StpUtil.login(userId)。这是因为:

  • 工厂类是策略模式和 Sa-Token 的 “桥梁”
  • 所有策略类都需要调用 Sa-Token 登录,放在工厂类中可以避免重复代码

策略模式的类图结构

让我们用类图来展示策略模式的结构:

mermaid-diagram-2025-12-28-201543

类图说明

AuthRequest 接口

  • 这是所有认证请求的统一接口
  • 定义了一个方法:getAuthType(),用于标识登录类型
  • 所有具体的请求类(如 PasswordAuthRequest)都必须实现这个接口

AuthStrategy 接口

  • 这是所有认证策略的统一接口
  • 定义了两个方法:
    • authenticate(AuthRequest):执行认证,返回用户 ID
    • getSupportedType():返回支持的认证类型

AuthStrategyFactory 工厂类

  • 管理所有策略的映射关系(Map<AuthType, AuthStrategy>
  • 提供 authenticate(AuthRequest) 方法,作为统一的认证入口
  • 在应用启动时自动发现和注册所有策略

具体策略类

  • PasswordAuthStrategy:账号密码登录
  • SmsAuthStrategy:手机验证码登录
  • WechatAuthStrategy:微信扫码登录

每个策略类都实现了 AuthStrategy 接口,并提供自己的认证逻辑。

设计模式的职责分工

让我们用一个表格来总结各个设计模式的职责:

设计模式职责核心类关键方法
策略模式封装算法族,让它们可以互相替换AuthStrategy 接口及其实现类authenticate(AuthRequest)
工厂模式根据条件创建对象,隐藏创建逻辑AuthStrategyFactorygetStrategy(AuthType)
多态统一接口,不同实现AuthRequest 接口及其实现类getAuthType()

策略模式的核心价值

将每种登录方式封装为独立的策略类,它们之间互不依赖。这样做的好处是:

  • 新增登录方式不需要修改现有代码
  • 可以单独测试每个策略
  • 可以动态启用或禁用某个策略

工厂模式的核心价值

根据登录类型自动选择对应的策略,隐藏了策略选择的复杂性。这样做的好处是:

  • Controller 不需要知道如何选择策略
  • 策略的注册和管理都由工厂类负责
  • 可以在运行时动态添加或删除策略

多态的核心价值

使用统一的接口(AuthRequestAuthStrategy),让不同的实现类可以互相替换。这样做的好处是:

  • Controller 只需要依赖接口,不需要依赖具体实现
  • 可以在不修改 Controller 的情况下替换实现类
  • 符合依赖倒置原则(Dependency Inversion Principle)

与传统写法的对比

让我们用一个表格来对比传统写法和策略模式的差异:

对比维度传统 if-else策略模式 + 工厂模式
代码组织所有逻辑混在一起每个策略独立成类
Controller 代码量250+ 行10 行
新增登录方式修改 Controller,增加 if-else 分支只需实现 AuthStrategy 接口
可测试性难以单独测试某个登录方式可以单独测试每个策略
职责分离Controller 包含业务逻辑Controller 只负责调度
动态管理不支持支持(配置化管理)
符合设计原则❌ 违反开闭原则、单一职责原则✅ 符合开闭原则、单一职责原则

本节小结

在本节中,我们完成了认证工厂的架构设计。

我们基于三个核心理念设计了整个系统:

  • 统一入口,多态分发:所有登录请求通过同一个接口进入,然后自动分发到对应的策略
  • 策略独立,互不干扰:每种登录方式都是独立的策略类,可以单独开发和测试
  • 工厂管理,自动发现:工厂类在启动时自动发现和注册所有策略

我们绘制了三张关键的架构图:

  • 架构全景图:展示了系统的六层结构
  • 请求流转时序图:展示了一个完整的登录请求如何在系统中流转
  • 策略模式类图:展示了策略模式的类结构

我们明确了策略模式与 Sa-Token 的职责边界:

  • 策略类负责验证身份,返回用户 ID
  • Sa-Token 负责生成 Token 和管理会话
  • 工厂类是它们之间的桥梁
要点何时使用关键动作
架构全景图理解系统整体结构识别六层架构及其职责
请求流转时序图理解登录流程跟踪请求从前端到数据库的完整路径
策略模式类图理解策略模式结构识别接口、工厂、策略三者的关系

在下一节中,我们将快速搭建项目骨架,让整个系统跑起来。


19.2.3. 快速搭建:10 分钟启动项目骨架

在上一节中,我们已经完成了认证工厂的架构设计,理解了策略模式与 Sa-Token 的协同关系。现在,让我们动手搭建一个可运行的项目骨架。

本节的目标很明确:用最短的时间搭建一个能够跑起来的基础环境,让你能够立即开始编写策略类。我们不会在这里讲解每个配置项的深层原理(那是后续章节的事情),而是专注于 “快速启动”。

环境准备检查清单

在开始之前,请确认你的开发环境满足以下要求:

组件版本要求检查方式备注
JDK17 或更高终端执行 java -version必须是 LTS 版本
Maven3.6+终端执行 mvn -v或使用 IDE 内置 Maven
Redis5.0+终端执行 redis-cli ping,返回 PONG本地或远程均可
IDEIntelliJ IDEA 2023+-推荐使用 Ultimate 版

如果你的 Redis 尚未启动,请先在终端执行 redis-server 启动 Redis 服务。Windows 用户可以使用 WSL 或 Docker 运行 Redis。

项目结构全景

我们将创建一个单模块的 Spring Boot 项目,目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
auth-factory-demo/
├── pom.xml # Maven 依赖配置
└── src/main/
├── java/com/example/auth/
│ ├── AuthFactoryApplication.java # 启动类
│ ├── config/
│ │ └── SaTokenConfig.java # Sa-Token 配置类
│ ├── enums/
│ │ └── AuthType.java # 认证类型枚举
│ ├── model/
│ │ ├── request/
│ │ │ └── AuthRequest.java # 认证请求接口
│ │ └── vo/
│ │ └── AuthTokenVO.java # 统一响应对象
│ ├── strategy/
│ │ └── AuthStrategy.java # 认证策略接口
│ ├── factory/
│ │ └── AuthStrategyFactory.java # 认证工厂
│ └── controller/
│ └── AuthController.java # 认证控制器
└── resources/
└── application.yml # 应用配置文件

这个结构非常简洁,没有复杂的多模块划分。我们的重点是 策略模式的实现,而不是项目结构的复杂性。


步骤 1:创建 Spring Boot 项目

打开 IntelliJ IDEA,选择 FileNewProject

在弹出的窗口中:

  1. 左侧选择 Spring Initializr

  2. 右侧配置项目信息:

    • Nameauth
    • LanguageJava
    • TypeMaven
    • Groupcom.example
    • Artifactauth
    • Package namecom.example.auth
    • JDK:选择 17 或更高版本
    • Java17
    • PackagingJar
  3. 点击 Next,在依赖选择页面,暂时不选择任何依赖(我们稍后手动添加)

  4. 点击 Finish,等待项目创建完成

验证项目创建成功

项目创建完成后,你应该能看到:

  • 左侧项目树中出现了 auth-factory-demo 文件夹
  • src/main/java/com/example/auth 目录下有一个 AuthFactoryDemoApplication.java 启动类
  • pom.xml 文件已经生成

步骤 2:配置 Maven 依赖

现在我们需要在 pom.xml 中添加必要的依赖。

📄 文件pom.xml

打开项目根目录下的 pom.xml 文件,将 <dependencies> 标签内的内容替换为以下内容:

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
<dependencies>
<!-- Spring Boot Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Sa-Token 核心依赖 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.37.0</version>
</dependency>

<!-- Sa-Token Redis 集成(使用 Jackson 序列化) -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.37.0</version>
</dependency>

<!-- Redis 客户端 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Apache Commons Pool(Redis 连接池) -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>

<!-- Jackson 数据绑定(用于 JSON 多态反序列化) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>

<!-- Validation API(用于参数校验) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Lombok(简化 POJO 编写) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<!-- Spring Boot Test(测试依赖) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

依赖说明

依赖作用为什么需要
spring-boot-starter-web提供 Web 功能我们需要创建 REST API
sa-token-spring-boot3-starterSa-Token 核心提供认证与会话管理能力
sa-token-redis-jacksonSa-Token Redis 集成将会话信息存入 Redis
spring-boot-starter-data-redisRedis 客户端连接 Redis 服务器
commons-pool2连接池提高 Redis 连接性能
jackson-databindJSON 处理实现多态反序列化
spring-boot-starter-validation参数校验验证前端传来的参数
lombok代码简化自动生成 getter/setter

刷新 Maven 依赖

保存 pom.xml 后,点击 IDE 右侧的 Maven 面板 → 点击刷新图标(或按 Ctrl + Shift + O)。

验证依赖下载成功

观察 IDE 底部的进度条走完,且 pom.xml 中的依赖没有红色波浪线,说明依赖下载成功。


步骤 3:配置 application.yml

现在我们需要配置 Spring Boot 的应用配置文件。

📄 文件src/main/resources/application.yml(新建)

src/main/resources 目录下,右键选择 NewFile,输入文件名 application.yml,然后粘贴以下内容:

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
# 服务器配置
server:
port: 8080 # 应用端口,默认 8080

# Spring 配置
spring:
application:
name: auth-factory-demo # 应用名称

# Redis 配置
data:
redis:
host: localhost # Redis 服务器地址
port: 6379 # Redis 端口
password: # Redis 密码(如果没有设置密码,留空)
database: 0 # 使用的数据库索引
lettuce:
pool:
max-active: 8 # 连接池最大连接数
max-idle: 8 # 连接池最大空闲连接数
min-idle: 0 # 连接池最小空闲连接数
max-wait: -1ms # 连接池最大阻塞等待时间

# Sa-Token 配置
sa-token:
token-name: Authorization # Token 的名称(前端请求头中的字段名)
timeout: 86400 # Token 有效期(单位:秒,86400 秒 = 1 天)
active-timeout: 1800 # Token 最低活跃频率(单位:秒,1800 秒 = 30 分钟)
is-concurrent: true # 是否允许同一账号多地同时登录
is-share: false # 是否共享 Token(多个系统是否共用一套 Token)
token-style: uuid # Token 风格(uuid、simple-uuid、random-32 等)
is-log: true # 是否打印 Sa-Token 的操作日志

配置说明

Redis 配置

  • hostport:指定 Redis 服务器的地址和端口
  • password:如果你的 Redis 设置了密码,请填写密码;否则留空
  • database:Redis 有 16 个数据库(0-15),我们使用 0 号数据库
  • lettuce.pool:连接池配置,用于提高 Redis 连接性能

Sa-Token 配置

  • token-name:前端请求头中携带 Token 的字段名,我们使用 Authorization
  • timeout:Token 的有效期,设置为 1 天(86400 秒)
  • active-timeout:如果用户 30 分钟内没有任何操作,Token 会自动续期
  • is-concurrent:允许同一个账号在多个设备上同时登录
  • is-share:不共享 Token(每个应用使用独立的 Token)
  • token-style:Token 的生成风格,使用 UUID
  • is-log:开启 Sa-Token 的操作日志,方便调试

19.2.4. 接口抽象:定义统一的认证规范

在上一节中,我们已经完成了项目骨架的搭建,确认了 Spring Boot 应用能够正常启动,Redis 连接正常。现在,我们需要开始设计认证工厂的核心接口。

这一节是整个认证工厂的 “输入规范”,它定义了所有登录方式必须遵循的统一接口。就像工厂的生产线需要标准化的零件接口一样,我们的认证工厂也需要标准化的请求接口,才能让不同的登录策略无缝接入。


19.2.4.1. 统一接口的设计意图

在传统写法中,Controller 使用 Map<String, Object> 接收请求参数:

1
2
3
4
5
6
@PostMapping("/login")
public Result<AuthToken> login(@RequestBody Map<String, Object> request) {
String type = (String) request.get("type");
String username = (String) request.get("username");
// ...
}

这种写法存在三个核心问题:

问题一:类型不安全

Map<String, Object> 中的值都是 Object 类型,需要手动强制转换。如果前端传错了类型,只能在运行时才能发现错误。

问题二:无法校验

我们无法使用 Spring 的 @Valid 注解进行参数校验,只能在业务逻辑中手动判断,导致校验代码散落在各个 if-else 分支中。

问题三:工厂无法分发

工厂模式的核心是 “根据类型选择策略”。但 Map<String, Object> 无法携带类型信息,工厂类只能通过字符串判断,这与我们的策略模式设计理念相悖。

解决方案:定义统一的接口

我们定义一个 AuthRequest 接口,所有登录请求都必须实现这个接口。这样做的核心价值是:

  • 类型安全:编译时就能发现类型错误
  • 参数校验:可以使用 @Valid 注解自动校验
  • 工厂分发:工厂类可以通过 getAuthType() 方法获取类型,自动选择对应的策略

接口在工厂模式中的作用

在策略模式中,接口扮演着 “统一输入” 的角色:

1
前端请求 → AuthRequest 接口 → 工厂类根据 authType 选择策略 → 策略类执行认证

所有登录方式的请求都实现 AuthRequest 接口,工厂类只需要依赖这个接口,就能处理所有类型的登录请求。这就是 “面向接口编程” 的核心思想。


19.2.4.2. 认证类型枚举(AuthType)

在定义接口之前,我们需要先定义一个枚举类,用于标识系统支持的所有登录方式。

📄 文件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
48
49
50
51
52
package com.example.auth.enums;

import lombok.Getter;

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

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

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

/**
* 类型编码(用于前端传参)
*/
private final String code;

/**
* 类型描述(用于日志记录)
*/
private final String description;

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

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

设计要点

这个枚举类有两个核心字段:

  • code:用于前端传参和数据库存储,必须是简短的英文标识符
  • description:用于日志记录和错误提示,必须是易读的中文描述

这样设计的好处是:前端传参时使用 code(如 "password"),简洁高效;日志记录时使用 description(如 "账号密码登录"),易于理解。

fromCode 方法用于将前端传来的字符串转换为枚举。如果前端传来的 code 不存在,会抛出 IllegalArgumentException,提示 “不支持的认证方式”。


19.2.4.3. 认证请求接口(AuthRequest)

现在我们定义统一的认证请求接口。这个接口是所有登录请求的 “父类”,它定义了所有登录方式必须遵循的规范。

📄 文件src/main/java/com/example/auth/model/request/AuthRequest.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
package com.example.auth.model.request;

import com.example.auth.enums.AuthType;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;

/**
* 认证请求接口
* 所有登录方式的请求参数都必须实现这个接口
*/
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "authType",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = PasswordAuthRequest.class, name = "PASSWORD"),
@JsonSubTypes.Type(value = SmsAuthRequest.class, name = "SMS"),
@JsonSubTypes.Type(value = WechatAuthRequest.class, name = "WECHAT")
})
public interface AuthRequest {
/**
* 获取认证类型
* 用于工厂类根据类型选择对应的策略
*/
AuthType getAuthType();
}

设计要点

这个接口只定义了一个方法:getAuthType()。因为不同登录方式的参数不同(账号密码登录需要 usernamepassword,手机验证码登录需要 phonecode),我们无法在接口中定义统一的参数字段。

接口上的两个注解(@JsonTypeInfo@JsonSubTypes)用于配置 Jackson 的多态反序列化。这样 Spring Boot 就能根据前端传来的 authType 字段,自动将 JSON 转换为对应的请求类。

Jackson 多态反序列化的工作流程

1
2
3
4
5
6
1. 前端发送 JSON: {"authType": "PASSWORD", "username": "admin", "password": "123456"}
2. Spring Boot 接收到请求,调用 Jackson 进行反序列化
3. Jackson 读取 authType 字段,发现值是 "PASSWORD"
4. Jackson 查找 @JsonSubTypes 注解,找到 name="PASSWORD" 对应的类是 PasswordAuthRequest
5. Jackson 将 JSON 转换为 PasswordAuthRequest 对象
6. Controller 接收到 PasswordAuthRequest 对象

19.2.4.4. 具体请求类示例

现在我们定义一个具体的认证请求类,用于演示接口的实现方式。

📄 文件src/main/java/com/example/auth/model/request/PasswordAuthRequest.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
package com.example.auth.model.request;

import com.example.auth.enums.AuthType;
import com.fasterxml.jackson.annotation.JsonTypeName;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

/**
* 账号密码登录请求
*/
@Data
@JsonTypeName("PASSWORD")
public class PasswordAuthRequest implements AuthRequest {

private AuthType authType = AuthType.PASSWORD;

@NotBlank(message = "用户名不能为空")
private String username;

@NotBlank(message = "密码不能为空")
private String password;

@Override
public AuthType getAuthType() {
return authType;
}
}

设计要点

这个请求类实现了 AuthRequest 接口,并定义了账号密码登录所需的两个字段:usernamepassword

@JsonTypeName("PASSWORD") 注解指定了类型名称,必须与 @JsonSubTypes 中的 name 属性一致,这样 Jackson 才能正确地将 "PASSWORD" 映射到 PasswordAuthRequest 类。

authType 字段设置了默认值 AuthType.PASSWORD,这样即使前端忘记传 authType 字段,也能正确识别类型。

其他请求类

按照同样的方式,我们还需要定义 SmsAuthRequestWechatAuthRequest

📄 文件src/main/java/com/example/auth/model/request/SmsAuthRequest.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
package com.example.auth.model.request;

import com.example.auth.enums.AuthType;
import com.fasterxml.jackson.annotation.JsonTypeName;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;

@Data
@JsonTypeName("SMS")
public class SmsAuthRequest implements AuthRequest {

private AuthType authType = AuthType.SMS;

@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;

@NotBlank(message = "验证码不能为空")
@Pattern(regexp = "^\\d{6}$", message = "验证码格式不正确")
private String code;

@Override
public AuthType getAuthType() {
return authType;
}
}

📄 文件src/main/java/com/example/auth/model/request/WechatAuthRequest.java(新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.auth.model.request;

import com.example.auth.enums.AuthType;
import com.fasterxml.jackson.annotation.JsonTypeName;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
@JsonTypeName("WECHAT")
public class WechatAuthRequest implements AuthRequest {

private AuthType authType = AuthType.WECHAT;

@NotBlank(message = "授权码不能为空")
private String code;

@Override
public AuthType getAuthType() {
return authType;
}
}

19.2.4.5. 本节小结

本节完成了认证工厂的输入规范设计,定义了 AuthType 枚举、AuthRequest 接口以及三个具体的请求类。这些接口为后续的工厂实现奠定了基础,工厂类可以通过 getAuthType() 方法获取类型,自动选择对应的策略。

要点何时使用关键动作
AuthType 枚举标识登录方式定义 code 和 description 字段
AuthRequest 接口统一请求规范定义 getAuthType() 方法
具体请求类实现不同登录方式实现 AuthRequest 接口,添加 @JsonTypeName 注解

在下一节中,我们将定义认证策略接口(AuthStrategy),让每种登录方式都遵循统一的策略规范。


19.2.5. 策略接口:定义统一的认证策略

在上一节中,我们定义了统一的认证请求接口(AuthRequest),解决了 “如何统一接收不同登录方式的参数” 这个问题。现在我们需要解决另一个核心问题:如何统一处理不同登录方式的验证逻辑?

这就是策略模式的核心所在。我们将定义一个 AuthStrategy 接口,让每种登录方式都实现这个接口,从而实现 “统一规范,各自实现”。


19.2.5.1. 策略模式的核心思想

在传统写法中,所有登录方式的验证逻辑都混在 Controller 的 if-else 分支中:

1
2
3
4
5
if ("password".equals(type)) {
// 查询用户、验证密码、生成 Token...
} else if ("sms".equals(type)) {
// 验证验证码、查询或创建用户、生成 Token...
}

这种写法的问题在于:验证逻辑与分发逻辑耦合在一起。Controller 既要负责 “选择哪种登录方式”,又要负责 “执行具体的验证逻辑”,违反了单一职责原则。

策略模式的解决方案

策略模式将 “算法的选择” 与 “算法的实现” 分离:

  • 工厂类负责选择:根据 authType 选择对应的策略
  • 策略类负责实现:每种登录方式都是一个独立的策略类,实现具体的验证逻辑

这样做的核心价值是:

  • 职责清晰:Controller 只负责调度,策略类只负责验证
  • 易于扩展:新增登录方式只需实现 AuthStrategy 接口,不需要修改任何现有代码
  • 易于测试:可以单独测试每个策略类,不需要启动整个应用

策略模式的三个角色

在我们的认证工厂中,策略模式包含三个角色:

角色职责在我们系统中的对应类
Strategy(策略接口)定义所有策略的统一接口AuthStrategy
ConcreteStrategy(具体策略)实现具体的算法PasswordAuthStrategySmsAuthStrategy
Context(上下文)持有策略引用,委托策略执行AuthStrategyFactory

19.2.5.2. 策略接口的设计

现在我们定义 AuthStrategy 接口。这个接口是所有登录策略的 “父类”,它定义了所有策略必须遵循的规范。

📄 文件src/main/java/com/example/auth/strategy/AuthStrategy.java(新建)

src/main/java/com/example/auth 目录下,右键选择 NewPackage,输入包名 strategy,然后在 strategy 包下新建 AuthStrategy.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
package com.example.auth.strategy;

import com.example.auth.enums.AuthType;
import com.example.auth.model.request.AuthRequest;

/**
* 认证策略接口
* 所有登录方式都必须实现这个接口
*
* 设计理念:
* 1. 统一规范:所有登录方式都遵循同一套接口
* 2. 策略模式:将每种登录方式封装为一个独立的策略类
* 3. 职责单一:策略类只负责验证身份,返回用户 ID
*/
public interface AuthStrategy {
/**
* 执行认证
* 这是策略模式的核心方法,每个策略类都必须实现
*
* 职责边界:
* - 策略类只负责验证身份(查询用户、验证密码/验证码等)
* - 策略类不负责生成 Token(由 Sa-Token 负责)
* - 策略类不负责管理会话(由 Sa-Token 负责)
*
* @param request 认证请求(多态参数)
* @return 用户 ID(用于 Sa-Token 登录)
* @throws RuntimeException 如果认证失败
*/
Long authenticate(AuthRequest request);

/**
* 获取支持的认证类型
* 用于工厂类根据类型选择对应的策略
*
* @return 认证类型枚举
*/
AuthType getSupportedType();
}

设计要点解析

设计点一:为什么返回 Long 而不是 AuthToken

这是本接口最关键的设计决策。让我们对比两种设计方案:

方案 A:策略类返回 AuthToken(不推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface AuthStrategy {
AuthToken authenticate(AuthRequest request);
}

// 实现类
public class PasswordAuthStrategy implements AuthStrategy {
@Override
public AuthToken authenticate(AuthRequest request) {
// 1. 验证身份
User user = userService.getByUsername(username);
// 2. 生成 Token
StpUtil.login(user.getId());
// 3. 返回 Token
return AuthToken.builder()
.tokenValue(StpUtil.getTokenValue())
.build();
}
}

这种设计的问题:

  • ❌ 策略类职责过重:既要验证身份,又要生成 Token
  • ❌ 代码重复:每个策略类都要写一遍 StpUtil.login() 和构建 AuthToken 的代码
  • ❌ 难以测试:测试策略类时,必须模拟 Sa-Token 的行为

方案 B:策略类返回 Long(推荐)

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
public interface AuthStrategy {
Long authenticate(AuthRequest request);
}

// 实现类
public class PasswordAuthStrategy implements AuthStrategy {
@Override
public Long authenticate(AuthRequest request) {
// 只负责验证身份
User user = userService.getByUsername(username);
// 返回用户 ID
return user.getId();
}
}

// 工厂类
public class AuthStrategyFactory {
public AuthTokenVO authenticate(AuthRequest request) {
// 1. 选择策略
AuthStrategy strategy = getStrategy(request.getAuthType());
// 2. 执行认证,获取用户 ID
Long userId = strategy.authenticate(request);
// 3. Sa-Token 登录
StpUtil.login(userId);
// 4. 返回 Token
return buildAuthToken(userId);
}
}

这种设计的优势:

  • ✅ 策略类职责单一:只负责验证身份
  • ✅ 代码复用:Token 生成逻辑统一在工厂类中
  • ✅ 易于测试:测试策略类时,只需要验证返回的用户 ID 是否正确

设计点二:为什么需要 getSupportedType() 方法?

这个方法用于标识策略类支持的认证类型。工厂类在启动时会调用这个方法,将策略注册到 Map<AuthType, AuthStrategy> 中:

1
2
3
4
5
6
7
// 工厂类的构造函数
public AuthStrategyFactory(List<AuthStrategy> strategies) {
for (AuthStrategy strategy : strategies) {
AuthType type = strategy.getSupportedType(); // 获取策略类型
strategyMap.put(type, strategy); // 注册到 Map
}
}

这样设计的好处是:

  • ✅ 自动注册:策略类只需要实现接口,工厂类会自动发现并注册
  • ✅ 类型安全:使用枚举而非字符串,避免拼写错误

设计点三:authenticate 方法的参数是 AuthRequest 接口

这是多态的体现。虽然参数类型是 AuthRequest 接口,但实际传入的是具体的实现类(如 PasswordAuthRequest)。

策略类内部需要将 AuthRequest 强制转换为具体的类型:

1
2
3
4
5
6
7
8
9
@Override
public Long authenticate(AuthRequest request) {
// 强制转换为具体类型
PasswordAuthRequest passwordRequest = (PasswordAuthRequest) request;
// 使用具体类型的字段
String username = passwordRequest.getUsername();
String password = passwordRequest.getPassword();
// ...
}

这个转换是安全的,因为工厂类会根据 authType 选择对应的策略。如果 authTypePASSWORD,工厂类一定会选择 PasswordAuthStrategy,而前端传来的请求一定是 PasswordAuthRequest


19.2.5.3. 策略接口的职责边界

在实现具体的策略类之前,我们需要明确策略接口的职责边界。这是避免代码混乱的关键。

AuthStrategy 应该做什么?

策略类只负责 “验证身份”,具体包括:

  • ✅ 验证用户名和密码
  • ✅ 验证手机验证码
  • ✅ 调用第三方 API 获取用户信息
  • ✅ 查询或创建用户
  • ✅ 返回用户 ID

AuthStrategy 不应该做什么?

策略类不负责 “会话管理”,具体包括:

  • ❌ 不应该生成 Token(由 Sa-Token 负责)
  • ❌ 不应该管理会话(由 Sa-Token 负责)
  • ❌ 不应该处理 HTTP 请求和响应(由 Controller 负责)
  • ❌ 不应该直接操作 Redis(应该通过 Sa-Token)

19.2.5.4. 策略模式与 Sa-Token 的协同关系

现在让我们理解策略模式与 Sa-Token 是如何协同工作的。

完整的认证流程

1
2
3
4
5
1. 前端发送登录请求 → Controller 接收
2. Controller 委托给工厂类 → 工厂类根据 authType 选择策略
3. 策略类验证身份 → 返回用户 ID
4. 工厂类调用 Sa-Token 登录 → Sa-Token 生成 Token 并存入 Redis
5. 工厂类构建响应对象 → 返回给前端

代码示例

让我们用一个具体的例子来说明:

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
// 步骤 1:策略类验证身份
@Component
public class PasswordAuthStrategy implements AuthStrategy {
@Override
public Long authenticate(AuthRequest request) {
// 1. 查询用户
User user = userService.getByUsername(username);

// 2. 验证密码
if (!BCrypt.checkpw(password, user.getPassword())) {
throw new RuntimeException("密码错误");
}

// 3. 返回用户 ID(策略类的职责到此结束)
return user.getId();
}

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

// 步骤 2:工厂类调用 Sa-Token 登录
@Component
public class AuthStrategyFactory {
public AuthTokenVO authenticate(AuthRequest request) {
// 1. 选择策略
AuthStrategy strategy = strategyMap.get(request.getAuthType());

// 2. 执行认证,获取用户 ID
Long userId = strategy.authenticate(request);

// 3. Sa-Token 登录(Sa-Token 的职责从这里开始)
StpUtil.login(userId);

// 4. 返回 Token
return AuthTokenVO.builder()
.tokenName(StpUtil.getTokenName())
.tokenValue(StpUtil.getTokenValue())
.loginId(userId)
.build();
}
}

关键设计点

  • 策略类只返回 userId,不返回 Token
  • 工厂类负责调用 StpUtil.login(userId)
  • Sa-Token 自动生成 Token 并存入 Redis
  • 工厂类通过 StpUtil.getTokenValue() 获取 Token 值

19.2.5.5. 本节小结

本节完成了认证策略接口的设计,定义了 AuthStrategy 接口,明确了策略类的职责边界。策略类只负责验证身份并返回用户 ID,Token 的生成和会话管理由 Sa-Token 负责,工厂类作为桥梁连接两者。

要点何时使用关键动作
AuthStrategy 接口定义策略规范定义 authenticate() 和 getSupportedType() 方法
返回 Long 而非 AuthToken保持职责单一策略类只返回用户 ID
职责边界避免代码混乱策略类只负责验证身份,不负责生成 Token

在下一节中,我们将实现认证策略工厂(AuthStrategyFactory),让它能够自动发现和注册所有策略,并根据 authType 自动选择对应的策略。


19.2.6. 工厂实现:自动发现与策略分发

在上一节中,我们定义了 AuthStrategy 接口,明确了策略类的职责边界:策略类只负责验证身份并返回用户 ID。现在,我们需要实现认证策略工厂(AuthStrategyFactory),让它能够自动发现和注册所有策略,并根据 authType 自动选择对应的策略。

这一节是整个认证工厂的 “中枢神经”,它连接了策略类和 Sa-Token,是策略模式能够运转的核心。


19.2.6.1. 工厂模式的核心职责

在策略模式中,工厂类扮演着 “上下文(Context)” 的角色。它的核心职责有三个:

职责一:策略管理

工厂类需要维护一个 Map<AuthType, AuthStrategy>,用于存储所有策略的映射关系。

职责二:自动发现

工厂类需要在应用启动时,自动扫描所有实现了 AuthStrategy 接口的 Bean,并注册到 Map 中。Spring 会将所有标注了 @Component 的策略类打包成 List<AuthStrategy>,注入到工厂类的构造函数中。

职责三:策略分发

工厂类需要根据认证类型(AuthType),从 Map 中获取对应的策略,并委托策略执行认证。认证成功后,工厂类调用 Sa-Token 的 StpUtil.login(userId) 完成登录,并返回 Token 信息。

工厂类的工作流程

1
2
3
4
5
6
7
8
9
1. 应用启动 → Spring 扫描所有 @Component 标注的 AuthStrategy 实现类
2. Spring 将这些 Bean 注入到工厂类的构造函数
3. 工厂类遍历所有策略,调用 getSupportedType() 获取认证类型
4. 工厂类将 AuthType 和 AuthStrategy 的映射关系存入 Map
5. 前端发送登录请求 → Controller 调用工厂类的 authenticate 方法
6. 工厂类根据 authType 从 Map 中获取对应的策略
7. 工厂类委托策略执行认证,获取用户 ID
8. 工厂类调用 StpUtil.login(userId) 完成登录
9. 工厂类构建响应对象,返回 Token 信息

19.2.6.2. 定义统一响应对象(AuthTokenVO)

在实现工厂类之前,我们需要先定义统一的响应对象。因为工厂类的 authenticate 方法会返回这个对象,所以必须先定义它。

📄 文件src/main/java/com/example/auth/model/vo/AuthTokenVO.java(新建)

src/main/java/com/example/auth/model 目录下,右键选择 NewPackage,输入包名 vo,然后在 vo 包下新建 AuthTokenVO.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.vo;

import lombok.Builder;
import lombok.Data;

/**
* 认证响应对象
* 包含 Sa-Token 生成的 Token 信息
*/
@Data
@Builder
public class AuthTokenVO {

/**
* Token 名称
* 对应 Sa-Token 配置中的 token-name(默认为 "Authorization")
*/
private String tokenName;

/**
* Token 值
* 前端需要在后续请求的 Header 中携带这个值
*/
private String tokenValue;

/**
* 用户 ID
* 用于前端展示或其他业务逻辑
*/
private Long loginId;
}

设计要点

这个响应对象包含三个字段:

  • tokenName:Token 的名称,对应 Sa-Token 配置中的 token-name(默认为 "Authorization"
  • tokenValue:Token 的值,前端需要在后续请求的 Header 中携带这个值
  • loginId:用户 ID,用于前端展示或其他业务逻辑

前端收到这个响应后,需要将 tokenValue 存储到本地(如 LocalStorage),并在后续请求的 Header 中携带:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 前端示例
const response = await fetch('/auth/login', {
method: 'POST',
body: JSON.stringify({authType: 'PASSWORD', username: 'admin', password: '123456'})
});
const data = await response.json();

// 存储 Token
localStorage.setItem('token', data.tokenValue);

// 后续请求携带 Token
fetch('/api/user/info', {
headers: {
'Authorization': localStorage.getItem('token')
}
});

19.2.6.3. 实现 AuthStrategyFactory

现在我们开始实现认证策略工厂。

📄 文件src/main/java/com/example/auth/factory/AuthStrategyFactory.java(新建)

src/main/java/com/example/auth 目录下,右键选择 NewPackage,输入包名 factory,然后在 factory 包下新建 AuthStrategyFactory.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
package com.example.auth.factory;

import cn.dev33.satoken.stp.StpUtil;
import com.example.auth.enums.AuthType;
import com.example.auth.model.request.AuthRequest;
import com.example.auth.model.vo.AuthTokenVO;
import com.example.auth.strategy.AuthStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
* 认证策略工厂
* 负责管理所有认证策略,并根据认证类型自动选择对应的策略
*/
@Slf4j
@Component
public class AuthStrategyFactory {
/**
* 策略容器
* Key: 认证类型(AuthType)
* Value: 认证策略(AuthStrategy)
* 使用 ConcurrentHashMap 保证线程安全
*/
private final Map<AuthType, AuthStrategy> strategyMap = new ConcurrentHashMap<>();

public AuthStrategyFactory(List<AuthStrategy> strategies) {
// 遍历所有策略,注册到 Map 中
for (AuthStrategy strategy : strategies) {
AuthType type = strategy.getSupportedType();

// 检查是否有重复的策略类型
if (strategyMap.containsKey(type)) {
throw new IllegalStateException("重复的认证策略: " + type.getDescription());
}

// 注册策略到 Map 中
strategyMap.put(type, strategy);
log.info("认证策略工厂初始化完成,已注册 {} 个策略", strategyMap.size());
}
}

/**
* 获取认证策略
* 根据认证类型从 Map 中获取对应的策略
*
* @param authType 认证类型
* @return 认证策略
* @throws IllegalArgumentException 如果认证类型不支持
*/
public AuthStrategy getStrategy(AuthType authType) {
AuthStrategy strategy = strategyMap.get(authType);
if (strategy == null) {
throw new IllegalArgumentException("不支持的认证方式: " + authType.getDescription());
}
return strategy;
}

/**
* 执行认证(门面方法)
* 这是工厂类对外提供的统一认证入口
* <p>
* 为什么需要这个方法?
* 1. 隐藏策略选择的复杂性:调用者不需要知道如何选择策略
* 2. 统一异常处理:可以在这里统一处理策略执行过程中的异常
* 3. 统一日志记录:可以在这里统一记录认证日志
* 4. 集成 Sa-Token:策略类只返回 userId,工厂类负责调用 Sa-Token 登录
*
* @param request 认证请求
* @return 认证响应(包含 Token 信息)
* @throws IllegalArgumentException 如果认证类型不支持
* @throws RuntimeException 如果认证失败
*/
public AuthTokenVO authenticate(AuthRequest request) {
AuthType authType = request.getAuthType();
// 步骤 1:选择策略
AuthStrategy strategy = getStrategy(authType);
try {
// 步骤 2:策略类验证身份,返回用户 ID
Long userId = strategy.authenticate(request);
log.info("认证成功: type={}, userId={}", authType.getDescription(), userId);
// 步骤 3:Sa-Token 登录
StpUtil.login(userId);

// 步骤 4:构建响应对象
return AuthTokenVO.builder()
.tokenName(StpUtil.getTokenName())
.tokenValue(StpUtil.getTokenValue())
.loginId(userId)
.build();
} catch (Exception e) {
log.error("认证失败: type={}, error={}", authType.getDescription(), e.getMessage());
throw e;
}
}

/**
* 获取所有已注册的认证类型
* 用于前端动态展示支持的登录方式
*
* @return 所有已注册的认证类型
*/
public List<AuthType> getSupportedTypes() {
return List.copyOf(strategyMap.keySet());
}
}

19.2.6.4. 关键设计点

设计点一:为什么要检查重复的策略类型?

如果有两个策略类返回相同的 AuthType,会导致后注册的策略覆盖先注册的策略。这是一个严重的 Bug,必须在启动时就检测出来:

1
2
3
if (strategyMap.containsKey(type)) {
throw new IllegalStateException("重复的认证策略: " + type.getDescription());
}

如果检测到重复,应用会在启动时抛出异常,而不是在运行时才发现问题。

设计点二:为什么要提供 authenticate 门面方法?

虽然调用者可以直接调用 getStrategy(authType).authenticate(request),但这样会暴露策略选择的细节。提供门面方法可以:

  • 隐藏策略选择的复杂性
  • 统一异常处理
  • 统一日志记录
  • 集成 Sa-Token(策略类只返回 userId,工厂类负责调用 StpUtil.login(userId)

设计点三:为什么要提供 getSupportedTypes 方法?

前端可以调用这个接口,动态获取系统支持的所有登录方式,然后展示对应的登录按钮。这样做的好处是:

  • 前端不需要硬编码登录方式
  • 后端新增登录方式后,前端自动感知
  • 可以通过配置动态启用或禁用某个登录方式

19.2.6.5. 本节小结

本节完成了认证策略工厂的实现。工厂类在应用启动时自动扫描并注册所有策略,在收到登录请求时根据 authType 选择对应的策略,策略验证成功后调用 Sa-Token 完成登录并返回 Token 信息。

要点何时使用关键动作
自动注册应用启动时Spring 注入所有策略 Bean,工厂类遍历并注册到 Map
策略分发收到登录请求时根据 authType 从 Map 中获取对应的策略
Sa-Token 集成策略验证成功后调用 StpUtil.login(userId) 完成登录

在下一节中,我们将重构第一章问题引入的Controller 层,将原来的 if-else 分支替换为工厂模式,实现真正的 “零修改扩展”。


19.2.7. 控制器实现:统一入口与零分支调度

在上一节中,我们完成了认证策略工厂的实现,工厂类能够自动发现和注册所有策略,并根据 authType 自动选择对应的策略。现在,我们需要创建 Controller 层,提供统一的登录入口。

这一节是整个认证工厂的 “对外门面”,它将展示策略模式的最终效果:无论有多少种登录方式,Controller 层的代码永远只有几行


19.2.7.1. Controller 层的职责定位

在传统的 MVC 架构中,Controller 层的职责非常明确:

Controller 应该做什么?

  • ✅ 接收 HTTP 请求
  • ✅ 参数校验(通过 @Valid 注解)
  • ✅ 调用 Service 层或工厂类
  • ✅ 返回统一的响应格式

Controller 不应该做什么?

  • ❌ 不应该包含业务逻辑(如验证密码、查询用户)
  • ❌ 不应该包含策略选择逻辑(如 if-else 分支)
  • ❌ 不应该直接操作数据库或 Redis

在我们的认证工厂中,Controller 层只需要做一件事:将请求委托给工厂类。所有的策略选择、身份验证、Token 生成都由工厂类和策略类完成。


19.2.7.2. 定义统一响应格式

在创建 Controller 之前,我们需要先定义统一的响应格式。这样可以确保所有接口的返回值都遵循同一套规范。

📄 文件src/main/java/com/example/auth/common/Result.java(新建)

src/main/java/com/example/auth 目录下,右键选择 NewPackage,输入包名 common,然后在 common 包下新建 Result.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
package com.example.auth.common;

import lombok.Data;

/**
* 统一响应格式
*
* @param <T> 响应数据类型
*/
@Data
public class Result<T> {

/**
* 响应码
* 200 表示成功,其他表示失败
*/
private Integer code;

/**
* 响应消息
*/
private String message;

/**
* 响应数据
*/
private T data;

/**
* 成功响应(无数据)
*/
public static <T> Result<T> ok() {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
return result;
}

/**
* 成功响应(有数据)
*/
public static <T> Result<T> ok(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
return result;
}

/**
* 失败响应
*/
public static <T> Result<T> fail(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}

/**
* 失败响应(自定义错误码)
*/
public static <T> Result<T> fail(Integer code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
return result;
}
}

设计要点

这个响应类包含三个字段:

  • code:响应码,200 表示成功,其他表示失败
  • message:响应消息,用于提示用户
  • data:响应数据,泛型类型,可以是任何对象

我们提供了四个静态方法,用于快速构建响应对象:

  • ok():成功响应,无数据
  • ok(T data):成功响应,有数据
  • fail(String message):失败响应,默认错误码 500
  • fail(Integer code, String message):失败响应,自定义错误码

19.2.7.3. 创建 AuthController

现在我们开始创建 Controller 层。

📄 文件src/main/java/com/example/auth/controller/AuthController.java(新建)

src/main/java/com/example/auth 目录下,右键选择 NewPackage,输入包名 controller,然后在 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
73
74
75
76
77
78
79
80
81
82
83
84
package com.example.auth.controller;

import cn.dev33.satoken.stp.StpUtil;
import com.example.auth.common.Result;
import com.example.auth.enums.AuthType;
import com.example.auth.factory.AuthStrategyFactory;
import com.example.auth.model.request.AuthRequest;
import com.example.auth.model.vo.AuthTokenVO;
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.List;
import java.util.Map;

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

private final AuthStrategyFactory authStrategyFactory;

/**
* 统一登录接口
* 支持多种登录方式,通过 authType 字段区分
*
* @param request 认证请求(多态参数)
* @return 统一响应格式
*/
@PostMapping("/login")
public Result<AuthTokenVO> login(@Valid @RequestBody AuthRequest request) {
log.info("收到登录请求: type={}", request.getAuthType().getDescription());

// 委托给工厂类,工厂类会自动选择对应的策略
AuthTokenVO authToken = authStrategyFactory.authenticate(request);

return Result.ok(authToken);
}

/**
* 注销登录接口
*
* @return 统一响应格式
*/
@PostMapping("/logout")
public Result<Void> logout() {
log.info("收到注销请求");

// Sa-Token 注销登录
StpUtil.logout();

return Result.ok();
}

/**
* 获取支持的登录方式
* 前端可以调用这个接口,动态展示登录按钮
*
* @return 所有支持的登录方式
*/
@GetMapping("/supported-types")
public Result<List<Map<String, String>>> getSupportedTypes() {
List<AuthType> types = authStrategyFactory.getSupportedTypes();

// 转换为前端友好的格式
List<Map<String, String>> result = types.stream()
.map(type -> {
Map<String, String> map = new HashMap<>();
map.put("code", type.getCode());
map.put("description", type.getDescription());
return map;
})
.toList();

return Result.ok(result);
}
}

关键设计点解析

设计点一:login 方法只有 3 行核心代码

1
2
3
4
5
6
@PostMapping("/login")
public Result<AuthTokenVO> login(@Valid @RequestBody AuthRequest request) {
log.info("收到登录请求: type={}", request.getAuthType().getDescription());
AuthTokenVO authToken = authStrategyFactory.authenticate(request);
return Result.ok(authToken);
}

这就是策略模式的最终效果。无论系统支持多少种登录方式,Controller 层的代码永远只有这几行。所有的策略选择、身份验证、Token 生成都由工厂类和策略类完成。

设计点二:@Valid 注解自动校验参数

1
public Result<AuthTokenVO> login(@Valid @RequestBody AuthRequest request)

@Valid 注解会自动触发 Spring 的参数校验机制。如果前端传来的参数不符合要求(如 username 为空),Spring 会自动返回 400 错误,错误信息为我们在请求类中定义的 message(如 “用户名不能为空”)。

设计点三:getSupportedTypes 方法返回前端友好的格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@GetMapping("/supported-types")
public Result<List<Map<String, String>>> getSupportedTypes() {
List<AuthType> types = authStrategyFactory.getSupportedTypes();

// 转换为前端友好的格式
List<Map<String, String>> result = types.stream()
.map(type -> {
Map<String, String> map = new HashMap<>();
map.put("code", type.getCode());
map.put("description", type.getDescription());
return map;
})
.toList();

return Result.ok(result);
}

前端调用这个接口后,会得到如下格式的响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"code": 200,
"message": "操作成功",
"data": [
{
"code": "password",
"description": "账号密码登录"
},
{
"code": "sms",
"description": "手机验证码登录"
}
]
}

前端可以根据这个列表动态展示登录按钮,而不需要硬编码登录方式。


19.2.7.4. 对比传统写法

现在让我们对比一下传统写法和策略模式的差异。

传统写法(假设的 if-else 版本)

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
@PostMapping("/login")
public Result<AuthToken> login(@RequestBody Map<String, Object> request) {
String type = (String) request.get("type");

if ("password".equals(type)) {
// 账号密码登录逻辑(50 行代码)
String username = (String) request.get("username");
String password = (String) request.get("password");
// 查询用户、验证密码、生成 Token...

} else if ("sms".equals(type)) {
// 手机验证码登录逻辑(50 行代码)
String phone = (String) request.get("phone");
String code = (String) request.get("code");
// 验证验证码、查询或创建用户、生成 Token...

} else if ("wechat".equals(type)) {
// 微信扫码登录逻辑(50 行代码)
String code = (String) request.get("code");
// 调用微信 API、查询或创建用户、生成 Token...

} else {
throw new RuntimeException("不支持的登录方式: " + type);
}
}

策略模式写法(当前实现)

1
2
3
4
5
6
@PostMapping("/login")
public Result<AuthTokenVO> login(@Valid @RequestBody AuthRequest request) {
log.info("收到登录请求: type={}", request.getAuthType().getDescription());
AuthTokenVO authToken = authStrategyFactory.authenticate(request);
return Result.ok(authToken);
}

对比结果

对比维度传统写法策略模式
Controller 代码量250+ 行3 行
if-else 分支5 个0 个
新增登录方式修改 Controller只需实现 AuthStrategy 接口
可测试性难以单独测试可以单独测试每个策略
职责分离Controller 包含业务逻辑Controller 只负责调度

19.2.7.5. 本节小结

本节完成了 Controller 层的创建,提供了统一的登录入口。Controller 层的代码非常简洁,只有 3 行核心代码,所有的策略选择、身份验证、Token 生成都由工厂类和策略类完成。这就是策略模式的最终效果:无论有多少种登录方式,Controller 层的代码永远只有几行

要点何时使用关键动作
统一响应格式所有接口定义 Result 类,包含 code、message、data 三个字段
统一登录接口前端发送登录请求委托给工厂类,工厂类自动选择策略
获取支持的登录方式前端动态展示登录按钮调用工厂类的 getSupportedTypes 方法

在下一节中,我们将实现具体的策略类,让整个认证工厂真正运转起来。