Spring Boot教学管理系统源码:登录鉴权与作业批阅
简介:这套源码对应一个基于Web的操作系统课程教学网站,面向计算机相关专业的毕业设计与课程设计,也适合刚接触Java Web、需要完整项目练手的开发者。系统涵盖课程讲义、视频教程、在线测试、答疑解惑、作业提交与成绩查询等模块,并配有成绩录入、查询与统计的管理逻辑;服务端用Java处理请求并与数据库交互,前端由JSP页面承载,可据此理清教学平台的业务链路。压缩包共502个文件、约8.57MB,以125个jsp页面为主要视图,183个gif与10个jpg提供界面素材,另有java与class文件、css与js脚本、db数据库、jar依赖以及11个doc文档,bak备份保留了改动痕迹。目前已有38人学习。文档部分说明了设计思路、系统架构、功能模块与使用方法,配合源码便于对照理解分层结构与数据表设计,可作为答辩演示、二次开发或功能扩展的基础。
1. 从三十份作业压缩包说起:这套源码到底能干什么
期末周收操作系统课的实验报告,邮箱里躺着三十多份压缩包,命名从「实验三-final-真的最终版」到「新建文件夹(2)」应有尽有,批阅记录散在聊天窗口和 Excel 里,想查谁没交得翻三个地方。这套 Java 毕业设计源码解决的就是这件事——把课程通知、课件资料、章节实验、作业提交与批阅搬到同一个 Web 站点上,学生登录看资料交作业,教师登录发通知打分。技术上是标准的 Spring Boot 单体工程,MVC 三层分得清楚,表结构围绕「课程—章节—资料—作业」四条主线铺开,没有微服务、没有中间件依赖,一台装了 JDK 和 MySQL 的机器就能跑起来。适合两类人:一类是要交课程设计、需要一份结构完整可讲的底稿;另一类是想拿现成骨架改成自己学院真在用的教学辅助站。
2. 技术栈与目录结构:一套 Spring Boot 单体工程的骨架
拿到源码先别急着点运行,先看 pom 和包结构。这类课程教学站的体量决定了它不需要花哨架构,一个进程扛住全部请求是最省心的选择——真按微服务拆,学生端、教师端、文件服务各起一个进程,答辩时讲不清收益,部署时还要多开三个端口,属于典型的过度设计。
2.1 依赖与版本组合
核心依赖就六七个,全部在pom.xml里能一眼看完。Spring Boot 2.7.x 是这类毕设的主流选择,它对 JDK 8 和 11 都友好,网上排错资料也最多。ORM 层用的是 MyBatis-Plus,省略了单表 CRUD 的 XML,复杂查询仍然可以回退到 XML 里写,属于「够用且不锁死」。模板引擎选 Thymeleaf 而不是 JSP,原因很实在:JSP 在 Spring Boot 内嵌 Tomcat 下打包成 jar 会有路径问题,很多同学在这上面栽过跟头。
<!-- pom.xml 关键依赖节选 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <!-- Web 容器 + MVC,站点的主体 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf 渲染服务端页面,直接放 templates 目录即可 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- MyBatis-Plus 启动器,单表查询不用写 XML --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <!-- MySQL 8 驱动,注意版本要和本地服务端对齐 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- Lombok,省掉 getter/setter 样板代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- 密码加密只用到 BCrypt 一段,spring-security-crypto 是轻量引入方式 --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> </dependency> </dependencies>这里有个取舍值得说清楚:项目没有引入spring-boot-starter-security全家桶。整套安全框架一旦开起来,默认会拦截所有请求并跳一个自带登录页,对毕设这种「自己写登录页 + 自己写拦截器」的模式反而是负担。只引spring-security-crypto拿 BCrypt 做密码哈希,是常见做法,既能避免明文存库这个硬伤,又不会让请求链路变复杂。
版本组合上还有一点:mysql-connector-j是 MySQL 8 之后的新坐标,老代码里写的是mysql:mysql-connector-java。如果你的 IDE 报「找不到依赖」,八成是坐标和驱动版本对不上,把 URL 里的serverTimezone一并配上就能解决。
2.2 包结构与分层
工程是标准三层:controller收请求、service放业务、mapper碰数据库,entity与表一一对应,config和interceptor承载横切逻辑。这个划分不新鲜,但它的好处是答辩时能顺着一条请求讲完整条链路,改功能时也知道该动哪一层。
src/main/java/com/example/course ├── CourseApplication.java # 启动类,@MapperScan 在这里声明 ├── config │ ├── WebConfig.java # 注册拦截器、静态资源映射 │ └── MybatisPlusConfig.java # 分页插件 ├── interceptor │ └── LoginInterceptor.java # 登录态 + 角色校验 ├── controller │ ├── LoginController.java # 登录、注销、注册 │ ├── CourseController.java # 课程列表、详情 │ ├── MaterialController.java # 课件上传下载 │ ├── HomeworkController.java # 作业发布、提交、批阅 │ └── NoticeController.java # 通知公告 ├── service │ ├── impl # 业务实现,接口与实现分离 ├── mapper │ └── *.java # 继承 BaseMapper,复杂 SQL 走 resources/mapper/*.xml ├── entity │ └── User.java / Course.java / Homework.java ... └── common ├── Result.java # 统一返回体 └── RoleEnum.java # STUDENT / TEACHER / ADMIN src/main/resources ├── application.yml # 数据源、上传路径、模板配置 ├── mapper/*.xml # 手写 SQL ├── templates # Thymeleaf 页面,按角色分目录 │ ├── login.html │ ├── student/ teacher/ admin/ └── static # css / js / img,以及本地上传目录templates下按角色分目录这一点很实用。学生端和教师端页面布局差异大,混在一个目录里后期加功能会互相影响;分开放之后,同一个 Controller 方法返回"student/homework-list"还是"teacher/homework-list",视图路径一目了然。
common/Result.java是统一返回体,前后端约定code / msg / data三字段。页面渲染走 Thymeleaf 时用不到它,但项目里那些异步接口(比如上传进度、作业列表刷新)全靠它,保持结构一致能省掉大量前端判断。
2.3 数据库表设计
表不多,八张就能把业务闭环撑起来。设计上有两个地方值得注意:作业和提交记录必须拆成两张表,一张存教师布置的题目与截止时间,一张存学生每次提交的文件与得分;课件资料和课程之间用外键式的course_id关联,不做级联删除,删除课程时手动标记资料失效,避免误删文件。
-- 用户表:一个账号一个角色,够用且简单 CREATE TABLE `sys_user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名/学号', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt 哈希,不存明文', `real_name` VARCHAR(50) NOT NULL, `role` VARCHAR(20) NOT NULL DEFAULT 'STUDENT' COMMENT 'STUDENT/TEACHER/ADMIN', `class_name` VARCHAR(50) DEFAULT NULL COMMENT '学生所在班级', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表 CREATE TABLE `course` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `course_name` VARCHAR(100) NOT NULL, `teacher_id` BIGINT NOT NULL COMMENT '关联 sys_user.id', `term` VARCHAR(30) NOT NULL COMMENT '如 2024-2025-1', `intro` VARCHAR(500) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 章节表:操作系统课的「进程管理」「内存管理」「文件系统」都挂这里 CREATE TABLE `chapter` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `course_id` BIGINT NOT NULL, `title` VARCHAR(100) NOT NULL, `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 作业表:教师发布,带截止时间 CREATE TABLE `homework` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `chapter_id` BIGINT NOT NULL, `title` VARCHAR(100) NOT NULL, `content` TEXT COMMENT '题目描述', `deadline` DATETIME NOT NULL, `create_by` BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 提交表:一个学生对一份作业只保留一条有效记录,重复提交覆盖 CREATE TABLE `homework_submit` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `homework_id` BIGINT NOT NULL, `student_id` BIGINT NOT NULL, `file_path` VARCHAR(255) NOT NULL COMMENT '相对路径,不存绝对路径', `file_name` VARCHAR(255) NOT NULL COMMENT '原始文件名,下载时还原', `submit_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `status` VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED' COMMENT 'SUBMITTED/GRADED', `score` INT DEFAULT NULL, `comment` VARCHAR(500) DEFAULT NULL, UNIQUE KEY `uk_hw_stu` (`homework_id`, `student_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;file_path只存相对路径是个血泪经验。早期版本存的是绝对路径,本地开发时是D:/upload/xxx.zip,换到另一台机器直接失效,部署到服务器更是全军覆没。改成存2025/06/uuid.zip这种相对路径,读取时再拼上配置里的根目录,迁移只要改一行配置。
uk_hw_stu这个唯一索引也值得留意。它让「重复提交」在数据库层面就变成了一次 upsert,不会出现同一个学生交了三份、教师批阅时挑花了眼的情况。
3. 核心链路的代码拆解:从登录鉴权到作业批阅
看懂表结构之后,接下来顺着一条真实请求走一遍。教学站的链路其实就三条主干:登录进来、把课件传上去、把作业交上去再打分。这三条串起来,整个项目的代码量也就消化掉一半了。
3.1 登录态与角色拦截器
登录逻辑本身简单,难的是「谁在什么地方能被拦住」。项目用的是 Session + HandlerInterceptor 的方案,没有引 JWT,原因很直白:这是个服务端渲染的多页应用,页面跳转靠的是浏览器跳转而不是 ajax,Session 天然合适,用 Token 反而要在每个模板里塞隐藏字段。
登录接口把用户放进 Session,密码校验走 BCrypt:
@PostMapping("/doLogin") public String doLogin(String username, String password, HttpSession session, Model model) { User user = userService.lambdaQuery() .eq(User::getUsername, username) .one(); // 用户不存在和密码错误统一提示,避免被枚举出哪些学号已注册 if (user == null || !passwordEncoder.matches(password, user.getPassword())) { model.addAttribute("error", "账号或密码错误"); return "login"; } // 只放必要字段进 Session,整个 User 对象含密码哈希,能不放就不放 session.setAttribute("loginUser", user); session.setMaxInactiveInterval(60 * 60 * 2); // 两小时无操作自动失效 return "redirect:/index"; }passwordEncoder.matches()是 BCrypt 的匹配方法,入参顺序是「明文、哈希」,写反了永远返回 false,这个坑不少人踩过。另外把整个User放进 Session 时记得在toString里屏蔽密码字段,否则打日志时会原样打印出来。
拦截器负责两件事:未登录打回登录页,以及管理员路径的角色校验。
@Component public class LoginInterceptor 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) { // 带上原始地址,登录后能跳回用户本来想去的页面 String target = URLEncoder.encode(request.getRequestURI(), "UTF-8"); response.sendRedirect(request.getContextPath() + "/login?redirect=" + target); return false; } String uri = request.getRequestURI(); String ctx = request.getContextPath(); // 路径前缀做角色隔离:/admin 只给管理员,/teacher 只给教师及以上 if (uri.startsWith(ctx + "/admin") && !RoleEnum.ADMIN.name().equals(loginUser.getRole())) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权限访问该页面"); return false; } if (uri.startsWith(ctx + "/teacher") && RoleEnum.STUDENT.name().equals(loginUser.getRole())) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权限访问该页面"); return false; } return true; } }注册拦截器时要把登录页、静态资源和错误页排除掉,否则会形成死循环——登录页自己被拦截,重定向到登录页,再被拦截:
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/login", "/doLogin", "/register", "/doRegister", "/css/**", "/js/**", "/img/**", "/favicon.ico", "/error" ); } @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 上传的课件不在 classpath 里,要单独映射成可访问的 URL 前缀 registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + UploadProperties.getRootPath() + "/"); } }addResourceLocations里的file:前缀是关键,指向磁盘绝对目录;少了它,Spring 会当成 classpath 相对路径去找,上传成功的文件用浏览器打开就是 404。
3.2 课件上传与文件落盘
上传接口的复杂度集中在三件事:文件名防冲突、大小与类型限制、路径拼接的跨平台问题。原始文件名不能直接用,同名文件会互相覆盖,还可能带上路径穿越字符;常见做法是用 UUID 重命名,把原始名单独存进数据库,下载时再用Content-Disposition还原回来。
@PostMapping("/material/upload") public String upload(@RequestParam("file") MultipartFile file, @RequestParam("chapterId") Long chapterId, HttpSession session, RedirectAttributes ra) throws IOException { if (file.isEmpty()) { ra.addFlashAttribute("msg", "请选择要上传的文件"); return "redirect:/teacher/material?chapterId=" + chapterId; } // 扩展名白名单,比黑名单可靠;后缀统一转小写避免 .ZIP 绕过 String original = file.getOriginalFilename(); String ext = original == null ? "" : original.substring(original.lastIndexOf('.') + 1).toLowerCase(); if (!Set.of("pdf", "ppt", "pptx", "doc", "docx", "zip", "txt").contains(ext)) { ra.addFlashAttribute("msg", "不支持的文件类型:" + ext); return "redirect:/teacher/material?chapterId=" + chapterId; } // 按月分目录,单目录文件数不会膨胀到几万个 String monthDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM")); Path dir = Paths.get(uploadRoot, monthDir); Files.createDirectories(dir); // UUID 做文件名,原始名存库;路径分隔符交给 Paths 处理,别手拼 "/" String storeName = UUID.randomUUID().toString().replace("-", "") + "." + ext; Path target = dir.resolve(storeName); file.transferTo(target.toFile()); Material m = new Material(); m.setChapterId(chapterId); m.setFileName(original); m.setFilePath(monthDir + "/" + storeName); // 相对路径入库 m.setFileSize(file.getSize()); m.setUploadBy(((User) session.getAttribute("loginUser")).getId()); materialService.save(m); ra.addFlashAttribute("msg", "上传成功"); return "redirect:/teacher/material?chapterId=" + chapterId; }参数说明一下:uploadRoot来自配置文件app.upload.root,本地一般写./upload或D:/upload,服务器上写/data/course/upload;file.transferTo()要求目标文件父目录已存在,所以前面必须先createDirectories,这也是新手最常漏的一行。
下载接口则反过来,从库里取相对路径拼根目录,并把原始文件名做一次 URL 编码:
@GetMapping("/material/download/{id}") public void download(@PathVariable Long id, HttpServletResponse response) throws IOException { Material m = materialService.getById(id); if (m == null) { response.sendError(404, "文件不存在"); return; } Path path = Paths.get(uploadRoot).resolve(m.getFilePath()); if (!Files.exists(path)) { response.sendError(404, "文件已被清理,请联系教师重新上传"); return; } // 中文文件名要编码,否则部分浏览器会截断或乱码 String encoded = URLEncoder.encode(m.getFileName(), "UTF-8").replace("+", "%20"); response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded); Files.copy(path, response.getOutputStream()); }filename*=UTF-8''是 RFC 5987 的写法,比单写filename=兼容性好得多,中文名不会变成一串问号。
3.3 作业提交与批阅状态机
作业这块的状态流转很轻,只有三种状态:未提交、已提交待批阅、已批阅。把这三种状态固定成枚举,比散落的字符串判断靠谱,改需求时也不用满项目搜"已批改"。
public enum SubmitStatus { SUBMITTED, // 学生已交,等教师打分 GRADED // 教师已给分 }提交时要做两道校验:截止时间已过则拒绝,重复提交则覆盖。覆盖用 MyBatis-Plus 的「先查再 insertOrUpdate」写法,配合前面那个唯一索引兜底:
@Transactional public void submit(Long homeworkId, Long studentId, MultipartFile file) throws IOException { Homework hw = homeworkService.getById(homeworkId); // 截止时间之后仍然允许补交,但要标记为迟交,方便教师区分 boolean late = LocalDateTime.now().isAfter(hw.getDeadline()); String storeName = UUID.randomUUID().toString().replace("-", "") + file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf('.')); Path dir = Paths.get(uploadRoot, "homework", String.valueOf(homeworkId)); Files.createDirectories(dir); file.transferTo(dir.resolve(storeName).toFile()); HomeworkSubmit exist = submitService.lambdaQuery() .eq(HomeworkSubmit::getHomeworkId, homeworkId) .eq(HomeworkSubmit::getStudentId, studentId) .one(); HomeworkSubmit record = exist == null ? new HomeworkSubmit() : exist; record.setHomeworkId(homeworkId); record.setStudentId(studentId); record.setFilePath("homework/" + homeworkId + "/" + storeName); record.setFileName(file.getOriginalFilename()); record.setSubmitTime(LocalDateTime.now()); record.setStatus(SubmitStatus.SUBMITTED.name()); // 重新提交后状态回退,需要教师重新批阅 record.setScore(null); record.setComment(late ? "迟交" : null); submitService.saveOrUpdate(record); }注意重新提交时把score和status一起重置了。这一点如果不做,会出现「学生改了作业但分数还是上次的」,属于教师端最容易引发争议的 bug。
批阅接口相对简单,教师填分数和评语,状态从SUBMITTED推到GRADED:
@PostMapping("/teacher/homework/grade") public String grade(Long submitId, Integer score, String comment) { if (score == null || score < 0 || score > 100) { throw new IllegalArgumentException("分数必须在 0-100 之间"); } HomeworkSubmit s = new HomeworkSubmit(); s.setId(submitId); s.setScore(score); s.setComment(comment); s.setStatus(SubmitStatus.GRADED.name()); submitService.updateById(s); // 只更新非 null 字段,不会覆盖文件路径 return "redirect:/teacher/homework/submits?homeworkId=" + submitService.getById(submitId).getHomeworkId(); }用「构造一个只带 id 和待改字段的实体,再updateById」是 MyBatis-Plus 的常用手法,它默认只更新非空字段,不会把没传的字段写成 null。
4. 从零跑起来:本地环境、建库、启动与验证
代码看明白了,剩下就是让它在自己机器上活过来。这套工程的启动门槛不高,真正容易卡住的是数据库连接参数和上传目录这两处,按顺序走一遍基本不会翻车。
4.1 环境准备与数据库导入
需要三样东西:JDK 8 或 11、Maven 3.6+、MySQL 5.7 或 8.0。JDK 别上 17,Spring Boot 2.7 虽然能跑,但部分老依赖在模块化限制下会抛InaccessibleObjectException,排查起来很折腾。
建库和导入数据:
# 登录 MySQL,新建库并指定字符集,utf8mb4 才能存住 emoji 和生僻字 mysql -uroot -p -e "CREATE DATABASE course_site DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 导入源码里的 sql 文件,一般在 src/main/resources/sql/ 或项目根目录 mysql -uroot -p course_site < course_site.sql导入完检查一下表数量和初始账号:
USE course_site; SHOW TABLES; -- 初始账号的密码通常存的是 BCrypt 哈希,先确认有几条 SELECT id, username, role FROM sys_user LIMIT 10;提示:如果 sql 文件里的建表语句带
DEFINER=,导入时可能因为当前账号没权限而报错。全选替换掉DEFINER=...那段,或者直接用 Navicat 的可视化导入,它会自动忽略。
4.2 配置改写与启动
application.yml是唯一需要动的地方,四个参数必须和自己的环境对齐:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver # 关键:时区必须写,否则时间字段会差 8 小时 url: jdbc:mysql://localhost:3306/course_site?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 你的密码 servlet: multipart: # 默认单文件 1MB,课件和作业包很容易超,调到 50MB max-file-size: 50MB max-request-size: 100MB thymeleaf: # 开发阶段关缓存,改完页面刷新就生效;上线记得改回 true cache: false mybatis-plus: configuration: map-underscore-to-camel-case: true mapper-locations: classpath*:/mapper/**/*.xml app: upload: root: ./upload # 相对项目根目录,服务器上换成绝对路径serverTimezone=Asia/Shanghai、allowPublicKeyRetrieval=true这两个参数是 MySQL 8 的必备项。前者不写会导致deadline读出来比实际早八小时,作业明明没到期却提示已截止;后者不写会报Public Key Retrieval is not allowed,属于新手最常见的报错之一。
配置好后直接启动:
# 在项目根目录执行,-Dfile.encoding 防止控制台中文乱码 mvn spring-boot:run -Dfile.encoding=UTF-8看到Started CourseApplication in x.xxx seconds就说明容器起来了。首次启动时如果upload目录不存在,程序里最好补一段初始化逻辑,或者手动建一个空目录——部分上传代码在启动阶段不会主动创建根目录。
4.3 端到端验证清单
光看到启动日志不算跑通,按下面的顺序点一遍,能把大部分隐藏问题提前暴露:
第一,用初始账号登录,观察是否跳转到对应角色首页。如果一直停在登录页,打开浏览器 F12 看/doLogin的响应,多半是重定向路径少了 context-path。
第二,教师端建一门课、加两个章节,再上传一个 PDF 课件。上传成功后点下载,文件能正常打开才算链路完整。这一步失败通常是静态资源映射没配对。
第三,学生端登录,进入作业列表,交一份 zip。然后刷新页面,看提交状态是否变成「已提交」;再用教师账号进去打分,学生端刷新能看到分数。
第四,故意在截止时间之后再交一次,确认系统给出迟交标记而不是直接 500 报错。
注意:验证完记得把
thymeleaf.cache和数据库连接池的调试日志关掉,这两项在演示时会让首屏慢上几百毫秒,投影出来很明显。
5. 避坑与常见问题排查:跑不起来多半卡在这几处
同一份源码在不同机器上的表现差异,基本集中在环境相关的几个点上。下面这几条是我在不同机器上反复遇到的,按「现象 → 原因 → 解决」整理,遇到问题时可以对号入座。
5.1 启动报 Public Key Retrieval is not allowed
现象:控制台抛出java.sql.SQLNonTransientConnectionException: Public Key Retrieval is not allowed,应用起不来。
原因:MySQL 8 默认使用caching_sha2_password认证插件,而 JDBC 驱动在非 SSL 连接下需要显式允许获取公钥。MySQL 5.7 用的是mysql_native_password,所以老项目换到新库时才突然报这个错。
解决:在 JDBC URL 末尾追加allowPublicKeyRetrieval=true&useSSL=false。如果不想改 URL,也可以在 MySQL 里把账号的认证插件改回旧版,但更推荐前者,配置文件里一行的事。
5.2 上传成功但下载 404
现象:教师端提示「上传成功」,数据库里也有记录,可点下载或者直接在浏览器打开/upload/xxx.pdf全是 404。
原因:上传目录不在 classpath 里,Spring Boot 默认只对外暴露static、public这些类路径目录。磁盘上的目录必须通过addResourceHandlers手动映射。另一种可能是映射写了,但addResourceLocations里漏了file:前缀,被当成了类路径相对地址。
解决:确认WebConfig里有registry.addResourceHandler("/upload/**").addResourceLocations("file:" + 绝对路径 + "/")。路径末尾那个斜杠不能省,少了它拼接出来的地址会变成/upload2025/06/xxx.pdf。
5.3 时间差八小时,作业提前显示已截止
现象:明明晚上十点才到截止时间,下午学生端就提示「已截止」。
原因:JDBC URL 没配serverTimezone,驱动按 UTC 解析数据库里的DATETIME,取出来再转成本地时间就少了八小时。另一种情况是实体类用了java.util.Date而数据库是DATETIME,序列化时又转了一次时区。
解决:URL 里补上serverTimezone=Asia/Shanghai,实体类统一改成LocalDateTime,模板里用#temporals.format(...)输出。改完重启,重新导入一次测试数据。
5.4 中文文件名下载后变成乱码或空文件
现象:下载的课件名变成%E5%AE%9E%E9%AA%8C.pdf这种,或者文件能下但名字被截断。
原因:Content-Disposition头里直接放中文,HTTP 头按 ISO-8859-1 编码,浏览器解析时就乱了。用URLEncoder.encode时把空格编成了+,也会导致部分浏览器还原失败。
解决:用filename*=UTF-8''的 RFC 5987 写法,并把+替换回%20。同时确保响应头在写输出流之前设置,response.getOutputStream()一调用,头就固定了,之后再加不生效。
5.5 页面改动不生效,必须重启
现象:改完templates/xxx.html,浏览器刷新还是旧内容,清缓存也没用。
原因:Thymeleaf 默认在application.yml里可能配置了cache: true,模板编译一次后缓存在内存里,文件变了也不重新加载。更隐蔽的一种是 IDE 编译输出目录和运行目录不一致,改的是源码,跑的是target/classes里的旧副本。
解决:开发阶段设spring.thymeleaf.cache=false,并确认 IDE 开启了自动编译。如果用的是 IDEA,Build Project Automatically要勾上,同时注意target目录别被手动清掉又没重新编译。
6. 二次开发:把「演示版」改成能真用的课程站
这套骨架能演示,但直接拿给真实教学用,还有几处得补。第一处是权限,现在的拦截器靠 URL 前缀判断,学生把/teacher/homework/grade手动敲进地址栏照样能提交——补一层方法级校验更稳。
// 自定义注解 + AOP 或拦截器里读取,比硬编码路径前缀灵活 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { String[] value(); // 允许访问的角色 } // 使用处一目了然,加新接口不用回头改拦截器 @RequireRole({"TEACHER", "ADMIN"}) @PostMapping("/teacher/homework/grade") public String grade(Long submitId, Integer score, String comment) { ... }第二处是文件存储。本地磁盘在单机够用,但换服务器、做备份、多实例部署都很别扭。把transferTo换成对象存储的 SDK,接口层保持「路径入库、下载时按路径取流」的抽象,改动量其实就集中在MaterialService一个类里。
第三处是成绩导出。教师批完一个班的作业,总想导成 Excel 交给教务。用 EasyExcel 写十几行就能出,注意表头字段与前端列一致,导出前先按学号排序。
| 改造点 | 现状 | 建议做法 | 改动范围 |
|---|---|---|---|
| 权限控制 | URL 前缀判断 | 注解 + 拦截器校验 | interceptor、controller |
| 文件存储 | 本地磁盘 | 对象存储,保留相对路径 | service 层 |
| 成绩导出 | 无 | EasyExcel 按班级导出 | 新增 controller |
| 分页 | 全量查询 | MyBatis-Plus 分页插件 | mapper、页面 |
第四处容易被忽略:学生列表和提交记录现在多半是全量查出来在内存里过滤,几十人没问题,一个年级几百人就明显卡。加上 MyBatis-Plus 的分页插件,Page<HomeworkSubmit> page = new Page<>(pageNo, 10)一行就能把 SQL 变成LIMIT,页面再补个翻页条即可。
验证改造是否生效有个笨办法但很可靠:把浏览器窗口缩到手机宽度点一遍,再把网络面板限速到 3G 刷一次。权限、分页、懒加载的问题在这两种极端情况下最容易露馅,比正常环境点十遍都管用。
从那以后我每次接这类毕设改造,都强制先跑一遍「学生账号手动敲教师接口」这条路径,再动任何功能代码。很多隐患不是写的时候没有,而是当时没想到要去试。希望帮到你。
本文还有配套的精品资源,点击获取