ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot学情预警系统源码解析:从数据模型到预警引擎

Spring Boot学情预警系统源码解析:从数据模型到预警引擎

简介:该源码是基于Spring Boot与MyBatis Plus搭建的学生学情预警管理系统,面向Java后端开发者及正在开展课设、毕设项目的计算机专业学生,尤其适合需要复现完整业务闭环的学习场景。系统涵盖用户管理、配置管理、文件上传下载、成绩数据管理、教师管理、系统公告等模块,支持数据的增删改查、人脸比对与地理位置获取,并内置特定条件下的成绩提醒功能,可作为复杂管理系统设计思路的参考范本。资源包共381个文件,压缩后8.52MB,以Java源码、Vue前端页面和SVG图标资源为主,另有SQL脚本、配置文件和启动运行脚本;Java文件承载后端接口与业务逻辑,Vue文件对应管理端界面,SVG与PNG图片则补充界面表现层内容,整体前后端脉络清晰,便于对照阅读。压缩包附带数据库脚本及运行工具,可降低本地部署成本,帮助使用者快速进入项目状态;已有39人学习下载,对希望理解Spring Boot整合方案、模块拆分和预警规则实现的人而言,这份代码提供了一条可直接上手的实践路径。

1. 这个学情预警系统源码,解决的不只是“算成绩”

学生学情预警管理系统,听起来像教务处的内部工具,但真正拆开这份 Spring Boot 源码之后,我发现它的核心价值不在“算成绩”,而在“提前发现问题”。系统通过监控学生的课程成绩、学分完成度、挂科记录等维度,在成绩单还没出来之前,就自动把“有退学风险”“学业困难”的学生标记出来,推送给辅导员或教务管理员。对做 Java 课程设计、毕业设计,或者想了解 Spring Boot 单体应用完整链路的人来说,这份源码提供了一个能直接跑起来的参考实现,从数据库表结构到前端页面都是齐的。我拆完的感受是:它不像某些拿来凑数的项目,至少预警判定逻辑、角色权限、数据看板这几块是认真做了的,值得下载下来当底子改。

2. 从数据模型到权限控制:数据库设计里的六个关键点

2.1 学生、课程、成绩三张核心表的关联方式

任何一个学情系统,底层都绕不开“学生—课程—成绩”这三张表的关联设计。这份源码里没有用复杂的中间表绕来绕去,而是直接以成绩表作为关联中心:学生表存学号、姓名、班级、入学年份;课程表存课程编号、名称、学分、开课学期;成绩表则用 student_id 和 course_id 两个外键把它们串起来,同时记录成绩类型(正常考试、补考、重修)和分数。

CREATE TABLE t_score ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT '学生ID,关联 t_student', course_id BIGINT NOT NULL COMMENT '课程ID,关联 t_course', score DECIMAL(5,2) NOT NULL COMMENT '成绩分数', score_type TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常考试 2-补考 3-重修', exam_time DATETIME NOT NULL COMMENT '考试时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );

这里要注意 score_type 这个字段,很多初级项目会把补考、重修当成新记录直接插入,导致同一个人同一门课出现多条记录,预警统计时就会把挂科次数翻倍算。这份源码的处理方式是用 score_type 区分成绩性质,统计挂科时只取正常考试里分数低于 60 的记录,再单独看补考和重修是否通过。逻辑上更接近真实教务场景。

2.2 预警规则表的可配置设计

预警系统最怕的就是规则写死在代码里,学期一改就得到处找 if else。这份源码把预警规则抽成了独立的配置表 t_warning_rule,包括规则名称、阈值分数、连续挂科次数上限、学分绩点下限、是否启用等字段。管理员在后台改了规则,前端展示和定时任务立刻生效,不需要重启服务。

CREATE TABLE t_warning_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, rule_name VARCHAR(64) NOT NULL COMMENT '规则名称', min_score DECIMAL(5,2) NOT NULL COMMENT '单科成绩低于该值触发预警', max_fail_count INT NOT NULL COMMENT '累计挂科次数超过该值触发预警', min_gpa DECIMAL(4,2) NOT NULL COMMENT 'GPA低于该值触发预警', enabled TINYINT DEFAULT 1 COMMENT '1-启用 0-停用', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );

