简介:一套基于Java技术栈的前后台分离式活动管理系统,面向学习JavaWeb的开发者,也适合作为课程设计或毕业设计参考。系统内置管理员与普通用户两种角色:管理员可进行登录、个人信息维护、活动类型管理、活动发布与报名审核,并支持游客管理;用户端则可按分类浏览活动、查看活动详情、在线提交报名、确认报名状态及管理个人报名记录,前后台联动完整覆盖活动管理业务闭环。资源包为RAR压缩格式,共214个文件,约10.56MB,内含55个Java源码及对应class文件、27个JSP页面、数据库SQL脚本,以及jQuery、Bootstrap、CSS、JS等前端资源,另有部分jpg预览图和配置文件,能够直接导入IDE运行。已有564人学习下载,借助这套项目可重点练习JSP、Servlet、Bootstrap三者的整合,理解MVC分层、接口调用和基础权限控制的实现思路,便于后续二次扩展。
1. 现在还有必要做 Java+JSP+Bootstrap+Servlet+MySQL 这套活动管理系统吗
活动管理系统、Java、JSP、Bootstrap、Servlet、MySQL——如果要在简历上写一个完整项目,这套组合其实是很好的底子。它没有 Spring Boot 全家桶,也没有前后端分离,但恰好因为技术栈老,从浏览器请求到 Servlet 再到 JDBC 和数据库的整条链路全部裸露,适合把 Java Web 基础吃透。活动管理系统的核心业务不复杂:用户登录、活动发布、用户报名、后台管理,几张表就能撑起一个可演示的闭环。适合正在找基于 JSP 毕设选题的学生,也适合只想快速验证业务原型、不想引入重框架的开发者。这套栈跑通容易,但乱码、连不上库、war 包缺资源这类坑,每一个都能卡住你半天。
2. 活动管理系统的表怎么建:六张表把“用户—活动—报名”串起来
2.1 为什么最少要建六张表:去掉角色表会后悔
活动管理系统的业务主链路是“用户登录 → 管理员发布活动 → 用户报名 → 查看报名记录”。单看核心数据,用户表、活动表、报名表三张表就能跑,但我一般会再加三张:角色表、活动类型表、操作日志表。
角色表解决的是“这个用户能不能进后台”的问题。把 role_id 直接塞在用户表里,用 1、2、3 这种数字代表管理员和普通用户,短期内能跑,但后面要加“活动审核员”这种角色时,就得改 Java 代码而不是改数据。活动类型表的价值在列表页筛选和统计报表,比如按“讲座、比赛、志愿活动”分类统计参与人数,没有类型表就只能写死下拉框。操作日志表是给答辩和排错用的,谁在什么时间把活动状态从草稿改成了发布,留一条记录,出问题时不用对着数据库 binlog 猜。
你如果只想压缩到五张,去掉操作日志表是最合理的;角色表可以简化成用户表里的一个字段,但我建议保留,因为活动管理系统的权限差异是刚需——普通用户只能报名,管理员才能编辑活动。建表的顺序也有讲究:先建角色表和类型表,再建用户表和活动表,最后建报名表和日志表,这样外键关联不会报“表不存在”。
2.2 关键字段设计与类型选择:状态机、唯一索引、默认值
活动表是整个系统的核心,设计时最容易翻车的是状态和名额这两个字段。
状态字段用 TINYINT 而不是 VARCHAR。我的定义是:0 草稿、1 报名中、2 进行中、3 已结束、4 已取消。用数字的好处是排序、比较、维护都方便,但页面上不能直接显示数字,所以 Java 端需要一个状态枚举或者字典映射,把 0 翻译成“草稿”,把 1 翻译成“报名中”。
名额字段建议建两个:quota 表示总名额,remaining_quota 表示剩余名额。quota 为 0 表示不限名额,这个约定要在代码里写明。建表时给 remaining_quota 设默认值 0,避免插入数据时漏传导致 NULL,后续做remaining_quota > 0条件判断时 NULL 会让结果永远为假。
报名表上必须加唯一索引,这是防重复报名的第一道防线:
CREATE TABLE activity_signup ( id BIGINT AUTO_INCREMENT PRIMARY KEY, activity_id BIGINT NOT NULL COMMENT '活动ID', user_id BIGINT NOT NULL COMMENT '用户ID', signup_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '报名时间', UNIQUE KEY uk_activity_user (activity_id, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动报名表';唯一索引uk_activity_user保证了同一个人对同一个活动只能有一条报名记录。这里有一个隐藏很深的坑:如果你想支持“取消报名后还能再报”,就不要设计成cancel_flag标记取消状态,因为取消标记加唯一索引会导致同一个人第二次报名时插入两条记录,严格模式下直接报唯一索引冲突。我的处理方式是取消报名就 DELETE 这条记录,重新报名就是全新 INSERT,简单且不会被约束卡住。
2.3 可直接执行的初始化 SQL:建库建表加测试数据
下面这份 SQL 是完整可执行的,库名、表名、字段名都按 Java 端习惯命名。用 MySQL Workbench 或命令行执行前,确认你的 MySQL 服务已启动;执行时选择 utf8mb4 字符集,否则后面写中文进表会变问号。
CREATE DATABASE IF NOT EXISTS activity_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE activity_system; CREATE TABLE sys_role ( id INT PRIMARY KEY, role_code VARCHAR(30) NOT NULL COMMENT '角色编码', role_name VARCHAR(30) NOT NULL COMMENT '角色名称' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; CREATE TABLE activity_type ( id INT AUTO_INCREMENT PRIMARY KEY, type_name VARCHAR(50) NOT NULL UNIQUE COMMENT '类型名称' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动类型表'; CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL COMMENT '登录名', password VARCHAR(128) NOT NULL COMMENT '密码散列值', real_name VARCHAR(50) COMMENT '姓名', role_id INT NOT NULL DEFAULT 2 COMMENT '角色ID:1管理员 2普通用户', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用 0禁用', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE activity ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL COMMENT '活动标题', description TEXT COMMENT '活动详情', location VARCHAR(200) COMMENT '活动地点', type_id INT COMMENT '活动类型ID', start_time DATETIME NOT NULL COMMENT '开始时间', end_time DATETIME NOT NULL COMMENT '结束时间', quota INT NOT NULL DEFAULT 0 COMMENT '总名额,0表示不限', remaining_quota INT NOT NULL DEFAULT 0 COMMENT '剩余名额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1报名中 2进行中 3已结束 4已取消', created_by BIGINT NOT NULL COMMENT '创建人ID', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_status_time (status, start_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动表'; CREATE TABLE activity_signup ( id BIGINT AUTO_INCREMENT PRIMARY KEY, activity_id BIGINT NOT NULL COMMENT '活动ID', user_id BIGINT NOT NULL COMMENT '用户ID', signup_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '报名时间', UNIQUE KEY uk_activity_user (activity_id, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动报名表'; CREATE TABLE operation_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT '操作人ID', action VARCHAR(50) NOT NULL COMMENT '操作动作', detail VARCHAR(500) COMMENT '操作详情', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表'; INSERT INTO sys_role (id, role_code, role_name) VALUES (1, 'ADMIN', '管理员'), (2, 'USER', '普通用户'); INSERT INTO activity_type (type_name) VALUES ('讲座'), ('比赛'), ('志愿活动'), ('团建'); INSERT INTO sys_user (username, password, real_name, role_id) VALUES ('admin', '240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9', '系统管理员', 1), ('zhangsan', '240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9', '张三', 2);说明几个容易忽略的参数。字符集统一用utf8mb4而不是utf8,因为utf8在 MySQL 里最多存 3 字节,用户昵称里出现 emoji 就插不进去。时间字段用DATETIME,不要用VARCHAR存时间字符串,否则 Java 端排序、范围查询都要做字符串转换,还会出现“2024-1-1”和“2024-01-01”这种不一致的数据。remaining_quota默认值 0 配合业务约定“0 表示不限名额”,代码里查询可报名活动时要用(quota = 0 OR remaining_quota > 0)这个条件,漏掉quota = 0会把不限名额的活动过滤掉。
初始化脚本里的密码列是admin123的 SHA-256 散列值,演示项目可以直接用;正式系统建议换成 BCrypt 加盐散列,后面 Java 端统一用工具类生成,不要再出现明文密码。
3. 用 Servlet + JSP 把登录和活动列表跑通:整条请求链路怎么串
3.1 项目骨架怎么搭:Maven war 结构与 Tomcat 版本配对
标题里的技术栈决定项目形态:Servlet 做控制器、JSP 做视图、Bootstrap 做样式,MySQL 做持久化。我建议用 Maven 的 war 包结构,而不是 IDEA 里直接新建一个空 Web 项目。Maven 的好处是依赖版本可控,换机器后mvn clean package能稳定还原环境。
搭骨架前先确认 Tomcat 版本。Tomcat 9 对应javax.servlet包名,Tomcat 10.1 及以上对应jakarta.servlet包名,二者不兼容。如果你照着网上的老教程写import javax.servlet.http.HttpServlet,却用到 Tomcat 10,编译直接找不到类。我的选择是 Tomcat 9 + Maven,最省事。pom.xml 里关键的依赖配法如下:
<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>servlet-api的 scope 必须设为provided。原因是 Tomcat 自带 Servlet 实现,如果你打成 war 包又把 Servlet API 打进WEB-INF/lib,运行时类加载器会出现两个同名的HttpServlet,轻则告警重则 NoSuchMethodError。JSTL 依赖则必须打包进去,很多 JSP 页面空白的问题就是 Tomcat 自带 JSTL 但项目没有声明依赖。
有人用 VSCode 配 Tomcat 写 Servlet,能跑,但调试信息分散在多个终端,遇到问题时排查路径长。想要入手简单,直接用 IDEA 社区版加 Maven,安装一个 Tomcat 插件或者用mvn tomcat7:run这种插件方式启动,都能把断点打到 Servlet 代码里。
目录结构保持 Maven 标准:src/main/java放包名如com.example.activity下的代码,src/main/webapp放 JSP、静态资源,src/main/resources放druid.properties这些配置文件。JSP 放在WEB-INF下可以防止用户直接输入 URL 访问未登录页面,但登录页本身要放外面,因为用户需要先访问它才能提交表单。
3.2 编码过滤器先写好:乱码问题一半在这里
JSP 页面不设置编码,表单提交中文用户名到 Servlet 后取出来就是乱码。我习惯在项目最开始就写一个全局编码过滤器,而不是在每个 Servlet 里重复写request.setCharacterEncoding。
package com.example.activity.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @WebFilter("/*") public class CharacterEncodingFilter implements Filter { @Override public void init(FilterConfig filterConfig) { // 过滤器初始化时不需要额外逻辑 } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); HttpServletResponse resp = (HttpServletResponse) response; resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(request, response); } @Override public void destroy() { // Tomcat 正常关闭时由容器调用 } }这段代码的逻辑是:在请求进入任何 Servlet 之前,先把请求体的解码字符集设为 UTF-8,再把响应的编码和 Content-Type 设好,之后chain.doFilter放行到真正的业务代码。这样 Servlet 里直接req.getParameter("title")拿到的就是正确的中文。
参数上要注意一个偏差:POST 请求的编码由request.setCharacterEncoding决定,但 GET 请求的参数是拼在 URL 里的,编码由 Tomcat 的URIEncoding决定。Tomcat 8.5 之后的默认值已经是 UTF-8,老教程让你改 server.xml 的URIEncoding="UTF-8"在 Tomcat 9 上写不写区别不大;如果你还在用 Tomcat 7,这行配置必须有。我一般不管 GET 还是 POST,统一用 POST 提交表单,避免 URL 里带中文参数的解析问题。
3.3 登录与会话:Servlet 处理表单、Session 存用户
登录功能是活动管理系统的入口。流程是:login.jsp 提交用户名密码到/login,LoginServlet 校验通过后把用户对象放进 Session,然后重定向到活动列表;校验失败则转发回登录页并显示错误信息。
package com.example.activity.servlet; import com.example.activity.entity.User; import com.example.activity.service.UserService; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); if (user == null) { req.setAttribute("errorMsg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(); session.setAttribute("loginUser", user); resp.sendRedirect(req.getContextPath() + "/activity/list"); } }这里最值得说的是“失败用转发、成功用重定向”的设计。转发是服务器内部跳转,地址栏还是/login,刷新时会重复提交表单;重定向是让浏览器重新发起一次 GET 请求,地址栏变成/activity/list,用户刷新页面也不会再次提交登录表单。这是老 Java Web 项目里处理表单提交的通用套路。
Session 里存的是 User 对象而不是 userId 字符串,因为 JSP 页面可能展示“欢迎你,张三”,从sessionScope.loginUser.realName取值比再查一次库方便。注意 User 对象需要实现序列化接口吗?Tomcat 的 Session 钝化场景下建议实现Serializable,但单机演示项目不钝化也能跑,写了更好。
对应的登录拦截 Filter 也不能漏,否则用户直接访问/activity/list就能绕过登录:
package com.example.activity.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebFilter(urlPatterns = {"/activity/*", "/user/*"}) 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; HttpSession session = req.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }写这个 Filter 时有一个细节:调用req.getSession(false),而不是req.getSession()。带 false 的意思是“如果当前没有 Session 就返回 null”,不带参数的版本会强制创建一个新的 Session。如果登录过滤器里用了getSession(),那么所有未登录的匿名请求都会被创建 Session,浪费服务器内存,还会让 session 超时的排查变得混乱。
3.4 活动列表页:JSTL 渲染 + Bootstrap 表格,JSP 里尽量不写 Java
活动列表的 Servlet 负责查数据库并分页,然后把结果放 request 属性里,转发到 JSP。JSP 里负责用 JSTL 和 EL 渲染,不要把<% %>脚本片段写得到处都是。
package com.example.activity.servlet; import com.example.activity.entity.Activity; import com.example.activity.service.ActivityService; import com.example.activity.util.PageResult; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; @WebServlet("/activity/list") public class ActivityListServlet extends HttpServlet { private final ActivityService activityService = new ActivityService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int page = 1; try { page = Integer.parseInt(req.getParameter("page")); } catch (NumberFormatException ignored) { // 参数缺失或非法时默认第一页 } PageResult<Activity> pageResult = activityService.listByPage(page, 10); req.setAttribute("page", pageResult); req.getRequestDispatcher("/WEB-INF/jsp/activityList.jsp").forward(req, resp); } }JSP 页面核心片段如下,Bootstrap 只负责样式,业务数据全部由 JSTL 标签输出:
<%@ 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" %> <div class="container mt-4"> <table class="table table-striped table-hover"> <thead> <tr> <th>活动名称</th> <th>类型</th> <th>开始时间</th> <th>剩余名额</th> <th>状态</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach items="${page.list}" var="act"> <tr> <td>${act.title}</td> <td>${act.typeName}</td> <td><fmt:formatDate value="${act.startTime}" pattern="yyyy-MM-dd HH:mm"/></td> <td>${act.remainingQuota}</td> <td>${act.statusText}</td> <td> <a href="${pageContext.request.contextPath}/activity/signup?id=${act.id}" class="btn btn-sm btn-primary">报名</a> </td> </tr> </c:forEach> </tbody> </table> </div>Bootstrap 的使用方法记住一条:栅格系统加预置类,不要自己写 CSS。container控制页面最大宽度和居中,table-striped让奇偶行颜色不同,table-hover行悬停高亮,按钮的大小和颜色由btn-sm btn-primary决定。引入方式直接用 CDN:
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>这里提一个 JSP 里常见的头疼问题:fmt:formatDate报错说标签不认识,多半是页面忘了声明<%@ taglib prefix="fmt" ... %>这一行。JSTL 由 core、fmt、fn 等多个标签库组成,用哪个标签就要声明哪个前缀,没有例外。
4. JDBC 怎么写才不拖后腿:连接池、预编译语句与报名事务
4.1 DriverManager 能跑为什么还要换 Druid 连接池
很多入门教程教你DriverManager.getConnection(url, user, password)拿连接,用完conn.close()。这个写法在教程里没问题,但放到活动管理系统里,用户点击“报名”按钮时,每次请求都新建物理连接、用完再销毁,MySQL 的握手认证开销比 SQL 执行本身还大。并发一高,数据库连接数即用即焚,应用侧表现为页面转圈、报 Too many connections。
连接池干的事是提前建好一批连接放在池子里,请求来了借走、用完了归还。Druid 在国内用得最多,配置直观,自带监控。这里不是不能用 DriverManager,而是“能跑”和“能扛”是两回事。课设答辩时如果被问到“并发场景你怎么办”,接上连接池比现场改代码从容得多。
4.2 配置 Druid 连接池:druid.properties 与 JdbcUtils
在src/main/resources下新建druid.properties:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://127.0.0.1:3306/activity_system?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&allowPublicKeyRetrieval=true username=root password=你的数据库密码 initialSize=5 maxActive=20 maxWait=30000一行一行解释:driverClassName用com.mysql.cj.jdbc.Driver,这是 MySQL 8.x 版本的驱动类名,老教程里的com.mysql.jdbc.Driver已经被移除了。URL 里四个参数都有明确的用处:useSSL=false关掉 SSL 握手,MySQL 8 默认开启 SSL,不关会报 “Communications link failure” 或一直告警;serverTimezone=Asia/Shanghai解决驱动读取数据库时间时遇到的时区报错;characterEncoding=utf8保证写入和读取中文不乱码;allowPublicKeyRetrieval=true配合 MySQL 8 默认的caching_sha2_password认证插件,否则连接时可能报 “Public Key Retrieval is not allowed”。
注意一个细节:properties 文件里&不需要转义,但这段 URL 如果写在 web.xml 或 Spring XML 里,&必须写成&。很多人在 xml 里配 JDBC 链接报 XML 解析错误,就是这个原因。
对应的工具类:
package com.example.activity.util; import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.io.InputStream; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Properties; public class JdbcUtils { private static DataSource dataSource; static { try (InputStream in = JdbcUtils.class.getClassLoader() .getResourceAsStream("druid.properties")) { Properties props = new Properties(); props.load(in); dataSource = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r != null) { try { r.close(); } catch (Exception ignored) { // 关闭资源失败不影响主流程 } } } } }静态代码块在类加载时执行一次,负责读取配置并创建连接池。getConnection返回的是池化连接,close方法接收可变参数,一次关掉 ResultSet、Statement、Connection。这里有个习惯要养:写 DAO 时,关资源的代码放在 finally 块里,并且从后往前关,先结果集再语句最后连接。用这个工具类就能写JdbcUtils.close(rs, stmt, conn)一行搞定。
4.3 报名扣名额:事务和条件更新怎么保住数据一致性
报名是活动管理系统里唯一一个必须用事务的地方。业务要求是:剩余名额减一,同时插入一条报名记录,两个操作要么都成功、要么都失败。如果不用事务,出现“名额扣了但报名记录没插进去”,用户会觉得自己报了名但列表里查不到,管理员更头疼。
package com.example.activity.dao; import com.example.activity.util.JdbcUtils; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; public class SignupDao { public boolean signUp(Long activityId, Long userId) { String deductSql = "UPDATE activity SET remaining_quota = remaining_quota - 1 " + "WHERE id = ? AND remaining_quota > 0 AND status = 1"; String insertSql = "INSERT INTO activity_signup (activity_id, user_id) VALUES (?, ?)"; Connection conn = null; try { conn = JdbcUtils.getConnection(); conn.setAutoCommit(false); try (PreparedStatement ps1 = conn.prepareStatement(deductSql); PreparedStatement ps2 = conn.prepareStatement(insertSql)) { ps1.setLong(1, activityId); int rows = ps1.executeUpdate(); if (rows == 0) { conn.rollback(); return false; } ps2.setLong(1, activityId); ps2.setLong(2, userId); ps2.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { conn.rollback(); throw e; } } catch (SQLException e) { throw new RuntimeException("报名失败,请稍后重试", e); } finally { JdbcUtils.close(conn); } } }这段代码的核心逻辑在deductSql的条件更新:UPDATE ... WHERE id = ? AND remaining_quota > 0 AND status = 1。affected rows 为 0 说明名额已满、活动已取消或活动不在报名中状态,此时回滚并返回 false。这就是用数据库的原子更新解决并发问题,两个用户同时报名最后一个名额时,数据库层面只会让一条 UPDATE 成功,不会出现超卖。
手动事务有三个关键点不要漏。第一,conn.setAutoCommit(false)必须在执行 UPDATE 之前调用,否则每条 SQL 各自独立提交,事务形同虚设。第二,catch 块里必须调用conn.rollback(),否则连接归还连接池时带着未提交的事务,下次被借出可能把旧数据一起提交。第三,finally 里的close会把连接归还池子,Druid 会自动回滚未提交的事务,但显式 rollback 是更稳妥的习惯。
PreparedStatement全部用?占位符而不是字符串拼接 SQL,两个原因:防 SQL 注入,以及避免拼字符串时漏掉单引号导致语法错误。setLong绑定参数时,索引从 1 开始,这个和数组下标从 0 开始的习惯不一样,写错会导致Parameter index out of range。
5. 传统 JSP 项目避坑实录:乱码、MySQL 连不上、war 包 404
5.1 页面和数据库全是中文乱码
现象:登录后页面显示“欢迎你,???”;打开数据库表,username 字段存的是问号或乱码;从 JSP 表单提交中文到 Servlet,控制台打印出来就是乱的。
原因:乱码是三层问题叠加的结果。第一层,浏览器提交的请求体编码不是 UTF-8,Servlet 拿getParameter时按默认 ISO-8859-1 解码;第二层,JDBC URL 里没有characterEncoding=utf8,驱动按平台默认编码读写;第三层,数据库表本身字符集是latin1,UTF-8 数据往里写直接被截断成问号。
解决:三层分别处理。请求编码用第 3.2 节的全局 Filter 统一设置;JDBC URL 加characterEncoding=utf8;建库建表时指定DEFAULT CHARSET=utf8mb4。对于已经建错的表,执行ALTER TABLE sys_user CONVERT TO CHARACTER SET utf8mb4可以补救,但之前已经写入的乱码数据不会自动恢复。排查顺序建议先查数据库表字符集,再查 JDBC URL,最后查 Filter,因为数据库层的问题最隐蔽。
5.2 error 2002 (HY000):mysql.sock 找不到
现象:写好的代码本地能连数据库,部署到 Linux 服务器后启动 Tomcat,报error 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'。
原因:这个报错说明 Java 通过 JDBC 连接localhost:3306时,客户端选择走 Unix socket 文件而不是 TCP 端口。Linux 上的 MySQL socket 路径可能不在/tmp/mysql.sock,或者 MySQL 服务根本没启动。如果服务没启动,连/tmp/mysql.sock这个文件都不存在。
解决:先确认 MySQL 服务状态,再决定连接方式。命令行执行:
systemctl status mysqld systemctl start mysqld mysql -h127.0.0.1 -P3306 -uroot -p关键点在最后一条命令:用127.0.0.1而不是localhost。Java JDBC URL 里同样把jdbc:mysql://localhost:3306改成jdbc:mysql://127.0.0.1:3306,这会强制客户端走 TCP 协议,绕开 socket 文件路径问题。数据库密码特别长或者包含特殊字符的,建议在 properties 文件里用环境变量占位,防止特殊字符被解析错位。
5.3 war 包发布后静态资源 404
现象:IDEA 里开发模式下页面正常,执行mvn clean package打出 war 包,扔到 Tomcat 的webapps目录启动后,页面能打开但 CSS、JS 全部失效,点“报名”按钮 404。
原因:最常见的是两个。第一,Bootstrap 的href写成了绝对路径/static/bootstrap.min.css,在 IDEA 里开发时项目上下文是根路径,部署后应用上下文变成了activity-system,资源实际地址是/activity-system/static/bootstrap.min.css,于是 404;第二,IDEA 打 war 时没有把依赖的 jar 包放进WEB-INF/lib,编译期 Servlet、Druid 类不缺失,但运行期找不到。
解决:JSP 里所有路径都用${pageContext.request.contextPath}拼前缀:
<link href="${pageContext.request.contextPath}/static/bootstrap/css/bootstrap.min.css" rel="stylesheet">在 IDEA 传统项目中,检查 Project Structure → Artifacts → Output Layout,确认右边列表里有lib目录且包含 druid、mysql-connector 等 jar。如果没有,右键项目依赖选择 Put into Output Root。用 Maven 的话,确认 pom.xml 里没有把druid或mysql-connector的 scope 写成provided,这会把运行期依赖排除在 war 包外。
5.4 Servlet 生命周期异常:init 报错、destroy 不执行
现象:Tomcat 启动后访问登录页直接 500,日志里能看到Servlet.init() for servlet LoginServlet threw exception;或者你明明在 LoginServlet 的destroy()里写了日志,重启 Tomcat 后日志没出现。
原因:第一,init()里做了重活,比如读取数据库配置、测试连接池,一旦配置有误,Servlet 初始化失败,整个应用不可用。第二,Tomcat 类加载机制下,Servlet 默认在第一次请求时初始化,不是 Tomcat 启动时就初始化。第三,destroy()只有在 Tomcat 正常关闭时才会被调用,你直接 kill 进程或者 IDE 里强制停止,容器根本不给你执行销毁方法的机会。Servlet 生命周期是单实例多线程:一个 Servlet 只有一个实例,service()方法被多个线程并发调用,init()和destroy()各执行一次。
解决:把init()里的逻辑降到最轻。需要预加载的数据,放到静态代码块或者单独的初始化 Servlet 里;init()只做log("init")级别的轻量操作,真正的连接池初始化交给JdbcUtils的静态块。验证destroy()时用catalina.sh stop正常关闭 Tomcat,不要用 kill -9。最容易被面试官追问的一点是:Servlet 里不要写可变的实例字段来存业务状态,因为多线程会同时读写这个字段,产生数据竞争。
5.5 JSP 页面空白但控制台没报错
现象:登录成功跳转活动列表,页面渲染出来是空白的,表格一行都没有,Tomcat 控制台没有任何异常,F12 看响应没有 500。
原因:第一,JSTL 标签库里uri="http://java.sun.com/jsp/jstl/core"在较新容器上解析失败,页面被渲染成空白但日志不显眼;第二,EL 表达式被当成普通文本输出,页面上出现${act.title}而不是活动标题;第三,Servlet 里 setAttribute 用的 key 和 JSP 里 EL 写的 key 不一致,取值结果全是 null。
解决:按顺序排查。先在 JSP 顶部写一行测试输出${pageScope}看有没有数据;没有数据就检查 Servlet 是否已经跳转,跳转用的forward和sendRedirect对 request 属性的存活期影响不同——sendRedirect之后的 request 属性全丢,必须用forward。EL 不解析的问题看 web.xml 头部web-app版本,如果版本低于 2.5,需要在 JSP 里显式写<%@ page isELIgnored="false" %>开启 EL。JSTL 依赖缺失的,在 pom.xml 里把jstl 1.2的依赖补上,清理本地 Maven 仓库后重新构建。
6. 把活动管理系统收尾验证:JSP 减重、状态流转检查和快速自测
6.1 用 JSTL 把 JSP 里的 Java 脚本片段清出去
活动列表页逻辑一多,新手会忍不住写这种代码:
<% if (activity.getStatus() == 1) { %> <span class="badge bg-success">报名中</span> <% } %>能跑,但页面越来越乱,Java 代码和 HTML 混在一起,改一个按钮颜色都可能碰到缩进错乱。JSTL 的c:choose可以直接替代这种分支:
<c:choose> <c:when test="${act.status == 1}"> <span class="badge bg-success">报名中</span> </c:when> <c:when test="${act.status == 2}"> <span class="badge bg-primary">进行中</span> </c:when> <c:otherwise> <span class="badge bg-secondary">已结束</span> </c:otherwise> </c:choose>如果状态文字在多个页面都要用,更好的做法是在实体类里加一个getStatusText()方法,把状态转文字的职责从 JSP 挪到 Java。这是把 JSP 当模板用而不是当页面逻辑用的关键一步。
6.2 状态校验写在 Service 而不是页面里
活动系统里最常见的越权操作是“活动不是报名中也能报名”“活动已结束还能编辑”。页面上的按钮禁用只能挡正常人,直接构造 HTTP 请求就能绕过。状态校验必须放在 Service 里,用条件 UPDATE 来实现比先查询再判断更可靠:
UPDATE activity SET status = 1 WHERE id = ? AND status = 0执行结果受影响行数为 1 才说明状态流转合法:只有草稿能变成报名中,只有报名中的活动能变成进行中。代码里收到 0 就直接抛出业务异常,前端再显示“操作失败:当前活动状态不允许此操作”。这套思路和前面报名扣名额的条件更新是同一个套路,核心是让数据库来判断条件是否满足,而不是在 Java 里先 SELECT 再 UPDATE——两步之间有并发窗口。
6.3 一套十分钟手工回归路径
每次改动上线前,我习惯走一遍这条路径:启动 MySQL → 启动 Tomcat → 用 zhangsan 账号登录 → 创建一个活动草稿 → 切换管理员账号发布活动 → 用 zhangsan 报名 → 用管理员查看报名记录 → 再尝试用 zhangsan 重复报名同一次活动。
验证点有三个:重复报名必须被唯一索引拦下;报名后remaining_quota减一;管理员把活动状态改成已结束后,报名按钮消失但历史报名记录还在。配合数据库命令行查报名表:
SELECT u.real_name, a.title FROM activity_signup s JOIN sys_user u ON s.user_id = u.id JOIN activity a ON s.activity_id = a.id;这套组合项目翻车率最高的不是代码逻辑,而是环境:MySQL 服务没起、Tomcat 端口被占、JDK 版本不对。我早期做课设时把 SQL 拼接成字符串、事务忘记提交、连接用完不关,答辩被问到“并发报名怎么保证数据一致”时直接卡壳。后来养成三个习惯:先设计表再写代码,每个写操作先想清楚事务边界,上线前留十分钟手工回归。这十分钟能救你一次,希望帮到你。
本文还有配套的精品资源,点击获取