简介:这套基于SSM与MySQL实现的毕业选题管理系统,是面向Java Web学习者、毕业设计开发者及需要快速搭建选题管理场景的学生的完整源码包。系统采用Spring、SpringMVC、MyBatis分层架构,覆盖用户登录注册、选题发布、学生选题、教师审核、选题状态查询等核心业务,并包含数据库SQL脚本与可部署的WAR包,能够帮助读者直观理解企业级Java项目的目录结构、Maven依赖管理及前后端交互流程。压缩包共1150个文件,以240个JSP页面、160个JavaScript脚本、115个XML配置、108个Class文件及Java源文件为主,同时包含CSS样式、图片、字体等静态资源,整体约60.44MB,结构清晰便于按模块查阅。目前已有65人学习下载。通过运行和分析该项目,可深入学习SSM框架整合、MyBatis映射、MySQL数据操作以及Tomcat部署等关键技能,对完善个人项目经验与提升开发能力具有较高参考价值。
1. 毕业选题管理系统卡在 Excel 传阅时,SSM 和 MySQL 是首选方案
每年五月份都会收到几条类似的求助:毕业选题还停留在辅导员收 Excel、老师统计、学生看公告栏的阶段,临近截止日期时容易出现选题撞车、名额分配不均、审核意见无法留痕。用 SSM(Spring + SpringMVC + MyBatis)搭配 MySQL 实现的毕业选题管理系统,正是这类场景下最常见的落地方案,它把题目发布、学生选题、教师审核、管理员导出合成一条可跟踪的业务线。
这篇记录按一套常规的选题管理诉求来拆:先设计能跑通全流程的 MySQL 表结构,再搭出 SSM 工程骨架,接着实现选题与审核的状态流转,最后把源码打包后常踩的部署坑和上线核对技巧一并列出。适合正在做毕业设计、想用 SSM 补一次完整 CRUD 加状态机的同学,也适合需要把类似选题流程搬到内部系统的开发。
2. 先立数据模型:毕业选题管理系统的 MySQL 表结构与建表脚本
2.1 从业务角色推导出的五张核心表
毕业选题管理并不需要把权限设计做成一棵 RBAC 树。常规系统只需要三种登录身份:学生、教师和管理员。学生要能浏览题目并提交申请,教师负责发布题目与审核申请,管理员做基础数据维护和最终名单导出。围绕这三个角色,核心表通常是五张:sys_user统一存账号与角色,student_profile存学生学号和班级,teacher_info存教师工号和每学期可带人数,topic存题目发布信息,selection_record存一条条选题申请和审核结果。
这五张表的划分有一个明显用意:登录认证、人员档案、业务数据三层分离。这样后续要把系统改成“课程设计选题”或“竞赛报名”,只需要替换topic表和selection_record表,账号体系不动。表与表之间不建物理外键,原因是后期清数据、做导入导出时物理外键会很碍事;另外在选题高峰期,外键检查会扩大锁范围,影响 MySQL 的写入并发。表之间的关联交给应用层校验,数据库只保留必要的唯一索引来兜底,这是 SSM 项目里更省心的做法。
2.2 能直接导入 Navicat 的建表脚本
脚本在 MySQL 5.7 和 8.0 上都能直接跑,不依赖 8.0 的新语法。字符集统一用utf8mb4,避免姓名、备注里的生僻字或 emoji 在写入时变成乱码。导入顺序建议是sys_user→student_profile→teacher_info→topic→selection_record,因为后面的表要引用前面的 id。用 Navicat 导入时选“运行 SQL 文件”,别直接复制整个文件到查询窗口,否则注释和分隔符解析容易出现偏差。
CREATE DATABASE IF NOT EXISTS topic_selection DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE topic_selection; CREATE TABLE sys_user ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID', username VARCHAR(50) NOT NULL COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '演示环境建议先存md5,上线前必须替换', user_type TINYINT NOT NULL DEFAULT 0 COMMENT '0学生 1教师 2管理员', real_name VARCHAR(50) NOT NULL, major_id BIGINT NULL COMMENT '学生专业ID,这里冗余不建外键', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB COMMENT='系统用户表'; CREATE TABLE teacher_info ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT '对应sys_user.id', department VARCHAR(100) COMMENT '所属系部', capacity INT NOT NULL DEFAULT 10 COMMENT '本学期可带人数', remaining INT NOT NULL DEFAULT 10 COMMENT '剩余可带人数', UNIQUE KEY uk_teacher_user (user_id) ) ENGINE=InnoDB COMMENT='教师信息表'; CREATE TABLE topic ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, teacher_user_id BIGINT NOT NULL COMMENT '发布教师', title VARCHAR(200) NOT NULL COMMENT '题目名称', description TEXT COMMENT '题目要求和参考方向', pub_year CHAR(4) NOT NULL COMMENT '选题年度,如2026', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1发布 2满员', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_year_teacher (pub_year, teacher_user_id), KEY idx_status (status) ) ENGINE=InnoDB COMMENT='题目表'; CREATE TABLE selection_record ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, student_user_id BIGINT NOT NULL COMMENT '学生用户ID', topic_id BIGINT NOT NULL, topic_year CHAR(4) NOT NULL COMMENT '冗余年度,便于按年查询', status TINYINT NOT NULL DEFAULT 1 COMMENT '1待审核 2通过 3驳回 4退选', apply_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, approve_time DATETIME NULL, reject_reason VARCHAR(255) NULL COMMENT '驳回原因', UNIQUE KEY uk_student_topic (student_user_id, topic_id), UNIQUE KEY uk_student_year (student_user_id, topic_year) ) ENGINE=InnoDB COMMENT='选题申请表';sys_user是登录入口,password字段在演示环境里可以用 MD5,正式环境建议改成加盐哈希。teacher_info里的capacity和remaining互为镜像,不是冗余设计,而是为并发扣减预留条件,下一节展开。topic的pub_year用 CHAR(4) 刻意压长度,比直接存 DATE 更省空间,按年查询时能直接命中索引。selection_record里冗余topic_year,是为了在不 jointopic表的情况下就能保证“一个学生在同一学年只能有一条选题记录”。
参数说明:UNIQUE KEY uk_student_topic防止同一学生对同一题目提交多条申请,即使前端不做校验也能在数据库层挡住;KEY idx_year_teacher是组合索引,服务端做“本年度某教师名下的题目列表”时,能同时用上年度和教师两个过滤条件。组合索引的顺序要把等值查询的字段放前面,这里pub_year是等值条件,teacher_user_id是范围条件,顺序不能反。
2.3 唯一索引和剩余配额字段的取舍
第一次接触这个系统的人容易纠结:既然selection_record可以通过 COUNT 统计某老师当前带了多少学生,为什么还要在teacher_info里放remaining?这里的关键不是省一次 COUNT,而是状态口径。学生提交申请时,记录状态是“待审核”,此时题目还不算真正带出去,但老师看到的剩余名额必须把“待审核 + 已通过”一起扣,否则拦截不了超量申请。每次选题都去 COUNT 一遍selection_record会带来锁竞争和索引扫描开销,不如在teacher_info维护一个剩余配额字段,申请成功的瞬间就把它减一。
剩余配额和唯一索引承担两种不同职责。唯一索引用于应用层漏判时兜底;剩余配额用于热点路径上的快速判断。两者缺一不可,也解释了“明明 remaining 大于 0,INSERT 却失败”的疑问:先执行 UPDATE 减配额,减成功后记录插入失败,事务回滚会把配额捞回来。这个流程放在第 4 章的 service 层代码里展开。
外键和级联删除在这个系统里不建议使用。题目下已经有学生申请时,题目只允许下架,不允许物理删除;学生毕业后,历史选记录要保留用于复查。让这些规则收敛在 service 层,比靠数据库级联更可控,也给后续对接消息通知、成绩录入留了扩展位。
2.4 初始化账号与测试数据
建表完成后需要造几笔最小数据才能进入开发调试。通用做法是插入一个管理员、两个教师、三个学生,并为每个教师初始化容量。真实场景中这些数据来自教务系统导出,开发阶段手工 INSERT 即可。
INSERT INTO sys_user (username, password, user_type, real_name, major_id) VALUES ('admin', MD5('123456'), 2, '系统管理员', NULL), ('t1001', MD5('123456'), 1, '张老师', NULL), ('t1002', MD5('123456'), 1, '李老师', NULL), ('s20001', MD5('123456'), 0, '王小明', 1), ('s20002', MD5('123456'), 0, '刘小红', 1), ('s20003', MD5('123456'), 0, '赵小刚', 2); INSERT INTO teacher_info (user_id, department, capacity, remaining) VALUES (2, '计算机系', 10, 10), (3, '软件工程系', 8, 8);这里的MD5('123456')只是在开发环境维持统一口径。用 MySQL 内置的 MD5 函数会让相同明文得到相同散列值,上线前需要换成加盐算法,否则一旦源码包泄露,密码可以被彩虹表直接查出来。测试数据量不需要大,五张表能串出“教师发题 — 学生选题 — 教师审核”的完整链路就够了,性能问题留给索引优化章节处理。
3. 落地 SSM 工程骨架:配置顺序和关键参数
3.1 依赖先把 mybatis-spring 接通
SSM 工程和 Spring Boot 的最大区别在于一切依赖都显式声明,启动过程也完全由 web.xml 驱动。这里不用 Spring Boot 的原因很实际:毕业设计管理系统这类源码包往往需要同学在答辩现场直接改配置跑起来,SSM 结构直观,出错点固定,更容易在几分钟内定位。如果引入 Boot 的自动配置,一旦自动配置和手工配置冲突,排查成本反而更高。
依赖清单里最容易被忽略的是mybatis-spring,它负责把 MyBatis 的SqlSessionFactory挂到 Spring 容器里。只用 MyBatis 不加mybatis-spring,Service 层拿不到SqlSession,启动时会出现NoSuchBeanDefinitionException。版本选择建议以你本地 Maven 仓库已有的为准,Spring 5.2.x、MyBatis 3.5.x、mybatis-spring 2.0.x 是常见组合;MySQL Connector/J 用 8.0.x 可以同时连接 5.7 和 8.0 的数据库,反向则不成立。
<dependencies> <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>${mybatis-spring.version}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.connector.version}</version> </dependency> </dependencies>版本变量集中放在<properties>里,以后升级依赖只改一处。spring-jdbc提供事务管理和DataSource支持,spring-webmvc负责处理 HTTP 请求,两者缺了任何一个都会让工程在启动阶段报ClassNotFoundException。JDBC 驱动的 scope 保持默认 compile,打包进 war 时才能把驱动带上。
3.2 web.xml、Spring 与 SpringMVC 的分层配置
SSM 的 web 启动逻辑分两层:ContextLoaderListener负责初始化根容器,加载业务 Service、Dao 和数据源;DispatcherServlet负责初始化 Web 层容器,加载 Controller 和视图解析器。两个容器各管各的 Bean,Controller 能看到 Service,Service 看不到 Controller,这种分层能有效避免循环依赖。关键配置如下:
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1"> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <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> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>CharacterEncodingFilter必须放在所有过滤器的最前面,并且forceEncoding必须为 true。只设置 encoding 而不强制,后端代码里手动request.setCharacterEncoding时可能被覆盖。DispatcherServlet映射/而不是/*,前者让 Tomcat 的 DefaultServlet 能处理 js、css、图片等静态资源,后者会把静态资源请求也拦截进 SpringMVC,导致页面样式加载不出来。load-on-startup为 1,让 Web 容器启动时就初始化 SpringMVC,尽早暴露配置错误。
applicationContext.xml放<context:component-scan>扫描com.example.service和com.example.dao,spring-mvc.xml只扫描com.example.controller,两边扫描包不能重叠。常见做法是 Controller 包放在父包com.example下,如果根容器扫描了整个com.example,Controller 就被注册了两遍,事务代理可能失效。这里建议直接拆开扫描粒度,省去后顾之忧。
3.3 MyBatis 驼峰映射与 Mapper 绑定
MyBatis 配置里最值得注意的开关是mapUnderscoreToCamelCase。表字段是teacher_user_id,实体属性是teacherUserId,开启这个开关后,查询结果能自动映射,省去大量手写 resultMap。剩余字段类型不一致或需要关联对象时,再用 resultMap 精准指定。
<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> <typeAliases> <package name="com.example.entity"/> </typeAliases> </configuration>Mapper 接口与 XML 文件的绑定通过两处保证:接口全限定名等于 XML namespace,方法名等于 XML 中 statement 的 id。XML 文件放在src/main/resources/mapper目录下,并在 Spring 配置里声明mapperLocations。一个典型的列表查询如下:
<select id="selectPublishList" resultType="Topic"> SELECT id, teacher_user_id, title, description, pub_year, status, create_time FROM topic WHERE pub_year = #{pubYear} AND status IN (1, 2) AND teacher_user_id = #{teacherUserId} ORDER BY create_time DESC </select>#{pubYear}是预编译参数占位符,MyBatis 会把它替换成?,由 JDBC 驱动做参数绑定,能有效防 SQL 注入。对比${pubYear}是字符串拼接,任何字符都会直接拼进 SQL,绝不应当出现在面向用户的查询条件里。ORDER BY create_time DESC让新发布的题目排前面,这是选题系统的默认排序习惯,学生打开页面第一眼看到的是最新题目。
3.4 数据源参数和本地连通性验证
数据源选型常见是 DBCP2 或 Druid。毕业选题系统并发量不高,DBCP2 零依赖更容易上手;如果需要在运维页面看活跃连接数,Druid 的监控页面更方便。核心参数只有五个:初始连接数、最大活跃连接数、最大等待时间、连接验证语句、空闲回收周期。推荐配置如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| initialSize | 5 | 启动时建立的连接数,过小导致首次请求慢 |
| maxTotal | 20 | 高峰期最大连接,超过后线程等待 |
| maxWaitMillis | 6000 | 等待连接的超时时间,单位毫秒 |
| validationQuery | SELECT 1 | 验证连接是否有效,防止拿到断开的连接 |
| testWhileIdle | true | 空闲连接定期验证,默认 true,建议保留 |
数据库连接串是本地跑通最容易出错的一处。MySQL 5.7 和 8.0 对时区、SSL 的处理不同,建议统一写成这样:
jdbc.driverClassName=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/topic_selection?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=your_passworduseSSL=false在本地开发环境能避免证书校验警告;serverTimezone=Asia/Shanghai解决 JDBC 驱动与 MySQL 服务器时区不一致导致的日期偏移;allowPublicKeyRetrieval=true是 MySQL 8.0 使用 caching_sha2_password 登录时需要的选项,不加会报 Access denied。先用命令行mysql -uroot -p确认账号密码能登录,再用 Navicat 或 MySQL Workbench 连一次,排除端口和防火墙问题,最后才启动 SpringMVC。连接串错了报的错很直接:Cannot create PoolableConnectionFactory,看到这个异常优先检查密码、端口、库名三项。
4. 选题和审核实现:状态机、并发扣减与容错
4.1 选题状态机与状态流转控制
选题管理系统的业务本质是状态机。状态定义如下:学生提交申请后记录变成“待审核”,教师审核通过后变成“通过”,驳回到“驳回”,学生主动取消或超时未处理则变成“退选”。状态迁移不允许跳变,已通过的选题不能直接变成“待审核”,这类规则写在 service 层比写 SQL 更容易维护。
public static final int STATUS_WAITING = 1; public static final int STATUS_APPROVED = 2; public static final int STATUS_REJECTED = 3; public static final int STATUS_CANCELED = 4; public void approve(Integer recordId) { int updated = selectionRecordMapper.updateStatus( recordId, STATUS_WAITING, STATUS_APPROVED ); if (updated == 0) { throw new BizException("该申请已被处理,请刷新页面"); } }updateStatus的 SQL 要带上当前状态条件:UPDATE selection_record SET status = #{newStatus}, approve_time = NOW() WHERE id = #{id} AND status = #{expectStatus}。用返回行数判断是否更新成功,比先 SELECT 再 UPDATE 更可靠,因为它把“确认旧状态”和“写入新状态”合并成一条原子语句。没有这一步,两个教师同时处理同一申请时,后提交的人会把先提交的结果覆盖掉。
4.2 原子扣减剩余配额
学生点击选题后,系统要做两件事:扣掉教师的剩余配额,插入选题申请记录。这两步必须在一个事务里。扣减不能先查询remaining再在 Java 里减一后写回,因为高并发下两个请求可能同时读到相同的remaining,导致超卖。正确做法是把判断和更新合并成一条 UPDATE:
@Transactional(rollbackFor = Exception.class) public boolean applyTopic(Long studentUserId, Long topicId) { Topic topic = topicMapper.selectById(topicId); if (topic == null || topic.getStatus() != TOPIC_PUBLISHED) { throw new BizException("题目不可选"); } int rows = teacherInfoMapper.decreaseRemaining(topic.getTeacherUserId()); if (rows == 0) { throw new BizException("该教师可带人数已满"); } SelectionRecord record = new SelectionRecord(); record.setStudentUserId(studentUserId); record.setTopicId(topicId); record.setTopicYear(topic.getPubYear()); record.setStatus(STATUS_WAITING); selectionRecordMapper.insert(record); return true; }对应 Mapper XML 里的扣减语句:
UPDATE teacher_info SET remaining = remaining - 1 WHERE user_id = #{teacherUserId} AND remaining > 0这行 UPDATE 是并发安全的关键。MySQL 对单条 UPDATE 加行锁,后到的请求会等前一个事务提交或回滚,remaining > 0条件保证不会扣成负数。如果更新行数为 0,说明配额已经耗尽,直接抛出业务异常,事务回滚,不会插入脏数据。@Transactional确保扣配额和插记录要么都成功,要么都回滚,任何一步失败都不会留下“配额扣了但没申请记录”的中间状态。
4.3 驳回回补的幂等写法
教师驳回申请后,剩余配额要加回来。这个步骤同样要防重复执行:同一申请被驳回两次,配额不能加两次。常见做法是在更新状态时带上前置状态条件,更新行数为 1 时才回补配额:
@Transactional(rollbackFor = Exception.class) public void reject(Integer recordId, String reason) { int updated = selectionRecordMapper.updateStatusWithReason( recordId, STATUS_WAITING, STATUS_REJECTED, reason ); if (updated == 0) { throw new BizException("申请状态已变化,请刷新后重试"); } Long teacherUserId = selectionRecordMapper.selectTeacherByRecordId(recordId); teacherInfoMapper.increaseRemaining(teacherUserId); }这里的selectTeacherByRecordId通过关联查询拿到教师 ID,不能用前端传来的参数,防止有人篡改请求把配额加回别人账上。increaseRemaining的 SQL 是UPDATE teacher_info SET remaining = remaining + 1 WHERE user_id = #{teacherUserId},天然幂等,联调阶段即使手动重跑也不会把数值加错。
4.4 截止时间与自动结题
选题阶段通常有固定截止日期。实现方式有两种:一种是 Spring 的@Scheduled定时任务,另一种是 MySQL 的事件调度器。推荐前者,因为业务逻辑还能复用 service 层的 Java 代码,日志也集中在应用里。事件调度器可以作为应用宕机时的兜底,但不要在事件里写复杂业务,只做状态推进。
@Scheduled(cron = "0 0 2 * * ?") @Transactional(rollbackFor = Exception.class) public void autoCancelExpiredRecords() { List<Long> expiredIds = selectionRecordMapper.selectWaitingBefore(deadline); for (Long id : expiredIds) { selectionRecordMapper.updateStatus(id, STATUS_WAITING, STATUS_CANCELED); Long teacherUserId = selectionRecordMapper.selectTeacherByRecordId(id); teacherInfoMapper.increaseRemaining(teacherUserId); } }cron表达式0 0 2 * * ?表示每天凌晨两点执行,避开学生使用高峰期。selectWaitingBefore查询所有状态为“待审核”且申请时间早于截止时间的记录,然后把状态置为“退选”并归还配额。这个任务必须在配置类上启用@EnableScheduling,否则注解不生效。定时任务里的事务粒度按单条记录控制,不要包住整个循环,否则一条数据失败会导致全部回滚,影响范围过大。
4.5 列表查询的索引与参数化
首页题目列表最常见的是按年度、按状态、按教师筛选。组合索引idx_year_teacher(pub_year, teacher_user_id)能同时覆盖年度和教师两个条件。如果还要加状态过滤,就在组合索引后面扩展状态列,顺序是等值列在前,范围列在后,但修改索引前要对比执行计划,不要凭感觉加。字段上用了函数或表达式后索引会失效,比如WHERE YEAR(create_time) = 2026无法命中create_time上的索引,推荐写法是WHERE create_time >= '2026-01-01' AND create_time < '2027-01-01'。
模糊查询要留意%位置。搜索题目时LIKE 'java%'能走索引,LIKE '%java%'无法走索引,数据量到几万条后前后模糊扫描会拖慢页面。若确实需要包含匹配,可以引入全文索引,或者保持现状并限制必须同时带年度过滤条件。排序字段也尽量落在索引列上,ORDER BY create_time DESC在create_time有索引时使用 filesort 的概率会明显降低。
SELECT id, title, teacher_user_id, pub_year, status FROM topic WHERE pub_year = #{pubYear} AND teacher_user_id = #{teacherUserId} ORDER BY create_time DESC LIMIT #{offset}, #{pageSize}LIMIT分页在深页码时性能下降,OFFSET 100000会让 MySQL 扫描前面十万行再丢弃。对毕业选题这种数据量很低的应用,深分页问题可以暂不处理,但要在代码里固定pageSize上限,防止有人把参数改成百万级把数据库拖垮。
5. 源码打包后的启动检查、排错对照与一致性核对
5.1 拿到打包源码以后先检查三处配置
源码打包的 scrum 包通常包含 SQL 脚本、Maven 工程、README,但不同人打包习惯不同,启动前不要默认里面所有配置都对。先看applicationContext.xml或jdbc.properties里的数据库连接串,确认库名是topic_selection而不是test或别人的库名;再看spring-mvc.xml里的注解扫描路径是否和你的包名一致,包名不一致时 Controller 不会被加载,访问 URL 直接 404;最后检查pom.xml里是否配置了maven-war-plugin,没有这个插件时打包出的 war 会缺少 web.xml 声明的结构。
打包命令用 Maven 的标准流程:mvn clean package -DskipTests,产物在target目录下,文件名带SNAPSHOT。部署到 Tomcat 8.5 或 9.0 时,把 war 包丢进webapps目录,Tomcat 会自动解压。应用上下文默认是 war 包名,如果包名带版本号,访问路径会变成http://localhost:8080/topic_selection-1.0/,不想带版本号就把 war 重命名为ROOT.war,直接通过根路径访问。
5.2 启动时的高频报错对照
| 报错现象 | 常见原因 | 处理方式 |
|---|---|---|
Access denied for user 'root'@'localhost' | 密码错误或账号无远程权限 | 先用命令行登录,确认密码,再检查jdbc.properties是否被覆盖 |
Unknown database 'topic_selection' | 只部署了应用没执行 SQL 脚本 | 用 Navicat 导入第 2 章的建表脚本 |
Invalid bound statement (not found) | Mapper 接口和 XML 没绑定 | 检查 XML namespace 和接口全限定名,再确认mapperLocations路径 |
ClassNotFoundException: org.springframework.web.context.ContextLoaderListener | 依赖没打包进 war | 检查maven-war-plugin配置,或直接mvn clean package重新打 |
Failed to configure a DataSource | 多数据源配置或驱动类名错误 | 确认驱动类是com.mysql.cj.jdbc.Driver,不是老旧的com.mysql.jdbc.Driver |
Invalid bound statement是最容易误导人的问题。IDE 里直接运行 main 方法能加载到 XML,但打 war 后 XML 没进classes目录就会出现这个报错。验证方法:把 war 解压,查看WEB-INF/classes/mapper下有没有 XML 文件。没有就调整 pom 里的 resource 配置,把src/main/resources/mapper明确声明为资源目录。
5.3 上线后的备份与配额一致性核对
选题系统上线后,最该盯的不是页面交互,而是teacher_info.remaining和selection_record中的有效申请数是否一致。配额一旦和记录数偏离,后续教师端显示的名额就会出错。可以用一条 SQL 做全量核对:
SELECT t.id, t.remaining, t.capacity - COALESCE(cnt.active_count, 0) AS calc_remaining FROM teacher_info t LEFT JOIN ( SELECT teacher_user_id, COUNT(*) AS active_count FROM topic tp JOIN selection_record sr ON sr.topic_id = tp.id WHERE sr.status IN (1, 2) GROUP BY teacher_user_id ) cnt ON cnt.teacher_user_id = t.user_id WHERE t.remaining != t.capacity - COALESCE(cnt.active_count, 0);返回空结果说明数据一致;返回记录说明存在配额回补遗漏,需要以计算值为准修正remaining。备份用 mysqldump 即可,放在每天凌晨的 Windows 计划任务里执行,配一个自动备份脚本,保留最近七天文件:
@echo off set YYYYMMDD=%date:~0,4%%date:~5,2%%date:~8,2% mysqldump -uroot -p123456 topic_selection > D:\backup\topic_selection_%YYYYMMDD%.sql forfiles /p D:\backup /m *.sql /d -7 /c "cmd /c del @path"恢复时用mysql -uroot -p123456 topic_selection < 备份文件.sql,恢复前确认当前库没有正在进行的选题事务,否则会把别人的操作覆盖。备份脚本里的密码在正式环境不要明文写在 bat 里,改成 MySQL 的--defaults-extra-file指向权限为 600 的配置文件。启动前先用上面的一致性 SQL 跑一次,确认数据干净,再开放给学生访问。
本文还有配套的精品资源,点击获取