简介:这是一份基于JSP、Java与MySQL的校园卡一卡通管理系统完整项目资源,既适合Java Web初学者理解真实业务场景,也可作为计算机专业课程设计或毕业设计的参考材料。资源整体5.6MB,共219个文件,包含28个Java源文件、26个JSP页面、29个Class编译文件、16个CSS样式、9个JS脚本、13个JAR依赖库,以及45个GIF演示动图和24张JPG界面截图;同时附带SQL数据库脚本,目录按源码、数据库、截图分层整理,便于开发时快速定位和学习。目前已有276人学习浏览。从内容预览可见,项目包含消费账单、用户管理、充值申请、管理员后台等核心业务类,覆盖了主要业务流程。结合源码、数据库表结构设计和页面交互,可以直观学习MVC分层、JDBC数据库操作、会话管理、权限校验等知识点;也可将数据库脚本导入MySQL并部署到Tomcat运行,适合反复阅读和二次改造,是掌握校园信息化系统开发流程的不错练手资源。
1. JSP校园卡一卡通管理系统:课程设计交付物背后的三层架构该怎么撑起来
拿到一个 JSP+JAVA+MYSQL 的校园卡一卡通管理系统压缩包,很多人的第一反应是「又是一套 Java Web 课设」,但拆开看会发现,这套系统麻雀虽小,五脏俱全:卡片开户、充值、消费、挂失、流水查询,每一个功能都落在 JSP 页面、Servlet 控制器和 MySQL 事务这三层上。它适合两类人研究:一类是要用这份工程完成课程设计交付的学生,另一类是要接手或改造老旧 JSP 项目的运维工程师。前者需要知道表结构和业务代码怎么对得上,后者需要知道连接池、编码和事务边界在哪里改。下面按「建表 → 业务 → 页面 → 部署」的顺序,把这套系统的完整骨架讲清楚,重点放在那些参数和坑上。
2. 数据库先行:校园卡一卡通的账户表、卡片表和流水表怎么拆
2.1 一卡通的核心是「卡账户分离」还是「卡户一体」
校园卡系统的本质不是管卡,而是管「卡对应的钱」。设计数据库时首先要想清楚一个问题:卡片信息和账户信息是放一张表还是拆两张表。
常见做法是拆两张表:一张 card 表存物理卡片信息(卡号、芯片编号、状态、绑定学生 ID),一张 account 表存资金账户信息(余额、累计充值、累计消费、账户状态)。这么拆的理由有两个:一是卡可能补办,补办时换卡不换账户,资金记录不断档;二是未来如果支持手机二维码刷码,二维码凭证可以直接复用 account 表,而不必依赖物理卡。
-- 卡片信息表 CREATE TABLE card ( card_id INT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL COMMENT '卡面号,例如20240001', chip_no VARCHAR(32) COMMENT '芯片序列号,可为空', student_id VARCHAR(20) NOT NULL COMMENT '学号', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 2挂失 3注销', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_card_no (card_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 资金账户表 CREATE TABLE account ( account_id INT PRIMARY KEY AUTO_INCREMENT, card_id INT NOT NULL, balance DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '当前余额,单位元', total_recharge DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '累计充值', total_consume DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '累计消费', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', FOREIGN KEY (card_id) REFERENCES card(card_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段建表 SQL 里有几个值得注意的参数:DECIMAL(10,2) 表示余额最大 99999999.99 元,对校园卡场景绰绰有余,而且 DECIMAL 是定点数,不会像 FLOAT、DOUBLE 那样出现 0.1+0.2 的二进制精度误差;card_no 上建唯一索引,是为了防止并发下同号开卡;version 字段是后面做乐观锁用的,先留好。
2.1.1 交易流水表:一天几十万条查询怎么不卡
除了卡片和账户,还必须有一张流水表。充值、消费、退款、挂失补卡,每一次资金变动都要落流水,否则余额对不上账时无从查起。
CREATE TABLE transaction_log ( trans_id BIGINT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL, trans_type TINYINT NOT NULL COMMENT '1充值 2消费 3退款 4补卡结转', amount DECIMAL(10,2) NOT NULL, balance_after DECIMAL(10,2) NOT NULL COMMENT '交易后余额,快照', op_channel VARCHAR(10) DEFAULT 'window' COMMENT 'window/pos/app', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_card_time (card_no, create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个初学者常犯的错误:流水表主键用 INT。校园卡系统一天的流水量可能超过十万条,流水表是只增不改的,几年累计下来 INT 会逼近上限,直接上 BIGINT 一劳永逸。联合索引 idx_card_time 支撑「查某张卡某段时间的交易记录」这类高频查询,单独按 card_no 查也能命中最左前缀。
2.2 一卡通业务的几个关键字段和状态机
卡片状态用 TINYINT 而不是字符串,查询快、存储省,但代码可读性要靠常量类来保证。常规状态设计如下:
| 状态值 | 卡片状态 | 账户状态 | 可执行操作 |
|---|---|---|---|
| 1 | 正常 | 正常 | 充值、消费、挂失 |
| 2 | 挂失 | 冻结 | 解挂、补卡 |
| 3 | 注销 | 销户 | 仅退款 |
| 4 | 已补卡 | 正常(新卡) | 消费、充值 |
状态字段的变更必须走服务层统一方法,不要在每个 JSP 页面里直接 UPDATE。常见做法是写一个 CardStatusService,所有状态变更都在这里做合法性校验,比如「挂失状态不能再次消费」这类规则集中在一个文件里,后面要加「挂失 3 天后自动冻结」之类的规则也只改一处。
2.3 MySQL 事务:余额扣减为什么不能先查后改
校园卡系统里最容易出事故的代码就是消费扣款。新手写法是「先 SELECT 余额,判断够不够,再 UPDATE 扣款」,这在单用户测试时没问题,一旦两个窗口同时消费就会超扣。MySQL 的 InnoDB 默认隔离级别是可重复读(REPEATABLE READ),两个连接同时读到余额 50 元,各自判断够用,各自扣 20,结果账户变成 30 而不是 10。
解决办法是把扣款做成原子操作:
UPDATE account SET balance = balance - 20, version = version + 1, total_consume = total_consume + 20 WHERE card_id = 123 AND balance >= 20 AND version = 5;这条 UPDATE 单条语句原子执行,WHERE 里的 balance >= 20 是余额保护,version = 5 是乐观锁比对。如果影响行数为 0,代码就判断这次扣款失败,提示余额不足或数据已变更。相比 SELECT FOR UPDATE 的悲观锁,这种写法在低冲突场景下性能更好,也不会因为事务没提交而锁住整行。
3. JSP+Servlet 处理一卡通核心业务:充值、消费、挂失的请求流转
3.1 从 index.jsp 表单到 MySQL 的完整调用链
JSP 项目的经典调用链是:JSP 页面收集参数 → form 提交到 Servlet → Servlet 调 Service 层 → Service 里写 JDBC 或 MyBatis → 数据库返回 → Servlet 转发或重定向回页面。以充值功能为例,JSP 里通常是这样的:
<form action="${pageContext.request.contextPath}/recharge" method="post"> <input type="hidden" name="cardNo" value="${sessionScope.cardNo}" /> 金额:<input type="number" name="amount" min="1" max="500" step="0.01" required /> <button type="submit">确认充值</button> </form>对应的 Servlet 在 doPost 里先收参数、做校验,再调 Service:
@WebServlet("/recharge") public class RechargeServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String cardNo = req.getParameter("cardNo"); BigDecimal amount = new BigDecimal(req.getParameter("amount")); // 业务校验:金额必须大于0且不超过单笔上限 if (amount.compareTo(BigDecimal.ZERO) <= 0 || amount.compareTo(new BigDecimal("500")) > 0) { req.setAttribute("error", "充值金额必须在0.01到500元之间"); req.getRequestDispatcher("/recharge.jsp").forward(req, resp); return; } boolean ok = CardService.getInstance().recharge(cardNo, amount); if (ok) { resp.sendRedirect(req.getContextPath() + "/balance.jsp?cardNo=" + cardNo); } else { req.setAttribute("error", "充值失败,请检查卡号或卡片状态"); req.getRequestDispatcher("/recharge.jsp").forward(req, resp); } } }这个 Servlet 里有三个细节值得说明:req.setCharacterEncoding("UTF-8") 必须在读取第一个参数之前调用,否则中文参数会乱码;金额从 String 转用 new BigDecimal 而不是 Double.valueOf,避免浮点精度问题;充值成功后用 sendRedirect 重定向,防止用户按 F5 重复提交表单导致二次入账。
3.2 Service 层:事务边界和连接管理
Service 层是 JSP 项目里最容易写坏的一层。常见误用是每个 DAO 方法各自开一个 Connection,充值事务里先插入流水、再更新余额,两个连接各干各的,中间一旦崩溃,钱扣了流水没记。正确做法是事务边界放在 Service 方法上,单个 Connection 贯穿整个业务:
public boolean recharge(String cardNo, BigDecimal amount) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关掉自动提交,开启事务 // 1. 更新余额 int rows = accountDao.recharge(conn, cardNo, amount); if (rows == 0) { conn.rollback(); return false; } // 2. 写流水 transactionDao.insert(conn, cardNo, 1, amount); conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } log.error("recharge failed", e); return false; } finally { DBUtil.close(conn); } }Connection 由 DBUtil 统一管理,每次从连接池取,用完归还。这里强调一点:rollback 和 close 必须分开处理,rollback 抛出的异常要吞掉,不能让回滚失败掩盖原始业务异常。事务里的更新余额和插入流水必须同时成功或同时失败,这是一卡通系统对账的基本保障。
3.3 挂失与解挂:状态校验放在哪一层
挂失的核心逻辑是「把卡状态从正常改成挂失,并同步冻结账户」,这个操作也必须走事务,因为一旦改了卡状态而账户没冻结,这张卡在状态切换的间隙可能已经被消费。状态变更的 SQL 建议写成带条件更新:
UPDATE card SET status = 2 WHERE card_no = '20240001' AND status = 1; UPDATE account SET status = 2 WHERE card_id = (SELECT card_id FROM card WHERE card_no = '20240001');第一句更新影响行数为 0,说明卡不在「正常」状态,可能是已挂失或已注销,此时应当直接返回「该卡当前状态不可挂失」,而不是继续执行账户冻结。把状态前置条件写进 WHERE,比先查再判省一次往返,也避免了两条语句之间的并发缝隙。
解挂时要注意:补卡后旧卡状态变成 3(注销),新卡状态是 1,新卡复用原账户余额。这一步在代码里要跟结转流水一起做,转账金额等于旧卡余额,trans_type 写 4(补卡结转),保证对账记录齐全。
4. 页面层:JSP 个人信息展示页面、EL 表达式与分页查询的参数设计
4.1 用 EL + JSTL 渲染一卡通用户信息,避免在 JSP 里写 Java 代码
JSP 页面的最佳实践是页面里不写<% %>Java 脚本,全部用 EL 表达式和 JSTL 标签。以个人信息展示页面为例,Servlet 往 request 里塞一个 userInfo 对象后,页面这样渲染:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <div class="card-info"> <p>学号:${userInfo.studentId}</p> <p>姓名:${userInfo.studentName}</p> <p>卡号:${userInfo.cardNo}</p> <p>余额:<fmt:formatNumber value="${userInfo.balance}" type="currency" currencySymbol="¥"/></p> <p>卡片状态: <c:choose> <c:when test="${userInfo.cardStatus == 1}">正常</c:when> <c:when test="${userInfo.cardStatus == 2}">挂失</c:when> <c:otherwise>注销</c:otherwise> </c:choose> </p> </div>userInfo 是 Servlet 用 request.setAttribute("userInfo", vo) 写入的,EL 表达式 ${userInfo.studentId} 会自动调用 getter。fmt:formatNumber 处理金额格式化,避免在 Java 里拼字符串。c:choose 做状态值到文案的映射,比在 Servlet 里预先转好字符串更直白,日后加状态只要改页面一处。
4.2 交易记录分页:pageNo 和 pageSize 的取值与 SQL 写法
一卡通系统的流水查询页是典型的分页场景。分页参数由前端传入 pageNo(页码,从 1 开始)和 pageSize(每页条数),服务端要做两件事:查总数算总页数、查当页数据。分页查询 SQL 用 LIMIT 语句:
SELECT trans_id, card_no, trans_type, amount, balance_after, create_time FROM transaction_log WHERE card_no = '20240001' ORDER BY trans_id DESC LIMIT ?, ?;两个占位符分别对应 offset 和 size,offset = (pageNo - 1) * pageSize。参数校验很关键:pageSize 不能超过 100,否则有人传 pageNo=1、pageSize=9999999 就能一次拉走全表;pageNo 小于 1 时强制改成 1。查总数要用 SELECT COUNT(*) 单独执行,不要用 SQL_CALC_FOUND_ROWS,这个语法在 MySQL 8.0 已废弃。
一个最常见的坑是 ORDER BY 用 create_time,而 create_time 默认精度是秒,同一秒内的多条记录排序不稳定,翻页时会出现记录重复或遗漏。解决办法是加主键作为次级排序:ORDER BY create_time DESC, trans_id DESC。主键自增天然反映插入顺序,与时间排序方向一致,稳定且能用到索引。
4.3 屏蔽 JSP 离开页面提示:onbeforeunload 的正确打开方式
「屏蔽 JSP 离开页面提示」说的是浏览器 onbeforeunload 事件。这个事件本意是防止表单内容没保存就离开,但在 JSP 课设里常被误用成「页面跳转时弹窗确认」,反而在提交成功跳转时也弹窗,非常影响体验。
正确的做法是只在「有未保存修改」时绑定该事件,提交成功后移除:
let formDirty = false; document.querySelector('#rechargeForm').addEventListener('input', () => { formDirty = true; }); window.addEventListener('beforeunload', (e) => { if (!formDirty) return; // 没有改动,直接放行,不弹提示 e.preventDefault(); e.returnValue = ''; // 兼容旧版浏览器 }); // 表单提交成功后置回 false,跳转时不再提示 document.querySelector('#rechargeForm').addEventListener('submit', () => { formDirty = false; });关键点是 e.preventDefault() 加 returnValue 双保险,Chrome 71 之后只调用 preventDefault 不设 returnValue,弹窗不会出现。很多 JSP 模板把 onbeforeunload 直接写进 body 标签,导致所有页面跳转都弹提示,这种写法要避免。
4.4 结合 JSONArray 的常见需求:后台返回 JSON 给页面局部刷新
现代 JSP 项目里,页面局部刷新是标配。后台返回 JSON,JSP 页面用 JavaScript 解析渲染,避免整页刷新。国内项目里用 fastjson 较多,引入和使用的写法是:
import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; JSONArray arr = new JSONArray(); JSONObject item = new JSONObject(); item.put("cardNo", "20240001"); item.put("balance", "123.45"); arr.add(item); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write(arr.toJSONString());前端拿到 JSON 后用 fetch 或 axios 请求接口渲染到表格中。注意响应头 charset=UTF-8 必须写,否则浏览器默认按 ISO-8859-1 解析中文会乱码。若项目内没引入 fastjson 的 jar,用 JDK 自带的 org.json 或 Jackson 也可以,核心是响应头别漏。
5. 部署排错与发布:从 war 包到 jsp 编译 class 的排查路径
5.1 Tomcat 下 JSP 编译后的 class 文件保存在哪里
接手 JSP 项目遇到 500 错误时,最该看的就是 JSP 翻译后的 Servlet 源码。JSP 首次被访问时由 Tomcat 的 Jasper 引擎编译成 Java 源文件再编译成 class,存放在work/Catalina/localhost/项目名/org/apache/jsp/,比如 index.jsp 对应 index_jsp.java 和 index_jsp.class。JSP 改了不生效时,删掉 work 目录下项目文件夹再重启 Tomcat 即可强制重新编译。
5.2 rar 工程部署到 Tomcat 的三个必查配置
拿到 rar 压缩包解压后,先查下面三项能省掉大部分排错时间。编码问题最隐蔽:MySQL 连接串的 characterEncoding=utf8 只管传输层,JSP 的 pageEncoding 和表的 charset 三者必须一致,否则中文全变问号;命令行启动 Tomcat 报找不到 javac 时,检查 JAVA_HOME 和 PATH,JDK 版本切换要两个变量一起改。
| 检查项 | 位置 | 典型错误 |
|---|---|---|
| MySQL 连接串 | src/jdbc.properties 或 DBUtil.java | localhost:3306 与实际端口不符 |
| Java 编译版本 | 项目构建配置 | JDK8 编译的 class 跑在 JDK17 上报 UnsupportedClassVersionError |
| 文件编码 | JSP 的 pageEncoding | GBK 工程配 UTF-8 连接串,中文乱码 |
5.3 定时备份:mysql 自动备份 bat 脚本与验证技巧
一卡通系统最怕数据库宕机丢数据。Windows 下给 MySQL 做定时备份,批处理脚本配合任务计划程序是最简单的方案:
@echo off set BACKUP_DIR=D:\backup\mysql set MYSQL_HOME=C:\Program Files\MySQL\MySQL Server 8.0\bin set DB_USER=root set DB_PASS=123456 set DB_NAME=campus_card if not exist %BACKUP_DIR% mkdir %BACKUP_DIR% set FILENAME=%DB_NAME%_%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%.sql set FILENAME=%FILENAME: =0% "%MYSQL_HOME%\mysqldump.exe" -u%DB_USER% -p%DB_PASS% --default-character-set=utf8mb4 %DB_NAME% > "%BACKUP_DIR%\%FILENAME%" forfiles /p "%BACKUP_DIR%" /d -30 /c "cmd /c del @path" 2>nul%date% 取到的小时可能带前导空格(比如「 9」),不替换掉文件名就带空格,forfiles 清理时路径解析会失败;mysqldump 加 --default-character-set=utf8mb4 防止备份文件中文乱码。部署完成后用mysqldump -u root -p --no-data campus_card验证所有表能正常导出,再刷新个人信息展示页面,结合 work 目录 class 文件的时间戳判断 JSP 是否被重新编译,五分钟内能定位大多数部署故障。
本文还有配套的精品资源,点击获取