嘟嘟校园一卡通系统:基于JavaWeb+MySQL的JSP+Servlet完整实战复盘
先说结论:这年头还在用JSP+Servlet做项目,很多人第一反应是"过时了"。但我把这套"嘟嘟校园一卡通系统"完整做完之后,反而觉得它比想象中更能打。
如果你是要交课程设计、毕业设计,或者想彻底弄懂JavaWeb最底层的那套请求-响应-数据库交互机制,这个项目就是一个非常合适的练手样本。它不是那种花里胡哨的微服务项目,没有Spring Boot帮你把一切都封装好,所有东西都摆在明面上:JSP怎么写页面,Servlet怎么接请求,JDBC怎么连MySQL,Ajax怎么局部刷新。把这一套跑通,你就真正理解了Java Web的地基。
这套系统我定位为"校园一卡通管理平台",核心功能覆盖了卡片管理、学生信息管理、充值消费记录、异常挂失、统计报表这些日常运维场景。前端用JSP+CSS+JavaScript+jQuery+Ajax,后端用Java+Servlet,数据库用MySQL,典型的三层结构,没有引入任何重量级框架。
本文会从整体架构设计、数据库建模、Servlet核心逻辑、JSP页面交互到实际部署踩坑,完整走一遍。你在其他博客里看到的可能是零散的代码片段,我这里直接给你一套能跑、能交、能扩展的完整思路。
1. 项目定位与整体架构:为什么选JSP+Servlet这套组合
在开始写代码之前,先把技术选型的逻辑讲清楚。
1.1 这套技术栈解决了什么问题
很多人在做校园一卡通这类管理系统时,会陷入一个选择困难:用Spring Boot几分钟就能搭起来,为什么要选JSP+Servlet这种"老古董"?
我的理由有三个:
第一,学习价值完全不同。 Spring Boot帮你把Tomcat内嵌、DispatcherServlet配置、组件扫描全部藏起来了,你写一个@RestController就好像会了Web开发,但实际上请求是怎么进到Java代码的、Session是怎么管理的、页面是怎么渲染出来的,你完全没有概念。而JSP+Servlet这套组合,每一个环节都是裸的,你在浏览器里点一下按钮,请求经过web.xml的映射、进入Servlet的doGet/doPost、调用DAO层、返回JSP页面,整个过程清清楚楚。
第二,课程设计和毕业设计的硬性要求。 很多学校的大作业明确指定要用JSP+Servlet+MySQL,不允许用Spring Boot。这套系统完美匹配这种需求。
第三,部署成本低。 一个WAR包扔进Tomcat的webapps目录就能跑,不依赖Maven仓库里那堆复杂依赖,在配置一般的电脑上也能流畅开发调试。
1.2 系统的三层架构拆解
这套嘟嘟校园一卡通系统采用经典的MVC三层架构:
- View层(视图层):JSP页面 + CSS + JavaScript + jQuery + Ajax。负责页面展示和用户交互。
- Controller层(控制层):Servlet。负责接收请求、调用业务逻辑、控制页面跳转。
- Model层(模型层):JavaBean + DAO + MySQL数据库。负责业务数据封装和持久化操作。
具体到代码结构上,我习惯把包结构按"分层 + 功能模块"双维度组织:
code复制com.duducard
├── entity // 实体类:User, Student, Card, RechargeRecord, ConsumeRecord等
├── dao // 数据访问层:接口 + 实现类
├── service // 业务逻辑层:处理充值、消费、挂失等业务规则
├── servlet // 控制层:LoginServlet, CardServlet, StudentServlet等
├── filter // 过滤器:登录验证、编码处理
└── util // 工具类:DBUtil数据库连接、DateUtil时间处理
WebContent目录下对应:
code复制WebContent
├── index.jsp // 登录页
├── admin/ // 管理员模块页面
├── student/ // 学生端功能页面
├── css/ // 样式文件
├── js/ // jQuery、Ajax脚本
├── images/ // 静态资源
└── WEB-INF/
├── web.xml // Servlet映射、欢迎页面配置
└── lib/ // 项目依赖JAR包
这种结构的好处是定位问题非常快:页面样式出了问题去JSP找,业务逻辑出了问题去Servlet找,数据不对了去DAO和SQL里找,不用像某些框架那样查一堆自动生成的代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计:一卡通的表结构到底该怎么建
数据库是这个系统的地基。校园一卡通的核心是"卡",所有业务都围绕卡片的状态和资金流动展开。我在设计表结构时,重点考虑了完整性约束和查询效率。
2.1 核心表结构详解
我建了6张核心表,每张表都对应一个明确的功能域。
用户表(t_user)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PRIMARY KEY AUTO_INCREMENT | 自增主键 |
| username | VARCHAR(50) UNIQUE | 登录用户名 |
| password | VARCHAR(100) | 登录密码(MD5加密存储) |
| role | VARCHAR(20) | 角色:admin/student |
| is_active | TINYINT | 是否启用:1启用,0禁用 |
学生信息表(t_student)
| 字段名 | 类型 | 说明 |
|---|---|---|
| student_no | VARCHAR(20) PRIMARY KEY | 学号,业务主键 |
| name | VARCHAR(50) | 姓名 |
| department | VARCHAR(50) | 院系 |
| major | VARCHAR(50) | 专业 |
| phone | VARCHAR(20) | 联系电话 |
| card_id | INT | 关联一卡通ID |
一卡通表(t_card)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PRIMARY KEY AUTO_INCREMENT | 卡ID |
| card_no | VARCHAR(30) UNIQUE | 卡号 |
| student_no | VARCHAR(20) | 持卡人学号 |
| balance | DECIMAL(10,2) | 卡内余额 |
| status | TINYINT | 卡片状态:1正常,2挂失,3注销 |
| create_time | DATETIME | 办卡时间 |
充值记录表(t_recharge)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PRIMARY KEY AUTO_INCREMENT | 流水ID |
| card_id | INT | 卡ID |
| amount | DECIMAL(10,2) | 充值金额 |
| recharge_time | DATETIME | 充值时间 |
| operator | VARCHAR(50) | 操作人 |
消费记录表(t_consume)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PRIMARY KEY AUTO_INCREMENT | 流水ID |
| card_id | INT | 卡ID |
| amount | DECIMAL(10,2) | 消费金额 |
| consume_time | DATETIME | 消费时间 |
| place | VARCHAR(100) | 消费地点(食堂、超市、图书馆等) |
挂失记录表(t_loss_report)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT PRIMARY KEY AUTO_INCREMENT | 记录ID |
| card_id | INT | 卡ID |
| report_time | DATETIME | 挂失时间 |
| status | TINYINT | 状态:1待处理,2已补卡 |
2.2 建表SQL和关键设计决策
下面是核心建表SQL,我直接给出可运行的版本:
sql复制CREATE DATABASE IF NOT EXISTS dudu_card DEFAULT CHARSET utf8mb4;
USE dudu_card;
CREATE TABLE t_user (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
role VARCHAR(20) DEFAULT 'student',
is_active TINYINT DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE t_student (
student_no VARCHAR(20) PRIMARY KEY,
name VARCHAR(50) NOT NULL,
department VARCHAR(50),
major VARCHAR(50),
phone VARCHAR(20),
card_id INT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE t_card (
id INT PRIMARY KEY AUTO_INCREMENT,
card_no VARCHAR(30) NOT NULL UNIQUE,
student_no VARCHAR(20),
balance DECIMAL(10,2) DEFAULT 0.00,
status TINYINT DEFAULT 1,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 其余表结构类似,省略
有些设计点需要特别说明:
- 金额字段一律用DECIMAL(10,2)。不要用FLOAT或DOUBLE存钱,否则会出现0.1+0.2=0.30000000000000004这种精度问题,账单对不上就麻烦了。
- 用逻辑删除代替物理删除。比如卡片注销,我设置status=3,而不是把记录删掉。这是为了保留历史流水,方便日后审计。
- card_no独立设置UNIQUE约束。卡号是业务唯一标识,不能出现重复。
- 索引策略:在recharge_time、consume_time上建索引,因为报表查询经常按时间范围过滤;在card_id上建索引,因为交易流水表都是按卡ID关联查询。
2.3 初始化数据的坑
建完表之后,一定要先插入一些测试数据。很多同学直接建完空表就开始写代码,结果页面列表永远空白,还不确定是SQL错了还是页面渲染错了。我在第一次测试时插入了一个管理员账号和几个学生的数据:
sql复制INSERT INTO t_user (username, password, role) VALUES
('admin', MD5('admin123'), 'admin'),
('2021001', MD5('123456'), 'student');
INSERT INTO t_student (student_no, name, department, major, phone) VALUES
('2021001', '张三', '信息工程学院', '计算机科学与技术', '13800000001');
INSERT INTO t_card (card_no, student_no, balance, status) VALUES
('C2021001', '2021001', 200.00, 1);
这里有个小坑:MySQL的MD5()函数返回32位十六进制字符串,所以在Java里校验密码时,也要对用户输入的密码做MD5,再转成32位小写字符串和数据库里的值比较,不要直接用明文比对。
3. Servlet核心逻辑:请求怎么进来、参数怎么处理、事务怎么保证
Servlet是这套系统的"交通枢纽"。所有页面请求、Ajax请求最终都要经过Servlet分发到对应的业务处理逻辑。这一节会把最核心的几个Servlet讲透。
3.1 登录与权限控制:Filter拦一切
登录功能是每个系统都有的,但很多初学者容易忽略权限控制。我写了一个登录过滤器LoginFilter,对所有请求进行拦截:
java复制@WebFilter("/*")
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(".css") || uri.endsWith(".js")
|| uri.endsWith(".jpg") || uri.endsWith(".png") || uri.contains("LoginServlet")) {
chain.doFilter(request, response);
return;
}
// 检查Session
HttpSession session = req.getSession(false);
if (session != null && session.getAttribute("loginUser") != null) {
chain.doFilter(request, response);
} else {
// 检测到Ajax请求时返回状态码,让前端跳转登录页
if ("XMLHttpRequest".equals(req.getHeader("X-Requested-With"))) {
resp.setStatus(401);
} else {
resp.sendRedirect(req.getContextPath() + "/login.jsp");
}
}
}
}
这里有两个容易踩的坑:
- 静态资源放行。如果你忘了放行
.css、.js,浏览器打开登录页后会一直加载不出来样式,控制台报404。因为Filter默认拦截所有请求,包括静态资源。 - Ajax请求不能重定向。普通页面请求可以直接sendRedirect(login.jsp),但Ajax请求如果收到302重定向,浏览器会直接跟随跳转,把登录页的HTML塞回你的Ajax回调里,导致前端无法判断"到底是不是登录过期"。所以我在Filter里判断了
X-Requested-With头,返回401状态码,前端jQuery的error回调里统一处理。
3.2 充值、消费的并发与事务处理
一卡通系统里最重要的业务逻辑就是充值和消费。充值需要更新卡余额并插入一条充值记录,这两个操作必须在一个事务里完成,否则会出现"钱加了但流水没了"或者"流水有了但余额没变"的数据不一致问题。
我来看一下充值Servlet的核心代码:
java复制public class RechargeServlet extends HttpServlet {
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
int cardId = Integer.parseInt(request.getParameter("cardId"));
BigDecimal amount = new BigDecimal(request.getParameter("amount"));
// 1. 开启事务
Connection conn = null;
PreparedStatement ps1 = null;
PreparedStatement ps2 = null;
try {
conn = DBUtil.getConnection();
conn.setAutoCommit(false); // 关闭自动提交
// 2. 更新余额(使用FOR UPDATE锁行,避免并发问题)
String sql1 = "UPDATE t_card SET balance = balance + ? WHERE id = ? AND status = 1";
ps1 = conn.prepareStatement(sql1);
ps1.setBigDecimal(1, amount);
ps1.setInt(2, cardId);
int rows = ps1.executeUpdate();
if (rows == 0) {
throw new RuntimeException("卡片不存在或已挂失");
}
// 3. 插入充值流水
String sql2 = "INSERT INTO t_recharge (card_id, amount, operator) VALUES (?, ?, ?)";
ps2 = conn.prepareStatement(sql2);
ps2.setInt(1, cardId);
ps2.setBigDecimal(2, amount);
ps2.setString(3, (String) request.getSession().getAttribute("username"));
ps2.executeUpdate();
// 4. 提交事务
conn.commit();
response.getWriter().write("{\"code\":200,\"message\":\"充值成功\"}");
} catch (Exception e) {
if (conn != null) {
try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); }
}
response.getWriter().write("{\"code\":500,\"message\":\"充值失败:" + e.getMessage() + "\"}");
} finally {
// 关闭资源
DBUtil.close(conn, ps1, ps2);
}
}
}
关于事务和并发,有几个关键经验:
conn.setAutoCommit(false)必须写在所有SQL执行之前,否则每条SQL执行完就自动提交了,事务形同虚设。balance = balance + ?这种SQL写法在并发场景下比先SELECT再UPDATE更安全。如果你先查余额再加钱,两个请求同时查到200元,然后分别改成300和250,最后一次写入会覆盖前一次的结果,白白丢了50元。直接使用数据库的原子更新可以避免这个问题。- 查询余额时要加条件
AND status = 1,挂失的卡不能充值消费,这个业务规则要卡死在SQL层面,而不是只在Java代码里判断。
3.3 消费操作的余额校验
消费和充值类似,但多了一个前置校验:余额必须大于消费金额。我把消费逻辑抽成一个Service方法,方便多个Servlet复用:
java复制public boolean consume(int cardId, BigDecimal amount, String place) {
Connection conn = null;
try {
conn = DBUtil.getConnection();
conn.setAutoCommit(false);
String sql = "UPDATE t_card SET balance = balance - ? WHERE id = ? AND status = 1 AND balance >= ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setBigDecimal(1, amount);
ps.setInt(2, cardId);
ps.setBigDecimal(3, amount);
int rows = ps.executeUpdate();
if (rows == 0) {
conn.rollback();
return false; // 余额不足或卡片状态异常
}
String insertSql = "INSERT INTO t_consume (card_id, amount, place) VALUES (?, ?, ?)";
PreparedStatement ps2 = conn.prepareStatement(insertSql);
ps2.setInt(1, cardId);
ps2.setBigDecimal(2, amount);
ps2.setString(3, place);
ps2.executeUpdate();
conn.commit();
return true;
} catch (SQLException e) {
e.printStackTrace();
try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); }
return false;
} finally {
DBUtil.close(conn);
}
}
核心要点是把余额校验和扣款放在同一条UPDATE语句中,通过balance >= ?条件保证原子性,避免查询与更新之间的时间差导致的超扣问题。这个写法虽然简单,但确实安全。
4. 前端交互与Ajax实战:JSP页面到底怎么和Servlet配合
很多初学者写JSP时容易陷入一个误区,在JSP里写大量的Java代码用<% %>嵌套循环。这种方式虽然能跑,但页面维护起来非常痛苦。我的做法是:JSP只负责数据展示,交互逻辑全部用jQuery+Ajax实现。
4.1 JSP页面中的数据展示
JSP页面里我主要使用JSTL表达式和EL表达式来展示数据。在Servlet中把查询结果放进request作用域:
java复制List<CardVO> cardList = cardService.queryAllCards();
request.setAttribute("cardList", cardList);
request.getRequestDispatcher("/admin/card_list.jsp").forward(request, response);
在JSP中用forEach遍历:
jsp复制<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<table class="table table-bordered" id="cardTable">
<thead>
<tr>
<th>卡号</th>
<th>持卡人</th>
<th>余额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<c:forEach items="${cardList}" var="card">
<tr>
<td>${card.cardNo}</td>
<td>${card.studentName}</td>
<td>${card.balance}</td>
<td>
<c:choose>
<c:when test="${card.status == 1}">正常</c:when>
<c:when test="${card.status == 2}">挂失</c:when>
<c:otherwise>注销</c:otherwise>
</c:choose>
</td>
<td>
<button class="btn btn-primary btn-sm rechargeBtn" data-id="${card.id}" data-no="${card.cardNo}">充值</button>
<button class="btn btn-warning btn-sm freezeBtn" data-id="${card.id}">挂失</button>
</td>
</tr>
</c:forEach>
</tbody>
</table>
注意几个经验:
- 使用
${card.studentName}这种VO字段。我的CardVO是从Card实体和Student表联查出来的一个组合对象,页面直接取属性,不用在JSP里写多余逻辑。 - 不要用
<%= %>输出Java方法返回值。全部用EL表达式替代,代码更干净。 - 按钮上的
data-id属性是给前端Ajax用的。点击按钮时从data属性里取ID,直接发给后端。
4.2 Ajax请求如何处理JSON数据
为了配合Ajax,我给Servlet增加了返回JSON的能力。以充值操作为例,前端按钮点击后发起异步请求:
javascript复制$(document).on('click', '.rechargeBtn', function() {
var cardId = $(this).data('id');
var amount = prompt('请输入充值金额:');
if (amount === null || amount === '') {
return;
}
$.ajax({
url: 'RechargeServlet',
type: 'POST',
data: {
cardId: cardId,
amount: amount
},
dataType: 'json',
success: function(data) {
if (data.code === 200) {
alert(data.message);
// 局部刷新余额列
refreshCardList();
} else {
alert(data.message);
}
},
error: function(xhr) {
if (xhr.status === 401) {
window.location.href = 'login.jsp';
} else {
alert('网络异常,请稍后重试');
}
}
});
});
对应地,Servlet中返回JSON的行格式是:
java复制response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":200,\"message\":\"充值成功\"}");
如果数据量较大或者需要返回列表,我会借助Gson库直接把对象转成JSON,避免手写字符串。Gson的引入只需要把gson-2.8.9.jar扔进WEB-INF/lib目录即可。
关于Ajax和Servlet交互,有几个常见问题:
- 中文乱码:前端
.ajax中可以不设置contentType,但Servlet里request.setCharacterEncoding("UTF-8")必须放在读取参数之前。同时MySQL连接串里加上characterEncoding=utf8。这三处任何一个漏了都会乱码。 - 返回JSON的MIME类型:很多人返回JSON时忘记设置
response.setContentType("application/json;charset=UTF-8"),结果前端拿到的responseText是一段JSON字符串,但jQuery的dataType:'json'却解析失败,因为响应头是text/html。这个坑我踩过好几次。 - Ajax请求失败要看error回调:不要把所有处理都放在success里,HTTP状态码401、500时要走error分支,否则用户看不到任何提示。
4.3 表单提交与页面跳转的Servlet写法
除了Ajax,系统里很多操作还是用传统表单提交,然后Servlet转发或重定向。以新增学生为例:
java复制protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
String studentNo = request.getParameter("studentNo");
String name = request.getParameter("name");
String department = request.getParameter("department");
String major = request.getParameter("major");
String phone = request.getParameter("phone");
Student student = new Student();
student.setStudentNo(studentNo);
student.setName(name);
student.setDepartment(department);
student.setMajor(major);
student.setPhone(phone);
boolean result = studentService.addStudent(student);
if (result) {
response.sendRedirect("StudentServlet?action=list");
} else {
request.setAttribute("errorMsg", "学号已存在,添加失败");
request.getRequestDispatcher("/admin/student_add.jsp").forward(request, response);
}
}
这里有个细节值得强调:添加成功用sendRedirect,添加失败用forward。为什么?因为如果添加成功后还用forward转发到列表页,用户刷新浏览器时表单数据会再次提交,造成重复插入。而sendRedirect完成了一次新的请求,刷新不会重复提交。失败时需要保留表单数据和错误信息给用户修改,所以用forward。
5. 那些容易翻车的配置细节:web.xml、数据库连接、JAR包管理
这一节是实战中踩坑最多的部分,很多项目代码写得没问题,但就是跑不起来,十有八九是环境配置问题。
5.1 web.xml中的Servlet映射
虽然Servlet 3.0以上支持@WebServlet注解,但很多课程设计项目还是习惯用web.xml配置。如果两者混用,有时会因为映射冲突导致404,排查起来很痛苦。我给出一份完整的web.xml关键配置参考:
xml复制<?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>dudu_card</display-name>
<welcome-file-list>
<welcome-file>login.jsp</welcome-file>
</welcome-file-list>
<servlet>
<servlet-name>StudentServlet</servlet-name>
<servlet-class>com.duducard.servlet.StudentServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>StudentServlet</servlet-name>
<url-pattern>/StudentServlet</url-pattern>
</servlet-mapping>
<session-config>
<session-timeout>30</session-timeout>
</session-config
