news 2026/10/9 3:00:49

基于Java的在线作业提交批改系统:设计要点与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Java的在线作业提交批改系统:设计要点与避坑指南

简介:一套基于Java的在线作业提交批改系统毕业设计资料包,面向计算机相关专业毕业生或课程设计人群,覆盖管理员、学生、教师三大角色:系统支持管理员维护教师与学生账号,教师可定义课程、发布作业并设置提交期限,学生限时提交Word文档,教师下载批改并给出评分与评语,最终成绩可导出Excel。资源共454个文件,压缩包约16.81MB,包含68个Java源文件、71个class文件、94个xml配置、50个html、34个js与28个css等前后端代码,另有sql数据库脚本、xlsx报表模板、jpg/png图片素材以及字体图标文件,便于直接部署运行与二次开发。从内容预览可见系统包含完整的控制器层与业务模块,结构清晰。目前已有212人学习下载。整套资料适合作为SSM框架毕业设计的完整参考,从需求分析到编码实现均能复用,既可学习角色权限、文件上传与超时控制等典型逻辑,也能快速理解Java Web项目从数据库到前端的整体设计思路。

1. 基于JAVA在线作业提交批改系统:毕设选题先想清楚这四件事

“基于JAVA在线作业提交批改系统”,这类毕设选题每年都有人做。看上去很简单:学生上传作业,教师批改打分。但真正动手做就会撞见一堆边界条件——截止时间判断用哪个时区、文件重名会不会覆盖、批改分数写一半数据库崩了怎么回滚,任何一个都能让联调阶段反复翻车。这套系统业务不复杂,却把 Spring Boot + MyBatis Plus 开发链路里的表设计、文件存储、事务控制、接口联调全部覆盖了一遍,对 Java 工程师岗位的面试也有实际帮助。如果你正在为毕设选题发愁,这个方向值得投入,但动手之前先想清楚四件事:谁在用、作业存在哪、批改怎么记、成绩怎么出。想清楚再写代码,能少走很多弯路。

2. 系统架构与数据表设计:为什么 Spring Boot + MyBatis Plus 是稳妥组合

2.1 角色权限模型:学生、教师、管理员三类账号的数据流

在线作业系统的核心是三类角色:学生、教师、管理员。权限模型的落地方式直接决定后面代码的复杂度。常见做法是在 user 表加一个 role 字段,用字符串区分 student、teacher、admin,后端用拦截器做接口级校验。学生角色能访问的接口包括课程列表、作业列表、提交作业、查看自己的成绩;教师角色多了创建作业、查看提交列表、批改作业、导出成绩;管理员只负责用户管理和课程管理,不参与具体作业流程。这样拆完之后,接口访问控制不需要引入 Spring Security 的完整机制,一个 HandlerInterceptor 加一个自定义注解就能满足毕设需求。

数据流上,学生端发起提交请求,请求先经过权限拦截器确认登录身份是学生,再进入业务层做截止时间校验和文件存储,最后把提交记录写入数据库。教师端批改时同理,先确认当前用户是教师且作业属于该教师名下课程,再更新提交状态并插入批改记录。这个流程里最关键的是两个前提条件:学生能提交的前提是作业存在且未截止;教师能批改的前提是提交记录存在且状态为待批改。只要这两个条件在代码里没有显式判断,后面就会出现“页面看起来正常、数据却对不上”的怪现象。

2.2 六张核心表的设计:作业表、提交表、批改表的字段与关联

表结构是整个系统的地基,我用过一版设计,总共六张表:user、course、course_student、homework、submission、review。user 表存用户基本信息,字段包括 id、username、password、role、real_name、create_time,其中 role 字段承载了权限模型。course 表存课程信息,字段包括 id、course_name、teacher_id、description,teacher_id 指向 user 表,表示课程任课教师。course_student 是课程与学生的关联表,字段包括 id、course_id、student_id,对应“一个课程下多个学生、一个学生选多门课”的多对多关系。

homework 表是作业信息的核心,字段包括 id、course_id、title、description、deadline、create_time。这里 deadline 字段强烈建议用 datetime 类型,存数据库支持的时间字面量,不要用字符串。submission 表是最容易设计失误的一张表,字段包括 id、homework_id、student_id、file_name、file_path、file_size、submit_time、status。status 标记提交状态,0 代表待批改,1 代表已批改。有一种偷懒设计是把分数直接放在 submission 表里,少一张表,但“批改记录”语义就不完整,后续想记录批改历史或多次批改会非常尴尬。所以我坚持拆了一张 review 表。

