Skip to content

🤖 Tlias 智能学习辅助系统_4


前面花了一大段篇幅来讲新增员工的细节问题

比如说在涉及到多次 数据库交互 的时候,我们需要使用事务管理

在需要存储图像的时候,我们学习了阿里云OSS云存储...

最后终于是讲完了新增员工的板块,目前已经完成了员工管理中的列表查询、新增员工的功能,

那关于员工管理还有两个功能分别是:删除员工、修改员工。


十一、删除员工


11.1 需求


1280X1280 (2)

当我们勾选列表前面的复选框,然后点击 "批量删除" 按钮,就可以将这一批次的员工信息删除掉了。

也可以只勾选一个复选框,仅删除一个员工信息。


❓ 问题:我们需要开发两个功能接口吗?一个删除单个员工,一个删除多个员工

答案:不需要。 只需要开发一个功能接口即可(删除多个员工包含只删除一个员工)


11.2 接口文档

参照资料中提供的接口文档,查看 员工管理 -> 删除员工 接口的描述。


11.3 思路分析


微信截图_20250811115125

11.4 功能开发


11.4.1 Controller接收参数


EmpController 中增加如下方法 delete ,来执行批量删除员工的操作。


  • 方式一:在 Controller 方法中通过数组来接收

多个参数,默认可以将其封装到一个数组中,需要保证前端传递的参数名 与 方法形参名称保持一致。

java
/**
* 批量删除员工
*/
@DeleteMapping
public Result delete(Integer[] ids){
    log.info("批量删除员工: ids={} ", Arrays.asList(ids));
    return Result.success();
}

  • 方式二:在 Controller 方法中通过集合来接收

也可以将其封装到一个List<Integer> 集合中,

❗ 注意:

如果要将其封装到一个集合中,需要在集合前面加上 @RequestParam 注解。

java
/**
* 批量删除员工
*/
@DeleteMapping
public Result delete(@RequestParam List<Integer> ids){
    log.info("批量删除员工: ids={} ", ids);
    empService.deleteByIds(ids);
    return Result.success();
}

两种方式,选择其中一种就可以,我们一般推荐选择集合,因为基于集合操作其中的元素会更加方便。


11.4.2 Service


  • 1). 在接口中 EmpService 中定义接口方法 deleteByIds
java
/**
* 批量删除员工
*/
void deleteByIds(List<Integer> ids);

  • 2). 在实现类 EmpServiceImpl 中实现接口方法 deleteByIds

在删除员工信息时,既需要删除 emp 表中的员工基本信息,还需要删除 emp_expr 表中员工的工作经历信息

java
@Transactional(rollbackFor = {Exception.class})
@Override
public void deleteByIds(List<Integer> ids) {
    //1. 根据ID批量删除员工基本信息
    empMapper.deleteByIds(ids);

    //2. 根据员工的ID批量删除员工的工作经历信息
    empExprMapper.deleteByEmpIds(ids);
}

由于删除员工信息,既要删除员工基本信息,又要删除工作经历信息,

操作多次数据库的删除,所以需要进行事务控制。


📌 补充:​

也要同时删除OSS中的头像图片,删除的时候注意的地方:

  • 先查询出来要删除的用户的数据等员工信息和工作经历删除完之后再删除OSS的图片

    因为事务只能回滚数据库内容 如果先删除OSS图片 后面报错导致事务回滚就会出现不一致的情况。

  • 数据库里图片的 UrL 地址的开头有一个空格,

    如果是用 url.getPath 的话要先用 trim() 去一下开头的空格不然会报错。


11.4.3 Mapper


  • 1). 在 EmpMapper 接口中增加 deleteByIds 方法实现批量删除员工基本信息
java
/**
* 批量删除员工信息
*/
void deleteByIds(List<Integer> ids);

  • 2). 在 EmpMapper.xml 配置文件中, 配置对应的 SQL 语句
xml
<!--批量删除员工信息-->
<delete id="deleteByIds">
    delete from emp where id in
    <foreach collection="ids" item="id" open="(" close=")" separator=",">
            #{id}
    </foreach>
</delete>

  • 3). 在 EmpExprMapper 接口中增加 deleteByEmpIds 方法实现根据员工 ID 批量删除员工的工作经历信息
java
/**
* 根据员工的ID批量删除工作经历信息
*/
void deleteByEmpIds(List<Integer> empIds);

  • 4). 在 EmpExprMapper.xml 配置文件中, 配置对应的 SQL 语句
xml
<!--根据员工的ID批量删除工作经历信息-->
<delete id="deleteByEmpIds">
    delete from emp_expr where emp_id in
    <foreach collection="empIds" item="empId" open="(" close=")" separator=",">
            #{empId}
    </foreach>
</delete>

11.5 功能测试


功能开发完成后,重启项目工程,打开 Apifox,发起 DELETE 请求:

831cb0d8-47d9-413c-a2e8-6e8e3b755383

控制台 SQL 语句:

e37c6c2a-f096-4caf-818c-1aebedad5487

11.6 前后端联调


打开浏览器,测试后端功能接口:

191ebc2e-7bcf-45d1-8b37-39b0fc4a435a

十二、修改员工


12.1 需求


803b8593-7af4-4442-9aa8-4c4ab993927c

在进行修改员工信息的时候,我们首先先要根据员工的 ID 查询员工的详细信息用于 页面回显 展示,

然后用户修改员工数据之后,点击保存按钮,就可以将修改的数据提交到服务端,保存到数据库。

具体操作为:

  • 根据 ID 查询员工信息

  • 保存修改的员工信息


12.2 查询回显


12.2.1 接口描述

参照资料中提供的接口文档,查看 员工管理 -> 根据ID查询 接口的描述。


12.2.2 思路


在查询回显时,既需要查询出员工的基本信息,又需要查询出该员工的工作经历信息。

