Spring Boot版SSM师生答疑作业系统:从需求到核心实现
先说结论这个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组合老项目用SpringSpring MVCMyBatisSpring Boot流行之后很多人的惯性思维是“Spring Boot就不算SSM了”。这个理解其实有点偏。Spring Boot只是把原本需要手写的配置变成了自动配置和starter依赖底层核心框架仍然是Spring IoC和Spring MVC的那套规则MyBatis也还是靠SqlSessionFactory那一套机制在跑。我在这套系统里的选型是这样JDK 1.8Spring 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但我这次重构时换成了BCryptSpring Security里自带的BCryptPasswordEncoder就能用不用额外引包密码安全性等级完全不一样。2.2 答疑模块提问与回复的表结构答疑模块涉及的表主要有t_question、t_question_reply、t_question_attention或者收藏关系表。其中t_question至少要包含id、user_id提问人title、content、course_idstatus待回答、已回答、已采纳、已关闭view_count、reply_countis_deletedcreate_time、update_timet_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、contentdeadline截止时间attachment_path作业附件路径非必填status0-未发布1-进行中2-已截止create_time、update_timet_homework_submit表核心字段id、homework_id、student_idcontent文字描述attachment_path学生提交的附件submit_timescore、commentstatus0-未批改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-webmybatis-spring-boot-starter版本2.3.xpagehelper-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.StdOutImplmap-underscore-to-camel-case这行非常关键。数据库字段是create_timeJava属性是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; }然后在登录接口里把用户对象塞进sessionsession.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?id1如果后台只判断了“是否登录”而没有判断“角色是否匹配”那学生就能把老师的作业删了。这就是越权漏洞。我一般会做一个简单的角色校验机制在拦截器里先判断角色再结合自定义注解处理那些“需要特定角色”的接口。不过更简单直观的做法是在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); ListHomework list homeworkMapper.selectListByCourseId(courseId); PageInfoHomework pageInfo new PageInfo(list);我这里踩过一个坑查询语句返回的类型不是List而是单个对象此时PageHelper的分页参数不会生效因为PageHelper的原理是利用MyBatis的拦截器对查询Executor做拦截只有StatementHandler返回的是ResultSet并映射为List时分页拦截器才会介入。如果你写了一个selectOne或返回Map的查询分页不会生效但也不报错。另外一个连带问题查询列表时如果用了多表JOINPageHelper只能拦截外层查询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方法正常结束事务自然就提交了。要回滚必须手动设置rollbackForTransactional(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后面加需求时你会感谢这个决定的。整个系统的接口风格统一了前端所有回调都按同一种格式处理联调效率能翻一倍。