news 2026/10/27 2:08:25

JSP+Servlet+MySQL游戏购买网站:MVC分层与下单事务实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JSP+Servlet+MySQL游戏购买网站:MVC分层与下单事务实战

简介:这是一套面向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. 一个结算后库存归零的下午:这套 JSP+Servlet+MySQL 游戏购买网站到底能干什么

有个同学把电脑搬到我工位上,说他的游戏购买网站“能登录、能加购物车、能下单,就是下完单库存一点没少”。打开工程一看,JSP 页面里嵌着Class.forName,Servlet 里直接写 SQL,下单时每张表各自开一个连接,中途出错谁也没回滚。这不是他写错了某一行,而是这类 JSP+Servlet+MySQL 游戏购买网站最典型的通病——页面、业务、数据访问全糊在一层里。

这套系统本身要做的事很清楚:给玩家一个能浏览游戏、搜索分类、加购物车、下单支付、查看订单的后台;给管理员一个能上架游戏、管理库存、处理订单的运营界面。技术上它就是原生 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

price用DECIMAL而不是DOUBLE,stock加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;charset=UTF-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" + "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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) {} // 还回池 } }

连接串里的characterEncoding=utf8是防乱码的第二道保险,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> List<T> query(String sql, RowMapper<T> mapper, Object... params) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; List<T> 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; } }

RowMapper<T>是一个只有一个方法的接口,T mapRow(ResultSet rs) throws SQLException。这样做的好处是 SQL 和对象映射彻底解耦,GameDao只关心“这一行怎么变成 Game 对象”,不用管连接和异常。Object... params是可变参数,调用时update(sql, name, price, stock)直接传,setObject会自动处理类型,BigDecimal也能原样进去。

再看游戏列表的分页查询,这是商城首页的核心 SQL:

public class GameDao extends BaseDao { public List<Game> findByPage(String keyword, int page, int size) { StringBuilder sql = new StringBuilder( "SELECT id, name, price, stock, cover, description FROM game WHERE status = 1"); List<Object> 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; } List<CartItem> cart = (List<CartItem>) 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)特别容易被忽略。连接是还回池子重复使用的,如果带着autoCommit=false的状态被下一个请求借走,那个请求的所有操作都不会提交,排查起来非常难受。

3.5 JSP 视图与 JSTL:列表页不该出现一行 Java 代码

