ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于Java Web的在线报名系统课设开发:SSM架构与避坑指南

基于Java Web的在线报名系统课设开发:SSM架构与避坑指南

简介:面向Java Web课程设计的在线报名系统完整项目包,适合计算机相关专业学生完成课程设计,也适合初学JSP/Servlet的开发者进行实战参考。系统围绕考生、管理员两类角色展开,核心功能包括考生注册登录、个人资料维护、通过身份证号与准考证号查询成绩、在线提问,以及管理端的考生信息管理、成绩录入与组合查询、成绩报表生成、问题解答和网上银行缴费等,覆盖教务报名类项目的典型业务场景。资源共包含130个文件,以31个Java源文件及对应class文件、31个JSP页面为主体,另外配有多张运行截图、jar依赖库、项目配置文件和doc课程设计报告,压缩包仅5.24MB,目录结构清晰,便于导入部署和按模块阅读。目前已有619人浏览学习,尤其适合需要快速搭建可运行系统并同步撰写设计文档的用户。通过源码可以掌握JDBC操作、会话管理、分层封装等要点,配套报告中的需求分析和模块设计说明,也能为课设答辩与系统扩展提供直接帮助。

1. 在线报名系统:课设里最「不起眼」却最能拉开差距的 Java Web 方向

很多人选课程设计题目时,第一反应是在线报名系统太普通,图书馆管理系统、学生信息管理都比它有「科技感」。但恰恰是这个看起来不起眼的题目,在课设答辩时最容易拉开分数差距。原因是它有一套完整的业务闭环:活动发布、用户注册、在线报名、名额限制、管理员审核、名单导出。任何一个环节做得不干净,演示时都会当场暴露。我见过太多把精力花在花哨页面、却在报名状态流转上翻车的项目,最后分数反而不如一个功能扎实、代码结构清晰的系统。这篇笔记就围绕「基于 Java Web 的在线报名系统」这个课设方向,把从技术选型到数据库设计、核心代码实现、踩坑排查的完整路径讲清楚。适合正在做课设、准备毕业设计,或者想用这个项目练手 Java Web 基础功底的开发者。

2. 技术选型:为什么我首选 SSM 而不是 Servlet 原生

2.1 课设评分最稳的组合:SSM + MySQL + Tomcat

Java Web 方向做在线报名系统,圈里常见的技术组合无非三种:纯 Servlet + JSP、SSM(Spring + SpringMVC + MyBatis)、Spring Boot。放在课程设计的语境下,我最推荐 SSM,理由很直接:评分老师普遍默认课设应该展示你对 Java Web 核心组件——Servlet、Filter、JSP、监听器——的理解,Spring Boot 把太多东西藏起来了,答辩时老师问「请求怎么进到 Controller 的」,你要是答不上来,印象分会掉得很难看。

纯 Servlet 原生开发虽然「够底层」,但课设周期内要手写大量重复代码,报名、审核、导出这些业务逻辑还没写,光处理请求分发和参数封装就耗掉一半时间。SSM 是折中:Spring 管对象、SpringMVC 管路由、MyBatis 管数据库访问,每一层都有明确的配置文件可以讲,答辩时「分层思想」「控制反转」「ORM 映射」这些考点全部覆盖。配套环境我一般用 JDK 1.8 + Tomcat 8.5 + MySQL 5.7,这套组合兼容性最稳,网上遇到问题也最好排查。Maven 做依赖管理,不要手动往 WEB-INF/lib 里塞 jar 包,那是个无底洞。

提示:如果你的课程设计要求里明确写了「必须使用 SSH 或原生 Servlet」,那就按题目要求走。这里给的是没有硬性约束时的最优默认选择。

2.2 项目骨架的分层目录:从 controller 到 dao 一次到位

