简介:本资源是一套基于Java Web经典技术栈(Servlet+JSP+MySQL)实现的完整购物网站系统,专为计算机专业学生课程设计、期末大作业及项目实战练习打造,已获98分高分评价,具备清晰业务逻辑与可运行性。压缩包共69个文件,包含15个核心Java源码、13个JSP页面、16个编译后Class文件、11个GIF与6个JPG等静态资源,辅以SQL建表脚本、CSS样式、JS交互逻辑及README说明文档,整体仅292KB,轻量易部署。目前已有234人学习下载,适合初学Web开发的学生快速理解MVC分层结构、用户注册登录、商品浏览、购物车管理及订单处理等典型电商功能模块。资源目录结构规范,含src源码、web前端页面、WEB-INF配置及数据库脚本,开箱即用,便于调试、二次开发与教学演示。
1. 为什么一个“Java+Servlet+JSP+MySQL”的购物网站,至今仍是Java Web入门最硬核的通关副本?
不是因为它多先进——Spring Boot三行代码起服务,Vue+Element Plus拖拽出后台,它连前端响应式都得手写CSS;也不是因为它多流行——GitHub上Star过千的电商项目,十有八九是Spring Cloud微服务架构。恰恰相反,正因为它“土”、慢、要写一堆web.xml配置、要手动管理HttpSession生命周期、要在JSP里混写Java脚本片段(哪怕只是<%=user.getName()%>),才让它成了检验Java Web基本功是否扎穿地壳的试金石。
这个组合不是历史包袱,而是能力显影液:Servlet暴露你对HTTP协议的理解深度(GET/POST区别?重定向和转发的Header差异?Filter链执行顺序?);JSP逼你直面MVC分层的真实代价(什么时候该用<jsp:include>,什么时候必须走Servlet跳转?pageContext和requestScope打架时谁赢?);MySQL则当场验明正身——你写的那个SELECT * FROM product WHERE category_id = ? AND status = 1 ORDER BY sort ASC LIMIT 0,20,有没有加联合索引?status=1在WHERE里是常量,但category_id=?是变量,索引能用上几成?
它适合三类人:刚学完Java基础、对着System.out.println("Hello World")还心潮澎湃的新手;准备Java后端面试、被问到“请手写一个登录校验Filter”的应届生;以及想给团队新人搭一个无框架依赖、可逐行调试、每一行代码都看得见来龙去脉的练兵场的Tech Lead。这不是怀旧,是把Web开发的“操作系统层”重新擦亮——当你能徒手用PrintWriter写出符合HTTP/1.1规范的响应头,再回看Spring MVC的@ResponseBody,才真正懂什么叫“封装的价值”。
2. 从零搭建:用最原始的方式跑通首页、商品列表与用户登录
2.1 环境筑基:JDK 8 + Tomcat 9 + MySQL 5.7 的黄金三角
提示:别碰JDK 17+或Tomcat 10+。Servlet 4.0规范强制要求HTTP/2支持,而本项目中大量
javax.servlet.*包在Jakarta EE 9+已迁移到jakarta.servlet.*,改包名会引发连锁编译失败。实测JDK 8u291 + Tomcat 9.0.83 + MySQL 5.7.44组合,兼容性最稳,报错信息最友好。
安装后验证关键路径:
# 检查JAVA_HOME指向JDK而非JRE echo $JAVA_HOME # 输出应为 /Library/Java/JavaVirtualMachines/jdk1.8.0_291.jdk/Contents/Home(Mac)或 C:\Program Files\Java\jdk1.8.0_291(Win) # Tomcat启动后访问 http://localhost:8080,看到Apache Tomcat欢迎页即成功 $CATALINA_HOME/bin/startup.sh # Linux/Mac %CATALINA_HOME%\bin\startup.bat # Windows # MySQL登录验证 mysql -u root -p # 输入密码后进入mysql>命令行,执行 SELECT VERSION(); 确认是5.7.x为什么坚持用MySQL 5.7?
datetime类型默认值支持CURRENT_TIMESTAMP(MySQL 8.0要求显式声明DEFAULT CURRENT_TIMESTAMP)GROUP BY语义宽松(8.0严格模式下SELECT id,name FROM user GROUP BY id会报错)utf8mb4字符集支持开箱即用,无需额外配置my.cnf中的collation-server=utf8mb4_unicode_ci
2.2 数据库建模:三张表撑起购物核心(product/user/order)
创建shopping_db数据库,并执行以下建表SQL。重点观察外键约束与索引设计逻辑:
-- 创建数据库(UTF8MB4编码确保emoji存储) CREATE DATABASE shopping_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE shopping_db; -- 用户表:密码必须BCRYPT加密,禁止明文! CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL, -- 存储BCRYPT哈希值,如 $2a$10$... `email` VARCHAR(100), `phone` VARCHAR(20), `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_username (username), -- 用户名查询高频,单独建索引 INDEX idx_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品表:分类ID为外键,状态字段驱动上下架 CREATE TABLE `product` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(200) NOT NULL, `description` TEXT, `price` DECIMAL(10,2) NOT NULL DEFAULT 0.00, `stock` INT NOT NULL DEFAULT 0, `category_id` BIGINT NOT NULL, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '0-下架,1-上架', `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_category_status (category_id, status), -- 联合索引:查某分类下所有上架商品 INDEX idx_name (name(50)) -- 前缀索引,避免长文本索引过大 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表:用户ID与商品ID均为外键,状态机驱动流程 CREATE TABLE `order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `product_id` BIGINT NOT NULL, `quantity` INT NOT NULL DEFAULT 1, `total_price` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0-待支付,1-已支付,2-已发货,3-已完成,4-已取消', `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE CASCADE, FOREIGN KEY (`product_id`) REFERENCES `product`(`id`) ON DELETE RESTRICT, INDEX idx_user_status (user_id, status), -- 用户订单状态查询 INDEX idx_created_time (created_time) -- 按时间倒序查最新订单 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;关键设计说明:
ON DELETE CASCADE用于用户删除时自动清理其订单(业务允许场景);ON DELETE RESTRICT防止误删商品导致订单数据断裂status字段用TINYINT而非ENUM:便于Java代码用switch(status)处理,且迁移至其他数据库时兼容性更好- 所有
DATETIME字段均设DEFAULT CURRENT_TIMESTAMP,避免Java层手动赋值导致时区混乱
2.3 Servlet层:从LoginServlet开始,理解请求生命周期
创建src/main/java/com/shopping/servlet/LoginServlet.java:
package com.shopping.servlet; import com.shopping.dao.UserDao; import com.shopping.model.User; 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; import java.util.Objects; // @WebServlet注解替代web.xml配置,更简洁 @WebServlet("/login") public class LoginServlet extends HttpServlet { private static final long serialVersionUID = 1L; @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 获取请求参数(注意:GET请求用getParameter,POST表单也用此方法) String username = req.getParameter("username"); String password = req.getParameter("password"); // 2. 参数校验(空值检查必须前置!) if (username == null || username.trim().isEmpty() || password == null || password.trim().isEmpty()) { req.setAttribute("error", "用户名或密码不能为空"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; // 阻止后续执行 } // 3. 调用DAO查询用户(此处简化,实际应使用BCRYPT比对) UserDao userDao = new UserDao(); User user = userDao.findByUsername(username); // 4. 密码校验(真实项目必须用BCRYPT.matches(password, user.getPassword())) if (user != null && Objects.equals(user.getPassword(), password)) { // 5. 登录成功:将用户信息存入Session(非Cookie!) HttpSession session = req.getSession(true); // true表示不存在则创建 session.setAttribute("currentUser", user); session.setMaxInactiveInterval(30 * 60); // Session超时30分钟 // 6. 重定向到首页(避免F5刷新重复提交) resp.sendRedirect(req.getContextPath() + "/index.jsp"); } else { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } // GET请求直接显示登录页 @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.getRequestDispatcher("/login.jsp").forward(req, resp); } }为什么用sendRedirect而非forward?
forward是服务器内部跳转,URL地址栏不变,用户刷新会导致表单重复提交(浏览器重发原POST请求)sendRedirect是客户端重定向,URL变为新地址,刷新时只GET新页面,彻底规避重复提交- 代价是丢失
request域属性(req.setAttribute()设置的数据),所以登录成功后用户信息必须存入session
2.4 JSP层:用<c:forEach>渲染商品列表,告别<% %>脚本
创建index.jsp,必须引入JSTL标签库(否则<c:forEach>无法解析):
<%@ 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" %> <html> <head> <title>首页</title> <meta charset="UTF-8"> <style> .product-card { border: 1px solid #eee; padding: 10px; margin: 10px 0; } .price { color: #e74c3c; font-weight: bold; } </style> </head> <body> <h1>欢迎来到购物网站</h1> <!-- 顶部导航栏 --> <div> <c:choose> <c:when test="${not empty sessionScope.currentUser}"> <span>欢迎,${sessionScope.currentUser.username}!</span> <a href="${pageContext.request.contextPath}/logout">退出</a> </c:when> <c:otherwise> <a href="${pageContext.request.contextPath}/login.jsp">登录</a> <a href="${pageContext.request.contextPath}/register.jsp">注册</a> </c:otherwise> </c:choose> </div> <!-- 商品列表:从request域获取productList --> <h2>热门商品</h2> <c:if test="${empty requestScope.productList}"> <p>暂无商品</p> </c:if> <c:forEach items="${requestScope.productList}" var="product"> <div class="product-card"> <h3>${product.name}</h3> <p>价格:<span class="price"><fmt:formatNumber value="${product.price}" pattern="¥#,##0.00"/></span></p> <p>库存:${product.stock}件</p> <a href="${pageContext.request.contextPath}/productDetail?id=${product.id}">查看详情</a> </div> </c:forEach> </body> </html>关键细节说明:
<fmt:formatNumber>确保价格显示为¥199.00格式,避免199.0这种不专业显示${pageContext.request.contextPath}动态获取应用上下文路径(如部署为/shopping,则生成/shopping/productDetail),避免硬编码路径sessionScope.currentUser和requestScope.productList明确指定作用域,防止同名变量冲突
3. 数据持久化实战:DAO层手写JDBC连接池与事务控制
3.1 JDBC工具类:用DruidDataSource实现连接复用与监控
注意:不要用
DriverManager.getConnection()——每次调用都新建TCP连接,高并发下直接打爆数据库。必须用连接池!
添加Maven依赖(pom.xml):
<dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.16</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> <!-- 严格匹配MySQL 5.7 --> </dependency>创建src/main/java/com/shopping/utils/DBUtils.java:
package com.shopping.utils; import com.alibaba.druid.pool.DruidDataSource; import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; public class DBUtils { private static DataSource dataSource; // 静态块初始化连接池(类加载时执行一次) static { DruidDataSource druidDataSource = new DruidDataSource(); druidDataSource.setUrl("jdbc:mysql://localhost:3306/shopping_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"); druidDataSource.setUsername("root"); druidDataSource.setPassword("your_password"); // 生产环境务必从配置文件读取! druidDataSource.setInitialSize(5); // 初始化连接数 druidDataSource.setMaxActive(20); // 最大活跃连接数 druidDataSource.setMinIdle(5); // 最小空闲连接数 druidDataSource.setMaxWait(60000); // 获取连接最大等待时间(毫秒) druidDataSource.setTimeBetweenEvictionRunsMillis(60000); // 空闲连接检测间隔 druidDataSource.setValidationQuery("SELECT 1"); // 连接有效性检测SQL druidDataSource.setTestWhileIdle(true); dataSource = druidDataSource; } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } // 关闭资源(必须在finally块中调用!) public static void close(Connection conn) { if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }为什么选Druid而非HikariCP?
- Druid提供
/druid/index.html监控页面(需配置Servlet),可实时查看SQL执行时间、慢SQL、连接池状态,对新手排查性能问题极其友好 setValidationQuery("SELECT 1")确保从连接池取出的连接未被MySQL主动断开(MySQL默认wait_timeout=28800秒,空闲连接8小时后失效)
3.2 UserDAO实现:手写PreparedStatement防SQL注入
创建src/main/java/com/shopping/dao/UserDao.java:
package com.shopping.dao; import com.shopping.model.User; import com.shopping.utils.DBUtils; import javax.sql.DataSource; import java.sql.*; public class UserDao { // 根据用户名查询用户(登录校验用) public User findByUsername(String username) { String sql = "SELECT id, username, password, email, phone, created_time FROM user WHERE username = ?"; try (Connection conn = DBUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); // 防SQL注入:?占位符绑定参数 try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User user = new User(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); user.setEmail(rs.getString("email")); user.setPhone(rs.getString("phone")); user.setCreatedTime(rs.getTimestamp("created_time")); return user; } } } catch (SQLException e) { e.printStackTrace(); } return null; } // 用户注册(插入新记录) public boolean save(User user) { String sql = "INSERT INTO user (username, password, email, phone) VALUES (?, ?, ?, ?)"; try (Connection conn = DBUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); // 实际应为BCRYPT加密后的密文 ps.setString(3, user.getEmail()); ps.setString(4, user.getPhone()); int affectedRows = ps.executeUpdate(); if (affectedRows == 0) { return false; // 插入失败 } // 获取自增主键ID try (ResultSet generatedKeys = ps.getGeneratedKeys()) { if (generatedKeys.next()) { user.setId(generatedKeys.getLong(1)); } } return true; } catch (SQLException e) { e.printStackTrace(); return false; } } }血泪经验:PreparedStatement的三个必做动作
ps.setString(1, username):永远用setXxx()方法绑定参数,绝不用字符串拼接"WHERE username = '" + username + "'"Statement.RETURN_GENERATED_KEYS:插入后需获取自增ID时,必须传此参数,否则getGeneratedKeys()返回空结果集try-with-resources自动关闭:Connection/PreparedStatement/ResultSet必须在try()中声明,确保异常时自动释放资源,避免连接泄漏
3.3 事务控制:在OrderService中保证“扣库存+写订单”原子性
创建src/main/java/com/shopping/service/OrderService.java:
package com.shopping.service; import com.shopping.dao.OrderDao; import com.shopping.dao.ProductDao; import com.shopping.model.Order; import com.shopping.model.Product; import com.shopping.utils.DBUtils; import java.sql.Connection; import java.sql.SQLException; public class OrderService { private final OrderDao orderDao = new OrderDao(); private final ProductDao productDao = new ProductDao(); // 创建订单(含库存校验与扣减) public boolean createOrder(Long userId, Long productId, Integer quantity) { Connection conn = null; try { conn = DBUtils.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 // 1. 查询商品当前库存 Product product = productDao.findById(productId); if (product == null || product.getStock() < quantity) { throw new RuntimeException("商品库存不足"); } // 2. 扣减库存(UPDATE操作) int updated = productDao.decreaseStock(productId, quantity); if (updated == 0) { throw new RuntimeException("库存扣减失败,请重试"); } // 3. 创建订单 Order order = new Order(); order.setUserId(userId); order.setProductId(productId); order.setQuantity(quantity); order.setTotalPrice(product.getPrice().multiply(new java.math.BigDecimal(quantity))); order.setStatus((byte) 0); // 待支付 boolean saved = orderDao.save(order); if (!saved) { throw new RuntimeException("订单创建失败"); } conn.commit(); // 所有操作成功,提交事务 return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); // 任一环节失败,回滚全部操作 } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn != null) { try { conn.close(); // 归还连接到Druid池 } catch (SQLException e) { e.printStackTrace(); } } } } }为什么事务必须手动控制?
productDao.decreaseStock()和orderDao.save()是两个独立的DAO方法,各自获取连接并执行SQL- 若不显式开启事务,两个操作可能在不同连接上执行,无法保证原子性(如扣库存成功但订单写入失败,导致库存虚减)
conn.setAutoCommit(false)是事务起点,conn.commit()是终点,中间任何异常都触发conn.rollback()
4. 避坑指南:那些让新手调试到凌晨三点的典型问题
4.1 现象:JSP页面中文乱码,显示为??????
原因:
- Tomcat默认使用
ISO-8859-1解码URL参数,而浏览器发送UTF-8编码的中文 - JSP响应未声明字符集,浏览器按默认编码(如GBK)解析UTF-8字节流
解决:
- 在
web.xml中配置全局编码过滤器(src/main/webapp/WEB-INF/web.xml):
<filter> <filter-name>CharacterEncodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>CharacterEncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>注意:即使不用Spring,也要复制此Filter类(可从Spring Framework jar中提取),或手写简易版
CharacterEncodingFilter。
- 在所有JSP顶部添加:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>- MySQL连接URL中必须包含:
?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai4.2 现象:登录后session.getAttribute("currentUser")返回null
原因:
HttpSession默认基于Cookie传递Session ID,若浏览器禁用Cookie,JSESSIONID无法传递- 或Servlet中调用了
req.getSession(false)(false表示不创建新Session),而用户首次访问时Session尚未创建
解决:
- 前端测试时确保浏览器启用Cookie
- 后端统一使用
req.getSession(true)或req.getSession()(等价于true) - 关键检查点:确认
web.xml中<session-config>未设置<tracking-mode>URL</tracking-mode>(该模式会将JSESSIONID拼在URL后,但现代浏览器普遍禁用)
4.3 现象:MySQL插入中文报错Incorrect string value: '\xF0\x9F\x98\x80'
原因:
- 表/字段字符集为
utf8(MySQL的utf8实为utf8mb3,最多3字节,不支持emoji) - 而Java程序发送的是
utf8mb4编码的emoji(如😀占4字节)
解决:
- 创建数据库时指定
CHARACTER SET utf8mb4(已写在2.2节) - 修改MySQL配置文件
my.cnf(Linux)或my.ini(Windows):
[client] default-character-set = utf8mb4 [mysql] default-character-set = utf8mb4 [mysqld] character-set-server = utf8mb4 collation-server = utf8mb4_unicode_ci- 重启MySQL服务后,执行SQL修正现有表:
ALTER DATABASE shopping_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; ALTER TABLE user CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 对product、order表执行相同命令4.4 现象:Druid连接池报错maxWait is 60000ms, but no available connection
原因:
- 数据库连接数耗尽(如
maxActive=20,但20个请求同时阻塞在慢SQL上) - 或MySQL服务端
max_connections参数过小(默认151)
解决:
- 检查MySQL当前连接数:
SHOW STATUS LIKE 'Threads_connected'; SHOW VARIABLES LIKE 'max_connections';- 调整Druid参数(
DBUtils.java中):
druidDataSource.setMaxActive(50); // 提高上限 druidDataSource.setMaxWait(30000); // 缩短等待时间,快速失败 druidDataSource.setRemoveAbandonedOnBorrow(true); // 移除超时借用的连接 druidDataSource.setRemoveAbandonedTimeoutMillis(60000);- 根本方案:在DAO方法中为所有
executeQuery()添加超时:
ps.setQueryTimeout(5); // 单条SQL执行超过5秒强制中断4.5 现象:JSP中<c:forEach>不解析,原样输出标签
原因:
- 未在
web.xml中声明JSTL版本(Servlet 3.0+容器需要) - 或
pom.xml未引入jstl依赖(仅taglibs-standard不够)
解决:
- 添加Maven依赖:
<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>- 确保
web.xml头部声明为Servlet 3.0+:
<?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_3_1.xsd" version="3.1">- JSP页面顶部必须有:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>5. 安全加固与生产就绪:从实验室走向真实战场
5.1 密码安全:用BCrypt替换明文存储(必须项!)
警告:
UserDao.save()中直接存password明文是严重安全漏洞!必须立即升级。
添加BCrypt依赖:
<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-crypto</artifactId> <version>5.8.5</version> </dependency>修改UserDao.save()和LoginServlet:
// UserDao.java 中 save 方法 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); public boolean save(User user) { // ... 其他代码 ps.setString(2, encoder.encode(user.getPassword())); // 加密后存储 // ... } // LoginServlet.java 中密码校验 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); // 替换原校验逻辑: if (user != null && encoder.matches(password, user.getPassword())) { // matches()比对明文与密文 // 登录成功 }BCrypt参数说明:
BCryptPasswordEncoder()无参构造使用默认强度10($2a$10$...),强度每+1,计算耗时翻倍- 生产环境建议
new BCryptPasswordEncoder(12),平衡安全性与响应时间 - 绝对禁止使用MD5/SHA1:彩虹表可秒破,且无盐值(salt)防护
5.2 SQL注入防御:所有用户输入必须预编译
回顾ProductDao.findByCategory方法(示例):
// ❌ 危险!字符串拼接 String sql = "SELECT * FROM product WHERE category_id = " + categoryId; // ✅ 正确!PreparedStatement占位符 String sql = "SELECT * FROM product WHERE category_id = ? AND status = 1"; ps.setLong(1, categoryId);覆盖所有DAO方法:
WHERE子句中的所有变量(id,username,email)ORDER BY字段名?不能用占位符!必须白名单校验:
private static final Set<String> VALID_SORT_FIELDS = Set.of("price", "created_time", "stock"); if (!VALID_SORT_FIELDS.contains(sortField)) { throw new IllegalArgumentException("非法排序字段: " + sortField); } String sql = "SELECT * FROM product ORDER BY " + sortField + " " + sortOrder;LIMIT参数?可用setInt():
ps.setInt(1, offset); ps.setInt(2, limit); String sql = "SELECT * FROM product LIMIT ?, ?";5.3 XSS防护:JSP输出必须HTML转义
用户输入的内容(如商品描述、评论)直接输出到JSP会触发XSS攻击。解决方案:
- JSTL内置转义(推荐):
<!-- 自动转义HTML特殊字符 --> <p>${product.description}</p> <!-- 不转义(仅当确认内容安全时用) --> <p><c:out value="${product.description}" escapeXml="false"/></p>- 手动转义工具类(备用):
import org.apache.commons.text.StringEscapeUtils; String safeDesc = StringEscapeUtils.escapeHtml4(userInput); req.setAttribute("safeDesc", safeDesc);注意:
StringEscapeUtils需引入commons-text依赖,且escapeHtml4()仅处理HTML实体,对JavaScript事件onerror=等仍需前端CSP策略配合。
5.4 部署优化:Tomcat生产配置与日志分离
修改$CATALINA_HOME/conf/server.xml,提升稳定性:
<!-- 1. 禁用shutdown端口(防止远程关闭) --> <!-- <Server port="8005" shutdown="SHUTDOWN"> 改为 --> <Server port="-1" shutdown="SHUTDOWN"> <!-- 2. Connector增加连接超时与压缩 --> <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" maxThreads="200" minSpareThreads="10" compression="on" compressionMinSize="2048" noCompressionUserAgents="gozilla, traviata" compressableMimeType="text/html,text/xml,text/plain,application/javascript,application/json"/>日志分离($CATALINA_HOME/conf/logging.properties):
# 将应用日志独立到shopping.log 1catalina.org.apache.juli.AsyncFileHandler.level = FINE 1catalina.org.apache.juli.AsyncFileHandler.directory = ${catalina.base}/logs 1catalina.org.apache.juli.AsyncFileHandler.prefix = shopping. # 关闭控制台日志(生产环境不需) java.util.logging.ConsoleHandler.level = OFF5.5 性能压测:用JMeter验证100并发下的订单创建
编写JMeter测试计划(shopping_test.jmx):
- 线程组:线程数100,Ramp-Up时间10秒,循环次数1
- HTTP请求:
- POST
/login,Body Data:username=test&password=123456 - POST
/createOrder,Body Data:productId=1&quantity=1(需先登录获取JSESSIONID)
- POST
- HTTP Cookie管理器:自动处理Session
- 聚合报告:关注
90% Line响应时间(应<1000ms)、错误率(应为0%)
压测发现的典型瓶颈:
productDao.findById()未加索引 → 在product表id字段建主键索引(已存在)orderDao.save()未加索引 → 在order表user_id字段建索引(已存在)- Druid连接池
maxActive=20不足 → 调整为50并观察ActiveCount峰值
6. 终极技巧:用Filter实现统一登录拦截与请求日志
6.1 登录拦截Filter:保护/admin/路径下的所有资源
创建src/main/java/com/shopping/filter/LoginFilter.java:
package com.shopping.filter; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; 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 servletPath = req.getServletPath(); // 白名单:登录页、注册页、静态资源、验证码接口 if (servletPath.startsWith("/login") || servletPath.startsWith("/register") || servletPath.startsWith("/css/") || servletPath.startsWith("/js/") || servletPath.startsWith("/images/") || servletPath.equals("/captcha")) { chain.doFilter(request, response); return; } // 检查Session中是否存在currentUser HttpSession session = req.getSession(false); if (session == null || session.getAttribute("currentUser") == null) { // 未登录,重定向到登录页,并保存原请求URL(用于登录后跳转) String originalUrl = req.getRequestURL().toString(); if (req.getQueryString() != null) { originalUrl += "?" + req.getQueryString(); } session.setAttribute("originalUrl", originalUrl); resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } <p> <a href="https://download.csdn.net/download/qq_38140936/89443728" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>