JSP 里写<% for(...) { %>这种脚本片段,在这类工程里属于历史遗留。既然引入了 JSTL,列表渲染就该是纯标签,页面只负责把后端给的数据摆出来。

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <div class="game-list"> <c:forEach items="${page.list}" var="g"> <div class="card"> <!-- contextPath 不能丢,否则部署到非根路径下图片全裂 --> <img src="${pageContext.request.contextPath}/upload/${g.cover}" alt="${g.name}"> <h3>${g.name}</h3> <p class="price">¥<fmt:formatNumber value="${g.price}" pattern="0.00"/></p> <c:choose> <c:when test="${g.stock > 0}"> <a href="${pageContext.request.contextPath}/cart/add?id=${g.id}">加入购物车</a> </c:when> <c:otherwise><span class="sold-out">已售罄</span></c:otherwise> </c:choose> </div> </c:forEach> </div> <!-- 分页条:page 是后端塞进 request 的 PageBean --> <div class="pager"> 第 ${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 用 8,Tomcat 用 8.5 或 9.0,MySQL 用 5.7 或 8.0,三者版本跨度别太大。JDK 17 配 Tomcat 8.5 会因为模块化限制起不来,MySQL 8 配老版本驱动会报Unknown system variable。

组件建议版本注意事项
JDK8编译级别 source/target 设 1.8
Tomcat8.5 / 9.09.0 对应 Servlet 4.0,注解扫描更宽松
MySQL5.7 / 8.08.0 必须用com.mysql.cj.jdbc.Driver
JDBC 驱动与 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 初始化一个管理员账号,密码是 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 或者部分生僻字会截断报错。密码字段长度给到 64,MD5 是 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 'root'@'localhost',检查JdbcUtils里的密码和 MySQL 用户权限;如果页面 404 但控制台没报错,多半是@WebServlet的路径和访问地址对不上,或者web.xml里的metadata-complete="true"把注解扫描关掉了。

4.4 三个必须改的配置项

工程拿到手,第一件事是改配置,别急着写新功能。数据库连接信息在JdbcUtils里,账号密码换成自己的;上传目录如果用到封面图,确保upload文件夹存在且 Tomcat 有写权限,Linux 下还要注意不要被readonly挂载;分页每页条数一般写在 Servlet 里的常量或者web.xml的<context-param>,默认 8 到 12 条比较合适,改成 100 会让首页加载明显变慢。

还有一处容易漏:web.xml里配 404 和 500 的错误页跳转,把异常兜住,别让用户看到 Tomcat 的默认报错页,那玩意儿会把堆栈和服务器路径全暴露出来。

5. 避坑与排查:五个把我按在工位上半夜的记录

下面这五条都是这套 JSP+Servlet+MySQL 游戏购买网站里高频复现的问题,按“现象 → 原因 → 解决”写,遇到时可以直接对照。

5.1 中文全是问号,控制台打印正常

现象:页面上输入“星际远征”,提交后数据库里变成????,但 IDE 控制台System.out.println打出来是正常中文。原因:控制台和 JVM 默认编码一致所以看着正常,而请求体和数据库连接用的是另一套编码,问题出在中间环节没统一成 UTF-8。解决:过滤器里setCharacterEncoding("UTF-8")必须在所有getParameter之前执行;JDBC 连接串补上useUnicode=true&characterEncoding=utf8;数据库和表统一utf8mb4;JSP 页头写<%@ page contentType="text/html;charset=UTF-8" %>。四处缺一处,乱码就会在某个环节冒出来。

5.2 下单页面 404,Servlet 明明写了

现象:@WebServlet("/order/create")注解写了,浏览器访问/game-shop/order/create返回 404。原因有两个常见分支:一是web.xml的<web-app>标签带metadata-complete="true",它会告诉容器忽略所有注解,只认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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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 List<Review> 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 里再查一次用户,是为了避免页面循环里 N+1 次查询——十条评价就多十次数据库往返,这是很多人第一次做评论功能会踩的坑。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 耗时日志,确认没有循环查询。

如果这套链路跑通了,基本可以确认自己已经摸清了这个工程的分层约定:实体在bean,SQL 在dao,流程在servlet,展示在WEB-INF/views,谁该干什么一清二楚。后面再加收藏、加优惠券、加订单退款,都是照着这个模板复制一遍的事。

有个习惯我保持了挺久:每次接手这类 JSP+Servlet 工程,不管需求多急,都先花二十分钟把“连接怎么拿、事务怎么开、资源怎么关”这三件事走一遍,再动手写业务。早年间我图快,直接在一个下单接口里写了六次DriverManager.getConnection,演示当天连接池耗尽,重启了三次才把流程走完。从那以后我每次动这类老派 Java Web 项目,都强制先把JdbcUtils和事务边界过一遍再写别的。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 2:07:00

计算机视觉入门:从OpenCV到深度学习的实践指南

1. 计算机视觉入门&#xff1a;为什么选择CV作为AI学习起点计算机视觉&#xff08;Computer Vision&#xff09;作为人工智能领域最成熟的分支之一&#xff0c;已经成为零基础学习者的理想切入点。我2016年第一次接触OpenCV时&#xff0c;就被它"所见即所得"的特性所…

作者头像 李华
网站建设 2026/10/27 2:04:33

13.0 mtk 音量等级从15改为25

1.前言 在系统音量等级默认只有15级,客户需要需要改到25级,接下来和音量曲线一起修改看下如果修改完成客户要求 2.mtk 音量等级从15改为25核心类 frameworks/base/services/core/java/com/android/server/audio/AudioService.java vendor/mediatek/proprietary/external/au…

作者头像 李华
网站建设 2026/10/27 2:03:42

二周小结!

Java 学习周记 第二周 这周学了什么 流程控制收了个尾&#xff0c;然后一路走数组、方法&#xff0c;最后进了面向对象。 进度是跟上了&#xff0c;但没学明白。数组、方法、封装、重载这几块在脑子里搅成一团&#xff0c;单拎一个词出来能说两句&#xff0c;真让我从头写一段…

作者头像 李华
网站建设 2026/10/27 2:03:00

微信小程序实战:双层 wx:for 渲染九九乘法表

一、需求分析本次案例我们利用小程序列表渲染 wx:for 来实现九九乘法表&#xff0c;核心需求&#xff1a;页面展示标题 九九乘法表整体纵向从上到下排布&#xff0c;每一行内部横向从左到右排列使用双层循环&#xff08;双层 wx:for&#xff09;&#xff0c;搭配列表渲染、条件…

作者头像 李华
网站建设 2026/10/27 2:02:57

一文看懂 AI Agent 工作原理:从大脑到手脚

一、Agent 不是更会聊的机器人 很多人把 AI Agent 当成「更高级的聊天机器人」&#xff0c;其实本质不同&#xff1a;它会自己干活。 一个 Agent 由三部分组成&#xff1a; 大脑&#xff1a;大模型&#xff0c;负责推理与规划手脚&#xff1a;工具&#xff08;Tools&#xff09…

作者头像 李华