在线报名系统的代码结构,我习惯按标准三层架构来分。很多同学拿到源码包后第一件事就是找 Controller 里的业务代码,然后发现业务逻辑全写在 Servlet 里,这就是典型的不分层。分层的目的不是好看,而是让每个类的职责单一:Controller 只做参数接收和视图跳转,Service 只做业务判断和事务控制,Mapper 只做 SQL 操作。这样做的好处是,答辩时老师问「名额校验逻辑在哪一层」,你能直接说出在 Service 层,而不是把整个项目翻一遍。

一个适用于在线报名系统的目录结构大致是这样:

src/main/java ├── com.example.signup │ ├── controller # 处理页面请求:登录、注册、报名、审核 │ ├── service # 业务逻辑:名额校验、事务、状态流转 │ ├── dao # MyBatis 的 Mapper 接口 │ ├── entity # 用户、活动、报名记录的实体类 │ ├── interceptor # 登录拦截器、管理员权限拦截器 │ ├── util # 分页工具、字符串处理、日期处理 │ └── vo # 视图对象:给页面展示的聚合数据 src/main/resources ├── mybatis-config.xml ├── spring-context.xml ├── spring-mvc.xml ├── jdbc.properties └── mapper # UserMapper.xml / ActivityMapper.xml / RegistrationMapper.xml src/main/webapp ├── WEB-INF │ └── jsp # 页面文件:list.jsp / signup_form.jsp / admin.jsp └── static # css / js

理解这个结构有个关键点:Mapper 接口和 Mapper XML 文件要放在同一个包路径下,MyBatis 才能自动扫描到。很多新人把 XML 放到 resources 根目录,结果运行时报Invalid bound statement (not found),就是路径没对上。另外 entity 里的属性名和数据库字段名要做映射,如果不想写繁琐的 resultMap,可以先按「数据库字段下划线对 Java 属性驼峰」来命名,然后在 mybatis-config.xml 里开启驼峰映射,能省掉一半的 XML 配置量。

2.3 跑通系统的最简 Maven 配置:pom 与两个核心配置文件

依赖配置是这个项目最先要落地的东西。我习惯先把 pom.xml 写齐,再写 spring 配置,最后才写代码。依赖版本不需要追新,稳定优先。下面是适合课设规模的 pom 核心部分,版本号按你本地 Maven 仓库能拉到的最稳定版本调整即可:

<dependencies> <!-- Spring 核心依赖 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> </dependency> <!-- MyBatis 与 Spring 整合 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> </dependency> <!-- MySQL 驱动、连接池、JSP 标准标签库 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> </dependency> </dependencies>

这段配置的逻辑是:spring 全家桶负责对象管理与事务,mybatis 负责数据库访问,druid 连接池替代默认的 dbcp,因为 Druid 自带监控页面,答辩时可以顺手展示一下 SQL 执行情况。注意 spring-jdbc 必须加,事务管理器DataSourceTransactionManager依赖它。

接下来是 spring-context.xml 里的数据源与事务配置,这是整个项目能不能连上数据库的关键:

<context:component-scan base-package="com.example.signup.service" /> <context:property-placeholder location="classpath:jdbc.properties" /> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <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" /> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource" /> </bean> <tx:annotation-driven transaction-manager="transactionManager" />

jdbc.properties 里对应配置数据库连接信息,常见踩坑点是 MySQL 8.x 的驱动类名变成了com.mysql.cj.jdbc.Driver,URL 里还必须带时区参数,否则启动就报Communications link failure。我的习惯是直接把 URL 写成jdbc:mysql://localhost:3306/signup_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,一次解决中文乱码和时区两个问题。这里 segment 的关键参数是mapperLocations——它决定了 MyBatis 去哪找 SQL 映射文件,路径写错,你的 DAO 方法全部不可用。

3. 数据库设计:三张表把报名系统的状态流转撑起来

3.1 用户表、活动表、报名记录表的结构设计