b9c6f631-b7e4-4cdf-95d6-ef08d294a83a

我们可以先通过一条 SQL 语句,查询出指定员工的基本信息,及其员工的工作经历信息。SQL如下:

sql
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 = 39;

📌 注意:

员工表和员工工作经历表的有些字段名是重复的,比如 id,job 等,

进行多表查询时,如果查询返回的字段有的是重复的,那么可以起别名


具体的实现思路如下:

微信截图_20250817151332

12.2.3 代码实现


  • 1). EmpController 添加 getInfo 用来根据 ID 查询员工数据,用于页面回显
java
/**
 * 查询回显
 */
@GetMapping("/{id}")
public Result getInfo(@PathVariable Integer id){
    log.info("根据id查询员工的详细信息");
    Emp emp  = empService.getInfo(id);
    return Result.success(emp);
}

  • 2). EmpService 接口中增加 getInfo 方法
java
/**
 * 根据ID查询员工的详细信息
 */
Emp getInfo(Integer id);

  • 3). EmpServiceImpl 实现类中实现 getInfo 方法
java
@Override
public Emp getInfo(Integer id) {
    return empMapper.getById(id);
}

  • 4). EmpMapper 接口中增加 getById 方法
java
/**
 * 根据ID查询员工详细信息
 */
Emp getById(Integer id);

  • 5). EmpMapper.xml 配置文件中定义对应的 SQL
xml
<!--自定义结果集 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> :

  • 根据 emp 对象的属性,一个字段一个字段描述,描述每一个字段要往哪一个属性里面封装,

    column 代表字段名,property 代表属性名

  • 主键 id 使用 id 标签指定,即属性 id 要封装的是字段 id;普通字段使用 result 标签声明

  • 集合属性使用 collection 标签封装

📌 Mybatis 中封装查询结果,什么时候用 resultType,什么时候用 resultMap

  • 如果查询返回的字段名与实体的属性名可以直接对应上,用 resultType

  • 如果查询返回的字段名与实体的属性名对应不上,

    或实体属性比较复杂,可以通过 resultMap 手动封装 。


12.2.4 Apifox 测试


重新启动服务,基于 Apifox 进行接口测试。

7b0d3c2e-c3ab-44b9-9e1b-59f78a5b3673

12.2.5 前后端联调


打开浏览器,进行前后端联调测试。

4a398f8e-177b-44a4-ab02-214044cfd3fc

12.3 修改员工


12.3.1 需求


查询回显之后,就可以在页面上修改员工的信息了。

8d9f90f3-8945-4b89-bf20-6dc6fd26240f
  • 当用户修改完数据之后,点击保存按钮,就需要将数据提交到服务端,

    然后服务端需要将修改后的数据更新到数据库中 。

  • 而此次更新的时候,既需要更新员工的基本信息; 又需要更新员工的工作经历信息 。


12.3.2 接口文档

参照资料中提供的接口文档,查看 员工管理 -> 修改员工 接口的描述。


12.3.3 实现思路


微信截图_20250817154727

12.3.4 代码实现


  • 1). EmpController 增加 update 方法接收请求参数,响应数据
java
/**
* 更新员工信息
*/
@PutMapping
public Result update(@RequestBody Emp emp){
    log.info("修改员工信息, {}", emp);
    empService.update(emp);
    return Result.success();
}

  • 2). EmpService 接口增加 update 方法
java
/**
* 更新员工信息
* @param emp
*/
void update(Emp emp);

  • 3). EmpServiceImpl 实现类实现 update 方法
java
@Transactional(rollbackFor = Exception.class)
@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);
    }
}

  • 4). EmpMapper 接口中增加 updateById 方法
java
/**
* 更新员工基本信息
*/
void updateById(Emp emp);

  • 5). EmpMapper.xml 配置文件中定义对应的 SQL 语句,基于动态 SQL 更新员工信息
xml
<!--根据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>

📌 注意:

  • 通过 if 标签来进行如果我们某些参数没有传参数过来,这时候我们就不修改其原来的值,

    如果传过来了,就执行相应的修改语句,这样应该能保证数据完整性

  • <set> 标签用来替换 set 关键字,可以自动帮助我们生成 set 关键字,

    同时可以自动去除掉更新字段后面多余的逗号


12.3.5 Apifox 测试


重新启动服务,打开 Apifox 进行接口测试。

6cd3c522-ce51-4efd-85a6-22a526c22fc7

12.3.6 前后端联调


5c11efc8-69f4-4909-b4d2-8b38ca15b98f

点击保存之后,查看更新后的数据。

5ffb909c-a5d5-4824-9571-a06c160d7679

十三、异常处理


13.1 问题分析


当我们在修改部门数据的时候,如果输入一个在数据库表中已经存在的手机号,

点击保存按钮之后,前端提示了错误信息,但是返回的结果并不是统一的响应结果,

而是框架默认返回的错误结果 。

11e2dc75-e3db-428b-a310-033db3b5a6ea

状态码为 500 ,表示服务器端异常,我们打开 idea,来看一下,服务器端出了什么问题。

42e146c8-f0c2-45a9-bf71-511676de93f5

上述错误信息的含义是,emp员工表的phone手机号字段的值重复了,

因为在数据库表emp中已经有了13309090027这个手机号了,

我们之前设计这张表时,为phone字段建议了唯一约束,所以该字段的值是不能重复的。

而当我们再将该员工的手机号也设置为 13309090027,就违反了唯一约束,此时就会报错。

我们来看一下出现异常之后,最终服务端给前端响应回来的数据长什么样。

72756624-a98e-43b7-a7af-fe7babcba714

响应回来的数据是一个 JSON 格式的数据。但这种 JSON 格式的数据还是我们开发规范当中所提到的

统一响应结果 Result 吗?显然并不是。由于返回的数据不符合开发规范,

