计划

  1. JDBC基础入门
  2. JDBC查询与增删改
  3. SQL注入与预编译SQL
  4. MyBatis快速入门
  5. MyBatis注解写法与XML映射
  6. SpringBoot配置文件与数据库连接池

笔记

1.JDBC概述

JDBC是什么
  • JDBC:Java DataBase Connectivity,使用Java语言操作关系型数据库的一套API
  • 本质:Sun公司定义的一套接口规范,数据库厂商提供驱动jar包实现接口
  • 执行原理:Java程序调用JDBC接口,真正执行的是数据库驱动中的实现类
Java操作数据库技术
技术 说明
JDBC 最底层、最基础的数据库操作技术
MyBatis 基于JDBC封装的持久层框架,简化数据库开发
MyBatisPlus 在MyBatis基础上进一步增强
Hibernate / SpringDataJPA ORM思想更强的持久层框架

2.JDBC查询数据

查询需求

基于JDBC实现用户登录,本质是执行一条带用户名和密码条件的查询语句:

1
select * from user where username = 'daqiao' and password = '123456';
准备数据表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
create table user(
id int unsigned primary key auto_increment comment 'ID,主键',
username varchar(20) comment '用户名',
password varchar(32) comment '密码',
name varchar(10) comment '姓名',
age tinyint unsigned comment '年龄'
) comment '用户表';

insert into user(id, username, password, name, age) values
(1, 'daqiao', '123456', '大乔', 22),
(2, 'xiaoqiao', '123456', '小乔', 18),
(3, 'diaochan', '123456', '貂蝉', 24),
(4, 'lvbu', '123456', '吕布', 28),
(5, 'zhaoyun', '12345678', '赵云', 27);
JDBC查询步骤
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/web", "root", "1234");

PreparedStatement pstmt = conn.prepareStatement(
"select * from user where username = ? and password = ?");
pstmt.setString(1, "daqiao");
pstmt.setString(2, "123456");

ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
Integer id = rs.getInt("id");
String username = rs.getString("username");
String password = rs.getString("password");
String name = rs.getString("name");
Integer age = rs.getInt("age");
System.out.println(id + "," + username + "," + password + "," + name + "," + age);
}

rs.close();
pstmt.close();
conn.close();
ResultSet结果集
  • ResultSet:封装DQL查询语句返回的结果
  • next():移动光标到下一行,有数据返回true,没有数据返回false
  • getXxx(…):获取当前行指定列的数据,推荐使用列名获取

3.预编译SQL与SQL注入

静态SQL与预编译SQL
写法 示例 问题
静态SQL username = 'daqiao' 参数写死,灵活性差
字符串拼接SQL 拼接用户输入 容易产生SQL注入
预编译SQL username = ? 安全、性能更高,推荐使用
PreparedStatement
1
2
3
4
5
PreparedStatement pstmt = conn.prepareStatement(
"select * from user where username = ? and password = ?");
pstmt.setString(1, username);
pstmt.setString(2, password);
ResultSet rs = pstmt.executeQuery();
SQL注入
  • SQL注入:通过控制输入内容,修改原本SQL语句含义,达到绕过校验或攻击服务器的目的
  • 典型场景:登录功能中,如果使用字符串拼接SQL,输入特殊内容可能使条件恒成立
  • 解决方式:使用预编译SQL,用户输入会被当成普通参数值处理,不再改变SQL结构

4.JDBC增删改数据

执行DML语句
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Connection conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/web", "root", "1234");

String sql = "update user set password = ?, age = ? where id = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setString(1, "123456");
pstmt.setInt(2, 25);
pstmt.setInt(3, 1);

int rows = pstmt.executeUpdate();
System.out.println(rows);

pstmt.close();
conn.close();
executeQuery与executeUpdate
方法 执行语句 返回值
executeQuery() DQL查询语句 ResultSet结果集
executeUpdate() DML增删改语句 影响的记录数

5.MyBatis概述

MyBatis是什么
  • MyBatis:一款优秀的持久层框架,用于简化JDBC开发
  • 持久层:数据访问层,负责操作数据库
  • 框架:半成品软件,提供通用基础代码,提高开发效率
JDBC的不足
  1. 数据库连接信息硬编码在Java代码中
  2. 查询结果解析和对象封装繁琐
  3. 每次操作都要创建和关闭连接,资源浪费明显
MyBatis的改进
  1. 数据库连接信息放到SpringBoot配置文件中
  2. 查询结果由MyBatis自动映射为Java对象
  3. 底层使用数据库连接池复用连接

6.MyBatis快速入门

使用步骤
  1. 创建SpringBoot工程,引入mybatis-spring-boot-startermysql-connector-j
  2. 创建数据库表,并创建与表字段对应的实体类
  3. 在配置文件中配置数据库连接信息
  4. 编写Mapper接口,并在接口上添加@Mapper
  5. 选择注解方式或XML方式编写SQL
  6. 在业务代码或测试代码中注入Mapper接口,调用方法操作数据库
核心关系
组成 作用 复习重点
实体类 封装表中一行数据 属性名尽量与字段名一致
Mapper接口 定义数据库操作方法 方法名表达清楚要执行的操作
SQL语句 真正操作数据库 可写在注解中,也可写在XML中
配置文件 配置数据源和MyBatis行为 数据库连接四要素、日志输出
Mapper接口基本模板
1
2
3
4
5
6
7
8
9
10
11
12
13
@Mapper
public interface UserMapper {
List<User> findAll();

int deleteById(Integer id);

int insert(User user);

int update(User user);

User findByUsernameAndPassword(@Param("username") String username,
@Param("password") String password);
}

细节

  • Mapper接口不需要自己写实现类,MyBatis运行时会生成代理对象
  • @Mapper的作用是让MyBatis识别该接口,并把代理对象交给Spring IOC容器管理
  • DML语句可以用int作为返回值,表示影响的记录数
  • 查询多条数据用List<实体类>,查询单条数据用实体类

7.MyBatis注解写法

适用场景

注解写法适合简单SQL,例如单表的基础增删改查。优点是直观、文件少;缺点是复杂SQL写在Java注解中可读性差。

查询
1
2
3
4
5
6
@Select("select * from user")
List<User> findAll();

@Select("select * from user where username = #{username} and password = #{password}")
User findByUsernameAndPassword(@Param("username") String username,
@Param("password") String password);

细节

  • @Select用于写查询语句
  • 多条件查询时,建议使用@Param给参数命名,SQL中通过#{参数名}取值
  • 查询结果字段名与实体类属性名一致时,MyBatis可以自动封装
删除
1
2
@Delete("delete from user where id = #{id}")
int deleteById(Integer id);

细节

  • #{id}不是字符串拼接,而是预编译占位符
  • 返回值int表示删除了几条记录
新增
1
2
@Insert("insert into user(username, password, name, age) values(#{username}, #{password}, #{name}, #{age})")
int insert(User user);

细节

  • 参数是对象时,#{username}表示调用对象的getUsername()取值
  • 自增主键字段通常不需要手动插入
  • 字段顺序要与values中的参数顺序保持一致
修改
1
2
@Update("update user set username = #{username}, password = #{password}, name = #{name}, age = #{age} where id = #{id}")
int update(User user);

细节

  • 修改语句必须注意where条件,缺少条件会更新整张表
  • 修改对象必须包含用于定位数据的主键值

8.MyBatis XML映射写法

适用场景

XML写法适合复杂SQL,例如多条件动态查询、多表查询、复杂字段映射。优点是SQL集中、可读性更好;缺点是接口和XML之间要严格对应。

XML开发规范
规范 要求
文件位置 XML映射文件与Mapper接口包路径保持一致
文件名称 XML文件名与Mapper接口名一致,如UserMapper.xml
namespace 必须写Mapper接口的全限定名
SQL的id 必须与Mapper接口方法名一致
resultType 查询返回单条记录要封装成的实体类类型
XML基本模板
1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zhang.mapper.UserMapper">

</mapper>
查询写法
1
2
3
4
5
6
7
<select id="findAll" resultType="com.zhang.pojo.User">
select * from user
</select>

<select id="findByUsernameAndPassword" resultType="com.zhang.pojo.User">
select * from user where username = #{username} and password = #{password}
</select>

细节

  • resultType写的是单条记录封装成什么类型,不是List<User>
  • 如果Mapper方法返回List<User>,XML中仍然写resultType="com.zhang.pojo.User"
  • id必须和接口方法名一致,否则MyBatis找不到对应SQL