我一般做这类系统会再补一个优先级字段,比如 GPA 预警的权重比单科预警高,触发时优先展示。但这份源码里没有做加权排序,只是按时间倒序展示预警记录。作为课设项目可以接受,如果真要上生产,建议加一个 warning_level 字段区分提示、警告、严重三个等级。

2.3 角色权限:Spring Security 还是拦截器?

拆完源码,我发现它没有引入 Spring Security,而是用拦截器加注解的方式做的权限控制。这种做法在小型项目里很常见,也更容易让初学者看懂。系统内置了三种角色:管理员、辅导员、学生。管理员能配置规则和查看全部数据,辅导员只能看自己管辖班级的学生,学生登录后只能看自己的成绩和预警信息。

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect("/login"); return false; } // 检查注解要求的角色 HandlerMethod handlerMethod = (HandlerMethod) handler; RequireRole roleAnnotation = handlerMethod.getMethodAnnotation(RequireRole.class); if (roleAnnotation != null && !hasRole(loginUser, roleAnnotation.value())) { response.sendError(403, "无权访问"); return false; } return true; } }

这段拦截器的逻辑很直接:先判断是否登录,再判断方法上有没有 RequireRole 注解,有的话就比对当前用户角色。相比 Spring Security 的过滤器链和权限表达式,这种方式调试起来直观得多。实际用的时候,唯一要留意的是静态资源也要放行,不然登录页的 CSS 和 JS 会被拦截。

2.4 数据字典与班级维度的统计口径

预警系统如果只到学生个人维度,辅导员用起来是很痛苦的——一个辅导员带几百个学生,不可能每天逐个点开看。这份源码在统计模块里做了按班级聚合:班级列表页显示总人数、预警人数、挂科人数、平均 GPA,点进去能看到具体预警学生名单。班级信息挂在学生表上,而不是单独建班级表,简化了关联查询。

<select id="countWarningByClass" resultType="map"> SELECT c.class_name, COUNT(DISTINCT s.id) AS total_student, COUNT(DISTINCT CASE WHEN w.id IS NOT NULL THEN s.id END) AS warning_count FROM t_student s LEFT JOIN t_class c ON s.class_id = c.id LEFT JOIN t_warning_record w ON w.student_id = s.id AND w.status = 0 GROUP BY c.class_name </select>

这里用 LEFT JOIN 而不是 INNER JOIN,是为了把没有预警记录的正常学生也统计进来,保证 total_student 的数值完整。很多人在写这种统计 SQL 时会用错 JOIN 类型,结果导致班级总人数对不上,这里值得记一下。

2.5 MyBatis 多条件动态查询的写法参考

预警记录查询页支持按学号、姓名、班级、预警类型、处理状态筛选。源码用的是 MyBatis 的<if>标签做动态 SQL,没有用 QueryWrapper 那种 Lambda 写法。两种风格都行,但这套源码里 XML 的方式对新手理解 SQL 拼接逻辑更友好。

