ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSM毕设管理系统实战:从表结构设计到核心业务开发全指南

SSM毕设管理系统实战:从表结构设计到核心业务开发全指南

简介:这是一份基于SSM(Spring+SpringMVC+Mybatis)框架开发的毕业设计管理系统项目,面向计算机专业毕业生、Java开发学习者,帮助完成高校毕设课题或理解后端项目从零搭建的过程。系统包含教师端、学生端、管理员端三类角色,覆盖登录认证、信息管理、审批流转、后台统计等典型业务,前端选用Bootstrap实现响应式界面。压缩包共2000个文件,约32.62MB,文件构成中JavaScript脚本数量最多(1386个),搭配HTML、JSP、CSS完成页面展示与交互,Java源文件与XML负责业务逻辑和MyBatis映射,同时附带SQL数据库脚本、JSON配置及说明文档,导入数据库并部署Tomcat即可运行。整体目录结构清晰,适合在IDEA或Eclipse中直接打开学习。已有209人浏览学习;对需要快速搭建SSM项目骨架、借鉴角色权限设计或准备毕业设计答辩的同学,都是可参考的完整示例。

1. SSM毕设管理系统:毕业设计流程管理的轻量方案,为什么这个老技术栈还在被需要

SSM毕设管理系统,核心是用 Spring + SpringMVC + MyBatis 这套经典 Java Web 组合,把毕业设计从选题、开题、中期检查到答辩归档的完整流程搬到线上。哪怕现在微服务和前后端分离满天飞,这类系统在高校教学场景里依然是高频需求——它体量小、边界清楚、部署简单,正好落在“单体会话应用”的最佳舒适区。对有课程作业或完整项目经验的人来说,它是能在一周内跑通全链路、又不会因为框架太新而卡在环境上的稳妥选择。

这篇笔记不是框架教程,而是按我实际做这类系统的次序来讲:先拆业务和表结构,再给核心链路的可抄代码,最后把最容易翻车的地方集中排查一遍。新手能照着把系统跑起来,熟手可以直接拿去改造成选题管理、课程设计管理或竞赛报名系统,只要把角色和字段换掉,骨架基本不用动。

2. 系统拆解与数据库设计:角色、流程和三张核心表

2.1 角色权限模型:RBAC 还是更简单的三表方案

这种管理系统涉及的流程很固定:管理员维护基础数据,教师发布课题并参与评审,学生选题、提交材料,答辩组录入成绩。常规做法是 RBAC 模型,但完整实现用户-角色-权限-资源四层会让小项目显得冗长。我一般会压缩成角色表加用户表角色字段的简化方案,权限靠拦截器按角色字符串判断,这样代码量砍掉三分之一,后期也好维护。

实际建表时,用户表里直接放role字段取值admin / teacher / student,再配一张角色表只做说明性映射。一个用户理论上可能有多重身份,但在毕设系统里一人一角色的约束可以接受。如果非要支持一人多角色,那就回到标准的五张表(用户、角色、用户角色关联、权限、角色权限关联),拦截逻辑从判断单个字段变成查关联表。

2.2 核心表结构:用户、选题、进度的字段级设计

表是整个系统的地基,字段设计比写 Controller 更值得花时间。按“用户-课题-进度”三条主线拆,表数量控制在六七张以内比较合适。每张表主键都用自增INT,不用 UUID 当主键,因为数据量级在千条级别,自增主键在索引性能和实现成本上都是最优解。

以下是三张最核心的建表 SQL,按通用 MySQL 8 方言写的。这段代码可以直接拿去建库,字段命名建议保留,后续 MyBatis 的映射文件会按这套命名写。

