准备工作

接口文档:接口文档 - 飞书云文档

开发模式:前后端分离开发(前端项目、后端项目 开发和部署都是分开的)

开发流程:需求分析 ➡ 接口设计 ➡ 前后端并行开发 ➡ 测试 ➡ 测试 ➡ 联调

接口测试工具:Apifox(浏览器发起的请求默认都是GET请求,如果需要测试POST、PUT、DELETE请求,需要用到专门的测试工具)

开发风格:Restful架构风格

REST风格URL 请求方式 含义 备注
http://localhost:8080/users/1 GET 查询id为1的用户 URL定位资源 HTTP动词描述操作 简洁、规范、优雅
http://localhost:8080/users/1 DELETE 删除id为1的用户
http://localhost:8080/users POST 新增用户
http://localhost:8080/users PUT 修改用户
  1. REST是风格,是约定方式,约定不是规定,可以打破
  2. 描述功能模块通常使用复数形式(加s),表示此类资源,而非单个资源。如:users、books

工程搭建

  1. 创建SpringBoot工程,引入起步依赖、Mybatis、Mysql驱动、Lombok
  2. 创建数据库表dept,并在application.yaml中配置基本信息
  3. 准备基础代码结构,并引入实体类Dept以及统一的响应结果封装类

部门管理

部门列表查询

Mapper

1
2
@Select("SELECT * FROM dept ORDER BY update_time desc")
List<Dept> findAll();

如果列名和实体类的属性名不匹配,比如create_time为列名,createTime为属性名,那就无法将获取的数据封装到实体类中

解决方式有三种:

  1. 手动结果映射:
1
2
3
4
5
6
@Results({
@Result(column = "create_time", property = "createTime"),
@Result(column = "update_time", property = "updateTIme")
})
@Select("SELECT * FROM dept ORDER BY update_time desc")
List<Dept> findAll();
  1. 为列名起别名:
1
2
@Select("SELECT id, name, create_time creatTime, update_time updateTime FROM dept ORDER BY update_time desc")
List<Dept> findAll();
  1. 如果是create_time这种蛇形命名法,可以通过修改配置自动转换为驼峰形式:
1
2
3
mybatis:
configuration:
map-underscore-to-camel-case: true

Service

1
2
3
4
@Override
public List<Dept> findAll() {
return deptMapper.findAll();
}

Controller

1
2
3
4
@GetMapping("/depts")
public Result findAll() {
return Result.success(deptService.findAll());
}

删除部门

Mapper

1
2
@Delete("DELETE FROM dept WHERE id = #{id}")
void deleteById(Integer id);

Service

1
2
3
4
@Override
public void deleteById(Integer id) {
deptMapper.deleteById(id);
}

Controller

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

如果请求参数是存放在路径中,可以使用@RequestParam(“参数名”)注解修饰形参@RequestParam Integer deptId

如果请求参数名与形参变量相同,直接定义方法形参即可接收:@RequestParam Integer id

当声明@RequestParam注解后,该参数在请求时**必须传递**,如果不传递会报错

如果希望即使不传递也不报错,可以在注解中将required设置为false:@RequestParam(required = false)

添加部门

Mapper

1
2
@Insert("INSERT INTO dept VALUES (#{id},#{name},#{createTime},#{updateTime})")
void insertDept(Dept dept);

Service

1
2
3
4
5
6
7
8
@Override
public void insertDept(Dept dept) {
// 设置Dept对象时间属性
dept.setCreateTime(LocalDateTime.now());
dept.setUpdateTime(LocalDateTime.now());

deptMapper.insertDept(dept);
}

Controller

1
2
3
4
5
6
@PostMapping("/depts")
public Result insertDept(@RequestBody Dept dept) {
System.out.println(dept); // Dept(id=null, name=测试部, createTime=null, updateTime=null)
deptService.insertDept(dept);
return Result.success();
}

如果想要获取请求体中的请求参数,可以使用@RequestBody注解

并且可以用实体类对获取的请求参数进行封装,前提是请求参数中的参数名(键名)要和实体类中的属性名匹配

根据ID查询部门

