JavaWeb企业电子商城:从JSP/Servlet到事务并发扣库存的完整实践
2026/9/12 15:39:25 网站建设 项目流程

简介:基于JavaWeb的企业电子商城项目资源包,采用JSP+Servlet+MySQL技术栈,包含jdk1.8、Tomcat8、MySQL5.5及以上运行环境说明。资源面向计算机相关专业毕业生与Java实战学习者,覆盖首页、销售排行、新品上架、特价商品、购物车结算、订单查看、会员修改等完整电商流程,可直接作为毕设或课程二次开发参考。压缩包共205个文件、47.65MB,以JSP页面、Java源码、Class编译文件、Jar依赖库和SQL脚本为主,另有JPG/GIF素材、XML/TLD配置及MP4讲解视频,目录结构清晰。项目按MVC思想封装Action、Dao等核心类,数据访问与业务控制分工明确,配合脚本可快速初始化环境,便于理解JavaWeb分层开发与前后台交互。已有352人学习下载,适合毕设选题及综合能力提升。

1. 从课程设计到生产骨架:JavaWeb企业电子商城到底在讲什么

一个让人印象深刻的 JavaWeb 课程设计,往往不是用了多新框架的,而是把 JSP、Servlet、JDBC、Session 这些最基础的技术完整串起来并且还能讲清楚的项目。企业电子商城恰好是最典型的一组业务闭环:前台展示商品、后台管理库存、用户下单走完一个真实事务。它解决的实际问题是——在没有 Spring Boot 的日子里,Java Web 应用怎么组织分层、怎么管数据库连接、怎么防超卖、怎么拦未登录请求。标题里的“源码 + 数据库脚本 + 项目讲解”,对应学习链路上的三件事:会读代码、会导库、会讲设计。适合刚学完 Java 基础准备做课设的初学者,也适合面试前需要快速复习整套 JavaWeb 技术栈的工程师。

2. 数据库脚本先行:商城表结构设计与字段选型

2.1 商城系统该拆成哪几张核心表

做 JavaWeb 商城,第一步定数据库设计。功能域一般拆成:用户、商品分类、商品、购物车、订单、订单明细、收货地址。课程设计里这七张表就够,但既然叫“企业电子商城”,设计时至少留两块扩展余地:一是用户表要能区分前台会员和后台管理员,二是订单明细里要有商品快照字段。

快照字段是最容易被忽略的一处设计。订单明细如果只存商品 ID 外键,后台一旦改了商品价格,历史订单金额就跟着变了,售后和对账时会产生一堆说不清的差异。所以订单明细表里要存下单时刻的商品名、单价、小计,商品 ID 只作弱关联保留。这是项目讲解里最值得讲的取舍点。

建表脚本的主干如下:

CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(100) NOT NULL, `role` TINYINT NOT NULL DEFAULT 1 COMMENT '1前台用户 2后台管理员', `email` VARCHAR(100) DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 1, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `product` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(120) NOT NULL, `subtitle` VARCHAR(200) DEFAULT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `image_url` VARCHAR(255) DEFAULT NULL, `category_id` INT DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `deleted` TINYINT NOT NULL DEFAULT 0, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_info` ( `id` VARCHAR(32) NOT NULL, `user_id` INT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0新建 1已支付 2已发货 3已完成 4已取消', `receiver_name` VARCHAR(50) NOT NULL, `receiver_phone` VARCHAR(20) NOT NULL, `receiver_address` VARCHAR(200) NOT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` INT NOT NULL AUTO_INCREMENT, `order_id` VARCHAR(32) NOT NULL, `product_id` INT NOT NULL, `product_name` VARCHAR(120) NOT NULL COMMENT '商品快照', `product_price` DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', `quantity` INT NOT NULL, `sub_total` DECIMAL(10,2) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字段上有几个点值得较真。价格和金额都用 DECIMAL(10,2) 而不是 FLOAT/DOUBLE,因为浮点类型在二进制下无法精确保存 0.1,求和会产生 0.30000000000000004 之类的误差,数据库里的钱必须用定点数。订单号用 VARCHAR(32) 存雪花 ID 或时间戳加随机数组合,不用自增 ID,原因有二:自增 ID 会暴露订单量,且分库分表后无法保证全局唯一。商品表和用户表都加了 status 或 deleted 字段,走软删除而不是物理 DELETE,历史订单的关联数据不会断。

索引也要顺手建好。高频查询集中在 product 表的 category_id 和 status、order_info 表的 user_id、order_item 表的 order_id。小项目里这些索引看似无所谓,但商品量过万、订单过十万后,缺索引的查询会直接把数据库慢查询日志刷满。脚本里所有外键关联字段都建了 KEY,讲解时一句话带过即可。

