☰
JSP+Servlet+MySQL活动管理系统开发实战:从建表到部署避坑
2026/9/28 8:15:43 网站建设 项目流程

简介:这套基于 Java + JSP + Bootstrap + Servlet + MySQL 实现的活动管理系统,面向初/中级 JavaWeb 学习者与课程设计场景,包含管理员与普通用户双角色:管理员可完成活动/活动类型管理、报名审核、游客管理等,用户则可浏览分类活动、在线报名及查看个人报名记录,能较好地覆盖活动管理业务的基础流程。压缩包共 214 个文件,约 10.56MB,主要包含 Java 源码与编译后的 class 文件、JSP 页面、SQL 数据库脚本、jar 依赖包,以及 CSS/JS/jQuery/bootstrap 等前端资源,项目结构完整,导入 IDE 配置后即可运行。已有 564 人学习下载。适合作为 JavaWeb 期末项目或毕业设计的参考模板;借助完整源码、数据库脚本和前端页面,可快速理解 Servlet+JSP 分层实现、Bootstrap 响应式布局以及管理员/用户权限区分等思路,便于二次改造和扩展。

1. 这个组合还值得做吗:活动管理系统与 JSP+Servlet 的生存空间

打开招聘网站看一圈,Spring Boot 几乎一统天下,JSP 更是被很多培训机构判了"死刑"。但现实是:绝大多数高校的 Java 课程设计、毕业设计选题,仍然要求用 JSP + Servlet + MySQL 这一套来交付;中小企业里也还躺着大量十年以上历史的传统 Java Web 项目,用的正是这个组合。你手里这个"活动管理系统"标题,背后对应的就是这类项目里最常见、最典型的一种——活动发布、用户报名、报名审核、签到管理这些闭环业务,刚好能覆盖 JSP + Servlet + Bootstrap + MySQL 全部四个组件的主要用法。这篇笔记会把整个系统的拆解方式、表结构设计、Servlet 写法、页面整合思路和部署验证全部讲透,如果你正在做课程设计、毕业设计,或者接手了一个老系统的二次开发,可以直接照着落。

2. 技术选型先立住:为什么这套 JSP+Servlet+Bootstrap+MySQL 的架子还能跑业务

2.1 四个组件的职责边界:别让 JSP 干 Servlet 的活

很多初学者拿到这个项目标题后,第一反应是"用 JSP 写页面,在页面里直接拼 Java 代码查数据库",这是最典型的理解偏差。JSP 的本质是 Servlet 在运行期的视图模板,它存在的意义是渲染 HTML,而不是处理业务逻辑。一个活动管理系统的正确分层应该是:MySQL 管数据落盘,Servlet 接收请求、调用业务方法、控制页面跳转,JSP 只负责把数据展示出来,Bootstrap 负责让页面看起来不像 2005 年的遗留系统。

以"活动列表页"为例,正常的请求链条是这样的:浏览器发起 list.action → Tomcat 根据 web.xml 里的映射找到 ActivityListServlet → Servlet 调用 ActivityDao 查询活动列表 → 把 List 放进 request 作用域 → forward 到 activityList.jsp → JSP 用 JSTL 标签循环渲染表格 → Bootstrap 负责表格样式和分页按钮。这条链路上任何一个环节越位都会出问题,比如在 JSP 里直接写 JDBC 代码,页面会变得极难维护,报错信息还直接暴露给用户。Servlet 里的 doGet 和 doPost 分工也要明确:doGet 处理查询和页面跳转,doPost 处理表单提交和数据变更,这个习惯能少踩一半的坑。

2.2 Servlet 生命周期和请求处理:一次完整的活动报名流程

Servlet 的生命周期是面试高频题,在实际项目里它对应的是连接和资源的创建时机。init() 在 Servlet 第一次被访问时执行,适合初始化数据库连接池;service() 根据 HTTP 方法分发给 doGet 或 doPost;destroy() 在应用卸载时释放资源。对于一个活动管理系统,表单提交全部走 doPost,超链接和按钮跳转全部走 doGet,这个约定要写进团队规范里,否则同一个 Servlet 里两个方法都写业务,后期排查请求到底走了哪个分支全靠猜。