所以前端并不能解析出响应的 JSON 数据 。


接下来我们需要思考的是出现异常之后,当前案例项目的异常是怎么处理的?

答案:没有做任何的异常处理

d7b850cb-5c09-47da-a276-6d7afaadd7eb

当我们没有做任何的异常处理时,我们三层架构处理异常的方案:

  • Mapper 接口在操作数据库的时候出错了,此时异常会往上抛

    (谁调用 Mapper 就抛给谁),会抛给 service

  • service 中也存在异常了,会抛给 controller

  • 而在 controller 当中,我们也没有做任何的异常处理,所以最终异常会再往上抛。

    最终抛给框架之后,框架就会返回一个 JSON 格式的数据,

    里面封装的就是错误的信息,但是框架返回的 JSON 格式的数据并不符合我们的开发规范。


13.2 解决方案


❓ ​那么在三层构架项目中,出现了异常,该如何处理?

  • 方案一:在所有 Controller 的所有方法中进行 try…catch 处理

缺点:代码臃肿(不推荐)

3e9394c9-f333-4913-86db-3117dfdbead7
  • 方案二:全局异常处理器

好处:简单、优雅(推荐)

729e3ef9-ccce-46e2-b277-5c1e83dd33ac

13.3 全局异常处理器


我们该怎么样定义全局异常处理器?

  • 定义全局异常处理器非常简单,就是定义一个类,在类上加上一个注解 @RestControllerAdvice

    加上这个注解就代表我们定义了一个全局异常处理器。

    (PS: 定义的这个类 GlobalExceptionHandler 可以专门开一个包 exception)

  • 在全局异常处理器当中,需要定义一个方法来捕获异常,

    在这个方法上需要加上注解 @ExceptionHandler

    通过 @ExceptionHandler 注解当中的 value 属性来指定我们要捕获的是哪一类型的异常。

java
@slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    //处理异常
    @ExceptionHandler
    public Result handleException(Exception e){
        log.error("程序出错了~",e);
        return Result.error("出错了,请联系管理员~");
    }
}

📌 @RestControllerAdvice = @ControllerAdvice + @ResponseBody

处理异常的方法返回值会转换为 json 后再响应给前端


重新启动 SpringBoot 服务,打开浏览器,再来测试一下 修改员工 这个操作,

我们依然设置已存在的 13309090027这个手机号

e9eeac42-4f62-409c-8df8-c978deb1b7d3

此时,我们可以看到,出现异常之后,异常已经被全局异常处理器捕获了。

然后返回的错误信息,被前端程序正常解析,然后提示出了对应的错误提示信息。


📌 以上就是全局异常处理器的使用,主要涉及到两个注解:

  • @RestControllerAdvice //表示当前类为全局异常处理器
  • @ExceptionHandler //指定可以捕获哪种类型的异常进行处理

13.4 优化


前面虽然我们已经成功写出了全局异常处理器,但是这种 出错了,请联系管理员~

的提示信息传到前端给用户看,很难知道是哪里有问题,我们可以把提示信息做得更准确


在前面重复手机号报错信息里如下图:

微信截图_20250818204042

我们可以从中提取到有问题的部分(重复的手机号),那么就可以传输这个信息去前端告诉用户


而且就不能写太宽泛的 Exception 了, 从报错信息我们可以知道是 DuplicateKeyException 这种异常

❓ 如果定义了多个异常处理器,是被哪个捕获

回答:是按照 最精确类型优先 , 只有找不到合适的才会向上回溯,如下图

微信截图_20250818204433
  • 那么我们可以添加一个处理 DuplicateKeyException 的处理器,

    并且截取到报错信息中重复的部分返回给前端

java
//处理重复信息的异常
@ExceptionHandler
public Result handleDuplicateKeyException(DuplicateKeyException e){
    log.error("程序出错了~",e);
    String message = e.getMessage();
    int i = message.indexOf("Duplicate entry");
    String errMsg = message.substring(i);
    String[] arr = errMsg.split(" ");
    return Result.error(arr[2] + " 已存在");
}

  • 前端测试重复手机号报错:
微信截图_20250818205201

可见,现在的提示信息就很明显了,用户可以定位到自己填写信息的错误部分进行修改。


十四、员工信息统计


员工管理的增删改查功能我们已开发完成,接下来,我们再来完成员工信息统计的接口开发。

对于这些图形报表的开发,其实呢,都是基于现成的一些图形报表的组件开发的,

比如:EchartsHighCharts 等。

而报表的制作,主要是前端人员开发,引入对应的组件(比如:ECharts)即可。

服务端开发人员仅为其提供数据即可。


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

86cdad30-7241-46b8-ac3c-50c75c890a8d

14.1 职位统计


14.1.1 需求


dfd5c9f9-ae3f-4f52-9641-8b156516a609

对于这类的图形报表,服务端要做的,就是为其提供数据即可。

我们可以通过官方的示例,看到提供的数据其实就是 X 轴展示的信息,和对应的数据。

cda1b485-71d1-48ef-8136-aa2c9955174d

14.1.2 接口文档

参照资料中提供的接口文档,查看 数据统计 -> 员工职位统计 接口的描述。


14.1.3 代码实现


  • 1). 定义封装结果对象 JobOption

com.itheima.pojo 包中定义实体类 JobOption

java
package com.itheima.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobOption {
    private List jobList; //职位列表
    private List dataList; //数据列表
}

  • 1). 定义 ReportController ,并添加方法。
java
@Slf4j
@RequestMapping("/report")
@RestController
public class ReportController {

    @Autowired
    private ReportService reportService;
    

    /**
     * 统计各个职位的员工人数
     */
    @GetMapping("/empJobData")
    public Result getEmpJobData(){
        log.info("统计各个职位的员工人数");
        JobOption jobOption = reportService.getEmpJobData();
        return Result.success(jobOption);
    }
}

  • 2). 定义 ReportService 接口,并添加接口方法。