2.2 字符集、引擎与导入导出脚本的约定

建表语句统一用 utf8mb4 而不是 utf8,因为 MySQL 的 utf8 只支持最多 3 字节字符,emoji 和生僻字存不进去,utf8mb4 才是完整的 4 字节 UTF-8 实现。引擎统一 InnoDB 是因为它支持事务和行级锁,这正是后面下单扣库存依赖的能力。

导入脚本时有一类高频报错:用 Navicat 或命令行导入 .sql 文件后中文变乱码。原因基本是文件保存的字符集和连接字符集不一致。导入前执行:

SET NAMES utf8mb4; SOURCE /path/to/eshop.sql;

SET NAMES utf8mb4会同时把客户端、连接层、服务端三层的字符集设为一致。用命令行SOURCE导入前,还要确认文件本身保存成 UTF-8 编码,Windows 记事本默认可能写成 GBK,最好在 IDE 里另存为 UTF-8 without BOM。

2.3 用初始化数据脚本替代手工造数

项目演示时商品列表不能是空的。价格、库存、分类这些数据如果在页面上手工录入,又慢又容易漏。数据库脚本里附带一段 INSERT 初始化数据,比备份后再手工改要可靠得多:

INSERT INTO product (`name`, `subtitle`, `price`, `stock`, `image_url`, `category_id`, `status`) VALUES ('Java核心技术', '第11版 卷I', 128.00, 200, '/images/java11.jpg', 1, 1), ('Spring实战', '第6版 中文版', 108.00, 150, '/images/spring6.jpg', 1, 1), ('第一行代码 Android', '第3版', 99.00, 80, '/images/android3.jpg', 2, 1);

初始化数据有两个用途:一是演示时页面直接有真实感,二是讲解“统计每个分类的销售额”这类 SQL 问题时,有现成数据可以跑。脚本里的数据量不用大,每个分类三五条就够,压测数据后续用独立造数脚本去生成。

3. 源码跑通的关键配置:分层结构、连接池与登录拦截

3.1 项目骨架怎么组织

拿到源码先看包结构,而不是直接看页面。一个能拿来讲的 JavaWeb 商城源码,包结构通常长这样:

controller 或 servlet 包放请求入口,service 放业务逻辑,dao 放 SQL 访问,model 或 entity 放实体类,filter 放统一编码和登录拦截,util 放工具类,dto 放参数传对象。分层的目的不是包名好看,而是出问题时知道去哪里找:页面报 500 错误去 controller 查参数,数据不对去 dao 查 SQL,业务规则不对去 service 查流程。

Servlet 的 API 包名有个版本陷阱。旧项目用javax.servlet,新版本规范改成了jakarta.servlet,这取决于 Tomcat 版本。Tomcat 9 及以下用 javax,Tomcat 10 以上用 jakarta。导入源码后一编译就报“找不到 javax.servlet 包”,多半是 Tomcat 10 的运行时和旧源码混在一起了。

3.2 连接池配置与 db.properties 参数说明

每次请求都DriverManager.getConnection是不行的,那意味着每个请求都要新建 TCP 连接、做数据库权限校验,性能差一个量级。常见做法是配一个连接池,JavaWeb 项目里最常用的是 Druid。

db.properties 的典型内容:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/eshop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=123456 druid.initialSize=5 druid.minIdle=5 druid.maxActive=20 druid.maxWait=60000

参数说明:driver 写的com.mysql.cj.jdbc.Driver是 MySQL 8.x 驱动,如果用的是 5.1.x 驱动要改回com.mysql.jdbc.Driver,两者不兼容。url 里的serverTimezone必须指定,MySQL 8.0 及以上会因默认时区问题报错,用Asia/Shanghai而不是 UTC,否则时间字段读写差 8 小时。useSSL=false是因为本地开发不需要加密连接。

连接池参数里initialSize是启动时创建的连接数,minIdle是最小空闲连接数,maxActive是最大活动连接数,maxWait是拿不到连接时最多等待的毫秒数。课程设计项目maxActive=20完全够用,不需要为调参而调参。

连接池初始化放在ServletContextListenercontextInitialized里,加载配置并创建DruidDataSource实例,存到ServletContext中供所有 Servlet 共享。讲解时肯定会被问“为什么不用 DriverManager”,回答思路:数据库连接是稀缺资源,复用比每次创建稳定得多。

3.3 登录拦截与请求编码:两个必写的 Filter

JavaWeb 里讲 Filter 一定绕不开登录拦截。商城要同时管前台用户和后台管理员两套权限,用 Filter 做统一拦截比在每个 Servlet 里重复判断干净得多。

