独立实战:学员管理
Day14:实现学员管理、违纪处理与统计报表,梳理接口参数、动态 SQL 和业务校验。
继续上次小节 →JavaWeb 线 · 线路目录
JavaWeb 线
课程介绍与前端基础4 站
Tlias 后端接口实战6 站
认证、AOP 与框架原理3 站
工程化、前端案例与部署6 站
本页导读
独立实战:学员管理
前面已经完成了班级管理,接下来完成学员管理、违纪处理和学员统计。参照 Day09-后端Web实战(部门管理开发),每个功能按照需求分析、思路分析、代码实现展开。
使用已有 Student、Result 和 PageBean;查询条件通过方法形参接收。统计按照讲义里的 Map 集合方式返回数据,沿用原有 Controller、Service、Mapper 分层,不增加额外的响应实体、异常类或校验工具。
各节方法分别增加到对应的类、接口和 XML 中。参考代码尚未在你的数据库中联调,功能测试部分是完成代码后需要执行的检查。
0. 开发准备
0.1 接口总览
| 功能 | 方法 | 路径 | 参数位置 |
|---|---|---|---|
| 条件分页查询 | GET | /students | 查询参数 |
| 全部班级下拉框 | GET | /clazzs/list | 无,复用 Day13 |
| 新增学员 | POST | /students | JSON |
| 回显 | GET | /students/{id} | 路径参数 |
| 修改 | PUT | /students | JSON |
| 批量删除 | DELETE | /students/{ids} | 路径中的逗号分隔 ID |
| 违纪处理 | PUT | /students/violation/{id}/{score} | 两个路径参数 |
| 班级人数统计 | GET | /report/studentCountData | 无 |
| 学历统计 | GET | /report/studentDegreeData | 无 |
0.2 表与实体
学员表为 student,使用已有 Student 实体,班级关联字段为 clazz_id。clazzName 是列表展示的班级名称,不对应学员表中的列。
no、phone、id_card 有唯一约束。新增时补充创建、修改时间,违纪次数和累计扣分使用数据库默认值 0。
注意:接口文档把身份证和是否院校字段标为选填,而课程建表脚本要求非空。按课程原表练习时,新增请求填写这两个字段即可;本篇不要求额外改表。
0.3 三层结构
和部门管理相同,Controller 接收请求,Service 处理业务,Mapper 执行 SQL。保留配置 mybatis.configuration.map-underscore-to-camel-case: true。数据库执行失败时,使用项目中原有的全局异常处理器。
下面先准备基本结构,各节的方法分别放进对应的类中。
// StudentController.java
package com.ziggy.controller;
import com.ziggy.entity.*;
import com.ziggy.service.StudentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/students")
public class StudentController {
@Autowired
private StudentService studentService;
// 放入下面各节的 Controller 方法
}
// StudentService.java:完整接口
package com.ziggy.service;
import com.ziggy.entity.*;
public interface StudentService {
PageBean page(String name, Integer degree, Integer clazzId, Integer page, Integer pageSize);
void save(Student student);
Student getInfo(Integer id);
void update(Student student);
void delete(Integer[] ids);
void violation(Integer id, Integer score);
}
// StudentServiceImpl.java
package com.ziggy.service.Impl;
import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.ziggy.entity.*;
import com.ziggy.mapper.StudentMapper;
import com.ziggy.service.StudentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class StudentServiceImpl implements StudentService {
@Autowired
private StudentMapper studentMapper;
// 放入下面各节的 Service 方法
}
// StudentMapper.java:包含统计查询,完整接口
package com.ziggy.mapper;
import com.ziggy.entity.*;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface StudentMapper {
List<Student> page(@Param("name") String name,
@Param("degree") Integer degree,
@Param("clazzId") Integer clazzId);
void save(Student student);
Student getInfo(@Param("id") Integer id);
void update(Student student);
void delete(@Param("ids") Integer[] ids);
void violation(@Param("id") Integer id, @Param("score") Integer score);
List<Map<String, Object>> studentCountData();
List<Map<String, Object>> studentDegreeData();
}
统计方法使用 java.util.Map,与讲义中的性别统计写法相同。Mapper XML 放在 src/main/resources/com/ziggy/mapper/StudentMapper.xml,把后续各节 SQL 合并到下列根标签内:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ziggy.mapper.StudentMapper">
<!-- 合并各节 SQL,同一个 id 只保留一份 -->
</mapper>
1. 学员列表条件分页查询
对应接口文档:4.1 学员列表查询(4.1.1 基本信息、4.1.2 请求参数、4.1.3 响应数据)
1.1 需求分析
打开学员列表时,按姓名、学历、班级筛选并分页。请求示例:
GET /students?name=张°ree=4&clazzId=1&page=1&pageSize=5
响应 data 是 { "total": ..., "rows": [...] },每名学员额外带 clazzName。
1.2 思路分析
Controller 接收查询参数;Service 启动分页并组装 PageBean;Mapper 使用 left join clazz 查询班级名称。姓名做模糊匹配,学历和班级 ID 做精确匹配。
1.3 代码实现
Controller 直接接收姓名、学历、班级 ID 和分页参数。
1. Controller 层
@GetMapping
public Result page(String name, Integer degree, Integer clazzId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize) {
PageBean pageBean = studentService.page(name, degree, clazzId, page, pageSize);
return Result.success(pageBean);
}
2. Service 层
@Override
public PageBean page(String name, Integer degree, Integer clazzId,
Integer page, Integer pageSize) {
PageHelper.startPage(page, pageSize);
List<Student> list = studentMapper.page(name, degree, clazzId);
Page<Student> p = (Page<Student>) list;
return new PageBean(p.getTotal(), p.getResult());
}
3. Mapper XML
<select id="page" resultType="com.ziggy.entity.Student">
select s.*, c.name as 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, s.id desc
</select>
1.4 功能测试
degree、clazzId是数字,只与 null 比较,不与''比较。- 没有筛选条件时正常分页;多个条件同时传入时使用 AND 组合。
- 传入不存在的姓名应返回空数组,不应报异常。
- 一名学员只对应一个班级,这里没有一对多集合,不需要
<collection>。
2. 批量删除学员
对应接口文档:4.2 删除学员(4.2.1 基本信息、4.2.2 请求参数、4.2.3 响应数据)
2.1 需求分析
勾选一名或多名学员,发送:
DELETE /students/1,2,3
这是路径参数,不是员工模块的 DELETE /emps?ids=1,2,3。一个 ID 和多个 ID 可以用同一个接口。
2.2 思路分析
Controller 用 @PathVariable 将逗号分隔内容绑定为数组;Service 调用 Mapper;Mapper 用 <foreach> 拼接 IN (...)。
2.3 代码实现
1. Controller 层
@DeleteMapping("/{ids}")
public Result delete(@PathVariable("ids") Integer[] ids) {
studentService.delete(ids);
return Result.success();
}
2. Service 层
@Override
public void delete(Integer[] ids) {
studentMapper.delete(ids);
}
3. Mapper XML
<delete id="delete">
delete from student where id in
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</delete>
2.4 功能测试
- Mapper 接口已用
@Param("ids")明确参数名,所以 XML 的collection写ids。 item="id"是每轮元素的临时变量,循环内用#{id}。- 必须有
open="("和close=")";否则拼出in 1,2,3,SQL 语法错误。 - 使用你自己新增的练习记录测试;既查删除结果,也检查其他学员未受影响。
3. 新增学员
对应接口文档:4.3 添加学员(4.3.1 基本信息、4.3.2 请求参数、4.3.3 响应数据)
3.1 需求分析
发送 POST /students,JSON 请求体提交学员基本资料。下面是满足课程原始表非空约束的完整演示数据,只用于练习,不代表真实个人信息;班级 ID 要替换为本地存在的 ID。
{
"name": "测试学员", "no": "2026101901", "gender": 1,
"phone": "13800000001", "idCard": "110101200001010001",
"isCollege": 0, "address": "练习地址", "degree": 4,
"graduationDate": "2026-06-30", "clazzId": 1
}
3.2 思路分析
和新增部门相同,Controller 用 @RequestBody 接收 JSON 对象,Service 补充创建时间和修改时间,Mapper 插入数据库。违纪字段不写入 INSERT,使用表中定义的默认值 0。
3.3 代码实现
1. Controller 层
@PostMapping
public Result save(@RequestBody Student student) {
studentService.save(student);
return Result.success();
}
2. Service 层
@Override
public void save(Student student) {
student.setCreateTime(LocalDateTime.now());
student.setUpdateTime(LocalDateTime.now());
studentMapper.save(student);
}
3. Mapper XML
<insert id="save" useGeneratedKeys="true" keyProperty="id">
insert into student
(name, no, gender, phone, id_card, is_college, address,
degree, graduation_date, clazz_id, create_time, update_time)
values
(#{name}, #{no}, #{gender}, #{phone}, #{idCard}, #{isCollege},
#{address}, #{degree}, #{graduationDate}, #{clazzId},
#{createTime}, #{updateTime})
</insert>
3.4 功能测试
新增一名学员,检查列表中能否查到,违纪次数和扣分是否为 0。使用不同的学号、手机号、身份证号,避免触发唯一约束。SQL 列名使用 id_card、clazz_id,占位符中使用对象属性 #{idCard}、#{clazzId}。
INSERT 中没有列出违纪字段,数据库才会使用默认值;显式插入 null 不等于使用默认值。
4. 修改学员:查询回显
对应接口文档:4.4 根据ID查询(4.4.1 基本信息、4.4.2 请求参数、4.4.3 响应数据)
4.1 需求分析
点击编辑,发送 GET /students/{id},拿到学员对象后填入表单。
4.2 思路分析
路径参数 ID → Service 调用 Mapper → select * from student where id = ...。返回 clazzId 用于选择班级,graduationDate 用于日期控件,不需要先查中文班级名才能选中下拉框。
4.3 代码实现
1. Controller 层
@GetMapping("/{id}")
public Result getInfo(@PathVariable("id") Integer id) {
return Result.success(studentService.getInfo(id));
}
2. Service 层
@Override
public Student getInfo(Integer id) {
return studentMapper.getInfo(id);
}
3. Mapper XML
<select id="getInfo" resultType="com.ziggy.entity.Student">
select * from student where id = #{id}
</select>
4.4 功能测试
- 接口 data 为一个对象,不是数组,也不是
PageBean。 clazz_id、graduation_date的回显依赖驼峰映射;响应有值而页面无值,再查前端绑定。- 检查违纪次数和扣分也被回显,因为课程修改接口会带回这两个字段。
5. 修改学员:保存数据
对应接口文档:4.5 修改学员(4.5.1 基本信息、4.5.2 请求参数、4.5.3 响应数据)
5.1 需求分析
编辑完成后,发送 PUT /students,JSON 携带 id 和表单字段。课程接口文档还要求传回 violationCount、violationScore。
5.2 思路分析
和修改部门相同,Controller 接收 JSON,Service 刷新修改时间,Mapper 用 <set>、<if> 动态更新。创建时间不需要修改。
5.3 代码实现
1. Controller 层
@PutMapping
public Result update(@RequestBody Student student) {
studentService.update(student);
return Result.success();
}
2. Service 层
@Override
public void update(Student student) {
student.setUpdateTime(LocalDateTime.now());
studentMapper.update(student);
}
3. Mapper XML
<update id="update">
update student
<set>
<if test="name != null and name != ''">
name = #{name},
</if>
<if test="no != null and no != ''">
no = #{no},
</if>
<if test="gender != null">
gender = #{gender},
</if>
<if test="phone != null and phone != ''">
phone = #{phone},
</if>
<if test="idCard != null and idCard != ''">
id_card = #{idCard},
</if>
<if test="isCollege != null">
is_college = #{isCollege},
</if>
<if test="address != null and address != ''">
address = #{address},
</if>
<if test="degree != null">
degree = #{degree},
</if>
<if test="graduationDate != null">
graduation_date = #{graduationDate},
</if>
<if test="clazzId != null">
clazz_id = #{clazzId},
</if>
<if test="violationCount != null">
violation_count = #{violationCount},
</if>
<if test="violationScore != null">
violation_score = #{violationScore},
</if>
<if test="updateTime != null">
update_time = #{updateTime},
</if>
</set>
where id = #{id}
</update>
5.4 功能测试
- 先 GET 回显,再 PUT 保存;本节按讲义用动态 SQL,未传字段不参与更新。
- 保存后重新打开编辑窗口,检查姓名、毕业日期和所属班级;确认创建时间未变化。
- 这不是员工“基本信息 + 工作经历”跨表更新,当前只有一条写入 SQL,不需要照搬员工经历的先删后增。
- 动态 SQL 若使用
<if>,字符串判空写!= '',不是!= ' ';后者可能触发 OGNL 类型转换。
6. 违纪处理
对应接口文档:4.6 违纪处理(4.6.1 基本信息、4.6.2 请求参数、4.6.3 响应数据)
6.1 需求分析
对学员记录一次违纪并扣分,例如 PUT /students/violation/1/5:违纪次数加 1,累计扣分加 5。
6.2 思路分析
Controller 接收两个路径参数,通过 Service 调用 Mapper。Mapper 用一条 UPDATE 完成违纪次数加 1、累计扣分加 score。不要先查旧值,再在 Java 中相加后覆盖数据库。
6.3 代码实现
1. Controller 层
@PutMapping("/violation/{id}/{score}")
public Result violation(@PathVariable("id") Integer id,
@PathVariable("score") Integer score) {
studentService.violation(id, score);
return Result.success();
}
2. Service 层
@Override
public void violation(Integer id, Integer score) {
studentMapper.violation(id, score);
}
3. Mapper XML
<update id="violation">
update student
set violation_count = violation_count + 1,
violation_score = violation_score + #{score},
update_time = now()
where id = #{id}
</update>
6.4 功能测试
原值为次数 2、扣分 7,调用 /students/violation/1/5 后应为次数 3、扣分 12,而不是把扣分直接改成 5。
课程脚本的计数字段是 tinyint unsigned,练习使用正常范围内的正分数;这里保持课堂的一条 SQL 累加写法。
7. 附加接口:查询班级下拉框
对应接口文档:3.6 查询所有班级(3.6.1 基本信息、3.6.2 请求参数、3.6.3 响应数据;复用 Day13 接口)
7.1 需求分析
新增、修改和筛选学员时需要班级选项,前端调用 GET /clazzs/list。
7.2 思路分析
复用 Day13 的全部班级接口,不在 StudentController 中再声明一份。下拉框文字使用班级 name,选中值使用班级 id,保存到学员对象的 clazzId。
7.3 代码实现
1. 前后端字段对应关系
GET /clazzs/list
→ data: [{id: 1, name: "Java参考班"}, ...]
→ 下拉框 label = name,value = id
→ 学员 JSON: {"clazzId": 1, ...}
7.4 功能测试
- 下拉框返回的是完整数组,不能把
GET /clazzs的当前一页当作所有班级。 - 回显中
clazzId与选项 ID 的类型要一致,不要一边数字、一边字符串。
8. 学员统计:准备工作
和 Day12 的员工统计相同,Controller 调用 ReportService,Service 调用 Mapper,Mapper 使用 Map 接收统计结果。
| 接口 | data 结构 |
|---|---|
| 班级人数统计 | {"clazzList":["A班","B班"],"dataList":[2,0]} |
| 学历统计 | [{"name":"本科","value":2}] |
本篇不创建统计响应实体:柱状图的两个数组放进一个 Map,饼图直接返回 List<Map>。Map 是 Java 集合;MyBatis 的 resultMap 则是另一种结果映射配置,不能混淆。
1. ReportService 接口
package com.ziggy.service;
import java.util.List;
import java.util.Map;
public interface ReportService {
Map<String, Object> studentCountData();
List<Map<String, Object>> studentDegreeData();
}
2. ReportServiceImpl 基本结构
package com.ziggy.service.Impl;
import com.ziggy.mapper.StudentMapper;
import com.ziggy.service.ReportService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class ReportServiceImpl implements ReportService {
@Autowired
private StudentMapper studentMapper;
// 增加第9、10节的实现方法
}
3. ReportController 基本结构
package com.ziggy.controller;
import com.ziggy.entity.Result;
import com.ziggy.service.ReportService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/report")
public class ReportController {
@Autowired
private ReportService reportService;
// 增加第9、10节的 Controller 方法
}
如果已经写过员工统计,把方法加入现有 ReportController、ReportService 和实现类,保留原方法,不要再创建一套重复类。StudentMapper 的统计方法已在第0节接口中列出。
9. 班级人数统计
对应接口文档:5.4 班级人数统计(5.4.1 基本信息、5.4.2 请求参数、5.4.3 响应数据)
9.1 需求分析
请求 GET /report/studentCountData,返回班级名称数组和对应人数数组,两个数组的下标必须一一对应。没有学员的班级也应显示,人数为 0。
9.2 思路分析
以班级表作为左表,关联学员表;用 count(s.id) 统计真实学生,而不是 count(*)。按班级 ID 和名称分组,固定按班级 ID 排序。Service 参照讲义使用 Stream 分别取出班级名称列表和人数列表,再封装为 Map 返回。
9.3 代码实现
1. ReportController 层
@GetMapping("/studentCountData")
public Result studentCountData() {
return Result.success(reportService.studentCountData());
}
2. ReportServiceImpl 层
@Override
public Map<String, Object> studentCountData() {
List<Map<String, Object>> list = studentMapper.studentCountData();
List<Object> clazzList = list.stream().map(map -> map.get("name")).toList();
List<Object> dataList = list.stream().map(map -> map.get("value")).toList();
Map<String, Object> data = new HashMap<>();
data.put("clazzList", clazzList);
data.put("dataList", dataList);
return data;
}
3. StudentMapper XML
<select id="studentCountData" resultType="java.util.Map">
select c.name as name, count(s.id) as value
from clazz c
left join student s on s.clazz_id = c.id
group by c.id, c.name
order by c.id
</select>
9.4 功能测试
- 空班级被 left join 补出一行,
count(*)会错误地算成 1;count(s.id)才是 0。 - 两个数组长度相同;同一位置的名字和人数对应同一个班级。
- 完全没有班级时返回两个空数组,不返回 null。
- 若存在引用了不存在班级的历史学员,这个“按有效班级统计”不会计入他们,需另行修复孤儿关联数据。
10. 学员学历统计
对应接口文档:5.3 学员学历统计(5.3.1 基本信息、5.3.2 请求参数、5.3.3 响应数据)
10.1 需求分析
请求 GET /report/studentDegreeData,返回对象数组,每项是学历文案和人数:[{"name":"本科","value":2}]。
10.2 思路分析
按 degree 分组统计人数,使用 CASE 将数字翻译为中文。没有学生的学历不需要补零;其他学历值按课堂 CASE 写法显示为“其他”。
10.3 代码实现
1. ReportController 层
@GetMapping("/studentDegreeData")
public Result studentDegreeData() {
return Result.success(reportService.studentDegreeData());
}
2. ReportServiceImpl 层
@Override
public List<Map<String, Object>> studentDegreeData() {
return studentMapper.studentDegreeData();
}
3. StudentMapper XML
<select id="studentDegreeData" 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 as name,
count(*) as value
from student
group by degree
order by degree
</select>
10.4 功能测试
- 响应字段必须叫
name和value,不要返回 degree/count 后让前端猜字段。 - 新增一名本科学生后,本科人数应加 1;删除该学生后应减 1。
- 所有学历人数之和应等于 student 表总人数。
resultType="java.util.Map"将每行查询结果封装成 Map,SQL 别名 name、value 就是 Map 的键。
11. 联调顺序与自查
按部门管理相同的方式,写完一个接口就检查该接口的输入与输出,再串联流程。
- 查询学员列表,检查默认分页、姓名/学历/班级组合筛选。
- 查询全部班级,确认下拉框 value 对应
clazzId。 - 用不重复的学号、手机号和身份证新增练习学生。
- 查询回显,检查毕业日期和班级 ID。
- 修改并重新打开表单,确认保存后的数据。
- 连续记录两次违纪,核对次数和累计扣分。
- 检查班级人数、学历统计,再批量删除练习记录,观察图表人数变化。
- 回到 Day13 验证:有学员的班级不能删;删完其学员后才允许删。
| 现象 | 优先检查 |
|---|---|
| 405 / DELETE 不支持 | 类和方法的映射路径是否匹配前端 |
| 回显日期或班级为 null | 表列有无值、驼峰映射、返回字段名 |
| NumberFormatException 出现在 OGNL | 是否把 '' 写成 ' '、是否拿数字与字符串比较 |
| Unknown column | SQL 左边用了 Java 属性名或拼错列名 |
| 批量删除 SQL 报错 | 数组是否传到 Mapper、@Param 名称、foreach 括号 |
| 新增时非空约束失败 | 身份证等字段与课程脚本、接口是否一致 |
| 前端只有“出错了” | 查看已有全局异常处理器和控制台的具体异常 |
| 柱状图不能展示 | 是否误把对象数组作为 clazzList/dataList 返回 |