review 表是批改记录表,字段包括 id、submission_id、teacher_id、score、comment、review_time。submission_id 和作业提交记录一一对应。我用过两种方案:一种直接把 submission_id 设成主键,另一种独立主键加唯一索引。实际开发中第二种更稳,因为后面一旦要扩展“教师重新批改”或“学生申诉复核”的需求,独立主键更容易改。

2.3 MyBatis Plus 根据 Java 实体类生成建表 SQL:一个反射工具的实现

很多用 MyBatis Plus 的同学会踩一个坑:JPA 有 ddl-auto 自动建表,MyBatis Plus 没有。它只负责 ORM 映射,建表得自己想办法。社区里常见的替代方案是写一个 DDL 工具类,通过反射读取实体类上的 @TableName、@TableId、@TableField 注解,按 Java 类型映射到 MySQL 类型,拼装成 CREATE TABLE IF NOT EXISTS 语句,在项目启动时执行。好处是表结构跟随实体类演进,新增字段时只改实体类就够了,不用同步改手写的建表脚本。

先看实体类的写法:

@Data @TableName("homework") public class Homework { @TableId(type = IdType.AUTO) private Long id; @TableField("course_id") private Long courseId; @TableField("title") private String title; @TableField("description") private String description; @TableField("deadline") private LocalDateTime deadline; @TableField("create_time") private LocalDateTime createTime; }

@TableName 声明表名,@TableId 标注主键,@TableField 把驼峰字段名映射成下划线列名。字段含义对应前面设计的 homework 表,id 是自增主键,course_id 关联课程表,deadline 用 LocalDateTime 类型承载截止时间。

工具类的核心实现如下:

public class DdlGenerator { public static String generateCreateTableSql(Class<?> entityClass) { TableName tableNameAnno = entityClass.getAnnotation(TableName.class); String tableName = tableNameAnno == null ? toSnakeCase(entityClass.getSimpleName()) : tableNameAnno.value(); StringBuilder sql = new StringBuilder("CREATE TABLE IF NOT EXISTS `" + tableName + "` ("); String primaryKeyColumn = null; Field[] fields = entityClass.getDeclaredFields(); for (Field field : fields) { field.setAccessible(true); TableId tableId = field.getAnnotation(TableId.class); TableField tableField = field.getAnnotation(TableField.class); String columnName; if (tableId != null) { columnName = tableId.value().isEmpty() ? toSnakeCase(field.getName()) : tableId.value(); primaryKeyColumn = columnName; } else { columnName = tableField == null || tableField.value().isEmpty() ? toSnakeCase(field.getName()) : tableField.value(); } sql.append("`").append(columnName).append("` ") .append(mapType(field.getType())).append(","); } sql.deleteCharAt(sql.length() - 1); if (primaryKeyColumn != null) { sql.append(", PRIMARY KEY (`").append(primaryKeyColumn).append("`)"); } sql.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); return sql.toString(); } private static String mapType(Class<?> javaType) { if (javaType == Long.class || javaType == long.class) return "BIGINT"; if (javaType == Integer.class || javaType == int.class) return "INT"; if (javaType == LocalDateTime.class) return "DATETIME"; if (javaType == String.class) return "VARCHAR(255)"; if (javaType == BigDecimal.class) return "DECIMAL(10,2)"; return "VARCHAR(255)"; } private static String toSnakeCase(String name) { return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); } }

这段代码的逻辑是:反射拿到实体类的所有字段,逐个读取注解,没有注解的字段用 toSnakeCase 自动把驼峰转下划线作为列名。mapType 方法维护 Java 类型到 MySQL 类型的映射规则。最后用 deleteCharAt 去掉最后一个多余的逗号,再把主键约束拼进去。 提示:这个工具能生成基本的建表语句,但不会自动处理索引和外键约束。批量分页查询、按课程查作业这类高频查询,建议手工在 SQL 里补索引,工具生成的建表语句只保证“能建表、字段对”。

3. 核心功能落地:作业上传、时间校验与在线批改的代码实现

3.1 文件上传接口:MultipartFile 接收、本地存储与扩展名校验