增删改写法
1
2
3
4
5
6
7
8
9
10
11
12
13
<insert id="insert">
insert into user(username, password, name, age)
values (#{username}, #{password}, #{name}, #{age})
</insert>

<update id="update">
update user set username = #{username}, password = #{password}, name = #{name}, age = #{age}
where id = #{id}
</update>

<delete id="deleteById">
delete from user where id = #{id}
</delete>

细节

  • <insert><update><delete>标签一般不需要写resultType
  • DML执行后返回影响行数,Mapper接口方法可以写int
  • XML和注解不要同时给同一个方法配置SQL,否则会冲突或造成理解混乱

9.注解写法与XML写法对比

对比点 注解写法 XML写法
SQL位置 Mapper接口方法上 resources下的Mapper.xml中
适合场景 简单增删改查 复杂SQL、动态SQL、多表查询
优点 写法直接,文件少 SQL更清晰,便于维护复杂语句
缺点 SQL复杂时可读性差 配置规则多,需要接口和XML对应
选择建议 入门和简单SQL优先使用 复杂业务SQL优先使用

10.MyBatis参数传递细节

#{...}${...}
符号 本质 结果 使用建议
#{...} 参数占位符 会替换为?,走预编译SQL 传递普通参数时强烈推荐
${...} 字符串拼接 直接拼接到SQL语句中 只有动态表名、字段名等特殊场景才考虑
为什么推荐#{...}
  1. 可以防止SQL注入
  2. 可以使用预编译SQL,提高性能
  3. 参数值会被当成普通数据处理,不会改变SQL结构
参数来源
Mapper方法参数 SQL中取值方式
单个简单参数 #{参数名}#{任意名}通常都能取到
多个简单参数 推荐使用@Param("名称")后通过#{名称}取值
对象参数 通过#{属性名}取对象属性值

11.数据库连接池

连接池作用
  • 数据库连接池是一个容器,负责分配和管理数据库连接
  • 程序启动时,连接池中会提前创建一定数量的Connection对象
  • 执行SQL时从连接池获取连接,执行完毕后归还连接池
  • 空闲过久的连接可以被自动释放,减少连接泄露风险
连接池优点
  1. 资源重用
  2. 提升系统响应速度
  3. 避免频繁创建和销毁连接
  4. 降低数据库连接遗漏风险
常见连接池
连接池 说明
Hikari SpringBoot默认连接池,性能好
Druid 阿里巴巴开源连接池,功能强大
C3P0 早期常见连接池
DBCP Apache提供的连接池

12.SpringBoot配置文件

properties与yml
配置文件 格式 特点
application.properties key=value 简单直接,配置多时层级不够清晰
application.yml / application.yaml 缩进表示层级 简洁清晰,以数据为中心,推荐使用
yml语法规则
  1. 大小写敏感
  2. 数值前必须有空格作为分隔符
  3. 使用缩进表示层级关系
  4. 缩进不能使用Tab,只能使用空格
  5. 相同层级左侧对齐
  6. #表示注释
  7. 以0开头的值建议使用引号包裹,避免被识别为八进制
数据源配置模板
1
2
3
4
5
6
7
8
9
10
11
12
spring:
application:
name: springboot-mybatis-quickstart1
datasource:
url: jdbc:mysql://localhost:3306/db02
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 数据库密码

mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
MyBatis日志配置
1
2
3
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

开启后,控制台可以看到MyBatis执行的SQL语句、参数和结果,方便排查数据库操作问题。

13.复习时容易忽略的细节

  1. Mapper接口只定义方法,不写实现类,实现类由MyBatis动态代理生成
  2. @Mapper不能忘,否则Mapper接口不会被MyBatis识别并交给Spring管理
  3. 注解和XML是两种SQL配置方式,同一个方法不要两边都写SQL
  4. XML中的namespace必须等于Mapper接口全限定名
  5. XML中SQL标签的id必须等于Mapper接口的方法名
  6. 查询集合时,XML的resultType仍写集合中单个元素的类型
  7. DML语句返回int可以知道影响了几条记录
  8. 修改和删除一定要注意where条件,避免影响整张表
  9. 普通参数传递优先使用#{...},不要为了省事使用${...}
  10. 开启MyBatis日志后,可以在控制台观察最终执行的SQL和参数

14.核心知识点总结

知识点 说明
JDBC Java操作关系型数据库的底层API
DriverManager 获取数据库连接
Connection 数据库连接对象
PreparedStatement 预编译SQL执行对象
ResultSet 查询结果集对象
executeQuery() 执行DQL查询语句
executeUpdate() 执行DML增删改语句
SQL注入 通过输入改变SQL语义的攻击方式
MyBatis 简化JDBC开发的持久层框架
@Mapper 声明Mapper接口,交给MyBatis和Spring管理
注解写法 通过@Select、@Insert、@Update、@Delete在接口方法上写SQL
#{…} 参数占位符,生成预编译SQL
${…} 字符串拼接符,存在注入风险
XML映射 将SQL写在XML中,适合复杂SQL
Hikari SpringBoot默认数据库连接池
application.yml SpringBoot推荐配置文件格式

day1

计划

  1. 今天学完IO

笔记

1.File

  1. File的3个构造方法
  2. 表示什么:路径,可以表示文件或文件夹
  3. 常用方法
    1. 判断,获取
    2. 创建,删除
    3. 遍历
  4. 不能读写数据,读写需要用到IO流

2.IO流

1.字节流
  1. 计算机数据的最小单位是字节
  2. 字节流可以操作任何文件
  3. 字节流读取文本文件,需要读取为byte[],指定编码方式转为字符串,否则中文字符会出现乱码。
  4. 字节流没有缓冲区,可以用byte数组来做缓冲区,提升读取的效率
2.字符流
  1. 字符集有ASCII,GBK,Unicode等
    1. ASCII英文占用1字节
    2. GBK兼容ASCII,英文1字节,中文2字节。英文字节首位为0,中文高位字节首位为1,由此区分中英文
    3. Unicode编码方式多样
      1. UTF-8,1-4字节表示字符,英文1,中文3
      2. UTF-16,2或4字节表示字符
      3. UTF-32,4字节表示字符
    4. 字符串转为bite[]可以指定编码方式,bite[]构建字符串也可以指定编码。
  2. 字符流底层通过解码器按指定字符集将字节转换为字符。以 UTF-8 为例,解码器会识别字节头,决定本次读取 1~4 个字节拼成一个字符。
  3. 字符流可以在定义时指定编码格式,未指定就会用默认编码。
  4. 字符流的输入,输出流都有缓冲区
    1. 输入流,会先填满缓冲区,优先读取缓冲区,读完缓冲区后才会再取数据填满缓冲区。即使文件清空,也会先读完缓冲区的数据。
    2. 输出流,会先存到缓冲区,如果不调用 flush() 或 close(),数据会一直卡在内存中,直到缓冲区满了才会写入文件。

day12

计划

  1. 多表关系设计(一对一、一对多、多对多)
  2. 多表查询SQL(内连接、外连接、子查询)
  3. 员工列表查询功能实现
  4. 分页查询(原始方式 vs PageHelper插件)
  5. 动态SQL实现条件查询

笔记

1.多表关系设计

三种表关系
关系 说明 实现方式 示例
一对多 一个部门下有多个员工 多的一方添加外键字段 dept(1) → emp(N)
一对一 一个用户对应一张身份证 任意一方加外键+UNIQUE user(1) ↔ user_card(1)
多对多 一个学生可选多门课程 建立中间表,两个外键 student ↔ course
一对多(部门与员工)

部门表(父表/一方):

1
2
3
4
5
6
CREATE TABLE dept (
id int unsigned PRIMARY KEY AUTO_INCREMENT,
name varchar(10) NOT NULL UNIQUE,
create_time datetime,
update_time datetime
);

员工表(子表/多方):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
create table emp(
id int unsigned primary key auto_increment,
username varchar(20) not null unique,
password varchar(50) default '123456',
name varchar(10) not null,
gender tinyint unsigned not null,
phone char(11) not null unique,
job tinyint unsigned,
salary int unsigned,
image varchar(300),
entry_date date,
dept_id int unsigned, -- 外键,关联dept表的id
create_time datetime,
update_time datetime
);
一对一(用户基本信息与身份信息)
1
2
3
4
5
6
7
8
9
10
-- 用户基本信息表
create table tb_user(id int, name varchar(10), ...);

-- 用户身份信息表
create table tb_user_card(
id int primary key,
user_id int unsigned not null unique, -- UNIQUE保证一对一
...
constraint fk_user_id foreign key (user_id) references tb_user(id)
);

要点: 外键设置为 UNIQUE 即可实现一对一。

多对多(学生与课程)
1
2
3
4
5
6
7
8
-- 中间表:学生选课关系
create table tb_student_course(
id int auto_increment primary key,
student_id int not null,
course_id int not null,
constraint fk_courseid foreign key (course_id) references tb_course(id),
constraint fk_studentid foreign key (student_id) references tb_student(id)
);

要点: 中间表至少包含两个外键字段,分别关联两方主键。

物理外键 vs 逻辑外键
对比项 物理外键(foreign key) 逻辑外键
概念 使用foreign key定义外键关联 在业务层逻辑中解决关联
优点 保证数据完整性和一致性 不影响增删改效率,适合分布式
缺点 影响增删改效率,不适合分布式/集群 需要业务层保证数据一致性
使用 学习阶段使用 企业开发首选

结论: 实际开发中基本都使用逻辑外键,物理外键很少使用。


2.多表查询

笛卡尔积

多表查询时,不加条件会产生笛卡尔积(两表所有记录的组合),需要通过连接条件消除无效数据。

1
2
3
4
5
-- 笛卡尔积(180条 = 30员工 × 6部门)
select * from emp, dept;

-- 加连接条件消除笛卡尔积
select * from emp, dept where emp.dept_id = dept.id;
内连接

隐式内连接:

1
2
3
4
5
6
select 字段列表 from1, 表2 where 连接条件;

-- 示例
select e.id, e.name, d.name
from emp e, dept d
where e.dept_id = d.id;

显式内连接:

1
2
3
4
5
select 字段列表 from1 inner join2 on 连接条件;

-- 示例
select e.id, e.name, d.name
from emp e inner join dept d on e.dept_id = d.id;

要点:

  • 内连接只查询两表交集部分的数据
  • 没有部门的员工(dept_id为NULL)不会被查询出来
  • inner 关键字可省略
外连接
类型 语法 说明
左外连接 left join ... on ... 查询左表所有数据 + 右表匹配数据
右外连接 right join ... on ... 查询右表所有数据 + 左表匹配数据
1
2
3
4
5
6
7
-- 左外连接:查询所有员工(包括无部门的)及其部门名称
select e.name, d.name
from emp e left join dept d on e.dept_id = d.id;

-- 右外连接:查询所有部门及其员工
select e.name, d.name
from emp e right join dept d on e.dept_id = d.id;

要点:

  • 外连接保留某一方所有数据,即使不满足连接条件
  • 左外连接和右外连接可通过交换表的位置互相转换
  • 员工列表查询应使用左外连接(保留所有员工)
子查询

SQL中嵌套select语句,称为子查询。

类型 结果 常用操作符 示例场景
标量子查询 一行一列(单值) =, >, <, >=, <= 查询最早入职的员工
列子查询 一列多行 in, not in 查询某部门的所有员工
行子查询 一行多列 =, in 查询与某人薪资和职位相同的员工
表子查询 多行多列 作为临时表 查询每部门最高薪资的员工

标量子查询示例:

1
2
3
-- 查询最早入职的员工
select * from emp
where entry_date = (select min(entry_date) from emp);

列子查询示例:

1
2
3
4
5
-- 查询"教研部"和"咨询部"的所有员工
select * from emp
where dept_id in (
select id from dept where name = '教研部' or name = '咨询部'
);

行子查询示例:

1
2
3
4
5
-- 查询与"李忠"的薪资及职位都相同的员工
select * from emp
where (salary, job) = (
select salary, job from emp where name = '李忠'
);

表子查询示例:

1
2
3
4
-- 获取每个部门中薪资最高的员工
select * from emp e,
(select dept_id, max(salary) max_sal from emp group by dept_id) a
where e.dept_id = a.dept_id and e.salary = a.max_sal;

3.员工列表查询实现

3.1 实体类

Emp员工实体类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Data
public class Emp {
private Integer id;
private String username;
private String password;
private String name;
private Integer gender; // 1:男, 2:女
private String phone;
private Integer job; // 1班主任,2讲师,3学工主管,4教研主管,5咨询师
private Integer salary;
private String image;
private LocalDate entryDate;
private Integer deptId; // 部门ID(逻辑外键)
private LocalDateTime createTime;
private LocalDateTime updateTime;

private String deptName; // 部门名称(多表查询结果封装)
}

PageResult分页结果封装类:

1
2
3
4
5
6
7
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult<T> {
private Long total; // 总记录数
private List<T> rows; // 当前页数据列表
}

3.2 基础实现(左外连接多表查询)

Mapper接口:

1
2
3
4
5
6
7
@Mapper
public interface EmpMapper {
/** 查询所有员工及其部门名称 */
@Select("select e.*, d.name as deptName
from emp e left join dept d on e.dept_id = d.id")
List<Emp> list();
}

要点:

  • 使用 left join 保留所有员工(包括dept_id为NULL的)
  • SQL中给部门名称起别名 deptName,与Java属性名对应
  • 开启驼峰映射后,dept_namedeptName 自动转换

4.分页查询

4.1 原始方式

Mapper接口:

1
2
3
4
5
6
7
8
9
10
11
@Mapper
public interface EmpMapper {
// 查询总记录数
@Select("select count(*) from emp e left join dept d on e.dept_id = d.id")
Long count();

// 分页查询(limit 起始索引, 每页条数)
@Select("select e.*, d.name deptName from emp e left join dept d on e.dept_id = d.id
limit #{start}, #{pageSize}")
List<Emp> list(Integer start, Integer pageSize);
}

Service实现:

1
2
3
4
5
6
public PageResult page(Integer page, Integer pageSize) {
Long total = empMapper.count(); // 1. 查总数
Integer start = (page - 1) * pageSize; // 2. 计算起始索引
List<Emp> empList = empMapper.list(start, pageSize); // 3. 查列表
return new PageResult(total, empList); // 4. 封装结果
}

分页公式: 开始索引 = (当前页码 - 1) × 每页条数

4.2 PageHelper分页插件(推荐)

1. 引入依赖:

1
2
3
4
5
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>

2. application.yml配置:

1
2
3
pagehelper:
reasonable: true # 分页合理化(负数页码查第1页,超范围查最后一页)
helper-dialect: mysql # 指定数据库方言

3. Mapper接口(只需一条SQL):

1
2
3
4
5
6
@Mapper
public interface EmpMapper {
@Select("select e.*, d.name deptName
from emp e left join dept d on e.dept_id = d.id")
List<Emp> list(); // 无需传分页参数
}

4. Service实现:

1
2
3
4
5
6
7
8
9
10
11
public PageResult page(Integer page, Integer pageSize) {
// 1. 设置分页参数(紧跟着的第一条SQL会被分页)
PageHelper.startPage(page, pageSize);

// 2. 执行查询(PageHelper自动追加count和limit)
List<Emp> empList = empMapper.list();
Page<Emp> p = (Page<Emp>) empList;

// 3. 封装结果
return new PageResult(p.getTotal(), p.getResult());
}

PageHelper核心要点:

  • PageHelper.startPage(page, pageSize) 之后的第一条SQL会被自动分页
  • PageHelper自动执行两条SQL:① count(0) 查总数 ② limit 查分页数据
  • SQL结尾不要加分号(;),否则可能导致分页失效
  • Page 对象继承自 List,可用 getTotal()getResult() 获取分页信息

5.条件分页查询

5.1 多参数接收

Controller:

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping
public Result page(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
String name, // 姓名(模糊匹配)
Integer gender, // 性别(精确匹配)
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin, // 开始日期
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) { // 结束日期

PageResult pageResult = empService.page(page, pageSize, name, gender, begin, end);
return Result.success(pageResult);
}

注意: @DateTimeFormat 用于将字符串日期转换为 LocalDate 对象。

5.2 动态SQL(XML映射文件)

EmpMapper.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zhang.mapper.EmpMapper">
<select id="list" resultType="com.zhang.pojo.Emp">
select e.*, d.name deptName
from emp e left join dept d on e.dept_id = d.id
<where>
<if test="name != null and name != ''">
e.name like concat('%', #{name}, '%')
</if>
<if test="gender != null">
and e.gender = #{gender}
</if>
<if test="begin != null and end != null">
and e.entry_date between #{begin} and #{end}
</if>
</where>
order by e.update_time desc
</select>
</mapper>

Mapper接口:

1
2
3
4
@Mapper
public interface EmpMapper {
List<Emp> list(String name, Integer gender, LocalDate begin, LocalDate end);
}

5.3 动态SQL标签说明

标签 作用 示例
<if> 条件成立时拼接SQL <if test="name != null">
<where> 自动添加where关键字,去除多余的and/or 包裹多个<if>条件
<set> UPDATE语句中去除尾部多余逗号 -
<foreach> 遍历集合/数组,常用于in查询 in (1,2,3)

<where> 标签的作用:

  • 自动在SQL后追加 where 关键字
  • 自动去除第一个条件前多余的 andor
  • 如果没有任何条件,不追加 where

5.4 参数封装优化

当请求参数较多时,可以封装为实体类:

EmpQueryParam参数类:

1
2
3
4
5
6
7
8
9
10
11
@Data
public class EmpQueryParam {
private Integer page = 1;
private Integer pageSize = 10;
private String name;
private Integer gender;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate begin;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate end;
}

Controller简化为:

1
2
3
4
5
@GetMapping
public Result page(EmpQueryParam param) { // 一个对象接收所有参数
PageResult pageResult = empService.page(param);
return Result.success(pageResult);
}

Mapper接口简化为:

1
List<Emp> list(EmpQueryParam param);  // 传递整个参数对象

6.核心知识点总结

知识点 说明
一对多 多方加外键,关联一方主键(如emp.dept_id → dept.id)
一对一 任意一方加外键+UNIQUE约束
多对多 建立中间表,两个外键分别关联
物理外键 foreign key,保证完整性但影响性能,少用
逻辑外键 业务层处理关联,企业开发首选
笛卡尔积 多表不加条件的组合,需通过连接条件消除
内连接 只查两表交集,inner join
左外连接 保留左表所有数据,left join
右外连接 保留右表所有数据,right join
标量子查询 结果为单值,用 = 比较
列子查询 结果为一列多行,用 in 比较
行子查询 结果为一行多列,用 = 比较
表子查询 结果为多行多列,作为临时表
LIMIT分页 limit 起始索引, 每页条数
PageHelper MyBatis分页插件,自动实现count+limit
PageHelper.startPage 紧跟其后的第一条SQL自动分页
动态SQL <if> 条件拼接、<where> 智能处理
@DateTimeFormat 字符串日期 → LocalDate
参数封装 多参数封装为实体类,简化方法签名

day11

计划

  1. 前后端分离开发规范与REST风格
  2. 工程搭建与基础代码结构
  3. 部门管理CRUD功能实现
  4. 参数接收方式详解
  5. MyBatis结果映射与驼峰命名
  6. Nginx部署与前后端联调
  7. Logback日志技术

笔记

1.前后端分离开发

前后端分离

将工程拆分为前端工程和后端工程,各自独立开发、独立部署。前端通过异步请求获取数据,后端根据接口文档提供数据。

开发流程
  1. 需求分析 → 理解产品原型和需求文档
  2. 接口定义 → 查阅接口文档,明确地址、参数、响应
  3. 前后端并行开发 → 各自按接口文档实现
  4. 接口测试 → 使用Apifox/Postman等工具测试后端接口
  5. 前后端联调 → 前端请求后端接口,验证整体功能

2.RESTful风格

传统URL vs RESTful
操作 传统URL RESTful URL
查询 /user/getById?id=1 GET /users/1 GET
新增 /user/saveUser POST /users POST
修改 /user/updateUser POST /users PUT
删除 /user/deleteUser?id=1 GET /users/1 DELETE
RESTful核心规则
  • URL定位资源:使用名词复数形式(如/depts/emps
  • HTTP动词描述操作:GET查询、POST新增、PUT修改、DELETE删除
  • 一句话总结:通过URL定位资源,通过HTTP请求方式描述操作

3.工程搭建

依赖引入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
application.yml配置
1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
application:
name: tlias-web-management
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/tlias
username: root
password: 1234

mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
包结构
1
2
3
4
5
6
com.zhang
├── controller/ # 控制层:接收请求、响应结果
├── service/ # 业务层:业务逻辑
│ └── impl/ # 业务实现类
├── mapper/ # 数据访问层:执行SQL
└── pojo/ # 实体类

4.统一响应结果Result

1
2
3
4
5
6
7
8
9
10
@Data
public class Result {
private Integer code; // 1成功,0失败
private String msg; // 提示信息
private Object data; // 响应数据

public static Result success() { ... } // 无数据返回
public static Result success(Object data) { ... } // 有数据返回
public static Result error(String msg) { ... } // 返回错误信息
}

5.部门管理CRUD

5.1 查询部门列表

Controller:

1
2
3
4
5
@GetMapping("/depts")
public Result list(){
ArrayList<Dept> data = deptService.findAll();
return Result.success(data);
}

**Service:**调用Mapper查询所有部门。

Mapper:

1
2
@Select("select id, name, create_time, update_time from dept")
ArrayList<Dept> findAll();
5.2 删除部门(根据ID)

请求方式: DELETE
请求路径: /depts?id=1

1
2
3
4
5
@DeleteMapping("/depts")
public Result deleteById(Integer id){
deptService.deleteById(id);
return Result.success();
}

Mapper:

1
2
@Delete("delete from dept where id=#{id}")
void deleteById(Integer id);
5.3 新增部门

请求方式: POST
请求路径: /depts
请求参数: JSON格式 {"name":"研发部"}

1
2
3
4
5
@PostMapping("/depts")
public Result insert(@RequestBody Dept dept){
deptService.insert(dept);
return Result.success();
}

**Service:**补全createTime和updateTime

1
2
3
4
5
public void insert(Dept dept) {
dept.setCreateTime(LocalDateTime.now());
dept.setUpdateTime(LocalDateTime.now());
deptMapper.insert(dept);
}

Mapper:

1
2
@Insert("insert into dept(name,create_time,update_time) values(#{name},#{createTime},#{updateTime})")
void insert(Dept dept);
5.4 根据ID查询部门

请求方式: GET
请求路径: /depts/1(路径参数)

1
2
3
4
5
@GetMapping("/depts/{id}")
public Result getById(@PathVariable("id") Integer id){
Dept dept = deptService.getById(id);
return Result.success(dept);
}
5.5 修改部门

请求方式: PUT
请求路径: /depts
请求参数: JSON格式 {"id":1,"name":"研发部"}

1
2
3
4
5
@PutMapping("/depts")
public Result update(@RequestBody Dept dept){
deptService.update(dept);
return Result.success();
}

**Service:**更新updateTime

1
2
3
4
public void update(Dept dept) {
dept.setUpdateTime(LocalDateTime.now());
deptMapper.update(dept);
}

6.参数接收方式

6.1 简单参数接收
方式 代码示例 说明
HttpServletRequest request.getParameter("id") 繁琐,需手动转换,不推荐
@RequestParam @RequestParam("id") Integer id 指定参数名,推荐
形参同名 Integer id(参数名与形参名一致时) 最简洁,推荐
1
2
3
4
5
// 方式一:@RequestParam
public Result delete(@RequestParam("id") Integer id)

// 方式二:省略注解(参数名与形参名一致)
public Result delete(Integer id)
6.2 JSON参数接收

使用@RequestBody注解,Spring自动将JSON数据反序列化为Java对象。

1
public Result save(@RequestBody Dept dept)

注意:JSON的key必须与实体类的属性名一致。

6.3 路径参数接收

使用@PathVariable注解,从URL路径中获取参数。

1
2
@GetMapping("/depts/{id}")
public Result getById(@PathVariable("id") Integer id)

7.请求方式映射注解

注解 对应请求方式 用途
@GetMapping GET 查询数据
@PostMapping POST 新增数据
@PutMapping PUT 修改数据
@DeleteMapping DELETE 删除数据
@RequestMapping 所有方式 可通过method属性限定

推荐:使用@GetMapping等衍生注解,比@RequestMapping更简洁、语义更清晰。

类级别@RequestMapping

将公共路径抽取到类上,方法上的路径与类上的路径拼接形成完整请求路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RequestMapping("/depts")
@RestController
public class DeptController {

@GetMapping // 完整路径:/depts
public Result list() { ... }

@DeleteMapping // 完整路径:/depts
public Result delete(Integer id) { ... }

@PostMapping // 完整路径:/depts
public Result insert(@RequestBody Dept dept) { ... }

@GetMapping("/{id}") // 完整路径:/depts/{id}
public Result getById(@PathVariable Integer id) { ... }

@PutMapping // 完整路径:/depts
public Result update(@RequestBody Dept dept) { ... }
}

8.MyBatis结果映射

问题:属性名与字段名不一致

实体类属性(createTime)与数据库字段(create_time)命名风格不同,导致MyBatis无法自动封装。

三种解决方案
方案 代码示例 说明
手动映射 @Results({@Result(column="create_time",property="createTime")}) 灵活但繁琐
SQL起别名 select create_time createTime from dept 简单直接
驼峰映射 配置map-underscore-to-camel-case: true 推荐
驼峰命名配置

application.yml中开启自动驼峰命名映射:

1
2
3
mybatis:
configuration:
map-underscore-to-camel-case: true

映射规则create_timecreateTimeupdate_timeupdateTime

9.Nginx与前后端联调

Nginx核心功能
功能 说明
Web服务器 托管静态资源(HTML、CSS、JS、图片)
反向代理 转发客户端请求到后端服务器
负载均衡 将请求分发到多台后端服务器
请求流程
1
浏览器 → Nginx(:90) → 后端Tomcat(:8080) → Nginx → 浏览器
  1. 浏览器访问 http://localhost:90/api/depts
  2. Nginx接收到请求,根据/api/前缀匹配规则
  3. Nginx重写路径:/api/depts/depts
  4. Nginx将请求转发到 http://localhost:8080/depts
  5. 后端处理完返回数据,Nginx将结果返回给浏览器
Nginx关键配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server {
listen 90;
server_name localhost;

# 前端页面
location / {
root html;
index index.html;
try_files $uri $uri/ /index.html;
}

# 后端API代理
location ^~ /api/ {
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://localhost:8080;
}
}

配置说明

配置项 作用
location / 匹配所有路径,返回前端页面
try_files 支持前端路由刷新不404
location ^~ /api/ 精确匹配API前缀,不再检查正则
rewrite 去掉/api/前缀,重写路径
proxy_pass 转发请求到后端服务器
为什么使用Nginx
  • 安全:后端Tomcat集群不直接暴露给前端
  • 灵活:后端增减服务器对前端无感知
  • 负载均衡:便于实现多服务器负载均衡

10.Apifox接口测试

Apifox是集成了API文档、调试、Mock、测试的协作平台。

使用步骤
  1. 创建项目,导入或编写接口文档
  2. 在”接口管理”中定义接口(路径、方法、参数、响应)
  3. 在”接口调试”中发送请求测试
  4. 在”Mock服务”中生成模拟数据供前端开发
  5. 支持GET/POST/PUT/DELETE等所有请求方式

11.Logback日志

为什么使用日志框架
System.out.println Logback
硬编码,不灵活 可通过配置文件控制
只能输出到控制台 支持输出到文件
无日志级别 支持debug/info/warn/error级别
不便于维护 灵活配置格式和输出位置
Logback入门

1. 引入依赖(SpringBoot已内置)

2. 配置文件logback.xml

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
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>

<!-- 文件输出 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<FileNamePattern>D:/tlias-%d{yyyy-MM-dd}-%i.log</FileNamePattern>
<MaxHistory>30</MaxHistory>
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>

<root level="ALL">
<appender-ref ref="STDOUT" />
<appender-ref ref="FILE" />
</root>
</configuration>

3. 获取Logger对象

1
2
3
4
5
6
7
8
// 方式一:声明Logger对象
private static final Logger log = LoggerFactory.getLogger(ClassName.class);

// 方式二:使用Lombok @Slf4j注解
@Slf4j
public class ClassName {
// 自动生成log对象
}

4. 记录日志

1
2
3
4
log.debug("调试信息");
log.info("运行信息");
log.warn("警告信息");
log.error("错误信息");
日志级别
级别 说明 优先级
trace 追踪(很少使用)
debug 调试
info 运行信息
warn 警告
error 错误

规则:设置的级别越高,输出的日志越少。如设置info级别,则debug和trace级别不会输出。

12.核心知识点总结

知识点 说明
前后端分离 前端独立工程、后端独立工程,通过接口文档协作
RESTful风格 URL定位资源(复数名词),HTTP动词描述操作
@GetMapping GET请求查询数据
@PostMapping POST请求新增数据
@PutMapping PUT请求修改数据
@DeleteMapping DELETE请求删除数据
@RequestBody 接收JSON格式参数
@RequestParam 接收URL查询参数
@PathVariable 接收URL路径参数
@RequestMapping 类级别公共路径抽取
Result 统一响应封装类
map-underscore-to-camel-case 下划线自动转驼峰映射
Nginx 静态服务器+反向代理+负载均衡
Apifox 接口文档编写与测试工具
Logback 日志框架,支持控制台和文件输出
日志级别 debug < info < warn < error

计划

  1. 删除员工(批量删除,一个接口兼容单个/多个)
  2. 修改员工(先查询回显 + 再保存修改)
  3. 异常处理(全局异常处理器 @RestControllerAdvice)
  4. 员工信息统计(职位统计 + 性别统计)

笔记

1.删除员工

需求分析

勾选列表复选框,点击”批量删除”按钮,删除这一批员工信息;也可以只勾选一个,仅删除一个员工。

结论:只需开发一个功能接口,批量删除接口天然包含”只删除一个”的场景,无需单独开发删除单个的接口。

接口要求: 请求路径 /emps、请求方式 DELETE、请求参数为逗号拼接的 id(如 ids=1,2,3)。

Controller 接收参数的两种方式

方式一:数组接收(默认可直接封装到数组中)

1
2
3
4
5
6
@DeleteMapping
public Result delete(Integer[] ids) { // 前端参数名必须与方法形参名一致
log.info("批量删除员工: ids={}", Arrays.asList(ids));
empService.deleteByIds(ids);
return Result.success();
}

方式二:集合接收(封装到 List 集合,需加 @RequestParam)

1
2
3
4
5
6
@DeleteMapping
public Result delete(@RequestParam List<Integer> ids) {
log.info("批量删除员工: ids={}", ids);
empService.deleteByIds(ids);
return Result.success();
}

推荐使用集合方式(List),因为基于集合操作其中的元素更加方便(如增删改查遍历)。本项目实际使用数组方式 Integer[] ids

Service 层(事务控制)

删除员工既要删 emp 表中的基本信息,又要删 emp_expr 表中工作经历信息,多次操作数据库必须加事务控制。

1
2
3
4
5
6
7
8
@Transactional
@Override
public void deleteByIds(List<Integer> ids) {
// 1. 根据ID批量删除员工基本信息
empMapper.deleteByIds(ids);
// 2. 根据员工ID批量删除工作经历信息
empExprMapper.deleteByEmpIds(ids);
}
Mapper 层(<foreach> 实现 in 查询)

EmpMapper.xml:

1
2
3
4
5
6
7
<!--批量删除员工信息-->
<delete id="deleteByIds">
delete from emp where id in
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</delete>

EmpExprMapper.xml: 同理按 emp_id in (...) 批量删除工作经历。

<foreach> 属性回顾:

属性 说明
collection 集合名称(方法参数名)
item 遍历出来的每一项
open 遍历开始前拼接的片段(如 (
close 遍历结束后拼接的片段(如 )
separator 每项之间的分隔符(如 ,

注意:opencloseseparator 属于字符串拼接,不要写成 #{open} 的形式


2.修改员工

修改分为两步:

  1. 查询回显:根据ID查询员工详细信息,回填到页面表单
  2. 保存修改:用户修改后提交,更新数据库

2.1 查询回显

思路

回显时既要查出员工基本信息,又要查出该员工的工作经历信息(一对多)。

方式一:两次查询 —— 分别查 emp 和 emp_expr,再在 Service 中组装(项目中有注释掉的代码)。

方式二:一条SQL多表查询 + resultMap 手动封装(推荐,本项目使用)

1
2
3
4
5
6
7
8
select e.*,
ee.id ee_id,
ee.begin ee_begin,
ee.end ee_end,
ee.company ee_company,
ee.job ee_job
from emp e left join emp_expr ee on e.id = ee.emp_id
where e.id = #{id};
resultMap 封装一对多结果

EmpMapper.xml:

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
<!--自定义结果集 ResultMap-->
<resultMap id="empResultMap" type="com.itheima.pojo.Emp">
<id column="id" property="id" />
<result column="username" property="username" />
<result column="password" property="password" />
<result column="name" property="name" />
<result column="gender" property="gender" />
<result column="phone" property="phone" />
<result column="job" property="job" />
<result column="salary" property="salary" />
<result column="image" property="image" />
<result column="entry_date" property="entryDate" />
<result column="dept_id" property="deptId" />
<result column="create_time" property="createTime" />
<result column="update_time" property="updateTime" />

<!--封装一对多中的"多":exprList-->
<collection property="exprList" ofType="com.itheima.pojo.EmpExpr">
<id column="ee_id" property="id"/>
<result column="ee_company" property="company"/>
<result column="ee_job" property="job"/>
<result column="ee_begin" property="begin"/>
<result column="ee_end" property="end"/>
<result column="ee_empid" property="empId"/>
</collection>
</resultMap>

<!--根据ID查询员工的详细信息-->
<select id="getById" resultMap="empResultMap">
select e.*,
ee.id ee_id, ee.emp_id ee_empid, ee.begin ee_begin,
ee.end ee_end, ee.company ee_company, ee.job ee_job
from emp e left join emp_expr ee on e.id = ee.emp_id
where e.id = #{id}
</select>

要点:

  • 一对多查询结果必须用 <resultMap> 手动封装
  • <resultMap><id> 标签映射主键(用于去重),<result> 映射普通字段
  • <collection> 用于封装一对多中的”多”,ofType 指定集合元素的类型
  • SQL 中给子表字段起别名(如 ee_id),与 resultMap 的 column 对应,避免与主表字段冲突
  • 联查的员工基本信息在 e.* 中,工作经历通过别名列封装进 exprList
resultType vs resultMap 如何选择
场景 选择
查询返回字段名与实体属性名能直接对应 resultType
字段名与属性名对应不上,或实体属性复杂(如一对多、嵌套) resultMap 手动封装
Controller / Service
1
2
3
4
5
6
@GetMapping("/{id}")
public Result getInfo(@PathVariable Integer id) {
log.info("根据id查询员工的详细信息");
Emp emp = empService.getInfo(id);
return Result.success(emp);
}

2.2 保存修改

实现思路(三步)
  1. 根据ID更新员工基本信息(动态SQL,只更新有值的字段)
  2. 根据员工ID删除旧的工作经历信息
  3. 新增新的工作经历信息

为什么要”先删旧再增新”? 页面提交的是编辑后的整份工作经历列表,无法精确得知哪些是被删改的,最简单可靠的方式就是全删旧 + 全增新

Service 实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional
@Override
public void update(Emp emp) {
// 1. 根据ID更新员工基本信息
emp.setUpdateTime(LocalDateTime.now());
empMapper.updateById(emp);

// 2. 根据员工ID删除员工的工作经历信息 【删除老的】
empExprMapper.deleteByEmpIds(Arrays.asList(emp.getId()));

// 3. 新增员工的工作经历数据 【新增新的】
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if (!CollectionUtils.isEmpty(exprList)) {
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
动态SQL更新(<set> + <if>

EmpMapper.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!--根据ID更新员工信息-->
<update id="updateById">
update emp
<set>
<if test="username != null and username != ''">username = #{username},</if>
<if test="password != null and password != ''">password = #{password},</if>
<if test="name != null and name != ''">name = #{name},</if>
<if test="gender != null">gender = #{gender},</if>
<if test="phone != null and phone != ''">phone = #{phone},</if>
<if test="job != null">job = #{job},</if>
<if test="salary != null">salary = #{salary},</if>
<if test="image != null and image != ''">image = #{image},</if>
<if test="entryDate != null">entry_date = #{entryDate},</if>
<if test="deptId != null">dept_id = #{deptId},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</set>
where id = #{id}
</update>

<set> 标签作用: 自动去掉更新语句中最后一个多余的逗号<where> 是去掉多余的 and/or)。

数字类型(Integer)判空只需 != null,字符串类型要同时判空字符串 != ''

Controller:

1
2
3
4
5
6
@PutMapping
public Result update(@RequestBody Emp emp) {
log.info("修改员工信息, {}", emp);
empService.update(emp);
return Result.success();
}

3.异常处理

问题分析

修改员工时手机号重复(违反唯一约束),服务端抛异常返回框架默认的错误JSON,而不是我们约定的统一响应结果 Result,导致前端无法解析。

未做任何异常处理时,异常在分层架构中的传递:Mapper → Service → Controller → 框架,最终由框架返回不符合规范的JSON。

两种解决方案
方案 做法 评价
方案一 所有Controller所有方法 try…catch 代码臃肿,不推荐
方案二 全局异常处理器 简单、优雅,推荐
全局异常处理器

步骤:

  1. 定义一个类,类上加 @RestControllerAdvice —— 代表全局异常处理器
  2. 定义一个方法捕获异常,方法上加 @ExceptionHandler,通过 value 属性(或方法形参)指定捕获的异常类型
1
2
3
4
5
6
7
8
9
10
11
@RestControllerAdvice
public class GlobalExceptionHandler {

// 处理异常:方法形参中指定能够处理的异常类型
@ExceptionHandler
public Result ex(Exception e) {
e.printStackTrace(); // 打印堆栈中的异常信息
// 捕获到异常之后,响应一个标准的Result
return Result.error("对不起,操作失败,请联系管理员");
}
}

关键注解:

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody,处理异常方法的返回值会转换为 JSON 响应给前端
  • @ExceptionHandler:指定可以捕获哪种类型的异常
项目中的增强版:处理具体异常(DuplicateKeyException)

本项目在全局异常处理器中重载了两个方法,Spring会按异常类型精确匹配,优先调用参数类型最匹配的处理方法(更精确的异常会覆盖通用 Exception 的处理)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

// 通用异常兜底
@ExceptionHandler
public Result handleException(Exception e) {
log.error("全局异常处理:{}", e.getMessage());
return Result.error("出错了,请联系管理员");
}

// 针对主键/唯一约束冲突的异常单独处理
@ExceptionHandler
public Result handleException(DuplicateKeyException e) {
log.error("全局异常处理:{}", e.getMessage());
String message = e.getMessage();
int index = message.indexOf("Duplicate entry"); // 定位重复字段位置
String duplicateKey = message.substring(index).split(" ")[2]; // 提取重复的值
return Result.error(duplicateKey + "已存在"); // 例如:13309090027已存在
}
}

要点: 出现异常后,全局异常处理器将异常捕获并统一包装成 Result 返回,前端可以正常解析并提示错误信息。


4.员工信息统计

报表(图形)制作主要靠前端引入 ECharts 等组件,服务端只负责提供数据

官网:https://echarts.apache.org/zh/index.html

4.1 职位统计

接口

GET /report/empJobData,返回封装好的 JobOption{jobList, dataList},分别对应 ECharts 的 X 轴分类和数据。

JobOption 封装类
1
2
3
4
5
6
7
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobOption {
private List jobList; // X轴:职位名称列表
private List dataList; // Y轴:对应人数列表
}
Controller / Service
1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/report")
public class ReportController {
@Autowired
private ReportService reportService;

@GetMapping("/empJobData")
public Result getEmpJobData() {
log.info("统计各个职位的员工人数");
JobOption jobOption = reportService.getEmpJobData();
return Result.success(jobOption);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class ReportServiceImpl implements ReportService {
@Autowired
private EmpMapper empMapper;

@Override
public JobOption getEmpJobData() {
List<Map<String,Object>> list = empMapper.countEmpJobData();
// 用Stream分别提取"职位名"和"人数"两个列表
List<Object> jobList = list.stream().map(dataMap -> dataMap.get("pos")).toList();
List<Object> dataList = list.stream().map(dataMap -> dataMap.get("total")).toList();
return new JobOption(jobList, dataList);
}
}
Mapper(查询结果封装为 Map)
1
2
@MapKey("pos")   // 指定返回Map的唯一标识字段【也可以不指定】
List<Map<String,Object>> countEmpJobData();

EmpMapper.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 统计各个职位的员工人数 -->
<select id="countEmpJobData" resultType="java.util.Map">
select
(case job when 1 then '班主任'
when 2 then '讲师'
when 3 then '学工主管'
when 4 then '教研主管'
when 5 then '咨询师'
else '其他' end) pos,
count(*) total
from emp group by job
order by total
</select>

要点:

  • 查询结果每行是一个 Map,resultType="java.util.Map",字段别名即 Map 的 key(postotal
  • @MapKey 用于指定返回 Map 集合中每条记录的标识字段(可不指定)

4.2 性别统计

接口

GET /report/empGenderData,返回 List<Map>(ECharts 饼图需要的数据格式)。

Mapper
1
2
@MapKey("name")
List<Map> countEmpGenderData();

EmpMapper.xml:

1
2
3
4
5
6
7
<!-- 统计员工的性别信息 -->
<select id="countEmpGenderData" resultType="java.util.Map">
select
if(gender = 1, '男', '女') as name,
count(*) as value
from emp group by gender
</select>
SQL 函数总结
函数 语法 作用
case语法一 case when cond1 then res1 [when cond2 then res2] else res end 条件成立取对应值,都不成立取 else
case语法二(等值匹配) case expr when val1 then res1 [when val2 then res2] else res end expr 等于 val1 取 res1,以此类推
if if(条件, 条件为true取值, 条件为false取值) 三元判断,如 if(gender=1,'男','女')
ifnull ifnull(expr, val1) expr 不为 null 取自身,否则取 val1

5.核心知识点总结

知识点 说明
批量删除设计 一个接口兼容单个/多个,无需分开开发
数组接收参数 Integer[] ids,前端参数名与形参名一致即可
集合接收参数 @RequestParam List<Integer> ids,推荐使用
<foreach> 动态拼接 in 查询,collection/item/open/close/separator
修改员工两步 ① 根据ID查询回显 ② 保存修改
查询回显SQL left join emp_expr 一次查出基本信息+工作经历
resultMap + collection 一对多结果手动封装,ofType 指定集合元素类型
resultType vs resultMap 字段能对上用 resultType;字段对不上或结构复杂用 resultMap
动态更新 <set> 自动去掉最后一个多余的逗号
更新经历三步 更新基本信息 + 删除旧经历 + 新增新经历
@RestControllerAdvice 全局异常处理器 = @ControllerAdvice + @ResponseBody
@ExceptionHandler 指定捕获的异常类型,可按异常类型重载多个处理方法
DuplicateKeyException 主键/唯一约束冲突异常,可提取重复值提示”xxx已存在”
报表开发模式 前端用 ECharts,后端只提供数据
JobOption 职位统计封装类(jobList + dataList)
@MapKey 指定返回Map集合的标识字段(可不指定)
case 函数 流程控制,两种语法(条件式/等值式)
if 函数 if(条件, true值, false值),如性别转男/女
ifnull 函数 expr 为 null 时取默认值
count + group by 统计场景的核心 SQL 组合

day13

计划

  1. 新增员工功能(保存emp表 + 批量保存工作经历)
  2. 事务管理(@Transactional、rollbackFor、propagation)
  3. 文件上传(前端三要素、本地存储、阿里云OSS)

笔记

1.新增员工

需求分析

新增员工时,表单中既要录入员工基本信息,又要录入工作经历信息:

  • 员工基本信息 → 存入 emp
  • 工作经历信息 → 存入 emp_expr 表(一个员工可有多段经历)

接口要求: 请求路径 /emps、请求方式 POST、请求参数 JSON格式(@PostMapping + @RequestBody

Emp实体类增加工作经历属性
1
2
3
4
5
6
@Data
public class Emp {
...
private String deptName; // 部门名称
private List<EmpExpr> exprList; // 员工工作经历信息(新增)
}
保存员工基本信息(主键返回)

EmpMapper:

1
2
3
4
5
// 主键返回:将数据库自动生成的主键回填到emp对象的id属性中
@Options(useGeneratedKeys = true, keyProperty = "id")
@Insert("insert into emp(username, name, gender, phone, job, salary, image, entry_date, dept_id, create_time, update_time) " +
"values (#{username},#{name},#{gender},#{phone},#{job},#{salary},#{image},#{entryDate},#{deptId},#{createTime},#{updateTime})")
void insert(Emp emp);

为什么需要主键返回? 保存员工后还要保存其工作经历,而工作经历表的 emp_id 字段需要关联员工的ID。@Options(useGeneratedKeys = true, keyProperty = "id") 会在insert执行后,把自增主键值回填到 emp.getId()

批量保存工作经历(<foreach>标签)

EmpExprMapper接口:

1
2
3
4
@Mapper
public interface EmpExprMapper {
void insertBatch(List<EmpExpr> exprList); // 批量插入
}

EmpExprMapper.xml:

1
2
3
4
5
6
<insert id="insertBatch">
insert into emp_expr (emp_id, begin, end, company, job) values
<foreach collection="exprList" item="expr" separator=",">
(#{expr.empId}, #{expr.begin}, #{expr.end}, #{expr.company}, #{expr.job})
</foreach>
</insert>

<foreach> 标签常用属性:

属性 说明
collection 集合名称(方法参数名)
item 遍历出来的元素/项
separator 每次遍历之间的分隔符(如逗号)
open 遍历开始前拼接的片段
close 遍历结束后拼接的片段
Service完整逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void save(Emp emp) {
// 1. 补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
// 2. 保存员工基本信息(insert后emp.getId()有值)
empMapper.insert(emp);
// 3. 保存工作经历 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if (!CollectionUtils.isEmpty(exprList)) {
exprList.forEach(expr -> expr.setEmpId(empId)); // 给每段经历补上empId
empExprMapper.insertBatch(exprList);
}
}

2.事务管理

为什么需要事务

新增员工涉及两次数据库操作(插emp表 + 插emp_expr表)。如果保存员工成功、保存经历失败,就会造成数据不完整不一致。事务能让多个操作要么全部成功,要么全部失败

事务概念与操作
  • 概念: 一组操作的集合,是不可分割的工作单位,要么同时成功,要么同时失败
  • SQL层面三步操作:
    • 开启事务:start transaction; / begin;
    • 全部成功:commit;
    • 有失败:rollback;

注意:MySQL默认事务是自动提交的,执行一条DML语句会立即隐式提交。

Spring事务管理:@Transactional注解

作用: 方法执行前开启事务,执行完毕提交事务,出现异常则回滚事务。

1
2
3
4
5
6
7
@Transactional
@Override
public void save(Emp emp) {
empMapper.insert(emp);
int i = 1/0; // 异常→回滚
empExprMapper.insertBatch(exprList);
}

可加的位置:

位置 作用
方法上 当前方法交给Spring事务管理
类上 当前类所有方法都进行事务管理
接口上 接口下所有实现类的所有方法都进行事务管理

为什么放在业务层? 一个业务功能可能包含多个数据访问操作,在业务层控制事务可以将多个操作控制在同一个事务范围内。

事务日志配置
1
2
3
logging:
level:
org.springframework.jdbc.support.JdbcTransactionManager: debug
rollbackFor属性(异常回滚)

默认规则: 只有出现**运行时异常(RuntimeException)**才回滚事务;抛出编译时异常(Exception)不会回滚。

1
2
3
4
5
6
// 模拟编译时异常:事务没有回滚,数据保存成功
@Transactional
public void save(Emp emp) throws Exception {
empMapper.insert(emp);
throw new Exception("出现异常了~~~"); // 不会回滚!
}

解决: 通过 rollbackFor 指定回滚的异常类型。

1
2
3
4
5
@Transactional(rollbackFor = Exception.class)  // 所有异常都回滚
public void save(Emp emp) throws Exception {
empMapper.insert(emp);
throw new Exception("出现异常了~~~"); // 现在会回滚
}

结论:

  • 默认只有 RuntimeException 会回滚
  • 需要回滚其他异常时,用 rollbackFor 属性指定
propagation属性(事务传播行为)

概念: 一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制。

属性值 含义 使用场景
REQUIRED(默认) 需要事务,有则加入,无则新建 大部分情况
REQUIRES_NEW 总是创建新事务 不希望事务相互影响(如记日志)
SUPPORTS 有则加入,无则无事务运行 少用
NOT_SUPPORTED 无事务状态下运行 少用
MANDATORY 必须有事务,否则抛异常 少用
NEVER 必须没有事务,否则抛异常 少用

案例:新增员工时无论成功失败都记录日志

问题:save方法有事务,insertLog默认REQUIRED会加入同一事务 → 主事务回滚时日志也被回滚,日志没记上。

解决: 让insertLog独立开启新事务。

1
2
3
4
5
6
7
8
@Service
public class EmpLogServiceImpl implements EmpLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW) // 独立新事务
@Override
public void insertLog(EmpLog empLog) {
empLogMapper.insert(empLog);
}
}

两个必须掌握的传播行为:

  • REQUIRED: 大部分情况下使用,加入已有事务
  • REQUIRES_NEW: 不希望事务互相影响时使用,如记录日志(无论业务成功与否日志都要记上)
事务四大特性(ACID)
特性 英文 说明
原子性 Atomicity 事务是不可分割的最小单元,要么全部成功,要么全部失败
一致性 Consistency 事务完成时,数据都必须处于一致状态
隔离性 Isolation 多个并发事务之间相互隔离,互不干扰
持久性 Durability 事务提交或回滚后,对数据的改变是永久的

3.文件上传

简介

将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载。

前端上传页面三要素
要素 要求 原因
file域 表单必须有 <input type="file"> 用于选择文件
POST方式 method="post" 上传文件通常较大,POST才能传输
enctype enctype="multipart/form-data" 普通编码格式不适合传输二进制大文件
1
2
3
4
5
<form action="/upload" method="post" enctype="multipart/form-data">
姓名: <input type="text" name="username"><br>
头像: <input type="file" name="file"><br>
<input type="submit" value="提交">
</form>
后端接收(MultipartFile)
1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
@RestController
public class UploadController {
@PostMapping("/upload")
public Result upload(String username, Integer age, MultipartFile file) throws Exception {
log.info("上传文件:{}, {}, {}", username, age, file);
if (!file.isEmpty()) {
file.transferTo(new File("D:\\images\\" + file.getOriginalFilename()));
}
return Result.success();
}
}

注意: 方法形参名必须与请求参数名一致。不一致时用 @RequestParam("file") MultipartFile image 绑定。

MultipartFile常用方法:

方法 作用
getOriginalFilename() 获取原始文件名
transferTo(File dest) 将接收的文件转存到磁盘
getSize() 获取文件大小(字节)
getBytes() 获取文件内容的字节数组
getInputStream() 获取文件内容的输入流
本地存储

问题1:文件名重复会覆盖。 解决:用UUID生成唯一文件名。

1
2
3
4
// 生成唯一文件名:UUID + 原文件后缀
String originalFilename = file.getOriginalFilename();
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName;

问题2:大文件上传报错。 SpringBoot默认单个文件最大1M,需配置:

1
2
3
4
5
spring:
servlet:
multipart:
max-file-size: 10MB # 单个文件最大
max-request-size: 100MB # 一次请求最大

本地存储的缺点: 不安全(磁盘损坏文件丢失)、容量有限、无法直接访问。

阿里云OSS(云存储)

OSS是什么: 阿里云对象存储服务(Object Storage Service),海量、安全、低成本、高可靠的云存储服务,通过网络随时存储和调用文件。

SDK: Software Development Kit(软件开发工具包),包含依赖jar包和代码示例。

使用第三方服务的通用思路:

  1. 准备工作:注册账号、开通服务、创建Bucket、创建AccessKey
  2. 参照官方SDK示例编写入门程序
  3. 集成到项目中

关键概念:

概念 说明
Bucket 存储空间,存储文件的容器,所有文件必须隶属于某个Bucket
Object 存储的对象(就是文件)
endpoint Bucket对应的访问域名
region Bucket所属地域(如cn-beijing)
AccessKey 访问凭证(ID + Secret),通过环境变量配置

1. 引入依赖:

1
2
3
4
5
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>

2. 环境变量配置AccessKey:

1
2
3
4
set OSS_ACCESS_KEY_ID=你的ID
set OSS_ACCESS_KEY_SECRET=你的Secret
setx OSS_ACCESS_KEY_ID "%OSS_ACCESS_KEY_ID%"
setx OSS_ACCESS_KEY_SECRET "%OSS_ACCESS_KEY_SECRET%"

3. 集成工具类AliyunOSSOperator:

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
@Component
public class AliyunOSSOperator {
private String endpoint = "https://oss-cn-beijing.aliyuncs.com";
private String bucketName = "java-ai";
private String region = "cn-beijing";

public String upload(byte[] content, String originalFilename) throws Exception {
// 从环境变量获取凭证
EnvironmentVariableCredentialsProvider credentialsProvider =
CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
// 按日期分目录 + UUID唯一文件名
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = dir + "/" + newFileName;
// 创建客户端
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.region(region)
.build();
try {
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
} finally {
ossClient.shutdown();
}
// 拼接返回可访问的URL
return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
}
}

4. UploadController改造:

1
2
3
4
5
6
7
8
@PostMapping("/upload")
public Result upload(MultipartFile file) throws Exception {
if (!file.isEmpty()) {
String url = aliyunOSSOperator.upload(file.getBytes(), file.getOriginalFilename());
return Result.success(url); // 返回图片访问URL给前端
}
return Result.error("上传失败");
}

4.配置参数优化(@Value vs @ConfigurationProperties)

问题: endpoint、bucketName等参数写死在Java代码中,测试/生产环境切换不便维护。

方式一:@Value注解逐个注入
1
2
3
4
@Value("${aliyun.oss.endpoint}")
private String endpoint;
@Value("${aliyun.oss.bucketName}")
private String bucketName;
1
2
3
4
5
aliyun:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
bucketName: java-ai
region: cn-beijing

适用: 一两个属性、不需要复用。

方式二:@ConfigurationProperties批量绑定(推荐)

三个步骤:

  1. 创建实体类,属性名与配置key一致
  2. 将实体类交给IOC容器管理(@Component
  3. 实体类上添加 @ConfigurationProperties(prefix="...") 指定前缀
1
2
3
4
5
6
7
8
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss") // 批量绑定配置
public class AliyunOSSProperties {
private String endpoint;
private String bucketName;
private String region;
}
1
2
3
4
5
6
7
8
9
10
@Component
public class AliyunOSSOperator {
@Autowired
private AliyunOSSProperties aliyunOSSProperties; // 直接注入整个配置对象

public String upload(...) {
String endpoint = aliyunOSSProperties.getEndpoint();
...
}
}

对比:

方式 优点 缺点
@Value 简单直接 配置项多时繁琐,不便维护复用
@ConfigurationProperties 批量注入,便于维护复用 需要额外建实体类

5.核心知识点总结

知识点 说明
@PostMapping + @RequestBody 接收JSON格式的新增请求
@Options(useGeneratedKeys, keyProperty) 主键返回,自增主键回填到对象id属性
<foreach> 批量插入,collection/item/separator/open/close
CollectionUtils.isEmpty 判断集合是否为空
事务 一组操作的集合,要么全部成功要么全部失败
@Transactional 开启事务管理,异常自动回滚
事务注解位置 业务层方法/类/接口上
rollbackFor 默认只回滚RuntimeException,rollbackFor=Exception.class回滚所有异常
REQUIRED 默认传播行为,有事务则加入
REQUIRES_NEW 总是创建新事务,互不影响(如记日志)
ACID 原子性、一致性、隔离性、持久性
上传三要素 file域 + POST + enctype=”multipart/form-data”
MultipartFile 接收上传文件,transferTo转存磁盘
UUID文件名 解决文件名重复覆盖问题
max-file-size SpringBoot默认单文件最大1M,需配置调大
阿里云OSS 对象存储服务,解决本地存储的缺点
SDK 软件开发工具包,含依赖和示例代码
@Value 单个属性注入配置
@ConfigurationProperties 批量绑定配置到实体类,便于维护复用

实战总结:登录认证(登录功能 + 登录校验)

本文是对《12-后端Web实战(登录认证)》的总结,记录”登录功能徒有其表 → 登录校验闭环”的完整链路。
项目地址:project/tlias-web-management(SpringBoot + MyBatis + JWT + Filter/Interceptor)
对应讲义:讲义/12-后端Web实战(登录认证).md


一、本节要解决的核心问题

上一节做完基础登录后,会出现一个安全漏洞:浏览器直接访问 http://localhost:90不登录也能进后台

原因:HTTP 是无状态协议,每次请求相互独立,服务器根本不知道”这次请求的员工登没登录过”。而部门管理、员工管理等接口在服务端没有任何登录判断

1
2
3
登录功能(徒有其表)  ──►  需要登录校验(闭环)
只查账号密码 服务端统一拦截每一个请求
不存登录标记 先校验是否登录,再放行业务

登录校验的实现思路拆成两块:

关键点 作用 用到的技术
① 存登录标记 登录成功后留一个”已登录”凭证,后续请求能带上 会话技术(令牌 JWT)
② 统一拦截 拦下所有请求,统一校验标记,避免每个接口都写一遍 Filter 过滤器 / Interceptor 拦截器

为什么不能在每个 Controller 方法里手写校验?——相同逻辑会重复出现在所有增删改查接口里,代码繁琐。统一拦截是标准做法。


二、会话技术:三种方案对比

1. 概念澄清

  • 会话:浏览器与服务器之间的一次连接(从打开浏览器第一次访问,到任一方断开)。一次会话可包含多次请求响应。
  • 会话跟踪:服务器识别”多次请求是否来自同一浏览器”,从而在同一次会话的多次请求间共享数据
  • 为什么要共享数据?因为 HTTP 无状态,后一次请求拿不到前一次请求生成的数据。

2. 三种方案横向对比

方案 存储位置 底层依赖 优点 缺点
Cookie 客户端浏览器 HTTP 协议自带(Set-Cookie 响应头 / Cookie 请求头) 浏览器自动收发,无需手写 移动端 APP 不可用;不安全(可禁用);不能跨域
Session 服务端 底层基于 Cookie(JSESSIONID) 数据在服务端,相对安全 集群环境下无法直接用;移动端不可用;可被禁用;不能跨域
令牌 Token 客户端(如 localStorage) 自己实现生成/传递/校验 支持 PC + 移动端;解决集群认证;不占服务端存储 需自己实现整套逻辑

企业开发首选第三种令牌技术,前两种已很少使用。本项目也采用令牌。

3. 跨域速记

三个维度(协议 / IP(域名) / 端口)任意一个不同就是跨域。Cookie 无法跨域,这是 Cookie/Session 方案在前后端分离部署时失效的直接原因。

1
2
3
http://192.168.150.200/login.html  →  https://192.168.150.200/login   协议不同 → 跨域
http://192.168.150.200/login.html → http://192.168.150.100/login IP不同 → 跨域
http://192.168.150.200/login.html → http://192.168.150.200:8080/login 端口不同 → 跨域

4. 集群环境为何 Session 失效

部署多份 Tomcat + 负载均衡时:登录请求落到 Tomcat1 创建 Session,下一次查询请求可能被分到 Tomcat2,Tomcat2 里没有这个 JSESSIONID 对应的 Session → 会话丢失。令牌方案把凭证存在客户端,每次请求自带,天然避开这个问题。


三、JWT 令牌

1. 是什么

JWT(JSON Web Token):一种简洁、自包含的字符串格式,用于通信双方以 JSON 格式安全传输信息,靠数字签名保证可靠性。

  • 简洁:就是一个字符串,可直接放请求参数或请求头。
  • 自包含:可在令牌里存自定义数据(如 id、username),不用查服务端。
  • 本质:把原始 JSON 数据安全封装成字符串,传输时基于 base64 编码(编码 ≠ 加密)。

2. 三段组成(用 . 分隔)

1
2
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSIsImV4cCI6MTcwMTkwOTAxNX0.N-MD6DmoeIIY5lB5z73UFLN9u7veppx1K5_N_jS9Yko
└── Header(头) ──┘ └── Payload(载荷) ──────────────────────────────┘ └── Signature(签名) ──────────────┘
部分 内容 举例
Header 令牌类型 + 签名算法 {"alg":"HS256","type":"JWT"}
Payload 自定义信息 + 默认信息(如 exp 过期时间) {"id":1,"username":"Tom","exp":...}
Signature Header + Payload + 秘钥,按指定算法计算,防篡改 签名串

关键结论:篡改令牌任意一个字符,解析都会报错令牌过期后解析也报错(非法)。

3. 生成与校验(讲义示例)

依赖:

1
2
3
4
5
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

生成 + 校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 生成
Map<String, Object> claims = new HashMap<>();
claims.put("id", 10);
claims.put("username", "itheima");

String jwt = Jwts.builder()
.signWith(SignatureAlgorithm.HS256, "aXRjYXN0") // 签名算法 + 秘钥
.addClaims(claims) // 自定义载荷
.setExpiration(new Date(System.currentTimeMillis() + 12 * 3600 * 1000)) // 过期时间
.compact();

// 校验(解析失败就抛异常)
Claims claims = Jwts.parser()
.setSigningKey("aXRjYXN0") // 必须和生成时同一秘钥
.parseClaimsJws(jwt)
.getBody();

两条铁律:

  • 校验用的签名秘钥,必须和生成时配套
  • 解析报错 = 令牌被篡改 或 已过期 = 非法。

四、登录时下发令牌(项目实现)

1. 整体流程

1
2
3
4
5
6
7
8
9
10
11
12
13
浏览器                服务端
│ POST /login {username,password}
│ ─────────────────────────────────► │ EmpService.login 查 emp 表
│ │ 查到 → 生成 JWT 装进 LoginInfo
│ {code:1, data:{id,username,name,token}}
│ ◄───────────────────────────────── │
│ 前端把 token 存到 localStorage
│ │
│ GET /depts Header: token: xxx
│ ─────────────────────────────────► │ Filter/Interceptor 校验 token
│ │ 合法 → 放行到 Controller
│ {code:1, data:[...]}
│ ◄───────────────────────────────── │

2. 关键类与代码

类/所在层 全限定类名 作用
pojo com.zhang.pojo.LoginInfo 登录成功响应封装:idusernamenametoken
utils com.zhang.utils.JwtUtils JWT 工具类:generateToken 生成、parseToken 解析
controller com.zhang.controller.LoginController /login 接口入口,返回 Result.success(loginInfo)Result.error(...)
service com.zhang.service.impl.EmpServiceImpl login(Emp):根据用户名密码查库 → 生成 JWT → 组装 LoginInfo
mapper com.zhang.mapper.EmpMapper getByUsernamePassword(Emp):按 username + password 查询员工

LoginInfo(响应封装):

1
2
3
4
5
6
7
8
9
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginInfo {
private Integer id;
private String username;
private String name;
private String token; // JWT 令牌
}

JwtUtils(项目版本,方法名是 generateToken / parseToken):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class JwtUtils {
private static String signKey = "emhhbmctMjAyNg=="; // 签名秘钥
private static long expire = 12 * 60 * 60 * 1000; // 12 小时

public static String generateToken(Map<String, Object> claims) {
return Jwts.builder()
.addClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + expire))
.signWith(SignatureAlgorithm.HS256, signKey)
.compact();
}

public static Map<String, Object> parseToken(String token) {
return Jwts.parser()
.setSigningKey(signKey)
.parseClaimsJws(token)
.getBody();
}
}

LoginController

1
2
3
4
5
6
7
8
9
@PostMapping("/login")
public Result login(@RequestBody Emp emp) {
log.info("登录请求 emp:{}", emp);
LoginInfo loginInfo = empService.login(emp);
if (loginInfo != null) {
return Result.success(loginInfo);
}
return Result.error("用户名或密码错误");
}

EmpServiceImpl.login(查库 + 生成 token):

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public LoginInfo login(Emp emp) {
Emp e = empMapper.getByUsernamePassword(emp); // select id,username,name from emp where username=? and password=?
if (e != null) {
Map<String, Object> map = new HashMap<>();
map.put("id", emp.getId()); // ⚠ 见下方"易错点":建议改用 e.getId()
map.put("username", emp.getUsername());
String token = JwtUtils.generateToken(map);
return new LoginInfo(e.getId(), e.getUsername(), e.getName(), token);
}
return null;
}

前端拿到 token 后存 localStorage(不是 cookie),后续每个请求在请求头 token 字段带上。


五、统一拦截方案一:Filter 过滤器

1. 是什么

Filter 是 JavaWeb 三大组件(Servlet、Filter、Listener)之一,能把对资源的请求拦下来做通用操作(登录校验、统一编码、敏感字符处理等)。要先经过 Filter,才能访问后面的资源。

2. 入门三步

  1. 定义类实现 Filter 接口,重写 init / doFilter / destroy
  2. 类上加 @WebFilter(urlPatterns = "/*") 配置拦截路径。
  3. 启动类加 @ServletComponentScan 开启 Servlet 组件支持。

三个方法:

方法 调用时机 调用次数
init web 服务器启动创建 Filter 实例时 1 次
doFilter 每次拦截到请求 多次
destroy web 服务器关闭时 1 次

放行chain.doFilter(request, response); —— 不调用就到不了后面的资源。

3. 执行流程(放行前 / 放行后)

1
2
3
4
5
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
System.out.println("放行前逻辑.....");
chain.doFilter(req, resp); // 放行,访问 web 资源
System.out.println("放行后逻辑....."); // 资源处理完回到这里
}

4. 拦截路径配置

拦截路径 urlPatterns 含义
具体路径 /login 只拦截 /login
目录拦截 /emps/* 拦截 /emps 下所有资源
拦截所有 /* 拦截所有资源

5. 过滤器链

多个 Filter 形成链,一个接一个执行;放行后回来时顺序相反。注解配置的 Filter,执行顺序按类名字符串自然排序(AbcFilter 先于 DemoFilter)。

6. 登录校验六步(项目类:TokenFilter,全限定类名 com.zhang.filter.TokenFilter

1
2
3
4
5
6
1. 获取请求 URI
2. 包含 /login → 放行(登录请求例外)
3. 取请求头 token
4. token 为空 → 响应 401
5. 解析 token 失败 → 响应 401
6. 解析成功 → 放行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 项目 TokenFilter 核心逻辑(注意:本项目中此 Filter 未启用,见"项目现状")
String uri = request.getRequestURI().toString();
if (uri.contains("/login")) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}
String jwt = request.getHeader("token");
if (jwt == null || jwt.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
return;
}
try {
JwtUtils.parseToken(jwt);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
filterChain.doFilter(servletRequest, servletResponse);

六、统一拦截方案二:Interceptor 拦截器

1. 是什么

Interceptor 是 Spring 框架提供的,动态拦截 Controller 方法的执行。作用和 Filter 类似,但只拦 Spring 环境内的资源。

2. 入门两步

  1. 定义类实现 HandlerInterceptor,重写三个方法,加 @Component
  2. 写配置类 WebConfig implements WebMvcConfigurer,重写 addInterceptors 注册拦截器。必须加@Configuration

三个方法:

方法 调用时机 返回值含义
preHandle 目标方法执行前 true 放行;false 不放行(Controller 不执行)
postHandle 目标方法执行后
afterCompletion 视图渲染完毕后,最后执行

3. 注册配置(项目类:WebConfig,全限定类名 com.zhang.config.WebConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TokenInterceptor tokenInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenInterceptor)
.addPathPatterns("/**") // 拦截所有
// .excludePathPatterns("/login") // 也可用排除方式放过登录
;
}
}

4. 拦截路径配置

拦截路径 含义 能匹配 不能匹配
/* 一级路径 /depts、/login /depts/1
/** 任意级路径 /depts、/depts/1、/depts/1/2
/depts/* /depts 下一级 /depts/1 /depts/1/2、/depts
/depts/** /depts 下任意级 /depts、/depts/1、/depts/1/2 /emps/1

排除登录请求有两种写法:① 拦截器里 uri.contains("login") 判断放行(项目用法);② 配置里 excludePathPatterns("/login")

5. 登录校验 Interceptor(项目类:TokenInterceptor,全限定类名 com.zhang.interceptor.TokenInterceptor

逻辑与 Filter 完全一致,只是返回 boolean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class TokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String uri = request.getRequestURI().toString();
if (uri.contains("/login")) {
return true; // 登录请求放行
}
String jwt = request.getHeader("token");
if (jwt == null || jwt.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
try {
JwtUtils.parseToken(jwt);
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
return true;
}
}

七、Filter vs Interceptor 对比(高频考点)

对比项 Filter 过滤器 Interceptor 拦截器
接口规范 实现 jakarta.servlet.Filter 实现 HandlerInterceptor
拦截范围 拦截所有资源(包括静态资源、JSP 等) 只拦 Spring 环境中的资源(Controller)
归属 JavaWeb 三大组件(Servlet 规范) Spring 框架提供
配置方式 @WebFilter + @ServletComponentScan WebMvcConfigurer.addInterceptors
核心方法 doFilter(放行 chain.doFilter preHandle 返回 boolean
执行时机 DispatcherServlet 之前 DispatcherServlet 之后、Controller 之前

执行顺序(重要):

1
2
浏览器 → Filter(放行前) → DispatcherServlet → Interceptor.preHandle → Controller
→ Interceptor.postHandle → Interceptor.afterCompletion → Filter(放行后) → 浏览器

本项目二选一用了 Interceptor,Filter 仅作示例保留(见下)。


八、项目现状速查(重点!)

复习时最容易把”示例代码”和”项目真正生效的校验链路”搞混,这里专门列清。

1. 实际生效的校验链路

1
请求 → TokenInterceptor.preHandle (校验 token) → Controller → Service → Mapper
  • 校验组件:TokenInterceptorcom.zhang.interceptor.TokenInterceptor,加了 @Component
  • 注册位置:WebConfigcom.zhang.config.WebConfigaddPathPatterns("/**") 注册拦截器)

2. Filter 是”保留示例”,未启用

TokenFiltercom.zhang.filter.TokenFilter)里 @WebFilter(urlPatterns = "/*") 被注释掉了,也没有 @Component,所以不会生效。讲义里也明确:”登录校验的过滤器和拦截器,我们只需要使用其中的一种就可以了。”

3. 项目与讲义的命名差异

讲义命名 项目命名 说明
JwtUtils.generateJwt JwtUtils.generateToken 同功能,方法名不同
JwtUtils.parseJWT JwtUtils.parseToken 同功能,方法名不同
empMapper.getUsernameAndPassword empMapper.getByUsernamePassword 同 SQL,方法名不同
LoginInfo 字段 token 一致

4. 项目涉及文件清单

全限定类名 / 类 本节做了什么
pojo com.zhang.pojo.LoginInfo 新增:登录响应封装(idusernamenametoken
utils com.zhang.utils.JwtUtils 新增:JWT 工具类(generateTokenparseToken
controller com.zhang.controller.LoginController 新增:POST /login 接口
service com.zhang.service.impl.EmpServiceImpl 新增 login(Emp):查库 → 生成 JWT → 组装 LoginInfo
mapper com.zhang.mapper.EmpMapper 新增 getByUsernamePassword(Emp):按用户名密码查询
interceptor com.zhang.interceptor.TokenInterceptor 新增:token 校验拦截器(当前生效
filter com.zhang.filter.TokenFilter 新增:token 校验过滤器(未启用,仅示例)
config com.zhang.config.WebConfig 新增:注册 TokenInterceptor,拦截 /**

九、易错点 & 复习提醒

  1. 生成 token 时取 id 的来源:项目 EmpServiceImpl.login 里写的是 map.put("id", emp.getId()),但 emp 是请求体对象,登录时前端不会传 id,所以这里的 id 实际是 null。更稳的写法是用查询结果 e.getId()(讲义示例就是这么写的)。不影响 token 是否生成,但 token 载荷里的 id 会丢。

  2. 登录请求必须放行:拦截器/过滤器第一件事就是判断 uri.contains("/login") 放行,否则永远登不进去拿不到 token。

  3. token 的传递字段是请求头 token,不是 Authorization,也不是 cookie。前后端要约定一致(本项目前端在 token 头里带)。

  4. 401 状态码:未登录、token 为空、token 解析失败,都返回 SC_UNAUTHORIZED(401)。前端拿到 401 会自动跳登录页。

  5. JWT 秘钥必须配套:生成和解析用同一个 signKey,换了就全部失效。

  6. JWT 不是加密:Payload 是 base64 编码,能直接解码出来,所以别往里放密码等敏感信息。签名只是防篡改,不是防查看。

  7. Filter 和 Interceptor 不要同时启用做同一件事:本项目 Filter 注释掉了,只用 Interceptor。两个都开会导致重复校验。

  8. @WebFilter 方式需要 @ServletComponentScan;如果用 @Component + FilterRegistrationBean 则不需要。本项目 Interceptor 走的是 Spring 的 WebMvcConfigurer 注册方式。

  9. 拦截路径 /* vs /**:Filter 用 /*(Servlet 规范);Interceptor 用 /**(Spring Ant 风格)。两者含义不同,别混。


十、一图回顾整条链路

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
┌───────── 登录 ─────────┐
│ POST /login │
│ ↓ │
│ LoginController │
│ ↓ │
│ EmpServiceImpl.login │
│ ├─ EmpMapper 查库 │
│ └─ JwtUtils 生成 token│
│ ↓ │
│ 返回 LoginInfo{token} │
│ ↓ │
│ 前端存 localStorage │
└────────────────────────┘

┌──────── 后续请求 ──────┐
│ 请求头带 token │
│ ↓ │
│ TokenInterceptor │
│ .preHandle │
│ ├─ /login → 放行 │
│ ├─ 无 token → 401 │
│ ├─ 解析失败 → 401 │
│ └─ 合法 → return true│
│ ↓ │
│ Controller → Service │
│ ↓ │
│ 返回结果 │
└────────────────────────┘

实战总结:班级管理、学员管理、学员信息统计

本文是对《11-后端Web实战(自己完成)》这个实战任务的总结,记录从需求分析、接口设计到代码实现的全过程。
项目地址:project/tlias-web-management(SpringBoot + MyBatis + PageHelper + Lombok)


一、任务概述

上一个实战任务已经完成了员工管理的增删改查和员工信息统计。本任务要求在此基础上,为培训机构系统补齐剩余模块:

  1. 班级管理:班级列表(条件分页)、添加班级、查询回显、修改班级、删除班级
  2. 学员管理:学员列表(条件分页)、添加学员、查询回显、修改学员、删除学员、违纪处理
  3. 学员信息统计:班级人数统计、学员学历统计
  4. 功能完善:删除部门时,若部门下存在员工则禁止删除

总共新增/修改了 16 个接口点,全部严格对照接口文档开发。


二、总体构思

1. 开发前的三个准备

步骤 内容 目的
① 读接口文档 讲义/接口文档.md 明确每个接口的路径、请求方式、参数格式、响应结构,这是前后端联调的契约
② 分析已有项目结构 查看 controller / service / mapper / pojo 各层 复用已有的代码风格、工具类和组件,不引入讲义外的语法
③ 分析数据库表 clazzstudent 表结构 字段名、类型、唯一约束(后续动态 SQL 和统计函数都要用到)

2. 严格遵守已有分层架构

1
2
3
4
5
6
7
Controller (接收请求/响应 Result)

Service 接口 + ServiceImpl 实现 (业务逻辑、事务)

Mapper 接口 + Mapper.xml (SQL)

MySQL (tlias 库)

复用的现成组件:Result(统一响应)、PageResult<T>(分页结果)、PageHelper(分页插件)、GlobalExceptionHandler(全局异常处理器)、@Slf4j 日志。

3. 按模块逐个开发,每个模块都是”老套路”

每个模块的开发顺序统一为:分页/列表查询 → 新增 → 根据ID查询 → 修改 → 删除,与之前员工管理的开发顺序完全一致,只是业务数据不同。


三、实体类与自定义异常

1. Clazz(班级)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Clazz {
private Integer id;
private String name; // 班级名称
private String room; // 班级教室
private LocalDate beginDate;// 开课时间
private LocalDate endDate; // 结课时间
private Integer masterId; // 班主任ID(员工ID)
private Integer subject; // 学科 1:java 2:前端 3:大数据...
private LocalDateTime createTime;
private LocalDateTime updateTime;

private String masterName; // 班主任姓名(联查员工表)
private String status; // 班级状态(未开班/已开班/已结课) - 计算字段
}

2. Student(学员)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Student {
private Integer id;
private String name;
private String no; // 学号
private Integer gender; // 1:男 2:女
private String phone;
private String idCard;
private Integer isCollege; // 是否院校学生 1:是 0:否
private String address;
private Integer degree; // 学历 1:初中 2:高中 3:大专 4:本科...
private LocalDate graduationDate;
private Integer clazzId;
private Short violationCount; // 违纪次数(数据库 tinyint)
private Short violationScore; // 违纪扣分
private LocalDateTime createTime;
private LocalDateTime updateTime;

private String clazzName; // 班级名称(联查班级表)
}

说明:masterNamestatusclazzName 这三个字段在数据库表中不存在,是联查或计算出来的扩展字段,用于响应前端。

3. StudentCountData(班级人数统计结果)

仿照已有的 EmpJobData(职位统计用 jobList + dataList),班级人数统计也封装成一个对象:

1
2
3
4
5
6
7
@Data
@AllArgsConstructor
@NoArgsConstructor
public class StudentCountData {
private List<String> clazzList; // 班级列表(饼图/柱状图 X 轴)
private List<Long> dataList; // 每个班级的人数(Y 轴)
}

4. BusinessException(自定义业务异常)

1
2
3
4
5
6
7
8
package com.zhang.exception;

// 自定义业务异常
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}

四、班级管理模块(/clazzs)

1. 条件分页查询 GET /clazzs

接口要点

  • 参数:name(模糊)、begin/end(匹配结课时间 end_date)、pagepageSize
  • 响应每行包含 masterName(班主任姓名)和 status(班级状态)

实现思路status 不是一个数据库字段,需要根据当前时间计算:

1
2
3
当前时间 > 结课时间  → 已结课
当前时间 < 开课时间 → 未开班
否则 → 已开班

Mapper.xml(联查员工表拿班主任姓名 + 动态条件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<select id="list" resultType="com.zhang.pojo.Clazz">
select c.id, c.name, c.room, c.begin_date, c.end_date, c.master_id,
c.subject, c.create_time, c.update_time, e.name masterName
from clazz c
left join emp e on c.master_id = e.id
<where>
<if test="name != null and name != ''">
c.name like concat('%', #{name}, '%')
</if>
<if test="begin != null and end != null">
and c.end_date between #{begin} and #{end}
</if>
</where>
order by c.update_time desc
</select>

Service(分页 + 在 Java 里算状态):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public PageResult page(Integer page, Integer pageSize, String name, LocalDate begin, LocalDate end) {
PageHelper.startPage(page, pageSize);
Page<Clazz> p = (Page<Clazz>) clazzMapper.list(name, begin, end);

// 根据开课时间、结课时间计算班级状态
LocalDate now = LocalDate.now();
p.getResult().forEach(clazz -> {
if (now.isAfter(clazz.getEndDate())) {
clazz.setStatus("已结课");
} else if (now.isBefore(clazz.getBeginDate())) {
clazz.setStatus("未开班");
} else {
clazz.setStatus("已开班");
}
});
return new PageResult(p.getTotal(), p.getResult());
}

为什么不直接在 SQL 里用 CASE WHEN 算状态?两种都可以,选 Java 计算是因为:状态计算逻辑简单清晰、可读性好,且分页结果已经拿到内存里,遍历赋值即可,无需改动 SQL。

2. 添加班级 POST /clazzs

Controller 用 @RequestBody Clazz 接收 JSON,Service 里补 createTimeupdateTime 后插入:

1
2
3
4
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into clazz(name, room, begin_date, end_date, master_id, subject, create_time, update_time)
values (#{name}, #{room}, #{beginDate}, #{endDate}, #{masterId}, #{subject}, #{createTime}, #{updateTime})
</insert>

3. 根据ID查询 GET /clazzs/{id}、修改 PUT /clazzs

  • 查询:select ... from clazz where id = #{id},用 resultType 直接映射(开启了 map-underscore-to-camel-case,下划线字段自动转驼峰)。
  • 修改:动态 SQL <set> + <if>,只更新非空字段,update_time 在 Service 里刷新。

4. 删除班级 DELETE /clazzs/{id} —— 删除校验

需求:班级下关联了学员则不允许删除,提示 “对不起, 该班级下有学生, 不能直接删除”。

实现思路(讲义提示:自定义异常 + 全局异常处理器):

1
2
3
4
5
6
7
8
9
@Override
public void deleteById(Integer id) {
// 判断该班级下是否关联的有学员, 如果有关联, 则不允许删除
Integer count = studentMapper.countByClazzId(id);
if (count != null && count > 0) {
throw new BusinessException("对不起, 该班级下有学生, 不能直接删除");
}
clazzMapper.deleteById(id);
}

StudentMapper 里新增统计方法:

1
2
3
<select id="countByClazzId" resultType="java.lang.Integer">
select count(*) from student where clazz_id = #{clazzId}
</select>

5. 查询所有班级 GET /clazzs/list

给”新增学员”页面下拉框用,直接 select ... from clazz,无分页、无联查,返回 List<Clazz>


五、学员管理模块(/students)

1. 条件分页查询 GET /students

  • 参数:name(模糊)、degree(学历)、clazzId(班级)、pagepageSize
  • 响应每行包含 clazzName(班级名称,联查班级表)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<select id="list" resultType="com.zhang.pojo.Student">
select s.id, s.name, s.no, s.gender, s.phone, s.id_card, s.is_college,
s.address, s.degree, s.graduation_date, s.clazz_id,
s.violation_count, s.violation_score, s.create_time, s.update_time,
c.name clazzName
from student s
left join clazz c on s.clazz_id = c.id
<where>
<if test="name != null and name != ''">
s.name like concat('%', #{name}, '%')
</if>
<if test="degree != null">
and s.degree = #{degree}
</if>
<if test="clazzId != null">
and s.clazz_id = #{clazzId}
</if>
</where>
order by s.update_time desc
</select>

2. 新增 / 根据ID查询 / 修改

  • 新增:violation_countviolation_score 数据库有默认值 0,不需要插入
  • 修改:动态 <set> 更新非空字段,clazzIddegree 等数字字段用 <if test="xxx != null"> 判断。
  • 注意:nophoneid_card 在表上有唯一约束,重复插入/修改会抛 DuplicateKeyException,已被已有的全局异常处理器捕获并返回 “xxx已存在”。

3. 批量删除 DELETE /students/{ids} —— 路径参数接收数组

与员工删除(查询参数)的区别:学员删除的 id 是放在路径里的,格式 /students/1,2,3

1
2
3
4
5
6
@DeleteMapping("/{ids}")
public Result deleteByIds(@PathVariable Integer[] ids) {
log.info("批量删除学员: ids:{}", Arrays.asList(ids));
studentService.deleteByIds(ids);
return Result.success();
}

知识点:Spring 底层会把逗号分隔的路径参数 "1,2,3" 通过转换服务自动拆分成 Integer[],和查询参数 ids=1,2,3 接收成数组是同一个机制。

Mapper 里用 <foreach>id in (...)

1
2
3
4
5
6
7
<delete id="deleteByIds">
delete from student where id in(
<foreach collection="ids" item="id" separator=",">
#{id}
</foreach>
)
</delete>

4. 违纪处理 PUT /students/violation/{id}/{score}

需求:违纪处理一次,违纪次数 +1,违纪扣分 + 前端传入的分数。

直接在 SQL 里基于原值自增,一条 update 搞定:

1
void violation(@Param("id") Integer id, @Param("score") Integer score);
1
2
3
4
5
6
7
<update id="violation">
update student
set violation_count = violation_count + 1,
violation_score = violation_score + #{score},
update_time = now()
where id = #{id}
</update>

注意:因为方法有多个参数(id、score),Mapper 接口上必须用 @Param 指定参数名,否则 MyBatis 无法识别 #{id}#{score}


六、学员信息统计(/report)

1. 班级人数统计 GET /report/studentCountData

需求:统计每个班级的人数,返回 { clazzList: [班级名...], dataList: [人数...] }(格式同员工职位统计)。

Mapper(联查班级表取班级名,按班级分组):

1
2
3
4
5
6
<select id="countStudentClazz" resultType="java.util.Map">
select c.name name, count(*) value
from student s
left join clazz c on s.clazz_id = c.id
group by s.clazz_id, c.name
</select>

Service(用 Stream 把 List<Map> 拆成两个 List 封装进 StudentCountData):

1
2
3
4
5
6
7
@Override
public StudentCountData getStudentCountData() {
List<Map<String, Object>> list = studentMapper.countStudentClazz();
List<String> clazzList = list.stream().map(itemMap -> itemMap.get("name").toString()).toList();
List<Long> dataList = list.stream().map(itemMap -> (Long) itemMap.get("value")).toList();
return new StudentCountData(clazzList, dataList);
}

注意强转:count(*) 在 MyBatis 里返回的是 Long,所以取出来要 (Long) 强转;名字取出来是 Object,用 .toString()

2. 学员学历统计 GET /report/studentDegreeData

需求:按学历分组统计人数,返回 [{name: '初中', value: 5}, ...](格式同员工性别统计)。

CASE WHEN 把 degree 数字映射成中文学历(这是之前员工职位统计用过的语法):

1
2
3
4
5
6
7
8
9
10
11
12
<select id="countStudentDegree" resultType="java.util.Map">
select case degree when 1 then '初中'
when 2 then '高中'
when 3 then '大专'
when 4 then '本科'
when 5 then '硕士'
when 6 then '博士'
else '其他' end name,
count(*) value
from student
group by degree
</select>

七、功能完善:删除部门校验

需求:删除部门时,如果部门下有员工,则不允许删除,提示 “对不起,当前部门下有员工,不能直接删除!”

在已有的 DeptServiceImpl.deleteById 中加一层判断(需要注入 EmpMapper):

1
2
3
4
5
6
7
8
9
@Override
public void deleteById(Integer id) {
// 判断该部门下是否关联的有员工, 如果有关联, 则不允许删除
Integer count = empMapper.countByDeptId(id);
if (count != null && count > 0) {
throw new BusinessException("对不起,当前部门下有员工,不能直接删除!");
}
deptMapper.deleteById(id);
}

EmpMapper.xml 新增:

1
2
3
<select id="countByDeptId" resultType="java.lang.Integer">
select count(*) from emp where dept_id = #{deptId}
</select>

八、关键技术点总结

1. 自定义异常 + 全局异常处理器的完整链路

1
2
3
4
Service 抛 BusinessException("对不起, 该班级下有学生, 不能直接删除")
→ 被 @RestControllerAdvice 标记的 GlobalExceptionHandler 捕获
→ @ExceptionHandler 方法返回 Result.error(异常信息)
→ 前端拿到统一格式的 Result,正常解析并弹窗提示

在已有的 GlobalExceptionHandler 中新增一个处理方法(Spring 会自动选择最匹配的异常处理器):

1
2
3
4
5
6
//处理自定义业务异常
@ExceptionHandler
public Result handleBusinessException(BusinessException e) {
log.error("业务异常:{}", e.getMessage());
return Result.error(e.getMessage());
}

为什么比 try…catch 好?业务代码里不用到处写 try…catch,异常统一收敛到一处,代码干净优雅。

2. 分页:PageHelper 插件

1
2
3
PageHelper.startPage(page, pageSize);        // ① 开启分页(下一行查询自动拼接 limit)
Page<Clazz> p = (Page<Clazz>) clazzMapper.list(...); // ② 查询结果强转为 Page
return new PageResult(p.getTotal(), p.getResult()); // ③ 取出总数和当前页数据

3. resultType vs resultMap

  • 字段能直接对应(下划线自动转驼峰)→ 用 resultType(本任务大部分查询都够用)。
  • 字段对应不上 / 需要手动嵌套封装(如员工带工作经历)→ 用 resultMap
  • 本任务班级列表、学员列表都是简单联查取别名,用 resultType + 列别名即可,不需要 resultMap。

4. 动态 SQL 的四个标签

标签 用途 本任务应用
<where> 自动处理首个条件的 and 班级/学员分页查询
<set> 动态更新,自动去掉末尾逗号 修改班级/学员
<if> 条件判断(字符串判空、数字判 null) 所有动态 SQL
<foreach> 拼接 in 集合 批量删除学员

5. 统计函数的两种写法

函数 语法 用途
CASE WHEN case expr when val1 then res1 else res end 学历数字 → 中文映射
IF() if(条件, 真值, 假值) 员工性别 1/2 → 男/女

6. 两个细节坑

  • 多参数 Mapper 方法必须加 @Param:如违纪处理 violation(@Param("id") ..., @Param("score") ...),否则报 Parameter 'id' not found
  • count(*) 返回类型是 Long:从 Map 中取值做算术或封装时要注意强转。

九、接口清单总览

# 模块 路径 方式 说明
1 班级管理 /clazzs GET 班级列表(条件分页,含 masterName/status)
2 班级管理 /clazzs POST 添加班级
3 班级管理 /clazzs/{id} GET 根据ID查询班级
4 班级管理 /clazzs PUT 修改班级
5 班级管理 /clazzs/{id} DELETE 删除班级(班级下有学生禁止删)
6 班级管理 /clazzs/list GET 查询所有班级(学员下拉框)
7 员工管理 /emps/list GET 查询全部员工(班主任下拉框)
8 学员管理 /students GET 学员列表(条件分页,含 clazzName)
9 学员管理 /students POST 添加学员
10 学员管理 /students/{id} GET 根据ID查询学员
11 学员管理 /students PUT 修改学员
12 学员管理 /students/{ids} DELETE 批量删除学员(路径逗号数组)
13 学员管理 /students/violation/{id}/{score} PUT 违纪处理
14 数据统计 /report/studentCountData GET 班级人数统计
15 数据统计 /report/studentDegreeData GET 学员学历统计
16 部门管理 /depts DELETE 删除部门(部门下有员工禁止删)

实战总结:AOP 面向切面编程

本文是对《13-后端Web进阶(AOP)》的总结,记录 AOP 的核心概念、通知类型、切入点表达式以及在项目中的实战应用(操作日志记录)。
项目地址:project/tlias-web-management(SpringBoot + AOP)
对应讲义:讲义/13-后端Web进阶(AOP).md


一、AOP 核心概念

1. 什么是 AOP

AOP (Aspect Oriented Programming) 即面向切面编程,是一种通过分离横切关注点(如日志、安全、事务等)来提高代码模块化程度的编程范式。

核心优势

  • 代码无侵入:在不修改原有业务代码的前提下,对功能进行增强。
  • 减少重复代码:将通用逻辑(如日志记录、权限校验)抽取出来,避免在每个业务方法中重复编写。
  • 提高开发效率与维护性:通用逻辑集中管理,修改时只需改一处。

2. 核心术语

术语 英文 描述 举例
连接点 JoinPoint 可以被 AOP 控制的方法 业务层的 list(), delete() 方法
通知 Advice 共性功能(重复逻辑) 记录方法执行耗时的代码
切入点 Pointcut 匹配连接点的条件,决定在哪些方法上应用通知 execution(* com.zhang.service.*.*(..))
切面 Aspect 通知 + 切入点的组合 日志切面 = 记录耗时逻辑 + 匹配业务方法的规则
目标对象 Target 被增强的原始对象 DeptServiceImpl 实例

关系总结:在切入点指定的连接点上,执行通知定义的逻辑,这个组合形成了切面,作用于目标对象


二、通知类型详解

1. 五种通知类型

类型 注解 执行时机 说明
前置通知 @Before 目标方法执行 常用于参数校验、权限检查
后置通知 @After 目标方法执行(无论是否异常) 常用于释放资源、清理操作
返回后通知 @AfterReturning 目标方法执行(无异常时) 常用于获取返回值、成功日志
异常通知 @AfterThrowing 目标方法执行(有异常时) 常用于异常处理、失败日志
环绕通知 @Around 目标方法执行前和后 功能最强大,可控制方法是否执行,获取返回值

2. 执行顺序表

正常执行(无异常)

1
2
3
4
5
6
1. @Around (前置部分)
2. @Before
3. 目标方法执行
4. @Around (后置部分)
5. @AfterReturning
6. @After

异常执行(有异常)

1
2
3
4
5
6
7
1. @Around (前置部分)
2. @Before
3. 目标方法执行 (抛出异常)
4. @Around (后置部分 - **不执行**)
5. @AfterReturning (**不执行**)
6. @AfterThrowing (执行)
7. @After (执行)

注意@Around 环绕通知中,若在 pjp.proceed() 前后的代码,当目标方法抛出异常时,proceed() 后的代码不会执行(除非捕获异常)。

3. 代码示例

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
@Aspect
@Component
@Slf4j
public class MyAspect {

// 1. 前置通知
@Before("execution(* com.zhang.service.*.*(..))")
public void before(JoinPoint joinPoint) {
log.info("Before: 方法执行前...");
}

// 2. 后置通知
@After("execution(* com.zhang.service.*.*(..))")
public void after(JoinPoint joinPoint) {
log.info("After: 方法执行后...");
}

// 3. 返回后通知
@AfterReturning("execution(* com.zhang.service.*.*(..))")
public void afterReturning(JoinPoint joinPoint) {
log.info("AfterReturning: 方法返回后...");
}

// 4. 异常通知
@AfterThrowing("execution(* com.zhang.service.*.*(..))")
public void afterThrowing(JoinPoint joinPoint) {
log.info("AfterThrowing: 方法异常时...");
}

// 5. 环绕通知 (最常用)
@Around("execution(* com.zhang.service.*.*(..))")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
log.info("Around Before: 进入方法...");

Object result = null;
try {
result = pjp.proceed(); // 执行目标方法
} catch (Throwable e) {
log.info("Around Exception: 方法异常...");
throw e;
}

log.info("Around After: 方法结束...");
return result;
}
}

4. 通知优先级 (@Order)

当多个切面或多个通知匹配同一个方法时,执行顺序由 @Order 注解控制:

  • 值越小,优先级越高
  • 前置通知:优先级高的先执行
  • 后置通知:优先级高的后执行(先进后出,类似栈结构)
1
2
3
4
5
6
7
8
9
@Aspect
@Component
@Order(1) // 优先级最高
public class MyAspect1 { ... }

@Aspect
@Component
@Order(2) // 优先级次之
public class MyAspect2 { ... }

三、切入点表达式

1. 两种匹配方式

方式 语法 适用场景
execution execution(modifiers-pattern? ret-type-pattern declaring-type-pattern? name-pattern(param-pattern) throws-pattern?) 基于方法签名(类名、方法名、参数)匹配
@annotation @annotation(annotation-type) 基于自定义注解匹配

2. execution 表达式详解

语法结构

1
execution(访问修饰符? 返回值 包名.类名.?方法名(参数) throws 异常?)

通配符

  • *:匹配任意单个字符
  • ..:匹配任意数量的字符或包
  • ?:表示该项可选

示例

表达式 含义
execution(* com.zhang.service.*.*(..)) 匹配 com.zhang.service 包下所有类的所有方法
execution(* com.zhang.service.impl.DeptServiceImpl.*(..)) 匹配 DeptServiceImpl 类的所有方法
execution(* com.zhang.service.*.*(String, Integer)) 匹配参数为 String 和 Integer 的方法
execution(void com.zhang.service.*.delete(*)) 匹配返回值为 void,方法名为 delete,一个参数的方法

3. @annotation 注解匹配

适用场景:当需要增强的方法名无规律时,通过自定义注解来标记哪些方法需要被拦截。

实现步骤

  1. 创建自定义注解
  2. 在需要增强的方法上添加该注解
  3. 切面类中使用 @annotation 匹配

代码示例

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
// 1. 创建自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation {
}

// 2. 在业务方法上添加注解
@RestController
public class DeptController {

@LogOperation // 标记此方法需要记录日志
@PostMapping
public Result save(@RequestBody Dept dept) {
deptService.save(dept);
return Result.success();
}
}

// 3. 切面类使用 @annotation 匹配
@Aspect
@Component
public class OperationLogAspect {

// 使用 @annotation 匹配带有 @LogOperation 注解的方法
@Around("@annotation(com.zhang.anno.LogOperation)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
// ... 记录日志逻辑
}
}

四、项目实战:操作日志记录

1. 需求分析

需求:记录 Tlias 系统中所有增、删、改操作的日志,便于后期数据追踪。

日志内容

  • 操作人 ID
  • 操作时间
  • 操作类名
  • 操作方法名
  • 方法参数
  • 返回值
  • 执行耗时

技术选型

  • 通知类型@Around 环绕通知(需要获取方法执行前后的信息)
  • 切入点@annotation 注解匹配(增删改方法名无统一前缀)

2. 项目实现代码

① 自定义注解 @LogOperation

1
2
3
4
5
// 文件:com.zhang.anno.LogOperation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation {
}

② 日志实体类 OperateLog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 文件:com.zhang.pojo.OperateLog
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OperateLog {
private Integer id; // 主键
private Integer operateEmpId; // 操作人ID
private LocalDateTime operateTime; // 操作时间
private String className; // 操作类名
private String methodName; // 操作方法名
private String methodParams; // 方法参数
private String returnValue; // 返回值
private Long costTime; // 执行耗时(毫秒)
}

③ Mapper 接口 OperateLogMapper

1
2
3
4
5
6
7
8
// 文件:com.zhang.mapper.OperateLogMapper
@Mapper
public interface OperateLogMapper {

@Insert("INSERT INTO operate_log(operate_emp_id, operate_time, class_name, method_name, method_params, return_value, cost_time) " +
"VALUES(#{operateEmpId}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime})")
void insert(OperateLog log);
}

④ 切面类 OperationLogAspect(核心)

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
// 文件:com.zhang.aop.OperationLogAspect
@Aspect
@Component
@Slf4j
public class OperationLogAspect {

@Autowired
private OperateLogMapper operateLogMapper;

@Around("@annotation(com.zhang.anno.LogOperation)")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long startTime = System.currentTimeMillis();

Object result = null;
try {
result = pjp.proceed(); // 执行目标方法
} catch (Throwable e) {
// 异常时也要记录日志,继续抛出异常
throw e;
} finally {
long endTime = System.currentTimeMillis();
long costTime = endTime - startTime;

// 构建日志对象
OperateLog log = new OperateLog();
log.setOperateEmpId(CurrentHolder.getCurrentId()); // 从 ThreadLocal 获取当前用户ID
log.setOperateTime(LocalDateTime.now());
log.setClassName(pjp.getTarget().getClass().getName());
log.setMethodName(pjp.getSignature().getName());
log.setMethodParams(Arrays.toString(pjp.getArgs()));
log.setReturnValue(result != null ? result.toString() : null); // ⚠ 注意空指针判断
log.setCostTime(costTime);

// 保存日志到数据库
operateLogMapper.insert(log);
}

return result;
}
}

⑤ 获取当前用户 ID(CurrentHolder

由于 AOP 切面无法直接获取 HttpServletRequest 中的登录信息,需要通过 ThreadLocal 在拦截器和切面之间传递数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 文件:com.zhang.utils.CurrentHolder
public class CurrentHolder {
private static final ThreadLocal<Integer> CURRENT_LOCAL = new ThreadLocal<>();

public static Integer getCurrentId() {
return CURRENT_LOCAL.get();
}

public static void setCurrentId(Integer id) {
CURRENT_LOCAL.set(id);
}

public static void remove() {
CURRENT_LOCAL.remove();
}
}

在登录拦截器中设置用户 ID

1
2
3
4
5
6
7
8
// TokenInterceptor.preHandle() 方法中
Integer userId = /* 从 token 解析出的用户ID */;
CurrentHolder.setCurrentId(userId);
try {
// 继续执行...
} finally {
CurrentHolder.remove(); // 必须清理,防止内存泄漏
}

⑥ 在 Controller 中添加注解

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
// 文件:com.zhang.controller.DeptController
@RestController
@RequestMapping("/depts")
public class DeptController {

@Autowired
private DeptService deptService;

@LogOperation // 新增部门
@PostMapping
public Result save(@RequestBody Dept dept) {
deptService.save(dept);
return Result.success();
}

@LogOperation // 修改部门
@PutMapping
public Result update(@RequestBody Dept dept) {
deptService.update(dept);
return Result.success();
}

@LogOperation // 删除部门
@DeleteMapping("/{id}")
public Result delete(@PathVariable Integer id) {
deptService.deleteById(id);
return Result.success();
}
}

五、项目文件清单

全限定类名 作用
anno com.zhang.anno.LogOperation 自定义注解,标记需要记录日志的方法
pojo com.zhang.pojo.OperateLog 日志实体类
mapper com.zhang.mapper.OperateLogMapper 日志持久化接口
aop com.zhang.aop.OperationLogAspect AOP 切面类,核心逻辑
utils com.zhang.utils.CurrentHolder ThreadLocal 工具类,传递用户 ID

六、易错点 & 复习提醒

1. 环绕通知的 proceed() 调用

  • 必须调用 pjp.proceed() 才能执行原始方法,否则原始方法不会执行。
  • 必须返回 proceed() 的返回值,否则原始方法的返回值会丢失。
  • 当原始方法抛出异常时,proceed() 后的代码不会执行(除非用 try-catch 包裹)。

2. 返回值空指针风险

1
2
3
4
5
// 错误写法
log.setReturnValue(result.toString()); // 当 result 为 null 时,抛出 NullPointerException

// 正确写法
log.setReturnValue(result != null ? result.toString() : null);

对于 void 返回值的方法,result 可能为 null,需要做空判断。

3. ThreadLocal 内存泄漏

CurrentHolder 使用 ThreadLocal 时,必须在使用完毕后调用 remove() 方法清理,否则会造成内存泄漏。

4. 切入点表达式书写规范

  • 优先使用接口execution(* com.zhang.service.DeptService.*(..)) 比使用实现类更好,提高可扩展性。
  • 缩小匹配范围:避免使用 .. 匹配所有包,应尽可能精确指定包路径。
  • 注释清晰:在切面类中注释该切面的功能,方便后期维护。

5. AOP 与事务的关系

Spring 的 @Transactional 注解底层也是基于 AOP 实现的。在使用自定义 AOP 时,需要注意事务注解的顺序,确保数据一致性。

6. 切面类必须是 Spring Bean

切面类必须添加 @Component 或其他注解,使其成为 Spring 管理的 Bean,否则切面不会生效。


七、一图回顾 AOP 流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌──────────────── AOP 执行流程 ────────────────┐
│ │
│ 请求到达 Controller 方法 │
│ ↓ │
│ AOP 切面拦截(匹配 @LogOperation 注解) │
│ ↓ │
│ @Around 环绕通知开始 │
│ ├── 记录开始时间 │
│ ├── 获取当前用户 ID(从 ThreadLocal) │
│ ├── 执行 pjp.proceed() → 原始业务方法 │
│ ├── 获取方法参数、返回值 │
│ ├── 计算耗时 │
│ ├── 构建 OperateLog 对象 │
│ └── 调用 OperateLogMapper 保存日志 │
│ ↓ │
│ 返回原始方法结果给前端 │
│ │
└────────────────────────────────────────────────┘