从账号泄露到 N+1 优化:一次 Spring Boot + Vue 项目的大改造

一次图书管理系统的全面优化,涵盖安全漏洞修复、N+1 查询优化、凭证管理强化以及数据库索引调优。从踩坑到解决,完整还原优化全过程。

🎵 配乐:暖阳下的代码时光

轻柔钢琴与弦乐交织,适合编程与思考的浪漫背景音乐


前言

做项目最怕的不是功能做不出来,而是上线之后才发现有安全漏洞,或者用户抱怨系统”慢得像蜗牛”。最近对图书管理系统进行了一次全面”体检”,发现了不少问题:用户密码明文暴露、N+1 查询导致审核页面卡死、OSS 凭证硬编码在代码里、数据库缺少关键索引……这些问题单个看都不大,但叠加在一起,足以让一个系统从”能用”变成”难用”。

本文完整记录了这次优化全过程,包括问题分析、解决思路、核心代码改动,以及数据库索引优化策略。如果你也在维护类似技术栈(Spring Boot + Vue)的项目,相信能从中学到一些实战经验。


一、问题全景:这次优化了什么

在开始深入细节之前,先整体看一下这次优化的四个核心问题:

编号 问题 类型 优先级 涉及范围
OPT-51 User.password 泄露 + 密码修改逻辑缺陷 安全 User.java, UserController.java, Password.vue
OPT-70 BookAudit N+1 查询优化 性能 Book.java, BookMapper.java, StudentBookController.java, BookAudit.vue
OPT-54 硬编码凭证清理 + 环境变量化 安全 OssConfig.java, application.properties
OPT-60/61 数据库索引优化 Round4 性能 sql/ 脚本,user/book/teacher/lend_rule 等表

四个问题分为两类:安全加固性能提升。安全问题是底线,性能问题影响体验,两者都不能忽视。


二、密码泄露危机:User.password 的 JsonIgnore 修复

密码泄露问题示意
图1:密码泄露问题示意

2.1 问题是怎么被发现的

在做登录功能测试时,我随手打了一个 console.log(user),结果在控制台看到了这个:

1
2
3
4
5
6
7
{
"id": 1,
"username": "admin",
"password": "123456",
"nickName": "管理员",
"role": "ADMIN"
}

密码居然是明文!当时就意识到这是一个严重的安全漏洞。只要有人通过浏览器的 Network 面板或者抓包工具查看登录接口的返回值,就能拿到所有用户的明文密码。

进一步排查发现,User.java 的 password 字段缺少 @JsonIgnore 注解。这意味着:

  • 登录接口返回用户信息 → 密码暴露
  • 任何将 User 对象序列化的地方 → 密码暴露
  • 如果把用户信息存入 sessionStorage 或 LocalStorage → 密码暴露

2.2 密码修改功能的连锁问题

修复 @JsonIgnore 之后,新的问题接踵而至:Password.vue 第 97 行有一段客户端旧密码比对逻辑:

1
2
3
4
5
const storedUser = JSON.parse(sessionStorage.getItem('user') || '{}');
if (storedUser.password !== this.form.oldPassword) {
this.$message.error('原密码错误');
return;
}

加了 @JsonIgnore 之后,从 sessionStorage 取出的 user.password 变成了 undefined,导致所有旧密码验证都失败。用户输入正确密码也会被系统拒绝。

2.3 解决方案:服务端验证 + 移除客户端逻辑

正确的做法是把密码验证逻辑后移到后端,前端只负责收集用户输入:

第一步:User.java 添加 @JsonIgnore

1
2
3
4
5
6
7
8
import com.fasterxml.jackson.annotation.JsonIgnore;

public class User {
// ... 其他字段 ...

@JsonIgnore // 新增:防止密码在 JSON 序列化时泄露
private String password;
}

第二步:UserController.java 增加旧密码验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@PutMapping("/password")
public Result<?> updatePassword(@RequestParam Integer id,
@RequestParam String password2,
@RequestParam String oldPassword) {
User existing = userMapper.selectById(id);
if (existing == null) {
return Result.error("-1", "用户不存在");
}
// 服务端验证旧密码
if (!existing.getPassword().equals(oldPassword)) {
return Result.error("-1", "原密码错误");
}
LambdaUpdateWrapper<User> updateWrapper = Wrappers.<User>lambdaUpdate()
.eq(User::getId, id);
User user = new User();
user.setPassword(password2);
userMapper.update(user, updateWrapper);
return Result.success();
}

第三步:Password.vue 删除客户端验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async submitForm() {
this.$refs.form.validate(async valid => {
if (valid) {
const res = await this.$put('/user/password', {
id: this.user.id,
oldPassword: this.form.oldPassword,
password2: this.form.password2
});
if (res.code === '0') {
this.$message.success('密码修改成功');
this.$refs.form.resetFields();
} else {
this.$message.error(res.msg || '原密码错误');
}
}
});
}

