简介这套基于JAVA与MySQL的网上作业批改系统源码面向课程设计、毕业设计及Java Web入门者围绕班级、教师、学生、公告、留言、资料和系统管理等模块提供管理员端信息录入、查询、删除、打印等业务流程并配有操作提示与基础安全约束。包内共265个文件以jsp页面、java源码、class编译文件及gif/png界面素材为主另含sql数据库脚本、css/js样式和少量doc/docx说明压缩包约1.93MB便于导入开发工具后对照数据库结构学习。已有1615人学习下载可作为JSP/Servlet课程实践的参考样本。读者能获得一套较完整的后台管理项目底稿用于理解模块划分、表单数据处理与增删改查实现也可作为二次开发或答辩演示的基础减少从零搭建页面的时间。1. 项目定位把“收作业—批改—登分”流程跑通的 Java 单体系统先说一个常见场景期末三门课同时收作业学生把文件丢进群里教师一份份下载、批注、回传最后还得把分数手抄进表格。三个人干两天还容易漏。这套网上作业批改系统 JAVAMysql 针对的正是这段流程教师发作业、设截止时间学生在线提交教师在后台逐份打分写评语成绩按课程和班级自动汇总。技术上它并不花哨是最典型的 SSM 单体Java 8 Spring SpringMVC MyBatis 跑完整条请求链路MySQL 存业务数据附件保存在服务器本地目录页面用 JSP 渲染打成 war 丢进 Tomcat 就能跑。适合两类人一类是课程设计、毕设需要一个功能完整、能演示全流程的参考实现另一类是接手了类似教务小系统想看看角色权限、文件上传、批改状态这几处别人是怎么处理妥当的开发者。2. 技术栈与目录结构SSM 分层怎么落到文件夹上拿到一个 war 包或者一份 source 目录先别急着改代码先看它怎么分层。分层乱的项目后面加一个字段要改七八个文件分层清楚的项目改起来就是照着模板填空。这套系统的分层属于比较规矩的那一类值得当作参照。2.1 依赖与版本为什么锁在 Java 8 SSM 这套组合这类项目普遍不会上 Spring Boot原因很实际学校机房的 JDK 常年是 8Tomcat 是现成的教师端要部署到一台内网服务器上少了内嵌容器的启动脚本反而更省事。pom.xml 里几个版本号基本决定了后面会不会踩坑先看一眼。properties project.build.sourceEncodingUTF-8/project.build.sourceEncoding spring.version5.2.8.RELEASE/spring.version mybatis.version3.5.6/mybatis.version /properties dependencies !-- Spring 全家桶只引 context/aop 就够web 和 webmvc 交给 MVC 那层 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- 事务必须走 spring-tx否则 Transactional 只生效一半 -- dependency groupIdorg.springframework/groupId artifactIdspring-tx/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency !-- 5.1.x 的驱动类是 com.mysql.jdbc.Driver8.x 是 com.mysql.cj.jdbc.Driver -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.21/version /dependency !-- 上传解析器依赖它SpringMVC 的 CommonsMultipartResolver 底层就是 commons-fileupload -- dependency groupIdcommons-fileupload/groupId artifactIdcommons-fileupload/artifactId version1.4/version /dependency dependency groupIdcommons-io/groupId artifactIdcommons-io/artifactId version2.8.0/version /dependency !-- JSP 相关scope 必须是 provided否则和 Tomcat 自带的 jar 打架 -- dependency groupIdjavax.servlet/groupId artifactIdjsp-api/artifactId version2.2/version scopeprovided/scope /dependency /dependencies几个参数值得单独说清楚。spring.version一旦定在 5.2.xJDK 就必须 8 以上用 7 会直接编译不过。mysql-connector-java的版本和jdbc.driver类名是一一对应的8.x 还要求 URL 上带serverTimezone否则连上去第一句查询就抛时区异常。commons-fileupload很多人以为引了 Spring 就自动有实际上没有它CommonsMultipartResolver一初始化就ClassNotFoundException。jsp-api和servlet-api的provided也别省省了打包进 war 会和容器里的版本冲突症状是启动时报一堆LinkageError。2.2 包结构与分层职责目录结构决定改代码时找文件的速度。这套系统的包结构是标准 Maven webapp按职责横切没有做领域驱动那套聚合划分对中小型教务系统来说够用。hw-grading/ ├── pom.xml ├── sql/ │ ├── schema.sql -- 建表语句 │ └── data.sql -- 初始账号、班级、课程种子数据 ├── src/main/java/com/example/hw/ │ ├── controller/ -- 只做参数接收、跳转、异常提示 │ │ ├── LoginController.java │ │ ├── HomeworkController.java │ │ ├── SubmitController.java │ │ ├── GradeController.java │ │ └── StatController.java │ ├── service/ -- 接口 │ │ └── impl/ -- 实现事务边界全部压在这一层 │ ├── mapper/ -- MyBatis 接口只有方法签名 │ ├── entity/ -- 和表一一对应的 PO │ ├── vo/ -- 页面要用的聚合对象比如 SubmitVO、CourseStatVO │ ├── interceptor/ -- AuthInterceptor 登录与角色拦截 │ ├── util/ -- MD5Util、FileUtil、PageResult │ └── exception/ -- BizException 全局异常处理器 ├── src/main/resources/ │ ├── jdbc.properties │ ├── spring-dao.xml │ ├── spring-service.xml │ ├── spring-mvc.xml │ ├── mybatis/mapper/*.xml │ └── log4j.properties └── src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ -- JSP 按角色分目录admin/ teacher/ student/ └── static/ -- css、js、upload 临时目录分层纪律有两条看代码时可以拿来当标尺。第一controller 里不出现 SQL 和SqlSession方法体超过 20 行基本就是把业务写错位置了。第二service 是唯一加Transactional的地方mapper 只负责拼 SQL别在 mapper 上挂事务挂了也不生效。entity 和 vo 分开这点也值得学homework_submit表里只有student_id但教师端列表要显示姓名和学号如果直接把studentName塞进 entity后面 insert 的时候 MyBatis 会因为找不到对应列报错。2.3 表结构六张主表撑起业务闭环数据库是这个项目里最该先看懂的部分接口和页面都是围着它转的。核心就六张表加一个防重复提交的唯一索引。-- 用户表三种角色共用一张class_id 只对学生有意义 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL COMMENT 登录账号学生用学号, password VARCHAR(64) NOT NULL COMMENT MD5 密文, real_name VARCHAR(32) NOT NULL, student_no VARCHAR(20) DEFAULT NULL COMMENT 学号教师为 NULL, role VARCHAR(16) NOT NULL COMMENT admin/teacher/student, class_id BIGINT DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 启用 0 停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 作业表deadline 是判断能不能提交的唯一依据 CREATE TABLE homework ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, title VARCHAR(128) NOT NULL, content TEXT, full_score DECIMAL(5,1) NOT NULL DEFAULT 100.0, deadline DATETIME NOT NULL, create_by BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_course (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 提交表一个学生对一份作业只能有一条记录靠唯一索引兜底 CREATE TABLE homework_submit ( id BIGINT PRIMARY KEY AUTO_INCREMENT, homework_id BIGINT NOT NULL, student_id BIGINT NOT NULL, content TEXT, file_path VARCHAR(255) DEFAULT NULL COMMENT 相对上传根目录的路径, file_name VARCHAR(128) DEFAULT NULL COMMENT 原始文件名下载时还原, submit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1 COMMENT 0 待批改 1 已提交 2 已批改 3 打回重做, score DECIMAL(5,1) DEFAULT NULL, comment VARCHAR(500) DEFAULT NULL, checker_id BIGINT DEFAULT NULL, check_time DATETIME DEFAULT NULL, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁, UNIQUE KEY uk_hw_student (homework_id, student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_hw_student这个唯一索引是整个设计里最关键的一处。学生手抖点两次提交、网络慢导致表单重发靠业务代码里的if (exists)判断是拦不住的并发下两个请求会同时读到“不存在”。让数据库来拦插入时捕获DuplicateKeyException转成 update逻辑反而更短。file_path存相对路径、file_name存原始文件名也是踩过坑之后的写法。绝对路径一换服务器就全废原始文件名如果直接拿去落盘中文名和特殊字符会在不同操作系统上出各种问题所以磁盘上用时间戳命名展示和下载时再用file_name还原。score用DECIMAL(5,1)而不是INT因为教务口径里 87.5 这种半分是常态用整数存后面统计平均分要来回转换。3. 核心链路一次“学生提交—教师打分”在代码里怎么走把表看明白之后剩下的就是沿着一条真实请求走一遍。从登录、提交、批改到统计四段代码串起来整个系统的骨架就清楚了。3.1 登录与角色拦截别让 session 变成唯一的防线登录这块很多人只做“账号密码对不对”忘了校验“这个账号是不是这个角色”。学生账号从教师端登录页登进去页面虽然报错但 session 已经写进去了后续直接改 URL 就能进教师后台。正确的做法是登录时就把角色卡死同时在拦截器里按 URL 前缀再验一次。Controller RequestMapping(/auth) public class LoginController { Resource private UserService userService; PostMapping(/login) public String login(String username, String password, String role, HttpSession session, Model model) { SysUser user userService.findByUsername(username); // 密码在库里存的是 MD5比对前先加密别拿明文直接比 if (user null || !MD5Util.encode(password).equals(user.getPassword())) { model.addAttribute(msg, 账号或密码不正确); return login; } // 角色必须和登录页选的一致否则学生能混进教师端 if (!user.getRole().equals(role)) { model.addAttribute(msg, 该账号不属于所选角色); return login; } if (user.getStatus() 0) { model.addAttribute(msg, 账号已停用请联系管理员); return login; } session.setAttribute(LOGIN_USER, user); session.setMaxInactiveInterval(2 * 60 * 60); // 2 小时无操作自动退出 return redirect:/ role /index; } }参数就三个username、password、role。role不是给后端信任的而是用来做一致性校验的。session.setMaxInactiveInterval单位是秒默认 30 分钟对批改作业来说太短教师改到一半被踢出去会丢未保存的评语调成两小时比较合适。拦截器负责第二道关。这里的巧思是让 URL 前缀直接等于角色名/teacher/**、/student/**、/admin/**判断逻辑就变成一行字符串比较不用维护角色到资源的映射表。public class AuthInterceptor implements HandlerInterceptor { // 白名单登录页、静态资源、验证码不拦 private static final String[] WHITE_LIST {/auth/, /static/, /captcha}; Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { String uri req.getRequestURI(); String ctx req.getContextPath(); for (String white : WHITE_LIST) { if (uri.startsWith(ctx white) || uri.contains(white)) { return true; } } SysUser user (SysUser) req.getSession().getAttribute(LOGIN_USER); if (user null) { resp.sendRedirect(ctx /auth/login); return false; } // 去掉 contextPath 后取第一段即 admin / teacher / student String path uri.substring(ctx.length()); String roleInPath path.split(/)[1]; if (!user.getRole().equals(roleInPath)) { resp.sendError(HttpServletResponse.SC_FORBIDDEN, 无权访问该模块); return false; } return true; } }preHandle返回false表示请求到此为止后面的 controller 不会执行。sendError(403)比sendRedirect更合适因为越权访问不是“没登录”重定向到登录页会让用户误以为 session 过期。这个拦截器在spring-mvc.xml里用mvc:interceptors注册mvc:exclude-mapping里再补一遍白名单两边都写看起来重复实际是防止有人改了一处忘了另一处。3.2 作业提交文件落盘和截止时间校验提交这段是整个系统里最容易出问题的地方涉及文件、时间、并发三件事。代码不长但每一段都对应一个具体的坑。private static final ListString ALLOW_EXT Arrays.asList(doc, docx, pdf, txt, zip, rar, jpg, png); PostMapping(/submit) public String submit(RequestParam Long homeworkId, RequestParam(required false) String content, RequestParam(required false) MultipartFile file, HttpSession session) throws IOException { SysUser student (SysUser) session.getAttribute(LOGIN_USER); Homework hw homeworkService.getById(homeworkId); // 1. 截止时间用服务器时间判断前端把按钮置灰只是体验不作数 if (hw null) { return redirect:/student/homework?errnotfound; } if (new Date().after(hw.getDeadline())) { return redirect:/student/homework?errexpired; } // 2. 正文和附件至少要有一个空提交没意义 boolean noFile (file null || file.isEmpty()); if (noFile StringUtils.isBlank(content)) { return redirect:/student/homework?errempty; } String relativePath null; String originName null; if (!noFile) { // 3. 后缀取原文件名但能不能存只看白名单不看客户端 Content-Type String ext FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(); if (!ALLOW_EXT.contains(ext)) { return redirect:/student/homework?errtype; } // 4. 按 作业id/学号 分目录重交直接覆盖同一条记录不产生垃圾文件 File dir new File(uploadRoot, homeworkId / student.getId()); FileUtils.forceMkdir(dir); File target new File(dir, System.currentTimeMillis() . ext); file.transferTo(target); relativePath target.getPath().replace(uploadRoot, ); originName file.getOriginalFilename(); } submitService.saveOrUpdate(homeworkId, student.getId(), content, relativePath, originName); return redirect:/student/homework?ok1; }参数说明一下。uploadRoot从jdbc.properties同级的app.properties里读部署到不同机器只改这一处。FilenameUtils.getExtension只取最后一个点后面的部分a.jsp.zip拿到的是zip所以白名单是有效的反过来如果只做黑名单排除jspa.jsp.zip解压出来还是能拿到 jsp那才是真出漏洞。System.currentTimeMillis()做文件名保证了唯一性代价是同一个学生重交之后旧文件会留在磁盘上所以saveOrUpdate里要在覆盖前把旧文件删掉。saveOrUpdate的实现走的是“先更新后插入靠唯一索引兜底”的路子Transactional(rollbackFor Exception.class) Override public void saveOrUpdate(Long homeworkId, Long studentId, String content, String path, String originName) { HomeworkSubmit exist submitMapper.selectByHwAndStudent(homeworkId, studentId); if (exist ! null) { // 已批改的作业不能直接覆盖要打回重做之后才允许再交 if (exist.getStatus() 2) { throw new BizException(该作业已批改如需重交请联系教师打回); } exist.setContent(content); exist.setFilePath(path); exist.setFileName(originName); exist.setSubmitTime(new Date()); exist.setStatus(1); submitMapper.updateContent(exist); return; } HomeworkSubmit sub new HomeworkSubmit(); sub.setHomeworkId(homeworkId); sub.setStudentId(studentId); sub.setContent(content); sub.setFilePath(path); sub.setFileName(originName); sub.setStatus(1); try { submitMapper.insert(sub); } catch (DuplicateKeyException e) { // 两次请求同时进来后到的那条改走更新避免直接 500 submitMapper.updateContent(sub); } }rollbackFor Exception.class不能省。Spring 默认只在RuntimeException回滚IOException这类检查异常抛出后前面的数据库写入不会撤销磁盘上还留着文件数据就不一致了。3.3 批改打分分数边界、乐观锁和事务边界教师端打分看着简单其实模糊地带最多分数能不能超过满分、能不能小于零、两个教师同时打开同一份作业怎么办、改完分之后统计要不要立刻变。这些问题如果不处理上线第一天就会碰到。Service public class GradeServiceImpl implements GradeService { Resource private HomeworkSubmitMapper submitMapper; Resource private HomeworkMapper homeworkMapper; Transactional(rollbackFor Exception.class) Override public void grade(Long submitId, BigDecimal score, String comment, Integer status, Long teacherId) { HomeworkSubmit sub submitMapper.selectById(submitId); if (sub null) { throw new BizException(提交记录不存在); } if (status null || status 0 || status 3) { throw new BizException(非法的批改状态); } // 打回重做时不校验分数其余情况分数必须在 0 ~ 满分之间 if (status 2) { Homework hw homeworkMapper.selectById(sub.getHomeworkId()); if (score null) { throw new BizException(请填写分数); } if (score.compareTo(BigDecimal.ZERO) 0 || score.compareTo(hw.getFullScore()) 0) { throw new BizException(分数须在 0 ~ hw.getFullScore() 之间); } } HomeworkSubmit update new HomeworkSubmit(); update.setId(submitId); update.setScore(score); update.setComment(comment); update.setStatus(status); update.setCheckerId(teacherId); update.setCheckTime(new Date()); update.setVersion(sub.getVersion()); // 带上读到的版本号 // update ... where id ? and version ?抢不到就返回 0 int rows submitMapper.updateForGrade(update); if (rows 0) { throw new BizException(该作业已被其他人批改请刷新后重试); } } }三个参数含义要分清。status是批改结果2 表示通过并给分3 表示打回重做把“打回”和“给分”放进同一个接口是为了让教师端只调一次省掉两个按钮两套逻辑。score用BigDecimal而不是doubledouble在87.5 0.1这种场景下会算出87.60000000000001最后写进DECIMAL(5,1)被截断学生看到的分数和教师填的不一样这种玄学问题查起来能耗掉半天。version是乐观锁字段读的时候拿到多少就传多少SQL 里带上and version #{version}rows 0说明有人先改了这时候抛异常让前端刷新比两个窗口互相覆盖要体面得多。统计这里还有个常见的误用。很多实现是“批改完成后再去 update 一张统计表”一旦事务回滚或者中途报错统计就和明细对不上。更稳的做法是统计全部用实时聚合查询明细表是唯一事实来源统计只是它的投影慢一点但永远正确。等数据量真到几万条再考虑加缓存或者定时物化别一上来就搞双写。3.4 列表与统计动态 SQL 分页和按课程聚合Mapper 的 XML 是这个项目里信息密度最高的文件。教师端列表要支持按状态筛选、按姓名或学号搜索、还要分页这三件事放一起用注解写 SQL 会很难维护用 XML 的if就清爽得多。!-- 教师端某份作业的提交列表带学生姓名和状态筛选 -- select idpageByHomework resultTypecom.example.hw.vo.SubmitVO select s.id, s.homework_id as homeworkId, u.real_name as studentName, u.student_no as studentNo, s.submit_time as submitTime, s.status, s.score, s.file_name as fileName from homework_submit s join sys_user u on u.id s.student_id where s.homework_id #{homeworkId} if teststatus ! null and s.status #{status} /if if testkeyword ! null and keyword ! and (u.real_name like concat(%, #{keyword}, %) or u.student_no like concat(%, #{keyword}, %)) /if order by s.submit_time desc limit #{offset}, #{pageSize} /select#{homeworkId}、#{status}这些用的是预编译占位符能防注入${}那种字符串拼接只在order by动态列名时才不得已用而且必须自己在 Java 层做白名单校验。limit #{offset}, #{pageSize}里的offset由PageResult算出来pageSize建议在 controller 层限制上限否则有人传pageSize100000就能把整张表拉出来。concat(%, #{keyword}, %)的写法比%${keyword}%安全虽然索引会失效但教师端数据量小可读性优先。统计这块是教师端首页的核心一条 SQL 把课程、作业数、提交数、平均分、待批改数全查出来select idstatByTeacher resultTypecom.example.hw.vo.CourseStatVO select c.id as courseId, c.course_name as courseName, count(distinct h.id) as hwCount, count(s.id) as submitCount, round(avg(case when s.status 2 then s.score end), 1) as avgScore, sum(case when h.deadline lt; now() and s.status 1 then 1 else 0 end) as overdueCount from course c left join homework h on h.course_id c.id left join homework_submit s on s.homework_id h.id where c.teacher_id #{teacherId} group by c.id, c.course_name order by c.id desc /selectavg(case when s.status 2 then s.score end)这个写法值得单独记一下。如果直接写avg(s.score)未批改的记录分数是NULLavg会自动忽略NULL这个是对的但一旦有人把未批改的分数默认填成 0平均分就会被拉低一大截教师看到的均分永远偏低。用case when显式圈定只统计已批改的语义就明确了。left join也不能换成inner join新建的课程还没布置作业时应该在列表里显示 0而不是整行消失。lt;是 XML 里小于号的转义写法直接写会让整个 mapper 解析失败报错信息还指不到具体行。4. 跑起来从建库到登录首页的实操步骤代码看懂之后真正动手的第一关是环境。这类 SSM 项目对版本比较敏感版本对上了十分钟能跑起来对不上能耗一整天。4.1 环境准备与版本对齐先把这几个版本核对一遍对不上就别急着往下走。组件建议版本关键点JDK1.8Spring 5.2 最低要求 8用 11 也能跑但 Lombok 要跟着升MySQL5.7 或 8.08.0 必须改驱动类名并加serverTimezoneTomcat8.5 或 9.09.0 之后javax.*包名没变10 才换成jakarta别用 10Maven3.6settings.xml 配好国内镜像否则依赖下到一半会断IDEIDEA 社区版即可需要装 Smart Tomcat 或配本地 Tomcat有一点要注意Tomcat 10 用的是jakarta.servlet这套代码里全是javax.servlet硬上 10 会满屏ClassNotFoundException改包名工作量不小不如直接降回 9。4.2 建库、导入数据、改三处配置命令行几步走完不需要图形化工具。# 1. 建库字符集必须是 utf8mb4utf8 存不下 emoji 和部分生僻字 mysql -uroot -p -e create database hw_grading default charset utf8mb4 collate utf8mb4_general_ci; # 2. 先导结构再导种子数据顺序反了会外键失败 mysql -uroot -p hw_grading sql/schema.sql mysql -uroot -p hw_grading sql/data.sql # 3. 打包老项目的测试类常年跑不过多半是连了本地库先跳过 mvn clean package -DskipTests # 4. 用插件直接起省得配 Tomcat mvn tomcat7:run配置只改三处。第一处jdbc.properties# MySQL 8 的驱动类名带 cj写成 5.x 的会直接报驱动找不到 jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://127.0.0.1:3306/hw_grading?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password你的密码 # 附件落盘根目录Windows 用 D:/data/hw-uploadLinux 用 /data/hw-upload # 这个目录必须提前建好并且有写权限代码不会帮你创建根目录 upload.rootD:/data/hw-upload第二处spring-mvc.xml里的上传解析器maxUploadSize单位是字节!-- 20MB压缩包一般够用不配这个默认只有 1MB学生传个 docx 就失败 -- bean idmultipartResolver classorg.springframework.web.multipart.commons.CommonsMultipartResolver property namedefaultEncoding valueUTF-8/ property namemaxUploadSize value20971520/ property namemaxInMemorySize value4096/ /beanmaxInMemorySize控制多大的文件先放内存超过就写临时文件。设成 4096 字节意味着几乎都走磁盘临时文件这样可以避免大文件把堆撑爆。第三处是数据库连接池spring-dao.xml里如果用的是DriverManagerDataSource每次取连接都新建教师端列表刷新几次就能感觉到慢换成DruidDataSource或者HikariDataSource会好很多。4.3 启动与冒烟验证三种角色各点一遍启动日志里出现Server startup in xxxx ms只是第一步真正的验证是把业务链路走一遍。种子数据里一般会给三个账号管理员、教师、学生密码是同一个初始值。先登录教师端新建一门课再新建一份作业deadline设成明天满分 100。然后开个无痕窗口登录学生端在作业列表里点提交传一个 docx。回到教师端刷新应该能看到这条记录状态是“已提交”。点批改输入 88.5 和一句评语提交。再刷新学生端状态要变成“已批改”分数 88.5。提示如果学生端状态没变先别怀疑代码打开浏览器 F12 看这个请求是不是被 304 缓存了Ctrl F5强制刷新再判断。最后登录管理员端看用户管理里能不能改角色、能不能重置密码课程统计页的数字是不是和刚才的操作对得上。这三条走完整个系统的核心链路就通了后面改需求心里也有底。5. 常见问题排查启动失败、上传 500、状态不刷新这套系统的问题分布很集中八成集中在这三块。每一条都按“现象—原因—解决”记下来遇到了直接对号入座。5.1 启动阶段连不上库和莫名其妙的 404现象一启动时抛Cannot create PoolableConnectionFactory或者Unknown database。原因通常不在代码。要么是jdbc.url里的库名和实际建的对不上很多人建库时手写成了hw_grading_db要么是 MySQL 8 的账号密码加密方式还是caching_sha2_password而驱动版本偏老。还有一种容易被忽略URL 里少了serverTimezone连接建立成功但第一次查询就抛时区异常日志会误导你去查 SQL。解决办法是先命令行验证一遍mysql -uroot -p -e use hw_grading; show tables;能进去说明库没问题。然后检查 URL 参数MySQL 8 至少要带serverTimezoneAsia/ShanghaiuseSSLfalse。账号加密方式的问题执行ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码;之后重启应用即可。注意改完要重新flush privileges。现象二项目起来了访问首页 404但控制台没有任何异常。这种“安静地失败”最难受。原因是 SpringMVC 的url-pattern配置成了/而web.xml里welcome-file-list指向的index.jsp在WEB-INF下容器根本访问不到。解决办法有两种。一是把index.jsp放到webapp根目录只做一句response.sendRedirect(auth/login)跳转。二是在web.xml里把DispatcherServlet的映射改成*.do然后所有 controller 的RequestMapping都带.do后缀访问路径写全。第一种更省事第二种更适合老项目改造。提示404 排查时先看 Tomcat 的logs/localhost.开头的日志而不是 IDE 控制台。IDE 控制台只打印应用日志容器层面的映射错误只写进 catalina 日志。5.2 上传与编码文件传上去了但打不开现象三提交带附件的作业页面直接返回 500日志里是FileUploadBase$SizeLimitExceededException。原因是maxUploadSize没配或者配得太小默认只有 1MB一个带图的 docx 轻松超。也可能是配了CommonsMultipartResolver但spring-mvc.xml没被ContextLoaderListener加载解析器压根没生效。解决办法是先确认maxUploadSize的值20MB 是20971520别写成20。然后确认web.xml里DispatcherServlet加载的是spring-mvc.xml而不是spring-dao.xml很多人三个 xml 的名字记混了。最后如果部署在 Nginx 后面client_max_body_size也要跟着调否则请求根本到不了应用层。现象四文件能传上去教师端点下载打开是空白或者乱码。原因多数出在下载响应头上。文件名直接放中文进Content-Disposition会被浏览器按 ISO-8859-1 解析变成一堆问号没设Content-Type的话浏览器会自己猜docx 猜成text/html就变成一片空白。解决代码如下关键在 URL 编码和 MIME 类型两行GetMapping(/download/{id}) public void download(PathVariable Long id, HttpServletResponse resp) throws IOException { HomeworkSubmit sub submitService.getById(id); File file new File(uploadRoot, sub.getFilePath()); if (!file.exists()) { resp.sendError(404, 文件已丢失); return; } // 中文名必须 URL 编码否则浏览器解析成乱码 String fileName URLEncoder.encode(sub.getFileName(), UTF-8).replace(, %20); resp.setContentType(application/octet-stream); resp.setHeader(Content-Disposition, attachment; filename*UTF-8 fileName); resp.setContentLengthLong(file.length()); Files.copy(file.toPath(), resp.getOutputStream()); }filename*UTF-8这个写法是 RFC 5987 规定的比单纯filename兼容性好现代浏览器都认。.replace(, %20)不能少URLEncoder会把空格编码成而 URL 里表示加号本身不替换的话文件名里带空格就会下载成ab.docx。5.3 状态与成绩统计批改完了学生那边还是“待批改”现象五教师端已经打了分学生端刷新还是显示“已提交”成绩统计页的均分也没变。原因有三层从浅到深分别是学生端页面被浏览器缓存了updateForGrade的 SQL 里漏了status字段只更新了分数没更新状态或者乐观锁的version传错rows返回 0 但异常被上层吞掉了事务回滚什么都没写进去。前两层好查第三层最容易漏。判断方法是在grade方法里把rows打印出来如果是 0 而页面显示成功那就是异常被try-catch吃掉了。检查一下 controller 里是不是写了catch (Exception e) { return redirect:...; }这种大包围把BizException也一起吞了。状态值本身建议用常量或者枚举固定下来别在各个地方写魔法数字public enum SubmitStatus { PENDING(0, 待批改), SUBMITTED(1, 已提交), GRADED(2, 已批改), REJECTED(3, 打回重做); private final int code; private final String label; SubmitStatus(int code, String label) { this.code code; this.label label; } public int getCode() { return code; } public String getLabel() { return label; } // 页面渲染时用避免 JSP 里写一堆 c:if public static String labelOf(Integer code) { if (code null) return ; for (SubmitStatus s : values()) { if (s.code code) return s.label; } return 未知; } }这一改看着不起眼但后面加“催交”“逾期”这类新状态时只需要在枚举里加一行JSP 和统计 SQL 都跟着走比在十几个文件里搜status 2强太多。6. 二次开发把评分规则抽成策略留一个自动判题的扩展点这套系统最容易被要求改的地方是“打分”这一环。原始实现是教师在页面上手填分数可一旦课程变多就会有人提“能不能按出勤自动扣分”“能不能给编程题自动判用例”。如果直接改GradeServiceImpl加一个if (courseType ...)两三次需求之后这个方法就没法看了。我的做法是先抽一个策略接口把“最终分数怎么算”这件事从业务流程里剥出来public interface ScoreStrategy { /** 返回 null 表示该策略不处理交回给教师手动打分 */ BigDecimal calculate(GradeContext ctx); } Component public class ManualScoreStrategy implements ScoreStrategy { Override public BigDecimal calculate(GradeContext ctx) { return null; // 手动打分不需要计算 } } Component public class CodeJudgeScoreStrategy implements ScoreStrategy { Override public BigDecimal calculate(GradeContext ctx) { // 按测试用例通过率折算满分以作业的 full_score 为准 int total ctx.getCaseTotal(); if (total 0) return null; int passed ctx.getCasePassed(); return ctx.getFullScore() .multiply(BigDecimal.valueOf(passed)) .divide(BigDecimal.valueOf(total), 1, RoundingMode.HALF_UP); } }然后在GradeServiceImpl里注入MapString, ScoreStrategySpring 会自动把 bean 名当 key 塞进来按作业上配置的策略名取用即可。新增一种打分方式就是新加一个类业务代码一行不动。这里有个小细节divide必须指定scale和RoundingMode不指定的话遇到100/3这种除不尽的会直接抛ArithmeticException而且往往只在某几个学生身上触发测的时候很容易漏过去。改造之后我一般会做一张回归清单每次动业务代码都跑一遍验证项操作期望结果截止前提交学生传 docx状态 1文件落盘截止后提交改系统时间到 deadline 之后拦截并提示不产生记录重复提交连点两次提交只有一条记录版本号递增并发批改两个窗口同时打开同一份后提交的提示“已被批改”分数越界填 120 或 -1抛业务异常页面回显打回重交状态改 3 后学生再交允许覆盖旧附件被删最后说个自己的习惯。这类教务小系统的坑十有八九不在算法里在边界条件和并发上截止时间差一秒、文件重名、两个教师同时点保存。所以从那以后我每次接类似的活儿都强制先跑一遍“空库建表 → 导种子数据 → 三种角色各走一遍完整链路”再谈加需求。看着慢但比上线后被学生堵着问“老师我交了怎么没显示”要快得多。希望帮到你。本文还有配套的精品资源点击获取