
简介这是一套面向Java Web入门与课程设计场景的游戏购买网站源码采用JSP、Servlet、DAO与MySQL搭建适合正在学习Java Web分层开发、需要完成课程设计或毕业设计的学生参考。系统区分管理员与普通用户两类角色管理员可登录后台完成订单管理、客户管理、游戏管理与类目管理用户端支持首页浏览、登录注册、购买游戏、查看购物车、提交订单与查询订单可据此理解从表现层到数据访问层的完整调用链路。压缩包共246个文件、约14.85MB包含55个java源文件与55个class文件、20个jsp页面以及css、js、图片等静态资源另附sql脚本与jar依赖目录按功能模块组织便于逐层阅读与二次改造。资源已有98人学习下载可作为课程设计模板或分层架构练习素材帮助读者掌握Servlet请求处理、DAO封装与数据库表设计的基本思路。1. 一个结算后库存归零的下午这套 JSPServletMySQL 游戏购买网站到底能干什么有个同学把电脑搬到我工位上说他的游戏购买网站“能登录、能加购物车、能下单就是下完单库存一点没少”。打开工程一看JSP 页面里嵌着Class.forNameServlet 里直接写 SQL下单时每张表各自开一个连接中途出错谁也没回滚。这不是他写错了某一行而是这类 JSPServletMySQL 游戏购买网站最典型的通病——页面、业务、数据访问全糊在一层里。这套系统本身要做的事很清楚给玩家一个能浏览游戏、搜索分类、加购物车、下单支付、查看订单的后台给管理员一个能上架游戏、管理库存、处理订单的运营界面。技术上它就是原生 Java Web 那一套JSP 渲染视图、Servlet 接请求、JDBC 连 MySQL、Tomcat 做容器不依赖任何重型框架。适合谁适合想真正搞懂“一个 HTTP 请求从浏览器到数据库再回来”整条链路的开发者也适合需要在课程或实践里交出一个结构完整、能跑能改的 Java Web 工程的人。2. 技术栈与工程骨架MVC 三层怎么切、目录怎么摆拿到一个 Java Web 工程我第一件事不是看代码是看目录。目录结构是最诚实的架构说明书它一眼就能告诉你作者有没有分层、有没有把视图和业务隔开、有没有给改数据库留后路。2.1 技术选型为什么这套系统不引入框架原生 JSP Servlet 这套组合在现在的技术栈里显得很“老”但对这个游戏购买网站来说它恰好把每一层都摊开摆在明面上。用 Spring Boot 的话Transactional注解一挂事务就开了很多人写了两年也不知道连接是怎么从池里借出来又还回去的而这里你得自己setAutoCommit(false)、自己commit()、自己rollback()踩过一次不回滚的坑这辈子都记得。常见的依赖组合是这样JDK 8、Tomcat 8.5 或 9.x、MySQL 5.7 或 8.0、JDBC 驱动mysql-connector-java、JSTL 标签库jstl.jar和standard.jar、连接池用 c3p0 或 Druid前端配一套 Bootstrap 加 jQuery 就够撑起商城页面。全部 jar 包丢进WEB-INF/lib不用 Maven 也能跑用 Maven 就把依赖写进pom.xml打包成 war 后可移植性更好。这里有个选型上的取舍值得说清楚JSP 负责渲染Servlet 负责流程编排DAO 负责 SQL三者职责不能串。我看到过不少工程为了让页面“灵活”直接在 JSP 里new 一个 Dao查数据结果是页面一刷新就多一次数据库连接压力测试时连接池直接被打空。分层不是形式主义它是资源管理的边界。2.2 目录结构webapp 下的每个文件夹都有规矩把工程摊开标准的 Maven Web 结构大致长这样game-shop/ ├── pom.xml └── src/main/ ├── java/com/example/gameshop/ │ ├── bean/ # 实体User、Game、Orders、OrderItem、CartItem │ ├── dao/ # 数据访问BaseDao、GameDao、OrderDao、UserDao │ ├── service/ # 业务OrderService、GameService可省但不能乱塞 │ ├── servlet/ # 控制GameListServlet、CartServlet、OrderServlet │ ├── filter/ # 编码过滤器、登录拦截过滤器 │ └── util/ # JdbcUtils、PageBean、MD5Utils └── webapp/ ├── WEB-INF/ │ ├── web.xml # 欢迎页、错误页、Filter 顺序 │ └── views/ # JSP 放这里禁止浏览器直接访问 ├── static/ # css / js / img走静态资源 ├── upload/ # 游戏封面图上传目录 ├── index.jsp # 首页只做转发 └── login.jsp关键点有三个。一是 JSP 统一放进WEB-INF/views这样浏览器无法通过地址栏直连 JSP所有页面必须经过 Servlet 转发权限判断才有落点。二是upload目录要能在运行时拿到真实磁盘路径用request.getServletContext().getRealPath(/upload)而不是写死D:/...换台机器才不会崩。三是bean包里的类必须是纯 POJO属性私有、getter/setter 公开不要在实体里塞业务方法否则后面接 JSON 接口时会很难受。2.3 数据库设计四张核心表撑起整个商城游戏购买网站的数据模型其实不复杂主链路就是“用户买游戏”。四张表足够跑通用户表、游戏表、订单主表、订单明细表。表名关键字段说明userid、username、password、email、role、create_timerole 区分普通用户和管理员密码存 MD5 摘要gameid、name、category_id、price、stock、cover、status、descriptionprice 用 decimal(10,2)status 控制上架/下架ordersid、order_no、user_id、total_amount、status、create_time表名不能叫 order那是 SQL 保留字order_itemid、order_id、game_id、game_name、price、num下单时把游戏名和单价冗余存一份防止改价影响历史订单建表时把这几条约束加进去能省掉后面一半的排查时间CREATE TABLE game ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category_id INT NOT NULL DEFAULT 1, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0, cover VARCHAR(255) DEFAULT default.jpg, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, description TEXT, KEY idx_category (category_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;price用DECIMAL而不是DOUBLEstock加NOT NULL DEFAULT 0存储引擎用 InnoDB 才有事务和行锁。order_item里冗余game_name和price看着像反范式但订单是历史凭证游戏改价改名之后旧订单必须还原当时的成交信息这个冗余是有意为之。3. 核心链路拆解一个下单请求穿过 Filter、Servlet、DAO 的完整走法用户点“立即购买”的那一瞬间浏览器发出一个 POST这个请求依次经过编码过滤器、登录拦截器、订单 Servlet、业务层、DAO 层最后落到 MySQL。这条链路上任何一环少写一句后面都会以“库存不对”“订单重复”“乱码”的形式爆发出来。3.1 编码过滤器乱码问题的第一道闸中文乱码在这类工程里出现的频率高到离谱最常见的场景是游戏名带中文提交到后台变成问号存进数据库再取出来就是一堆???。根因是 POST 请求体默认按 ISO-8859-1 解码必须显式声明 UTF-8。WebFilter(/*) // 拦截所有请求注意 Filter 顺序编码必须在最前 public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 请求体按 UTF-8 解码解决 POST 中文乱码 request.setCharacterEncoding(UTF-8); // 响应头声明 UTF-8解决输出中文乱码 response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }WebFilter(/*)里的/*是匹配规则代表拦截全部请求如果只写/game/*那登录页提交的中文就漏掉了。setCharacterEncoding必须在第一次调用getParameter之前执行否则不生效——这是最容易翻车的地方很多人把过滤器写在web.xml里但filter-mapping排在了其他过滤器后面。响应侧的setContentType只影响输出流编码不解决请求体解码两者要一起写。3.2 连接池与 JDBC 工具类把 getConnection 关进笼子新手写 JDBC 最典型的写法是每个 DAO 方法里Class.forName加DriverManager.getConnection一个页面查三次数据库就建三次物理连接几百并发直接打满 MySQL 的max_connections。正确做法是连接池加一个静态工具类全工程只从这里拿连接、只从这里还连接。public class JdbcUtils { private static final ComboPooledDataSource DS new ComboPooledDataSource(); // 静态块只执行一次池子全局复用 static { try { DS.setDriverClass(com.mysql.cj.jdbc.Driver); DS.setJdbcUrl(jdbc:mysql://localhost:3306/game_shop ?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai); DS.setUser(root); DS.setPassword(123456); DS.setInitialPoolSize(5); // 初始连接数 DS.setMaxPoolSize(20); // 最大连接数别超过 MySQL 的 max_connections DS.setCheckoutTimeout(3000); // 拿不到连接时 3 秒超时避免线程无限等待 } catch (Exception e) { throw new ExceptionInInitializerError(连接池初始化失败 e.getMessage()); } } public static Connection getConnection() throws SQLException { return DS.getConnection(); // 从池里借不是新建 } public static void close(ResultSet rs, Statement st, Connection conn) { // 关闭顺序ResultSet - Statement - Connection逐个判空 try { if (rs ! null) rs.close(); } catch (SQLException ignored) {} try { if (st ! null) st.close(); } catch (SQLException ignored) {} try { if (conn ! null) conn.close(); } catch (SQLException ignored) {} // 还回池 } }连接串里的characterEncodingutf8是防乱码的第二道保险serverTimezone在 MySQL 8 驱动下不加会抛时区异常。setMaxPoolSize(20)这个值不是越大越好池子大小要跟 Tomcat 的maxThreads匹配配成 200 但数据库只支持 100 连接结果就是把数据库压垮。checkoutTimeout设短一点池子耗尽时快速失败返回错误页比整个请求线程挂在那里强。这里必须提醒一句conn.close()在连接池语境下不是真的关闭是归还。所以千万别在 DAO 里调完close()又拿着这个 Connection 去执行下一条 SQL那会抛Connection is closed。事务场景下连接要一路传下去不能中途还回去。3.3 DAO 层模板方法把重复代码压到一处每个实体都写一遍“拿连接、拼 SQL、设参数、执行、关资源”代码量会膨胀得离谱。我一般抽一个BaseDao把增删改的骨架固定下来子类只传 SQL 和参数。public abstract class BaseDao { /** 通用更新insert / update / delete 都能用 */ public int update(String sql, Object... params) { Connection conn null; PreparedStatement ps null; try { conn JdbcUtils.getConnection(); ps conn.prepareStatement(sql); // 参数从 1 开始注意别写成 i for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(SQL 执行失败 sql, e); } finally { JdbcUtils.close(null, ps, conn); } } /** 通用查询由子类实现行映射避免反射性能损耗 */ public T ListT query(String sql, RowMapperT mapper, Object... params) { Connection conn null; PreparedStatement ps null; ResultSet rs null; ListT list new ArrayList(); try { conn JdbcUtils.getConnection(); ps conn.prepareStatement(sql); for (int i 0; i params.length; i) ps.setObject(i 1, params[i]); rs ps.executeQuery(); while (rs.next()) list.add(mapper.mapRow(rs)); // 每行交给子类映射 } catch (SQLException e) { throw new RuntimeException(查询失败 sql, e); } finally { JdbcUtils.close(rs, ps, conn); } return list; } }RowMapperT是一个只有一个方法的接口T mapRow(ResultSet rs) throws SQLException。这样做的好处是 SQL 和对象映射彻底解耦GameDao只关心“这一行怎么变成 Game 对象”不用管连接和异常。Object... params是可变参数调用时update(sql, name, price, stock)直接传setObject会自动处理类型BigDecimal也能原样进去。再看游戏列表的分页查询这是商城首页的核心 SQLpublic class GameDao extends BaseDao { public ListGame findByPage(String keyword, int page, int size) { StringBuilder sql new StringBuilder( SELECT id, name, price, stock, cover, description FROM game WHERE status 1); ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { sql.append( AND name LIKE ?); params.add(% keyword.trim() %); // 模糊查询必须拼通配符 } sql.append( ORDER BY id DESC LIMIT ?, ?); params.add((page - 1) * size); // offset params.add(size); // 每页条数 return query(sql.toString(), rs - { Game g new Game(); g.setId(rs.getInt(id)); g.setName(rs.getString(name)); g.setPrice(rs.getBigDecimal(price)); g.setStock(rs.getInt(stock)); g.setCover(rs.getString(cover)); return g; }, params.toArray()); } }LIMIT ?, ?第一个参数是偏移量第二个是条数页数从 1 开始算所以偏移量是(page-1)*size。这里刻意没用SELECT *字段写死让索引覆盖更可控也防止以后加了大字段比如详情富文本把列表接口拖慢。status 1过滤掉下架游戏这个条件不能交给前端做否则用户改个请求参数就能看到下架商品。3.4 Servlet 层下单接口的事务边界在哪下单是整个系统里唯一必须保证原子性的操作扣库存、写订单、写订单明细三步要么全成要么全废。很多工程在这里翻车原因是每一步都调用了BaseDao.update而它内部各自拿连接、各自提交中间抛异常前面的扣减已经落库了。WebServlet(/order/create) public class OrderCreateServlet extends HttpServlet { private final OrderDao orderDao new OrderDao(); private final GameDao gameDao new GameDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { User user (User) req.getSession().getAttribute(loginUser); if (user null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } ListCartItem cart (ListCartItem) req.getSession().getAttribute(cart); if (cart null || cart.isEmpty()) { resp.sendRedirect(req.getContextPath() /cart.jsp); return; } Connection conn null; try { conn JdbcUtils.getConnection(); conn.setAutoCommit(false); // 开启事务关闭自动提交 BigDecimal total BigDecimal.ZERO; for (CartItem item : cart) { // 扣库存带 stock ? 条件返回 0 说明库存不足 int affected gameDao.reduceStock(conn, item.getGameId(), item.getNum()); if (affected 0) throw new RuntimeException(库存不足 item.getGameName()); total total.add(item.getPrice().multiply(new BigDecimal(item.getNum()))); } String orderNo G System.currentTimeMillis(); // 简单订单号生产环境要加用户维度 int orderId orderDao.insertOrder(conn, orderNo, user.getId(), total); orderDao.insertItems(conn, orderId, cart); // 批量插入明细 conn.commit(); // 三步全成才提交 req.getSession().removeAttribute(cart); // 清空购物车 resp.sendRedirect(req.getContextPath() /order/list); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } req.setAttribute(msg, 下单失败 e.getMessage()); req.getRequestDispatcher(/WEB-INF/views/error.jsp).forward(req, resp); } finally { if (conn ! null) { try { conn.setAutoCommit(true); } catch (SQLException ignored) {} JdbcUtils.close(null, null, conn); // 归还连接前恢复自动提交 } } } }核心在于这个Connection conn是贯穿全流程的reduceStock、insertOrder、insertItems都接收它作为第一个参数而不是各自去JdbcUtils.getConnection()。扣库存的 SQL 必须写成UPDATE game SET stock stock - ? WHERE id ? AND stock ?把库存校验塞进 WHERE 里靠数据库行锁保证并发下不会超卖——如果先SELECT stock再判断再更新两个请求同时进来就会双双通过校验。finally里那句setAutoCommit(true)特别容易被忽略。连接是还回池子重复使用的如果带着autoCommitfalse的状态被下一个请求借走那个请求的所有操作都不会提交排查起来非常难受。3.5 JSP 视图与 JSTL列表页不该出现一行 Java 代码JSP 里写% for(...) { %这种脚本片段在这类工程里属于历史遗留。既然引入了 JSTL列表渲染就该是纯标签页面只负责把后端给的数据摆出来。% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % div classgame-list c:forEach items${page.list} varg div classcard !-- contextPath 不能丢否则部署到非根路径下图片全裂 -- img src${pageContext.request.contextPath}/upload/${g.cover} alt${g.name} h3${g.name}/h3 p classpricefmt:formatNumber value${g.price} pattern0.00//p c:choose c:when test${g.stock 0} a href${pageContext.request.contextPath}/cart/add?id${g.id}加入购物车/a /c:when c:otherwisespan classsold-out已售罄/span/c:otherwise /c:choose /div /c:forEach /div !-- 分页条page 是后端塞进 request 的 PageBean -- div classpager 第 ${page.pageNum} / ${page.totalPage} 页 c:if test${page.pageNum 1} a href?pageNum${page.pageNum - 1}keyword${param.keyword}上一页/a /c:if c:if test${page.pageNum page.totalPage} a href?pageNum${page.pageNum 1}keyword${param.keyword}下一页/a /c:if /div${pageContext.request.contextPath}拿到的是应用上下文路径部署成ROOT时它是空串部署成/game-shop时它是/game-shop统一用它拼绝对路径能避免“本地好好的一部署图片 404”。fmt:formatNumber保证价格显示两位小数否则99.9会显示成99.9而不是99.90。分页链接里带上keyword是为了翻页时搜索条件不丢这个小细节漏了用户搜“角色扮演”翻到第二页就变回全部游戏了。4. 从零跑起来环境清单、建库脚本和第一次访问代码看懂了不等于能跑起来Java Web 工程的启动门槛集中在环境版本、数据库初始化和部署路径三件事上。这里按我实际复现的顺序走一遍每一步都说明失败时该看哪里。4.1 环境准备与依赖清单先把版本对齐这是血泪经验中最值得先说的一条。JDK 用 8Tomcat 用 8.5 或 9.0MySQL 用 5.7 或 8.0三者版本跨度别太大。JDK 17 配 Tomcat 8.5 会因为模块化限制起不来MySQL 8 配老版本驱动会报Unknown system variable。组件建议版本注意事项JDK8编译级别 source/target 设 1.8Tomcat8.5 / 9.09.0 对应 Servlet 4.0注解扫描更宽松MySQL5.7 / 8.08.0 必须用com.mysql.cj.jdbc.DriverJDBC 驱动与 MySQL 版本匹配放进WEB-INF/lib别只加进 IDE 的构建路径JSTL1.2缺standard.jar会报标签找不到依赖包只加进 IDE 的 Libraries 是不够的Tomcat 运行时只认WEB-INF/lib下的 jar。如果 IDE 里能跑、单独部署 war 就报ClassNotFoundException基本就是这个原因。4.2 建库建表与初始数据打开 MySQL 客户端先建库再执行建表脚本。库名、字符集、排序规则一次定好后面不用返工。CREATE DATABASE IF NOT EXISTS game_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE game_shop; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT MD5 摘要不存明文, email VARCHAR(100), role TINYINT NOT NULL DEFAULT 0 COMMENT 0普通用户 1管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( 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 NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 初始化一个管理员账号密码是 123456 的 MD5 INSERT INTO user (username, password, role) VALUES (admin, e10adc3949ba59abbe56e057f20f883e, 1); INSERT INTO game (name, category_id, price, stock, description) VALUES (模拟项目星际远征, 1, 98.00, 100, 科幻策略类示例数据), (模拟项目山谷旅人, 2, 68.00, 50, 冒险解谜类示例数据);字符集必须用utf8mb4而不是utf8后者在 MySQL 里其实是三字节的utf8mb3存 emoji 或者部分生僻字会截断报错。密码字段长度给到 64MD5 是 32 位十六进制留出余量方便以后换更强的摘要算法。4.3 部署与第一次访问如果工程是 Maven 结构命令行mvn clean package打出 war 包丢进 Tomcat 的webapps目录启动bin/startup.sh或startup.bat。不是 Maven 结构的话在 IDE 里配一个 Tomcat Server把工程以 exploded 形式部署Application context 设成/game-shop。访问顺序建议固定成先http://localhost:8080/game-shop/看首页列表能不能出来再点登录用admin/123456进后台最后走一遍加购物车到下单。首页能出来说明 JDBC 和 Tomcat 都通了能登录说明 Session 和过滤器正常能下单说明事务链路没问题。分三段验证比一上来就点下单更容易定位问题出在哪一层。启动时如果控制台刷出一堆ClassNotFoundException: com.mysql.cj.jdbc.Driver就是驱动没进WEB-INF/lib如果是Access denied for user rootlocalhost检查JdbcUtils里的密码和 MySQL 用户权限如果页面 404 但控制台没报错多半是WebServlet的路径和访问地址对不上或者web.xml里的metadata-completetrue把注解扫描关掉了。4.4 三个必须改的配置项工程拿到手第一件事是改配置别急着写新功能。数据库连接信息在JdbcUtils里账号密码换成自己的上传目录如果用到封面图确保upload文件夹存在且 Tomcat 有写权限Linux 下还要注意不要被readonly挂载分页每页条数一般写在 Servlet 里的常量或者web.xml的context-param默认 8 到 12 条比较合适改成 100 会让首页加载明显变慢。还有一处容易漏web.xml里配 404 和 500 的错误页跳转把异常兜住别让用户看到 Tomcat 的默认报错页那玩意儿会把堆栈和服务器路径全暴露出来。5. 避坑与排查五个把我按在工位上半夜的记录下面这五条都是这套 JSPServletMySQL 游戏购买网站里高频复现的问题按“现象 → 原因 → 解决”写遇到时可以直接对照。5.1 中文全是问号控制台打印正常现象页面上输入“星际远征”提交后数据库里变成????但 IDE 控制台System.out.println打出来是正常中文。原因控制台和 JVM 默认编码一致所以看着正常而请求体和数据库连接用的是另一套编码问题出在中间环节没统一成 UTF-8。解决过滤器里setCharacterEncoding(UTF-8)必须在所有getParameter之前执行JDBC 连接串补上useUnicodetruecharacterEncodingutf8数据库和表统一utf8mb4JSP 页头写% page contentTypetext/html;charsetUTF-8 %。四处缺一处乱码就会在某个环节冒出来。5.2 下单页面 404Servlet 明明写了现象WebServlet(/order/create)注解写了浏览器访问/game-shop/order/create返回 404。原因有两个常见分支一是web.xml的web-app标签带metadata-completetrue它会告诉容器忽略所有注解只认web.xml里的配置二是表单action写成了相对路径order/create在/game-shop/game/detail这种二级路径下会拼成错误地址。解决确认metadata-complete为false或直接删掉表单 action 一律用${pageContext.request.contextPath}/order/create拼绝对路径。5.3 跑一会儿就报连接超时重启才好现象本地测试没问题演示时点几下就卡住日志里出现An attempt by a client to checkout a Connection has timed out。原因某个 DAO 方法的finally里只关了PreparedStatement没关Connection连接被借走不还池子二十个连接很快耗尽。解决统一走JdbcUtils.close(rs, ps, conn)三个参数都传别图省事只关一半或者改用 try-with-resources排查时在getConnection里打印当前线程名和借出次数一眼就能看出哪个接口在漏。5.4 订单金额差一分钱对账对不上现象购物车三件商品合计和订单表里的total_amount差 0.01。原因金额用double参与运算0.1 0.2 0.30000000000000004这种浮点误差累积。解决实体、DAO、数据库字段全部换成BigDecimal加法用add()、乘法用multiply()比较用compareTo()而不是equals()。数据库字段用DECIMAL(10,2)别用FLOAT。这条改起来涉及面广越早改越省事。5.5 并发下单后库存变成负数现象压测或者手动开两个窗口同时下单库存从 1 变成 -1卖出两份。原因先SELECT stock判断够不够再UPDATE扣减两个请求在判断和更新之间插队了或者事务没生效扣减和写订单各自提交。解决把判断塞进更新语句UPDATE game SET stock stock - ? WHERE id ? AND stock ?用返回的影响行数判断是否成功同时确保整条链路共用一个Connection并显式控制事务。表名用orders而不是order也算一条附带经验用保留字当表名SQL 会莫名其妙报语法错误排查半天才发现要加反引号。6. 二次开发给商城加一个游戏评价模块顺带验证改造有没有真的落地拿到这套工程之后多数人想加的第一个功能就是评价。原因很实际游戏购买网站只有列表和下单看起来太单薄加个评价模块既能练手又能验证自己是不是真的摸清了分层结构。我一般会按“表 → DAO → Servlet → JSP”的顺序推进每一层加完立刻验证而不是四层全写完再一起调。先加表字段设计上留好扩展位CREATE TABLE review ( id INT PRIMARY KEY AUTO_INCREMENT, game_id INT NOT NULL, user_id INT NOT NULL, score TINYINT NOT NULL COMMENT 1-5 星, content VARCHAR(500), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_game (user_id, game_id), -- 一人一游戏只能评一次 KEY idx_game (game_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_user_game这个唯一索引是关键靠数据库拦住重复评价比在 Java 里先查再插可靠得多——后者在并发下依然会插进两条。DAO 层直接继承BaseDao只写映射逻辑public class ReviewDao extends BaseDao { public int insert(Review r) { String sql INSERT INTO review(game_id, user_id, score, content) VALUES(?,?,?,?); return update(sql, r.getGameId(), r.getUserId(), r.getScore(), r.getContent()); } public ListReview findByGameId(int gameId) { String sql SELECT r.*, u.username FROM review r JOIN user u ON r.user_id u.id WHERE r.game_id ? ORDER BY r.create_time DESC; return query(sql, rs - { Review r new Review(); r.setId(rs.getInt(id)); r.setScore(rs.getInt(score)); r.setContent(rs.getString(content)); r.setUsername(rs.getString(username)); // 联表带出昵称页面直接用 return r; }, gameId); } }联表查username而不是在 JSP 里再查一次用户是为了避免页面循环里 N1 次查询——十条评价就多十次数据库往返这是很多人第一次做评论功能会踩的坑。Servlet 层只需要一个转发和一个提交WebServlet(/review/*) public class ReviewServlet extends HttpServlet { private final ReviewDao reviewDao new ReviewDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int gameId Integer.parseInt(req.getParameter(gameId)); req.setAttribute(reviews, reviewDao.findByGameId(gameId)); req.getRequestDispatcher(/WEB-INF/views/review/list.jsp).forward(req, resp); } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { User user (User) req.getSession().getAttribute(loginUser); if (user null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } Review r new Review(); r.setGameId(Integer.parseInt(req.getParameter(gameId))); r.setUserId(user.getId()); r.setScore(Integer.parseInt(req.getParameter(score))); r.setContent(req.getParameter(content)); try { reviewDao.insert(r); } catch (RuntimeException e) { // 唯一索引冲突会走到这里转成友好提示而不是 500 req.setAttribute(msg, 你已经评价过这款游戏了); } resp.sendRedirect(req.getContextPath() /review/list?gameId r.getGameId()); } }/review/*用通配符把列表和提交收在一个 Servlet 里靠doGet和doPost分流比写两个 Servlet 更省事。提交后立刻sendRedirect而不是forward是为了防止用户按 F5 重复提交表单——这是最经典的重放问题重定向走 GET 就自然解决了。改完之后怎么验证我的习惯是三层都要过一遍。数据库层直接SELECT * FROM review看有没有落库、唯一索引有没有生效接口层开两个浏览器窗口同一个账号对同一款游戏连提交两次看第二次是不是被友好提示拦住页面层检查评价列表里的用户名和评分是不是正确渲染顺便在ReviewDao.findByGameId里打个 SQL 耗时日志确认没有循环查询。如果这套链路跑通了基本可以确认自己已经摸清了这个工程的分层约定实体在beanSQL 在dao流程在servlet展示在WEB-INF/views谁该干什么一清二楚。后面再加收藏、加优惠券、加订单退款都是照着这个模板复制一遍的事。有个习惯我保持了挺久每次接手这类 JSPServlet 工程不管需求多急都先花二十分钟把“连接怎么拿、事务怎么开、资源怎么关”这三件事走一遍再动手写业务。早年间我图快直接在一个下单接口里写了六次DriverManager.getConnection演示当天连接池耗尽重启了三次才把流程走完。从那以后我每次动这类老派 Java Web 项目都强制先把JdbcUtils和事务边界过一遍再写别的。希望帮到你。本文还有配套的精品资源点击获取