java
public interface ReportService {
    /**
     * 统计各个职位的员工人数
     * @return
     */
    JobOption getEmpJobData();
}

  • 3). 定义 ReportServiceImpl 实现类,并实现方法
java
@Service
public class ReportServiceImpl implements ReportService {

    @Autowired
    private EmpMapper empMapper;
        
    @Override
    public JobOption getEmpJobData() {
        
        List<Map<String,Object>> list = empMapper.countEmpJobData();
        
        //构建职位列表
        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);
    }
}

  • 4). 定义 EmpMapper 接口

统计的是员工的信息,所以需要操作的是员工表。 所以代码我们就写在 EmpMapper 接口中即可。

java
/**
 * 统计各个职位的员工人数
 */
@MapKey("pos")
List<Map<String,Object>> countEmpJobData();

📌 注意:

如果查询的记录往 Map 中封装,

可以通过 @MapKey 注解指定返回的 map 中的唯一标识是那个字段。【也可以不指定】


  • 5). 定义 EmpMapper.xml
xml
<!-- 统计各个职位的员工人数 -->
<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>

📌 case 流程控制函数:

  • 语法一:case when cond1 then res1 [ when cond2 then res2 ] else res end ;

    • 含义:如果 cond1 成立, 取 res1

      如果 cond2 成立,取 res2。 如果前面的条件都不成立,则取 res


  • 语法二(仅适用于等值匹配):

    case expr when val1 then res1 [ when val2 then res2 ] else res end ;

    • 含义:如果 expr 的值为 val1 , 取 res1

      如果 expr 的值为 val2 ,取 res2。 如果前面的条件都不成立,则取 res


14.1.4 Apifox 测试


重新启动服务,打开 Apifox 进行测试。

21282068-d30d-494d-b2a1-591077fd702a

14.1.5 前后端联调


1e838669-a9a5-4c3b-910b-0a58c49c5931

14.2 性别统计


14.2.1 需求


48e23b3f-c445-4ddf-93cb-db6664f65d3e

对于这类的图形报表,服务端要做的,就是为其提供数据即可。

我们可以通过官方的示例,看到提供的数据就是一个 json 格式的数据。


14.2.2 接口文档

参照资料中提供的接口文档,查看 数据统计 -> 员工性别统计 接口的描述。


14.2.3 代码实现


  • 1). 在 ReportController,添加方法。
java
/**
 * 统计员工性别信息
 */
@GetMapping("/empGenderData")
public Result getEmpGenderData(){
    log.info("统计员工性别信息");
    List<Map> genderList = reportService.getEmpGenderData();
    return Result.success(genderList);
}

  • 2). 在 ReportService 接口,添加接口方法。
java
/**
 * 统计员工性别信息
 */
List<Map> getEmpGenderData();

  • 3). 在 ReportServiceImpl 实现类,实现方法
java
@Override
public List<Map> getEmpGenderData() {
    return empMapper.countEmpGenderData();
}

  • 4). 定义 EmpMapper 接口

统计的是员工的信息,所以需要操作的是员工表。 所以代码我们就写在 EmpMapper 接口中即可。

java
/**
 * 统计员工性别信息
 */
@MapKey("name")
List<Map> countEmpGenderData();

  • 5). 定义 EmpMapper.xml
xml
<!-- 统计员工的性别信息 -->
<select id="countEmpGenderData" resultType="java.util.Map">
    select
    if(gender = 1, '男', '女') as name,
    count(*) as value
    from emp group by gender ;
</select>

📌 ​条件函数:

  • if 函数语法:if(条件, 条件为true取值, 条件为false取值)

  • ifnull 函数语法:ifnull(expr, val1) 如果 expr 不为 null ,取自身,否则取 val1


14.2.4 Apifox 测试


fcce1735-0f52-4730-8a96-aef09c00a1f2


14.2.5 前后端联调


1c631d98-0e06-4cde-ad7b-aa56eaf08800

十五、【后端作业】


微信截图_20250821165510
  • 这是目前的进度:
    • 已经完成:
      • 部门管理
      • 员工管理
      • 员工信息统计
    • 目前可以完成的作业:
      • 班级管理
      • 学员管理
      • 学员信息统计

参考链接:https://heuqqdmbyk.feishu.cn/wiki/NRjLwaRqDiEiFVkHLWGct6zFnNg


十六、登录认证


我们已经实现了部门管理、员工管理的基本功能,

但是,我们并没有登录,就直接访问到了 Tlias 智能学习辅助系统的后台。 这是不安全的,

所以我们今天的主题就是登录认证。最终要实现的效果是:


  • 如果用户名密码错误,不允许登录系统。
1280X1280 (3)
  • 如果用户名和密码都正确,则登录成功,可以访问系统。
1280X1280 (4)

16.1 登录功能


16.1.1 需求


1280X1280 (5)

在登录界面中,我们可以输入用户的用户名以及密码,然后点击 "登录" 按钮就要请求服务器,

服务端判断用户输入的用户名或者密码是否正确。如果正确,则返回成功结果,前端跳转至系统首页面。


16.1.2 接口文档

我们参照接口文档中的 其他接口 -> 登录接口


16.1.3 思路分析


  • 怎么样才算登录成功了呢?
    • 用户名和密码都输入正确,登录成功
    • 否则,登录失败
  • 登录功能的本质是什么?
    • 查询
    • 根据用户名和密码查询员工信息

16.1.4 功能开发


  • 学会使用 ai 来增加开发效率
    • 学会使用好 prompt 提示词,让 ai 给出的回答更精准
    • 根据 ai 的回答进行微调

💡 AI 提示词:

txt
你是一名java开发工程师,现需要基于 SpringBoot+Mybatis 实现员工登录的基本功能,开发一个基本的登录接口,基本信息如下:
1. 接口请求路径 /login ,请求方式post 
2. 接口请求参数有:用户名 username, 密码 password,为json格式的数据 {"username":"admin", "password":"123456"}
3. 接口响应数据:json格式,具体的数据格式如下:
{
    "code": 1,
    "msg": "success",
    "data": {
        "id": 1,
        "username": "songjiang",
        "name": "宋江",
        "token": "..."
    }
}
4. 数据库表为 emp, 对应的实体类为Emp,已存在,对应的表结构为:
create table emp (
    id          int unsigned primary key auto_increment comment 'ID,主键',
    username    varchar(20)                  not null comment '用户名',
    password    varchar(32) default '123456' not null comment '密码',
    name        varchar(10)                  not null comment '姓名',
    gender      tinyint unsigned             not null comment '性别, 1:男, 2:女',
    phone       char(11)                     not null comment '手机号',
    job         tinyint unsigned             null comment '职位, 1:班主任,2:讲师,3:学工主管,4:教研主管,5:咨询师',
    salary      int unsigned                 null comment '薪资',
    image       varchar(300)                 null comment '头像',
    entry_date  date                         null comment '入职日期',
    dept_id     int unsigned                 null comment '关联的部门ID',
    create_time datetime                     null comment '创建时间',
    update_time datetime                     null comment '修改时间',
    constraint emp_pk unique (phone),
    constraint username unique (username)
) comment '员工表';

  • 1). 准备实体类 LoginInfo, 封装登录成功后, 返回给前端的数据 。
java
/**
 * 登录成功结果封装类
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginInfo {
    private Integer id; //员工ID
    private String username; //用户名
    private String name; //姓名
    private String token; //令牌
}

  • 2). 定义 LoginController
java
@Slf4j
@RestController
public class LoginController {

    @Autowired
    private EmpService empService;

    @PostMapping("/login")
    public Result login(@RequestBody Emp emp){
        log.info("员工来登录啦 , {}", emp);
        LoginInfo loginInfo = empService.login(emp);
        if(loginInfo != null){
            return Result.success(loginInfo);
        }
        return Result.error("用户名或密码错误~");
    }

}

  • 3). EmpService 接口中增加 login 登录方法
java
/**
 * 登录
 */
LoginInfo login(Emp emp);

  • 4). EmpServiceImpl 实现 login 方法
java
@Override
public LoginInfo login(Emp emp) {
    Emp empLogin = empMapper.getUsernameAndPassword(emp);
    if(empLogin != null){
        LoginInfo loginInfo = new LoginInfo(empLogin.getId(), empLogin.getUsername(), empLogin.getName(), null);
        return loginInfo;
    }
    return null;
}

  • 5). EmpMapper 增加接口方法
java
/**
 * 根据用户名和密码查询员工信息
 */
@Select("select * from emp where username = #{username} and password = #{password}")
Emp getUsernameAndPassword(Emp emp);

16.1.5 测试


功能开发完毕后,我们就可以启动服务,打开 Apifox 进行测试了。

发起 POST 请求,访问:http://localhost:8080/login

15649887-5bd8-4cdd-a7ce-de59f879dab7

Apifox 测试通过了,那接下来,我们就可以结合着前端工程进行联调测试。

先退出系统,进入到登录页面。在登录页面输入账户密码:

c07eddd0-faca-40bb-a7ef-405b4b6b4684

登录成功之后进入到后台管理系统页面:

9c966867-3387-4647-be09-a92f9cc80ab4

我们已经完成了基础登录功能的开发与测试,在我们登录成功后就可以进入到后台管理系统中进行数据的操作。

但是当我们在浏览器中新的页面上输入地址:http://localhost:90

发现没有登录仍然可以进入到后端管理系统页面。

67fc0e98-08b6-4202-814b-1b660124d7d2

而真正的登录功能应该是:登陆后才能访问后端系统页面,不登陆则跳转登陆页面进行登陆。

为什么会出现这个问题?其实原因很简单,就是因为针对于我们当前所开发的部门管理、员工管理

以及文件上传等相关接口来说,我们在服务器端并没有做任何的判断,没有去判断用户是否登录了。

所以无论用户是否登录,都可以访问部门管理以及员工管理的相关数据。

所以我们目前所开发的登录功能,它只是徒有其表。

而我们要想解决这个问题,我们就需要完成一步非常重要的操作:登录校验


16.2 登录校验


什么是登录校验?

所谓登录校验,指的是我们在服务器端接收到浏览器发送过来的请求之后,首先我们要对请求进行校验。

先要校验一下用户登录了没有,如果用户已经登录了,就直接执行对应的业务操作就可以了;

如果用户没有登录,此时就不允许他执行相关的业务操作,直接给前端响应一个错误的结果,

最终跳转到登录页面,要求他登录成功之后,再来访问对应的数据。


16.2.1 思路


了解完什么是登录校验之后,接下来我们分析一下登录校验大概的实现思路。

首先我们在宏观上先有一个认知:

前面在讲解 HTTP 协议的时候,我们提到 HTTP 协议是无状态协议。

❓ ​什么又是无状态的协议?

所谓无状态,指的是每一次请求都是独立的,下一次请求并不会携带上一次请求的数据。

而浏览器与服务器之间进行交互,基于HTTP协议也就意味着现在我们通过浏览器来访问了登陆这个接口,

实现了登陆的操作,接下来我们在执行其他业务操作时,服务器也并不知道这个员工到底登陆了没有。

因为 HTTP 协议是无状态的,两次请求之间是独立的,所以是无法判断这个员工到底登陆了没有。

6463d1e0-9502-4dd2-9f11-7791ac5d4bc8