通常来说,对于修改操作,实现的前提是先通过ID获取需要修改的对象(查询回显),然后再进行修改

Mapper

1
2
@Select("SELECT * FROM dept WHERE id = #{id}")
Dept findById(Integer id);

Service

1
2
3
4
@Override
public Dept findById(Integer id) {
return deptMapper.findById(id);
}

Controller

1
2
3
4
5
@GetMapping("/depts/{id}")
public Result findById(@PathVariable Integer id) {
Dept dept = deptService.findById(id);
return Result.success(dept);
}

如果参数是以路径/参数值的形式存放在请求路径中,获取请求参数的方式与路径?参数名=值不同

获取**路径参数**的方式是通过@PathVariable注解,并且路径中用{名称}给参数起名称

@PathVariable(“参数名”)表达的意思和@RequestParam是一样的

如果URL中携带了多个路径,比如:depts/1/0,是可以正常接受的,接受方式如下:

1
2
3
4
5
@GetMapping("/depts/{id}/{status}")
public Result findById(@PathVariable("id") Integer id, @PathVariable("status") Integer status) {
Dept dept = deptService.findById(id);
return Result.success(dept);
}

修改部门

完成了根据ID查询部门的接口,就可以正式开发修改部门接口了

Mapper

1
2
@Update("UPDATE dept SET name = #{name} , update_time = #{updateTime} WHERE id = #{id}")
void updateDept(Dept dept);

Service

1
2
3
4
5
@Override
public void updateDept(Dept dept) {
dept.setUpdateTime(LocalDateTime.now());
deptMapper.updateDept(dept);
}

Controller

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

总结

观察部门接口的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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.norlcyan.controller;

import com.norlcyan.pojo.Dept;
import com.norlcyan.pojo.Result;
import com.norlcyan.service.DeptService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
public class DeptController {
private final DeptService deptService;

@Autowired
public DeptController(DeptService deptService) {
this.deptService = deptService;
}

@GetMapping("/depts")
public Result findAll() {
return Result.success(deptService.findAll());
}

@DeleteMapping("/depts")
public Result deleteById(@RequestParam Integer id) {
deptService.deleteById(id);
return Result.success();
}

@PostMapping("/depts")
public Result insertDept(@RequestBody Dept dept) {
deptService.insertDept(dept);
return Result.success();
}


@GetMapping("/depts/{id}")
public Result findById(@PathVariable Integer id) {
Dept dept = deptService.findById(id);
return Result.success(dept);
}

@PutMapping("/depts")
public Result updateDept(@RequestBody Dept dept) {
System.out.println(dept);
deptService.updateDept(dept);
return Result.success();
}
}

由于所有请求路径都有相同的基础路径 /depts,可以在类上使用 @RequestMapping(“/depts”) 统一声明

这样其他方法上的映射注解就可以省略基础路径,使代码更简洁

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

import com.norlcyan.pojo.Dept;
import com.norlcyan.pojo.Result;
import com.norlcyan.service.DeptService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RequestMapping("/depts")
@RestController
public class DeptController {
private final DeptService deptService;

@Autowired
public DeptController(DeptService deptService) {
this.deptService = deptService;
}

@GetMapping
public Result findAll() {
return Result.success(deptService.findAll());
}

@DeleteMapping
public Result deleteById(@RequestParam Integer id) {
deptService.deleteById(id);
return Result.success();
}

@PostMapping
public Result insertDept(@RequestBody Dept dept) {
deptService.insertDept(dept);
return Result.success();
}


@GetMapping("/{id}")
public Result findById(@PathVariable Integer id) {
Dept dept = deptService.findById(id);
return Result.success(dept);
}

@PutMapping
public Result updateDept(@RequestBody Dept dept) {
System.out.println(dept);
deptService.updateDept(dept);
return Result.success();
}
}

像@GetMapping、@PostMapping、@DeleteMapping和@PutMapping都是@RequestMapping的衍生注解

所以,也就说明@RequestMapping注解可以添加在:

  • 类上(可选)
  • 方法上
  • 完整的请求路径为 = 类上 + 方法上的

员工管理

员工分页查询