CREATE TABLE `sys_user` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT '加密后的密码', `real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名', `role` VARCHAR(20) NOT NULL DEFAULT 'student' COMMENT '角色:admin/teacher/student', `dept` VARCHAR(50) DEFAULT NULL COMMENT '所属院系或教研室', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1可用 0禁用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `topic` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '课题ID', `title` VARCHAR(100) NOT NULL COMMENT '课题题目', `teacher_id` INT NOT NULL COMMENT '发布教师', `description` TEXT COMMENT '课题要求描述', `selected_student_id` INT DEFAULT NULL COMMENT '当前选题学生', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1已通过 2已选 3已结题', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课题表'; CREATE TABLE `progress` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '进度ID', `student_id` INT NOT NULL COMMENT '学生ID', `topic_id` INT NOT NULL COMMENT '课题ID', `stage` TINYINT NOT NULL COMMENT '阶段:1开题 2中期 3论文定稿', `content` TEXT COMMENT '阶段内容说明', `file_path` VARCHAR(200) DEFAULT NULL COMMENT '附件存储路径', `submit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `audit_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审 1通过 2打回', `audit_comment` VARCHAR(255) DEFAULT NULL COMMENT '审核意见', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='进度记录表';

这三张表的设计逻辑是:topic表用selected_student_id做当前占用标记,选题冲突的检测就变成一条原子更新语句;progress表把阶段做成字段,而不是拆开题表、中期表、论文表三张独立表,因为每个阶段的字段高度相似,合并后查询列表只需要一张表排序,后期加阶段(比如预答辩)也不用改表结构。sys_user的password字段长度留到 100,是因为用了 BCrypt 加密,密文本身就 60 个字符,如果用 MD5 留 32 就够,但 MD5 现在无论如何不建议用于新系统。

注意status字段设计成数字枚举而不是字符串,排序和索引占用都比字符串小,而且TINYINT的取值范围已经覆盖了所有阶段状态。每个字段的COMMENT必须写,多人协作时这一行注释能省掉大量沟通成本。

2.3 选题状态的迁移逻辑:用状态机思想约束操作

选题流程里最容易出 bug 的是状态流转。学生能选什么状态的题、教师能改什么状态的题、管理员能不能干预,这些约束如果散落在各个 Controller 里,半年后自己都会改出逻辑漏洞。我把状态流转做成一个明确规则,代码里每次更新状态前先校验收敛规则:

  • 学生只能选择status=1(已审核通过)且selected_student_id IS NULL的课题
  • 选题成功后课题状态从 1 改为 2,此时其他人不可再选
  • 教师或管理员可以把状态 2 的课题打回为 1,同时清空selected_student_id
  • 答辩结束后状态改成 3(已结题),这条是不可逆操作

在数据库层面,选题动作是一个事务里同时更新topic和写入progress记录,用事务保证不出现“课题状态改了但进度记录没生成”的数据不一致。这个事务在第三章的代码里会完整给出。状态字段只是TINYINT,但靠应用层收紧所有入口,不能指望数据库 CHECK 约束,因为 MySQL 8 之前的版本对 CHECK 约束支持不完整,应用层拦截才是最可靠的。

3. 用 SSM 把核心链路跑通:从配置到登录、选题、文件上传

3.1 Spring 容器与 MyBatis 整合:一份十分钟能改完的基础配置

现在新建 SSM 项目多数通过 Maven 骨架生成,但很多人卡在依赖版本冲突上。我这里直接给一套组合经过验证的依赖坐标,Spring 用 5.2.x 系列,MyBatis 用 3.5.x,MyBatis-Spring 用 2.0.x,这套组合在 JDK 8 到 JDK 11 环境下编译运行都没有兼容问题。

<properties> <spring.version>5.2.22.RELEASE</spring.version> <mybatis.version>3.5.6</mybatis.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.6</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> </dependencies>

spring-jdbc是最常被漏掉的依赖。很多人只引入spring-context和spring-webmvc,结果启动时报DataSourceTransactionManager类找不到。另外 MySQL 8 驱动包的com.mysql.cj.jdbc.Driver和旧版com.mysql.jdbc.Driver不一样,如果你用的是 8.x 驱动,数据库连接串必须加上serverTimezone=Asia/Shanghai,否则 DateTime 类型读写会直接报时区错误。

Spring 配置分两个文件是常规做法:spring.xml管数据源、事务、MyBatis,springmvc.xml管控制器扫描、视图解析器、静态资源和上传。分开的理由是监听的入口不同,spring.xml由ContextLoaderListener加载,springmvc.xml由 DispatcherServlet 加载,两个容器有父子关系,混在一起写容易出现在控制器里注不到 Service 的情况。核心数据源配置如下:

<context:property-placeholder location="classpath:jdbc.properties"/> <bean id="dataSource" class="org.apache.tomcat.jdbc.pool.DataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.demo.entity"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.demo.dao"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

typeAliasesPackage可以让你在 Mapper XML 里写resultType="Topic"而不是全限定类名,但前提是实体类包路径配置正确。MapperScannerConfigurer会把接口自动代理成实现类并注入 Spring 容器,这是 MyBatis-Spring 最方便的特性,不用再手动写SqlSessionDaoSupport那一套。事务管理器配好后用<tx:annotation-driven>开启注解事务,Service 层加@Transactional即可。

3.2 登录与角色鉴权:一个拦截器搞定全部权限控制

登录接口没什么特别,但密码校验必须用 BCrypt,不要再碰 MD5 加盐这种过时方案。BCrypt 每次加密结果不同,所以校验方法是BCryptPasswordEncoder.matches(明文, 密文)。用户登录成功后往 Session 里放用户对象,拦截器从 Session 取对象判断角色,整个权限体系只有两个类,比 Spring Security 的配置链轻量得多。

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } // 按需做角色级控制,比如只有教师能访问 /teacher/** 路径 User loginUser = (User) user; String uri = request.getRequestURI(); if (uri.contains("/teacher/") && !"teacher".equals(loginUser.getRole()) && !"admin".equals(loginUser.getRole())) { response.sendError(403); return false; } return true; } }

在springmvc.xml里注册拦截器并配置拦截路径,注意排除登录页、静态资源和验证码接口。这里的逻辑是:先判断是否登录,再判断角色访问权限。实际开发中很多翻车发生在静态资源被拦,比如 CSS 和图片不显示,原因是默认拦截了/css/**和/js/**,排查时先看控制台是否输出拦截日志,基本能立刻定位。

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/css/**"/> <mvc:exclude-mapping path="/js/**"/> <mvc:exclude-mapping path="/images/**"/> <bean class="com.demo.interceptor.AuthInterceptor"/> </mvc:interceptor> </mvc:interceptors>

SpringMVC 的拦截器只在 DispatcherServlet 处理链内生效,所以排除路径必须写/**下的全模式。<mvc:exclude-mapping>的路径是相对于应用上下文根的,加不加/前缀要一致,否则排除不生效。拦截器方案适合角色数不超过五个的小系统,角色超过五个或者权限粒度到按钮级别,就要考虑换成成熟的权限框架。

3.3 选题业务:唯一性校验与事务边界的设计

选题是整个系统里业务规则最强的一个操作。学生选择一个课题,要求同时满足:课题存在、状态是已通过、没人选过。这两个条件在并发场景下必须用数据库原子操作保证,不能先查再更新,因为两步之间有间隙,两个人同时提交时会双双通过检查。

@Transactional public boolean selectTopic(Integer topicId, Integer studentId) { Topic topic = topicDao.selectByIdForUpdate(topicId); if (topic == null) { throw new ServiceException("课题不存在"); } if (!Integer.valueOf(1).equals(topic.getStatus()) || topic.getSelectedStudentId() != null) { throw new ServiceException("该课题当前不可选"); } int rows = topicDao.updateSelectedStudent(topicId, studentId); if (rows != 1) { throw new ServiceException("选课冲突,请选择其他课题"); } // 写入进度初始记录,开题阶段待提交 Progress progress = new Progress(); progress.setStudentId(studentId); progress.setTopicId(topicId); progress.setStage(1); progress.setAuditStatus(0); progressDao.insert(progress); return true; }

selectByIdForUpdate对应 Mapper XML 里的SELECT ... FOR UPDATE,这是行级锁的关键。两个事务同时执行到这条语句时,后到的会阻塞等待,前一个提交后它拿到的数据就是新状态,然后进入updateSelectedStudent时影响行数为 0,抛异常回滚。@Transactional必须加在 public 方法上,如果从同一个类的另一个方法内部调用,事务注解会失效,这个是 Spring 的代理机制决定的坑。

3.4 文件上传:本地目录存储方案的配置和大小限制

提交文档是毕设管理系统的硬需求,实现方案两种:本地磁盘存储和对象存储。个人开发和校内部署选本地存储最简单,把上传目录配成应用外部的一个绝对路径,这样重新部署不会丢文件。下面是一个基于 SpringMVC 配置类的上传限制配置:

@Configuration public class UploadConfig implements WebMvcConfigurer { @Value("${upload.maxSize:52428800}") private long maxSize; @Bean public MultipartResolver multipartResolver() { CommonsMultipartResolver resolver = new CommonsMultipartResolver(); resolver.setDefaultEncoding("UTF-8"); resolver.setMaxUploadSizePerFile(maxSize); resolver.setMaxUploadSize(maxSize * 2); return resolver; } }

注意CommonsMultipartResolver的 bean 名称必须是multipartResolver,这是 SpringMVC 按约定查找的,改名字直接不生效。上传接口里用MultipartFile.transferTo(File)保存,transferTo的坑在于跨盘符移动文件会失败,比如临时目录在 C 盘但目标路径在 D 盘,此时应该先getInputStream()再手动拷贝。文件名必须重命名,用UUID.randomUUID()拼上原文件扩展名一次性生成,防止中文名乱码和路径注入风险。

@RequestMapping(value = "/upload", method = RequestMethod.POST) @ResponseBody public ApiResult upload(@RequestParam("file") MultipartFile file, @RequestParam("topicId") Integer topicId, HttpSession session) { if (file.isEmpty()) { return ApiResult.fail("文件为空"); } String originalName = file.getOriginalFilename(); String ext = originalName.substring(originalName.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + ext; String basePath = uploadProperties.getStoragePath(); File dir = new File(basePath + File.separator + topicId); if (!dir.exists()) { dir.mkdirs(); } File target = new File(dir, newName); try { file.transferTo(target); } catch (IOException e) { return ApiResult.fail("文件保存失败"); } // 把新文件路径写入 progress 表 return ApiResult.success(newName); }

上传目录按课题 ID 分子文件夹,查找某个课题下的所有文档只需要拼路径前缀。如果以后改对象存储,只需要把transferTo换成客户端 SDK 的上传调用,Controller 层接口不变,对前端完全透明。

3.5 Controller 与前端页面的衔接:JSON、时间格式和请求路径约定

后端接口统一返回ApiResult包装对象,包含code / message / data三个字段,这样前端判断业务成功只看code即可。SpringMVC 返回 JSON 需要在方法上标@ResponseBody,或者类上直接写@RestController。需要特别注意的是时间字段的序列化格式,默认的 Jackson 会把Date序列化成时间戳数字,需要配置自定义格式化器:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void extendMessageConverters(List<HttpMessageConverter<?>> converters) { for (HttpMessageConverter<?> converter : converters) { if (converter instanceof MappingJackson2HttpMessageConverter) { ObjectMapper mapper = ((MappingJackson2HttpMessageConverter) converter).getObjectMapper(); mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } } } }

前端页面如果是 JSP 加 JSTL,Controller 返回视图名配合InternalResourceViewResolver解析,注意 JSP 放在WEB-INF/views下禁止外部直接访问,所有页面必须通过 Controller 跳转。如果前端用的是 Vue 等框架做前后端分离,后端只需要把所有接口返回 JSON 并处理好 CORS 跨域即可,Controller 不再返回视图。

4. SSM毕设管理系统配置与开发中的高频坑:现象、原因和解决

4.1 启动报错:Invalid bound statement (not found) 却找不到问题在哪

现象:Tomcat 启动正常,但一访问业务接口就抛Invalid bound statement (not found): com.demo.dao.TopicDao.selectByIdForUpdate。

原因:三个位置出一处就会这样。Mapper XML 的namespace没写成接口全限定名,或者 Mapper XML 文件没有被打包进 classes 目录,或者mapperLocations路径配错匹配不到 XML。

解决:先打开target/classes查看有没有对应mapper目录下的 XML 文件。没有的话在 POM 中加资源过滤配置,把src/main/java下的 XML 也纳入打包。namespace必须与接口的包路径完全一致,一个字母错了都会无法绑定。

<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> <resource> <directory>src/main/resources</directory> </resource> </resources> </build>

4.2 查询出来的数据某些列是空值,其他列正常

现象:MyBatis 查询结果对象里部分属性为 null,数据库字段明明有值;控制台打印 SQL 也是正常的。

原因:数据库字段名是下划线风格(如create_time),实体属性是驼峰风格(createTime),MyBatis 默认不做自动映射转换。如果你用了resultMap但漏了字段,或直接用resultType,下划线字段就映射不上。

解决:在SqlSessionFactoryBean的配置里加configuration,开启驼峰映射。

<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.demo.entity"/> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> </bean> </property> </bean>

这个配置加了之后,像selected_student_id到selectedStudentId的映射自动完成,resultMap 里只需写多表关联查询的特殊列,单表查询全部可以只用resultType。

4.3 数据库只读事务却修改了数据、事务回滚不生效

现象:Service 方法标了@Transactional,但业务抛异常后数据还是变了,回滚形同虚设。

原因:最常见是方法内部 try-catch 吞掉了异常,事务感知不到。Spring 事务回滚的触发条件是异常从被代理的方法向外抛出,而不是你 catch 后自己处理。另一种情况是@Transactional默认只回滚RuntimeException,检查异常(比如直接throws Exception)不会触发回滚。

解决:业务方法里不要吞异常,确实需要 catch 的业务场景改成TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚。事务注解显式指定回滚异常类型,把检查异常也纳入回滚。

@Transactional(rollbackFor = Exception.class) public boolean doSomething() { try { // 业务逻辑 } catch (Exception e) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); throw new ServiceException("操作失败", e); } return true; }

还有一种隐蔽场景,同一个类里this.selectTopic()调用自己写的另一个带@Transactional的方法,事务不生效。Spring 通过代理对象拦截注解,this是原始对象而非代理。解决是在类里注入自身代理(通过AopContext.currentProxy()),或者拆到两个 Service 类里互相调用。

4.4 表单提交中文乱码,文件下载的文件名乱码

现象:表单提交到后端后中文全部变成问号,或者下载文件时文件名显示为乱码。

原因:POST 请求体没有配置字符编码过滤器,Tomcat 8 之后 GET 请求的 URI 编码默认 UTF-8,但 POST 的请求体会用 ISO-8859-1 解码。文件下载时响应头里Content-Disposition的filename属性不支持非 ASCII 字符也是老问题。

解决:在web.xml配置CharacterEncodingFilter,顺序一定要放在所有过滤器最前面。

<filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

forceEncoding必须设为true,这样不仅对请求体解码,响应也会强制用 UTF-8,两个问题一并解决。下载文件名乱码的解决是用URLEncoder.encode先编码再拼到响应头,前端拿到后用decodeURIComponent还原。

4.5 部署到服务器后上传的文件丢失,路径报找不到目录

现象:本地 Windows 或 Mac 开发时上传正常,部署到 Linux 服务器后上传报目录不存在,或者文件存到了临时目录导致重启后全部丢失。

原因:本地代码里用了相对路径,或者直接写到应用部署目录下。相对路径在 Linux 上依赖工作目录,而 Tomcat 的服务脚本工作目录通常不是 webapp 根目录。写进部署目录的文件在重新部署时会整个清空。

解决:上传目录用绝对路径,在application.properties里配置成外部路径比如/var/www/upload,启动前mkdir -p手动创建。路径拼接用File.separator而不是硬编码/或\,保证跨平台一致。我习惯在应用启动时加一个ApplicationRunner检查目录是否存在,不存在就自动创建,省去部署时手动建目录的步骤。

4.6 页面打开 404,但接口和静态资源都配置正常

现象:直接访问http://localhost:8080/xxx返回 404,SpringMVC 控制器方法没有任何报错,前端资源也都在。

原因:典型的三种情况:Controller 类没有被扫描到,看包扫描路径是否包含 Controller 所在包;web.xml里 DispatcherServlet 的url-pattern写的是/但缺少mvc:annotation-driven导致注解驱动失效;或者视图解析器前缀后缀配置错误导致 JSP 资源定位失败。

解决:检查springmvc.xml的<context:component-scan base-package="com.demo.controller"/>,包名写错导致注入不上,报错往往不是 404 而是NoSuchBeanDefinitionException。DispatcherServlet 的初始化参数contextConfigLocation指向的 XML 文件路径如果写错,启动时其实会报错,但有些版本会静默失败然后给出空容器,需要打开 Tomcat 的 localhost 日志确认容器初始化顺序。页面 404 时先看网络面板返回的是 404 还是 500,500 代表 Controller 匹配上了但渲染出错,排查方向完全不同。

5. 提速技巧:MyBatis 代码生成器、无损分页和上线前必做的接口验证清单

5.1 用通用 Mapper 和代码生成器把重复 CRUD 代码压缩到两成

在建表工作完成后,实体类、DAO 接口、XML 映射文件这些代码是高度模板化的。手动写一遍四张表还撑得住,表超过七张再手写就是在浪费生命。推荐的做法是使用 MyBatis Generator(MBG)生成基础代码,然后用通用 Mapper 或者 MyBatis-Plus 简化单表操作,手写部分只保留多表关联和复杂业务。

MBG 的配置要点:targetProject指向实际源码路径,javaModelGenerator和sqlMapGenerator分开指定包名,table节点用%匹配所有表。生成完成后做三件小事:XML 中删除不需要的重复列、实体类补上默认值、DAO 接口加上自定义方法。注意 MBG 生成的结果是覆盖式的,表结构变更后重新生成前要把手写代码先备份或放到另一个目录。

规模小到中等时,更省事的路线是引入 MyBatis-Plus 的BaseMapper,它自带selectById / insert / updateById / selectPage等现成接口,XML 里只需要写多表 join 的 SQL。MyBatis-Plus 与原生 MyBatis 是兼容的,切换成本主要是把 SqlSessionFactory 的配置换成 MyBatisPlus 的版本。只要不是团队里有强约定必须原生 MyBatis,我建议毕设类项目直接用 Plus,开发效率差距是肉眼可见的。

5.2 PageHelper 分页的注意点:嵌套查询和统计语句的准确性

列表分页是管理后台逃不掉的功能。用 PageHelper 做分页,能自动拦截查询并生成COUNT语句,比手写LIMIT方便。但有两个高频坑必须先注意:PageHelper.startPage()后面必须紧跟第一条 SQL,如果中间插了任何其他查询,拦截会作用到错误的语句上;对一对多关联查询(比如查课题列表同时带出教师信息)做分页时,COUNT 语句可能统计错行数,需要在 Mapper 层把关联查询拆成分步查询。

PageHelper 的参数设置按实际数据量调整。毕设管理系统单表数据量在几千条这个量级,reasonable=true这个参数一定要开,它会自动纠正页码越界,比如请求页码是 99 就自动归到最后一页。supportMethodsArguments设为true,这样 Controller 方法里直接用PageNum / PageSize参数接收,省去手动从Page对象取值的样板代码。

@RequestMapping("/topic/list") @ResponseBody public ApiResult list(@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); List<TopicVO> list = topicDao.selectTopicWithTeacher(keyword); PageInfo<TopicVO> pageInfo = new PageInfo<>(list); return ApiResult.success(pageInfo); }

PageInfo包含list / total / pageNum / pageSize / pages等完整分页数据,前端拿到直接渲染,不需要再自己拼接统计接口。注意PageHelper.startPage只对接下来的一次查询生效,查完自动清除,所以你不需要也不能在 finally 里手动清理。多次调用startPage会后者覆盖前者,这是常见 bug 来源。

5.3 部署验证:启动顺序、端口占用、数据库连接三连查

交接给部署环境前,我习惯用一个固定顺序自查,能省一半的运维沟通时间。先确认 MySQL 已启动且jdbc.properties的账号密码正确,用客户端连一下数据库这种最原始的方式反而最快;然后检查server.xml的端口没被占用,Linux 上用netstat -lnp | grep 8080验证;最后启动 Tomcat 后马上看catalina.out和localhost.log,不要看 IDEA 控制台——IDEA 控制台是合并输出,会吞掉很多关键报错。

访问任何需要登录的接口之前,先访问静态资源确认应用上下文能通。如果部署的是 war 包,确认 Tomcat 解压后的目录名和访问路径一致,比如demo.war部署后访问前缀是/demo/,前端所有请求路径都要带这个前缀。我见过最奇怪的一次是项目用根路径启动正常,换到子路径后 CSS 全部 404,原因是 JSP 里引资源写了绝对路径/css/style.css,少写了${pageContext.request.contextPath}。这个细节不难改,但排查时很容易忽略。

5.4 上线前补上这几个接口测试,能躲过大部分课后反馈

按重要程度排序,我每次上线前会至少跑通下面五条链路:学生选题的一次成功与一次冲突场景、教师审核通过的完整闭环(状态 0 到 1)、文件上传后能立即下载且文件名不乱码、管理员重置密码后再登录成功、以及并发提交同一个选题时只有一个成功。

这五条链路里最容易被忽略的是并发测试。两个浏览器同时提交同一个课题,后提交的那个要么拿到“已被选择”的提示,要么事务报错自动回滚,无论如何不能出现数据里一个课题被两个学生同时关联的情况。如果表设计里没有给topic.selected_student_id做唯一约束,应用层又没有FOR UPDATE,并发漏掉就是必然的。有个简单的验证方式:topic表里selected_student_id加唯一索引,这样就算应用层漏锁,数据库也会兜底报错,数据不会脏。

我说句实在话,上面这些坑我每一类都亲手踩过,尤其是事务回滚不生效和 XML 没打进包这两个,每次都耗掉大半天。这也是为什么我坚持把配置代码写全注释、把状态迁移规则写在表注释上方——后来接手这些代码的师弟不需要重新踩一遍。如果你也是第一次独立跑完一个 SSM 系统,记得先按 4.3 的清单检查事务配置,这关过了,系统的基本可靠性就立住了。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进