在线报名系统的数据库不需要设计得太复杂,三张核心表足够覆盖全部业务:用户表t_user、活动表t_activity、报名记录表t_registration。三张表的关系是:一个用户可报名多个活动,一个活动可被多个用户报名,报名记录是中间表,同时存用户 ID、活动 ID 和报名状态。多余的冗余字段能省则省,课设阶段表越多越难维护,答辩时把自己绕晕的案例我见过太多次。

先把建表 SQL 骨架列出来,业务字段按常见做法补齐:

CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(20), email VARCHAR(100), phone VARCHAR(20), role TINYINT DEFAULT 0, -- 0普通用户 1管理员 create_time DATETIME ); CREATE TABLE t_activity ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, description TEXT, location VARCHAR(100), start_time DATETIME, end_time DATETIME, max_count INT DEFAULT 50, -- 活动名额上限 signed_count INT DEFAULT 0, -- 已报名人数 status TINYINT DEFAULT 1 -- 1报名中 2已截止 3已结束 ); CREATE TABLE t_registration ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, activity_id INT NOT NULL, signup_time DATETIME, status TINYINT DEFAULT 0, -- 0待审核 1通过 2驳回 remark VARCHAR(255), UNIQUE KEY uk_user_activity (user_id, activity_id) );

这三张表的设计里有一个非常关键的决策:t_registration上加了唯一键uk_user_activity。这是为了防止同一用户对同一活动重复报名。如果只在代码里做判断,并发场景下两个请求同时通过「是否已报名」的检查,就会插入两条重复记录。数据库层面的唯一索引是最后的防线,这个细节在答辩时提出来,老师会认为你考虑了数据的完整性。

密码字段我用 VARCHAR(64) 而不是 VARCHAR(20),是为了给 SHA-256 或 BCrypt 哈希后的密文留空间。明文存密码是课设里最常见的安全硬伤,虽然不是所有老师都会检查,但一旦被问到就很被动。报名记录表里加一个remark字段,用于管理员驳回时填写原因,这个字段不值什么功夫,但演示时能展示一个完整的审核闭环。

3.2 状态字段是灵魂:活动状态与审核状态分开放

在线报名系统最容易做乱的就是状态管理。很多同学的翻车现场是这样的:活动明明已经截止了,用户还能报名成功;或者管理员已经审核通过了,用户还能取消报名。根本原因是把所有状态混在一个字段里,用一串数字猜含义。我的习惯是把活动状态和审核状态分开,各管各的。活动状态记录在t_activity.status,审核状态记录在t_registration.status,两者互不干扰。

活动状态的三段式设计对应真实业务:报名中 → 已截止 → 已结束。报名中的活动才允许提交报名申请;已截止的不允许新报名但可以查看名单;已结束的进入归档。报名记录的状态则是:待审核 → 通过 / 驳回。这里还要补一种「取消报名」的状态,用户提交报名后、管理员审核前可以自行取消,取消后名额释放。这样状态流转就是:

报名中 + 待审核 → 管理员通过 → 已通过 报名中 + 待审核 → 管理员驳回 → 已驳回 报名中 + 待审核 → 用户取消 → 已取消(名额释放) 已截止 / 已结束 → 不可再报名

这套状态机一定要在 Service 层做统一校验,不要在 JSP 页面里用 if 判断来控制按钮显隐就算完。因为按钮隐藏只影响正常用户的页面操作,直接构造 HTTP 请求照样可以绕过页面。后端校验才是真正的门槛。状态字段建议在数据库里用 TINYINT 存储,占用空间小,取值固定。如果嫌数字可读性差,在 Java 里写一个枚举类或者在常量类里定义对应的注释即可,不要为了省事用字符串「pending」「approved」,那会让 SQL 的 where 条件写得越来越别扭。

3.3 种子数据怎么造:让演示效果像真实系统

课设答辩时,评委老师往往直接看演示画面。如果数据库里只有两三条测试数据,报名列表空荡荡的,演示效果会大打折扣。所以建完表之后,我建议立刻准备一份种子数据 SQL。常见做法是插 8 到 10 个测试用户、5 个左右不同状态的活动、若干条报名记录,覆盖「报名中」「已截止」「已结束」三种活动状态和「待审核」「已通过」「已驳回」三种报名状态。