<select id="pageWarningRecords" resultType="com.example.entity.WarningRecordVO"> SELECT w.id, s.student_no, s.student_name, c.class_name, w.warning_type, w.score, w.status, w.create_time FROM t_warning_record w LEFT JOIN t_student s ON w.student_id = s.id LEFT JOIN t_class c ON s.class_id = c.id <where> <if test="studentNo != null and studentNo != ''"> AND s.student_no LIKE CONCAT('%', #{studentNo}, '%') </if> <if test="className != null and className != ''"> AND c.class_name = #{className} </if> <if test="warningType != null"> AND w.warning_type = #{warningType} </if> <if test="status != null"> AND w.status = #{status} </if> </where> ORDER BY w.create_time DESC </select>

参数说明:studentNo 是学号模糊查询,className 是精确匹配,warningType 和 status 是下拉框选择。这里有个小细节,学号用 LIKE 拼接时要注意 CONCAT 的位置,如果写成LIKE '%#{studentNo}%'会直接报错,MyBatis 的 #{} 占位符不能直接嵌在字符串里。换成${}又有 SQL 注入风险,源码里的 CONCAT 写法是对的。

3. 预警引擎:规则判定与跨学期 GPA 计算的具体实现

3.1 定时任务触发预警扫描

学情预警不能等辅导员手动点“开始计算”,那样时效性太差。源码里用了 Spring Boot 自带的@Scheduled注解做定时任务,每天晚上 23 点自动扫描一次所有学生的成绩数据,命中规则就生成预警记录。这个方案不需要额外引入 Quartz,Spring Boot 内置的调度器对单体应用完全够用。

@Component public class WarningScanTask { private final WarningService warningService; @Scheduled(cron = "0 0 23 * * ?") public void scanAllStudents() { List<Long> studentIds = warningService.getAllStudentIds(); for (Long studentId : studentIds) { try { warningService.evaluateStudent(studentId); } catch (Exception e) { log.error("扫描学生 {} 预警规则异常", studentId, e); } } } }

注意两个细节:第一,cron 表达式必须是六位或七位,写错了项目启动会报错;第二,循环里一定要做 try-catch,否则一个学生的数据异常会导致整批任务中断,后面所有学生都扫不到。这种批量任务里,单条失败不应该影响整体进度。

3.2 GPA 计算:学分加权平均

GPA 的计算方式直接影响预警判定结果。源码采用的是最常见的学分加权平均法:每门课的绩点乘以该课程学分,求和后除以总学分。课程绩点的换算规则是:90 分以上为 4.0,80-89 为 3.0,70-79 为 2.0,60-69 为 1.0,60 以下为 0。这个规则在真实教务系统里可能稍有差异,但作为框架代码很容易改。

public BigDecimal calculateGPA(List<ScoreVO> scores) { BigDecimal totalPoints = BigDecimal.ZERO; BigDecimal totalCredits = BigDecimal.ZERO; for (ScoreVO score : scores) { BigDecimal gpaPoint = convertToGpaPoint(score.getScore()); BigDecimal credit = score.getCourseCredit(); totalPoints = totalPoints.add(gpaPoint.multiply(credit)); totalCredits = totalCredits.add(credit); } if (totalCredits.compareTo(BigDecimal.ZERO) == 0) { return BigDecimal.ZERO; } return totalPoints.divide(totalCredits, 2, RoundingMode.HALF_UP); } private BigDecimal convertToGpaPoint(BigDecimal score) { if (score.compareTo(new BigDecimal("90")) >= 0) return new BigDecimal("4.0"); if (score.compareTo(new BigDecimal("80")) >= 0) return new BigDecimal("3.0"); if (score.compareTo(new BigDecimal("70")) >= 0) return new BigDecimal("2.0"); if (score.compareTo(new BigDecimal("60")) >= 0) return new BigDecimal("1.0"); return BigDecimal.ZERO; }

用 BigDecimal 而不是 double 计算 GPA 是个好习惯,double 的浮点误差在累加学分绩点时会导致最终结果偏差,比如 2.85 变成 2.8499999,界面展示时很尴尬。计算结果保留两位小数,四舍五入。另外跨学期计算时,要注意只统计已出成绩的课程,没出成绩的学期不算进分母,否则 GPA 会被拉低。

3.3 多规则命中时的预警合并策略

一个学生可能同时触发单科低分预警和 GPA 过低预警,这时候如果生成两条预警记录,辅导员处理起来就会觉得系统很“吵”。源码的做法是一个学生一天最多产生一条预警记录,但预警类型字段支持多值存储,用逗号分隔。

List<String> triggeredRules = new ArrayList<>(); if (minScoreRule != null && score.compareTo(minScoreRule.getMinScore()) < 0) { triggeredRules.add("SINGLE_SCORE_LOW"); } if (failCount > maxFailCount) { triggeredRules.add("TOO_MANY_FAILS"); } if (gpa.compareTo(minGpa) < 0) { triggeredRules.add("GPA_TOO_LOW"); } if (!triggeredRules.isEmpty()) { warningService.saveWarning(studentId, String.join(",", triggeredRules), score); }

合并存储的优势是查询简单,劣势是后续想按预警类型做统计分析时,需要做字符串拆分的处理。如果是我自己写,我会加一张预警明细表,用主记录 ID 关联多条类型记录,查询和统计都更灵活。但这份源码的方式胜在简单,适合课程设计答辩时口头讲清楚。

3.4 预警状态流转:已读、已处理、已解除

预警不能生成完就结束,还要有后续跟踪。源码里 t_warning_record 表设计了一个 status 字段:0-待处理,1-已处理,2-已解除。辅导员看到预警记录后,可以联系学生谈话,然后在系统里把状态改为“已处理”;如果学期结束时学生补考通过,GPA 恢复,定时任务会自动把之前的预警记录标记为“已解除”。

UPDATE t_warning_record SET status = 2, resolve_time = NOW() WHERE student_id = #{studentId} AND status = 0 AND create_time < #{semesterEndTime}

这个自动解除逻辑避免了手动维护的麻烦,但要注意前提条件是补考成绩已经录入系统。如果补考成绩还没录进去,GPA 计算时仍然按挂科处理,预警就不会自动解除。所以在实际使用中,成绩录入的及时性比预警判断本身更重要,这点需要在项目文档里跟使用者说清楚。

4. Web 端到定时任务:控制层、安全拦截器与 MyBatis 映射细节

4.1 控制层 RESTful 接口的命名规范

这套源码的控制层遵循了标准的 RESTful 风格,Controller 类名和方法的命名可以直接当范本用。几个典型接口:GET /api/student/{id}/scores查看学生成绩,POST /api/warning/process处理预警记录,GET /api/dashboard/stats获取首页统计卡片数据。参数用@RequestParam和@PathVariable接,返回结果统一封装成 Result 对象。

@RestController @RequestMapping("/api/warning") public class WarningController { private final WarningService warningService; @GetMapping("/list") public Result<PageResult<WarningRecordVO>> page( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String studentNo) { PageResult<WarningRecordVO> pageResult = warningService.pageQuery(pageNum, pageSize, studentNo); return Result.success(pageResult); } @PostMapping("/process") public Result<Void> process(@RequestBody ProcessWarningRequest request) { warningService.processWarning(request.getRecordId(), request.getRemark()); return Result.success(); } }

pageNum 从 1 开始,pageSize 默认 10,这两个参数是前端分页组件的标准配置。required = false 表示学号参数可选,不传就是查全量。这里的 Result.success() 统一返回 code=200,前端拦截器判断 code 不等于 200 就弹出错误提示。这个封装方式在中小型项目里很普遍,比直接返回 Map 要清晰。

4.2 登录会话管理:Session 还是 Token?

因为这套系统是传统的服务端渲染页面加上部分 Ajax 请求,所以登录状态用的是 Session 管理,而不是前后端分离项目里常见的 JWT Token。Session 方案在单体应用里有天然优势:后端可以直接控制会话失效,不需要考虑 Token 刷新和过期时间的问题。

@PostMapping("/login") public Result<Void> login(@RequestBody LoginRequest request, HttpSession session) { User user = userService.login(request.getUsername(), request.getPassword()); if (user == null) { return Result.error("用户名或密码错误"); } session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); return Result.success(); }