作业提交功能的心脏是上传接口。Spring Boot 接收文件用 MultipartFile 对象配合 @RequestParam("file") 注解。一个完整的提交接口要做这些事:校验文件是否为空、校验扩展名是否在白名单、校验作业是否未截止、把文件保存到磁盘、把提交记录写入数据库。Controller 层的实现如下:

@PostMapping("/submit") public Result submit(@RequestParam("file") MultipartFile file, @RequestParam("homeworkId") Long homeworkId, @RequestParam("studentId") Long studentId) { if (file.isEmpty()) { return Result.fail("文件不能为空"); } String originalName = file.getOriginalFilename(); String ext = originalName.substring(originalName.lastIndexOf(".") + 1).toLowerCase(); Set<String> allowedExts = new HashSet<>(Arrays.asList("pdf", "doc", "docx", "zip")); if (!allowedExts.contains(ext)) { return Result.fail("不支持的文件类型,仅允许 pdf、doc、docx、zip"); } String dateDir = "uploads/" + LocalDate.now().toString(); File dir = new File(dateDir); if (!dir.exists()) { dir.mkdirs(); } String storedName = UUID.randomUUID() + "." + ext; File target = new File(dir, storedName); try { file.transferTo(target); } catch (IOException e) { return Result.fail("文件保存失败,请重试"); } // 调用 service 写入提交记录 submissionService.createSubmission(homeworkId, studentId, originalName, target.getPath()); return Result.success("提交成功"); }

这里有几个细节值得展开。UUID.randomUUID() 生成随机文件名,从根上避免同名文件互相覆盖;原始文件名 originalName 单独存数据库,教师下载时用来还原展示名。transferTo 方法直接把 MultipartFile 写入磁盘,适合中小文件。扩展名用白名单集合而不是黑名单,白名单更安全,省得后面还要考虑 .exe、.jsp 之类不该传的类型。文件大小校验放在后面的全局配置里做,Controller 只关心业务逻辑。

提交记录写入数据库之前,必须做截止时间校验。我一般放在 service 层,代码如下:

public void createSubmission(Long homeworkId, Long studentId, String originalName, String filePath) { Homework homework = homeworkMapper.selectById(homeworkId); if (homework == null) { throw new BizException("作业不存在"); } if (LocalDateTime.now().isAfter(homework.getDeadline())) { throw new BizException("已超过截止时间,无法提交"); } Submission submission = new Submission(); submission.setHomeworkId(homeworkId); submission.setStudentId(studentId); submission.setFileName(originalName); submission.setFilePath(filePath); submission.setSubmitTime(LocalDateTime.now()); submission.setStatus(0); submissionMapper.insert(submission); }

注意 deadline 的比较用的是 LocalDateTime.now(),这就要求数据库连接时区、服务器时区、前端展示时区必须一致。时区不一致导致的“前端还能交、后端提示已截止”的情况,在避坑章节展开说。另外,提前把 homework 查出来还有个额外好处:可以在一个事务里同时完成时间校验和提交记录写入,避免高并发下先判断后插入的竞态问题。

3.2 在线批改与分数回写:事务边界怎么划才不出错

批改功能是另一个容易出问题的模块。逻辑上批改做了三件事:更新 submission 表的 status 为已批改;向 review 表插入一条批改记录;如果系统有作业平均分统计,还要同步更新统计数值。这三步必须在一个事务范围内,否则会出现“分数写进去了、状态还是待批改”的不一致现象。

Spring Boot 事务控制最简单的方式是 @Transactional 注解,放在 Service 实现类的 public 方法上:

@Service public class ReviewServiceImpl implements ReviewService { @Transactional(rollbackFor = Exception.class) @Override public void review(Long submissionId, Integer score, String comment) { Submission submission = submissionMapper.selectById(submissionId); if (submission == null) { throw new BizException("提交记录不存在"); } if (submission.getStatus() == 1) { throw new BizException("该作业已被批改,请勿重复操作"); } // 第 1 步:更新提交状态为已批改 submission.setStatus(1); submissionMapper.updateById(submission); // 第 2 步:插入批改记录 Review review = new Review(); review.setSubmissionId(submissionId); review.setScore(score); review.setComment(comment); review.setReviewTime(LocalDateTime.now()); reviewMapper.insert(review); } }

rollbackFor = Exception.class 这个参数特别关键。Spring 默认只对 RuntimeException 回滚,对 checked exception 不回滚。如果项目里自定义的 BizException 继承自 Exception 而不是 RuntimeException,事务就不会回滚。所以我一直建议业务异常统一继承 RuntimeException,这样 @Transactional 遇到任何失败情况都会触发回滚。

还有一个很容易被忽略的点:@Transactional 必须放在 public 方法上,而且要由外部调用方通过注入的 Service 对象来调用。如果在同一个类里用 this.review() 自调用,Spring AOP 代理不会生效,事务就完全失效了。判断事务是否生效有一个笨办法:在 review 方法里故意抛一个 RuntimeException,看数据库里的记录有没有变化。如果无法回滚,那就说明事务没生效,赶紧检查调用方式。

3.3 成绩统计查询:QueryWrapper 的边界与聚合 SQL 的写法

成绩统计用于教师端展示。比如查看某门课程的平均分、及格率、每个学生的提交次数。简单的条件查询可以用 MyBatis Plus 的 QueryWrapper,但一旦涉及多表 join 或者聚合函数,QueryWrapper 就力不从心了。我的习惯是:单表简单条件用 QueryWrapper,多表统计直接用 Mapper 注解写 SQL。

@Mapper public interface StatisticsMapper { @Select("SELECT AVG(r.score) FROM review r " + "JOIN submission s ON r.submission_id = s.id " + "JOIN homework h ON s.homework_id = h.id " + "WHERE h.course_id = #{courseId}") BigDecimal getAvgScore(@Param("courseId") Long courseId); @Select("SELECT COUNT(DISTINCT s.student_id) " + "FROM submission s JOIN homework h ON s.homework_id = h.id " + "WHERE h.course_id = #{courseId}") Integer countSubmitters(@Param("courseId") Long courseId); }

@Select 注解直接写在 Mapper 接口方法上,MyBatis 启动时会自动解析。参数用 @Param 绑定,避免 SQL 注入问题。聚合 SQL 在数据量不大的毕设场景下完全够用,但如果课程人数上千,AVG 这类聚合查询建议加索引,否则全表扫描会拖慢响应。

成绩导出是教师端的高频需求,用 Apache POI 即可。Controller 返回 ResponseEntity 包装的 byte[],前端拿到的就是可下载的 Excel 文件:

@GetMapping("/exportScore") public ResponseEntity<byte[]> exportScore(@RequestParam Long courseId) { List<ScoreRow> rows = statisticsMapper.getCourseScores(courseId); try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { 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("分数"); for (int i = 0; i < rows.size(); i++) { ScoreRow row = rows.get(i); Row dataRow = sheet.createRow(i + 1); dataRow.createCell(0).setCellValue(row.getStudentId()); dataRow.createCell(1).setCellValue(row.getStudentName()); dataRow.createCell(2).setCellValue(row.getHomeworkTitle()); dataRow.createCell(3).setCellValue(row.getScore()); } workbook.write(out); String fileName = URLEncoder.encode("成绩单_" + courseId + ".xlsx", "UTF-8"); return ResponseEntity.ok() .header("Content-Disposition", "attachment; filename*=UTF-8''" + fileName) .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) .body(out.toByteArray()); } catch (IOException e) { throw new BizException("导出成绩失败"); } }

这段代码有个容易忽略的地方:下载文件名的中文乱码。早期我用的是 filename=成绩单.xlsx,前端拿到的文件名直接是乱码。后来改成 filename*=UTF-8'' 加 URLEncoder 编码,才算稳定解决。还有,Workbook 和 ByteArrayOutputStream 写在 try-with-resources 里,保证流关闭,避免内存泄漏,这个习惯对 POI 这种重对象特别重要。

4. 前后端联调与接口设计:REST 规范、跨域配置与统一返回体

4.1 接口 URL 设计与统一返回体:从风格到语义

在线作业系统的接口设计直接影响后期联调和答辩演示的流畅度。我一般遵循 REST 风格:资源用名词、操作用 HTTP 方法、参数尽量放路径或 query 参数而不是 body。下面这组接口是从实际项目里沉淀出来的,按这个口径设计,前端同学看文档基本不会产生歧义。

方法URL作用
POST/api/auth/login登录,返回 token
GET/api/homework/list?courseId=1查询课程作业列表
POST/api/submission/submit提交作业(multipart/form-data)
GET/api/submission/detail?submissionId=8查看提交详情
POST/api/review/doReview批改作业
GET/api/statistics/courseAvg?courseId=1统计课程平均分

统一返回体用 Result 包装,前端拿到响应先判断 code 再处理数据。这里给出定义:

@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMsg("success"); result.setData(data); return result; } public static <T> Result<T> fail(String msg) { Result<T> result = new Result<>(); result.setCode(500); result.setMsg(msg); return result; } }

