简介:一套面向Java初学者与后端开发学习者的网上购物商城项目源码包,以Java后端技术与MySQL数据库为核心,实现商品浏览、用户注册登录、购物车管理、订单提交等完整电商流程。项目采用经典的分层架构,将数据模型、业务逻辑与页面显示分离,配合数据库连接操作,清晰展示动态网站从接收请求到返回响应的完整链条。压缩包共57个文件,含源代码、编译后类文件、配置、数据库脚本、说明文档及少量依赖库,大小约1.93兆字节,结构紧凑,便于导入开发环境学习。随包附带从下载到运行的详细步骤说明和数据库初始化脚本,可以协助新手快速完成环境搭建与数据导入,避免常见配置错误。此外,项目还涉及会话跟踪、用户权限控制、表单提交处理及基础安全防范等知识点,能够帮助读者串联起课堂理论与实际项目开发之间的缺口,该资源目前已有一千三百八十七人浏览学习,作为课程设计或毕业设计的参考模板也具有较强的实用价值。
1. 从一份可运行的购物商城源码说起
拿到“Java+MySQL实现网上购物商城”这个压缩包时,大多数人第一反应是把它当成一个“双击就能看结果”的成品。实际拆开你会发现,它是一份典型的 Servlet + JSP + JDBC + MySQL 的完整 Web 工程,包含源码目录、数据库脚本、Eclipse/IDEA 项目配置文件和一份写好的“从下载到运行详细步骤.txt”。这类项目在 Java Web 学习路线里恰好卡在一个关键位置:你已经懂 Java 基础语法,但还不清楚 HTTP 请求怎么被处理、数据库连接池高并发下为什么会卡、Session 和 Cookie 到底谁在维持登录状态。把它当作“第一个能跑起来的完整系统”去拆解,比看一百遍理论更有效。本文我会从架构、表设计、环境搭建、核心功能到排错调优,完整过一遍这个商城项目的可复现路径。
2. 系统架构与数据库设计:先把 MVC 和表结构立住
2.1 Servlet+JSP 分工:谁在干活,谁在展示
这个商城没有使用 Spring Boot 这种现代框架,而是用最原始的 Servlet + JSP 搭建。它的好处是把 Web 底层的请求响应模型完全暴露出来:浏览器发 HTTP 请求,Tomcat 根据 web.xml 里的映射找到对应的 Servlet 类,Servlet 调用 JDBC 查询 MySQL,再把结果塞进 request 或 session 作用域,最后 forward 到 JSP 渲染 HTML。
常见做法的分工是:
- Controller(Servlet):接收参数、调用业务方法、控制页面跳转。例如
LoginServlet负责取出用户名和密码,调用UserDao.login()验证。 - Model(JavaBean + DAO):
User、Product、Order等实体类对应数据库表;UserDao、ProductDao里写 JDBC 代码。 - View(JSP):只负责显示,用 JSTL 或脚本片段输出
productList。
注意项目里没有明显分层的话,Servlet 里直接写Class.forName("com.mysql.jdbc.Driver")是常见情况。我一般会建议把数据库连接抽到一个DBUtil类里,否则每个 DAO 都重复getConnection()、PreparedStatement、close()三件套,维护成本很高。
2.2 数据库表设计:用户、商品、订单、购物车
商城核心表最少要有四张:用户表、商品表、订单主表、订单明细表。购物车可以放在 Session 里,也可以单独建表。这里有实际取舍:如果只允许登录用户下单,购物车表加user_id外键即可;如果允许游客先加购、登录后合并,则必须建表才能持久化。
下面是一套符合教学项目规模的设计,直接贴shopping.sql的核心片段:
CREATE DATABASE IF NOT EXISTS shopping_db DEFAULT CHARSET utf8mb4; USE shopping_db; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, phone VARCHAR(20), address VARCHAR(200), role TINYINT DEFAULT 0, -- 0 普通用户,1 管理员 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE t_product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, image VARCHAR(255), description TEXT, category VARCHAR(50), status TINYINT DEFAULT 1, -- 1 上架,0 下架 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, -- 0 待支付,1 已支付,2 已发货,3 已完成 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user(id) ) ENGINE=InnoDB; CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES t_order(id), CONSTRAINT fk_item_product FOREIGN KEY (product_id) REFERENCES t_product(id) ) ENGINE=InnoDB;这段 SQL 里值得注意的几个点:所有表都用InnoDB,因为订单和库存更新需要事务支持;DECIMAL(10,2)存金额而不是FLOAT,避免浮点精度问题;外键约束在这里是教学演示,真正高并发商城往往去掉外键改由应用层保证一致性。order_no设置为UNIQUE,用来承载支付回调等场景的幂等判断。
2.3 JDBC 连接与事务边界:下单为什么不能少了 commit
数据库连接驱动的加载方式是这个项目最核心的基础设施。常见做法是在静态代码块里注册驱动:
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/shopping_db?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL驱动加载失败"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { // 顺序关闭 rs -> stmt -> conn,放在 finally 中执行 } }参数说明:useSSL=false是避免本地开发时 MySQL 8 的 SSL 握手警告;serverTimezone=Asia/Shanghai解决驱动与数据库时区不一致导致的报错;characterEncoding=utf8保证中文写入不乱码。如果你的 MySQL 版本是 5.x,驱动类名要换成com.mysql.jdbc.Driver,而 8.x 必须用com.mysql.cj.jdbc.Driver。
下订单这个动作至少涉及两条 SQL:往t_order插入主表、往t_order_item插入明细、扣减t_product.stock。三条语句必须保证同时成功或同时失败,这就是事务边界。JDBC 原生写法是:
Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交 // 执行 insert into t_order ... // 执行 insert into t_order_item ... // 执行 update t_product set stock = stock - ? where id = ? and stock >= ? conn.commit(); } catch (SQLException e) { if (conn != null) conn.rollback(); e.printStackTrace(); } finally { if (conn != null) conn.setAutoCommit(true); // 归还连接前恢复 DBUtil.close(conn, null, null); }注意最后一行setAutoCommit(true)很容易漏。连接池复用时,如果连接交给下一次调用时仍保留false状态,后续查询会一直不提交,数据出问题也很难排查。商品扣减库存的where条件里加stock >= ?是防止超卖最简单的手段,如果update返回 0 表示库存不足,应该主动抛异常触发回滚。
3. 运行环境搭建与项目导入:从 JDK 到 MySQL 全链路
3.1 JDK 与环境变量配置:JAVA_HOME 到底有多重要
这个商城项目只要是 Servlet 3.0 左右的规范,JDK 8 就能跑。但很多同学的机器上同时装了多个版本的 JDK,导致编译级别和 Tomcat 运行环境不匹配。先确认版本:
java -version javac -version如果javac提示找不到命令,说明环境变量没有配好。常见配置方式是在系统环境变量里新增:
JAVA_HOME = C:\Program Files\Java\jdk1.8.0_202 PATH 追加 = %JAVA_HOME%\bin CLASSPATH 可选 = .;%JAVA_HOME%\lib\dt.jar;%JAVA_HOME%\lib\tools.jarJAVA_HOME不只是给命令行用的。Tomcat 的catalina.bat、IDEA 的编译器和 Maven 都会读取它。如果你用 IDE 自带 JBR(JetBrains Runtime),IDEA 内能编译但命令行跑不了java -jar,这种情况就是系统 PATH 没指向 JDK。配置完环境变量后,打开新的命令行窗口再验证,避免缓存。
3.2 MySQL 安装与数据库导入:免安装版还是安装包
这个项目的数据库脚本是.sql文件,有两种导入路径。如果你是用 MySQL 安装包(MSI),安装完默认端口 3306,root 密码自己设置。如果你用的是免安装版(zip 包),需要手动写my.ini:
[mysqld] basedir=D:/software/mysql-8.0.34-winx64 datadir=D:/software/mysql-8.0.34-winx64/data port=3306 character-set-server=utf8mb4 default-authentication-plugin=mysql_native_password初始化数据目录的命令是mysqld --initialize-insecure,这会生成一个 root 空密码的实例,然后net start mysql启动服务。注意mysqld必须以管理员权限运行,写datadir时路径里不要用中文。
数据库脚本导入直接走命令行:
mysql -u root -p mysql> source D:/path/to/shopping.sql; mysql> show tables;历史命令里也可以用mysql -u root -p < shopping.sql导入。导入成功后执行show tables;如果看到t_user、t_product等表,说明 SQL 文件没有语法问题。这里有一个高频坑:如果你的.sql文件里没有CREATE DATABASE语句,导入前需要手动先建库,否则会报No database selected。
3.3 在 IDEA 中导入项目并配置 Tomcat
解压网上购物商城.zip后,你会看到.project、.classpath和.idea目录。这说明项目既兼容 Eclipse 也兼容 IDEA。用 IDEA 导入时,选择File -> New -> Project from Existing Sources,直接选中解压目录。如果项目依赖的 MySQL 驱动 jar 包没有放在WEB-INF/lib下,IDEA 编译会报大量红字。
确认依赖后,需要配置 Tomcat:
- 打开
Run/Debug Configurations,新增Tomcat Server -> Local。 - Application server 指向本地 Tomcat 安装目录。
- Deployment 标签页点击
+,选择Artifact...,把项目 war exploded 添加进去。 - Application context 建议改成
/shopping,这样访问路径是http://localhost:8080/shopping/。
启动前还要修改项目的数据库连接配置。在这个教学项目里,通常是一个db.properties或DBUtil.java类的常量,把用户名密码改成你自己的 root 账户。改完再启动 Tomcat,如果控制台没有ExceptionInInitializerError,说明驱动加载成功。
4. 核心功能实现拆解:登录、购物车与下单事务
4.1 用户认证与 Session 管理:登录状态是怎么保持的
用户登录过程是典型的一次 HTTP 请求生命周期。前端表单POST到LoginServlet,Servlet 取出参数,调用UserDao.findByUsernameAndPassword()查询 MySQL。如果查到用户,就把User对象放进 Session;否则跳回登录页并带上错误信息。
@WebServlet("/login") public class LoginServlet extends HttpServlet { private UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userDao.login(username, password); if (user == null) { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(); session.setAttribute("loginUser", user); resp.sendRedirect(req.getContextPath() + "/index"); } }这里的req.getParameter取到的是字符串,如果前端没有做非空校验,后端至少也要过滤 null 和空串。resp.sendRedirect之后浏览器会重新发起请求到/index,这个“重定向”和forward的区别是:forward 发生在服务器内部,浏览器地址栏不变;sendRedirect 会告诉浏览器重新请求新地址。登录成功后必须用重定向,防止用户刷新页面时重复提交表单。
Session 默认存活时间在 Tomcat 的web.xml里是 30 分钟。你可以通过session.setMaxInactiveInterval(600)单独设置。购物车放在 Session 里时,这个时间决定了用户加购后多久清空。
4.2 商品列表与分页查询:LIMIT 参数怎么拼
商品展示页需要分页,SQL 核心是LIMIT offset, size。常见做法是先查总数SELECT COUNT(*) FROM t_product WHERE status=1,再查当前页数据。Servlet 里接收page和pageSize参数,默认值是 1 和 8。
int page = 1; int pageSize = 8; String pageParam = req.getParameter("page"); if (pageParam != null && pageParam.matches("\\d+")) { page = Integer.parseInt(pageParam); } int offset = (page - 1) * pageSize; List<Product> productList = productDao.findByPage(offset, pageSize, 1); req.setAttribute("productList", productList);对应 DAO 方法:
String sql = "SELECT id, name, price, stock, image FROM t_product " + "WHERE status = ? ORDER BY id DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, status); ps.setInt(2, offset); ps.setInt(3, pageSize); try (ResultSet rs = ps.executeQuery()) { // 封装到 List<Product> } }注意LIMIT后面的两个参数用setInt设置时,数据库类型也是 int,不能直接拼字符串,否则既有 SQL 注入风险,又可能在特殊字符下报语法错误。页面底部生成页码时,根据totalCount和pageSize算出总页数:
int totalPages = (int) Math.ceil((double) totalCount / pageSize);4.3 购物车与订单事务:把 Session 里的商品变成数据库订单
购物车的实现有两种简易方案:一是用Map<Integer, Integer> cart = new HashMap<>()存在 Session 里,key 是商品 ID,value 是数量;二是数据库t_cart表存user_id, product_id, quantity。教学项目建议先用 Session 方式理解状态管理,再升级成数据库表。
加购 Servlet 的核心逻辑是:
HttpSession session = req.getSession(); Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); session.setAttribute("cart", cart); } int productId = Integer.parseInt(req.getParameter("productId")); int quantity = Integer.parseInt(req.getParameter("quantity")); cart.merge(productId, quantity, Integer::sum); // 相同商品累加数量提交订单时,Servlet 从 Session 取出 cart,然后在一个事务里执行插入订单主表和明细表。这个环节最容易踩的坑是:你从cart里取商品 ID 去查询最新价格,但此时商品可能已被管理员下架或价格修改,所以订单明细里必须保存商品名称和下单时的价格快照。这也是t_order_item表里为什么要有product_name和price字段的原因。
事务代码可以复用 2.3 节的模板,业务步骤是:
String orderNo = "SN" + System.currentTimeMillis(); for (Map.Entry<Integer, Integer> entry : cart.entrySet()) { // 1. 查询当前商品库存 // 2. 扣减库存,sql = "update t_product set stock = stock - ? where id = ? and stock >= ?" // 3. 插入一条 t_order_item }如果库存足够,update影响行数为 1,继续下一步;如果影响行数为 0,直接抛出SQLException触发回滚,页面提示“库存不足”。订单号用System.currentTimeMillis()在单机课程设计里够用,但如果要求更严谨,可以加上随机数或用户 ID,避免并发时重复。
4.4 防 SQL 注入与 XSS 的常见做法
这个项目里最容易出现安全隐患的位置是登录和商品搜索。如果之前项目里用了字符串拼接 SQL:
String sql = "SELECT * FROM t_user WHERE username='" + username + "' AND password='" + password + "'";当用户名输入' OR '1'='1时,SQL 会变成恒真条件,非法登录直接绕过认证。换成PreparedStatement的参数绑定是第一步:
String sql = "SELECT * FROM t_user WHERE username=? AND password=?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password);PreparedStatement会对参数做转义,单引号不再具有拼接语义。不过要注意:表名、列名、ORDER BY 的排序字段不能用?占位,这些位置仍然存在注入风险。比如排序字段来自前端参数时,最好做一个白名单校验:
String orderField = "id"; String sort = req.getParameter("sort"); if ("price".equals(sort)) orderField = "price"; if ("sales".equals(sort)) orderField = "sales";XSS 防护主要在 JSP 输出层。商品名称、用户注册时的个人介绍都可能包含<script>标签。JSP 里用 EL 表达式输出时,${product.name}只是原样输出,不会转义。最好使用 JSTL 的<c:out>标签并开启编码:
<c:out value="${product.name}" escapeXml="true" />或者在后台统一过滤:String safe = htmlEscape(userInput);把<替换成<,>替换成>。如果项目用了富文本编辑器,就需要更严格的白名单方案,而不能只做全局转义,否则会把正常的加粗、链接标签都滤掉。
5. 排错与调优:从运行日志到 MySQL 性能
5.1 常见启动失败问题对照表
我按这个项目教程带人跑通的过程中,遇到最多的问题集中在下面几类,直接对照排查:
| 现象 | 原因 | 处理方式 |
|---|---|---|
ClassNotFoundException: com.mysql.jdbc.Driver | 驱动 jar 不在 WEB-INF/lib | 把 mysql-connector-java.jar 拷入 lib 并执行File -> Invalidate Caches |
Access denied for user 'root'@'localhost' | 密码错误或权限表异常 | 用mysql -u root -p验证密码,必要时ALTER USER 'root'@'localhost' IDENTIFIED BY '新密码' |
Unknown database 'shopping_db' | 没有导入 SQL 或库名不一致 | 检查.sql里的建库语句,手动CREATE DATABASE后再导入 |
Port 8080 required by Tomcat... is already in use | 端口被占用 | `netstat -ano |
| 中文乱码 | 页面编码、请求编码、数据库编码不一致 | JSP 顶部pageEncoding="UTF-8",Servletreq.setCharacterEncoding("UTF-8"),连接 URL 加characterEncoding=utf8 |
javax.servlet.ServletException: File "/index.jsp" not found | JSP 文件路径不对 | 检查req.getRequestDispatcher的路径是否以/开头指向 webapp 根目录 |
上面表格里的“处理方式”是我实际使用后确认最直接的路径,不是说官方文档一定这么写。比如 Tomcat 端口占用后,你改conf/server.xml里的 8080 为 8081 也行,但问题根源还是有一个残留进程没杀掉,治本更重要。
5.2 MySQL 查询慢的排查方向
商品表和订单表数据量上去之后,最直接的性能瓶颈在索引。原项目的建表语句里,t_product不一定有索引,但WHERE category = ?和ORDER BY price这类条件会频繁执行。
用EXPLAIN看一下执行计划:
EXPLAIN SELECT * FROM t_product WHERE category = '手机' ORDER BY price;如果EXPLAIN结果里type是ALL,说明是全表扫描,应该在category和price上建联合索引:
CREATE INDEX idx_category_price ON t_product(category, price);但索引不是越多越好。t_order_item表外键order_id需要索引,这通常是外键约束自动创建的。如果项目去掉了外键,而你经常按order_id查明细,就必须手动补索引。更新频繁的字段也不适合建太多索引,比如stock每次下单都会变更,索引维护成本反而拖慢写入速度。
5.3 一个实用技巧:用统一工具类收敛 JDBC 操作
这个项目里 DAO 重复代码很多。我一般会把ResultSet到实体对象的映射抽成函数式接口,减少样板代码。JDK 8 写法如下:
public interface RowMapper<T> { T map(ResultSet rs) throws SQLException; } public static <T> List<T> queryList(String sql, RowMapper<T> mapper, Object... args) { List<T> list = new ArrayList<>(); try (Connection conn = getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { for (int i = 0; i < args.length; i++) { ps.setObject(i + 1, args[i]); } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapper.map(rs)); } } } catch (SQLException e) { throw new RuntimeException("查询失败", e); } return list; }调用时传入对应的列映射:
List<Product> products = queryList( "SELECT * FROM t_product WHERE status = ?", rs -> { Product p = new Product(); p.setId(rs.getInt("id")); p.setName(rs.getString("name")); p.setPrice(rs.getBigDecimal("price")); p.setStock(rs.getInt("stock")); return p; }, 1 );queryList方法里用可变参数Object... args接收查询条件,内部统一用setObject绑定参数。这样之后新增查询不需要重复写连接管理和 try-with-resources,只要传 SQL、映射器和参数就行。注意setObject对null的处理是向数据库写入NULL,如果你的查询条件允许为空,判断逻辑最好前置。这个技巧应用到这个商城项目后,至少能让ProductDao、UserDao里的重复代码压缩一半以上。
本文还有配套的精品资源,点击获取