session.setMaxInactiveInterval 设置了 30 分钟超时,用户超过 30 分钟没操作就需要重新登录。这个数值可以根据实际使用场景调整,如果辅导员上午登录下午才处理预警,30 分钟可能太短,建议改成 60 分钟。当然,改长了也有安全风险,看具体使用环境决定。

4.3 文件上传:批量导入学生信息的实现

系统支持通过 Excel 批量导入学生名单,这是辅导员开学时的刚需。源码里用的是 Apache POI 解析 .xlsx 文件,上传后先读取到内存,逐行校验数据合法性(学号不能为空、班级必须存在),然后批量插入数据库。

@PostMapping("/import") public Result<Void> importStudents(MultipartFile file) { if (file.isEmpty()) { return Result.error("上传文件不能为空"); } try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) { Sheet sheet = workbook.getSheetAt(0); List<Student> students = new ArrayList<>(); for (Row row : sheet) { if (row.getRowNum() == 0) continue; // 跳过表头 String studentNo = row.getCell(0).getStringCellValue().trim(); String studentName = row.getCell(1).getStringCellValue().trim(); if (StringUtils.hasText(studentNo) && StringUtils.hasText(studentName)) { Student s = new Student(); s.setStudentNo(studentNo); s.setStudentName(studentName); students.add(s); } } int inserted = studentService.batchInsert(students); return Result.success("成功导入 " + inserted + " 条记录"); } catch (Exception e) { return Result.error("文件解析失败:" + e.getMessage()); } }

这里的 try-with-resources 写法保证 Workbook 用完后能自动关闭,避免文件句柄泄漏。实际使用时我建议再加个模板文件下载接口,让用户先下载标准模板,再往里面填数据,能省掉很大一部分“列对不上”的求助消息。

4.4 数据看板的统计 SQL:近期预警趋势与排行

首页数据看板是这套系统的门面,包含四个统计卡片:总学生数、今日预警数、待处理预警数、平均 GPA。下面还有最近一周预警趋势折线图和预警学生排行表。这些数据都来自聚合查询,源码里用了一个独立的 DashboardMapper 来管理这类统计 SQL。

SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) AS count FROM t_warning_record WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY day ORDER BY day

这种趋势查询的坑在于:如果某天没有预警记录,这一天的数据就查询不出来,前端折线图会出现断点。解决方法是前端补全日期,或者后端先查询出最近 7 天的日期列表再关联预警数据。源码里是前端做补全的,如果你要改成后端处理,需要注意时区问题,数据库和服务器时区不一致会导致按天分组的数据串位。

5. 本地跑通与常见问题:Spring Boot 2.6.x 下的部署踩坑记录

5.1 环境准备与启动参数说明

这份源码基于 Spring Boot 2.6.x 开发,JDK 要求 1.8 或 11。数据库用的 MySQL 5.7,同时也兼容 MySQL 8.0。导入 IDE 之后,需要先修改 application.yml 里的数据源配置,把数据库地址、账号、密码改成自己本地的。启动类直接运行 main 方法即可,默认端口是 8080。

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/student_warning?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true

url 里的 serverTimezone=Asia/Shanghai 必须加,不加的话 MySQL 8.0 驱动在连接时容易报 CST 时区错误。map-underscore-to-camel-case 把数据库的下划线字段自动映射成驼峰属性,这样 Java 实体类里的 studentName 不用写 @TableField 也能对应上数据库的 student_name 列。

5.2 常见问题一:启动时报“Failed to configure a DataSource”

这个报错最常见的原因不是配置写错,而是 Spring Boot 的自动配置在启动阶段就要初始化数据源,如果在 application.yml 里找不到数据源配置,或者配置了但连接不上,就会直接启动失败。现象是控制台一堆红字,最后一句报数据源初始化异常。

解决步骤:先确认本地 MySQL 服务已经启动,再用命令行mysql -u root -p123456试一下能不能连上。如果命令行能连,程序连不上,检查防火墙和数据库用户的主机权限。MySQL 的 root 用户默认只允许 localhost 连接,如果启动类在远程服务器上,需要把用户的主机改成 % 或创建一个新用户。

CREATE USER 'warning_app'@'%' IDENTIFIED BY 'your_password'; GRANT ALL PRIVILEGES ON student_warning.* TO 'warning_app'@'%'; FLUSH PRIVILEGES;

5.3 常见问题二:登录时中文乱码

登录页面输入中文用户名,提交后后台接收到的是一串问号。这个问题的根源是请求编码不对。Spring Boot 2.6 里默认的请求编码是 UTF-8,但如果 IDE 控制台或前端页面本身的编码不是 UTF-8,就会出现乱码。

解决办法分两步:第一,在 application.yml 里强制指定编码:

server: servlet: encoding: charset: UTF-8 enabled: true force: true

第二,检查数据库连接 url 里的 characterEncoding=utf8。这两步做完乱码问题基本能解决。注意 filter 的顺序,force 参数要设置成 true,否则已经设置过编码类型的请求不会被覆盖。

5.4 常见问题三:定时任务不执行

有的同学部署完成后,第二天来看没有生成任何预警记录,打开日志也没看到报错。排查思路是先确认@EnableScheduling注解有没有加在启动类上。这个注解经常被遗漏,遗漏之后@Scheduled的方法根本不会注册到任务调度器里。

@SpringBootApplication @EnableScheduling @MapperScan("com.example.mapper") public class WarningApplication { public static void main(String[] args) { SpringApplication.run(WarningApplication.class, args); } }

如果注解已经加了还是不执行,检查 cron 表达式。Spring 的 cron 和 Linux 的 crontab 语法不同,0 0 23 * * ?在 Spring 里表示每天 23 点执行,最后一位必须写 ? 而不是空的。写错表达式启动时不一定报错,但任务永远不会触发。最方便的办法是在配置里临时把 cron 改成0 */5 * * * ?(每 5 分钟执行一次),确认有日志输出后再改回来。

5.5 常见问题四:MyBatis 映射结果全为 null

查询接口通了,但返回的数据里对象不为 null,所有属性全是 null。这种情况十有八九是 application.yml 漏了map-underscore-to-camel-case: true。数据库字段是 student_no,Java 属性是 studentNo,MyBatis 默认不会自动做这个转换。

还有一种情况是实体类用了 Lombok 的 @Data 注解,但没有引入 Lombok 依赖,IDE 编译时也不报错,运行时 getter 方法不存在,导致 MyBatis 设置属性值时直接跳过。检查方式:看 target 目录下的 .class 文件里有没有 getStudentNo 方法。

6. 进阶改造:把单机预警做成可持续跟踪的学业状态机

6.1 给预警加上 485 通信式的状态机

这里想聊一个偏硬核的改造思路。这套系统的预警记录目前只有待处理、已处理、已解除三种状态,但在实际使用中,一次预警的完整生命周期远比这个复杂:辅导员联系学生是第一步,学生提交学业改进计划是第二步,学期结束后的成绩复核是第三步,如果复核不通过还要升级到院系层面。把预警状态改成状态机模型,每个状态绑定一个动作和下一个允许的状态,能让整个流程更严谨。

public enum WarningState { PENDING(0, "待处理"), CONTACTED(1, "已约谈"), PLAN_SUBMITTED(2, "改进计划已提交"), RESOLVED(3, "已解除"), ESCALATED(4, "已升级"); private final int code; private final String desc; }

这个改造建议放在毕业设计答辩的“系统扩展性”环节讲,能让评委觉得你不只是在复制功能,而是考虑了业务闭环。当然,只写一个枚举是不够的,需要配套一张状态流转表,记录每次变更的操作人、操作时间、备注内容,这样才能追溯整条处理链。

6.2 用一条 SQL 完成“连续 N 次挂科”的判断

原始预警规则里支持“单科挂科次数超过 N 次”的判断,但如果要判断“连续两个学期同一门课挂科”,SQL 的写法就有点讲究了。这里给出一个窗口函数方案,MySQL 8.0 和 Spring Boot 2.6 配合使用没有问题:

SELECT student_id, course_id, COUNT(*) AS fail_times FROM ( SELECT student_id, course_id, semester, ROW_NUMBER() OVER (PARTITION BY student_id, course_id ORDER BY semester) AS rn FROM t_score WHERE score < 60 AND score_type = 1 ) t GROUP BY student_id, course_id HAVING COUNT(*) >= 2

内层子查询给每个学生的每门课按学期排序,外层统计总数。这里有一个细节:如果学生某个学期没有选这门课,那就不算连续挂科,但这份源码的成绩表里没有 semester 字段,只有 exam_time,所以严格来说要按考试时间跨学期判断,需要再加工一层。作为改进建议,可以在表结构里把 semester 字段补上。

6.3 预警通知与数据导出的实用扩展

最后给一个能直接用的技巧:把预警记录导出成 Excel。源码自带的导出是 CSV 格式,用 Excel 打开会出现中文乱码问题,原因是 CSV 默认用系统编码。改成用 POI 写 .xlsx 是最稳妥的:

try (Workbook workbook = new XSSFWorkbook(); OutputStream os = response.getOutputStream()) { Sheet sheet = workbook.createSheet("预警记录"); Row header = sheet.createRow(0); header.createCell(0).setCellValue("学号"); header.createCell(1).setCellValue("姓名"); header.createCell(2).setCellValue("预警类型"); header.createCell(3).setCellValue("状态"); // 填充数据... workbook.write(os); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=warning_records.xlsx"); }

contentType 一定要设置成 xlsx 对应的 MIME 类型,否则浏览器可能直接把文件打开成乱码。Content-Disposition 里的 filename 用英文,如果写中文文件名的 URL 编码处理很麻烦,不建议折腾。

6.4 我踩过的坑:预警记录误删后的恢复办法

有一次我在测试环境手动删了几条预警记录,后来发现数据没备份,恢复不回来。从那以后我养成一个习惯:在任何预警系统里,都不提供物理删除的接口,只做逻辑删除——加一个 deleted 字段,查询时默认过滤掉,管理员在回收站里还能看到。这个改动成本很低,但能避免很多“删了才发现不该删”的尴尬时刻。如果这台系统已经上了生产环境,你至少要保证每天凌晨自动备份 t_warning_record 表的数据,一条 mysqldump 命令的事,别等出问题再后悔。希望这篇拆解对你调通这套系统、或者拿它做二次开发时有实际帮助。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进