以"用户报名活动"这个动作为例,前端用 Bootstrap 模态框收集用户名和手机号,提交到 EnrollServlet 的 doPost 方法。Servlet 里的标准处理步骤是:设置 request 编码为 UTF-8(防止中文乱码)→ 用 getParameter 取出表单字段 → 做非空校验 → 调用 EnrollDao 的 insert 方法 → 成功后重定向到活动详情页(重定向而不是 forward,防止刷新页面时重复提交)。这个过程里最容易翻车的是编码设置:request.setCharacterEncoding("UTF-8") 必须写在 getParameter 之前,response.setContentType 也要一并设置。MySQL 那边表结构、连接参数、JDBC 驱动版本,任何一个环节的编码不统一,用户名里的中文就会变成问号。

2.3 与传统 Spring Boot 方案的边界:什么时候该换掉这套技术栈

JSP + Servlet 的优势是轻、直接、依赖少——一个 Tomcat、一个 MySQL、一个 JDBC 驱动 jar 包就能跑起来,不需要 Maven 拉几百兆依赖,也不需要理解 Spring 容器初始化那套黑匣子。对于活动管理系统这个体量(几张表、几个 Servlet、十几个页面),这套方案的开发效率其实不低,尤其是你熟悉之后,从建表到跑通全部功能一到两天就能完成。但它的边界也很清晰:如果活动管理系统要接入微信小程序端、要做分布式部署、要对接消息队列,JSP+Servlet 这层架构就不够用了,Servlet 代码里每加一个接口都要重复写参数解析和响应封装,那时换 Spring Boot 是合理的选择。

我的建议是:课程设计、毕业设计、企业内的小型管理后台,用这套完全没问题,重点是把它做完整——分页、模糊查询、登录拦截、事务处理这些细节都补齐;如果项目要求高并发、前后端分离、微服务,那就别在 JSP 上浪费时间,直接上 Spring Boot + Vue。技术选型从来不是选最新的,是选够得着需求的。

3. 活动管理系统的数据模型:MySQL 建表与连接配置全流程

3.1 三张核心表 + 两张辅助表的设计:从活动发布到签到的闭环

活动管理系统的最小可用数据模型是四张表:用户表 user、活动表 activity、报名表 enroll、签到表 signin。用户表存管理员和普通用户,通过 role 字段区分,管理员可以发布活动,普通用户只能报名和签到。活动表的核心字段是:id、title、description、start_time、end_time、location、max_people、status。报名表是 user_id 和 activity_id 的关联表,加上报名时间 enroll_time 和状态 status(已报名/已取消)。签到表记录签到时的时间,防止一个用户重复签到。

