☰
Spring Boot版SSM师生答疑作业系统:从需求到核心实现
2026/10/10 10:44:38 网站建设 项目流程

先说结论:这个Java系的SSM315师生交流答疑作业系统,本质上是一个“教学场景下的业务管理系统”,核心解决三件事——师生之间怎么顺畅提问答疑、作业怎么布置和提交、成绩怎么留痕和统计。技术栈上沿用SSM的组合思路:Spring负责业务对象管理、Spring MVC负责请求分发、MyBatis负责数据持久化,只不过换成了当下更主流的Spring Boot外壳来跑。如果你正在做类似的课设、毕设,或者想把自己手写的老SSM项目升级成Spring Boot版本,这篇文章能够帮你把整个系统从需求到表结构再到核心链路的坑全部捋一遍。

我前几天刚把这个系统的代码重新翻修了一遍。说实话,纯SSM的旧项目本身功能是不缺的,但每次手动配置Spring和MyBatis的一堆XML文件都让人头大,迁移到Spring Boot之后清爽很多,而且老的代码结构基本不用大改。这篇就把我这次翻修过程中梳理出来的设计和实现细节都写下来,包括角色权限设计、答疑和作业模块的表结构、文件上传、越权防护,以及一套可以直接拿来排查问题的经验清单。

1. 系统整体设计与需求拆解

要动手写这套系统之前,最忌讳的就是直接开建表。先把角色、业务流向和页面边界弄清楚,后面写代码的速度反而快得多。很多人一上来就想着“我是学生登录后能干嘛、老师登录后能干嘛”,然后噼里啪啦写了一堆零散的接口,最后发现权限一团乱、页面跳转全靠硬编码,改起来特别痛苦。

1.1 三类角色和两条核心业务线

这个系统里的角色非常清晰:学生、教师、管理员。管理员一般不做业务操作,主要是维护基础数据,比如课程、班级、用户状态,以及处理一些异常数据。教师是内容生产者,负责发布作业、解答疑问、批改打分。学生是使用频率最高的角色,既要看作业、交作业,也要提问、看回复。

整个系统的业务可以分成两条主线。

第一条是答疑线:学生发起提问,教师回答,其他学生可以围观或者补充回答。这里要注意一个问题——不是所有问答都必须老师来答,所以不少同类系统里做了“最佳答案”或者“采纳”机制。提问人可以采纳某一条回复作为标准答案,这样后来者看帖子时能直接锁定有效信息。

第二条是作业线:教师创建作业、设置截止时间,学生提交作业(通常带附件),教师在截止后或者随时批改,给出分数和评语。这条线要处理好几个关键状态:作业的草稿发布态、提交后是否允许修改、逾期提交怎么标记、成绩是否允许学生看到。

我这次整理需求时,把两条业务线的状态变化梳理成了几张流程表,写接口的时候对照着来,逻辑清晰了很多。比如答疑帖的状态有:待回答、已回答、已采纳、已关闭。作业的状态有:未发布、进行中、已截止、已批改。每条状态变更都会对应一两个核心接口,不需要额外讨论“要不要加这个按钮”,状态图上写得很明白。

1.2 技术选型:Spring Boot版本下的SSM组合

老项目用Spring+Spring MVC+MyBatis,Spring Boot流行之后,很多人的惯性思维是“Spring Boot就不算SSM了”。这个理解其实有点偏。Spring Boot只是把原本需要手写的配置变成了自动配置和starter依赖,底层核心框架仍然是Spring IoC和Spring MVC的那套规则,MyBatis也还是靠SqlSessionFactory那一套机制在跑。

我在这套系统里的选型是这样:

  • JDK 1.8,Spring Boot 2.x(基于javax命名空间,兼容老项目代码)
  • MyBatis + pagehelper做数据库访问和分页
  • MySQL 5.7,数据库名建议直接叫ssm315
  • 模板引擎用Thymeleaf,做服务端渲染,适合这种传统SSM风格的项目
  • 前端框架没额外引入Vue,只用了Bootstrap加少量原生JavaScript

用Thymeleaf而不是Freemarker或者JSP,原因很简单:JSP在Spring Boot里的支持比较麻烦,Freemarker虽然也不错但我个人觉得Thymeleaf的标签语法跟HTML更贴近,接手的人上手成本低。

有人会问为什么不用MyBatis-Plus。说实话,这个项目如果只图快,MyBatis-Plus确实省事,单表CRUD基本不用写SQL。但SSM315这种教学性质比较重的项目,标准MyBatis的XML映射反而更适合用来讲清楚数据怎么查、怎么改,所以我保留了手写Mapper XML的方式。后面如果自己接项目,再考虑替换成MP也不迟。

2. 核心模块拆解与数据库设计