统一返回体的好处是前端可以做一个全局拦截器,统一处理 401、500、200 三类状态码。code=200 正常解析数据;code 非 200 弹出 msg 里的错误提示。很多同学会直接在 Controller 里返回裸对象,前端每个方法都要 try-catch,这是日后联调效率低下的根源。

4.2 跨域与认证:让前端页面真正连上后端

前后端分离是这套毕设的主流做法。前端用 Vite 起 Vue 服务,默认跑在 5173 端口,后端 8080 端口,两者一接触就撞上跨域问题。Spring Boot 里配置跨域推荐用 CorsFilter:

@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", config); return new CorsFilter(source); } }

一个非常阴间的坑是:允许所有来源时,不能使用 addAllowedOrigin(""),否则开启 allowCredentials(true) 后浏览器会直接拒绝响应。必须用 addAllowedOriginPattern("")。这个差异不踩一次根本想不到,报错信息还特别隐蔽,只会在控制台打一行 CORS error。

认证部分,毕设一般不用完整 JWT 方案,但简单登录态还是要做的。常见做法是登录成功后由后端生成 token 并保存到 Redis,过期时间设 30 分钟,前端把 token 存 localStorage,之后每个请求在请求头带 Authorization 字段。后端用拦截器统一验证:

@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !RedisUtil.hasKey(token)) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}"); return false; } return true; } }