-- 活动表:状态字段用 tinyint,0为未开始,1为进行中,2为已结束 CREATE TABLE activity ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT '活动标题', description TEXT COMMENT '活动描述', start_time DATETIME NOT NULL COMMENT '开始时间', end_time DATETIME NOT NULL COMMENT '结束时间', location VARCHAR(200) COMMENT '活动地点', max_people INT DEFAULT 50 COMMENT '人数上限', status TINYINT DEFAULT 0 COMMENT '活动状态', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 报名表:联合唯一索引防止重复报名 CREATE TABLE enroll ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, activity_id INT NOT NULL, enroll_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1 COMMENT '1已报名 0已取消', UNIQUE KEY uk_user_activity (user_id, activity_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这个建表语句里有几个关键设计值要点:第一,ENGINE 必须指定 InnoDB,这样才能使用事务和外键约束;第二,CHARSET 用 utf8mb4 而不是 utf8,因为 utf8 在 MySQL 里最多存 3 字节,用户填个 emoji 表情就会插入失败;第三,报名表的联合唯一索引是业务约束的兜底,即使用户在前端重复点击了报名按钮,数据库这一层也会拦截第二次插入。status 字段用 tinyint 存数字状态而不是字符串,排序和统计都更高效,活动列表页需要按状态筛选时,一条WHERE status = 0就能查出来。

3.2 MySQL 安装后的必改参数:时区、编码和认证插件

热词里大量出现 "mysql 安装教程" 和 "mysql 安装配置教程",说明这个环节卡住的人最多。MySQL 5.7 和 MySQL 8.0 安装完后,有三处配置必须改,不改后面连接 JDBC 一定会报错。第一处是 my.ini(Windows)或 /etc/my.cnf(Linux)里的字符集设置,在 [mysqld] 段下加character-set-server=utf8mb4,同时确认 collation-server 也是 utf8mb4_unicode_ci。第二处是时区,MySQL 8.0 默认时区是 UTC,而你的 Java 程序跑在中国时区,两者不一致会导致 DATETIME 字段读写差 8 个小时。在 my.ini 里加default-time-zone='+08:00',或者在连接 URL 里通过 serverTimezone 参数指定。第三处是 MySQL 8.0 的默认认证插件是 caching_sha2_password,老版本的 mysql-connector-java 驱动不认这个插件,连接时会直接抛异常,解决方案是安装时选 mysql_native_password,或者在命令行执行ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';。

# Ubuntu / CentOS 下安装 MySQL 后用命令行验证三件事 mysql -u root -p # 1. 查看字符集 SHOW VARIABLES LIKE 'character_set_server'; # 2. 查看时区 SHOW VARIABLES LIKE 'time_zone'; # 3. 查看认证插件 SHOW VARIABLES LIKE 'default_authentication_plugin';

3.3 JDBC 连接参数:连接池和 URL 参数的边界

连接池在热词里被反复提及,实际项目中不会有人每次请求都新建 Connection 再关掉,那样性能太差。常见方案是用 DBCP 或 C3P0 连接池,在项目里放一个 db.properties 配置文件,用 BasicDataSource 初始化连接池。但很多课程设计项目为了简单,直接写一个 JdbcUtil 类用 DriverManager.getConnection 拿连接,这个在小项目里其实够用,几百个用户量级的活动管理系统不会有性能瓶颈。真正要小心的是连接 URL 的参数拼写:

String url = "jdbc:mysql://localhost:3306/activity_db" + "?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/Shanghai" + "&allowPublicKeyRetrieval=true";

每个参数都有对应要解决的问题:useUnicode=true 和 characterEncoding=utf8 解决中文乱码;useSSL=false 是 MySQL 5.7 之后默认开启 SSL 认证,不关掉会看到大量 SSL 警告日志,但在内网环境其实没必要加密;serverTimezone=Asia/Shanghai 不加的话,MySQL 8.0 连接会报 "The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized" 这个经典的时区报错;allowPublicKeyRetrieval=true 是配合 mysql_native_password 使用的,不加可能报 "Public Key Retrieval is not allowed",但前提是你确认自己的网络环境安全,因为这条参数允许客户端从服务器获取公钥,存在理论上的中间人风险。驱动 jar 包的选择也要注意:MySQL 5.7 用 5.1.49 版本的驱动兼容性最好,MySQL 8.0 必须用 8.0.x 的驱动,把老驱动硬塞给 MySQL 8.0 会报 ClassNotFoundException 或者认证插件不支持。

4. 后端落地:Servlet 处理活动发布与报名的最小可运行实现

4.1 项目目录结构:先说清楚 src、webapp、WEB-INF 各自干什么

用 IDEA 新建 JSP 项目时(热词里也有 "idea新建jsp项目"),不要直接选 Java Enterprise 里的 Web Application 空模板,最好创建一个 Maven 项目再手动补 web 目录,这样结构更可控。一个标准的活动管理系统目录结构是这样的:src/main/java 放 Servlet 和 Dao 类,src/main/webapp 下面放 JSP 页面、CSS、JS 和 Bootstrap 资源文件,WEB-INF 下面必须有 web.xml(Servlet 3.0 之后可以用注解替代,但 web.xml 更直观)和 lib 目录(放 mysql-connector-java.jar,如果你不用 Maven 的话)。

<!-- web.xml 核心配置:Servlet 映射路径决定浏览器怎么访问 --> <?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <display-name>Activity Management System</display-name> <servlet> <servlet-name>ActivityServlet</servlet-name> <servlet-class>com.activity.servlet.ActivityServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>ActivityServlet</servlet-name> <!-- 所有 /activity/ 开头的请求都进这个 Servlet --> <url-pattern>/activity/*</url-pattern> </servlet-mapping> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> </web-app>

这个 web.xml 里有几个值得注意的参数:url-pattern 写成/activity/*而不是/activity,前者是路径匹配,后面还可以挂 /activity/list、/activity/detail 这样的子路径;welcome-file-list 决定了访问项目根路径时打开的默认页面,一般放登录页而不是活动列表页,这样用户必须登录才能看内容。Servlet 3.0 以后可以用 @WebServlet("/activity/*") 注解替代 web.xml 的配置,但如果你的项目要兼容老 Tomcat 或者老师要求写 web.xml,两种方式保留一种即可,别同时在两处定义同一个映射,那会导致 Servlet 被实例化两次的诡异问题。

4.2 ActivityServlet:活动发布和列表查询的 doPost / doGet 写法

对于一个活动管理系统,最核心的 Servlet 就是 ActivityServlet。它管三件事:发布活动、查活动列表、查活动详情。我建议用一个 Servlet 加一个 action 参数来区分操作,而不是给每个功能建一个 Servlet,这样 web.xml 的映射少,代码也更集中。

@WebServlet("/activity/*") public class ActivityServlet extends HttpServlet { private ActivityDao activityDao = new ActivityDao(); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String pathInfo = request.getPathInfo(); // 拿到 /list 或 /detail?id=1 request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); if ("/list".equals(pathInfo)) { // 分页参数:默认第1页,每页10条 int pageNo = Integer.parseInt( request.getParameter("pageNo") == null ? "1" : request.getParameter("pageNo")); int pageSize = 10; List<Activity> list = activityDao.findByPage(pageNo, pageSize); int total = activityDao.countAll(); request.setAttribute("activityList", list); request.setAttribute("total", total); request.setAttribute("pageNo", pageNo); // forward 到 JSP,URL 不变,request 里的数据能带到页面 request.getRequestDispatcher("/WEB-INF/jsp/activityList.jsp").forward(request, response); } else if ("/detail".equals(pathInfo)) { int id = Integer.parseInt(request.getParameter("id")); Activity activity = activityDao.findById(id); request.setAttribute("activity", activity); request.getRequestDispatcher("/WEB-INF/jsp/activityDetail.jsp").forward(request, response); } } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); // 发布活动:从表单取参数,组装成 Activity 对象,落库后重定向到列表页 String title = request.getParameter("title"); String location = request.getParameter("location"); String maxPeople = request.getParameter("maxPeople"); Activity activity = new Activity(); activity.setTitle(title); activity.setLocation(location); activity.setMaxPeople(Integer.parseInt(maxPeople)); boolean ok = activityDao.insert(activity); if (ok) { // 重定向防止刷新重复提交 response.sendRedirect("list"); } else { request.setAttribute("error", "发布失败,请稍后重试"); request.getRequestDispatcher("publish.jsp").forward(request, response); } } }

这段代码里有几个边界情况要说明:第一,doGet 里解析 pageNo 时用了空值判断,因为用户第一次访问列表页时根本没有 pageNo 参数,不判空会抛 NumberFormatException;第二,forward 到 WEB-INF 目录下的 JSP,而不是直接放在 webapp 根目录,这是安全实践——WEB-INF 下的页面浏览器无法直接访问,必须经过 Servlet 转发,防止用户绕过登录直接打开页面;第三,doPost 里用重定向而不是 forward,这个区别很关键:forward 是服务器内部跳转,浏览器地址栏不变,刷新时会把上次的 POST 请求再发一遍,导致重复插入活动,重定向相当于浏览器重新发了一次 GET 请求,不会重复提交。

4.3 报名逻辑与事务边界:用唯一索引兜底,用事务保护数据一致性

报名是活动管理系统里最容易出数据问题的场景。一个用户重复点击报名按钮、两个用户同时抢最后一个名额、用户报名后活动被删——这些情况如果不在代码层面兜住,数据库里的数据就会乱掉。我之前遇到的实际案例:用户双击报名按钮,前端没做防重复处理,后端也没校验,结果 enroll 表里插进去两条相同 user_id + activity_id 的记录,导致签到页面同一个用户显示两行。数据模型里加了联合唯一索引可以挡住这一层,但如果索引不加,就只能靠代码查两次——先 SELECT 再 INSERT,这个方案在单机小并发下没问题,高并发下会有竞态条件。

// 报名方法的正确写法:事务保证"查重+插入"两步操作要么都成功要么都失败 public boolean enroll(int userId, int activityId) { Connection conn = null; PreparedStatement checkStmt = null; PreparedStatement insertStmt = null; try { conn = JdbcUtil.getConnection(); // 开启事务,setAutoCommit(false) 之后执行的SQL不会立刻落库 conn.setAutoCommit(false); // 第一步:查这个用户是否已报名该活动 String checkSql = "SELECT id FROM enroll WHERE user_id=? AND activity_id=? AND status=1"; checkStmt = conn.prepareStatement(checkSql); checkStmt.setInt(1, userId); checkStmt.setInt(2, activityId); ResultSet rs = checkStmt.executeQuery(); if (rs.next()) { return false; // 已报名,直接放弃,事务回滚 } // 第二步:插入报名记录 String insertSql = "INSERT INTO enroll (user_id, activity_id) VALUES (?, ?)"; insertStmt = conn.prepareStatement(insertSql); insertStmt.setInt(1, userId); insertStmt.setInt(2, activityId); insertStmt.executeUpdate(); // 两步都成功才提交 conn.commit(); return true; } catch (SQLException e) { try { if (conn != null) conn.rollback(); // 任何一步失败都回滚 } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { // 关闭资源:先关Statement,再关Connection,顺序不能反 JdbcUtil.close(insertStmt, checkStmt, conn); } }

这段代码里有三个值得深挖的参数和习惯:第一,setAutoCommit(false) 是事务的开关,没有这行代码,两条 SQL 各自独立提交,第二步失败时第一步已经写进数据库了,数据就处于中间状态;第二,最后关闭资源时要用 try-with-resources 或者嵌套 try-finally,顺序是 ResultSet → Statement → Connection,如果只关 Connection 不关 Statement,连接池里的连接最终会被耗尽,日志里会出现 "Connection is not available, request timed out";第三,conn.rollback() 在 catch 里也要包 try-catch,因为回滚本身也可能抛异常。把这些都写到位,代码看起来繁琐,但至少数据一致性有保障。

5. Bootstrap 与 JSP 页面:列表、分页、权限按钮的整合方式

5.1 Bootstrap 资源引入:不要用网络 CDN,本地化才是免翻车的正路

Bootstrap 在这个项目里的作用不是炫技,而是让你不写一行 CSS 也能做出像样的后台界面。很多教程让你用 CDN 引用 Bootstrap 的 CSS 和 JS 文件,但校园网的网络状况大家都懂——页面加载半天出了纯 HTML 的样子,样式全丢了,原因是 CDN 文件没加载下来。更稳妥的做法是把 bootstrap.min.css 和 bootstrap.min.js 下载到本地,放进 webapp/static/bootstrap 目录,然后在 JSP 里用绝对路径引用。

<%-- 引入 Bootstrap 和 jQuery:注意 contextPath 的用法 --%> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>活动列表</title> <%-- ${pageContext.request.contextPath} 是项目根路径 --%> <link rel="stylesheet" href="${pageContext.request.contextPath}/static/bootstrap/css/bootstrap.min.css"> <script src="${pageContext.request.contextPath}/static/js/jquery-3.6.0.min.js"></script> <script src="${pageContext.request.contextPath}/static/bootstrap/js/bootstrap.min.js"></script> </head>

这里最容易翻车的点就是路径。JSP 里如果写href="static/bootstrap/css/bootstrap.min.css",浏览器解析相对路径时是按当前 URL 的目录去拼的。如果当前页面是/activity/list,浏览器会把相对路径解析成/activity/static/...,结果 404;如果当前页面是/activity/list.jsp,又会解析成/activity/static/...或/static/...取决于具体层级。统一用${pageContext.request.contextPath}拼出的绝对路径,访问/activity/list和访问/activity/list.jsp时都能正确找到资源。这个细节就是很多项目"后台能跑但样式全丢"的根因。

5.2 活动列表页与分页:JSTL 循环渲染 + Bootstrap 分页组件

活动列表页是这个系统的门面,要展示的信息包括活动标题、开始时间、地点、报名人数/上限、状态。用 JSP + JSTL 标签渲染列表,不要用<% %>脚本片段写 for 循环——那样页面里全是 Java 代码,可读性极差。JSTL 的 c:forEach 标签配合 Bootstrap 的表格类就能完成渲染。

<div class="container mt-3"> <h3>活动列表</h3> <table class="table table-striped table-bordered table-hover"> <thead class="thead-dark"> <tr> <th>活动标题</th> <th>开始时间</th> <th>地点</th> <th>报名情况</th> <th>状态</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach items="${activityList}" var="activity"> <tr> <td>${activity.title}</td> <%-- fmt:formatDate 解决 java.util.Date 直接输出的丑格式 --%> <td><fmt:formatDate value="${activity.startTime}" pattern="yyyy-MM-dd HH:mm"/></td> <td>${activity.location}</td> <td>${activity.enrolledCount} / ${activity.maxPeople}</td> <td> <c:if test="${activity.status == 0}"> <span class="badge badge-secondary">未开始</span> </c:if> <c:if test="${activity.status == 1}"> <span class="badge badge-success">进行中</span> </c:if> <c:if test="${activity.status == 2}"> <span class="badge badge-danger">已结束</span> </c:if> </td> <td> <a href="${pageContext.request.contextPath}/activity/detail?id=${activity.id}" class="btn btn-sm btn-info">查看</a> <%-- 登录用户才能看到报名按钮,管理员不显示 --%> <c:if test="${sessionScope.user != null && sessionScope.user.role == 1}"> <button class="btn btn-sm btn-primary" onclick="enroll(${activity.id})">报名</button> </c:if> </td> </tr> </c:forEach> </tbody> </table> <%-- Bootstrap 分页组件:pageNo 从 Servlet 传过来 --%> <nav aria-label="Page navigation"> <ul class="pagination"> <li class="page-item ${pageNo <= 1 ? 'disabled' : ''}"> <a class="page-link" href="list?pageNo=${pageNo - 1}">上一页</a> </li> <li class="page-item active"> <span class="page-link">第 ${pageNo} 页</span> </li> <li class="page-item ${pageNo * 10 >= total ? 'disabled' : ''}"> <a class="page-link" href="list?pageNo=${pageNo + 1}">下一页</a> </li> </ul> </nav> </div>

这个页面里有几个参数要重点理解:第一,activity.enrolledCount 和 activity.maxPeople 是 Activity 对象的两个属性,前者需要在查列表的 SQL 里用 LEFT JOIN + COUNT 聚合出来,不能只查 activity 表本身;第二,c:if 标签判断 status 来渲染不同的 Bootstrap 徽章样式,比在 Java 代码里拼接 HTML 字符串干净得多;第三,分页组件的 disabled 类——当前页码是第一页时,上一页按钮要加 disabled 防止用户点出 pageNo=0 的非法请求,同时 Servlet 里也做了空值判断,双保险;第四,sessionScope.user 是登录后存在 session 里的用户对象,通过它判断是否渲染报名按钮,这是最简单的权限控制方式,比用过滤器简单但对这个项目够用。

5.3 Bootstrap 模态框 + jQuery Ajax:无刷新报名和表单校验

报名动作如果用传统表单提交,点完按钮页面要刷新一次,体验像 2010 年的产品。用 Bootstrap 模态框 + jQuery Ajax 可以做到点报名按钮弹出确认框,点确认后异步提交,不刷新页面就更新报名人数。

function enroll(activityId) { // 从 session 里的用户取 userId,用户名做展示用 var userId = $('#loginUserId').val(); $.ajax({ url: '${pageContext.request.contextPath}/activity/enroll', type: 'POST', data: { activityId: activityId, userId: userId }, dataType: 'json', success: function(result) { if (result.code === 1) { alert('报名成功'); // 更新该行的报名人数,不用整页刷新 location.reload(); } else { alert(result.msg); } }, error: function() { alert('网络异常,请重试'); } }); }

前端发送数据后,Servlet 里要单独加一个 enroll 分支处理这个 Ajax 请求,并且返回 JSON 格式的数据,而不是 forward 到 JSP 页面。返回 JSON 的方式有两种:直接用response.getWriter().write("{\"code\":1,\"msg\":\"success\"}")拼字符串,简单但容易出错;或者引入 Gson 库,把一个 Map 对象转成 JSON 字符串,可读性和可维护性更好。Ajax 请求有一个容易忽略的问题:Servlet 返回的数据编码必须和前端 dataType 声明的一致,response.setContentType("application/json;charset=UTF-8") 这行不能省,否则中文 msg 会乱码成问号。另外注意报错提示不能太笼统,比如用户已报名时,后端返回的 msg 应该具体指出"你已经报过这个活动了",这样用户才知道不是系统问题。

6. 避坑手册:JSP + Servlet + MySQL 项目从启动到部署的 7 个高频陷阱

6.1 MySQL 8.0 连接失败:Authentication plugin 'caching_sha2_password' cannot be loaded

现象:Tomcat 启动后访问活动列表页,页面报java.sql.SQLException: Unable to load authentication plugin 'caching_sha2_password',控制台一堆堆栈信息。

原因:MySQL 8.0 默认认证插件是 caching_sha2_password,而你项目 lib 目录下的 mysql-connector-java 驱动是 5.x 老版本,老驱动不认识这个新插件,握手阶段直接失败。这个坑在全新安装 MySQL 8.0 的环境里几乎必踩。

解决:两条路任选。推荐的是在 MySQL 命令行执行ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';,把 root 用户的认证插件改回老版本兼容的模式;或者把 mysql-connector-java 的 jar 包升级到 8.0.x 版本,8.0 驱动同时支持两种认证插件。两个方案都做完是稳妥的做法,因为改完插件后如果驱动还是老版本,驱动不认识 mysql_native_password 的情况极少但存在。

6.2 中文乱码:页面和数据库全是问号

现象:活动标题输入 "迎新晚会",存进数据库变成 "????",页面读出来显示 "??"。

原因:乱码是三层编码不一致叠加的结果。第一层,JSP 页面本身的 pageEncoding 没设 UTF-8;第二层,Servlet 接收请求时没调 request.setCharacterEncoding("UTF-8"),POST 提交的中文按 ISO-8859-1 解码直接就废了;第三层,MySQL 表结构的 charset 不是 utf8mb4。三层里有任何一层不对,最后落库的数据就是乱码。

解决:三层都改成 UTF-8。JSP 头部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>;Servlet 的 doPost 第一行写request.setCharacterEncoding("UTF-8");建表时用 utf8mb4 字符集,已经建好的表执行ALTER TABLE activity CONVERT TO CHARACTER SET utf8mb4;补救。改完这三处后重启 Tomcat,再测试一遍新增数据而不是查旧数据——旧数据已经是乱码的救不回来。

6.3 部署后 404 或访问路径不带项目名

现象:IDEA 里运行一切正常,把 war 包丢到 Tomcat 的 webapps 目录后,访问http://localhost:8080/list.jsp直接 404。

原因:IDEA 里配置的 Tomcat 部署方式是把应用作为根路径访问,而独立运行的 Tomcat 把 war 包解压后是以 war 包名作为上下文路径的。比如你的 war 包叫 activity.war,访问路径必须是http://localhost:8080/activity/,少了 project name 这一段,Tomcat 根本找不到应用。

解决:第一,访问路径带项目名;第二,JSP 和 Servlet 里所有跳转都用${pageContext.request.contextPath}拼绝对路径,这样不管部署时的项目名是什么,都能正确跳转;第三,如果确实想用根路径访问,把 war 包改名为 ROOT.war 再部署,但我不推荐这么干,多个应用部署在一起时命名冲突是必然的。热词里 "传统jsp项目打包war" 正好对应这个场景,打包时记得用 IDEA 的 Build > Build Artifacts > action > Build,war exploded 和 war 的区别是后者是压缩包,前者是目录。

6.4 WEB-INF/lib 目录下缺 jar 包:ClassNotFoundException

现象:本地 Tomcat 跑得好好的,换一台电脑部署,启动时疯狂报ClassNotFoundException: com.mysql.cj.jdbc.Driver。

原因:用 Maven 构建时,依赖 jar 包在 Maven 本地仓库,打包进 war 的 lib 目录没问题;但如果是手动拷贝项目的模式,Tomcat 的 lib 目录和项目的 WEB-INF/lib 目录各自独立,mysql-connector-java 只放到了 Tomcat 的 lib 下,换环境就没了。IDEA 里配置了 library 的是开发期,部署期 war 包里没带就完蛋。

解决:检查 war 包里的 WEB-INF/lib 目录,确认 mysql-connector-java.jar、jstl.jar、standard.jar 都在里面。IDEA 里 File > Project Structure > Artifacts > Output Layout > 把库的 "provided" 和 "compile" 区分开,Tomcat 自带的 servlet-api.jar 设为 provided(不打进 war 包,否则和 Tomcat 的 jar 冲突),mysql-connector 和 JSTL 设为 compile(必须打进 war 包)。

6.5 Bootstrap 样式不生效:F12 一看全是 404

现象:页面 HTML 结构正常,但丑得像白板,没有任何 Bootstrap 风格。F12 打开开发者工具,Console 和 Network 标签里全是红色 404,路径形如http://localhost:8080/static/bootstrap/css/bootstrap.min.css。

原因:上一章讲的路径问题,JSP 里用了相对路径写静态资源引用。访问列表页时当前 URL 是/activity/list,浏览器解析相对路径static/...时认为你要找/activity/static/...,这个目录不存在,所以 404。HTML 文本能显示是因为它不需要经过路径解析,CSS 和 JS 是外部资源,路径错了就加载不了。

解决:统一改成${pageContext.request.contextPath}/static/...拼绝对路径。在一个项目里写 JSP 时,所有 href 和 src 属性都必须带这个前缀,没有例外。这是个血泪经验:每个 JSP 页面头部的那行拿来复制的<link>标签,都要检查是不是带上了 contextPath。

6.6 Tomcat 端口被占用:8080 被其他进程占着

现象:IDEA 启动 Tomcat 时控制台报Port 8080 was already in use.,但浏览器访问 8080 又能打开东西,页面又不是自己的项目。

原因:另一个 Tomcat 实例或者别 Java 进程(比如其他 IDE 开着的 Tomcat)占用了 8080。校园里上课时经常是同学之间互相占用,或者是上一个跑崩的 Tomcat 进程没杀干净。

解决:Windows 下netstat -a -n -o | findstr "8080"找到 PID,然后taskkill /F /PID <pid>或者去任务管理器找对应进程结束掉。上面这条命令处理不了时,改 Tomcat 端口最省事:conf/server.xml 里把 Connector 的 port 改成 8081。顺手在 IDEA 里把配置里的 HTTP port 也改掉,保持两边一致。

6.7 JSP 页面直接访问能打开但样式全丢,登录失效

现象:直接在浏览器地址栏输入某张 JSP 页面的 URL 能打开 HTML,但这张页面里的 Bootstrap 样式全丢,并且显示的数据是未登录状态。

原因:你没经过 Servlet 转发,直接访问了 webapp 根目录下的 JSP 文件。这种访问方式绕过了登录拦截和 Servlet 里的数据装配,request 作用域里没有框架传过来的数据,session 里也没有用户信息,所以页面渲染出来是半残状态。

解决:这是设计问题,不是简单改代码就能绕过的。正确做法是 JSP 放在 WEB-INF 目录下,浏览器无法直接访问,所有页面跳转都走 Servlet 转发。登录拦截用 Servlet 的 Filter 实现更规整——写一个 LoginFilter,在 doFilter 里检查 session 是否有 user 对象,没有就重定向到 login.jsp。Filter 的 url-pattern 设成/*但排除 login 相关的路径,否则登录页面自己也访问不了。

7. 部署验证与性能检查清单:从 IDEA 到独立 Tomcat 的全流程

一个活动管理系统开发完,真正的分水岭不是代码写完,而是能不能在一台干净的环境里顺利部署。很多同学在 IDEA 里点绿三角跑得飞起,换到独立 Tomcat 就翻车。这里给出一套我自己每次交付这类项目都会执行一遍的验证清单和部署流程。

Tomcat + JDK + MySQL 的版本搭配要先确认:JDK 8 配 Tomcat 8.5 或 Tomcat 9 是兼容性最好的组合,JDK 11 以上配老 Tomcat 会报各种反射相关的警告;MySQL 5.7 和 8.0 都行,但 8.0 记得处理认证插件问题。打包部署时,在 IDEA 里选 Clean 然后 Build Artifacts,确认 war 包小于 50MB(正常这个项目就是十几 MB),放到 Tomcat 的 webapps 目录,启动 Tomcat 后观察 logs/catalina.out 有没有 Exception。

部署完成后按验证清单走一遍流程:用不同浏览器访问项目根路径能否跳到登录页;登录后能否看到活动列表(列表分页是否正常,页脚显示第几页);点报名按钮是否弹出模态框,报名后列表里的报名人数是否 +1;管理员账号发布活动后,普通用户能否看到新活动;活动开始时间超过当前时间后状态是否从"未开始"变成"进行中"。这些功能全部过一遍之后,还要测一个隐藏项:部署第二天再访问,连接池是否还能正常工作,部分老项目的连接池配置没有做空闲回收,第二天第一次访问会超时,重启 Tomcat 又好了,这种要加连接池的 testOnBorrow 配置才能根治。

# 独立 Tomcat 部署后的启动日志检查常用命令 # 查看窗口运行信息 tail -f /opt/tomcat/logs/catalina.out # 检查端口监听,正常能看到 8080 处于 LISTEN netstat -tlnp | grep 8080 # 查看 MySQL 连接是否建立成功 netstat -tlnp | grep 3306 # 用 curl 验证项目首页返回 HTML 而不是 500 错误页 curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/activity/

这套验证流程里,我自己养成的习惯是每个功能点至少验证两次:第一次刚部署完,第二次隔一天再看。因为很多与数据库连接池、session 过期相关的问题都是时间堆积出来的,当天发现不了。另外项目交付给对方时,数据库脚本要单独给一份 SQL 文件而不是让他们自己建表,包含建库语句、建表语句和初始数据(至少一个管理员账号和一个普通用户账号),连接数据库的账号密码单独写在配置说明里,这也是让对方能最快跑起来的关键。希望帮到你——把这套验证清单跑完,你的活动管理系统不管交到谁手里,都能稳定运行起来。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询