简介这是一套面向Java Web入门者的网上购物商城系统实战练习资源适合刚接触Servlet与JSP、希望打通前后端数据交互链路的同学。项目采用原生JDBC连接数据库前端引入layui框架完整实现了登录、购物车与商品展示三大核心功能并附带SQL脚本可直接导入运行。压缩包共201个文件约5.5MB包含31个Java源文件与对应class文件、4个JSP页面、30个JS脚本、5个CSS样式及6个XML配置另有gif演示图与jar依赖结构清晰便于对照学习。其中前端通过layui table完成数据传值并借助layui模板实现总价与订单价格的实时联动Servlet负责数据组装使返回结果能被表格组件正确接收。目前已有1655人学习下载可作为理解JDBC配置、Servlet请求处理与前端渲染衔接的入门范例为后续过渡到MyBatis、Spring Boot等框架打下基础。1. 从零手搓 JSPServletJDBC 购物系统为什么它仍是理解 Web 底层的最佳练手项目很多人第一次接触 Java Web 就是从「网上购物商系统」开始的。你打开一个商品列表页点「加入购物车」再点「去结算」背后其实是一条完整的请求链路浏览器发 HTTP 请求 → Servlet 接收并解析参数 → JDBC 拿连接查数据库 → 把结果塞进 JSP 渲染成 HTML 返回。这条链路今天被 Spring Boot 和 MyBatis 封装得几乎看不见了但一旦线上出问题——连接池耗尽、事务没回滚、中文乱码——你还是得回到这三样东西本身去找原因。这个项目适合两类人一是刚学完 Java SE、想搞明白「网页到底怎么动起来」的新手二是用了很久框架、但说不清doGet和doPost区别、SQL 注入怎么防的熟手。它不高级但它是黑匣子被打开的样子。下面我按真实搭建顺序把库表、连接、Servlet 到 JSP 的每一步拆开讲带 SQL 脚本能直接复现。2. 库表设计与 SQL 脚本五张表撑起一个购物流程2.1 为什么是五张表而不是三张一个能跑通「浏览 → 加购 → 下单」的最小购物系统核心实体有四个用户、商品、购物车、订单。购物车和订单之间是多对多关系所以订单明细必须单独一张表。加上用户表、商品表一共五张。常见做法是表名作用关键字段user存账号密码id, username, password, phoneproduct商品信息id, name, price, stock, imgcart购物车条目id, user_id, product_id, numorders订单主表id, user_id, total, status, create_timeorder_item订单明细id, order_id, product_id, num, price这里有个血泪经验orders不要写成order因为ORDER是 SQL 关键字MySQL 里虽然能用反引号绕过去但换到别的库或者 ORM 映射时容易翻车。我一般直接加 s。2.2 可直接执行的建表脚本-- 建库字符集必须指定 utf8mb4否则中文和 emoji 都会乱码 CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE shop; -- 用户表password 存明文仅用于练习真实项目必须加盐哈希 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 商品表stock 用于下单时扣减price 用 DECIMAL 不用 FLOAT CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, img VARCHAR(200), intro VARCHAR(500) ); -- 购物车user_id product_id 建唯一索引防止同一商品重复插行 CREATE TABLE cart ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, num INT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_product (user_id, product_id) ); -- 订单主表status 用 0 待付款 1 已付款 2 已发货 3 已完成 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, total DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 订单明细price 要冗余存一份因为商品价格会变订单要留快照 CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, num INT NOT NULL, price DECIMAL(10,2) NOT NULL ); -- 插几条测试数据 INSERT INTO product(name, price, stock, intro) VALUES (机械键盘, 299.00, 50, 87 键青轴), (无线鼠标, 89.00, 100, 2.4G 静音), (显示器, 899.00, 20, 24 寸 IPS);脚本里三个参数值得说清楚。第一utf8mb4而不是utf8MySQL 的utf8是三字节的存不下 emoji商品名里带个表情就报错。第二金额用DECIMAL(10,2)FLOAT做累加会出现0.10.20.30000000000000004这种玄学订单总额对不上账。第三order_item里的price是故意冗余的商品调价后历史订单金额不能跟着变这是业务正确性问题不是设计冗余。2.3 唯一索引在购物车里的作用cart表的uk_user_product唯一索引配合后面 JDBC 里的INSERT ... ON DUPLICATE KEY UPDATE num num ?就能实现「同一商品重复加购只累加数量」。如果不加这个索引用户连点两次「加入购物车」就会插两行购物车列表出现重复条目这是新手最常见的翻车点之一。3. JDBC 工具类与连接管理把 DriverManager 关进一个类里3.1 为什么必须抽一个 DBUtil如果每个 Servlet 里都写一遍Class.forName、DriverManager.getConnection、close代码会重复到无法维护而且极容易忘记关连接跑几十次请求数据库连接就满了。我一般会抽一个DBUtil把「拿连接」和「关资源」两件事封起来。注意这里用的是最朴素的DriverManager不是连接池——练习阶段够用但你要知道它的边界每次请求都新建物理连接并发一高就顶不住生产必须换 Druid 或 HikariCP。3.2 工具类代码与参数说明import java.sql.*; public class DBUtil { // 四个参数驱动、URL、用户名、密码 // useSSLfalse 关掉 SSL 警告serverTimezone 必须设否则时间差 8 小时 private static final String URL jdbc:mysql://localhost:3306/shop?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4; private static final String USER root; private static final String PWD your_password; // 静态块只加载一次驱动避免每次 getConnection 都反射 static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(驱动加载失败, e); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } // 统一关闭顺序必须是 ResultSet - Statement - Connection public static void close(Connection c, Statement s, ResultSet r) { try { if (r ! null) r.close(); } catch (SQLException ignored) {} try { if (s ! null) s.close(); } catch (SQLException ignored) {} try { if (c ! null) c.close(); } catch (SQLException ignored) {} } }参数逐个说。serverTimezoneAsia/Shanghai不写的话MySQL 8 驱动会抛The server time zone value is unrecognized或者存进去的时间差 8 小时这是最高频的报错。characterEncodingutf8mb4保证连接层编码和库表一致否则中文商品名查出来是问号。com.mysql.cj.jdbc.Driver是 MySQL 8 的驱动类名如果你用的是 5.x 的 jar 包类名是com.mysql.jdbc.Driver写错了会报ClassNotFoundException。3.3 用 try-with-resources 替代手动 close上面那个close方法是为了兼容老代码新写的代码我更推荐 try-with-resources编译器帮你关不会漏public static void main(String[] args) throws SQLException { String sql SELECT id, name, price FROM product WHERE stock ?; // 资源写在 try 的括号里出了作用域自动 close顺序是反的 try (Connection c DBUtil.getConn(); PreparedStatement ps c.prepareStatement(sql)) { ps.setInt(1, 0); // 第一个问号赋值为 0 try (ResultSet rs ps.executeQuery()) { while (rs.next()) { System.out.println(rs.getString(name) rs.getBigDecimal(price)); } } } }PreparedStatement的?占位符不只是为了好看它是防 SQL 注入的关键。如果你用字符串拼接WHERE name name 用户输入 OR 11就能把整张表拖出来。setInt、setString会把参数当数据而不是 SQL 语法处理这是 JDBC 层面最值钱的一个习惯。4. Servlet 处理请求与 JSP 渲染把数据从后台送到页面4.1 一个商品列表 Servlet 的完整写法Servlet 是控制器负责接参数、调 JDBC、把结果放进 request 域然后转发给 JSP。下面这个ProductListServlet是购物系统里最典型的读操作WebServlet(/product/list) public class ProductListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置响应编码必须在 getWriter 之前否则中文乱码 resp.setContentType(text/html;charsetutf-8); ListProduct list new ArrayList(); String sql SELECT id, name, price, stock, intro FROM product ORDER BY id DESC; try (Connection c DBUtil.getConn(); PreparedStatement ps c.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Product p new Product(); p.setId(rs.getInt(id)); p.setName(rs.getString(name)); p.setPrice(rs.getBigDecimal(price)); p.setStock(rs.getInt(stock)); p.setIntro(rs.getString(intro)); list.add(p); } } catch (SQLException e) { // 练习阶段直接抛生产要记日志并给用户友好页面 throw new ServletException(查询商品失败, e); } // 把 list 放进 request 域JSP 用 EL 表达式取 req.setAttribute(products, list); // 转发浏览器地址栏不变如果用 redirect 则 request 域数据会丢 req.getRequestDispatcher(/product_list.jsp).forward(req, resp); } }三个关键点。第一resp.setContentType必须在写任何输出之前调用写在getWriter()后面就无效了这是乱码排查的第一顺位。第二forward是服务器内部跳转request域里的products能带到 JSPsendRedirect是让浏览器重新发请求域数据全丢很多人在这里踩坑。第三WebServlet注解要求web.xml的metadata-completefalse如果你在web.xml里写了metadata-completetrue注解会被忽略访问 404。4.2 JSP 里怎么安全地取数据JSP 只负责展示不要在里面写 JDBC。用 JSTL 的c:forEach遍历用c:out输出能自动做 HTML 转义防止 XSS% page contentTypetext/html;charsetUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table border1 trth名称/thth价格/thth库存/thth操作/th/tr c:forEach items${products} varp tr tdc:out value${p.name}//td td${p.price}/td td${p.stock}/td td a href${pageContext.request.contextPath}/cart/add?pid${p.id}加入购物车/a /td /tr /c:forEach /table${pageContext.request.contextPath}拿到的是项目部署路径比如/shop。如果直接写/cart/add项目名不是 ROOT 时就会 404这是部署到 Tomcat 后最常见的路径问题。c:out会把商品名里的script转义成lt;scriptgt;如果直接用${p.name}商品名里塞一段脚本就能在别人浏览器里执行这就是存储型 XSS。4.3 加入购物车的写操作与事务加购涉及「查是否已存在 → 存在则加数量不存在则插入」用一条 SQL 搞定WebServlet(/cart/add) public class CartAddServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 从 session 拿登录用户没登录就跳登录页 User user (User) req.getSession().getAttribute(loginUser); if (user null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } int pid Integer.parseInt(req.getParameter(pid)); String sql INSERT INTO cart(user_id, product_id, num) VALUES(?,?,1) ON DUPLICATE KEY UPDATE num num 1; try (Connection c DBUtil.getConn(); PreparedStatement ps c.prepareStatement(sql)) { ps.setInt(1, user.getId()); ps.setInt(2, pid); ps.executeUpdate(); } catch (SQLException e) { throw new ServletException(加购失败, e); } resp.sendRedirect(req.getContextPath() /cart/list); } }ON DUPLICATE KEY UPDATE依赖第 2 章建的唯一索引没有索引它就不会触发更新分支而是直接插重复行。Integer.parseInt如果参数不是数字会抛NumberFormatException生产代码要 try-catch 并返回 400练习阶段可以先不管但你要知道这个边界。5. 避坑与排查五个让新手卡半天的真实问题5.1 中文乱码现象是页面显示问号原因是编码链没对齐现象商品名在数据库里正常页面上显示???。原因请求、响应、数据库连接、库表四层编码任意一层不是 utf8mb4 都会乱。解决库表用utf8mb4JDBC URL 加characterEncodingutf8mb4Servlet 里resp.setContentType(text/html;charsetutf-8)JSP 页头pageEncodingUTF-8。四层全对齐缺一不可。5.2 404现象是访问 Servlet 报找不到原因是路径或注解没生效现象浏览器访问/product/list返回 404。原因通常是三种web.xml里metadata-completetrue导致WebServlet被忽略url-pattern写成了/product/list/多了斜杠项目部署路径不是 ROOT链接里没加contextPath。解决先看 Tomcat 启动日志有没有注册该 Servlet再检查web.xml的metadata-complete最后统一用${pageContext.request.contextPath}拼路径。5.3 连接耗尽现象是跑一会儿就卡死原因是连接没关现象连续刷新几十次后页面一直转圈日志报Too many connections。原因某个分支抛异常后close没执行连接泄漏。解决全部改用 try-with-resources或者把close放在finally里。练习阶段可以在 MySQL 里执行SHOW PROCESSLIST看有多少连接挂着一眼就能定位。5.4 时间差 8 小时现象是订单时间不对原因是时区没设现象create_time存进去比实际时间早 8 小时。原因JDBC URL 没写serverTimezone驱动用了 UTC。解决URL 加serverTimezoneAsia/Shanghai同时确认 MySQL 服务器时区。这个坑在跨时区部署时更明显本地开发也建议显式写上。5.5 金额精度丢失现象是总额对不上原因是用了 FLOAT现象购物车三件商品加起来是 299.0089.00899.00结果算出 1286.9999999。原因FLOAT/DOUBLE是二进制浮点无法精确表示十进制小数。解决库表用DECIMAL(10,2)Java 侧用BigDecimal并且用BigDecimal.valueOf(double)而不是new BigDecimal(double)后者会把浮点误差原样带进来。6. 进阶技巧用过滤器统一编码与登录校验项目能跑通之后你会发现每个 Servlet 都在重复写编码设置和登录判断。这时候该上Filter了。下面这个编码过滤器一行配置解决全站乱码WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { // 请求和响应都设 utf-8覆盖所有路径 req.setCharacterEncoding(utf-8); resp.setContentType(text/html;charsetutf-8); // 放行交给下一个过滤器或 Servlet chain.doFilter(req, resp); } }WebFilter(/*)里的/*表示拦截所有请求包括静态资源。如果你只想拦动态请求可以写成/product/*这种前缀匹配。登录校验过滤器同理在doFilter里判断 session 有没有loginUser没有就sendRedirect到登录页放行登录页和登录接口本身其余全部拦截。这样CartAddServlet里那段登录判断就可以删掉了。再进一步把 JDBC 的DriverManager换成连接池只需要改DBUtil.getConn()的内部实现上层 Servlet 一行不用动——这就是当初抽工具类的回报。我自己的习惯是任何项目只要 JDBC 出现第二次就立刻抽工具类只要编码设置出现第二次就立刻上过滤器。这两条习惯帮我省掉了大量重复排查的时间。希望帮到你。本文还有配套的精品资源点击获取