毕业设计季又要到了,每年这时候都有大量同学被同样的题目卡住——“基于Spring Boot的在线作业管理系统”。说实话,这个题目我在实际项目中接手过不少次,它既是典型的Spring Boot入门到进阶的实战项目,又覆盖了文件上传、权限控制、定时任务、数据库设计这几个Java后端的核心考点。无论你是拿它做毕业设计、课程设计,还是纯粹想通过一个完整项目把Spring Boot的知识串起来,这个选题都很合适。但恰恰是因为太常见,网上类似的代码和文档多到泛滥,真正能讲清楚“为什么这么做”,而不是只贴一堆CRUD代码的资料反而很少。这篇文章我就从需求分析、技术选型、表结构设计、核心功能实现到部署上线,把整个系统的设计思路和踩坑经验完整拆给你看。
1. 项目整体设计与方案选型
1.1 需求梳理:这个系统到底要解决什么问题?
很多人一上来就写代码,结果做着做着发现功能越加越多,数据库表改了一遍又一遍,最后项目变成一坨难以维护的“屎山”。我建议拿到这个题目的第一件事,是坐下来把角色和流程梳理清楚。
一个在线作业管理系统,核心角色无非三类:教师、学生、管理员。
从教师视角看,需要的是:创建课程、发布作业、设置截止时间、查看提交情况、在线批改打分、导出成绩。从学生视角看,需要的是:查看作业列表、在线提交作业(文本答案或上传附件)、查看批改结果和评语。从管理员视角看,需要的是:用户管理、课程管理、数据统计,比如每个教师布置了多少作业、学生提交率如何。
画成一张用例图就非常清晰了。我见过很多同学把这道题做成“学生CRUD + 作业CRUD”就交差,那基本拿不到高分,因为思维还停留在“数据增删改查”的层面。真正关键的几个点在于:作业有截止时间,所以涉及状态流转(未开始、进行中、已截止、已批改);作业允许附件提交,所以涉及文件上传下载;提交后不能随意修改,所以涉及幂等设计和状态校验;批改有分数和评语,所以涉及成绩统计。这些才是这个项目的核心难点,也是你答辩时能拿出来讲的亮点。
1.2 技术选型:Spring Boot 的边界在哪里该用谁?
技术选型是这个项目最容易出问题的地方,尤其是近两年Spring Boot版本升级很快,网上教程参差不齐,很多同学照着老教程做,结果依赖一拉全是报错。
先说一个很现实的情况:如果你是在校生做毕设,我强烈建议使用 Spring Boot 2.7.x + JDK 1.8 这套组合。为什么?因为大部分学校的毕业设计环境、答辩用的服务器、机房电脑,装的都还是JDK 1.8,而且Spring Boot 2.7.x是2.x系列最后一个稳定版本,兼容性最好,网上的中文资料也最多。Spring Boot 3.x虽然已经发布很久,但它要求JDK 17起步,而且javax包名改成了jakarta,很多老教程里的代码直接搬过来会编译失败。如果你想用3.x图个新,那也要做好遍地是坑的准备,我在第4章会详细讲。
其他核心选型我直接给出我实测下来比较稳妥的组合:
| 组件 | 推荐方案 | 理由 |
|---|---|---|
| 持久层框架 | MyBatis-Plus | 单表CRUD不用写SQL,复杂统计用注解SQL搞定 |
| 权限认证 | JWT + Spring拦截器 | 前后端分离场景最轻量,比Session省心 |
| 前端 | Vue 2 + Element UI | 和后端同学配合时最不容易出幺蛾子 |
| 文件存储 | 本地磁盘 + Nginx映射 | 毕设级别不需要OSS,本地存储逻辑最简单清楚 |
| 数据库 | MySQL 5.7 或 8.0 | 都行,注意驱动和连接串版本匹配即可 |
有人会问,为什么不上Redis做缓存、为什么不用Spring Security做安全认证?不是不行,而是这个项目的业务量级根本用不到,你引入Redis只会给毕业答辩增加“为什么用Redis”的追问风险。Spring Security学习曲线陡峭,配置复杂,对理解核心业务没有帮助,用拦截器自己实现一个简单的JWT校验,反而能把权限控制的原理吃得透透的。
1.3 项目结构与分层设计
选好了技术栈,接下来聊聊代码结构。我见过不少同学写的项目,包结构五花八门——有人把所有的类都塞进一个包,有人把Controller里写了几百行业务逻辑,还有人把SQL直接写在controller里。这些做法在项目小的时候看着没事,等你加功能的时候就会痛苦到怀疑人生。
我习惯的标准分包方式是:
code复制com.example.homework
├── common # 统一返回结果、异常处理、常量
├── config # 配置类:跨域、拦截器注册、文件上传配置
├── controller # 接口层,只做参数接收和结果返回
├── service # 业务层,核心逻辑都在这层
│ └── impl
├── mapper # MyBatis-Plus的Mapper接口
├── entity # 数据库实体类
├── vo # 视图对象,给前端用的组合数据
└── utils # 工具类,比如JWT工具、文件上传工具
分层最核心的原则是:Controller要“薄”,Service要“厚”,Mapper只碰单表。Controller只负责参数校验和调用Service,具体怎么查数据、怎么组装VO是Service的事,而Mapper层面尽量不写跨表的多表关联SQL,宁可多查一次然后在Service里组装。这样做的好处是后期需求变更时改动范围最小。举个实际例子:你在作业列表页面想展示“该作业已提交人数”,如果用连表查询,得改Mapper的SQL;如果你在Service里先查作业列表,再根据作业ID查提交数量,只需要增加一个方法就够了,对原有代码完全无侵入。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与核心表结构
2.1 实体关系分析:表和表之间到底是什么关系?
在线作业管理系统的数据库设计,决定了你后面所有功能的实现难度。做设计时先在纸上画出实体关系,不要直接开建表。
核心实体包括:用户(涵盖教师和学生)、课程、作业、提交记录、附件。它们之间的关系是这样的:一个教师可以创建多门课程,一个学生可以选择多门课程,所以课程和学生之间是多对多关系,需要一张选课关联表;一门课程下有多份作业,所以课程和作业是一对多;一份作业对应多条学生提交记录,所以作业和提交记录是一对多;一次提交可以附带多个文件,所以提交记录和附件是一对多。
这个关系建模看着简单,但很多人的表会在这里埋下隐患。最常见的问题是:有人把教师信息单独建表、学生信息单独建表,再用外键关联。这是一种想当然的做法,因为教师和学生本质上都是“用户”,只是角色不同。如果把两类人分开存,权限认证的时候要查两张表,跨角色操作(比如一个用户既是助教又是学生)时数据根本没法复用。正确做法是建一张user表,加role字段区分角色,再加profile信息字段存学号或工号。这样登录认证只需要查这一张表,JWT里放userId和role就能搞定所有权限判断。
2.2 核心表结构设计与字段说明
下面给出我认为最精简、最够用的核心表设计,直接按这个建表基本能覆盖所有功能,并且在你答辩时数据库设计这一项是加分项:
用户表(user)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| username | varchar(50) | 用户名,通常用学号/工号 |
| password | varchar(255) | BCrypt加密后的密码 |
| real_name | varchar(50) | 真实姓名 |
| role | tinyint | 1-学生 2-教师 3-管理员 |
| avatar | varchar(255) | 头像路径 |
| status | tinyint | 0-禁用 1-正常 |
| create_time | datetime | 创建时间 |
课程表(course)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar(100) | 课程名称 |
| code | varchar(50) | 课程编号,选课码 |
| teacher_id | bigint | 授课教师ID |
| description | text | 课程简介 |
| create_time | datetime | 创建时间 |
选课表(course_student)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| course_id | bigint | 课程ID |
| student_id | bigint | 学生ID |
| enroll_time | datetime | 选课时间 |
这里需要注意,这个表要加唯一索引(course_id, student_id),避免学生重复选同一门课。很多同学就是忘了这个约束,导致数据里出现重复选课记录,后面统计“选课人数”时怎么查都对不上。
作业表(homework)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| course_id | bigint | 所属课程ID |
| title | varchar(200) | 作业标题 |
| content | text | 作业要求描述 |
| deadline | datetime | 截止时间 |
| total_score | int | 总分 |
| status | tinyint | 0-未发布 1-进行中 2-已截止 |
| create_time | datetime | 创建时间 |
为什么这里有一个status字段,因为作业可以“未发布”保存草稿,也可以到时间自动变为“已截止”,这个字段是作业列表状态显示和提交权限判断的依据。
提交记录表(submission)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| homework_id | bigint | 作业ID |
| student_id | bigint | 学生ID |
| content | text | 文本答案内容 |
| submit_time | datetime | 提交时间 |
| update_time | datetime | 修改时间 |
| is_late | tinyint | 是否补交 0-否 1-是 |
| score | int | 批改得分 |
| comment | varchar(500) | 教师评语 |
| status | tinyint | 0-未提交 1-待批改 2-已批改 |
| file_id | bigint | 附件ID |
附件表(file)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| original_name | varchar(255) | 原始文件名 |
| stored_name | varchar(255) | 存储文件名,防止重名 |
| path | varchar(255) | 存储路径 |
| size | bigint | 文件大小(字节) |
| upload_time | datetime | 上传时间 |
这里有一个重要设计:提交记录表中的content和file字段都允许为空,因为学生可以选择“只填写文本答案”或“只上传附件”或“两者都有”。另外,score和comment字段在提交后是空的,只有教师批改后才写入,所以status字段就能准确表达提交记录的状态。
2.3 状态字段设计:别用时间比对代替状态判断
我见过一个很典型的问题:判断作业是否已截止,有人能在业务代码里写if (now.after(homework.getDeadline())),然后每次查询都实时计算。这个思路本身没错,但问题在于,当作业有草稿态(未发布)和截止态的区别后,仅仅靠deadline是判断不出来的。未发布的作业也可以设置一个deadline,但你不能让学生看到,更不能让学生提交。
所以我的做法是给作业表增加一个status字段,把状态显式存储。业务流程是这样的:教师新建作业时status=0,点击发布后status=1;系统提供一个定时任务,每分钟扫描一次,把所有deadline < now()且status=1的作业批量改成status=2。定时任务扫描的频率不需要太高,1分钟一次足够了,因为作业截止时间通常精确到分钟级就够了。这样设计的好处是,业务查询只需要判断status,SQL查询都能走到索引,效率高、逻辑清晰。如果你担心定时任务有延迟,可以在查询提交记录时再做一个兜底判断:如果deadline已过,即使status还是1,也不能继续提交。这就是“状态字段 + 实时判断”双保险,我在第3章会给出具体代码。
3. 核心功能实现与关键代码拆解
3.1 登录认证与权限控制
登录认证这块,我推荐用JWT来实现。JWT的原理一句话概括:用户登录成功后,服务端生成一个签名的token返回给前端,后续请求前端在Header里带上这个token,服务端解析并验证签名后就能确定用户身份。对比传统的Session方案,JWT是无状态的,后端不需要存储session数据,天然适合前后端分离架构。
核心实现分三步:第一步是登录接口,验证用户名密码,生成token返回;第二步是一个拦截器,拦截所有需要登录的请求,解析token并校验;第三步是权限判断,根据token里的role信息判断是否有权访问某个接口。
以Spring Boot 2.7.x为例,登录接口的核心逻辑大概是:
java复制@Service
public class UserServiceImpl implements UserService {
@Override
public String login(String username, String password) {
// 1. 根据用户名查用户
User user = userMapper.selectOne(
new LambdaQueryWrapper<User>()
.eq(User::getUsername, username));
// 2. 判断用户是否存在且状态正常
if (user == null || user.getStatus() == 0) {
throw new BusinessException("用户名或密码错误");
}
// 3. 密码校验,这里用的是BCrypt加密
if (!BCrypt.checkpw(password, user.getPassword())) {
throw new BusinessException("用户名或密码错误");
}
// 4. 生成JWT,过期时间设为72小时
String token = JwtUtil.createToken(user.getId(), user.getRole());
return token;
}
}
然后是拦截器的实现。这里要特别注意排除放行路径,比如登录接口、注册接口、文件下载接口等不需要token就能访问的路径:
java复制@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true; // 静态资源直接放行
}
// 从Header获取token
String token = request.getHeader("Authorization");
if (StringUtils.isBlank(token)) {
throw new BusinessException(403, "未登录");
}
try {
Claims claims = JwtUtil.parseToken(token);
// 把用户ID放入request上下文,方便后续使用
request.setAttribute("userId", claims.get("userId"));
request.setAttribute("role", claims.get("role"));
return true;
} catch (Exception e) {
throw new BusinessException(401, "登录已过期,请重新登录");
}
}
}
如果你希望更精细化地做权限控制,比如“只有教师才能发布作业”,可以再写一个@RequireRole注解配AOP,或者在Controller里手动判断request.getAttribute("role")。毕设级别我建议用后一种,逻辑直观、代码量少,你还能在答辩时讲清楚AOP和拦截器各自的适用场景。
3.2 作业发布与截止时间校验
作业发布的核心逻辑不复杂,就是插入一条作业数据,但要注意的是发布操作的幂等性——也就是说,如果教师点击了两次“发布按钮”,不能产生两条作业。前端防按钮重复点击是最基本的,后端还要再做一层校验:同一个课程下,同一时间点发布的同标题作业,视为重复,直接拦截。
java复制public void publishHomework(HomeworkDTO dto) {
// 同一课程下不能出现标题相同的未删除作业
Long count = homeworkMapper.selectCount(
new LambdaQueryWrapper<Homework>()
.eq(Homework::getCourseId, dto.getCourseId())
.eq(Homework::getTitle, dto.getTitle()));
if (count > 0) {
throw new BusinessException("该课程下已存在同名作业");
}
Homework homework = new Homework();
BeanUtils.copyProperties(dto, homework);
homework.setStatus(0); // 初始为草稿
homeworkMapper.insert(homework);
}
截止时间校验主要发生在学生提交作业时。这块要比“当前时间晚于deadline”多考虑一层:如果作业是补交场景,我们要允许提交,但要在提交记录上打上补交标记。所以逻辑应该是:
java复制public void submitHomework(SubmissionDTO dto, Long studentId) {
// 1. 查作业,判断是否存在
Homework homework = homeworkMapper.selectById(dto.getHomeworkId());
if (homework == null) {
throw new BusinessException("作业不存在");
}
// 2. 判断作业是否已截止
boolean isLate = LocalDateTime.now().isAfter(homework.getDeadline());
if (isLate && homework.getStatus() == 2) {
// 已截止且不允许补交,直接报错
throw new BusinessException("作业已截止,无法提交");
}
// 3. 判断是否重复提交
Submission exist = submissionMapper.selectOne(
new LambdaQueryWrapper<Submission>()
.eq(Submission::getHomeworkId, dto.getHomeworkId())
.eq(Submission::getStudentId, studentId));
if (exist != null && exist.getStatus() == 1) {
// 已提交过,直接更新还是拒绝?
// 这里取决于业务规则,我推荐允许修改,但要在提交记录中保留修改痕迹
exist.setContent(dto.getContent());
exist.setUpdateTime(LocalDateTime.now());
exist.setIsLate(isLate ? 1 : 0);
submissionMapper.updateById(exist);
return;
}
// 4. 新提交
Submission submission = new Submission();
BeanUtils.copyProperties(dto, submission);
submission.setStudentId(studentId);
submission.setSubmitTime(LocalDateTime.now());
submission.setStatus(1); // 待批改
submission.setIsLate(isLate ? 1 : 0);
submissionMapper.insert(submission);
}
注意看,这个逻辑里出现了“重复提交”——同一学生对同一作业只能有一条提交记录。这个约束必须在数据库层面也加上唯一索引,否则并发请求下可能出现两条提交数据。加了(homework_id, student_id)唯一索引后,即便你代码里判断漏了,数据库也会拒绝重复插入。
3.3 学生提交作业:文件上传与防重复提交
文件上传是这个项目里最容易被忽略、但在答辩时最容易展示的亮点功能。Spring Boot处理文件上传很简单,用MultipartFile接收就行,但有几个细节处理不好会出大问题。
第一个细节是存储路径不要用绝对路径,要用相对路径,然后通过配置项指定。因为部署环境不同,你的项目在本地跑可能放在D://workspace,在服务器上可能放在/opt/app,写死绝对路径换个环境就崩。推荐的做法是在application.yml里配置:
yaml复制file:
upload-dir: ./upload/
然后在代码里用@Value注入,路径不存在时自动创建:
java复制@Value("${file.upload-dir}")
private String uploadDir;
public String uploadFile(MultipartFile file) {
if (file.isEmpty()) {
throw new BusinessException("上传文件为空");
}
// 按日期分目录,避免一个目录下文件太多
String datePath = LocalDate.now().toString().replace("-", "/");
File dir = new File(uploadDir + datePath);
if (!dir.exists()) {
dir.mkdirs();
}
// 存储文件名用UUID+原始文件名的后缀,防止重名和中文乱码
String originalName = file.getOriginalFilename();
String suffix = originalName.substring(originalName.lastIndexOf("."));
String storedName = UUID.randomUUID().toString().replace("-", "") + suffix;
// 保存文件
file.transferTo(new File(dir, storedName));
// 返回文件访问路径
return "/api/file/" + datePath + "/" + storedName;
}
第二个细节是文件下载时的防盗链和权限校验。很多人把文件放到static目录下就完事,那等于任何人拿到URL都能访问,还没法记录谁下载了。正确做法是存到外部目录(就是上面./upload/这种,不在classpath里),然后通过Controller提供下载接口。
第三个细节是防重复提交。学生快速连点“提交按钮”,后台上传接口会被调用多次,每次生成一条提交记录就麻烦了。除了前端按钮置灰,后端最好做一个防重方案:提交接口进入时,用Redis或数据库查询判断该学生该作业是否已经提交,如果已提交并且状态是“已批改”,则拒绝再次提交;如果已提交但还未批改,则视为修改更新原记录。上面的代码已经展示了这个逻辑。如果你把接口做成“允许修改”,一定要注意更新操作必须带上版本号或者乐观锁,防止丢失更新。
这里我再补一句,文件上传的大小限制也是必考的坑。Spring Boot默认单文件最大是1MB,很多同学第一次上传一张手机拍的照片就报错。需要在配置里改大:
yaml复制spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 100MB
3.4 作业批改与成绩统计
教师批改作业是另一个核心场景。前端打开提交列表,看到所有学生的提交记录和附件列表,点击某个学生后,右侧显示该学生的答案详情,教师输入分数和评语,点击保存。后端接口逻辑很简单,就是更新submission表的score、comment和status:
java复制public void gradeSubmission(Long submissionId, Integer score, String comment) {
Submission submission = submissionMapper.selectById(submissionId);
if (submission == null) {
throw new BusinessException("提交记录不存在");
}
// 分数不能超过作业总分
Homework homework = homeworkMapper.selectById(submission.getHomeworkId());
if (score > homework.getTotalScore() || score < 0) {
throw new BusinessException("分数必须在0~" + homework.getTotalScore() + "之间");
}
submission.setScore(score);
submission.setComment(comment);
submission.setStatus(2); // 已批改
submissionMapper.updateById(submission);
}
成绩统计这块,我推荐用SQL聚合,不要把所有数据拉回内存再算。比如统计一份作业的平均分、最高分、最低分、提交率,一个SQL就能搞定:
sql复制SELECT
COUNT(CASE WHEN status IN (1, 2) THEN 1 END) AS submitted_count,
COUNT(CASE WHEN status = 2 THEN 1 END) AS graded_count,
AVG(CASE WHEN status = 2 THEN score END) AS avg_score,
MAX(CASE WHEN status = 2 THEN score END) AS max_score,
MIN(CASE WHEN status = 2 THEN score END) AS min_score
FROM submission
WHERE homework_id = #{homeworkId}
实际使用时,还需要和该课程的选课人数比对,才能得出提交率。那你就要再查一次选课表人数,或者在course_student表上做子查询。这块在业务代码里组装即可。
4. 异常场景与常见问题排查实录
4.1 上传文件大小限制与跨域配置
上传限制那个坑我刚说过,这里讲一个容易被忽视的连带问题:上传大文件时Nginx层也有默认大小限制。如果你前面挂了Nginx做反向代理,Spring Boot层的配置调大了,但Nginx默认client_max_body_size还是1m,上传依然会报413 Request Entity Too Large。排查的时候可以用curl直接打后端接口,如果直连能成功而走域名失败,那基本就是Nginx的问题,在配置里加一句client_max_body_size 50m;重启Nginx即可。
跨域问题也是前后端分离项目的常客。如果你用Vue的devServer代理(proxy)转发请求,同源下通常不会有跨域问题。但如果你前端部署在Nginx、后端在另一台服务器,直接通过IP:端口访问后端接口,那后端一定要配跨域,否则浏览器会拦截响应。Spring Boot的跨域配置用CorsFilter最直接:
java复制@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
这里有个细节:addAllowedOrigin("*")在setAllowCredentials(true)时会失效,必须用addAllowedOriginPattern("*"),这是Spring Boot 2.4+之后的一个行为变化,很多人就卡在这一行配置上。
4.2 Spring Boot 3.x 与旧教程的兼容性坑
搜过Spring Boot问题的人应该都看过类似“springboot版本太高”的热搜内容。这个现象真实存在。很多同学在GitHub上拉了一个教程项目,一看Spring Boot版本是2.x,觉得自己要用最新的,直接改成3.x,然后编译报错,各种依赖冲突一堆。
最大的冲突来源是javax改jakarta。Spring Boot 3.x把Java EE的包名从javax.*迁移到了jakarta.*,所以你在网上搜到的代码里import javax.servlet.*、import javax.annotation.*这些全部会报“包不存在”。解决办法是全局替换成jakarta.*。比如拦截器里用的HttpServletRequest,3.x是jakarta.servlet.http.HttpServletRequest。
另一个高频坑是Swagger依赖不兼容。Spring Boot 2.x用的springfox或springdoc版本,到3.x可能不认。如果你的项目用了Swagger生成接口文档,我建议直接用springdoc-openapi的最新版本,或者干脆在3.x里不用Swagger,用Knife4j的适配版本,省心很多。除了依赖版本,Spring Boot 3.x还强制校验Bean依赖,循环依赖在2.6及以上版本里默认不允许配置了,日志也变了,Jackson的日期序列化默认行为也调整了。
4.3 事务失效与循环依赖:两个必查的高频问题
事务失效是Spring Boot项目里一个经典面试题,也是真实开发中容易踩的坑。最典型的情况是:在同一类中,一个方法调用另一个被@Transactional标注的方法,事务会失效。
java复制@Service
public class HomeworkService {
public void processHomework(HomeworkDTO dto) {
// 调用同类中的事务方法,事务不生效!
this.publishHomework(dto);
}
@Transactional(rollbackFor = Exception.class)
public void publishHomework(HomeworkDTO dto) {
// 数据库操作...
}
}
为什么失效?因为Spring的@Transactional默认通过JDK动态代理实现,而this.publishHomework()调用的是当前对象的方法,根本没有经过代理对象,事务自然无从管理。解决办法有三个:一是把事务方法放到另一个Service类中;二是注入自身的代理对象再调用;三是启动类加@EnableAspectJAutoProxy(exposeProxy = true),用AopContext.currentProxy()获取代理对象。第一种方案最推荐,职责更清晰,可维护性也更好。
循环依赖的坑在Spring Boot 2.6版本后变得更常见。比如A依赖B、B又依赖A,在早期的Spring版本里,Spring能通过三级缓存解决大部分循环依赖,但从2.6开始默认关闭了循环依赖支持,项目启动就会报错。解决思路优先是从设计上避免循环依赖,把互相调用的逻辑用中间Service或者把公共代码抽到独立服务中。如果你已经在项目里写了循环依赖,最简单的临时方案是加配置:
yaml复制spring:
main:
allow-circular-references: true
但我不推荐长期用这个配置,它掩盖了设计问题,以后代码量大了迟早要爆。
4.4 前后端联调与鉴权拦截导致的401/404
联调阶段的报错大部分集中在两类:一是401未登录,二是404接口不存在。401多是登录接口本身没放行,被拦截器拦住了,或者是前端没把token放到Header。404的情况就更细了,有可能是接口路径对不上,也有可能是请求方式不对(明明写了POST,前端用了GET)。
这里我强烈建议后端同学用Swagger/knife4j管理接口文档,这样前后端联调时接口路径、参数、返回值一目了然,谁都不用追着谁问。另外,如果项目设置了server.servlet.context-path,比如所有接口前面都有个/api前缀,那静态资源和拦截器路径也都要带上这个前缀,不然前端请求路径总是对不上。跨域配置里的/**匹配路径,也要注意有没有context-path的影响。
再有就是时间格式的坑:前端传一个"2025-03-10 23:59:59"的字符串给后端,Spring Boot默认的Jackson反序列化可能不认识这个格式,报Jackson转换错误。解决方案是在接收参数上用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")标注LocalDateTime字段,或者全局配置Jackson的时间格式。这个问题不大,但碰到一次能卡你半小时。
5. 部署上线与后续扩展
5.1 项目打包与部署:本地能跑不代表服务器能跑
很多同学项目做完了,本地一切正常,一到部署就翻车。最常见的坑是JDK版本不一致:本地JDK 17,服务器JDK 8,打包用mvn package默认会带上编译参数,结果到服务器运行时跑不起来,报UnsupportedClassVersionError。解决方案是打包时在pom.xml里明确指定Java版本,并且服务器版本与本地保持一致。
Spring Boot项目部署,我推荐打包成jar包,因为它是内嵌Tomcat的,一条java -jar命令就能跑,不用再装Tomcat、不用配置war包的部署目录。打包命令很简单:
bash复制mvn clean package -DskipTests
生成的目标文件在target/homework-system-0.0.1-SNAPSHOT.jar,传到服务器后:
bash复制nohup java -jar homework-system-0.0.1-SNAPSHOT.jar --server.port=8080 > app.log 2>&1 &
用nohup 后台运行,输出日志写到app.log,这样就算退出SSH终端进程也不会被杀掉。注意如果服务器上有多个Java版本,一定要指定JAVA_HOME或用完整路径,比如:
bash复制/usr/local/jdk1.8.0_291/bin/java -jar app.jar
很多“本地能跑服务器不能跑”的玄学问题,最后查出来都是这个原因。
5.2 从课设到生产环境还要补什么
如果你不满足于“让系统能跑”,想让它像一个真正的生产系统,那至少还要补这几块:
第一是日志。别再用System.out.println打印日志了,这个习惯在学校里还能混过去,在真实项目中会被喷死。要引入logback或log4j2,配置info和error分别输出到不同文件,日志格式带上时间、线程名、类名,方便排查线上问题。
第二是统一异常处理。用@RestControllerAdvice把异常统一包装成JSON返回,而不是让Spring返回一个默认的错误页。这个问题很重要,否则前端没法判断你后端到底报了什么错。
第三是附件清理策略。学生删除提交记录、教师删除作业时,关联的物理文件要不要删?要删的话怎么保证不误删共享文件?一个简单的方案是:删除业务数据时只做逻辑删除(标记删除状态),物理文件由定时任务每天凌晨扫描一次,删除那些“没有关联业务数据”的孤儿文件,既安全又不容易误操作。
第四是分页查询。作业列表、提交列表、用户管理列表,数据量一大就会卡,MyBatis-Plus内置了分页插件,接上就能用,一定要用而不是自己截取List。
第五是接口返回统一格式。建议定义Result类,包含code、message、data三个字段,所有Controller统一返回这个类型。前端处理起来非常规范,也方便全局捕获异常。
5.3 这个项目还能扩展出什么?
在线作业管理系统做完之后,顺着业务逻辑可以往好几个方向延伸,而且每个方向都是一个有价值的毕设创新点。
一个是选课功能。在上面表设计里已经预留了course_student表,扩展出学生选课、退课、课程表功能思路非常顺畅,把这个加上,系统就从“作业管理”升级成了“课程教学管理”。
一个是在线考试功能。作业管理扩展到试卷管理,增加试题表、考试记录表,成绩统计复用现有逻辑。本质上和作业系统是同构的,只是需要增加答题卡和自动判分逻辑。
一个是成绩分析。在成绩统计基础上,按班级、按作业维度的得分趋势分析,用ECharts做图表展示,前端视觉效果会非常出彩,答辩时绝对是加分项。
再一个就是消息通知。作业发布后,给选课学生发送App或邮件通知;提交截止前提醒未交作业的学生。这个功能可以在作业截止时间存储接口中加一个异步任务,或者在定时任务里判断,实现起来不难但很显眼。
根据我的实践经验,做这种管理系统类项目,技术本身并没有多高深,真正拉开差距的是细节——你有没有考虑到重复提交、有没有做统一异常处理、有没有处理好文件上传的边界条件、有没有把日志打印规范好。这些细节你在答辩现场能讲出来,面试官就愿意听下去。如果你还在纠结这个项目怎么做,我建议你把精力放在数据库设计和文件上传这两块,先把表设计搞通,剩下的代码就是水到渠成的事。