那应该怎么来实现登录校验的操作呢?具体的实现思路可以分为两部分:

  • 在员工登录成功后,需要将用户登录成功的信息存起来,记录用户已经登录成功的标记。

  • 在浏览器发起请求时,需要在服务端进行统一拦截,拦截后进行登录校验。


📌 统一拦截:

想要判断员工是否已经登录,我们需要在员工登录成功之后,存储一个登录成功的标记,

接下来在每一个接口方法执行之前,先做一个条件判断,判断一下这个员工到底登录了没有。

如果是登录了,就可以执行正常的业务操作,

如果没有登录,会直接给前端返回一个错误的信息,前端拿到这个错误信息之后会自动的跳转到登录页面。

我们程序中所开发的查询功能、删除功能、添加功能、修改功能,都需要使用以上套路进行登录校验。

此时就会出现:相同代码逻辑,每个功能都需要编写,就会造成代码非常繁琐。

为了简化这块操作,我们可以使用一种技术:统一拦截技术

通过统一拦截的技术,我们可以来拦截浏览器发送过来的所有的请求,拦截到这个请求之后,

就可以通过请求来获取之前所存入的登录标记,在获取到登录标记且标记为登录成功,就说明员工已经登录了。

如果已经登录,我们就直接放行(意思就是可以访问正常的业务接口了)。


我们要完成以上操作,会涉及到 web 开发中的两个技术:

  • 会话技术:用户登录成功之后,在后续的每一次请求中,都可以获取到该标记。

  • 统一拦截技术:过滤器 Filter、拦截器 Interceptor


我们先学习会话技术,然后再学习统一拦截技术


16.2.2 会话技术


16.2.2.1 介绍

什么是会话?

  • 在我们日常生活当中,会话指的就是谈话、交谈。
  • 在 web 开发当中,会话指的就是 浏览器与服务器之间的一次连接 ,我们就称为 一次会话

📌 ​一次会话:

在用户打开浏览器第一次访问服务器的时候,这个会话就建立了,直到有任何一方断开连接,此时会话就结束了。

在一次会话当中,是可以包含多次请求和响应的。

比如:打开了浏览器来访问 web 服务器上的资源(浏览器不能关闭、服务器不能断开)

  • 第1次:访问的是登录的接口,完成登录操作
  • 第2次:访问的是部门管理接口,查询所有部门数据
  • 第3次:访问的是员工管理接口,查询员工数据

只要浏览器和服务器都没有关闭,以上 3 次请求都属于一次会话当中完成的。


需要注意的是:会话是和浏览器关联的,当有三个浏览器客户端和服务器建立了连接时,就会有三个会话。

同一个浏览器在未关闭之前请求了多次服务器,这多次请求是属于同一个会话。

比如:下图 1、2、3 这三个请求都是属于同一个会话。当我们关闭浏览器之后,这次会话就结束了。

而如果我们是直接把 web 服务器关了,那么所有的会话就都结束了。

3dbe554c-644a-4cd5-b72d-4e8f16ee73f8

知道了会话的概念了,接下来我们再来了解下 会话跟踪

📌 会话跟踪:

一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,

以便在同一次会话的多次请求间共享数据。

服务器会接收很多的请求,但是服务器是需要识别出这些请求是不是同一个浏览器发出来的。

比如:1 和2 这两个请求是不是同一个浏览器发出来的,3 和 5 这两个请求不是同一个浏览器发出来的。

如果是同一个浏览器发出来的,就说明是同一个会话。

如果是不同的浏览器发出来的,就说明是不同的会话。

而识别多次请求是否来自于同一浏览器的过程,我们就称为会话跟踪。

我们使用会话跟踪技术就是要完成在同一个会话中,多个请求之间进行共享数据。

❓ ​为什么要共享数据呢?

由于 HTTP 是无状态协议,在后面请求中怎么拿到前一次请求生成的数据呢?

此时就需要在一次会话的多次请求之间进行数据共享


  • 传统的会话跟踪技术有两种:

    • Cookie(客户端会话跟踪技术):数据存储在客户端浏览器当中

    • Session(服务端会话跟踪技术):数据存储在储在服务端

  • 此外现在使用得更多的是:令牌技术


16.2.2.2 会话跟踪方案

上面我们介绍了什么是会话,什么是会话跟踪,并且也提到了会话跟踪 3 种常见的技术方案。

接下来,我们就来对比一下这 3 种会话跟踪的技术方案,来看一下具体的实现思路,以及它们之间的优缺点。



cookie 是客户端会话跟踪技术,它是存储在客户端浏览器的,我们使用 cookie 来跟踪会话,

我们就可以在浏览器第一次发起请求来请求服务器的时候,我们在服务器端来设置一个 cookie

比如第一次请求了登录接口,登录接口执行完成之后,我们就可以设置一个 cookie

cookie 当中我们就可以来存储用户相关的一些数据信息。

  • 比如我可以在 cookie 当中来 存储当前登录用户的用户名,用户的 ID 。

服务器端在给客户端在响应数据的时候,会 自动 的将 cookie 响应给浏览器,

浏览器接收到响应回来的 cookie 之后,会 自动 的将 cookie 的值存储在浏览器本地。

接下来在后续的每一次请求当中,都会将浏览器本地所存储的 cookie 自动 地携带到服务端。

64b12592-f13d-49d2-8465-33a3839a8b0d

接下来在服务端我们就可以获取到 cookie 的值。我们可以去判断一下这个 cookie 的值是否存在,

如果不存在这个cookie,就说明客户端之前是没有访问登录接口的;

如果存在 cookie 的值,就说明客户端之前已经登录完成了。

这样我们就可以基于 cookie 在同一次会话的不同请求之间来共享数据。