名称 类型 是否必须 备注
code number 必须 响应码, 1 成功 , 0 失败
msg string 非必须 提示信息
data object 必须 返回的数据
|- total number 必须 总记录数
|- rows object [] 必须 数据列表
|- id number 非必须 id
|- username string 非必须 用户名
|- name string 非必须 姓名
|- password string 非必须 密码
|- gender number 非必须 性别 , 1 男 ; 2 女
|- image string 非必须 图像
|- job number 非必须 职位, 说明: 1 班主任,2 讲师, 3 学工主管, 4 教研主管, 5 咨询师
|- salary number 非必须 薪资
|- entryDate string 非必须 入职日期
|- deptId number 非必须 部门id
|- deptName string 非必须 部门名称
|- createTime string 非必须 创建时间
|- updateTime string 非必须 更新时间

分页查询,后端要返回总记录数(total)结果列表(rows)

PageResult实体类如下:

1
2
3
4
5
6
7
8
9
10
11
package com.norlcyan.pojo;

import lombok.Data;

import java.util.List;

@Data
public class PageResult<T>{
private Long total;
private List<T> rows;
}

Mapper

PageHelper实现:

1
2
@Select("SELECT emp.*, dept.name as dept_name FROM emp LEFT JOIN dept ON dept_id = dept.id ORDER BY update_time desc")
List<Emp> list();

原始实现:

1
2
3
4
5
6
7
// 获取结果列表
@Select("SELECT emp.*, dept.name as dept_name FROM emp LEFT JOIN dept ON dept_id = dept.id ORDER BY update_time desc LIMIT #{pageNum},#{pageSize}")
List<Emp> findEmps(Integer pageNum,Integer pageSize);

// 获取总记录数
@Select("SELECT COUNT(emp.id) FROM emp LEFT JOIN dept ON dept_id = dept.id")
Long getTotals();

Service

PageHelper实现(**关注它就行**):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public PageResult<Emp> page(Integer pageNum, Integer pageSize) {
// 1. 设置分页参数
PageHelper.startPage(pageNum, pageSize);

// 2. 执行查询
// 由于设置了PageHelper分页参数,底层会进行分页处理
// PageHelper通过MyBatis拦截器机制,在执行SQL时自动添加LIMIT子句
List<Emp> list = empMapper.list();

// 3. 解析查询结果并封装
// Page底层继承了ArrayList,所以它属于集合
// Page → ArrayList → List
// 该对象还会存储分页相关元数据,包括总记录数
Page<Emp> p = (Page<Emp>) list;
return new PageResult<Emp>(p.getTotal(), p.getResult());
}

原始实现:

1
2
3
4
5
6
7
8
9
@Override
public List<Emp> findEmps(Integer pageNum, Integer pageSize) {
return empMapper.findEmps((pageSize * (pageNum - 1)),pageSize);
}

@Override
public Long getTotals() {
return empMapper.getTotals();
}

Controller

PageHelper实现:

1
2
3
4
5
@GetMapping
public Result findEmps(@RequestParam(defaultValue = "1",name = "page") Integer pageNum,@RequestParam(defaultValue = "10") Integer pageSize) {
PageResult<Emp> pageResult = empService.page(pageNum,pageSize);
return Result.success(pageResult);
}

原始实现:

1
2
3
4
5
6
7
@GetMapping
public Result findEmps(@RequestParam(defaultValue = "1") Integer pageNum,@RequestParam(defaultValue = "10") Integer pageSize) {
PageResult<Emp> pageResult = new PageResult<>();
pageResult.setTotal(empService.getTotals());
pageResult.setRows(empService.findEmps(pageNum,pageSize));
return Result.success(pageResult);
}

@RequestParam(defaultValue = "1")注解的参数表示的是默认值,如果请求中没有相应的请求参数,就会用默认值替代

员工分页条件查询

Mapper

由于SQL语句相对复杂,所以使用XML文件映射(EmpMapper.xml):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 动态查询员工列表 -->
<select id="list" parameterType="com.norlcyan.pojo.EmpQueryParam" resultType="com.norlcyan.pojo.Emp">
SELECT
e.*, dept.name dept_name
FROM emp e LEFT JOIN dept ON dept_id = dept.id
<where>
<if test="name != null and name != ''">
AND 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 update_time DESC
</select>

