简介:基于 JSP+Java Servlet+MySQL 技术栈的校园卡一卡通管理系统完整源码,面向 Java Web 学习者、计算机专业毕业生及需要开发校园消费管理场景的开发者。系统实现身份识别、账户充值、消费记账、管理员审核等功能,采用 MVC 分层设计,覆盖 Controller、Service、Dao 完整调用链,适合课程设计、毕业设计或学习传统 Java Web 工程结构。压缩包共 219 个文件、5.6MB,包含 26 个 JSP 页面负责界面渲染、28 个 Java 类承担业务处理,16 份 CSS 与 9 份 JS 完善前端样式和交互,另有 45 个 GIF 动图与 24 张 JPG 截图用于演示流程,一并提供 SQL 数据库初始化脚本和 jar 依赖库,可直接导入 IDE 运行。已有 276 人在线学习该资源,适合正在开展校园管理类 Web 项目的读者参考。通过阅读源码可快速理解 Servlet 控制层、业务层与 Dao 持久层的分层实现方式,并结合项目截图核对界面与代码的对应关系;数据库文件能帮助搭建测试环境,是快速掌握 JSP 项目开发流程的实用素材。
1. 从一次刷卡说起:JSP 一卡通系统到底在做什么
想象一下:学生在食堂刷一次卡,卡片机把学号、金额、时间发给后台,后台先在账户表里扣一次余额,再往消费记录表插一条账单,最后在 JSP 页面上把余额刷新出来。这一串动作跨了前台页面、Servlet 控制器、Service 业务层、DAO 数据层和 MySQL 事务,任何一个环节掉链子,都会出现"扣款了但查不到记录"这类经典故障。这套 JSP + JAVA + MYSQL 的校园卡一卡通管理系统,正是把这些步骤拆成了用户管理、账户管理、消费账单、充值申请、管理员审核几个模块,代码包里的BillDaoImpl.class、AdminBillServlet.class、UserServiceImpl.class等类,分别对应着数据持久化、请求分发和业务逻辑三层。适合刚学完 Java Web 基础、想找完整项目练手的人,也适合已经在做毕设或课程设计、需要参考分层写法的同学。它能帮你理清一件事:一个请求从 Tomcat 到 JSP 再回到浏览器的完整生命周期,远比你背熟的 MVC 概念图具体得多。
2. 数据库与实体设计:从表结构看一卡通的功能边界
2.1 五张核心表如何划分职责
一卡通系统的核心是"账户余额"和"交易流水"这两块数据。常见做法是把持卡人信息、账户余额、消费记录、充值记录、申请审核拆成独立的表,而不是全塞进一张大表里。表划分的合理性直接决定了后续统计报表和权限控制的复杂度。
我一般会按下面的方式设计,字段名以项目源码中的实际变量为准,但结构基本是这几类:
| 表名 | 核心字段 | 作用 |
|---|---|---|
| users | user_id, card_no, user_name, password, role, status | 存放持卡人基础信息和角色(学生/教师/管理员) |
| accounts | account_id, user_id, balance, is_active | 一张卡对应一条余额记录,金额用 decimal 不用 float |
| bills | bill_id, user_id, amount, item_name, bill_time | 每笔消费的明细,支撑后续的账单查询 |
| deposit_records | deposit_id, user_id, amount, deposit_time | 充值流水,与 bills 分开记录 |
| apply | apply_id, user_id, apply_type, apply_status, create_time | 补卡、挂失、开通权限等申请流程 |
为什么余额要单独拆成 accounts 表而不是直接放在 users 里?因为一卡通可能涉及多张卡、挂失换卡、临时卡等场景,单独拆表后,换卡只需要修改 accounts 表的外键关联,持卡人的基本信息不受影响;而且余额字段的读写频率远高于用户资料,拆表后数据库的锁竞争范围更小。金额字段必须用DECIMAL(10,2),用 float 做累加和扣减会产生浮点误差,这在财务类数据里是不能接受的。
2.2 DAO 层实现:BillDaoImpl 与 UserDaoImpl 的职责边界
源码中的BillDaoImpl.class和UserDaoImpl.class是典型的 DAO 层实现。DAO 层只负责一件事:把 Java 对象转换成 SQL 执行,再结果集转换成 Java 对象。它不关心业务逻辑,更不关心请求从哪个 Servlet 过来。
以插入一条消费记录为例,BillDaoImpl 里通常会有这么一段:
public int insertBill(Connection conn, Bill bill) throws SQLException { String sql = "INSERT INTO bills(user_id, amount, item_name, bill_time) " + "VALUES(?, ?, ?, NOW())"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, bill.getUserId()); ps.setBigDecimal(2, bill.getAmount()); ps.setString(3, bill.getItemName()); return ps.executeUpdate(); } }这里有几个细节值得注意。conn是从外部传入的,而不是在 DAO 内部开启新连接,这样做的目的是让 Service 层控制事务边界——一次扣款需要同时更新 accounts 表余额和插入 bills 表记录,两个操作要么一起成功,要么一起回滚。PreparedStatement的?占位符能避免拼接 SQL 字符串产生的注入风险,比如用户传一个1 OR 1=1进来,预编译会把它当成普通字符串处理。返回值executeUpdate()返回受影响的行数,如果返回 0 说明插入失败,Service 层可以根据这个值决定是否抛出异常。
2.3 业务层的事务控制:BillServiceImpl 如何保证扣款和记账一致
BillServiceImpl.class对应 Service 层实现,它的职责是编排 DAO 方法。
只贴 DAO 代码看不出问题,真正容易翻车的是这里:扣余额和加账单是两个 SQL 操作,如果第一条执行成功、第二条抛异常,数据库里就会出现"钱扣了但消费记录丢了"的脏数据。下面这段是常见的事务控制写法:
public void consume(int userId, BigDecimal amount, String itemName) { Connection conn = null; try { conn = DriverManager.getConnection(url, user, password); conn.setAutoCommit(false); // 关闭自动提交,开启手动事务 AccountDaoImpl accountDao = new AccountDaoImpl(); BillDaoImpl billDao = new BillDaoImpl(); int rows = accountDao.deductBalance(conn, userId, amount); if (rows == 0) { throw new RuntimeException("余额不足或账户不存在"); } billDao.insertBill(conn, buildBill(userId, amount, itemName)); conn.commit(); // 两条 SQL 都成功才提交 } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } throw new RuntimeException("消费失败,事务已回滚", e); } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这段代码里的关键在于setAutoCommit(false)之后的commit()和rollback()配对。很多初学的人把事务写在 DAO 层,导致每个 DAO 方法自己开事务、自己提交,Service 层根本控制不了整体原子性。另外注意deductBalance的 SQL 一般写成UPDATE accounts SET balance = balance - ? WHERE user_id = ? AND balance >= ?,把余额判断放进 SQL 条件里,比先 SELECT 查出来再在 Java 里比较更安全——数据库的行级锁能避免并发扣款时超扣。
3. 从 JSP 表单到 Servlet:一次充值请求的完整生命周期
3.1 用户点击"充值"后 Tomcat 内部发生了什么
当用户在浏览器里点下"充值"按钮,Tomcat 会按 URL 映射找到对应的 Servlet。假设表单提交到/adminBill,web.xml里配置了<servlet-mapping>,那么容器会把这个请求交给AdminBillServlet.class。
ServletRequest 对象携带着表单里的参数到达 Servlet,比如action=recharge、amount=100、userId=21。Tomcat 会为每次请求创建一个线程来处理,这也是为什么 Servlet 是单实例多线程工作模式——AdminBillServlet的成员变量如果被多个请求同时修改,会引发线程安全问题。我见过不止一个人把数据库连接直接写成 Servlet 成员变量,高并发下连接被多个线程抢用,最后报Connection is closed或者数据错乱,就是这个原因。
3.2 AdminBillServlet:用 action 参数区分操作类型
一个后台管理 Servlet 往往要处理列表查询、审核通过、审核拒绝、账户充值多个动作。项目里的AdminBillServlet通常用请求参数action来区分:
@WebServlet("/adminBill") public class AdminBillServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String action = request.getParameter("action"); if ("recharge".equals(action)) { doRecharge(request, response); } else if ("list".equals(action)) { doList(request, response); } else if ("audit".equals(action)) { doAudit(request, response); } else { response.sendError(HttpServletResponse.SC_BAD_REQUEST); } } }@WebServlet("/adminBill")是 Servlet 3.0 以后的注解式配置,省掉了 web.xml 里的<servlet>声明,适合这个量级的项目。doPost里先统一设置字符编码,因为 Tomcat 8 及以上版本默认按 UTF-8 解码 POST 请求,但旧版本或者手动修改过server.xml的情况需要显式声明。把大段的业务逻辑写在 doGet/doPost 里是新手常见问题,拆成doRecharge、doAudit这样的私有方法后,每个方法的职责一目了然,也方便后续加单元测试。
充值操作落到 Service 层时,完整的调用链是AdminBillServlet.doRecharge()→UserServiceImpl.updateBalance()→AccountDaoImpl.update()。表单里传的userId如果是普通 String,Service 层必须做一次数值校验,否则传abc进来会在Integer.parseInt抛 NumberFormatException。
3.3 JSP 页面上的表单与数据展示
对应到 JSP 源码,充值页面大概是这样的结构:
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <body> <h3>账户充值</h3> <form action="${pageContext.request.contextPath}/adminBill" method="post"> <input type="hidden" name="action" value="recharge"> 用户ID:<input type="text" name="userId"><br> 金额:<input type="text" name="amount"><br> <input type="submit" value="确认充值"> </form> </body> </html>${pageContext.request.contextPath}是 JSP EL 表达式,输出当前项目的上下文路径。它是 jsp 个人信息展示页面里经常用到的基本功——如果直接写死/jsp-campus-card这种硬编码路径,项目改名或者部署到别的 Tomcat 目录下,所有链接都会 404。表单里那个hidden的action字段,就是上一节 Servlet 里request.getParameter("action")的数据来源。
顺带说一个 jsp 编译 class 文件保存在哪里的问题。Tomcat 首次访问 jsp 页面时,会把 jsp 翻译成 .java 再编译成 .class,默认存放在$TOMCAT_HOME/work/Catalina/localhost/项目名/org/apache/jsp/下。如果改了 JSP 页面但刷新没生效,多半是 Tomcat 没有重新编译,删除 work 目录重启即可,这一步在mysql 安装配置教程和web项目配置tomcat后查看jsp编译后的java类这些需求里经常被一起问到。
3.4 GET 与 POST 请求的乱码处理差别
充值只改数据,必须用 POST。但订单查询、消费明细这类只读操作往往用 GET 方便分享链接。GET 请求的参数放在 URL 里,Tomcat 8 之前默认用 ISO-8859-1 解码 URL,所以中文参数会出现乱码。Tomcat 8 之后默认改成 UTF-8,但server.xml里的URIEncoding属性仍然可以直接配置。
安全起见,项目里可以加一个全局过滤器统一设置编码,而不是在每个 Servlet 里重复写request.setCharacterEncoding:
@WebFilter("/*") public class EncodingFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8"); chain.doFilter(request, response); } }这样不管 POST 参数还是转发到 JSP 后的输出,字符编码都统一成了 UTF-8,能省掉一多半中文乱码问题。filter 的chain.doFilter一定要放在设置编码之后,否则后续 Servlet 拿到的还是未解码的参数。
4. Tomcat 部署与 MySQL 联调:跑通一卡通系统的标准步骤
4.1 MySQL 数据库导入与账号配置
拿到项目压缩包后,数据库目录下通常有一个.sql文件,包含了建表语句和初始数据。命令行导入是最直接的方式:
mysql -uroot -p < school_card.sql如果你的 MySQL 是免安装版,记得先确保bin目录加入了系统 PATH,否则命令找不到。导入后用mysql -uroot -p登录,执行SHOW DATABASES;确认数据库存在,再执行USE 数据库名; SHOW TABLES;看看表是否齐全。
生产环境一般不建议直接用 root 账号连应用。单独建一个账号,只授予这个库的权限,更安全:
CREATE USER 'card_app'@'localhost' IDENTIFIED BY 'Card@2024'; GRANT SELECT, INSERT, UPDATE, DELETE ON school_card.* TO 'card_app'@'localhost'; FLUSH PRIVILEGES;GRANT后面精确到库名school_card.*,比给全局权限更克制。如果想用 Navicat for MySQL 连接,新建连接时填主机localhost、端口3306、用户名card_app即可。MySQL 8.0 默认的caching_sha2_password加密方式在旧版本 JDBC 驱动下连不上,如果报Public Key Retrieval is not allowed,可以在连接串加allowPublicKeyRetrieval=true,或者把用户改成mysql_native_password。
4.2 JDBC 连接参数修改
源码里 jdbc 配置一般在src下面的db.properties或.java常量里。改成你自己的环境:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/school_card?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=card_app jdbc.password=Card@2024characterEncoding=utf8解决中文写入乱码;serverTimezone=Asia/Shanghai是 MySQL 8.0 之后的必填项,否则驱动拿不到服务器时区直接报错;useSSL=false是本地开发禁用 SSL 加密,减少握手开销。如果你的mysql connector是 5.x 版本,驱动类名要写成com.mysql.jdbc.Driver,6.0 之后才改成com.mysql.cj.jdbc.Driver。把这个写错,Tomcat 一启动就会抛ClassNotFoundException。
4.3 把项目部署进 Tomcat
项目是普通动态 Web 工程,一般直接导出成 WAR 包,或者直接把整个项目文件夹拷贝到$TOMCAT_HOME/webapps/下。然后启动:
# Linux / macOS $TOMCAT_HOME/bin/startup.sh # Windows $TOMCAT_HOME\bin\startup.bat启动后访问http://localhost:8080/项目名/。注意 IDE(IDEA/Eclipse)里如果是 Artifact 部署,配置里Application context的值就是访问路径中的项目名。如果 404,先看 Tomcat 日志logs/catalina.out有没有报错,而不是急着改前端代码。
部署时 jsp 编译 class 文件保存在哪里的问题还会再碰到一次:Tomcat 启动后会在work/Catalina下生成编译产物,如果你更新了 JSP 文件但浏览器始终显示旧页面,清空work目录后重启是最快的解决办法。
4.4 常见报错与排查对照表
下面是部署过程中出现频率最高的六个报错及对应处理方式:
| 现象 | 原因 | 处理 |
|---|---|---|
ClassNotFoundException: com.mysql.jdbc.Driver | 驱动包缺失或驱动类名写错 | 把 mysql-connector jar 放进WEB-INF/lib;MySQL 8+ 用com.mysql.cj.jdbc.Driver |
Access denied for user 'card_app'@'localhost' | 账号密码错误或授权未生效 | 重跑 GRANT 语句并执行FLUSH PRIVILEGES |
Unknown database 'school_card' | 数据库没导入成功 | 执行SHOW DATABASES核对库名,重新导入 sql 文件 |
Cannot create PoolableConnectionFactory | 连接 URL 缺少 timezone 或密码错误 | 核对serverTimezone=Asia/Shanghai和 db.properties 密码 |
java.sql.SQLException: Lock wait timeout exceeded | 事务没提交导致行锁未释放 | 检查 Service 层是否漏了conn.commit(),或连接池是否耗尽 |
页面中文全是? | JSP 编码与数据库编码不一致 | JSP 加pageEncoding="UTF-8";数据库建表指定DEFAULT CHARSET=utf8mb4 |
关于 MySQL 8.0 与 5.7 的差异多说一句:如果你按mysql 安装配置教程装的是最新版 MySQL,密码默认用caching_sha2_password,而项目老,驱动是老版本mysql-connector-java:5.1.x,那会有认证方式不兼容的问题。要么换 8.x 驱动,要么把用户改成mysql_native_password,否则 Tomcat 启动时数据源初始化会卡在握手鉴权。
5. 进阶:给一卡通系统补上安全验证与分页查询
一卡通系统涉及现金充值、账户余额,简单跑通只是第一步。如果想让这个项目从"课程设计"上升到一个能演示给面试官看的程度,下面三个优化可以优先考虑,全部基于现有代码小改。
先看 SQL 注入问题。项目里如果用的是字符串拼接 SQL 而不是PreparedStatement,比如String sql = "SELECT * FROM users WHERE user_name='" + name + "'",攻击者传' OR '1'='1就能直接绕过密码查询。最直接的修复是把所有 DAO 层拼串改成?占位符 +ps.setString。替换完成后还可以做一个快速验证:在登录页面输入admin'--,如果返回登录失败而不是异常抛错,说明拼接问题已处理干净。
再看查询分页。以账单查询为例,SQL 拼接分页的通用写法是:
SELECT * FROM bills WHERE user_id = ? ORDER BY bill_time DESC LIMIT ?, ?; SELECT COUNT(*) FROM bills WHERE user_id = ?;第一条的LIMIT ?, ?两参数分别为偏移量和每页条数,比如每页 10 条,查第 3 页就是LIMIT 20, 10。第二条COUNT(*)用于计算总页数,然后封装成分页对象传给 JSP。注意OFFSET越大的时候查询会变慢,数据量过万可以改用"上一页最后一条记录的 bill_time 作为游标"的方式,但这对于毕设项目是过度设计,能讲清楚 LIMIT 边界即可。
另外可以在用户表加一个固定的status字段来支持账号锁定,每个尝试登录的 IP 记录失败次数,连续 5 次失败就暂时锁定 15 分钟。核心代码是用UPDATE users SET status = 0 WHERE card_no = ?,判断 15 分钟时间窗用created_at BETWEEN NOW() - INTERVAL 15 MINUTE AND NOW()。
最后是日志审计。一卡通扣费流水改动敏感,用 Filter 记录每个请求的处理耗时和访问路径:
@WebFilter("/adminBill") public class AccessLogFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; long start = System.currentTimeMillis(); chain.doFilter(req, resp); long cost = System.currentTimeMillis() - start; System.out.println(request.getRequestURI() + " 耗时 " + cost + " ms"); } }System.currentTimeMillis()在耗时统计上精度足够,如果以后要统计更多指标可以换System.nanoTime(),但一卡通管理后台的访问频率没有必要用高精度计时。这个AccessLogFilter只拦/adminBill,避免给静态资源也打印日志。真要发布上线,把 System.out 替换成 Log4j2 或 SLF4J 就行。
本文还有配套的精品资源,点击获取