这里把 Redis 作为 token 的黑匣子来用:登录时写入,拦截器里查存在性。Redis 的过期机制天然实现了“登录态过期”,比 session 方案更贴近生产环境,也是面试中有内容可讲的点。注册拦截器时注意要让登录接口 /api/auth/login 跳过拦截,否则自己人打不开门。

4.3 Swagger 接口文档与 Postman 调试用例

联调阶段效率的大头在接口文档。Spring Boot 集成 Swagger 三件事:加依赖、配置 Docket、给 Controller 写注解。在 Controller 方法上加上 @ApiOperation 和 @ApiParam,Swagger UI 里的可读性立刻上一个台阶:

@ApiOperation("提交作业") @PostMapping("/submit") public Result submit( @ApiParam("作业文件") @RequestParam("file") MultipartFile file, @ApiParam("作业ID") @RequestParam("homeworkId") Long homeworkId, @ApiParam("学生ID") @RequestParam("studentId") Long studentId) { // ... }

Swagger 文档生成后,建议在 Postman 里建一个 collection,把新增接口全部跑通。重点看两个地方:文件上传接口的请求体格式必须选 form-data,MultipartFile 字段才能绑定成功,我见过不止一次有人用 raw JSON 调上传接口,一直报 “Required request part 'file' is not present”;带 token 的接口需要在 Postman 的 Authorization 页签里配置 Bearer Token,否则请求直接返回 401。每完成一个模块,我习惯把所有接口在 Postman 里跑一遍形成回归用例,后端改动后直接全部重跑一遍,能节省大量返工时间。

5. 避坑指南:在线作业提交批改系统最常见的 5 个翻车现场

5.1 文件重名覆盖:两次提交,作业变成了同一份

现象:学生第一次提交的作业叫《实验报告.docx》,教师批改后让学生修改,学生修改完再次提交同名文件,此时教师下载看到的却是第一次提交的内容,新文件不见了。

原因:代码里直接使用 file.getOriginalFilename() 作为磁盘文件名。第二次提交的文件名与第一次同名,操作系统层面直接覆盖了服务器上的旧文件。数据库里的提交记录虽然更新了 filePath,但指向的还是同一个磁盘文件,内容已经是旧版本。

解决:存储文件名用 UUID 生成唯一名称,与原始文件名完全解耦。原始文件名仅作为展示字段存入数据库,磁盘上永远用 uploads/2025/06/01/uuid.docx 这样的路径。如果还允许“多次重交覆盖旧记录”,需要在 service 层先查出旧提交记录,删除旧文件再保存新文件,否则磁盘上会堆积垃圾文件。

5.2 批改分数丢失:事务没加对地方,回滚没生效

现象:教师在前端页面输入批改分数和评语,界面显示成功,但退出重新进入后分数空白,提交状态仍然是待批改。数据库里 review 表也没有记录。

原因:@Transactional 注解写在了 Controller 的私有方法上,或者是 Service 类内部用 this.xxx() 自调用,Spring AOP 代理没生效。还有一种常见写法是把 rollbackFor 去掉,BizException 继承的是 Exception 而不是 RuntimeException,导致事务没有回滚。

解决:事务注解统一放到 ServiceImpl 的 public 方法上,并且由 Controller 注入 Service 接口来调用。自定义业务异常继承 RuntimeException。验证方法很直接:在 review 方法里故意抛一个 BizException,然后查看数据库 review 表是否新增了记录。如果没有新增,说明事务回滚确实生效了。

5.3 截止时间前后端不一致:数据库时间和服务器时间打架

现象:前端页面展示截止时间是 23:59,学生 23:58 点击提交,后端却返回“已超过截止时间”。交个作业提前了整整一天,学生直接懵了。

原因:三种典型情况:MySQL JDBC 连接串没有指定 serverTimezone,MySQL 用了默认时区;服务器操作系统时间设置成了 UTC;前端拿到时间后,用浏览器本地时区做了转换,而后端用 LocalDateTime.now() 取的是 JVM 默认时区。任一种都会导致前后端时间差出 8 个小时或者更多的偏差。

解决:全链路统一使用中国标准时区。JDBC 连接串加上 serverTimezone=Asia/Shanghai,服务器执行 timedatectl set-timezone Asia/Shanghai,JVM 启动参数加 -Duser.timezone=Asia/Shanghai。前端传递时间时统一用时间戳或者 ISO 8601 字符串,由后端解析,不在前端做时区换算。涉及跨时区的演示,比如答辩现场连的数据库在云上,这一步尤其重要。

5.4 大文件上传内存溢出:默认限制和容器配置冲突

现象:上传一个 50MB 的压缩包时,后端直接抛 MaxUploadSizeExceededException 或者内存溢出,Tomcat 返回 413。

原因:Spring Boot 默认 multipart 限制是 1MB,超过就报错。如果直接改到 100MB,MultipartFile 一次性把整个文件读进内存,JVM 堆内存不够就会 OutOfMemoryError。很多机器默认堆只有 256MB 或 512MB,几十 MB 文件乘上并发请求,堆瞬间被打满。

解决:在 application.yml 里按需调大,并设置合理阈值:

spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MB

同时,对超过 10MB 的文件,建议使用 CommonsMultipartResolver 配合磁盘暂存,或者手动把 MultipartFile 以流式方式写入磁盘,避免一次性加载到内存。毕设场景不用追求分片上传,但提前在论文里写上“大文件采用流式写入,降低内存占用”这句话,答辩时能成为一个加分的技术点。

5.5 并发提交同一份作业:最后一次提交覆盖之前记录

现象:两个学生在同一秒先后提交了各自版本的作业,结果数据库里只有一条提交记录,后提交的文件覆盖了前面同学的。

原因:submission 表没有对 homework_id + student_id 做唯一约束,或者代码里没有做“已存在提交记录”的前置判断,导致 insert 插入了两条,而业务查询只会取其中一条,造成“丢失”。

解决:在数据库层加兜底约束,对 submission 表建立唯一索引:

ALTER TABLE submission ADD UNIQUE KEY uk_homework_student (homework_id, student_id);

代码里先查询再插入,如果已存在记录,视业务规则决定是拒绝重复提交还是更新文件路径。更稳妥的做法是使用 INSERT ON DUPLICATE KEY UPDATE 把旧记录文件路径和提交时间一并更新,但旧文件记得从磁盘删除,否则时间久了服务器会堆满无用文件。数据库唯一索引是最可靠的兜底,代码判断只能处理单线程,无法挡住并发场景。

6. 答辩之前,用集成测试和压测把风险降到最低

6.1 三个核心场景的 JUnit 集成测试

答辩前最后一周,我习惯把核心链路写进 JUnit 集成测试,用 @SpringBootTest 启动完整上下文,跑通“提交作业、截止时间拦截、重复批改拦截”三个场景。下面这套测试可以直接抄进项目里:

@SpringBootTest @Transactional class SubmissionFlowTest { @Autowired private SubmissionService submissionService; @Autowired private ReviewService reviewService; @Test void 提交作业会生成提交记录() { submissionService.createSubmission(1L, 2L, "实验一.pdf", "uploads/2025/06/01/uuid.pdf"); Submission record = submissionService.getByHomeworkIdAndStudentId(1L, 2L); assertNotNull(record); assertEquals(0, record.getStatus()); } @Test void 已过期作业提交被拒绝() { // homeworkId=2 的作业 deadLine 已设为过去时间,见测试数据 assertThrows(BizException.class, () -> submissionService.createSubmission(2L, 2L, "补交.pdf", "uploads/...")); } @Test void 重复批改被拦截() { reviewService.review(1L, 85, "做得不错"); assertThrows(BizException.class, () -> reviewService.review(1L, 60, "重复批改")); } }

@Transactional 注解让每个测试方法跑完自动回滚,测试数据不会污染开发库。测试数据的准备是关键,我一般在 resources/test 目录放一份 data.sql,里面预置一门课程、两份作业(一个未过期、一个已过期)、一个学生账号。答辩前跑一遍 mvn test,看到绿色输出,心里就有底了。

6.2 演示数据准备与答辩演示路径

答辩演示比功能数量更重要的是顺畅度。建议准备 3 个学生账号、1 个教师账号、一门课程、4 份作业,其中一份已批改、一份待批改、一份已过期、一份即将截止。演示顺序按“学生提交作业 → 教师批改 → 成绩统计和导出”来走,正好覆盖三条核心链路。数据库里放 20 条左右的提交记录,页面列表才不至于空荡荡。演示时机器上开好数据库、后端、前端三个终端,切屏时用 Alt+Tab 顺序来回切换,不要手忙脚乱。

我自己的习惯是每次改完代码,先把 JUnit 集成测试跑一遍再去做别的事。有一回就是因为漏了事务回滚验证,答辩前一天才发现分数写不进去,连夜改的,那种感觉实在不想再来第二次。把核心链路用测试钉死,演示时才能稳如老狗。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 3:00:31

HarmonyOS 7 系统能力 03|文件读写

这一篇解决的问题&#xff1a;文件保存到底该放哪。我们不背 API&#xff0c;而是从一个真实页面需求出发&#xff0c;把“应用沙箱中的文本文件读写”做成能继续扩展的工程写法。先说问题&#xff1a;功能能跑&#xff0c;不等于接对了 做 HarmonyOS 7 页面时&#xff0c;最容…

作者头像 李华
网站建设 2026/10/9 2:58:07

DeepSeek+MIDI:从文本意图到可播放MIDI的AI作曲实战

简介&#xff1a;这份PDF文档面向对AI音乐创作感兴趣的技术开发者与音乐爱好者&#xff0c;系统讲解如何将DeepSeek大模型与MIDI技术结合&#xff0c;实现原创音乐的自动生成。内容从AI作曲技术背景切入&#xff0c;涵盖DeepSeek模型特点与调用步骤、MIDI文件结构与解析、数据预…

作者头像 李华
网站建设 2026/10/9 2:57:14

工控数据类型与值范围详解:从PLC到Modbus的解析避坑指南

1. 从一次通讯调试翻车说起&#xff1a;为什么数据类型值得单独拎出来讲刚入行那会儿&#xff0c;我接手过一个改造项目&#xff1a;用上位机通过 Modbus RTU 读取一台老设备的温度值。协议文档上白纸黑字写着"温度寄存器地址 40001&#xff0c;单位 0.1℃"。我照着地…

作者头像 李华
网站建设 2026/10/9 2:56:42

SRP Batcher:让CPU少为绘制“重复准备”

SRP Batcher 主要优化的是&#xff1a;CPU 为连续绘制准备和绑定 Shader 数据的开销。 它通常不会减少 Draw Call 数量&#xff0c;也不会让 GPU 少画几个三角形。 先记住这组对比&#xff1a; GPU Instancing&#xff1a;多个实例尽可能放进一次绘制。SRP Batcher&#xff1a;…

作者头像 李华
网站建设 2026/10/9 2:56:29

ntko控件实战:浏览器内Word编辑、盖章与留痕开发指南

简介&#xff1a;这份资源围绕NTKO Office文档控件的使用展开&#xff0c;面向需要在Web应用中实现在线文档编辑与处理的开发者&#xff0c;尤其适合企业级文档管理系统的搭建者。内容涵盖控件接口参考、JavaScript编程指南、技术白皮书及多版本函数功能列表&#xff0c;帮助读…

作者头像 李华