刚才在介绍流程的时候,用了 3 个自动:

  • 服务器会 自动 的将 cookie 响应给浏览器。
  • 浏览器接收到响应回来的数据之后,会 自动 的将 cookie 存储在浏览器本地。
  • 在后续的请求当中,浏览器会 自动 的将 cookie 携带到服务器端。

❓ ​为什么这一切都是自动化进行的?

是因为 cookie 它是 HTTP 协议当中所支持的技术,而各大浏览器厂商都支持了这一标准。

在 HTTP 协议官方给我们提供了一个响应头和请求头:

  • 响应头 Set-Cookie :设置 Cookie 数据的
  • 请求头 Cookie:携带 Cookie 数据的
9eb25fe0-bb90-4f65-a0a9-a6298a815afa
  • 代码测试:
java
@Slf4j
@RestController
public class SessionController {

    //设置Cookie
    @GetMapping("/c1")
    public Result cookie1(HttpServletResponse response){
        response.addCookie(new Cookie("login_username","itheima")); //设置Cookie/响应Cookie
        return Result.success();
    }
        
    //获取Cookie
    @GetMapping("/c2")
    public Result cookie2(HttpServletRequest request){
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if(cookie.getName().equals("login_username")){
                System.out.println("login_username: "+cookie.getValue()); //输出name为login_username的cookie
            }
        }
        return Result.success();
    }
}

  • 访问 c1 接口,设置 Cookie,http://localhost:8080/c1
66c97f08-a248-46d4-a74c-d1ae44013a56

我们可以看到,设置的cookie,通过 响应头Set-Cookie 响应给浏览器,

并且浏览器会将 Cookie,存储在浏览器端。

7de4398d-5326-44b1-ac30-87008a6bf720
  • 访问 c2 接口 http://localhost:8080/c2

    此时浏览器会自动的将 Cookie 携带到服务端,是通过 请求头Cookie 携带的。

f4251365-0bc8-4267-84ee-d041b047ae84

📌 优缺点:

  • 优点:HTTP 协议中支持的技术(像 Set-Cookie 响应头的解析以及 Cookie 请求头数据的携带,

    都是浏览器自动进行的,是无需我们手动操作的)

  • 缺点:

    • 移动端 APP(Android、IOS) 中无法使用 Cookie
    • 不安全,用户可以自己禁用 Cookie
    • Cookie 不能跨域

📌 跨域介绍:


45ce1fa7-3f1e-4db1-9bbf-89ea91cc9bbf
  • 现在的项目,大部分都是前后端分离的,前后端最终也会分开部署,

    如上图:前端部署在服务器 192.168.150.200 上,端口 80

    后端部署在 192.168.150.100 上,端口 8080

  • 我们打开浏览器直接访问前端工程,访问 url:http://192.168.150.200/login.html

  • 然后在该页面发起请求到服务端,而服务端所在地址不再是 localhost

    而是服务器的 IP 地址 192.168.150.100,假设访问接口地址为:http://192.168.150.100:8080/login

  • 那此时就存在跨域操作了,因为我们是在 http://192.168.150.200/login.html

    这个页面上访问了 http://192.168.150.100:8080/login 接口

  • 此时如果服务器设置了一个 Cookie,这个 Cookie 是不能使用的,因为 Cookie 无法跨域


区分跨域的维度(三个维度有任何一个维度不同,那就是跨域操作):

  • 协议
  • IP/协议
  • 端口

举例:

前端后端是否跨域
http://192.168.150.200/login.htmlhttps://192.168.150.200/login协议不同,跨域
http://192.168.150.200/login.htmlhttp://192.168.150.100/loginIP不同,跨域
http://192.168.150.200/login.htmlhttp://192.168.150.200:8080/login端口不同,跨域
http://192.168.150.200/login.htmlhttp://192.168.150.200/login不跨域

16.2.2.2.2 Session

前面介绍的时候,我们提到 Session,它是服务器端会话跟踪技术,所以它是存储在服务器端的。

Session 的底层其实就是基于我们刚才所介绍的 Cookie 来实现的。


  • 获取 Session
e90ab3b3-73cb-4212-a044-a8ddf2b39f2f

如果我们现在要基于 Session 来进行会话跟踪,浏览器在第一次请求服务器的时候,

我们就可以直接在服务器当中来获取到会话对象 Session

如果是第一次请求 Session ,会话对象是不存在的,这个时候服务器会自动的创建一个会话对象Session 。

而每一个会话对象 Session ,它都有一个 ID(示意图中 Session 后面括号中的 1,就表示 ID),

我们称之为 Session 的 ID。


  • 响应 Cookie ( JSESSIONID )
d844dad5-7c32-4a4b-bc0e-76354d684395

接下来,服务器端在给浏览器响应数据的时候,它会将 Session 的 ID 通过 Cookie 响应给浏览器。

其实在响应头当中增加了一个 Set-Cookie 响应头。这个 Set-Cookie 响应头对应的值是不是cookie?

cookie 的名字是固定的 JSESSIONID 代表的服务器端会话对象 Session 的 ID。

浏览器会自动识别这个响应头,然后自动将 Cookie 存储在浏览器本地。


  • 查找 Session
67a38f35-9005-4426-a96e-26c8fb26aa38

接下来,在后续的每一次请求当中,都会将 Cookie 的数据获取出来,并且携带到服务端。

接下来服务器拿到 JSESSIONID 这个 Cookie 的值,也就是 Session 的 ID。

拿到 ID 之后,就会从众多的 Session 当中来找到当前请求对应的会话对象 Session。

这样我们是不是就可以通过 Session 会话对象在同一次会话的多次请求之间来共享数据了?

好,这就是基于 Session 进行会话跟踪的流程。


java
@Slf4j
@RestController
public class SessionController {

    @GetMapping("/s1")
    public Result session1(HttpSession session){
        log.info("HttpSession-s1: {}", session.hashCode());

        session.setAttribute("loginUser", "tom"); //往session中存储数据
        return Result.success();
    }

