简介:这是一套基于JavaWeb的作业提交与批改系统项目源码,面向计算机相关专业做毕设的学生以及需要项目实战练习的Java学习者,可直接作为毕业设计使用。系统采用B/S结构,后台基于JSP、Servlet、JDBC开发,以MySQL作为数据库,开发环境为JDK、Eclipse与Tomcat,并划分管理员、教师、学生三种角色。功能覆盖班级、教师、学生、公告、留言、资料及系统管理七大模块,支持信息的录入、查询、删除与打印等操作,界面美观、操作简单、管理便捷。资源包共3个文件,包含项目源码压缩包、数据库脚本sql文件以及项目说明txt文档,整体约4.51MB,源码与脚本均已严格调试,可确保运行。目前已有3583人学习下载,适合希望快速搭建作业管理场景、理解JavaWeb分层开发与数据库设计的读者参考借鉴。
1. 作业提交与批改系统的真实痛点:为什么 JavaWeb 毕设总在“交作业”这一步翻车
每年到毕设季,基于 JavaWeb 的作业提交与批改系统都是计算机毕设选题里的高频选项。原因很直接:需求场景清晰,功能边界可控,老师看得懂,答辩好讲。但真正动手做过的人都知道,这类系统最容易翻车的不是登录注册,而是作业文件的上传下载、批改状态的流转、以及数据库脚本在别人机器上跑不起来。我见过太多项目在自己电脑上跑得好好的,换台机器导入 SQL 就报外键约束错误,或者上传的作业文件路径写死在本地 D 盘,部署到服务器直接 404。
这个系统的核心价值在于把“布置作业 → 学生提交 → 教师批改 → 成绩反馈”这条链路用代码固化下来。适合正在做毕设的计算机专业学生,也适合想拿一个完整 JavaWeb 项目练手的初学者。技术栈上,常见做法是 SpringBoot + MyBatis + MySQL + Thymeleaf 或 Vue,数据库脚本用 IDEA 导出或手写建表语句。下面我会按实际开发顺序,把选型、建库、核心功能实现和踩坑记录一层层拆开讲。
2. 技术选型与数据库设计:从“能跑”到“好改”的取舍
2.1 为什么我最终选了 SpringBoot + MyBatis 而不是纯 JSP + Servlet
纯 JSP + Servlet 是很多 JavaWeb 课程的标配,黑马程序员的笔记里也大量涉及。但真做毕设,纯 JSP 的痛点很明显:页面逻辑和 Java 代码混在一起,改一个字段要动三四个文件,答辩时老师问“你的分层在哪里”很难回答。SpringBoot 的好处是起步依赖帮你把 Tomcat、Jackson、日志全配好了,application.yml里改个端口就行,不用折腾web.xml。
MyBatis 相比 JPA 更适合毕设场景。作业提交与批改系统的查询条件往往很灵活——按班级查、按课程查、按提交状态查、按学生姓名模糊查。MyBatis 的 XML 映射可以直接写动态 SQL,<if test="status != null">这种标签用起来很顺手。JPA 虽然也能做,但复杂查询要么写 Specification 要么写原生 SQL,对新手不够直观。
前端我建议用 Thymeleaf 而不是前后端分离的 Vue。原因很现实:毕设周期通常只有两三个月,前后端分离要处理跨域、Token 刷新、接口联调,时间成本翻倍。Thymeleaf 模板直接放在resources/templates下,Controller 返回视图名就能渲染,配合 Bootstrap 快速出效果。如果导师明确要求前后端分离,那就用 Vue + Axios,后端接口统一返回Result<T>包装类。
2.2 数据库表设计:五张核心表与字段类型选择
作业提交与批改系统的数据库不需要太复杂,但几个关键字段的类型选错,后面改起来很痛苦。下面是我常用的建表脚本,基于 MySQL 8.0,字符集用utf8mb4,排序规则utf8mb4_general_ci。
-- 用户表:区分学生、教师、管理员 CREATE TABLE `sys_user` ( `user_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT '密码(BCrypt加密)', `real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0学生 1教师 2管理员', `class_id` BIGINT DEFAULT NULL COMMENT '班级ID(学生用)', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 作业表:教师布置的作业 CREATE TABLE `homework` ( `hw_id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL COMMENT '作业标题', `content` TEXT COMMENT '作业要求', `teacher_id` BIGINT NOT NULL COMMENT '布置教师', `class_id` BIGINT NOT NULL COMMENT '面向班级', `deadline` DATETIME NOT NULL COMMENT '截止时间', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`hw_id`), KEY `idx_teacher` (`teacher_id`), KEY `idx_class` (`class_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='作业表'; -- 提交表:学生提交记录 CREATE TABLE `submission` ( `sub_id` BIGINT NOT NULL AUTO_INCREMENT, `hw_id` BIGINT NOT NULL, `student_id` BIGINT NOT NULL, `file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件相对路径', `file_name` VARCHAR(200) DEFAULT NULL COMMENT '原始文件名', `submit_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `status` TINYINT DEFAULT 0 COMMENT '0未批改 1已批改', `score` DECIMAL(5,1) DEFAULT NULL COMMENT '分数', `comment` TEXT COMMENT '教师评语', PRIMARY KEY (`sub_id`), UNIQUE KEY `uk_hw_student` (`hw_id`,`student_id`), KEY `idx_student` (`student_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='提交表';这里有几个设计决策值得展开。submission表上建了uk_hw_student唯一索引,防止同一个学生对同一份作业重复提交。如果业务允许覆盖提交,就在 Service 层做“先删后插”或“更新”逻辑,但数据库约束要留着,这是最后一道防线。file_path存相对路径而不是绝对路径,比如/upload/2025/03/xxx.pdf,这样换服务器或改存储目录时不用批量更新数据库。score用DECIMAL(5,1)而不是FLOAT,避免浮点精度问题,支持 0.5 分这种粒度。
班级表和课程表按需扩展,毕设里如果只做单班级演示,可以简化成class_id直接存在用户表里。但建议至少留出class表,答辩时老师问“多个班级怎么处理”你能答上来。
2.3 IDEA 导出数据库脚本的两种方式与常见报错
很多毕设要求提交“数据库脚本”,用 IDEA 的 Database 工具导出最方便。右键数据库连接 → SQL Scripts → Generate DDL Data Source,选好输出路径即可。但导出的脚本经常带AUTO_INCREMENT=xxx和DROP TABLE IF EXISTS,前者会让新环境插入数据时主键从奇怪的值开始,后者在导入顺序不对时会报外键依赖错误。
我一般会手动清理导出的脚本:删掉所有AUTO_INCREMENT=后面的数字,保留DROP TABLE IF EXISTS但调整表顺序,先删子表再删父表。如果用了外键约束,导入时先执行SET FOREIGN_KEY_CHECKS = 0;,导入完成后再SET FOREIGN_KEY_CHECKS = 1;。这个技巧在导入别人给的脚本时特别管用,能省掉大量“Cannot add or update a child row”的排查时间。
3. 核心功能实现:文件上传、批改状态流转与分页查询
3.1 作业文件上传:从 MultipartFile 到磁盘落地的完整链路
文件上传是这类系统的命脉,也是最容易出安全漏洞的地方。SpringBoot 默认支持MultipartFile,但有几个参数必须显式配置。在application.yml里:
spring: servlet: multipart: max-file-size: 20MB # 单文件上限 max-request-size: 50MB # 单次请求总上限 enabled: trueController 层接收文件并保存:
@PostMapping("/submit") public Result<String> submitHomework( @RequestParam("hwId") Long hwId, @RequestParam("file") MultipartFile file, HttpSession session) { // 1. 校验登录态 SysUser user = (SysUser) session.getAttribute("loginUser"); if (user == null || user.getRole() != 0) { return Result.error("请以学生身份登录"); } // 2. 校验文件非空 if (file.isEmpty()) { return Result.error("请选择文件"); } // 3. 校验扩展名白名单 String originalName = file.getOriginalFilename(); String ext = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase(); List<String> allowExt = Arrays.asList("pdf", "doc", "docx", "zip", "rar", "java", "py"); if (!allowExt.contains(ext)) { return Result.error("不支持的文件类型:" + ext); } // 4. 生成存储路径:按日期分目录,避免单目录文件过多 String dateDir = new SimpleDateFormat("yyyy/MM").format(new Date()); String uploadDir = "D:/upload/" + dateDir; // 生产环境改为配置项 File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } // 5. 用 UUID 重命名,防止中文名和路径穿越 String newFileName = UUID.randomUUID().toString().replace("-", "") + "." + ext; File dest = new File(dir, newFileName); try { file.transferTo(dest); } catch (IOException e) { return Result.error("文件保存失败"); } // 6. 写数据库 Submission sub = new Submission(); sub.setHwId(hwId); sub.setStudentId(user.getUserId()); sub.setFilePath("/upload/" + dateDir + "/" + newFileName); sub.setFileName(originalName); submissionService.saveOrUpdate(sub); return Result.success("提交成功"); }这段代码的关键点在于:扩展名白名单必须做,不能只靠前端accept属性;UUID 重命名既避免了中文乱码,也防止了../../这种路径穿越攻击;按年月分目录是为了文件多了以后ls不卡。file.transferTo(dest)在 SpringBoot 里底层用的是StandardMultipartFile,如果目标目录不存在会抛FileNotFoundException,所以mkdirs()不能省。
下载接口用ResponseEntity<Resource>实现,设置Content-Disposition时注意中文文件名要用URLEncoder.encode(fileName, "UTF-8")处理,否则浏览器下载下来是乱码。
3.2 批改状态流转:从“未批改”到“已批改”的原子操作
批改功能看起来简单——教师填个分数和评语,更新一下状态。但并发场景下容易出问题:两个教师同时打开同一个提交记录,A 先提交了分数,B 后提交覆盖了 A 的批改结果。解决办法是在 SQL 更新时加状态条件:
UPDATE submission SET score = #{score}, comment = #{comment}, status = 1 WHERE sub_id = #{subId} AND status = 0MyBatis 的update返回受影响行数,Service 层判断返回值:
int rows = submissionMapper.updateScore(subId, score, comment); if (rows == 0) { throw new BusinessException("该作业已被批改,请刷新后查看"); }这个“乐观锁”思路在毕设里足够用了,不需要引入版本号字段。另外,批改完成后如果要做成绩统计,建议在homework表加一个corrected_count字段,每次批改成功后UPDATE homework SET corrected_count = corrected_count + 1 WHERE hw_id = ?,这样教师端首页能直接显示“已批改 23/45”,不用每次 count 子查询。
3.3 分页查询与动态条件:MyBatis 的<where>和 PageHelper
学生端要查“我的提交记录”,教师端要查“某次作业的提交列表”,管理员要查“所有用户”。这些查询条件组合多,用 MyBatis 动态 SQL 最省事。引入 PageHelper 分页插件:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.6</version> </dependency>Mapper XML 里写:
<select id="selectByCondition" resultType="com.example.entity.SubmissionVO"> SELECT s.*, u.real_name AS studentName, h.title AS hwTitle FROM submission s LEFT JOIN sys_user u ON s.student_id = u.user_id LEFT JOIN homework h ON s.hw_id = h.hw_id <where> <if test="hwId != null">AND s.hw_id = #{hwId}</if> <if test="studentId != null">AND s.student_id = #{studentId}</if> <if test="status != null">AND s.status = #{status}</if> <if test="keyword != null and keyword != ''"> AND (u.real_name LIKE CONCAT('%', #{keyword}, '%') OR h.title LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY s.submit_time DESC </select>Service 层调用PageHelper.startPage(pageNum, pageSize)后再执行查询,返回的PageInfo对象直接传给前端渲染分页条。注意PageHelper.startPage必须紧挨着查询方法,中间不能插入其他数据库操作,否则分页会作用到错误的 SQL 上。这个坑我踩过,表现为分页数量对不上,排查了半天才发现是中间多了一次count查询。
4. 避坑与排查:那些让毕设答辩翻车的细节
4.1 上传文件后刷新页面 404:路径拼接少了斜杠
现象:文件上传成功,数据库里file_path存的是/upload/2025/03/xxx.pdf,但前端下载链接写成${path}直接拼在域名后面,结果请求变成http://localhost:8080upload/...,少了斜杠。
原因:Thymeleaf 里用th:href="@{'/download?path=' + ${sub.filePath}}"时,如果filePath以/开头,浏览器解析相对路径会出问题。
解决:统一在 Controller 的下载接口用@RequestParam接收路径,后端拼完整 URL 返回给前端,或者前端用th:href="@{/download(path=${sub.filePath})}"让 Thymeleaf 处理 URL 编码。
4.2 导入 SQL 脚本报“Unknown character set: 'utf8mb4_0900_ai_ci'”
现象:在自己电脑上 MySQL 8.0 导出的脚本,拿到同学电脑 MySQL 5.7 上导入报错。
原因:MySQL 8.0 默认排序规则是utf8mb4_0900_ai_ci,5.7 不认识。
解决:导出时显式指定utf8mb4_general_ci,或者导入前用文本编辑器全局替换utf8mb4_0900_ai_ci为utf8mb4_general_ci。更稳妥的做法是在建库语句里写死DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci。
4.3 教师批改页面看不到学生姓名:VO 字段没对上
现象:SQL 里LEFT JOIN sys_user查出了real_name,但前端显示空白。
原因:SubmissionVO类里字段名是studentName,而 SQL 里别名写的是student_name,MyBatis 的mapUnderscoreToCamelCase没开启。
解决:在application.yml里加mybatis.configuration.map-underscore-to-camel-case: true,或者 SQL 别名直接用驼峰AS studentName。建议开启下划线转驼峰,一劳永逸。
4.4 截止时间判断失效:时区差了 8 小时
现象:作业截止时间设的是2025-03-20 23:59:59,但学生 23:50 提交时提示“已过截止时间”。
原因:MySQL 连接 URL 没指定时区,服务器 UTC 时间比北京时间早 8 小时。
解决:JDBC URL 加上serverTimezone=Asia/Shanghai,完整示例:jdbc:mysql://localhost:3306/homework_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false。
4.5 部署到服务器后上传目录不存在:绝对路径写死了
现象:本地开发时上传目录是D:/upload/,打成 jar 包扔到 Linux 服务器上,文件保存失败。
原因:代码里硬编码了 Windows 路径。
解决:把上传根路径抽到application.yml里,用@Value("${file.upload-dir}")注入。生产环境配置成/data/upload/,开发环境配置成D:/upload/。同时确保运行 jar 的用户对该目录有写权限,chmod 755或chown一下。
5. 进阶技巧:用拦截器 + 注解做权限校验,以及一个验证系统是否“真跑通”的笨办法
权限校验是毕设答辩的必问点。很多项目在每個 Controller 方法里写if (user.getRole() != 1) return error;,代码重复且容易漏。更优雅的做法是自定义注解 + HandlerInterceptor。
先定义注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireRole { int[] value() default {}; // 允许的角色,空数组表示登录即可 }拦截器实现:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod hm = (HandlerMethod) handler; RequireRole anno = hm.getMethodAnnotation(RequireRole.class); if (anno == null) { return true; // 没加注解的接口放行 } HttpSession session = request.getSession(); SysUser user = (SysUser) session.getAttribute("loginUser"); if (user == null) { response.sendRedirect("/login"); return false; } int[] roles = anno.value(); if (roles.length > 0) { boolean allowed = Arrays.stream(roles).anyMatch(r -> r == user.getRole()); if (!allowed) { response.setStatus(403); response.getWriter().write("无权限访问"); return false; } } return true; } }注册拦截器:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/doLogin", "/css/**", "/js/**"); } }Controller 里用法就变成一行注解:
@RequireRole({1, 2}) // 教师或管理员可访问 @PostMapping("/correct") public Result<String> correctHomework(@RequestBody CorrectDTO dto) { // ... }这个方案的好处是权限规则集中管理,新增接口时加个注解就行,不会漏。答辩时老师问“怎么防止学生调用教师接口”,你可以直接指出拦截器代码,比口头解释有说服力。
最后说一个验证系统是否真跑通的笨办法:把项目打包成 jar,在一台没装过开发环境的电脑上,只装 JDK 和 MySQL,按 README 步骤导入 SQL、改application.yml里的数据库密码、java -jar启动。如果能正常登录、上传、批改、下载,说明依赖和配置都干净了。我自己的习惯是每次改完数据库脚本,都会在本地先DROP DATABASE再重新导入一遍,确认没有残留依赖。这个习惯帮我省掉了答辩前夜发现“换台电脑就跑不起来”的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取