2.4 经验总结

  • 永远不要在客户端存储或比对密码:密码验证必须在服务端完成,客户端只负责传输用户输入。
  • @JsonIgnore 是敏感字段的标配:任何序列化场景(API 响应、缓存存储、日志输出)都要考虑是否包含敏感字段。
  • 先找影响范围再动手:修复安全问题时,最好先梳理清楚所有依赖点,避免”修复一个 bug 引入三个新 bug”。

三、N+1 查询噩梦:BookAudit 审核页面的性能陷阱

N+1查询优化对比
图2:N+1 查询优化前后对比

3.1 问题现象

打开图书审核页面,加载 20 本待审核的图书,需要大约 5-6 秒。这个速度对于一个内部管理系统来说实在太慢了。

通过浏览器的 Network 面板分析请求,发现了问题:主请求只有 1 个,但 /user/{id} 请求有 20 个!每显示一本书,都要单独请求一次上传者的用户名。

这就是经典的 N+1 查询问题:1 次主查询 + N 次关联查询。

3.2 问题根因分析

1
2
3
4
5
6
7
8
主请求: GET /studentBook/pendingAudit
→ 返回 20 本书(不含 uploaderName)

前端 for each book:
GET /user/{book.uploaderId}
→ 返回用户信息(含 nickName)

总请求数: 1 + 20 = 21 次

图书审核页面需要显示上传者名称,但后端接口只返回 Book 实体,不包含用户信息。前端不得不逐行调用用户接口获取上传者名称,导致请求数量暴增。

3.3 解决方案:JOIN 查询一次搞定

思路转变:与其让前端发多次请求,不如后端直接 JOIN 一次查出来。

第一步:Book.java 添加虚拟字段

1
2
3
4
5
6
public class Book {
// ... 原有字段 ...

@TableField(exist = false) // 标记为非数据库字段
private String uploaderName; // 上传者名称(来自 JOIN 查询)
}

第二步:BookMapper.java 新增 JOIN 方法

1
2
3
4
5
@Select("SELECT b.*, u.nick_name AS uploader_name FROM book b " +
"LEFT JOIN user u ON b.uploader_id = u.id " +
"WHERE (b.audit_status IS NULL OR b.audit_status = 0) " +
"ORDER BY b.create_time DESC")
IPage<Book> selectPendingAuditWithUploader(IPage<Book> page);

第三步:StudentBookController.java 改用新方法

1
2
3
4
5
@GetMapping("/pendingAudit")
public Result<?> pendingAudit(Page<Book> page) {
IPage<Book> result = bookMapper.selectPendingAuditWithUploader(page);
return Result.success(result);
}

第四步:BookAudit.vue 删除 loadUploaderInfo()

1
2
3
4
5
6
// 整个 loadUploaderInfo 方法删除
// 表格数据直接使用 book.uploaderName
<el-table :data="tableData">
<el-table-column prop="uploaderName" label="上传者" />
<!-- 其他列 -->
</el-table>

3.4 性能对比

指标 优化前 优化后
请求数(审核 20 本书) 21 次 1 次
预计耗时 ~500ms+ ~50ms
RTT 开销

优化后页面加载时间从 5-6 秒降到不足 1 秒,用户体验大幅提升。

3.5 经验总结

  • 后端思维:如果页面需要展示关联数据,优先在后端用 JOIN 一次查出来,而不是让前端发多次请求。
  • N+1 是性能大敌:在设计接口时就要考虑是否会触发 N+1 查询,提前规避。
  • @TableField(exist = false) 的妙用:用于接收 JOIN 查询的衍生字段,不影响原有数据库结构。

四、凭证裸奔:OSS 和 API Key 的硬编码危机

凭证环境变量管理
图3:凭证安全管理的关键——环境变量注入

4.1 问题有多严重

检查代码时发现,OssConfig.java 里写着:

1
2
3
4
public OssConfig() {
this.accessKeyId = "LTAI4xxxxxxxxxxxxxx";
this.accessKeySecret = "Abcdefxxxxxxxxxxxxxx";
}

application.properties 里也有:

1
2
aliyun.oss.access-key-id=LTAI4xxxxxxxxxxxxxx
aliyun.oss.access-key-secret=Abcdefxxxxxxxxxxxxxx

这意味着:代码提交到 Git 仓库之后,这些凭证就永久暴露了。任何能访问代码库的人都能拿到云资源的访问权限。

更危险的是,启动日志还会打印这些凭证:

1
log.info("OSS配置加载,accessKeyId: {}", accessKeyId);

4.2 解决方案:环境变量注入

OssConfig.java 重构

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
@ConfigurationProperties(prefix = "aliyun.oss")
public class OssConfig {

private String accessKeyId; // 从环境变量注入,不再硬编码
private String accessKeySecret;

@PostConstruct
public void init() {
// 只记录初始化状态,不打印敏感信息
log.info("OSS配置已加载");
}
}

application.properties 改用占位符

1
2
3
4
5
6
# OSS凭证 — 通过环境变量注入,不提交到代码库
aliyun.oss.access-key-id=${OSS_ACCESS_KEY_ID:}
aliyun.oss.access-key-secret=${OSS_ACCESS_KEY_SECRET:}