种子数据还有一个隐蔽的用途:验证业务逻辑的边界。如果一个活动已经报名满员(signed_count = max_count),系统应该提示「名额已满,不可报名」,这条边界必须有一条现成的活动数据能触发。我一般会专门造一个满员活动和一个人数未满的活动做对照。创建种子数据的写法,重点是时间字段不要写死,用DATE_SUB(NOW(), INTERVAL 3 DAY)这类相对时间,保证任何时候运行 SQL,数据的时间状态都是合理的。

-- 两个测试用户,密码是 123456 的 SHA-256 哈希 INSERT INTO t_user (username, password, real_name, role, create_time) VALUES ('zhangsan', '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92', '张三', 0, NOW()), ('admin', '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92', '管理员', 1, NOW()); -- 两个活动:一个报名中,一个已截止 INSERT INTO t_activity (title, description, location, start_time, end_time, max_count, signed_count, status) VALUES ('2024年秋季校园编程大赛', '面向全校学生的编程竞赛', '某高校实验楼A区201', DATE_SUB(NOW(), INTERVAL 30 DAY), DATE_ADD(NOW(), INTERVAL 30 DAY), 100, 35, 1), ('办公软件技能培训(已截止)', '第一期办公软件培训', '某高校教学楼303', DATE_SUB(NOW(), INTERVAL 15 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY), 50, 50, 2); -- 报名记录:一条待审核,一条已通过 INSERT INTO t_registration (user_id, activity_id, signup_time, status) VALUES (1, 1, NOW(), 0), (1, 2, DATE_SUB(NOW(), INTERVAL 5 DAY), 1);

这段 SQL 的参数说明:用户表里role为 1 的是管理员账号,演示时用它登录看审核后台;活动 2 的signed_count已经等于max_count,专用于测试满员场景;报名记录里故意只给用户 1 插入两条,让演示时可以选择新用户注册后再报名,展示完整流程。注意密码哈希值如果不想额外生成,可以用SHA-256('123456')计算一次后填充,课设阶段不必引入加密库,JDK 自带的MessageDigest几行代码就能搞定。

4. 从注册到审核:核心功能实现路径

4.1 登录与权限:Filter 拦截器 + Session 会话管理

在线报名系统有两种角色:普通用户和管理员。网购过的同学都清楚,登录没做好,后面所有功能都白搭。Java Web 里做登录校验最直接的手段是 Filter,它比 SpringMVC 的拦截器更底层,能在请求到达 Controller 之前统一拦截。我习惯写一个LoginFilter,实现javax.servlet.Filter接口,在doFilter里判断 Session 中是否有用户信息。

public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 放行登录、注册、静态资源 String uri = req.getRequestURI(); if (uri.endsWith("/login.jsp") || uri.endsWith("/register.jsp") || uri.contains("/static/") || uri.endsWith("/login") || uri.endsWith("/register")) { chain.doFilter(request, response); return; } // 从 Session 中获取登录用户,拿不到就跳回登录页 Object user = req.getSession().getAttribute("loginUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }

逻辑说明:Filter 的核心思路是「白名单放行 + 其余拦截」。放行条件要覆盖登录页、注册页、静态资源、登录和注册的请求接口。注意req.getContextPath()是项目上下文路径,如果有部署路径前缀,重定向时缺少它会导致跳转地址错误。参数上有一个容易漏的点:/static/前缀匹配的写法,如果你的页面引用了 CSS/JS,静态资源也会被 Filter 拦住,页面样式全丢。注册这个 Filter 时,可以在web.xml里配置<url-pattern>/*</url-pattern>匹配所有请求。

权限控制上还要区分普通用户和管理员。除了登录 Filter,再写一个AdminFilter检查loginUser对象的role字段是否等于 1。这个 Filter 只配置在/admin/*路径上。管理员的后台页面统一放在 admin 目录下,拦截范围清晰,不需要在每个 Controller 方法里重复写权限判断。

4.2 在线报名:事务控制下的名额扣减

报名功能是整个系统的核心业务。流程拆开是四步:校验活动存在且状态为「报名中」→ 校验用户未报名 → 校验名额未满 → 插入报名记录并增加signed_count。最后两步行必须在一个数据库事务里,否则可能出现插入了报名记录但名额没更新,或者相反的情况。

SSM 架构下,这段逻辑放在 Service 层,用@Transactional注解声明事务。常见误用是有些同学把@Transactional写在 Controller 方法上,或者写到 DAO 接口上,这都不会真正生效——事务的切面是通过 Spring AOP 代理 Service 对象来实现的,Controller 对象不在事务代理范围内。

@Service public class RegistrationService { @Autowired private RegistrationMapper registrationMapper; @Autowired private ActivityMapper activityMapper; @Transactional(rollbackFor = Exception.class) public boolean signUp(Integer userId, Integer activityId) { // 1. 查询活动信息 Activity activity = activityMapper.selectById(activityId); if (activity == null || activity.getStatus() != 1) { throw new BusinessException("活动不存在或不在报名时间内"); } // 2. 校验名额 if (activity.getSignedCount() >= activity.getMaxCount()) { throw new BusinessException("名额已满,无法报名"); } // 3. 校验重复报名,这个判断配合唯一索引双保险 int count = registrationMapper.countByUserAndActivity(userId, activityId); if (count > 0) { throw new BusinessException("您已报名该活动,请勿重复提交"); } // 4. 插入记录并更新名额 Registration reg = new Registration(); reg.setUserId(userId); reg.setActivityId(activityId); reg.setStatus(0); reg.setSignupTime(new Date()); int inserted = registrationMapper.insertSelective(reg); int updated = activityMapper.increaseSignedCount(activityId); if (inserted != 1 || updated != 1) { throw new RuntimeException("报名失败,请重试"); } return true; } }

这段代码有三个关键设计点。第一,rollbackFor = Exception.class——默认的RuntimeException才会触发回滚,如果你在业务里抛的是自定义的BusinessException(它继承自RuntimeException),那没问题;如果你抛的是Exception,就必须显式声明,否则事务不生效。第二,insertSelective和increaseSignedCount两个操作必须同时成功,正好对应数据库事务的原子性。第三,activityMapper.increaseSignedCount的 SQL 要写成UPDATE t_activity SET signed_count = signed_count + 1 WHERE id = #{id},而不是先查出来再更新。原因很简单,如果两个请求同时读到signed_count = 35,各自加 1 后都写回 36,名额就被重复占用了。虽然课设阶段并发量不大,但业务逻辑上养成「原子更新」的习惯,答辩时讲出来是加分项。

4.3 审核与名单导出:管理员的两个高频操作

管理员的审核操作相对简单:把t_registration.status从 0 改成 1(通过)或 2(驳回),驳回时写入 remark。这一步的排查要点是 MyBatis 的 update 语句要动态拼接,只更新非空字段,避免把原来的值覆盖成 null。很多人的审核功能「审核完了数据没了」,就是set remark = #{remark}里的 remark 传了 null,把已有内容清空了。

名单导出是亮点功能,操作性强还容易出彩。常见做法是导出 CSV 文件,用逗号分隔字段,这样 Excel 可以直接打开,不需要引入 POI 这种重型依赖。实现思路是 Controller 返回response.setContentType("text/csv"),把报名数据逐行写出。有一个常见的坑是中文文件名在浏览器里乱码,解决方法是给文件名做 URL 编码:

@GetMapping("/admin/export") public void exportCsv(HttpServletResponse response, @RequestParam Integer activityId) throws IOException { List<RegistrationVO> list = registrationMapper.selectByActivity(activityId); String fileName = URLEncoder.encode("报名名单_" + activityId + ".csv", "UTF-8"); response.setContentType("text/csv;charset=UTF-8"); response.setHeader("Content-Disposition", "attachment;filename=" + fileName); PrintWriter writer = response.getWriter(); // 写入 UTF-8 BOM,否则 Excel 打开中文会乱码 writer.write('\uFEFF'); writer.write("序号,姓名,手机号,报名时间,状态\n"); for (int i = 0; i < list.size(); i++) { RegistrationVO vo = list.get(i); writer.write((i + 1) + "," + vo.getRealName() + "," + vo.getPhone() + "," + vo.getSignupTime() + "," + statusText(vo.getStatus()) + "\n"); } writer.flush(); }

CSV 导出的两处关键处理:第一是\uFEFFBOM 头,没有它,Excel 打开 CSV 时会把中文识别为乱码;第二是URLEncoder.encode处理文件名,这样下载窗口里的中文名就不会变成%E6%8A%A5%E5%90%8D.csv这种编码串。如果导出的数据量比较大,比如几千条报名记录,可以考虑用缓冲流BufferedWriter包一层。课设场景下这种直接PrintWriter逐行写的方式简单直观,也方便在答辩时解释。

5. 避坑实录:六个翻车点与排查路径

5.1 数据库连接串缺少时区参数,项目启动报错

现象:Tomcat 启动时控制台直接抛Cannot create PoolableConnectionFactory,错误信息里能看到The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized。

原因:MySQL 8.x 版本对时区配置更严格,连接串没有指定serverTimezone时,驱动拿不到合法的时区值。这个报错信息里还会出现乱码,看起来像是数据库编码问题,实际上跟编码没关系。解决:在 jdbc.properties 的连接 URL 末尾追加?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8,两个参数都要,前者解决时区,后者解决中文写入乱码。我习惯把这两个参数作为固定默认值,新建任何项目都直接带上。

5.2 JSP 页面数据不显示,EL 表达式原样输出

现象:页面上一堆${activity.title},浏览器里也原样显示这个字符串,不渲染值。

原因:web.xml 版本声明太旧。Servlet 2.5 及以下的 web.xml 默认不启用 EL 表达式,或者 JSP 页面里漏写了isELIgnored="false"。解决:把 web.xml 的版本声明改成 3.1(对应 Tomcat 8.5),或者在 JSP 页面顶部加<%@ page isELIgnored="false" %>,两选一即可。检查顺序是:先看 web.xml 头部 schema 版本,再看具体页面。这个坑出现频率极高,而且效果隐蔽,页面不报错,就是内容缺失。

5.3 @Transactional 事务没生效,半截数据写入

现象:报名操作执行后,t_registration表有记录,但signed_count没有加一;或者反过来,名额扣了报名记录没插进来。

原因:事务没有真正交给 Spring 管理。常见的三种引发原因:一是 spring-context.xml 里忘了配置<tx:annotation-driven>;二是@Transactional加在 Controller 或 DAO 方法上;三是 Service 类没有被component-scan扫描到。解决:先检查事务管理器的 bean 是否存在,再确认注解位置在 public 的 Service 方法上。还有一个隐蔽情况:同一个类里的方法通过this调用另一个@Transactional方法,事务会失效——因为this调用不走 Spring 代理。如果报名方法里调用了当前类的其他事务方法,需要注入自身代理或者把事务往上层方法放。

5.4 中文参数传递后乱码,存进数据库变问号

现象:在报名表单里填的中文姓名、备注,保存到数据库后变成???,页面重新加载后显示乱码。

原因:链路有三处,任何一处不是 UTF-8 都会出问题。第一处是请求入口,Tomcat 8 及以上版本 GET 请求默认 UTF-8,但 POST 请求需要CharacterEncodingFilter过滤;第二处是数据库连接,URL 缺少characterEncoding=utf8;第三处是数据库表本身的字符集是不是 utf8mb4。解决:在 web.xml 里配置 Spring 提供 CharacterEncodingFilter,强制所有请求编码为 UTF-8;建表语句显式写明DEFAULT CHARSET=utf8mb4;连接串带characterEncoding=utf8。三处都检查一遍,乱码基本绝迹。这种玄学问题,排查路径要按「页面 → 过滤器 → 连接 → 表结构」逐层看。

5.5 MySQL 连接被耗尽,页面偶发 500

现象:系统用一段时间后,某个操作随机报错,Tomcat 日志里有Connection is not available, request timed out。

原因:数据源连接没有被正确回收。常见是代码里用了 JDBC 原生操作但没关闭Connection、Statement、ResultSet,又或者 Druid 的maxActive配置太小挤兑了连接。我的习惯排查思路是先看 Druid 监控页面的活跃连接数,如果持续增长就是泄漏。解决:课设阶段使用的框架操作基本不会泄漏,问题多出在自定义 JDBC 工具类上,检查是否在 finally 块中关闭资源。如果项目不大,可以直接在 spring 配置里把 Druid 的maxActive设为 20,初始连接 5 个,足够课设演示并发了。

5.6 管理员修改状态后页面不刷新,操作「没效果」

现象:审核通过后跳回列表页,状态还是「待审核」,刷新一下才变成「已通过」。

原因:浏览器缓存了页面,或者后端返回的是转发而不是重定向。request.getRequestDispatcher().forward()时浏览器地址栏 URL 不变,用户按 F5 刷新会重新提交上次的 POST 请求,产生表单重复提交。解决:审核、报名这类数据变更操作完成后,使用redirect:重定向到列表页,保证浏览器地址变成 GET 请求。SpringMVC 里写法是return "redirect:/admin/list";。数据展示页面考虑用response.setHeader("Cache-Control", "no-cache")或者 JSP 里加 meta 标签禁止缓存。这个坑不报错,体验上却很掉价。

6. 让系统从「能跑」到「能答辩」:三个验证技巧

第一个技巧是并发报名验证。演示前用浏览器开两个隐身窗口,同时提交同一活动的报名请求,展示数据库里不会出现重复报名记录,名额扣减也是准确的。如果想让验证更有说服力,可以在报名接口里加一行 Thread.sleep(100),人为拉长处理时间,然后两个窗口同时提交,观察signed_count的最终值。这个操作能直观展示唯一索引和原子更新 DB 字段的价值,答变时独立完成。

第二个技巧是数据库备份与恢复演示。课设档案里一般要求提交数据库文件,很多同学直接导出一个.sql就完事。更好的做法是演示里现场操作一次mysqldump -u root -p signup_system > backup.sql,然后DROP DATABASE再source backup.sql恢复,整个过程截图存档。这段演示给评审老师的信号是:你不仅会用框架写代码,还知道数据库的日常运维操作。数据库脚本我会同步放到项目根目录的db/文件夹下,和源码一起归档。

第三个技巧是日志追踪。项目里配置一个最简单的 log4j,在报名、审核、导出的关键方法里输出业务日志。答辩时如果被问到「你的系统怎么排查问题」,打开控制台展示报名接口的入参、处理耗时、返回值。这比嘴上说「我打了断点调试」要直观得多,也说明你不是在对着黑匣子写代码。

最后说一个我的习惯。每做完一个课设功能点,我会在本地用十分钟走一遍完整的用户旅程——新用户注册 → 登录 → 浏览活动列表 → 报名 → 管理员审核 → 导出名单。这个过程能暴露大多数演示 moment 的意外。走完一遍没问题,这个系统才算真正交付。项目做完了不代表结束,你的代码两年后回头看,能不能在半小时内跑起来,才是衡量这次课设学到东西没有的标准。希望这些经验能帮你少走一点弯路,把宝贵的课设时间花在真正有价值的地方,祝答辩顺利。

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

RELATED READING

延伸阅读

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