@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; // 放行登录页本身,避免重定向死循环 String uri = req.getRequestURI(); if (uri.endsWith("/admin/login") || uri.endsWith("/admin/login.jsp")) { chain.doFilter(request, response); return; } // 从Session中取管理员标记,不存在则跳回登录页 Object admin = req.getSession().getAttribute("adminUser"); if (admin == null) { resp.sendRedirect(req.getContextPath() + "/admin/login.jsp"); return; } chain.doFilter(request, response); } }

逻辑说明:这个 Filter 只拦截/admin/*路径。第一段判断把登录页自己放行,否则用户还没登录就被重定向,登录页也被拦截,形成重定向死循环。第二段检查 Session 中是否有adminUser属性,没有就重定向到登录页。注意用endsWith判断 URI 结尾,而不是用contains,否则带参数的请求被误判。

编码问题单靠页面<meta charset>不够,请求和响应都要有统一的编码过滤器。Spring 里有现成的CharacterEncodingFilter,纯 Servlet 项目得自己写。乱码问题 90% 出在编码 Filter 没写,或者 filter-mapping 顺序不对——编码 Filter 必须排在所有业务 Filter 的最前面。

3.4 部署参数对照表

运行时组件推荐版本关键配置
JDK8 或 11编译级别与 IDEA Project Structure 一致
Tomcat8.5(javax)/ 10.1(jakarta)端口不冲突,路径无中文
MySQL5.7 / 8.0驱动版本与 serverTimezone 对应
Maven3.6 以上依赖源配阿里云镜像

Tomcat 的 server.xml 里,Connector 建议加上URIEncoding="UTF-8"。Tomcat 8 及以上默认就是 UTF-8,但显式声明无害,做排错时也能排除一个变量。

4. 下单链路:购物车、事务和并发扣库存

4.1 购物车数据放在 Session 还是数据库表

商城项目里有个经典选择题:购物车存 Session 还是存表。纯课程设计大多把购物车对象放 Session 里,好处是实现快、不用查库,坏处是用户换设备购物车就没了。既然标题叫“企业电子商城”,我一般建议购物车落库,单独建 cart 表,记录 user_id、product_id、quantity。

落库的最大收益是能统计加购转化率,这是企业运营真正关心的数据。实现成本并没有高多少:读取购物车就是按 user_id 查一张表,加购就是INSERT ... ON DUPLICATE KEY UPDATE。这个选择在项目讲解时是个很好的区分点——能说清为什么这么选,比单纯会写代码更有说服力。

4.2 下单过程为什么必须是一个事务

商城业务里最适合讲“事务必要性”的就是下单。一笔订单要做这几件事:扣商品库存、生成订单主表记录、生成订单明细记录。如果扣了库存但订单没生成,库存凭空消失;如果订单生成但没扣库存,就超卖了。这两组操作必须同时成功或同时失败。

传统 JDBC 下的事务控制要借助同一个 Connection 完成。连接获取、提交、回滚放在同一个方法里,DAO 方法接收这个连接作为参数:

public boolean createOrder(OrderInfo order, List<OrderItem> items) { Connection conn = null; PreparedStatement ps = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // 扣库存:把库存校验放进WHERE条件里,天然防超卖 String deductSql = "UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?"; for (OrderItem item : items) { ps = conn.prepareStatement(deductSql); ps.setInt(1, item.getQuantity()); ps.setInt(2, item.getProductId()); ps.setInt(3, item.getQuantity()); int rows = ps.executeUpdate(); if (rows == 0) { throw new RuntimeException("库存不足: " + item.getProductId()); } ps.close(); } // 插入订单主表 ps = conn.prepareStatement( "INSERT INTO order_info (id, user_id, total_amount, status, receiver_name, receiver_phone, receiver_address) " + "VALUES (?,?,?,0,?,?,?)"); ps.setString(1, order.getId()); ps.setInt(2, order.getUserId()); ps.setBigDecimal(3, order.getTotalAmount()); ps.setString(4, order.getReceiverName()); ps.setString(5, order.getReceiverPhone()); ps.setString(6, order.getReceiverAddress()); ps.executeUpdate(); // 插入订单明细(含商品快照) for (OrderItem item : items) { ps = conn.prepareStatement( "INSERT INTO order_item (order_id, product_id, product_name, product_price, quantity, sub_total) " + "VALUES (?,?,?,?,?,?)"); ps.setString(1, order.getId()); ps.setInt(2, item.getProductId()); ps.setString(3, item.getProductName()); ps.setBigDecimal(4, item.getProductPrice()); ps.setInt(5, item.getQuantity()); ps.setBigDecimal(6, item.getSubTotal()); ps.executeUpdate(); ps.close(); } conn.commit(); return true; } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { // 回滚失败时记录日志,避免吞异常 ex.printStackTrace(); } } return false; } finally { if (ps != null) { try { ps.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }

代码逻辑说明:核心是setAutoCommit(false)commit()之间的三步必须用同一个连接。扣库存的 SQL 写成UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?,这是一行最关键的设计:把库存是否足够的判断放进 SQL 的 WHERE 条件里,数据库的行锁保证并发时只有一个事务能成功扣减,库存不足时受影响行数为 0,直接抛异常回滚。这一条语句挡住了大多数并发超卖问题。

事务边界要收敛。Connection 从连接池取,用完的close()是归还给连接池而不是真正断开。资源释放写在 finally 中,顺序是先关闭 Statement 再关闭 Connection。如果内存中连接不归还,连接池很快耗尽,表现是系统运行一段时间后所有请求卡在获取连接上。

4.3 并发扣库存的三种方案对比

方案实现并发安全性适用场景
条件更新(推荐)UPDATE ... WHERE stock >= ?中小并发直接可用
悲观锁SELECT ... FOR UPDATE高,但锁等待时间长库存操作频繁且冲突高
乐观锁UPDATE ... WHERE version = ?中,需重试机制冲突概率低的写操作

条件更新的底层机制是 InnoDB 的行锁:UPDATE 执行时,命中的行被加上排他锁,直到事务提交才释放。第二个并发事务的相同 UPDATE 会阻塞在锁上,等第一个事务提交后,它再执行时库存已经不满足stock >= ?的条件,受影响行数为 0。所以不需要额外写SELECT ... FOR UPDATE,也不需要额外的 version 字段。

乐观锁通常用于多读少写、冲突概率低的场景,比如用户修改个人资料。扣库存如果用乐观锁,并发冲突频繁时用户体验很差,不断提示“操作失败请重试”。讲解时能说清楚不同方案的适用边界,比背概念加分得多。

4.4 防重复提交:容易被忽略的电商必备逻辑

下单接口还有一个典型问题:用户双击下单按钮,或网络慢时刷新页面,会产生两笔一模一样的订单。前端禁用按钮防不住构造请求的恶意重复提交,后端必须有幂等控制。

常见做法是下单前查订单状态,或者在订单主表加唯一约束——把 user_id 和 order_mark(每次请求生成的一个随机令牌)组成唯一索引,第二次插入直接因唯一键冲突失败。这个点在源码讲解里常被跳过,但企业级项目里属于基本要求,写进数据库脚本里成本极低。

5. 项目讲解的三个技巧与两个必改的坑

5.1 把讲解讲成一场设计评审

拿到源码之后要能讲得出来。讲解不要按页面顺序过一遍,那样太平。我习惯的顺序是:先画业务主线——浏览商品、加购物车、下单、支付、后台发货,再从每个环节倒推表结构和关键代码。讲到下单模块时,把事务和并发扣库存作为重点,讲清楚为什么用条件更新而不是同步锁,这一段的分量顶得上整场讲解的一半。

5.2 两个必改的坑

第一个坑是密码明文存储。大量教学源码的 user 表密码直接明文落库,演示能跑通,但提到企业级就会被质疑。至少要改成 MD5 加盐,给出可行的改造路径:

// 生成8位随机盐 String salt = UUID.randomUUID().toString().substring(0, 8); // 加盐后做一次摘要 String encrypted = DigestUtils.md5Hex(salt + rawPassword);

存 salt 和 encrypted 两个字段,校验时用同样的盐重算比对。这不算强安全方案,但比明文强一个量级,也能证明你理解密码不能明文入库。生产环境再升级到 BCrypt 或 PBKDF2。

第二个坑是 SQL 注入。不少 JSP 页面还在用字符串拼接方式传 SQL 参数。改用 PreparedStatement 的占位符传参,这个改动覆盖所有增删改查。讲解时可以直接说:凡是把用户输入直接拼进 SQL 的写法,在评审里属于一票否决项。

5.3 从课程设计走向生产的成长路径

最后给一条可操作的路径。小商城跑通后,下一步不要急着上 Spring Boot,先把 DAO 层换成 MyBatis,体会 SQL 与 Java 代码分离的变化;再用 Redis 缓存商品详情,感受读多写少场景下的性能提升;最后引入前后端分离,把 JSP 换成 Vue 或纯 HTML 加接口。三步走完,这个 JavaWeb 商城才真正开始接近“企业级”。

密码加盐、防重复提交、条件更新扣库存这三件事做完再讲,项目的含金量会明显不同。讲解时把代码行指给听众看,比任何总结都更有说服力。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询