ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于Spring Boot的课程作业管理系统:从ER建模到提交批改实现

基于Spring Boot的课程作业管理系统:从ER建模到提交批改实现 简介这是一份基于Spring Boot开发的课程作业管理系统毕业设计资源面向计算机相关专业毕业生、Java Web开发者以及需要搭建作业管理平台的教育工作者。项目完整覆盖作业发布、提交、批改、查看等核心流程同时包含学生信息与教师角色管理模块界面友好教师和学生能够快速上手。系统采用模块化设计代码注释详细业务逻辑清晰具备良好的可扩展性和可定制性特别适合作为毕业设计参考项目或Spring Boot框架的实战学习案例。压缩包为rar格式大小37.38MB内含项目源代码与毕业论文文档可直接导入开发工具运行也能辅助论文撰写。目前已有81人学习下载对于正在筹备毕业设计或希望提升Java Web开发能力的学习者具有较高参考价值。1. 课程作业管理系统看着是增删改查难的是把状态机和文件流管住第一次拿到“基于 Spring Boot 开发的课程作业管理系统”这类题目时我习惯先问一句这门课到底交几次作业、老师怎么批、学生能不能补交、成绩在哪个环节落库。这些问题没想清楚之前把实体类建得再漂亮也是白搭。课程作业管理系统在大部分简历和毕设结构里长得都一样用户、课程、作业、提交记录四个页面加一个权限切面。但真把它按课程真实的运行节奏跑起来困难集中在文件怎么落盘、重复提交怎么算、批改后还能不能改成绩这些边界问题上。下面按我接手同类系统时会走的路子把 ER 建模、Spring Boot 分层工程、提交批改主链路以及一个能写进论文监控亮点的 HealthIndicator 讲完。示例代码可以直接抄进你自己的工程这里不附带打包源码。2. 从 ER 图到建表 SQL课程作业管理系统的领域模型2.1 实体识别五张表搞定不要一上来就拆权限表课程作业管理系统的数据域比普通商城要窄不涉及购物车、退款单、渠道分润所以领域建模的关键是把“一次作业提交”这个动作抽象清楚。我一般保留五张核心表user、course、course_student、assignment、submission其中 user 表通过 role 字段区分教师、学生、管理员不单独建 role 表和 user_role 关联表。原因很直接系统的权限动作只有“老师发布/批改”“学生提交/查看”“管理员维护课程和学生选课”三个静态角色拆三张表意味着每次判断权限都要多两次 join在答辩时还要解释一套 RBAC 设计收益很低。ER 关系实际就五条边教师 1:N 课程课程 N:M 学生选课表 course_student课程 1:N 作业作业 1:N 提交记录学生 1:N 提交记录。画 ER 图时我会把这五条边直接落在外键字段和联合索引上不再引入班级实体。班级如果确实需要建议在 course 表加 class_name 字段表达“该课程面向哪些班级”而不是单独建 class 表否则两学期同一门课程的数据归属会很难维护。2.2 关键表结构状态字段、成绩归属和附件路径assignment 表要表达作业的生命周期一个 status 字段存草稿 0、已发布 1、已截止 2。deadline 用 datetimestart_time 也用 datetime 而不是 date因为同一门课一天内可能开放多个提交窗口。submission 表是整套系统最需要想清楚的表它同时承担“学生交作业”“老师批改”“成绩记录”三件事所以 score、comment、status、grade_time 都放这一张表里不另建批改记录表。成绩字段归属这里有一个反直觉的决定成绩只写在 submission 表不在 assignment 表冗余一份平均分。课程平均分、提交率、按时提交人数全部在需要展示时用 group by 查询现算。毕设阶段数据量在几千到几万行没必要在写操作里维护统计字段查询侧一个索引就够。附件路径统一存相对路径例如/uploads/assignment/10001/1734595200000_cw.zip不存http://localhost:8080/...这种完整地址换部署环境时只要改一个全局前缀目录数据库不用动。2.3 可直接执行的 MySQL 建表脚本这个脚本去掉外键约束用应用层逻辑保证引用完整性理由放到脚本后面说明。CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL COMMENT 登录名, password VARCHAR(128) NOT NULL COMMENT BCrypt 密文, real_name VARCHAR(32) DEFAULT NULL, role TINYINT NOT NULL DEFAULT 3 COMMENT 1教师 2学生 3管理员, student_no VARCHAR(20) DEFAULT NULL COMMENT 学生学号教师/管理员为空, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE course ( id BIGINT NOT NULL AUTO_INCREMENT, course_name VARCHAR(64) NOT NULL, teacher_id BIGINT NOT NULL, semester VARCHAR(32) NOT NULL COMMENT 2024-2025-1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_teacher_semester (teacher_id, semester) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表; CREATE TABLE course_student ( id BIGINT NOT NULL AUTO_INCREMENT, course_id BIGINT NOT NULL, student_id BIGINT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_course_student (course_id, student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选课关系表; CREATE TABLE assignment ( id BIGINT NOT NULL AUTO_INCREMENT, course_id BIGINT NOT NULL, title VARCHAR(128) NOT NULL, content TEXT, attachment_path VARCHAR(255) DEFAULT NULL, start_time DATETIME NOT NULL, deadline DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1已发布 2已截止, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_course_deadline (course_id, deadline) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT作业表; CREATE TABLE submission ( id BIGINT NOT NULL AUTO_INCREMENT, assignment_id BIGINT NOT NULL, student_id BIGINT NOT NULL, file_path VARCHAR(255) NOT NULL, file_name VARCHAR(128) NOT NULL COMMENT 原始文件名, file_size BIGINT DEFAULT 0, content VARCHAR(500) DEFAULT NULL COMMENT 文字说明, status TINYINT NOT NULL DEFAULT 1 COMMENT 1已提交 2已批改 3已退回, score DECIMAL(5,2) DEFAULT NULL, comment VARCHAR(500) DEFAULT NULL, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, submit_time DATETIME DEFAULT NULL, grade_time DATETIME DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_assignment_student (assignment_id, student_id), KEY idx_student (student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT作业提交表;脚本里两个决策值得展开。第一submission 对 (assignment_id, student_id) 建唯一索引从数据库层面保证一个学生对一份作业只有一条有效提交记录程序里配合 version 做版本覆盖避免“重复提交”把成绩和批改记录弄乱。第二不用外键约束是因为批量导入学生、批量发布作业时外键在级联删除上容易误伤已交了作业的课程应用层在事务里逐个校验引用关系报错信息更精确。分页和统计查询主要命中 idx_course_deadline 与 uk_assignment_student数据量到十万行以内都不需要再调整。3. Spring Boot 工程骨架分层、依赖和 yml 里的关键参数3.1 包结构与 Maven 依赖的取舍课程作业管理系统的后端按 controller / service / mapper / entity 四层组织就够了不需要引入单独的 repository 抽象或者 DDD 分层。工程目录我按下述结构摆放src/main/java/com/example/coursework ├─ config # WebMvc、拦截器、异步配置 ├─ common # 统一返回体 Result、业务异常、状态码 ├─ controller # AssignmentController / SubmissionController / CourseController ├─ service # 接口和 impl 实现 ├─ mapper # MyBatis-Plus BaseMapper 子接口 ├─ entity # 对应五张表的实体类 └─ dto # 入参出参对象不做实体透传持久层我一般选 MyBatis-Plus而不是 JPA。原因是课程作业管理系统的查询条件组合很多按课程查未交名单、按截止时间查即将到期作业、按成绩区间导出MyBatis-Plus 的 LambdaQueryWrapper 不需要为每个组合写 SQLJPA 在动态条件上要么拆方法名要么上 Specification毕设阶段会带入额外的心智负担。Maven 依赖保留 web、validation、mysql、mybatis-plus、lombok 和 springdoc-openapi模板引擎、消息队列、缓存一类先不加。这里有高频问题“springboot 版本太高”的具体出处Spring Boot 3.x 里 javax.servlet 包改成 jakarta.servlet很多旧教程的过滤器代码会直接报 ClassNotFoundException。用 Spring Boot 3.2 时MyBatis-Plus 要引入mybatis-plus-spring-boot3-starter用 2.7 系列则注意它默认不兼容 JDK 17 以上的字节码。下表是我处理同类项目时的基准方案选型维度Spring Boot 2.7.xSpring Boot 3.2.x基础 JDK8 / 1117 起包名javax.*jakarta.*MyBatis-Plus 依赖mybatis-plus-boot-startermybatis-plus-spring-boot3-starterAPI 文档组件knife4j-openapi2 分支springdoc-openapi-starter-webmvc-ui旧教程复现度高中高注意包名迁移如果目标是顺利跑通且能解释每一步用 2.7 系列最省事如果想在论文里写“基于 JDK17 的模块化封装”就上 3.2 并把包名迁移写进踩坑记录。3.2 application.yml 的必调参数与密文选择server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/coursework?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD:root} servlet: multipart: max-file-size: 10MB max-request-size: 50MB jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0配置里三个参数最容易翻车。第一是 datasource 的 serverTimezone不写 Asia/Shanghai 时 MySQL 8 驱动会拿容器默认时区作业截止时间会差 8 小时表现成“刚过零点就交不了了”。第二是 multipart 的两个体积限制只配 max-file-size 不配 max-request-size前端一次提交多份作业时抛 MaxUploadSizeExceededException 而不是返回业务提示。第三是 map-underscore-to-camel-case 必须配合实体字段一起看grade_time 映射到 gradeTime这个开关关闭的话查询结果里全是 null。生产环境的数据库密码可以用 jasypt-spring-boot 做 ENC 密文论文“配置安全”一节有这个点可以讲本地开发直接用环境变量占位符${DB_PASSWORD:root}已经够用。3.3 实体、Mapper 和第一个分页查询接口实体属性用 Lombok 缩减样板代码Assignment 定义如下只展示核心片段Data TableName(assignment) public class Assignment { /** 主键自增 */ TableId(type IdType.AUTO) private Long id; private Long courseId; private String title; private String content; private LocalDateTime startTime; private LocalDateTime deadline; /** 0草稿 1已发布 2已截止用 Byte 而不是 boolean后续加状态不用改表 */ private Byte status; }对应的分页接口RestController RequestMapping(/api/assignment) RequiredArgsConstructor public class AssignmentController { private final AssignmentService assignmentService; GetMapping(/page) public ResultIPageAssignmentVO page(RequestParam(defaultValue 1) long pageNum, RequestParam(defaultValue 10) long pageSize, RequestParam(required false) Long courseId, RequestParam(required false) Byte status) { PageAssignment page new Page(pageNum, pageSize); IPageAssignmentVO result assignmentService.pageQuery(page, courseId, status); return Result.ok(result); } }Service 实现里的动态条件用 LambdaQueryWrapper 组装以status ! null作为拼接开关避免拼出where 11public IPageAssignmentVO pageQuery(PageAssignment page, Long courseId, Byte status) { LambdaQueryWrapperAssignment wrapper new LambdaQueryWrapper(); wrapper.eq(courseId ! null, Assignment::getCourseId, courseId) .eq(status ! null, Assignment::getStatus, status) .orderByDesc(Assignment::getDeadline); return assignmentMapper.selectPage(page, wrapper); }两个 RequestParam 的细节pageNum 从 1 开始是 MyBatis-Plus 的约定返回给前端时要带上 total 和 pages否则表格组件不知道总页数status 用包装类型 Byte 而不是基本类型 byte是为了区分“请求没传该参数”和“查状态为 0 的数据”。courseId 为空对应老师端“看所有课程下的作业”或管理端全量列表由前端决定传不传。接口逻辑只有十几行但把分页、动态条件、排序三个高性能考点一次覆盖。4. 作业提交、批改的主链路实现与并发兜底4.1 三个核心接口先约定状态流转最常被追问的接口不是登录而是作业生命周期与提交生命周期怎么衔接。我按三个主接口组织操作接口状态变化约束老师发布作业POST /api/assignment草稿 → 已发布必须配置 deadline学生提交作业POST /api/submission无 → 已提交 / 已提交版本 1作业已发布且未过截止老师批改POST /api/submission/{id}/grade已提交 → 已批改 / 已退回只能批改一次重复批改需先退回作业从草稿变已发布后不允许直接删除只能改成已截止这样学生的提交记录不会被级联清掉。提交流转是单向链学生提交 → 老师批改老师可以退回退回后学生才能重新提交。“退回才能重交”规则非常关键允许已批改的作业被覆盖score 会被悄悄改掉成绩统计就和批改结果对不上。4.2 提交接口文件落盘与同一学生幂等PostMapping(/submit) public ResultLong submit(RequestParam Long assignmentId, RequestParam(required false) String content, RequestPart(file) MultipartFile file) { Assignment assignment assignmentService.getById(assignmentId); if (assignment null || assignment.getStatus() ! 1) { throw new BizException(作业不存在或未开放提交); } if (LocalDateTime.now().isAfter(assignment.getDeadline())) { throw new BizException(已过截止时间); } // 后缀白名单只放行 zip/rar/doc/docx/pdf String ext StringUtils.getFilenameExtension(file.getOriginalFilename()); if (!Arrays.asList(zip, rar, doc, docx, pdf).contains(ext)) { throw new BizException(不支持的附件类型); } return submissionService.saveSubmission(assignmentId, content, file, ext); }saveSubmission 里用 assignment_id student_id 查已存在记录存在则更新 file_path 和 version不存在则插入。更新时用乐观锁兜底SQL 带where version ?。文件落盘路径按/uploads/assignment/{assignmentId}/{studentId}_{timestamp}.{ext}组织时间戳用System.currentTimeMillis()解决同一学生在毫秒级重复点击提交时的文件名冲突。容易踩的坑不要用file.getOriginalFilename()直接拼路径中文文件名和路径穿越字符都可能制造安全告警服务端重新生成文件名才是标准做法。4.3 批改接口分数校验、批语长度和成绩覆盖保护PostMapping(/{submissionId}/grade) public ResultVoid grade(PathVariable Long submissionId, RequestBody Valid GradeRequest request) { Submission submission submissionMapper.selectById(submissionId); if (submission null || submission.getStatus() ! 1) { throw new BizException(该记录不在可批改状态); } submission.setScore(request.getScore()); submission.setComment(request.getComment()); submission.setStatus((byte) 2); submission.setGradeTime(LocalDateTime.now()); submissionMapper.update(submission, new LambdaUpdateWrapperSubmission() .eq(Submission::getId, submissionId) .eq(Submission::getVersion, submission.getVersion())); return Result.ok(); }GradeRequest 里的 score 用DecimalMin(0.0)和DecimalMax(100.0)限制comment 用Size(max 500)限制长度。更新条件里带 version 判断是防止两个老师同时打开同一份提交记录一个退回一个给分后提交的更新把前一个覆盖。这里不加重试逻辑是故意的版本冲突时静默重试会让答辩讲不清“为什么同一条记录批了两次”让冲突直接抛给前端提示“数据已更新请刷新”更直观。4.4 并发与重复提交答辩追问里的高频考点“同一份作业提交了两次怎么处理”是面试和答辩都爱问的问题。我给出的三层防线唯一索引 uk_assignment_student 兜底并发插入防止应用层判断间隙产生两条 submission乐观锁版本号兜底并发更新防止两条更新请求互相覆盖Controller 层检查作业状态和截止时间做业务拦截让前两层只在极端场景起作用。把三层说清楚再补一句“这个数据量下用 Redis 分布式锁属于过度设计”基本就是标准答案。异步通知属于可以提但不必做的点作业系统的提交峰值通常在校验接口能扛住的范围内主动引入消息队列反而让论文多一个可用性讨论的包袱。5. 给提交服务加一个能写进论文的监控技巧自定义 HealthIndicatorSpring Boot Actuator 的/actuator/health默认只上报 UP 和 DOWN对毕业设计来说太单薄。自己实现一个 HealthIndicator把“上传目录磁盘剩余空间”和“最近一次提交写入时间”两个业务指标暴露出来既能填充论文章节又能在部署后快速定位批量提交失败的原因。这也是把 heapdump 敏感信息泄露这类风险挡在外的正确姿势只暴露 health 和 info不把 env、heapdump、beans 端点开给公网。Component RequiredArgsConstructor public class SubmitHealthIndicator implements HealthIndicator { private final SubmissionMapper submissionMapper; Override public Health health() { File uploadDir new File(/data/coursework/uploads); long freeBytes uploadDir.getUsableSpace(); double freeGb freeBytes / (1024.0 * 1024.0 * 1024.0); if (freeGb 1) { return Health.down() .withDetail(reason, upload disk free space under 1GB) .build(); } Submission last submissionMapper.selectOne( new LambdaQueryWrapperSubmission() .orderByDesc(Submission::getSubmitTime) .last(limit 1)); if (last ! null last.getSubmitTime().isBefore(LocalDateTime.now().minusHours(2))) { return Health.down() .withDetail(reason, no submission in last 2 hours) .build(); } return Health.up() .withDetail(freeDiskGb, Math.round(freeGb * 100) / 100.0) .build(); } }yml 里加一行management.endpoints.web.exposure.includehealth,info重启后请求/actuator/health会看到 components 里多出 submit 这一项。磁盘空间不足时作业文件写入必失败离线上交超过两小时说明服务可能阻塞或任务调度停了。部署环境上传目录不在/data/coursework时把目录路径抽成Value(${coursework.upload-dir})配置项读进来不要写死在常量里。健康检查不止给运维看也能作为论文“系统可观测性”一节里最有代码量的一小段实现。本文还有配套的精品资源点击获取
返回列表