每年毕业季总有人问我,JSP 到底还能不能拿来当毕业设计?我的答案一直是:能,而且选对了题目方向,JSP 做出来的东西反而比那些跟着教程敲出来的 Spring Boot 管理系统更有区分度。前阵子我把自己做过的“jsp连锁花店管理平台”完整翻出来复盘了一遍,从表结构到前端页面再到部署时踩的坑,整理出了这篇东西。如果你正在纠结选题,或者已经选了类似的管理系统题目,这篇内容应该能帮你省下不少走弯路的时间。
连锁花店这个业务方向选得比较巧。它既不像“图书管理系统”那样满大街都是,又不像“电商平台”那样功能边界大得收不住。花店连锁的核心痛点在于多门店之间的库存协同、订单流转和会员信息共享,这三个点恰恰是 JSP + Servlet + MySQL 这套经典技术栈最容易展示能力的场景。做完这个项目,你既能把 JSP、Servlet、JavaBean、JDBC、EL 表达式、JSTL 这些课程重点全都串起来,又能讲出一个逻辑完整的业务故事,答辩的时候能说的东西非常多,演示效果也好。
整篇文章我会从业务流程设计、数据库建表、核心功能实现、常见 Bug 排查这几个维度展开,代码给的是关键片段,但思路是完整的。你拿到之后可以照着敲,也可以按自己学校的要求改功能点,灵活度很高。
1. 业务分析与功能模块拆解
1.1 连锁花店的核心业务流程
花店连锁和单店最大的区别在于库存和订单都要跨门店协同。举个例子,顾客 A 在朝阳门店订了一束花,要求当天下午配送到海淀区,这时候系统要做的不是简单记一笔订单,而是要判断朝阳门店库存够不够,不够的话能不能从附近的配送中心或其他门店调货。这个“跨店调拨”的流程,是整个平台最有业务价值的地方。
我画的业务流程是这样一个闭环:总部录入鲜花基础信息,各门店维护自己的库存,顾客通过前台页面浏览商品并下单,门店员工处理订单并进行配送或到店自提,会员在消费过程中累计积分,总部可以查看所有门店的销售报表。这个流程看起来简单,但落到 JSP 项目里,每个环节都会牵扯到表关联、状态流转和权限控制。
这个平台整体分为三个角色:总部管理员、门店员工、普通会员。总部管理员管商品、管门店、查报表;门店员工管库存、处理订单;会员在前台注册登录、下单、查订单。角色拆分清晰了,权限控制就好写,我采用的是基于 Session 和 Filter 的简单角色拦截方案,没引入 Spring Security,因为对 JSP 课设来说那套东西太重了,而且自己写 Filter 更能体现对底层原理的掌握。
1.2 功能模块划分
整个平台按角色拆分成三个端,每个端往下再细分子模块。功能清单建议在开题报告里就把表格列清楚,后面写代码心里才有底。
| 角色 | 功能模块 | 具体功能点 |
|---|---|---|
| 总部管理员 | 门店管理 | 添加/编辑/停用门店,查看门店经营数据 |
| 总部管理员 | 商品管理 | 维护鲜花种类、价格、图片、上下架状态 |
| 总部管理员 | 报表统计 | 按门店/按时间段查看销售额、订单量 |
| 总部管理员 | 员工管理 | 为门店分配员工账号 |
| 门店员工 | 库存管理 | 入库、出库、查看库存预警、发起门店间调拨 |
| 门店员工 | 订单处理 | 接单、备货、标记配送/自提、完成订单 |
| 门店员工 | 会员管理 | 查看会员信息、手动调整积分 |
| 普通会员 | 前台浏览 | 浏览鲜花列表、按分类筛选、搜索 |
| 普通会员 | 购物车 | 加入购物车、修改数量、删除 |
| 普通会员 | 订单 | 提交订单、在线支付(模拟)、取消订单 |
| 普通会员 | 个人中心 | 注册登录、修改资料、查看历史订单 |
这套功能列表做完,页面数量大约在 22 到 28 个之间,对于毕业设计来说工作量刚刚好,不会太少显得敷衍,也不会多到做不完。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与系统架构设计
2.1 为什么是 JSP + Servlet + MySQL
现在 JavaWeb 方向的毕业设计主流选择其实已经变成了 Spring Boot,但 JSP 这套老技术栈在学校课程里依然是主流,很多学校的 JavaWeb 课程教的就是 JSP + Servlet + JSTL。我选择用这套技术栈做这个项目,核心原因有三个。
第一,能完整覆盖课程考点。JSP 的九个隐式对象(request、response、session、application、out、pageContext、config、page、exception)、Servlet 的生命周期、Filter 过滤器、Listener 监听器,这些知识点在答辩时老师一定会问。用 Spring Boot 的话,这些底层概念会被框架藏起来,老师一问你反而答不上来。
第二,业务体量恰好合适。连锁花店平台的数据量级撑死也就几千条订单、几百种鲜花,完全不需要 Redis、MQ 那套高并发组件,MySQL + 连接池就够用了。用复杂的技术解决简单的问题,在毕业设计里反而是减分项,因为老师会觉得你并不理解为什么要用这些技术。
第三,部署和答辩演示环境容易搭建。只要装一个 JDK 8、Tomcat 9、MySQL 5.7,再配个 IDEA,前后不超过半小时就能把环境搭起来。最近有些同学在浏览器里访问 JSP 页面遇到路径问题,那多半是 Tomcat 的 context path 没搞明白,后面我会专门写一节来说这个问题。
2.2 三层架构与目录结构
JSP 项目虽然没有 Spring 那种强制分层,但自己写代码的时候还是要遵循 MVC 的思想。我的项目结构是这样组织的:
text复制flower-shop/
├── src
│ └── com
│ └── flower
│ ├── dao # 数据访问层,JDBC操作
│ ├── entity # 实体类,对应数据库表
│ ├── filter # 过滤器,登录状态与角色权限
│ ├── listener # 监听器,统计在线人数等
│ ├── servlet # 控制层,接收请求调用业务逻辑
│ ├── service # 业务逻辑层
│ └── util # 工具类,DBUtil、MD5Util等
├── web
│ ├── admin # 后台管理页面(JSP页面)
│ ├── front # 前台页面(JSP页面)
│ ├── css / js / images
│ └── WEB-INF
│ ├── web.xml
│ └── lib
实体类的设计有几个细节要注意。一个是日期字段尽量用 java.util.Date,不要用 java.sql.Date,否则在页面显示的时候会非常麻烦。另一个是金额字段在 Java 里用 BigDecimal,不要用 double,因为订单金额要参与计算,double 的精度问题在涉及小数点后两位时会出大问题。
2.3 数据库连接池与工具类封装
数据库连接这块,我不建议每操作一次就 DriverManager.getConnection() 一次,那样页面稍微多点请求数据库就会报 Too many connections。我采用的是 DBCP 连接池,配置文件写在 db.properties 里:
properties复制driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql://localhost:3306/flower_shop?useUnicode=true&characterEncoding=utf8&useSSL=false
username=root
password=123456
initialSize=10
maxActive=50
maxIdle=20
minIdle=5
连接池的初始化放在一个静态代码块里,整个应用启动后共用一批连接,这样既稳定又能提升访问速度。DBUtil 工具类里只保留两个核心方法:一个是拿到连接,一个是释放资源。释放资源的时候一定要遵循“后开先关”的顺序,先关 ResultSet,再关 Statement,最后关 Connection,否则连接池里的连接会被一直占用。
3. 数据库设计与建表 SQL
3.1 核心表结构设计
数据库设计是整个项目的灵魂,表建得好不好直接决定后面写 SQL 的痛苦程度。我总共设计了 8 张表,这里挑几张核心表出来讲。
门店表 store:
sql复制CREATE TABLE `store` (
`id` INT NOT NULL AUTO_INCREMENT,
`store_name` VARCHAR(100) NOT NULL COMMENT '门店名称',
`address` VARCHAR(255) DEFAULT NULL COMMENT '门店地址',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话',
`manager_name` VARCHAR(50) DEFAULT NULL COMMENT '店长姓名',
`status` TINYINT DEFAULT 1 COMMENT '状态:1启用 0停用',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
鲜花表 flower:
sql复制CREATE TABLE `flower` (
`id` INT NOT NULL AUTO_INCREMENT,
`flower_name` VARCHAR(100) NOT NULL COMMENT '鲜花名称',
`category` VARCHAR(50) DEFAULT NULL COMMENT '分类:玫瑰/百合/康乃馨/混搭',
`price` DECIMAL(10,2) NOT NULL COMMENT '销售单价',
`unit` VARCHAR(10) DEFAULT '束' COMMENT '单位',
`image` VARCHAR(255) DEFAULT NULL COMMENT '图片路径',
`description` TEXT COMMENT '商品描述',
`status` TINYINT DEFAULT 1 COMMENT '1上架 0下架',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
库存表 inventory 是连锁业务的关键,它必须把门店和商品关联起来:
sql复制CREATE TABLE `inventory` (
`id` INT NOT NULL AUTO_INCREMENT,
`store_id` INT NOT NULL COMMENT '门店ID',
`flower_id` INT NOT NULL COMMENT '鲜花ID',
`quantity` INT DEFAULT 0 COMMENT '当前库存数量',
`warning_line` INT DEFAULT 10 COMMENT '库存预警线',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_store_flower` (`store_id`, `flower_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
表设计的时候有几个容易踩的坑。第一个坑是 store_id 和 flower_id 加不加唯一约束。如果不加,同一个门店下可能出现两条相同鲜花的库存记录,数据一混乱,库存统计就全错了。第二个坑是金额字段用 DECIMAL 而不是 FLOAT,鲜花价格 99.90 这种小数用浮点存会存成 99.89999999。第三个坑是建表语句最后一定要加 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4,用 MyISAM 引擎不支持事务,下订单的时候一旦中途出错,库存和订单数据就对不上了。
3.2 订单与订单明细表设计
订单相关的表设计是答辩时老师最喜欢追问的地方,这里要讲清楚“为什么拆成两张表”。订单表 orders 存一次订单的汇总信息,订单明细表 order_item 存订单里每一种花的具体信息。
sql复制CREATE TABLE `orders` (
`id` INT NOT NULL AUTO_INCREMENT,
`order_no` VARCHAR(32) NOT NULL COMMENT '订单编号',
`store_id` INT NOT NULL COMMENT '接单门店ID',
`member_id` INT DEFAULT NULL COMMENT '会员ID',
`total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额',
`status` TINYINT DEFAULT 0 COMMENT '状态:0待付款 1已付款 2已接单 3配送中 4已完成 5已取消',
`pay_type` TINYINT DEFAULT 1 COMMENT '支付方式:1微信 2支付宝 3线下',
`receiver_name` VARCHAR(50) NOT NULL COMMENT '收货人',
`receiver_phone` VARCHAR(20) NOT NULL COMMENT '收货电话',
`receiver_address` VARCHAR(255) DEFAULT NULL COMMENT '配送地址',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`pay_time` DATETIME DEFAULT NULL,
`finish_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
sql复制CREATE TABLE `order_item` (
`id` INT NOT NULL AUTO_INCREMENT,
`order_id` INT NOT NULL COMMENT '订单ID',
`flower_id` INT NOT NULL COMMENT '鲜花ID',
`flower_name` VARCHAR(100) NOT NULL COMMENT '鲜花名称(快照)',
`price` DECIMAL(10,2) NOT NULL COMMENT '成交单价(快照)',
`quantity` INT NOT NULL COMMENT '购买数量',
`subtotal` DECIMAL(10,2) NOT NULL COMMENT '小计',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
明细表里为什么要冗余 flower_name 和 price 两列?这是为了做“快照”。假设鲜花卖完了之后价格调整了,或者商品被下架删除了,历史订单里依然能查出当时买的是什么花、多少钱。如果订单明细表直接去关联 flower 表,一旦后来改了商品信息,历史订单的显示就会跟着变,这在业务上是不允许的。答辩的时候把这个设计说出来,老师会觉得你真的考虑过数据一致性。
3.3 会员与积分表
会员表 member 相对简单,但要注意密码的存储方式。明文存密码是课设里最常见的扣分项,哪怕只是课设,也要用 MD5 加盐处理。我会在工具类里写一个 MD5Util,对密码进行 MD5(密码 + 固定盐值) 的加密,盐值可以是 "flower_shop_salt" 这种写死的字符串,虽然不如随机盐安全,但比明文强得多。会员积分我采用一个整数 points 字段,消费金额和积分之间的兑换比例在代码里写死成一块钱一分,后续如果要改成满减规则,只需要改 Service 层的方法就行。
4. 核心功能实现与关键代码
4.1 登录认证与角色权限控制
登录功能是每个页面都要用的基础功能,但也是学生最容易写乱的地方。我推荐把登录状态存到 Session 里,然后用一个 Filter 拦截所有需要登录才能访问的 URL。
用户登录的表单提交到 LoginServlet,Servlet 里做这些事情:接收用户名和密码,把密码转成 MD5,调用 StaffDao 或 MemberDao 查询用户,查到就把用户对象放进 Session,然后根据角色跳转到不同的首页。查不到就返回登录页并提示“用户名或密码错误”。
java复制@WebServlet("/login")
public class LoginServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
String username = req.getParameter("username");
String password = MD5Util.md5(req.getParameter("password"));
String role = req.getParameter("role"); // staff / member
Object user = null;
if ("staff".equals(role)) {
StaffDao dao = new StaffDao();
user = dao.findByUsernameAndPassword(username, password);
} else {
MemberDao dao = new MemberDao();
user = dao.findByUsernameAndPassword(username, password);
}
if (user != null) {
HttpSession session = req.getSession();
session.setAttribute("loginUser", user);
session.setAttribute("role", role);
resp.sendRedirect("index.jsp");
} else {
req.setAttribute("errorMsg", "用户名或密码错误");
req.getRequestDispatcher("/login.jsp").forward(req, resp);
}
}
}
权限控制的 Filter 逻辑也很直接。先判断请求的 URI 里是否包含 admin 路径,如果包含就检查 Session 里有没有登录用户以及角色是否是总部管理员或门店员工,不是就重定向到登录页。
java复制@WebFilter("/admin/*")
public class AdminAuthFilter 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();
Object loginUser = session.getAttribute("loginUser");
String role = (String) session.getAttribute("role");
if (loginUser == null || !"staff".equals(role)) {
resp.sendRedirect(req.getContextPath() + "/login.jsp");
return;
}
chain.doFilter(request, response);
}
}
注意 req.getContextPath() 这个细节。如果你的项目部署名不是 ROOT,比如访问路径是 http://localhost:8080/flower-shop/login.jsp,那 getContextPath() 返回的就是 /flower-shop,重定向的时候加上它才不会把用户踢到 404 页面。这个也是最近看到有人在问“为什么 JSP 页面点登录之后跳到 404”的最常见原因。
4.2 跨门店库存查询与调拨
连锁花店的核心在库存协同。前台会员浏览鲜花的时候,默认展示的是“最近门店”的库存,后台可以按门店查询某一款花在哪些店有货,以及是否接近预警线。
查询某款花在各门店的库存情况,用一条多表 JOIN 就能搞定:
sql复制SELECT s.store_name, i.quantity, i.warning_line,
CASE WHEN i.quantity <= i.warning_line THEN '预警' ELSE '正常' END AS stock_status
FROM inventory i
JOIN store s ON i.store_id = s.id
WHERE i.flower_id = ?
ORDER BY i.quantity DESC;
门店间调拨是另一个可以拿来讲的功能。调拨的本质是在两个门店的库存表上各做一次更新:调出门店数量减少,调入门店数量增加。为了保证这两个更新是原子的,必须放在同一个事务里。我在 Service 层用 DBUtil 提供的 Connection 对象手动开启事务:
java复制public boolean transferStock(int fromStoreId, int toStoreId,
int flowerId, int quantity) {
Connection conn = null;
try {
conn = DBUtil.getConnection();
conn.setAutoCommit(false);
InventoryDao dao = new InventoryDao();
boolean reduceSuccess = dao.reduceStock(conn, fromStoreId, flowerId, quantity);
boolean increaseSuccess = dao.increaseStock(conn, toStoreId, flowerId, quantity);
if (reduceSuccess && increaseSuccess) {
conn.commit();
return true;
} else {
conn.rollback();
return false;
}
} catch (Exception e) {
try {
if (conn != null) conn.rollback();
} catch (SQLException ex) {
ex.printStackTrace();
}
return false;
} finally {
DBUtil.closeConn(conn);
}
}
这个事务逻辑写出来,答辩的时候可以展开讲很多:为什么要关掉自动提交、什么时候 commit、什么时候 rollback、连接池里的连接关闭了连接真的关掉了吗。这几个问题都是经典的高频答辩问题。
4.3 订单状态机与购物车实现
订单状态的管理我用了一个简单整数状态机。状态流转的规则是这样的:待付款 0 可以取消变成 5,也可以支付变成 1;已付款 1 被门店接单变成 2;已接单 2 开始配送变成 3;配送中 3 确认送达变成 4 已完成。我在 Service 里写了一个 updateOrderStatus(orderId, fromStatus, toStatus) 方法,每次更新都带着当前状态作为条件,这样能避免并发情况下状态被乱改,相当于一个简化版的乐观锁。
购物车这块我没有用数据库表来存,而是存在 Session 里,用 HashMap<Integer, CartItem> 来保存,key 是鲜花 ID,value 是购物车项(包含鲜花信息和数量)。这符合购物车“临时、不需要持久化”的业务特点,也减少了数据库操作。购物车的核心操作无非四个:加入、修改数量、删除、清空。
java复制public class CartItem {
private Flower flower;
private int quantity;
// getter and setter...
}
public class Cart {
private Map<Integer, CartItem> items = new HashMap<>();
public void add(Flower flower, int quantity) {
if (items.containsKey(flower.getId())) {
CartItem item = items.get(flower.getId());
item.setQuantity(item.getQuantity() + quantity);
} else {
CartItem item = new CartItem();
item.setFlower(flower);
item.setQuantity(quantity);
items.put(flower.getId(), item);
}
}
public double getTotalPrice() {
double total = 0;
for (CartItem item : items.values()) {
total += item.getFlower().getPrice() * item.getQuantity();
}
return total;
}
}
下单的时候要把购物车里的数据清空吗?我的做法是下单成功后调用 cart.clear(),然后跳转到订单成功页面。如果在下单过程中出现库存不足的异常,购物车里的数据要保留,不能清掉,否则用户要重新选一次花,体验会很差。
4.4 图片上传与路径处理
花店商品必须有图片,所以图片上传功能绕不开。最近看到热搜词里有“jsp代码谷歌浏览器获取保存文件路径”,这让我想到很多新手在上传功能上会卡住。这里要澄清一个误区:出于浏览器安全策略,JavaScript 是拿不到客户端本地文件的完整路径的,你能拿到的只是文件名和文件对象。真正能拿到服务器端保存路径的是服务端代码,而不是前端。
JSP 中处理图片上传,我用的是 Commons FileUpload 组件,配置一个上传目录,然后把文件写到服务器磁盘上,再把访问路径存到数据库的 flower.image 字段。这里我强烈建议把上传后的文件路径改成相对路径,比如 /upload/flowers/1092837493.jpg,不要存绝对路径 D:/apache-tomcat-9.0.80/webapps/upload/...。因为项目换一台电脑部署,绝对路径就全乱了,而相对路径通过 Tomcat 的虚拟路径映射在任何机器上都能访问。
图片上传的关键 Servelt 片段:
java复制ServletFileUpload upload = new ServletFileUpload(new DiskFileItemFactory());
upload.setFileSizeMax(5 * 1024 * 1024); // 单文件不超过5MB
List<FileItem> items = upload.parseRequest(request);
for (FileItem item : items) {
if (!item.isFormField()) {
String fileName = System.currentTimeMillis() + "_"
+ new File(item.getName()).getName();
String savePath = getServletContext().getRealPath("/upload/flowers");
File dir = new File(savePath);
if (!dir.exists()) {
dir.mkdirs();
}
File file = new File(dir, fileName);
item.write(file);
imagePath = "/upload/flowers/" + fileName;
}
}
这里还有个细节,如果只传一个图片和一堆表单字段,item.isFormField() 的判断帮你把图片和其他输入框分开。getRealPath("/upload/flowers") 会把项目的 web 根目录解析成实际磁盘路径,这样上传文件就落在了 Tomcat 部署目录里。
4.5 分页查询实现
列表页面都要分页,鲜花列表、订单列表、会员列表统统要分页。我在 Dao 层封装了一个分页查询的通用写法:
java复制public List<Order> findByPage(int storeId, int pageNum, int pageSize) {
String sql = "SELECT * FROM orders WHERE store_id = ? ORDER BY id DESC LIMIT ?, ?";
return queryList(sql, storeId, (pageNum - 1) * pageSize, pageSize);
}
public int countByStore(int storeId) {
String sql = "SELECT COUNT(*) FROM orders WHERE store_id = ?";
return queryInt(sql, storeId);
}
分页参数的逻辑要说清楚:第一页展示的是第 0 条到第 pageSize 条,第二页是第 pageSize 条到第 2 * pageSize 条,所以 SQL 里的偏移量是 (pageNum - 1) * pageSize。前端页面上我用 JSTL 的 c:forEach 循环生成页码按钮,高亮当前页。如果数据量超过 10 页,我建议加一个“上一页/下一页”的导航,不要一次性把所有页码都渲染出来,10 页以内的数据是课设最常用的量级。
5. 前端页面实现与用户体验优化
5.1 前台页面设计思路
前台面向顾客的页面要求干净清爽,和花店的调性匹配。我用了 Bootstrap 4 做布局,没有自己手写复杂的 CSS,因为课设的重点在后端,前端能保证响应式、能用就行。首页展示推荐鲜花,分类页按玫瑰、百合、康乃馨、混搭花束筛选,详情页展示图片、价格、库存和“加入购物车”按钮。这些页面里大量使用 EL 表达式 \${flower.price} 和 JSTL 标签输出数据,JSP 里尽量不要出现大段的 <% %> 脚本片段,否则页面会非常难维护,答辩时也容易被老师批评。
5.2 后台管理页面布局
后台管理页面用了独立的 iframe 框架布局,左侧是菜单栏,右侧是内容区。左侧菜单按角色动态渲染,总部管理员能看到“门店管理”“商品管理”“员工管理”“报表统计”,门店员工只能看到“库存管理”“订单处理”“会员管理”。菜单的动态渲染可以通过 c:if 判断 Session 里的角色属性来实现。
jsp复制<c:if test="${sessionScope.role == 'staff'}">
<li><a href="${pageContext.request.contextPath}/admin/orderList.jsp">订单处理</a></li>
<li><a href="${pageContext.request.contextPath}/admin/inventoryList.jsp">库存管理</a></li>
</c:if>
需要注意 ${pageContext.request.contextPath} 这个 EL 表达式的用法,它等同于 getContextPath()。在所有页面里写绝对路径时都要加上它,否则页面一旦从二级目录访问,链接就会全部失效。
5.3 谷歌浏览器下的静态资源路径问题
之前热搜里有一条“jsp代码谷歌浏览器获取保存文件路径”,我理解很多人实际遇到的是另一个问题,就是在 Chrome 下 JSP 页面的 CSS 和 JS 加载不出来。这个通常是路径写错导致的。比如你在 admin/orderList.jsp 里写了 <link href="css/style.css">,浏览器会解析成 /admin/css/style.css,但实际文件在 /css/style.css,自然 404。
解决方法是永远用绝对路径引用静态资源:
jsp复制<link href="${pageContext.request.contextPath}/css/style.css" rel="stylesheet">
<script src="${pageContext.request.contextPath}/js/jquery.min.js"></script>
另外还有一个 Chrome 的经典坑:修改 JSP 或 JS 文件后,浏览器缓存了旧版本,页面一直显示旧效果。这个问题最容易让新手误以为是代码没改对。解决办法是在引用的 JS 和 CSS 后面加版本号参数:
jsp复制<script src="${pageContext.request.contextPath}/js/main.js?v=20250101"></script>
改一次代码就更新一次版本号参数,Chrome 就会强制拿新的文件。
6. 常见问题与排查经验汇总
6.1 数据库中文乱码问题
中文乱码是 JSP 项目里出现频率最高的 Bug,没有之一。乱码通常出现在三个环节,链路是:页面提交数据 -> Servlet 接收 -> 数据库存取 -> 页面显示。任何一环的编码不一致,就会出现乱码。
我排查乱码的顺序是按数据流从前往后查。第一,JSP 页面顶部要写:
jsp复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
第二,Servlet 里处理 POST 请求时要设置请求编码:
java复制req.setCharacterEncoding("UTF-8");
第三,数据库连接 URL 里要带编码参数:
properties复制url=jdbc:mysql://localhost:3306/flower_shop?useUnicode=true&characterEncoding=utf8
第四,建表时表字段的字符集用 utf8mb4。这四个环节全部到位,中文基本不会乱。如果前三个都做了还是乱码,检查一下 Tomcat 的 server.xml 里 Connector 是否加了 URIEncoding="UTF-8",有时候 GET 请求的参数编码就得靠这个属性。
6.2 数据库连接超时与连接池泄漏
早期我把连接池配置好后,跑了一两天发现后台偶尔报 Connection is closed 异常。排查后发现是代码里有个别地方在 try-catch 里查完数据忘记关闭连接。连接没还回池子,池子里的连接就越来越少,最后新的请求拿不到连接就报错。
排查方法是在 DBUtil 的 getConnection() 方法里打印当前的活跃连接数,跑一段时间看趋势。后来我在 finally 块里统一调用 DBUtil.closeConn(conn, stmt, rs),确保所有连接都会归还。记住一个原则:打开的连接必须在同一个方法的 finally 里关闭,绝不可以在别的类里代关。
6.3 部署后在别的电脑上访问不了
答辩之前你可能需要把项目部署到老师电脑上演示,最常见的错误是把 MySQL 和 Tomcat 装好后直接双击启动,然后访问不了。原因是 MySQL 的 root 用户默认只允许 localhost 登录,你需要执行一句授权 SQL:
sql复制GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY '123456' WITH GRANT OPTION;
FLUSH PRIVILEGES;
Tomcat 默认端口是 8080,如果老师的电脑上 8080 被占用了,打开 conf/server.xml,把 <Connector port="8080" 改成 8088,再重启就行了。还有防火墙也可能拦截端口,排查时先关掉 Windows 防火墙或者放行对应端口,一般就能解决。
6.4 JSP 安全性问题
网上最近有些关键词挺火的,比如“冰蝎jsp免杀”,这里我以过来人的身份多说一句,这个话题涉及的都是恶意的 WebShell 工具,千万别在毕设里掺和这类东西,也不要在自己的项目里留任意文件上传的后门漏洞。JSP 项目最常见的安全问题有四个:SQL 注入、XSS 跨站脚本、未授权访问、文件上传漏洞。你自己做课设的时候至少要做到以下几点:
第一,所有 DAO 查询都用 PreparedStatement 的 ? 占位符,不要拼接 SQL 字符串。第二,页面显示用户输入的内容时用 <c:out> 转义,防止 XSS。第三,管理员后台必须经过 Filter 拦截。第四,文件上传要校验文件类型,不要允许上传 .jsp 文件。做完了这些,答辩的时候关于安全性的问题就能对答如流。
7. 项目部署与答辩演示建议
7.1 本地开发环境搭建
推荐环境组合是:JDK 8 + Tomcat 9 + MySQL 5.7 + IDEA 2023。JDK 8 是 JSP 项目最稳定的版本,Tomcat 9 对应 Servlet 4.0 规范,兼容 JSP 2.3,这套组合经过大量项目验证不会踩版本兼容性的坑。MySQL 8.0 也可以用,但驱动要换成 com.mysql.cj.jdbc.Driver,URL 里还需要指定 serverTimezone=Asia/Shanghai,否则会有时区报错。
导入项目到 IDEA 的时候,要确认项目结构里 web 目录被标记为 Web Resource Directory,否则跑起来后找不到 JSP 页面。配置 Tomcat 时 Artifact 要选 war exploded 模式,这样修改 JSP 后不需要重启 Tomcat 就能在浏览器里刷新看到最新效果,极大提升开发效率。
7.2 预置演示数据与答辩演示脚本
答辩最大的尴尬就是演示到一半发现数据库里没有数据。我强烈建议你初始化 SQL 脚本里预置好一套完整演示数据:3 个门店、20 种鲜花、5 个会员账号、10 条覆盖不同状态的订单、若干库存记录。这样老师想看订单列表能直接看到数据,想看库存预警能直接看到红色警告,想看报表能直接看到统计数字。
演示的顺序建议按业务流程走:前台注册/登录 -> 浏览鲜花 -> 加入购物车 -> 提交订单 -> 后台门店员工接单 -> 配送 -> 完成订单 -> 到总部看报表。这个流程一气呵成,能很好地展示一个完整的闭环业务。
另外准备一个演示脚本,把要说的每句话都写下来,演示前自己过两遍。我第一次答辩时演示到购物车下单,因为紧张不小心清空了购物车,重新点了一遍才恢复。提前准备脚本,这种意外就能避免。
7.3 部署包与文档的整理
毕业设计最终提交的内容不仅要能跑起来,还要有一份完整的部署说明文档和一份设计说明书。部署说明文档里写清楚环境版本、数据库初始化步骤、Tomcat 配置步骤和默认账号密码。设计说明书则重点写系统分析、表结构设计、核心功能实现和测试用例。这些文档放在项目的根目录下,方便老师和评委查看。项目源码的命名规范也要注意,包名使用小写字母加反斜杠,类名使用大驼峰命名,变量和参数用小驼峰,这样代码看起来专业规范。
我在写的过程中,一直有意识地把注释写在关键代码的旁边,比如事务开启和回滚的位置、分页偏移量计算的公式原因、订单状态机的流转规则。这些注释一方面是给自己看,另一方面在答辩时翻开代码就能快速找到要讲解的位置。
8. 项目扩展方向与我的个人体会
8.1 从 JSP 迁移到 Spring Boot 的扩展路径
答辩完之后,如果你想把这个项目继续完善,可以考虑在毕业后的学习里把它迁移到 Spring Boot。迁移的路径通常是:用 Spring Boot + MyBatis 替换原生的 JDBC 和 DAO 层,用 Thymeleaf 替换 JSP,用 Spring Security 替换自己的 Filter 过滤器。业务逻辑不变,表结构基本不变,只是技术栈升级了,整个迁移过程是很好的学习机会。如果你要找实习或工作,这种“同一个项目两种技术栈实现”的经历,在面试中是非常好的切入点,因为你能讲清楚每一种技术解决的是什么问题。
8.2 功能扩展建议
“按门店查看销售额报表统计”毕业后,有时间可以扩展。核心 SQL 已经做到了按月汇总,也能按日汇总。更进一步,可以做按分类统计热销商品、按星期分析下单高峰、按门店对比客单价,这些数据展示能给这个项目增加不少亮点。
也可以给会员模块增加积分商城功能,积分兑换鲜花或优惠券,这能撑起更多页面和业务规则。会员积分规则从“一元一分”扩展成“会员等级折扣”,就涉及等级表、折扣计算、优惠券核销等逻辑,做起来之后再写一篇博客都不成问题。
8.3 我做完这个项目的真实感受
做这个 jsp连锁花店管理平台,我最大的收获其实不是代码能力,而是踩了一圈坑之后建立起来的排查问题思路。我记得最清楚的一次是订单提交后库存没有扣减,我盯着代码看了三个小时没发现原因,后来才发现是因为 Service 层和 Dao 层用了两个不同的 Connection 对象,事务根本不在同一个连接上。那次之后我养成了一个习惯,凡是涉及多步数据库更新的操作,先从连接的获取和释放入手检查,再看业务逻辑。这种排查思路放在任何项目里都是通用的。
这个项目做完大约花了三周,第一周建表和搭架子,第二周写门店和商品模块,第三周写订单、会员和前端页面。如果你时间紧,可以参考这个节奏来安排。实际上大部分时间消耗在调试环境和小细节上,真正理解和梳理清楚业务流程之后,代码量并不算夸张。如果这篇内容能帮你少走一些弯路,那分享就有了意义。
