高校学生学籍管理系统这个题目,真的是Java课设和毕设里的常青树。我当时拿到的实训项目编号就叫java_ssm78,一听就知道是走最经典的Spring + SpringMVC + MyBatis技术组合。说实话刚看到这个题目时觉得太“大路货”,但真正做完之后反而庆幸选了它——SSM框架项目里最常见的那些坑,这个学籍系统几乎全踩了一遍,而且每一步都能讲出因果。
学籍管理系统说白了就是管一个学生从入学到毕业期间的所有关键信息:基本信息、班级归属、成绩记录、休学复学退学这类学籍异动。它不像电商系统有复杂的营销逻辑,也不像社交产品需要高并发设计,它最大的价值在于把业务状态理清楚、把增删改查写扎实、把权限和事务边界划明白。对正在做Java课程设计和毕业设计的同学,以及刚入行想用一个完整项目梳理SSM框架体系的开发者来说,这份实战经验应该能帮你省不少折腾的时间。
下面我会直接按项目从需求拆解、数据库建模、后端搭建、编码实现到部署踩坑的顺序,把这个系统原原本本地拆开讲,包括每个环节的取舍理由和翻车现场。
1. 从题面到需求:学籍管理系统到底要解决什么
1.1 学籍业务的完整闭环:从入学到毕业
先不说框架,先想清楚“学籍管理”这四个字到底是什么意思。我在做需求梳理的时候,把学籍系统的业务链路画成了一条线:新生入学(学号分配、班级归属、基本信息建档)→ 在校期间(成绩录入、成绩查询、个人信息维护)→ 学籍异动(休学、复学、转专业、退学、转学)→ 毕业(毕业审核、状态归档)。
这条链路的每个节点,都对应着数据库里的一条状态流转,也对应着页面上的一个操作按钮。学籍系统跟论坛、博客这类项目最大的区别在于,它的所有功能都围绕“学生当前处于什么状态”来展开。这个认知直接影响了我后面设计学籍状态字段和异动日志表的方式。
从高校教务口的实际业务来看,功能上至少需要覆盖这些模块:
- 学生信息管理:学生基本信息的增删改查、按学院/专业/班级/状态筛选。
- 学籍异动管理:休学、复学、退学、转专业、转学的申请与审批记录。
- 成绩管理:按班级录入成绩、按学生查询成绩、按学期统计及格率和平均分。
- 基础数据维护:学院、专业、班级、课程的维护,这是所有下拉框和筛选条件的数据来源。
- 用户与角色:管理员、教师/教务、学生三类账号,对应不同可见范围和操作权限。
- 统计页面:各学院学生人数、班级成绩分布这类数据,不需要太复杂,但不能没有。
这些需求不是拍脑袋定的,是照着真实学籍管理业务的逻辑来的。如果做项目时只堆“增删改查”而不把业务闭环讲清楚,答辩时很容易被问住。
1.2 三类用户视角下的功能清单
同一个系统,不同角色看到的界面和能做的事完全不同。最初设计时我没想清楚角色,导致所有页面都裸奔,后来才补了登录拦截和角色判断。
管理员这边的活最重:基础数据维护(学院、专业、班级、课程)、学生账号分配、学籍异动的审核归档。教师/教务这边偏向日常操作:录入成绩、查看所带班级学生名单、发起学籍异动申请。学生这边最简单也最敏感:只能看自己的学籍信息和成绩,不能看到别人的。
这里有一个容易忽略的细节——学生自己只能读、不能改关键字段。比如年龄、身份证号这些应由管理员维护,学生只能看。我实现的时候,在“更新学生信息”这个业务方法里做了字段级别的拆分:管理员走完整更新接口,学生只能走修改密码和部分联系方式接口。
1.3 学籍状态机的设计
学籍状态是整个系统的核心字段,我用数字枚举存库,避免直接用中文状态导致统计和修改困难。
状态定义:0=在读、1=休学、2=退学、3=毕业、4=转学。在读状态下允许发起休学申请,休学状态下允许复学(回到在读),在读状态下允许转专业(保留在读状态但变更专业班级),退学、转学、毕业都是终态,一旦置为终态就不能再回到在读。
状态变更绝不能只是改个字段,每次变更都要记录“谁在什么时间因为什么原因把某个学生从A状态变成了B状态”。这就是后面要专门建学籍异动日志表的原因。试想一个学生休学一年后复学,如果没有日志,根本没法追溯。真做项目时,这张日志表的价值甚至比student表本身还大。
2. 技术选型复盘:SSM这套东西在这个项目上为什么能打
2.1 三件套的职责切分
Spring、SpringMVC、MyBatis,三个框架各管一段:MyBatis管数据库访问,把SQL写在XML或注解里,结果自动映射成Java对象;Spring管对象生命周期和依赖关系,把Service、Mapper这些类统一装配起来,同时用声明式事务管住数据库操作的一致性;SpringMVC管HTTP请求分发,用户的每一次请求都由DispatcherServlet转发给对应Controller方法,再把返回值解析成页面路径或JSON。
用个生活化比喻就是:SpringMVC是前台接待,拿到用户诉求后转给对应的办事窗口;Controller是窗口业务员,负责收数据;Service是后台业务主管,负责审核和拍板;MyBatis是档案室工作人员,只负责取数据、存数据。
这三个东西在学籍系统里的分工非常清晰,也把分层结构一并强制出来了:Controller层不写SQL,Service层不碰HttpServletRequest,Mapper接口不写业务逻辑。这个分层习惯对后面扩展和面试都特别好用。
2.2 和Spring Boot方案比,SSM多了什么价值
现在新项目基本都直接用Spring Boot了,但SSM依然值得做,原因有两条。
第一,SSM强迫你理解“配置是怎么来的”。Spring Boot把大部分配置都自动装配了,你只需要加注解,但底层怎么把DataSource交给SqlSessionFactory,再交给Service,再注入到Controller,这套容器关系不亲手配一遍很难有体感。我在配置web.xml和applicationContext.xml上花的时间,比写业务代码还多,但正是一张张XML把Spring容器的父子关系、Bean扫描机制、事务代理原理都逼着弄明白了。
第二,面试题大量集中在这一块。你看网上Java面试题里关于Spring IOC、AOP、事务失效、MyBatis一级二级缓存、#{}与${}的区别,绝大多数都基于SSM或Spring底层原理在问。做完一个SSM项目,等于把这些知识在真实代码里验证了一遍。面试官问你“项目事务怎么控制的”,你如果只说“我加了@Transactional”,肯定不行;但你能说出事务管理器配在哪里、为什么@Service的类要用代理对象、自调用为什么失效,这就是加分项。
2.3 环境与依赖版本锁定
老项目最怕版本乱。我给这个项目定的环境比较稳:JDK 1.8、Maven 3.7+、Tomcat 8.5、MySQL 5.7(或8.0)、IDEA。依赖方面用Spring 5.2.x、MyBatis 3.5.x、mybatis-spring 2.0.x、Druid 1.2.x、PageHelper 5.1.x、Jackson 2.9.x。
pom.xml核心依赖大致是这样:
<properties> <spring.version>5.2.22.RELEASE</spring.version> <mybatis.version>3.5.9</mybatis.version> <mysql.version>8.0.22</mysql.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</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.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.version}</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.9</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.1.11</version> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> </dependencies>注意一点:Servlet API要加provided,否则打war包时会和Tomcat自带的环境冲突。这个问题我当时遇到过,debug了半天才发现是依赖冲突引起的。
3. 数据库建模:学籍系统的表结构怎么设计才不返工
3.1 基础表与业务表
我把表按层级分成了基础数据表和业务数据表。基础表是学院、专业、班级、课程,业务表是学生、成绩、学籍异动、用户。
先看最重要的一张表:
CREATE TABLE `student` ( `id` int(11) NOT NULL AUTO_INCREMENT, `student_no` varchar(20) NOT NULL COMMENT '学号', `name` varchar(50) NOT NULL COMMENT '姓名', `gender` char(2) NOT NULL COMMENT '性别', `birthday` date DEFAULT NULL COMMENT '出生日期', `id_card` varchar(18) DEFAULT NULL COMMENT '身份证号', `political_status` varchar(20) DEFAULT NULL COMMENT '政治面貌', `native_place` varchar(100) DEFAULT NULL COMMENT '籍贯', `college_id` int(11) NOT NULL COMMENT '所属学院', `major_id` int(11) NOT NULL COMMENT '所属专业', `clazz_id` int(11) NOT NULL COMMENT '所属班级', `enrollment_year` int(4) DEFAULT NULL COMMENT '入学年份', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '学籍状态: 0在读 1休学 2退学 3毕业 4转学', `phone` varchar(20) DEFAULT NULL, `email` varchar(100) DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_no` (`student_no`), KEY `idx_college_major_clazz` (`college_id`, `major_id`, `clazz_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;学号作为唯一键是必须的,业务上通常不用id找学生,而是用学号。查询和筛选也大多集中在college_id、major_id、clazz_id这些字段,所以建了联合索引。
不要用中文状态直接存字段。status直接写“在读”看似直观,但程序里需要比较、统计、流转时就会到处写字符串常量,很容易出错。用tinyint存枚举值,程序里定义常量或枚举类,展示时再翻译成中文,这是更稳的做法。接口层返回给前端时再根据枚举值映射成中文标签,一劳永逸。
3.2 成绩表的设计
成绩表的核心是学生与课程的多对多关系。一个学生选多门课,一门课有多个学生,所以score表本质是关联表。
CREATE TABLE `score` ( `id` int(11) NOT NULL AUTO_INCREMENT, `student_id` int(11) NOT NULL, `course_id` int(11) NOT NULL, `semester` varchar(20) NOT NULL COMMENT '学期,例如2023-2024-1', `score` decimal(5,2) DEFAULT NULL COMMENT '成绩,百分制,空代表缺考', `credit_point` decimal(3,1) DEFAULT NULL COMMENT '学分绩点,可由成绩换算', `exam_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_student_course_semester` (`student_id`, `course_id`, `semester`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个唯一约束是关键。同一学期、同一学生、同一门课只能有一条成绩记录,录入时的幂等性就有了保障。成绩录入接口如果被重复提交,数据库层面会直接拒绝,程序里再做一次友好提示即可。没这个约束之前,重复录入很容易把某门课的成绩覆盖或插出多条记录,数据一多就乱套。
3.3 学籍异动日志表
这张表是我中途加上的,但对整个系统价值很大。
CREATE TABLE `status_change_log` ( `id` int(11) NOT NULL AUTO_INCREMENT, `student_id` int(11) NOT NULL, `change_type` varchar(20) NOT NULL COMMENT '休学/复学/退学/转专业/转学/入学/毕业', `prev_status` tinyint(4) NOT NULL, `next_status` tinyint(4) NOT NULL, `reason` varchar(500) DEFAULT NULL COMMENT '异动原因', `operator_id` int(11) DEFAULT NULL COMMENT '操作人id', `change_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_student` (`student_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;每次状态变更时,程序必须同时完成两件事:更新student表的status、插入一条status_change_log。这两步必须在一个数据库事务里完成,否则会出现状态改了但没日志,或者有日志但状态没改的脏数据。这个业务点非常适合用来讲清楚“事务”这个面试考点,也是本项目中最能体现业务严谨性的地方。
4. 后端搭建顺序:从XML配置文件到第一个接口跑通
4.1 web.xml:整个应用的起点
SSM项目是war包部署在Tomcat里的,web.xml是Tomcat读取的第一个配置。里面那段配置决定了Spring容器、SpringMVC容器、字符编码过滤器怎么启动。
<context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath*:spring/applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <servlet> <servlet-name>dispatcherServlet</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath*:spring/springmvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcherServlet</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <filter> <filter-name>encodingFilter</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>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>这里有个高频坑:ContextLoaderListener加载的是父容器,DispatcherServlet加载的是子容器。如果两个容器扫描的包范围重叠,同一个类会被创建两份对象,Service的单例就失效了,事务代理也会出问题。正确做法是父容器只扫描Service和Mapper,子容器只扫描Controller。我一开始图省事把所有包都放到一个component-scan里,运行没报错,但一加事务就各种不生效,排查了好久才发现是这个原因。
4.2 Spring与MyBatis的桥接配置
applicationContext.xml是父容器的核心配置。数据源、SqlSessionFactory、Mapper接口扫描、事务管理器都在这边。
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <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="typeAliasesPackage" value="cn.school.entity"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="plugins"> <array> <bean class="com.github.pagehelper.PageInterceptor"> <property name="properties"> <value>helperDialect=mysql</value> </property> </bean> </array> </property> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="cn.school.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>db.properties里MySQL 8.0的连接串必须指定时区和驱动类名,否则连接会报错:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/school_ms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=falseurl里不写characterEncoding=utf8,后面页面显示中文就会出现乱码。这个坑我踩过不止一次。
4.3 SpringMVC的请求链路配置
springmvc.xml里做三件事:扫描Controller、开启注解驱动、配置视图解析器。
<context:component-scan base-package="cn.school.controller"/> <mvc:annotation-driven/> <mvc:default-servlet-handler/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>Controller方法的返回值如果是"student/list",视图解析器会自动拼成"/WEB-INF/views/student/list.jsp"。这里默认后缀必须配好,否则一个字符串返回值会被当成完整路径去匹配,直接404。
4.4 第一个接口跑通后的自检清单
配置完成后,我会按这个清单验证一遍,能省掉后期大把Debug时间:
- 数据库连接正常,打印日志能看到Druid连接池初始化成功。
- 访问项目根路径能跳转到登录页,登录页能正常加载静态资源(CSS、JS)。
- 所有Mapper能被扫描注册,用测试代码调一个最简单的selectById不报绑定异常。
- 在一个加了事务的Service方法中成功完成一次数据库提交。
这四关过了,SSM骨架就算立住了,后面写业务代码会顺畅很多。
5. 核心模块编码细节:学籍异动、成绩录入与分页查询
5.1 学籍异动接口的事务边界
学籍异动接口的Service方法加了@Transactional,方法内部先改student状态,再插日志。第二步失败时,第一步自动回滚。
@Service public class StudentStatusServiceImpl implements StudentStatusService { @Autowired private StudentMapper studentMapper; @Autowired private StatusChangeLogMapper statusChangeLogMapper; @Override @Transactional(rollbackFor = Exception.class) public void changeStudentStatus(StudentStatusChangeRequest request) { Student student = studentMapper.selectById(request.getStudentId()); if (student == null) { throw new ServiceException("学生不存在"); } // 校验状态是否允许流转 if (!checkStatusTransition(student.getStatus(), request.getNextStatus())) { throw new ServiceException("当前状态不能变更为目标状态"); } studentMapper.updateStatus(student.getId(), request.getNextStatus()); StatusChangeLog log = new StatusChangeLog(); log.setStudentId(student.getId()); log.setChangeType(request.getChangeType()); log.setPrevStatus(student.getStatus()); log.setNextStatus(request.getNextStatus()); log.setReason(request.getReason()); log.setOperatorId(request.getOperatorId()); statusChangeLogMapper.insert(log); } }@Transactional默认只对RuntimeException生效,如果业务方法抛的是checked异常,就必须显式设置rollbackFor=Exception.class,否则事务不会回滚。这是我实测踩过的坑,后来所有事务方法统一设了rollbackFor,不再依赖默认行为。
还有个细节:事务方法不能在同一类内部自调用。比如StudentStatusService里一个方法调用本类的另一个方法,如果被调用的方法是this.xxx()这样直接调用,事务就不会经过Spring代理,直接失效。要触达事务,必须让代理对象去调。这个“事务自调用失效”就是面试常问的经典场景。
5.2 成绩统计:让人头疼的多表分组SQL
成绩模块除了增删改查外,还有一个统计页面:按学院/专业/班级/课程维度,统计人数、平均分、最高分、最低分、及格率。这个页面最能体现SQL功底。
SELECT c.course_no, c.name AS course_name, COUNT(s.id) AS total_count, AVG(s.score) AS avg_score, MAX(s.score) AS max_score, MIN(s.score) AS min_score, ROUND(SUM(CASE WHEN s.score >= 60 THEN 1 ELSE 0 END) / COUNT(s.id) * 100, 2) AS pass_rate FROM course c LEFT JOIN score s ON c.id = s.course_id LEFT JOIN student stu ON s.student_id = stu.id WHERE stu.college_id = #{collegeId} AND stu.major_id = #{majorId} AND s.semester = #{semester} GROUP BY c.id, c.course_no, c.name ORDER BY c.course_no;这里用LEFT JOIN而不是INNER JOIN,是为了把选了课但还没录入成绩的课程也统计出来。空成绩不参与平均分计算,但会有count为0的展示。COUNT(s.id)和COUNT(s.score)有区别:前者统计记录数,后者自动忽略null,统计缺考情况就要灵活用这两种写法。
成绩录入接口同样要处理一种特殊情况:同一学期下同一门课重复提交时,不要走到insert报唯一约束异常,最好先走一次select判断,再决定走update还是insert,这样页面端可以给出“已提交修改”的提示,而不是让用户看到500错误页。
5.3 分页与条件查询的动态SQL
学生列表页是使用频率最高的页面,学院、专业、班级、学号、姓名、状态都是筛选条件,任何一个都可能为空。固定SQL完全不行,必须用MyBatis动态SQL拼条件。
<select id="selectStudentPage" resultType="cn.school.entity.Student"> SELECT * FROM student <where> <if test="studentNo != null and studentNo != ''"> AND student_no LIKE CONCAT('%', #{studentNo}, '%') </if> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="collegeId != null"> AND college_id = #{collegeId} </if> <if test="majorId != null"> AND major_id = #{majorId} </if> <if test="clazzId != null"> AND clazz_id = #{clazzId} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY student_no </select>分页用PageHelper插件,在Page类上打印SQL时会自动改造成limit语句:
PageHelper.startPage(pageNum, pageSize); List<Student> list = studentMapper.selectStudentPage(condition); PageInfo<Student> pageInfo = new PageInfo<>(list);PageHelper.startPage后面紧跟的第一条查询才会被分页,中间不能插入其他查询语句。如果Service方法里先查了别的东西再查学生列表,分页就失效了。这是PageHelper使用最常见的坑,没有之一。
6. 前端页面与数据交互:JSP + Layui + Ajax的组合拳
6.1 页面组织与路由
SSM项目的前端通常还是JSP,我用了Layui做后台管理界面,因为它的表格、弹窗、表单组件对管理类系统非常友好,API也好上手。页面统一放在WEB-INF/views下,按模块建目录:
views/login.jsp、views/main.jsp、views/student/list.jsp、views/student/edit.jsp、views/score/list.jsp、views/score/edit.jsp、views/status/change.jsp。
WEB-INF目录下的页面不能靠URL直接访问,必须经Controller跳转,这天然增加了一层访问控制。我写了一个LoginInterceptor登录拦截器,在springmvc配置里注册,排除登录页和静态资源后,其他所有URL都检查Session里有没有用户,没有就跳回登录页。学籍系统涉及学生隐私数据,这个拦截器必须加。
6.2 Ajax请求与JSON数据返回
页面表格的数据全部走Ajax,用Layui的table组件渲染,后端接口返回JSON。Controller方法的套路要统一:
@RequestMapping("/page") @ResponseBody public Result page(@RequestParam Integer page, @RequestParam Integer limit, StudentQuery query) { PageHelper.startPage(page, limit); List<Student> list = studentService.queryPage(query); PageInfo<Student> pageInfo = new PageInfo<>(list); return Result.success(pageInfo.getTotal(), list); }这里的Result是统一返回对象,包含code、msg、data三个字段,前端拿到后统一判断code是否等于200。这个习惯很重要,如果每个Controller方法各返回各的JSON结构,前端处理逻辑会乱成一团,每加一个模块就要复制一批处理代码。
对于复杂查询条件,我就在查询对象里包一个StudentQuery,前端靠URL参数传递。参数特别多时也可以改成POST加@RequestBody接收JSON对象,Jackson会自动反序列化成Java对象。这个方案我在学生批量查询里用过,很稳。
6.3 表单校验与数据回显
新增和编辑学生页面的表单校验分两层。前端必填项用Layui自带校验,后端在Service里做业务校验,比如学号唯一性、身份证号格式。很多人只做前端校验,结果用Postman直接调接口就能绕过,数据脏得没法看。学籍系统这种管理软件,后端校验是底线。
数据回显有个小坑:编辑页面打开时,要先按id查出学生的完整记录,再准备学院、专业、班级联动下拉框的数据。我的方案是:页面加载后先调接口获取所有学院列表,根据当前学生的collegeId回显选中项;再异步加载该学院下的专业,最后是班级。这个三级联动是学籍系统里最典型的页面细节,难在每次切换学院时,下级下拉框要清空重新加载,否则会出现旧数据残留。
7. 部署运行与经典踩坑:把我折腾到凌晨的几个问题
7.1 Spring容器与SpringMVC容器重复扫描,导致事务失效
这个坑我在配置里提过,但值得单独拿出来说一遍,因为它真的隐蔽。
把Service和Controller放在同一个component-scan扫描包下,看起来一切正常,可真到写学籍异动接口时,@Transactional怎么都不生效。数据库数据改了,但异常时没有回滚。
排查链路是这样的:先在Service方法里人为抛RuntimeException,看数据是否回滚,结果没回滚。检查applicationContext.xml,事务管理器配置没问题,@Transactional注解位置没问题。最后打日志发现,同一个Service类在Spring容器和SpringMVC容器里各创建了一个Bean,Controller里注入的实例跟事务代理对象不是同一个。原因就是两个容器的扫描范围重叠,子容器把Service也扫描了一遍,父容器又扫了一遍,导致Controller拿到的Service实例没有经过父容器的事务代理。
修复方式很简单:父容器扫描cn.school.service和cn.school.mapper,子容器只扫描cn.school.controller。这也是SSM项目经典的配置禁忌,网上案例很多,但只有自己踩一遍印象才深。
7.2 MyBatis绑定异常与SQL注入隐患
项目里经常报“Invalid bound statement”,多半是Mapper接口和XML绑定不上。原因不外乎三点:namespace没和接口全限定名保持一致;XML文件没被扫描到(mapperLocations路径不对);方法id和接口方法名不一致。
排查习惯:先看编译后的target目录里XML文件是否拷贝过去,再看namespace,最后看方法签名。其中第一种在新手里最常见,因为src/main/java下的XML有时会被Maven忽略,需要在pom里加resource声明,否则运行期根本找不到映射文件。
还有SQL注入隐患。模糊查询如果用字符串拼接:
String name = request.getParameter("name"); String sql = "SELECT * FROM student WHERE name LIKE '%" + name + "%'";这种写法就是经典注入点。项目里全部走MyBatis的#{}参数绑定,它是预编译占位符,传什么都被当字符串处理,安全得多。${}不能用来拼接用户输入,只能做排序字段名这类白名单控制,且必须校验合法值。
7.3 静态资源404和中文乱码
静态资源404是SSM新手高频问题。DispatcherServlet把url-pattern配成/后,所有请求都会进SpringMVC,CSS、JS、图片也被拦截了。解决方法是springmvc里加一句:
<mvc:default-servlet-handler/>这样SpringMVC处理不了的请求会交回Tomcat默认的servlet处理,静态资源就能正常加载了。
中文乱码分两头。请求参数乱码用CharacterEncodingFilter解决,已在web.xml里配好;数据库存储和读取乱码需要在JDBC连接串上加characterEncoding=utf8,同时数据库表用utf8mb4字符集。另外MySQL的serverTimezone参数也别落,某些版本不加会直接报连接错误。
7.4 项目跑通之后的扩展思路
这个项目做完,我建议你别停在原地,做几个方向的扩展,顺便变成面试谈资。
- 引入POI做学生信息批量导入导出,把Excel上传解析后批量插入,面试时能讲清楚内存占用和数据校验的取舍。
- 用Spring Security或Shiro替换手写的登录拦截器,能顺势讲RBAC权限模型。
- 把学籍异动改造成带审批流的模块,加深对状态机、审批流程的理解。
- 把接口从JSP跳转改成纯RESTful + JSON,前端做前后端分离,能自然延伸到当前主流开发模式。
做完SSM项目再去看Spring Boot,你会发现很多配置都“懂原理”了,上手速度远快于直接学框架的人。
我自己做完这个项目最大的体会是:学籍管理系统看起来简单,但真正动手才会发现,需求要梳理清楚、表结构要设计合理、事务边界要划准、分页查询要写对、前端交互要配好,每一步都是实打实的基础功。这类系统没有花哨的技术,但它把Java后端日常开发中最常用的能力全练了一遍。越早把SSM这套链路跑通,后面用Spring Boot、微服务时,越能理解框架到底帮你搞定了什么。
如果你的课设、毕设也选了类似的学籍或信息管理系统,不用贪多,只要把学籍异动事务、成绩统计SQL、分页条件查询、登录拦截这些点做扎实,答辩和面试都足够拿得出手。把上面这些踩坑经历记下来,至少能让你少熬两个通宵。