EmpMapper:

1
List<Emp> list(EmpQueryParam empQueryParam);

Service

EmpService:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public PageResult<Emp> page(EmpQueryParam empQueryParam) {
// 1. 设置分页参数
PageHelper.startPage(empQueryParam.getPage(), empQueryParam.getPageSize());
// 2. 执行查询
// 由于设置了PageHelper分页参数,底层会进行分页处理
// PageHelper通过MyBatis拦截器机制,在执行SQL时自动添加LIMIT子句
List<Emp> list = empMapper.list(empQueryParam);
// 3. 解析查询结果并封装
// Page底层继承了ArrayList,所以它属于集合
// Page → ArrayList → List
// 该对象还会存储分页相关元数据,包括总记录数
Page<Emp> p = (Page<Emp>) list;
return new PageResult<Emp>(p.getTotal(), p.getResult());
}

Controller

EmpController:

1
2
3
4
5
@GetMapping
public Result findEmps(EmpQueryParam empQueryParam) {
PageResult<Emp> pageResult = empService.page(empQueryParam);
return Result.success(pageResult);
}

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

新增员工

Mapper

EmpMapper:

1
2
3
4
@Options(useGeneratedKeys = true, keyProperty = "id")   // 主键返回,相当于给Emp对象中的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 saveBasicInfo(Emp emp);

新增员工时,id是通过数据库自增的,如果想要获取到自增后的id,可以使用MyBatis下的@Options注解

@Options(useGeneratedKeys = true, keyProperty = "id"),第一个参数表示是否使用自动生成的主键,第二个参数表示主键值赋给哪个属性

EmpExprMapper:

1
void saveExprInfo(List<EmpExpr> exprList);

EmpExprMapper.xml:

1
2
3
4
5
6
7
<!--  保存员工经历  -->
<insert id="saveExprInfo">
INSERT INTO emp_expr(emp_id,begin,end,company,job) VALUES
<foreach item="item" index="index" collection="exprList" separator=",">
(#{item.empId},#{item.begin},#{item.end},#{item.company},#{item.job})
</foreach>
</insert>

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public void save(Emp emp) {
// 保存员工基本信息
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
empMapper.saveBasicInfo(emp); // 当存入了员工的基本信息,数据库会自动生成ID,通过MyBatis的Options注解可以将生成的ID返回给传入的对象
// 保存员工经历
List<EmpExpr> exprList = emp.getExprList();
if (!CollectionUtils.isEmpty(exprList)) {
// 返回给emp对象后,需要对集合遍历,将emp中的id赋值给集合中的每个EmpExpr对象下的empId
exprList.forEach(item -> item.setEmpId(emp.getId()));
empExprMapper.saveExprInfo(exprList);
}
}

以上代码中一次操作了两张表,所以涉及到事务管理的问题

产生问题的原因:

  • 第一张表与第二张表是关联关系
  • 当操作第一张表时,没有发生异常情况,数据库正确写入数据
  • 但是当操作第二张表时,发生了异常情况,导致数据库没有正确写入数据
  • 所以,此时第一张表存在数据,第二张表由于异常没有存入数据,这就导致了数据的**不一致性**

Controller

1
2
3
4
5
@PostMapping
public Result saveEmps(@RequestBody Emp emp) {
empService.save(emp);
return Result.success();
}

员工头像图片保存

具体流程:

Snipaste_2025-08-12_22-33-01

创建OSS上传工具类:

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
package com.norlcyan.utils;

import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

@Component
public class AliyunOSSOperator {
@Value("${aliyun.oss.endpoint}")
private String endpoint;
@Value("${aliyun.oss.bucketName}")
private String bucketName = "norlcyan-javaweb-ai";
@Value("${aliyun.oss.region}")
private String region = "cn-shanghai";

public String upload(byte[] content, String originalFilename) throws Exception {
// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();

// 填写Object完整路径,例如202406/1.png。Object完整路径中不能包含Bucket名称。
//获取当前系统日期的字符串,格式为 yyyy/MM
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
//生成一个新的不重复的文件名
String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = dir + "/" + newFileName;

// 创建OSSClient实例。
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.clientConfiguration(clientBuilderConfiguration)
.region(region)
.build();

try {
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
} finally {
ossClient.shutdown();
}

return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
}

}

代码由官方提供的Demo改造而来,返回值为上传文件的URL

想要使工具类可以正常使用,还需要在本地配置阿里云的Access Key

Controller

UploadController:

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
@Slf4j
@RestController
@RequestMapping("/upload")
public class UploadController {
private final AliyunOSSOperator aliyunOSSOperator;

public UploadController(AliyunOSSOperator aliyunOSSOperator) {
this.aliyunOSSOperator = aliyunOSSOperator;
}

/* 本地磁盘存储方式
@PostMapping
public Result upload(String name, Integer age, MultipartFile file) throws IOException {
log.info("接收到的参数:{},{},{}",name,age,file);

// 获取原始文件名
String originalFilename = file.getOriginalFilename();

// 转换原始文件名
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); // 获取后缀
String newFileName = UUID.randomUUID() + extension; // 利用UUID生成不会重复的文件名称

// 保存文件
file.transferTo(new File("E:\\MyFiles\\" + newFileName));

return Result.success();
}
*/

@PostMapping
public Result upload(MultipartFile file) throws Exception {
log.info("文件上传:{}", file.getOriginalFilename());
// 将文件交给OSS存储管理
String url = aliyunOSSOperator.upload(file.getBytes(), file.getOriginalFilename());
log.info("文件上传OSS,该文件的URL为:{}", url);
return Result.success(url);
}
}

删除员工

Mapper

EmpMapper.xml:

1
2
3
4
<delete id="removeEmps">
DELETE FROM emp WHERE id IN
<foreach collection="ids" index="index" item="item" open="(" separator="," close=")">#{item}</foreach>
</delete>

EmpExprMapper.xml:

1
2
3
4
<delete id="removeEmpExprs">
DELETE FROM emp_expr WHERE emp_id IN
<foreach collection="ids" index="index" item="item" open="(" close=")" separator=",">#{item}</foreach>
</delete>

Service

1
2
3
4
5
6
@Override
@Transactional(rollbackFor = {Exception.class})
public void removeEmps(Integer[] ids) {
empMapper.removeEmps(ids);
empExprMapper.removeEmpExprs(ids);
}

删除员工操作包括了员工基本信息和员工经历这两张表

Controller

1
2
3
4
5
6
@DeleteMapping
public Result removeEmps(Integer[] ids) {
log.info("ids:{}", Arrays.toString(ids));
empService.removeEmps(ids);
return Result.success();
}

对于DELETE /emps?ids=1,2,3这样的请求参数,可以直接使用数组的方式接收

或者利用@RequestParam注解让List接收:public Result delete(@RequestParam List<Integer> ids) {...}

根据ID查询

根据ID查询员工信息,有两种方案:

第一种是操作两次表,将结果封装到Emp实体类中

第二种是通过一次SQL查询同时获取员工基本信息和工作经历,然后让MyBatis自动组装成完整的员工对象

Mapper

第一种方式

EmpMapper:

1
2
@Select("SELECT id, username, password, name, gender, phone, job, salary, image, entry_date, dept_id, create_time, update_time FROM emp WHERE id = #{id}")
Emp findEmpById(Integer id);

EmpExprMapper:

1
2
@Select("SELECT id, emp_id, begin, end, company, job FROM emp_expr WHERE emp_id = #{id}")
List<EmpExpr> findEmpExprById(Integer id);

第二种方式

EmpMapper:

1
Emp getEmpById(Integer id);

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
36
<!--  自定义结果集ResultMap  -->
<resultMap id="empResultMap" type="com.norlcyan.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.norlcyan.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>

<select id="getEmpById" resultMap="empResultMap">
SELECT emp.*,
ee.id ee_id,
ee.company ee_company,
ee.job ee_job,
ee.emp_id ee_empid,
ee.begin ee_begin,
ee.end ee_end
FROM emp LEFT JOIN emp_expr ee on emp.id = ee.emp_id WHERE emp.id = #{id};
</select>

注意自定义结果集中的column对应的是查询结果的列名,property对应的是实体类中的成员属性

对于自定义结果集,主要针对需要复用自定义结果集、 字段名和属性名不匹配、复杂的关联查询(一对一/一对多)这种情况

Service

1
2
3
4
5
6
7
8
9
10
@Override
public Emp findEmpById(Integer id) {
// 第一种方式:通过操作两个数据表,得到结果
// Emp e = empMapper.findEmpById(id);
// e.setExprList(empExprMapper.findEmpExprById(id));

// 第二种方式:通过外连接得到结果
Emp e = empMapper.getEmpById(id);
return e;
}

Controller

1
2
3
4
5
@GetMapping("/{id}")
public Result findEmpById(@PathVariable Integer id) {
Emp e = empService.findEmpById(id);
return Result.success(e);
}

修改员工

Mapper

EmpMapper:

1
void modify(Emp emp);

EmpMapper.xml:

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

以上代码可以防止前端未将所有字段值都传入,而导致某些字段被修改为空值

Service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
@Transactional
public void modifyEmp(Emp emp) {
// 1. 先删除员工所有的工作经历,再添加现有的所有工作经历(先删后增)
empExprMapper.removeEmpExprs(new Integer[]{emp.getId()});
List<EmpExpr> exprList = emp.getExprList();
if (!CollectionUtils.isEmpty(exprList)) {
exprList.forEach(item -> item.setEmpId(emp.getId()));
empExprMapper.saveExprInfo(exprList);
}

// 2. 修改员工基本信息
emp.setUpdateTime(LocalDateTime.now());
empMapper.modify(emp);
}

员工经历信息按照先删后增的原则进行修改

员工基本信息直接修改即可

数据统计

员工职位人数统计

通过以下SQL语句,可以对每个职位上的员工数量进行统计:

1
2
3
4
5
6
7
8
9
10
11
12
-- case函数
SELECT
COUNT(e.id) AS num,
(CASE e.job
WHEN 1 THEN '班主任'
WHEN 2 THEN '讲师'
WHEN 3 THEN '学工主任'
WHEN 4 THEN '教研主管'
WHEN 5 THEN '咨询师'
ELSE '其他'
END) AS pos
FROM emp e GROUP BY e.job ORDER BY num desc;

得到的结果:

num pos
14 讲师
7 班主任
7 咨询师
1 教研主管
1 学工主任
1 其他

Mapper

EmpMapper:

1
List<Map<String, Object>> countJobs();

返回值的类型是List集合,集合的泛型是Map

Map用于保存每一行数据,如:Map[num: 14, pos: 讲师]

List集合就是把每一行集合都保存起来,从结构上就看类似表格

EmpMapper.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!--  统计员工人数  -->
<select id="countJobs" resultType="java.util.Map">
SELECT
COUNT(e.id) AS num,
(CASE e.job
WHEN 1 THEN '班主任'
WHEN 2 THEN '讲师'
WHEN 3 THEN '学工主任'
WHEN 4 THEN '教研主管'
WHEN 5 THEN '咨询师'
ELSE '其他'
END) AS pos
FROM emp e GROUP BY e.job ORDER BY num desc
</select>

Service

这一层主要是把之前的List集合封装到JobOption实体类中:

1
2
3
4
5
6
7
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobOption {
private List jobList; // 职位列表
private List dataList; // 数据列表
}

ReportServiceImpl:

1
2
3
4
5
6
7
8
9
@Override
public JobOption countJobs() {
List<Map<String, Object>> maps = empMapper.countJobs();
List<Object> pos = maps.stream().map(dataMap -> dataMap.get("pos")).toList();
log.info("pos:{}",pos.toString());
List<Object> num = maps.stream().map(dataMap -> dataMap.get("num")).toList();
log.info("num:{}",num.toString());
return new JobOption(pos,num);
}

Controller

ReportController:

1
2
3
4
5
@GetMapping("/empJobData")
public Result countJobs() {
JobOption j = reportService.countJobs();
return Result.success(j);
}

员工性别统计

1
2
{"name": "男性员工","value": 5},
{"name": "女性员工","value": 6}

和员工人数统计不同的在于,它需要的返回值是一个对象而非数组,以下是员工人数统计返回的结果,用于进行对比:

1
2
"jobList": ["教研主管","学工主管","其他","班主任","咨询师","讲师"],
"dataList": [1,1,2,6,8,13]

Mapper

EmpMapper:

1
List<GenderOption> countGender();

EmpMapper.xml:

1
2
3
4
5
6
7
<!--  统计员工性别数量  -->
<select id="countGender" resultType="com.norlcyan.pojo.GenderOption">
SELECT
COUNT(e.id) as count,
if (gender = 1, '男性员工', '女性员工') as emp_gender
FROM emp e GROUP BY gender
</select>

Service

1
2
3
4
5
6
@Override
public List<GenderOption> countGender() {
List<GenderOption> list = empMapper.countGender();
log.info("list:{}",list.toString());
return list;
}

Controller

1
2
3
4
5
@GetMapping("/empGenderData")
public Result countGender() {
List<GenderOption> g = reportService.countGender();
return Result.success(g);
}

登录功能

定义登录成功的实体类:

1
2
3
4
5
6
7
8
9
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginInfo {
private Integer id;
private String username;
private String name;
private String 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
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler
public Result handleGlobalException(Exception e) {
log.error("程序发生异常:",e);
return Result.error("发生未知异常,请联系网管");
}

@ExceptionHandler
public Result handleDuplicateKeyException(DuplicateKeyException e) {
log.error("程序发生异常:",e);
String message = e.getMessage();

// 解析数据库错误信息
if (message.contains("emp.username")) {
return Result.error("用户名已存在");
} else if (message.contains("emp.phone")) {
return Result.error("手机号已存在");
}

// 如果上面的内容没有匹配到,就会采用下面的默认处理方式
int i = message.indexOf("Duplicate entry");
String errorMessage = message.substring(i);
String[] arr = errorMessage.split(" ");
return Result.error(arr[2] + "已存在");
}
}

异常会根据最具体匹配优先

最具体匹配优先(Most Specific Match First)是指当发生异常时,Spring会优先选择参数类型最精确、最具体的异常处理方法

前端请求设计

请求工具类

在前端项目中,创建一个统一的请求工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import axios from "axios";

const request = axios.create({
baseURL: "http://localhost:8080", // 请求路径的基本地址
timeout: 60000,
});

request.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
return Promise.reject(error);
}
);