数据库设计是这个系统的重头戏。我见过不少半途做不下去的项目,十有八九是表关系没想清楚就开始写代码。这个系统里最核心的表大概有八九张,我挑几张有代表性的说明设计思路和为什么这么设计。

2.1 用户体系:单表多角色还是分表

用户表的设计方法其实挺多,我最终采用了“单表 + 角色字段”的方案:

字段类型说明
idbigint主键自增
usernamevarchar(50)登录名,唯一索引
passwordvarchar(100)加密后的密码
roletinyint1-学生,2-教师,3-管理员
full_namevarchar(50)姓名
avatarvarchar(255)头像路径
statustinyint0-禁用,1-正常
create_timedatetime创建时间

为什么不拆成student表和teacher表?因为这个项目里学生和教师共享登录、个人信息这些基础逻辑,拆开之后反而要写两套登录逻辑。真正有差异的业务用“关系表”去扩展,而不是复制一份用户表。

需要注意的是,密码字段不要只用MD5。虽然很多老代码里都是MD5,但我这次重构时换成了BCrypt,Spring Security里自带的BCryptPasswordEncoder就能用,不用额外引包,密码安全性等级完全不一样。

2.2 答疑模块:提问与回复的表结构

答疑模块涉及的表主要有t_question、t_question_reply、t_question_attention或者收藏关系表。其中t_question至少要包含:

  • id、user_id:提问人
  • title、content、course_id
  • status:待回答、已回答、已采纳、已关闭
  • view_count、reply_count
  • is_deleted
  • create_time、update_time

t_question_reply表里有一条关键字段is_accepted,用来标记是否被提问人采纳。查询回复列表时,is_accepted等于1的回复排最前面,这样阅读体验最好。

这里有个容易被忽略的点:question列表页的reply_count性能问题。如果你在查询列表时用COUNT函数实时统计回复数,数据量稍微上来一些就会变慢。我的做法是每次回复成功后直接在t_question表的reply_count字段上做自增,列表页直接取字段值。统计不准的问题在这个数据量级别完全可以忽略,但性能上好很多。

2.3 作业模块:布置、提交、批改的状态流转

作业模块表结构更关键一些,因为涉及附件存储和状态流转。基础表是t_homework和t_homework_submit。

t_homework表核心字段:

  • id、course_id:所属课程
  • title、content
  • deadline:截止时间
  • attachment_path:作业附件路径(非必填)
  • status:0-未发布,1-进行中,2-已截止
  • create_time、update_time

t_homework_submit表核心字段:

  • id、homework_id、student_id
  • content:文字描述
  • attachment_path:学生提交的附件
  • submit_time
  • score、comment
  • status:0-未批改,1-已批改,2-逾期提交

设计提交表时要特别注意一个约束:同一名学生对同一份作业只能有一条提交记录。所以homework_id和student_id一定要做联合唯一索引。后面更新作业时只用UPDATE而不是INSERT,这样就能避免“重复提交”导致的数据混乱。

我遇到过有的同学在这里设计了主表和明细表两张表,理由是学生可能多次提交记录历史版本。这个想法是可取的,但在基础版系统里可以先不做版本管理,最多加一个resubmit_time字段来记录修改的最新时间。先跑通业务闭环,比一开始就考虑完美更重要。

[\frac{sqrt}{2}] 作业截止之后,允许不允许学生继续提交?这个需求最好在开发前确认。我采用的策略是:截止后状态机直接锁定,不允许学生再做提交操作。老师如果想“通融”某个学生,就给一个单独设置的按钮,修改该学生的提交时间并放行,但这块权限只开放给教师。

3. 核心链路实现与实操细节

架构和表结构定下来之后,真正写代码的阶段要分三条链路来走:一是Spring Boot整合SSM的基础配置,二是作业提交中的文件上传,三是前后端接口的权限控制。这三条链路任何一条没处理好,都会在联调阶段反复折腾。

3.1 Spring Boot整合SSM的关键配置

我这次用的Spring Boot 2.7.18,不是最新的3.x,因为不想面对javax到jakarta的迁移问题。如果你不想碰这些历史包袱,就尽量别用3.x。依赖方面,核心就三个:

  • spring-boot-starter-web
  • mybatis-spring-boot-starter(版本2.3.x)
  • pagehelper-spring-boot-starter(版本1.4.x)

如果你的项目有用到文件上传,Spring Boot内置的Servlet容器默认就支持MultipartFile,不需要额外引包。

在application.yml里,我习惯把mybatis配置这样写:

mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.ssm315.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

map-underscore-to-camel-case这行非常关键。数据库字段是create_time,Java属性是createTime,没有这行配置,你的实体类属性全是null,排查起来还特别隐蔽。我接手过不少项目,就是因为少了这行配置,找bug找了一下午。

Mapper XML放在src/main/resources/mapper目录下面,跟实体类所在的包路径不要混在一起。如果是Maven多模块,还得注意模块间资源文件的引用,但单模块项目就简单很多。