# API密钥 — 通过环境变量注入
ai.bailian.api.key=${AI_API_KEY:}

生产环境配置

1
2
3
4
# 在服务器上设置环境变量
export OSS_ACCESS_KEY_ID=your_access_key_id
export OSS_ACCESS_KEY_SECRET=your_access_key_secret
export AI_API_KEY=your_api_key

4.3 安全建议

  • 生产环境使用密钥管理服务:如阿里云 KMS、AWS Secrets Manager 等。
  • 本地开发使用 .env 文件:已在 .gitignore 中,避免误提交。
  • 定期轮换凭证:即使泄露了,及时轮换也能降低损失。

五、数据库索引优化:让查询飞起来

数据库索引优化
图4:索引优化让数据库查询效率大幅提升

5.1 为什么需要索引优化

项目跑了一段时间后,一些高频查询开始变慢。排查发现,部分热点查询缺少索引,导致数据库进行全表扫描。举个例子,用户登录时按用户名查询:

1
SELECT * FROM user WHERE username = 'admin';

没有索引的话,MySQL 需要遍历全表才能找到匹配行。随着数据量增长,这个查询会越来越慢。

5.2 新增索引(8 个)

表名 索引名 字段 类型 用途
user idx_username username UNIQUE 登录/注册热点查询
book uk_isbn isbn UNIQUE 业务主键查询
book idx_name name INDEX 图书名 LIKE 查询
book idx_create_time create_time INDEX 仪表盘排序
teacher idx_name_status (name, status) INDEX 教师查询
lend_rule idx_user_type_active (user_type, is_active) INDEX 借阅规则查询
book_review idx_isbn_deleted_time (isbn, deleted, create_time) INDEX 书评列表查询
student idx_name name INDEX 学生名搜索

5.3 删除冗余索引(4 个)

有些索引是冗余的,不仅没用,还会增加写入开销:

表名 索引名 删除原因
bookwithuser idx_reader_id uk_reader_isbn 的左前缀,冗余
bookwithuser idx_bwu_reader_id 索引在自增主键上,无用
bookwithuser idx_bwu_isbn 与 idx_isbn 重复
lend_record idx_fine_amount 从未被查询使用

5.4 完整 SQL 脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- ============================================
-- 数据库索引优化 Round4
-- 日期: 2026-04-15
-- ============================================

-- 新增索引
ALTER TABLE user ADD UNIQUE INDEX idx_username (username);
ALTER TABLE book ADD UNIQUE INDEX uk_isbn (isbn);
ALTER TABLE book ADD INDEX idx_name (name);
ALTER TABLE book ADD INDEX idx_create_time (create_time);
ALTER TABLE teacher ADD INDEX idx_name_status (name, status);
ALTER TABLE lend_rule ADD INDEX idx_user_type_active (user_type, is_active);
ALTER TABLE book_review ADD INDEX idx_isbn_deleted_time (isbn, deleted, create_time);
ALTER TABLE student ADD INDEX idx_name (name);

-- 删除冗余索引
ALTER TABLE bookwithuser DROP INDEX idx_reader_id;
ALTER TABLE bookwithuser DROP INDEX idx_bwu_reader_id;
ALTER TABLE bookwithuser DROP INDEX idx_bwu_isbn;
ALTER TABLE lend_record DROP INDEX idx_fine_amount;

5.5 性能影响预估

场景 优化前 优化后
用户登录查询 全表扫描 索引查找
图书 ISBN 查询 全表扫描 UNIQUE 查找
图书名搜索 全表扫描 索引查找
仪表盘加载 filesort 索引排序

六、优化成果汇总

经过这轮优化,系统在安全性和性能上都有了显著提升:

优化项 优化前 优化后 提升
密码泄露风险 暴露明文密码 @JsonIgnore 保护 安全加固
密码修改验证 客户端验证 服务端验证 安全加固
审核页面请求数 21 次 1 次 减少 95%
凭证管理 硬编码 环境变量 安全加固
热点查询 部分全表扫描 索引覆盖 性能提升

结语

这次优化让我深刻体会到:安全和性能不是事后补丁,而是设计之初就要考虑的。代码写得太随意,上线之后就会踩坑;提前多花一点时间设计,上线之后就能少很多麻烦。

如果你也在做类似的项目,建议从一开始就注意这些问题:

  1. 敏感数据加 @JsonIgnore,别等泄露了再补救
  2. 密码验证放后端,别依赖客户端
  3. 接口设计时考虑 N+1 问题
  4. 凭证用环境变量,别硬编码
  5. 数据库建表时同步规划索引

希望这篇文章对你有帮助!如果有任何问题,欢迎留言交流。


相关代码已脱敏处理,请勿直接照搬,结合实际项目情况调整。

配乐:《暖阳下的代码时光》— 轻柔钢琴与弦乐交织,适合编程与思考的浪漫背景音乐