export default request;

为了方便管理,还需要创建一个Api文件夹,在该文件夹下对每个接口进行分类操作

比如调用部门接口就创建dept.js,这个文件内存放所有增删改查的具体调用方式:

1
2
3
4
5
6
7
8
9
10
11
import request from "@/utils/request"

// 部门列表查询
export const queryAllDept = () => {
return request.get("/depts")
}

// 查询回显
export const queryDept = (id) => {
return request.get(`/depts/${id}`)
}

需要使用的时候,调用即可:

1
2
3
4
import {queryAllDept,queryDept} from '@/api/dept'

const list = queryAllDept()
const dept = queryDept(1)

配置文件设置服务器信息

在上一章中,请求工具类中的baseURL是写死的。项目打包后,如果后期更换了服务器地址,无法直接修改请求工具类

所以,可以利用vite.config.js配置请求服务器信息,具体流程如图:

Snipaste_2025-08-21_12-10-10

vite.config.js中的proxy配置意思是:当发送请求时的路径为/api时,将其目标基础地址设置为http://localhost:8080,并重写(移除)掉/api

登录

登录成功时,将服务器返回的用户信息(username、name、token)存储到本地中,可以利用localStorage

  • localStorage是浏览器提供的本地存储机制(最大5MB)
  • 存储形式为key-value形式,键和值都是字符串形式
  • API方法:
    • localStorage.setItem(key, value)
    • localStorage.getItem(key)
    • localStorage.removeItem(key)
    • localStorage.clear()