启动类上必须加@MapperScan注解:

@SpringBootApplication @MapperScan("com.example.ssm315.mapper") public class Ssm315Application { public static void main(String[] args) { SpringApplication.run(Ssm315Application.class, args); } }

如果不想用@MapperScan,也可以在每个Mapper接口上单独加@Mapper注解。前者更推荐,免得以后接口越来越多时漏加。

3.2 作业提交中的文件上传与静态资源映射

作业提交几乎都会涉及附件上传,这里最容易出的问题不是代码逻辑,而是文件存到哪里以及怎么让浏览器能下载。

我采用的方案是:本地上传目录。在application.yml里配置一个自定义的文件根路径:

file: upload-dir: ./uploads

上传接口用MultipartFile接收,然后生成唯一文件名并写入磁盘:

@PostMapping("/student/homework/submit") public Result submit(@RequestParam("file") MultipartFile file, @RequestParam("homeworkId") Long homeworkId, @RequestParam(value = "content", required = false) String content) { if (file.isEmpty()) { return Result.error("请选择文件"); } String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + suffix; File dir = new File(uploadDir + "/homework/" + homeworkId); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(dir, newName)); } catch (IOException e) { return Result.error("文件保存失败"); } // 保存数据库路径:"homework/{homeworkId}/{newName}" }

用UUID做文件名,是为了防止学生上传同名文件时互相覆盖,也防止中文文件名在某些环境下出现乱码。数据库中保存相对路径而不是绝对路径,这样以后换服务器或者迁移目录时,数据库里的数据不用改。

文件下载是另一个坑。如果你直接映射了静态资源路径,比如:

registry.addResourceHandler("/uploads/**").addResourceLocations("file:" + uploadDir + "/");

那浏览器直接输入URL就能访问到文件。但这个方式有个问题:你知道文件的相对路径,任何人都能下载,作业提交这种场景可能还好,但如果系统里有一些只允许特定角色查看的资料,可就不安全了。我一般在更严谨的项目里会加一层权限判断,通过专门的下载接口流式输出文件,而不是直接暴露静态目录。不过基础版直接用静态资源映射也足够,看业务需求取舍。

3.3 权限控制与越权问题处理

这个系统有三个角色,最简单的权限方式是写一个拦截器,拦截所有请求,判断session或token中的用户角色是否匹配。

我先定义了一个SessionConst常量类:

public class SessionConst { public static final String LOGIN_USER = "loginUser"; }

然后在登录接口里把用户对象塞进session:

session.setAttribute(SessionConst.LOGIN_USER, user);

拦截器核心逻辑:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); LoginUser loginUser = (LoginUser) session.getAttribute(SessionConst.LOGIN_USER); if (loginUser == null) { response.sendRedirect("/login"); return false; } return true; } }

但这里有个经常被忽略的越权问题:学生登录之后,自己手动拼接一个URL,比如/teacher/homework/delete?id=1,如果后台只判断了“是否登录”而没有判断“角色是否匹配”,那学生就能把老师的作业删了。这就是越权漏洞。

我一般会做一个简单的角色校验机制,在拦截器里先判断角色,再结合自定义注解处理那些“需要特定角色”的接口。不过更简单直观的做法是在Controller方法入口做校验:

@GetMapping("/teacher/homework/delete") public Result deleteHomework(HttpSession session, Long id) { LoginUser loginUser = (LoginUser) session.getAttribute(SessionConst.LOGIN_USER); if (loginUser.getRole() != UserRole.TEACHER) { return Result.error("无权限操作"); } // 业务逻辑 }

当接口数量不多时,这种方式反而直观。接口非常多的项目,还是建议用拦截器统一处理。这套系统大概二十几个接口,每个Controller手写校验也就是几行代码的事,很清晰。

4. 常见问题汇总与排查思路实录

做这种教学型管理系统时,有大量问题不是需求复杂,而是环境和配置上的小坑。我把这次翻修和之前带着其他同学做类似项目时遇到的高频问题整理出来,按照“现象—原因—解法”的顺序写,方便以后遇到同类问题直接套。

4.1 MyBatis映射XML失效,接口报“Invalid bound statement”

这个报错在MyBatis项目中几乎人手一次。现象是启动类正常跑起来,但一调用某个Mapper接口的方法就直接抛异常,提示Invalid bound statement (not found)。

先别急着改代码,按顺序排查三板斧:

第一,检查Mapper XML文件所在位置,Spring Boot默认只扫描resources目录下的文件。如果XML文件不小心放到了java包目录下,而构建配置没有做特殊处理,XML不会被复制到classes目录,运行时自然就找不到。

第二,检查application.yml里的mapper-locations配置,确认路径和实际存放位置一致。我习惯用classpath:mapper/*.xml,如果你把XML文件放在更深的子包下,要改成classpath:mapper/**/*.xml。

第三,检查Namespace、Mapper接口名和XML里的id是否严格匹配。哪怕某个方法少写一个字母,也会报这个问题。

最好用的排查方法是,项目启动后直接去target/classes目录下看一眼XML文件到底在不在、路径对不对。这个方法比盯着代码找半天快多了。

4.2 PageHelper分页失效或查出全部数据

用PageHelper做分页时,最常见的错误用法是:先执行了List查询,再调用PageHelper.startPage(),或者在两个查询之间没有正确传递线程变量。

正确写法是把startPage放在查询语句的前一行:

PageHelper.startPage(pageNum, pageSize); List<Homework> list = homeworkMapper.selectListByCourseId(courseId); PageInfo<Homework> pageInfo = new PageInfo<>(list);

我这里踩过一个坑:查询语句返回的类型不是List而是单个对象,此时PageHelper的分页参数不会生效,因为PageHelper的原理是利用MyBatis的拦截器对查询Executor做拦截,只有StatementHandler返回的是ResultSet并映射为List时,分页拦截器才会介入。如果你写了一个selectOne或返回Map的查询,分页不会生效,但也不报错。

另外一个连带问题:查询列表时如果用了多表JOIN,PageHelper只能拦截外层查询,count(*)的生成逻辑可能不准确,出现总条数比实际少的情况。解决方法是手动指定countSql,或者拆分成两步统计,别指望PageHelper在所有复杂SQL下都万无一失。

4.3 事务失效为什么没人提醒你

作业提交和批改这两个环节一定要加事务,否则数据写到一半出错时,会出现“附件已存但数据库没记录”这种奇怪状态。

先说结论:在Spring Boot里,只要在Service方法上加了@Transactional,理论上事务就会生效。但有几个细节容易导致事务静默失效。

第一个坑是方法内部调用自己类的另一个方法。比如:

public void submitHomework(...) { this.updateScore(...); // 这个调用不会经过代理 }

updateScore上的@Transactional注解不会生效,因为this调用没有走Spring代理对象。要么把事务方法拆到另一个Service里,要么通过AopContext.currentProxy()获取代理对象再调用。

第二个坑是异常被吞了。@Transactional默认只在RuntimeException时回滚,如果你的方法里catch了异常然后返回一个Result.error,方法正常结束,事务自然就提交了。要回滚必须手动设置rollbackFor:

@Transactional(rollbackFor = Exception.class)

养成一个习惯:所有写操作涉及多表更新的地方,都加上rollbackFor = Exception.class。

第三个坑是连接池配置不当导致事务操作时报错。我遇到过一个现象:系统运行一段时间后突然报错“Connection is not available, request timed out”,原因是默认的HikariCP最大连接数只有10,而项目里在循环中逐个处理学生的作业提交,一个连接被占住不放,后面请求全部排队超时。解决方法是先优化代码,不要在一次请求里循环多次数据库操作;如果确实有批量场景,适当提升maximum-pool-size,同时设置上connection-timeout。

4.4 前端页面直连后端接口的几种典型报错

在服务端渲染的Thymeleaf模式下,最烦人的不是模板语法,而是表单提交的路径错误。我见到最多的一次报错是404,排查了半天才发现Controller里的RequestMapping是/student/homework,但表单的action地址写成了/homework/submit。

这里建议一个习惯:所有前端跳转和表单action尽量用相对路径,不要用绝对路径。如果项目部署在根路径下可能没问题,但一旦你二级目录部署,比如http://host/ssm315/,绝对路径就会全部失效。Spring Boot里还可以配置server.servlet.context-path来统一加前缀,但前后端路径必须保持一个基准。

另外,很多同学喜欢在JS里硬拼URL,代码里写死了http://localhost:8080/api/...,这个一旦换IP换端口就崩。正确做法是使用相对路径,例如/student/homework/list,浏览器会自动基于当前域名和端口拼接。这个习惯在前后端分离项目里同样适用。


这次翻修下来,我个人最大的体会是:这类管理系统的难点真不在于某个技术点有多深,而在于整体流程是否闭环。答疑闭环是“提问→回答→采纳→沉淀”,作业闭环是“发布→提交→批改→评分”,权限闭环是“登录→鉴权→校验→防越权”。每一步拆开看都很简单,但把它们串起来还能保持代码清晰,就很考验设计能力了。

最后再分享一个小技巧:做这种系统时,一定要在开发最开始就设定一个“统一返回结构”,比如Result对象,包含code、message、data三个字段。不要一开始图方便直接返回Map,或者返回ModelAndView,后面加需求时你会感谢这个决定的。整个系统的接口风格统一了,前端所有回调都按同一种格式处理,联调效率能翻一倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询