    @GetMapping("/s2")
    public Result session2(HttpServletRequest request){
        HttpSession session = request.getSession();
        log.info("HttpSession-s2: {}", session.hashCode());

        Object loginUser = session.getAttribute("loginUser"); //从session中获取数据
        log.info("loginUser: {}", loginUser);
        return Result.success(loginUser);
    }
}

  • 访问 s1 接口,http://localhost:8080/s1
e5c841b0-d98a-419f-bb8e-39f5d0608753

请求完成之后,在响应头中,就会看到有一个 Set-Cookie 的响应头,里面响应回来了一个Cookie,

就是 JSESSIONID,这个就是 服务端会话对象 Session 的 ID。


  • 访问 s2 接口,http://localhost:8080/s2
e9ffe14c-7b11-4a74-b9f7-b2705f90be86

接下来,在后续的每次请求时,都会将 Cookie 的值,携带到服务端,

那服务端呢,接收到 Cookie 之后,会自动的根据 JSESSIONID 的值,找到对应的会话对象 Session。

那经过这两步测试,大家也会看到,在控制台中输出如下日志:

e1d7d840-3c50-43b7-8573-f9390cd21c0d

两次请求,获取到的 Session 会话对象的 hashcode 是一样的,就说明是同一个会话对象。

而且,第一次请求时,往 Session 会话对象中存储的值,第二次请求时,也获取到了。

那这样,我们就可以通过 Session 会话对象,在同一个会话的多次请求之间来进行数据共享了。


📌 优缺点 :

  • 优点:Session 是存储在服务端的,安全

  • 缺点:

    • 服务器集群环境下无法直接使用 Session

      • 首先第一点,我们现在所开发的项目,一般都不会只部署在一台服务器上,

        因为一台服务器会存在一个很大的问题,就是单点故障。所谓单点故障,

        指的就是一旦这台服务器挂了,整个应用都没法访问了。

      351168e5-43d3-45fd-a530-60803e67f73b
      • 所以在现在的企业项目开发当中,最终部署的时候都是以集群的形式来进行部署,

        也就是同一个项目它会部署多份。比如这个项目我们现在就部署了 3 份。

      • 而用户在访问的时候,到底访问这三台其中的哪一台?

        其实用户在访问的时候,他会访问一台前置的服务器,我们叫 负载均衡服务器

        在后面项目当中会详细讲解。目前大家先有一个印象负载均衡服务器,

        它的作用就是将前端发起的请求均匀的分发给后面的这三台服务器。

      2ccae39d-d324-48aa-8ade-420e9831020c
      • 此时假如我们通过 session 来进行会话跟踪,可能就会存在这样一个问题。

        用户打开浏览器要进行登录操作,此时会发起登录请求。登录请求到达负载均衡服务器,

        将这个请求转给了第一台 Tomcat 服务器。

      • Tomcat 服务器接收到请求之后,要获取到会话对象 session。

        获取到会话对象 session 之后,要给浏览器响应数据,最终在给浏览器响应数据的时候,

        就会携带这么一个 cookie 的名字,就是 JSESSIONID ,下一次再请求的时候,

        是不是又会将 Cookie 携带到服务端?

      • 好。此时假如又执行了一次查询操作,要查询部门的数据。这次请求到达负载均衡服务器之后,

        负载均衡服务器将这次请求转给了第二台 Tomcat 服务器,此时他就要到第二台 Tomcat 服务器

        当中。根据 JSESSIONID 也就是对应的 session 的 ID 值,要找对应的 session 会话对象。

      • 我想请问在第二台服务器当中有没有这个 ID 的会话对象 Session, 是没有的。

        此时是不是就出现问题了?我同一个浏览器发起了 2 次请求,结果获取到的不是同一个会话对象,

        这就是 Session 这种会话跟踪方案它的缺点,在服务器集群环境下无法直接使用 Session。


    • 移动端 APP(Android、IOS) 中无法使用 Cookie

    • 用户可以自己禁用 Cookie

    • Cookie 不能跨域


📌 PS:Session 底层是基于Cookie实现的会话跟踪,如果Cookie不可用,则该方案,也就失效了。


会看到上面这两种传统的会话技术,在现在的企业开发当中是不是会存在很多的问题。

为了解决这些问题,在现在的企业开发当中,基本上都会采用第三种方案,通过 令牌技术 来进行会话跟踪。


16.2.2.2.3 令牌技术

这里我们所提到的令牌,其实它就是一个用户身份的标识,看似很高大上,很神秘,其实本质就是一个字符串。

72d82d05-1016-4110-bde4-54edce08f59c

如果通过令牌技术来跟踪会话,我们就可以在浏览器发起请求。

在请求登录接口的时候,如果登录成功,我就可以生成一个令牌,令牌就是用户的合法身份凭证。

接下来我在响应数据的时候,我就可以直接将令牌响应给前端。


接下来我们在前端程序当中接收到令牌之后,就需要将这个令牌存储起来。

这个存储可以存储在 cookie 当中,也可以存储在其他的存储空间 (比如:localStorage) 当中。


接下来,在后续的每一次请求当中,都需要将令牌携带到服务端。

携带到服务端之后,接下来我们就需要来校验令牌的有效性。

如果令牌是有效的,就说明用户已经执行了登录操作,如果令牌是无效的,就说明用户之前并未执行登录操作。


此时,如果是在同一次会话的多次请求之间,我们想共享数据,

我们就可以将共享的数据存储在令牌当中就可以了。


📌 优缺点:

  • 优点:
    • 支持 PC 端、移动端
    • 解决集群环境下的认证问题
    • 减轻服务器的存储压力(无需在服务器端存储)
  • 缺点:需要自己实现(包括令牌的生成、令牌的传递、令牌的校验)