news 2026/11/4 12:17:52

JSP+Servlet+MySQL游戏商城源码:MVC分层、购物车与订单事务

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JSP+Servlet+MySQL游戏商城源码:MVC分层、购物车与订单事务

简介:一套基于JSP+Servlet+MySQL的游戏购买网站源码,面向Java Web课程设计、毕业设计与入门实训场景,适合希望理解分层开发与完整业务流程的学生和初级开发者。项目实现管理员登录、订单管理、客户管理、游戏管理、类目管理,以及用户首页、登录、购买游戏、购物车、提交订单、查看订单等功能,并附带数据库脚本与默认账号,便于快速导入Eclipse、IDEA或MyEclipse,在JDK1.8/1.9与Tomcat8.x/9.x环境下部署运行。压缩包共246个文件,约14.85MB,包含55个Java源文件与55个class、20个JSP页面、9个JAR依赖、6个CSS与8个JS等,另有JPG/PNG/GIF图片和SQL文件,基本覆盖源码、页面、样式、依赖与数据初始化。已有99人学习,可作为课程设计参考,帮助梳理Servlet控制层、DAO数据访问层与JSP展示层的协作方式,并对照订单、购物车等模块理解数据库表设计与业务流程。

1. 从一份 JSP+Servlet+MySQL 源码说起:游戏购买网站的最小闭环

很多人翻到 jsp+servlet+mysql 游戏购买网站这类源码,第一反应是“没有 Spring Boot,太老了没什么可看”。真把代码铺开反而是另一回事:没有框架封装,一个请求从浏览器打到数据库要经过哪几层,全写在 web.xml 和几个 Servlet 里,这种项目恰恰是看懂 MVC 分层最省力的样本。它的功能链路是完整的——用户注册登录、按分类翻游戏、关键词搜索、加入购物车、填收货信息下单、后台维护商品和改订单状态,一套买游戏的流程能从头走到尾。适合两类人:手里有 Java Web 课程设计要交、需要一个结构清楚还能跑起来的骨架;或者写惯了 Spring 注解,想弄清 DispatcherServlet 到底替我们省了哪些事的人。

2. 工程结构与请求链路:一个 /game/list 请求到底经过了几层

先把技术栈摊开说清楚,不然改代码时很容易改错地方。这份源码用的全是 Java Web 原生组件:JSP 负责视图渲染,Servlet 当控制器,普通 Java 类写 Service 和 DAO,JDBC 直连 MySQL,Tomcat 做容器。没有依赖注入,对象的 new 都写在 Servlet 的成员变量或者方法体里;没有 ORM,SQL 是手写的字符串;页面上的循环和判断靠 JSTL 的<c:forEach>和<c:if>,而不是脚本片段<% %>。这套组合的好处是每一环都可替换、可打断点,坏处是所有样板代码都得自己写。

2.1 技术栈清点:每件东西各自解决什么问题

初学者常犯的错是把 JSP 当成“能写 Java 的 HTML”,然后在页面里塞 JDBC 代码,一个页面几百行,改一个字段要翻三个文件。这份源码的价值在于它把职责切开了,先看清每层管什么:

组件版本常见区间在项目里承担的职责换掉它的代价
JSP + JSTLJSP 2.3 / JSTL 1.2只做输出,拿 request 域里的数据渲染列表和详情低,可换成 Thymeleaf 或纯 JSON
Servlet3.1+收参数、校验、调 Service、转发或重定向中,注解和 web.xml 都要动
Service手写普通类组合多个 DAO,控制事务边界低
DAO + JDBCmysql-connector 5.1/8.0拼 SQL、设参数、映射 ResultSet 到实体中,SQL 全在这里
MySQL5.7 / 8.0存用户、游戏、分类、订单、订单明细高
Tomcat8.5 / 9.0Servlet 容器,负责 war 解包和类加载低

选型的逻辑其实很朴素:课程设计场景下不允许引入太重的东西,答辩时要能讲清每一行代码为什么在这。JDBC 直连虽然土,但事务控制是显式的setAutoCommit(false),比在 Spring 里配@Transactional更能讲明白事务到底怎么回事。

2.2 目录结构:一个 war 包里每个文件夹的职责

下载下来解压,标准的 Eclipse 动态 Web 项目或 Maven webapp 结构,展开后大致是这样:

game-shop/ ├── src/main/java/com/shop/ │ ├── entity/ # User、Game、Category、Orders、OrderItem、CartItem │ ├── dao/ # UserDao、GameDao、CategoryDao、OrderDao │ ├── service/ # UserService、GameService、OrderService │ ├── servlet/ # LoginServlet、GameListServlet、CartServlet、OrderServlet │ ├── filter/ # EncodingFilter、AdminLoginFilter │ └── util/ # DBUtil、PageBean、OrderNoUtil ├── src/main/resources/ │ └── db.properties # 数据库四要素 └── src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── jsp/ # 所有页面放这里,外部无法直接访问 ├── static/ # css、js、游戏封面图 └── index.jsp # 唯一允许直接访问的入口

两个细节值得注意。一是 JSP 全部塞进WEB-INF/jsp/下,浏览器敲/jsp/game_list.jsp会直接 404,只能由 Servlet 通过forward进去,这样数据一定经过控制器,不会出现“页面打开了但列表是空的”这种问题。二是entity和数据库表基本一一对应,字段名用驼峰,SQL 里用下划线,映射工作在 DAO 里手工完成,没引入任何反射工具。

2.3 请求链路:从 URL 到页面渲染走了六步

拿首页游戏列表举例,GET /game/list?cid=2&page=1这条请求的完整路径是这样的:

  1. Tomcat 根据web.xml或@WebServlet("/game/list")把请求交给GameListServlet;
  2. Servlet 里request.getParameter("cid")拿分类,getParameter("page")拿页码,做空值和数字校验;
  3. 调GameService.findByPage(cid, keyword, page, size),Service 里算出offset = (page-1)*size;
  4. GameDao拼select ... limit ?,?,PreparedStatement设参,ResultSet逐行塞进List<Game>;
  5. 再查一次count(*)算出总条数,封装成PageBean放进request.setAttribute("page", pb);
  6. request.getRequestDispatcher("/WEB-INF/jsp/game_list.jsp").forward(req, resp),JSP 用 JSTL 遍历渲染。

用代码表达第 5、6 步,这一段几乎是所有列表页的模板:

@WebServlet("/game/list") public class GameListServlet extends HttpServlet { private final GameService gameService = new GameService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 参数容错:非数字或越界一律回落到第 1 页,避免 SQL 里出现负数 offset int page = parseInt(req.getParameter("page"), 1); int size = 8; String kw = req.getParameter("kw"); Integer cid = parseIntOrNull(req.getParameter("cid")); PageBean<Game> pb = gameService.findByPage(cid, kw, page, size); req.setAttribute("page", pb); req.setAttribute("cid", cid); req.setAttribute("kw", kw); // 转发而非重定向,request 域里的数据才能带到 JSP req.getRequestDispatcher("/WEB-INF/jsp/game_list.jsp").forward(req, resp); } }

参数说明上,page和size决定 SQL 的limit偏移量,cid为空表示全部分类,kw走的是like '%?%'模糊匹配。forward和redirect在这里不能混用:重定向会丢掉 request 域,页面渲染出来必然是空列表,这是新手最常见的第一个坑。

2.4 分层边界:每一层不该做什么

这条线划不清,项目很快就会烂掉。约束是:Servlet 里不出现 SQL,DAO 里不出现request,JSP 里不出现 Java 逻辑块。Service 层是最容易被省略的一层,很多课程设计直接从 Servlet 调 DAO,单人开发看不出问题,一旦订单要同时写orders、order_item并扣库存,事务没地方放就麻烦了。PageBean这种工具类也值得单独拎出来,泛型封装list / page / size / total / totalPage,所有列表页复用一套,省掉大量重复的分页计算。

3. 商品与用户模块拆解:DAO 到 JSP 的那条 JDBC 直线

这一章拆最基础也最常被改的两块:游戏商品的展示链路,以及用户登录的数据通路。这两块代码量大但模式固定,看懂了后面购物车和订单只是照着套。

3.1 表结构与实体映射:字段怎么对应

游戏表和分类表是整个网站的地基:

CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, sort INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE game ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category_id INT NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, cover VARCHAR(255) DEFAULT NULL, description TEXT, status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_cate (category_id), KEY idx_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

实体类Game就按字段写 getter/setter,price用BigDecimal而不是double——游戏价格做加减乘除时,double会出现0.30000000000000004这种结果,购物车合计一显示就露馅。category_id在实体里保留成int,另外加一个categoryName字段用于列表页显示分类名,这个冗余字段由联表查询填充,不做数据库存储。

3.2 DBUtil:一次静态加载,到处取连接

所有 DAO 的入口都是这个工具类,它决定连接从哪来、用完怎么还:

public class DBUtil { private static String url, user, pwd; static { // 静态块只在类加载时执行一次,把驱动和配置准备好 try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { Properties p = new Properties(); p.load(in); Class.forName(p.getProperty("jdbc.driver")); url = p.getProperty("jdbc.url"); user = p.getProperty("jdbc.user"); pwd = p.getProperty("jdbc.password"); } catch (Exception e) { // 静态块里抛异常容易被吞,包一层让容器启动阶段就报出来 throw new ExceptionInInitializerError("数据源初始化失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, pwd); } public static void close(ResultSet rs, Statement st, Connection conn) { // 关闭顺序必须与打开顺序相反,且每个都要单独 try 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) {} } }

db.properties里的四行配置决定了项目能不能连上库:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/game_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.user=root jdbc.password=123456

要点有三个。驱动类名在 MySQL 8 之后从com.mysql.jdbc.Driver换成了com.mysql.cj.jdbc.Driver,写旧名字会报Loading class ... deprecated然后连接失败;serverTimezone不加,插入时间字段会抛时区异常;characterEncoding=utf8保证中文游戏名不会变成问号。这三个参数是后面排查章节反复出现的元凶。

3.3 GameDao:分页查询与 SQL 注入防护

分页查询是 DAO 层最典型的一段代码,注意PreparedStatement的占位符用法:

public List<Game> findByPage(Integer cid, String kw, int offset, int limit) { StringBuilder sql = new StringBuilder( "SELECT g.*, c.name AS categoryName FROM game g " + "LEFT JOIN category c ON g.category_id = c.id WHERE g.status = 1 "); List<Object> params = new ArrayList<>(); if (cid != null) { // 分类条件按需拼,不能写死 sql.append("AND g.category_id = ? "); params.add(cid); } if (kw != null && !kw.trim().isEmpty()) { sql.append("AND g.name LIKE ? "); params.add("%" + kw.trim() + "%"); } sql.append("ORDER BY g.id DESC LIMIT ?,?"); params.add(offset); params.add(limit); List<Game> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { 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")); g.setCategoryName(rs.getString("categoryName")); list.add(g); } } } catch (SQLException e) { throw new RuntimeException("查询游戏列表失败", e); } return list; }

几个参数值得说清楚。offset由页码算出,limit是每页条数,两个都必须是int,绝不能把用户传的字符串直接拼进 SQL——?kw=后面跟一段' or 1=1 --就能把整张表拖走,这是最典型的注入场景。用StringBuilder拼条件、把值全部丢进params列表,最后统一setObject,是手写 JDBC 里最稳妥的写法。try-with-resources保证ResultSet和PreparedStatement自动关闭,Connection也交给它,就不用再手工调DBUtil.close。

count方法把select换成select count(*)、去掉limit即可,条件部分与上面完全一致,可以抽成一个私有方法buildWhere复用,避免两处条件写不一致导致“列表有 20 条但总页数显示 1 页”。

3.4 JSP 渲染:JSTL 遍历加空数据兜底

页面这端要干净,理想状态是只有输出没有逻辑:

<c:forEach items="${page.list}" var="g"> <div class="card"> <a href="${ctx}/game/detail?id=${g.id}"> <img src="${ctx}/static/cover/${g.cover}" alt="${g.name}"> </a> <h3>${g.name}</h3> <p class="cate">${g.categoryName}</p> <p class="price">¥${g.price}</p> <c:choose> <c:when test="${g.stock > 0}"> <a class="btn" href="${ctx}/cart/add?id=${g.id}">加入购物车</a> </c:when> <c:otherwise><span class="soldout">已售罄</span></c:otherwise> </c:choose> </div> </c:forEach> <c:if test="${empty page.list}"> <p class="empty">没有找到相关游戏</p> </c:if>

${ctx}是pageContext.request.contextPath的别名,必须带,否则部署到非根路径时所有链接和图片都会 404。价格直接用${g.price}输出,BigDecimal会原样调用toString,两位小数没问题;如果想统一成¥99.00格式,可以用<fmt:formatNumber value="${g.price}" pattern="0.00"/>。库存判断放在页面做展示控制,但真正的下单校验必须在 Service 里再做一次,页面上的c:when挡不住直接构造 URL 的请求。

4. 购物车与订单落库:Session 暂存加数据库事务才算闭环

购物车到下单这一段是整个项目最容易出问题的地方,因为它同时牵扯会话状态、金额计算和跨表写入。看懂这部分,后面加优惠券、加支付回调都有地方下手。

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

两种做法这份源码都可能出现,判断标准很简单:未登录用户能不能加购。如果允许游客加购、登录后再合并,购物车就放 Session,用Map<Integer, CartItem>存,key 是游戏 id,天然去重;如果要求必须登录才能加购,直接落cart表,跨设备能同步。课程设计里绝大多数是前者,代码量小,也不用设计合并逻辑。

public class Cart implements Serializable { // LinkedHashMap 保证加购顺序,页面展示不会乱跳 private Map<Integer, CartItem> items = new LinkedHashMap<>(); public void add(Game g, int num) { CartItem it = items.get(g.getId()); if (it == null) { items.put(g.getId(), new CartItem(g, num)); } else { it.setNum(it.getNum() + num); // 同一游戏重复加购则累加数量 } } public void update(int gameId, int num) { if (num <= 0) { items.remove(gameId); return; } CartItem it = items.get(gameId); if (it != null) it.setNum(num); } public BigDecimal getTotal() { BigDecimal total = BigDecimal.ZERO; for (CartItem it : items.values()) { // 单价乘数量,全程 BigDecimal,避免浮点误差 total = total.add(it.getGame().getPrice() .multiply(BigDecimal.valueOf(it.getNum()))); } return total; } }

Cart实现了Serializable,因为 Tomcat 重启时会尝试序列化 Session 做持久化,不实现接口会刷一堆警告。CartItem里存的是Game对象快照而不是 id 再回查,好处是购物车页面不用每条都查库;坏处是商品改价后购物车里还是旧价,所以下单时必须以数据库当前价重新计算,这一步在 Service 里做。

4.2 加购、改数量、删除的三个入口

购物车的操作全部由CartServlet一个类接住,用action参数分流:

@WebServlet("/cart/*") public class CartServlet extends HttpServlet { private final GameService gameService = new GameService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String path = req.getPathInfo(); // /add /update /remove /list HttpSession session = req.getSession(); Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); } if ("/add".equals(path)) { int id = Integer.parseInt(req.getParameter("id")); int num = parseInt(req.getParameter("num"), 1); Game g = gameService.findById(id); if (g != null && g.getStock() >= num) { cart.add(g, num); } resp.sendRedirect(req.getContextPath() + "/cart/list"); return; // 改完必须重定向,防止刷新重复加购 } if ("/update".equals(path)) { cart.update(Integer.parseInt(req.getParameter("id")), Integer.parseInt(req.getParameter("num"))); resp.sendRedirect(req.getContextPath() + "/cart/list"); return; } if ("/remove".equals(path)) { cart.update(Integer.parseInt(req.getParameter("id")), 0); resp.sendRedirect(req.getContextPath() + "/cart/list"); return; } req.getRequestDispatcher("/WEB-INF/jsp/cart.jsp").forward(req, resp); } }

关键点是加购之后用sendRedirect而不是forward。用转发的话浏览器地址栏还停在/cart/add?id=3,用户按 F5 会再提交一次,数量翻倍,这是购物车最经典的翻车现场。重定向让浏览器重新发一个GET /cart/list,天然幂等。

4.3 下单事务:一次提交要写三张表

下单不是一条 insert,它至少包含:写orders主表、写若干条order_item、扣减game.stock。三步必须同生共死,中途任何一步失败都要回滚,否则会出现“订单生成了但库存没扣”或者“库存扣了但订单没生成”的脏数据。

public String createOrder(int userId, Cart cart, String receiver, String phone, String address) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 事务从这里开始 // 1. 主表:先插入,用 getGeneratedKeys 拿回自增 id String orderNo = OrderNoUtil.next(); // 时间戳 + 随机数,保证唯一 BigDecimal total = cart.getTotal(); int orderId; String sql1 = "INSERT INTO orders(order_no,user_id,total,receiver,phone,address,status) " + "VALUES(?,?,?,?,?,?,0)"; try (PreparedStatement ps = conn.prepareStatement(sql1, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, orderNo); ps.setInt(2, userId); ps.setBigDecimal(3, total); ps.setString(4, receiver); ps.setString(5, phone); ps.setString(6, address); ps.executeUpdate(); try (ResultSet rs = ps.getGeneratedKeys()) { rs.next(); orderId = rs.getInt(1); } } // 2. 明细 + 3. 扣库存,任何一步影响行数为 0 都抛异常触发回滚 String sql2 = "INSERT INTO order_item(order_id,game_id,game_name,price,num) " + "VALUES(?,?,?,?,?)"; String sql3 = "UPDATE game SET stock = stock - ? WHERE id = ? AND stock >= ?"; try (PreparedStatement ps2 = conn.prepareStatement(sql2); PreparedStatement ps3 = conn.prepareStatement(sql3)) { for (CartItem it : cart.getItems().values()) { ps2.setInt(1, orderId); ps2.setInt(2, it.getGame().getId()); ps2.setString(3, it.getGame().getName()); // 快照,商品改名不影响历史订单 ps2.setBigDecimal(4, it.getGame().getPrice()); ps2.setInt(5, it.getNum()); ps2.addBatch(); ps3.setInt(1, it.getNum()); ps3.setInt(2, it.getGame().getId()); ps3.setInt(3, it.getNum()); if (ps3.executeUpdate() == 0) { throw new RuntimeException("库存不足: " + it.getGame().getName()); } } ps2.executeBatch(); } conn.commit(); return orderNo; } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } throw new RuntimeException("下单失败: " + e.getMessage(), e); } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} } } }

这段代码里有三个不容易想到的细节。RETURN_GENERATED_KEYS是拿主表自增 id 的唯一正路,很多人写完主表再去select max(id),并发下必然拿错。扣库存的 SQL 用了stock >= ?作为条件,靠数据库的行锁保证不会超卖,executeUpdate()返回 0 就说明库存不够,直接抛异常让整笔事务回滚。order_item里冗余存了game_name和price,这是有意为之的订单快照——商品后来改价改名,历史订单显示的仍是下单时的信息。finally里恢复autoCommit再关连接,如果用连接池,不恢复会污染下一个使用者。

4.4 订单状态流转与后台查询

orders.status用 tinyint 表示 0 待支付、1 已支付、2 已发货、3 已完成、4 已取消。状态流转必须单向,后台改状态时要带where id=? and status=?做乐观校验,防止用户连点两次“支付”把状态从已完成改回已支付。前台“我的订单”按user_id查,后台订单列表按状态和订单号模糊查,两者共用同一个 DAO 方法,只传不同的条件组合即可,不要写两套 SQL。

5. 环境跑通与典型问题排查:建库、部署和五个高频翻车点

代码看得再明白,跑不起来等于零。这一章按“从零到浏览器出现首页”的顺序走一遍,然后把最容易卡住人的几个问题按现象、原因、解决拆开。

5.1 环境清单与建库脚本

需要准备的东西不多:JDK 8、Tomcat 8.5 或 9、MySQL 5.7 或 8.0、一个能管依赖的 IDE(Eclipse/IDEA 都行)。依赖就两个 jar:mysql-connector-java和jstl-1.2.jar,前者放WEB-INF/lib,后者也放同一位置——JSTL 忘了放,页面上<c:forEach>会原样显示成文本或者报Unable to find taglib。

建库脚本一般随源码附带game_shop.sql,直接在客户端里source进去:

# 先建库再导数据,字符集必须显式指定,否则中文描述会存成问号 mysql -uroot -p -e "CREATE DATABASE game_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p game_shop < game_shop.sql # 确认表建好了、数据进去了 mysql -uroot -p game_shop -e "SHOW TABLES; SELECT COUNT(*) FROM game;"

导入完成后检查两件事:game表里有没有数据(空表首页一片空白,很容易误判成程序挂了),以及user表里有没有管理员账号(没有的话后台登录进不去,可以手工update user set role=1 where id=1)。

5.2 配置、打包与启动

改db.properties里的地址、账号、密码,然后按容器方式启动。用 IDE 的话,配置一个 Tomcat Server,把项目 add 进去,Application context设成/game-shop;用命令行的话,Maven 项目先mvn clean package打出 war,丢进webapps目录:

mvn clean package -DskipTests cp target/game-shop.war $TOMCAT_HOME/webapps/ $TOMCAT_HOME/bin/startup.sh # 盯日志,看到 Server startup in xxx ms 才算起来 tail -f $TOMCAT_HOME/logs/catalina.out

访问http://localhost:8080/game-shop/,能看到首页就算通了;再挨个点一遍注册、登录、加购、下单、后台登录五个入口,任何一处报错先去catalina.out里找最早的堆栈,后面的异常往往都是第一个异常引发的连锁反应。

5.3 五个高频翻车点:现象、原因、解决

第一个:表单提交中文变问号。现象是注册页填“张三”,数据库里存成???,页面返回给用户也是乱码。原因是 POST 请求体默认按 ISO-8859-1 解码,而 JSP 页面又声明了 UTF-8,两边对不上。解决方式是加一个编码过滤器,且必须排在所有过滤器最前面:

@WebFilter("/*") public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); // 只对 POST 请求体有效 resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }

GET 请求的中文参数另算,Tomcat 8 之后server.xml的 Connector 默认已是 UTF-8,老版本要在<Connector>上加URIEncoding="UTF-8"。另外 JSP 文件头必须有<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>,数据库连接串必须有characterEncoding=utf8——三处任何一处缺了都会乱码,缺哪处乱哪处。

第二个:启动就报ExceptionInInitializerError。现象是访问任何页面都 500,日志里数据源初始化失败。八成是mysql-connector-java的 jar 没进WEB-INF/lib。IDE 里只在 Build Path 里加了、没勾“部署到 WEB-INF/lib”,编译能过,运行时类加载器找不到驱动。把 jar 复制进webapp/WEB-INF/lib目录,重新部署即可。

第三个:MySQL 8 连接报Unknown system variable 'serverTimezone'或The server time zone value is unrecognized。前者是驱动版本太老配了新的连接参数,后者是驱动新了但 URL 里没给时区。解决:驱动换 8.0.x,URL 补serverTimezone=Asia/Shanghai,同时把驱动类名改成com.mysql.cj.jdbc.Driver。如果还报 SSL 警告,加useSSL=false&allowPublicKeyRetrieval=true。

第四个:订单提交成功但库存没变。现象是点了提交,订单列表里能看到,game.stock一个没少。原因多半是game表用了 MyISAM 引擎,setAutoCommit(false)对它根本不生效,回滚也回不去。用SHOW TABLE STATUS看 Engine 字段,全部改成 InnoDB 再试。另一个可能是 Service 里根本没做扣库存,只写了主表和明细。

第五个:购物车刷新一次多一件。现象是加购后页面刷新,数量翻倍。原因是加购走的是转发或者表单直接 POST 到列表页,浏览器刷新时重复提交了上一次请求。解决就是前面说的sendRedirect重定向;如果页面本身是 POST 提交的表单,采用“提交后重定向到 GET 结果页”的 PRG 模式,这个问题会一次性消失。

6. 二次开发改造:从能跑到能改的四个切入点

这套代码跑起来之后,真正花时间的不是加功能,而是别把原有的东西改坏。我一般按风险从低到高排,先动不影响主流程的地方,每改一处就跑一遍那条链路。

第一个切入点是连接池。DriverManager.getConnection每次新建物理连接,几十个并发就能把 MySQL 的连接数打满。改成 Druid 或 H2 自带的池,只需要动DBUtil,DAO 一行不用改:

private static DataSource ds; static { try (InputStream in = DBUtil.class.getClassLoader().getResourceAsStream("druid.properties")) { Properties p = new Properties(); p.load(in); ds = DruidDataSourceFactory.createDataSource(p); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); // 拿到的 close() 是归还,不是关闭 }

注意druid.properties里要把removeAbandoned打开,否则某段代码忘了关连接,跑一阵就wait timeout。改完用SHOW PROCESSLIST观察连接数是否稳定在池大小附近,这是最直观的验证方式。

第二个切入点是密码。明文存password字段是这类项目的通病,改成SHA-256 加盐,注册时digest = sha256(raw + salt),盐每个用户随机生成存一列,登录时用同样方式算一遍比对。改表加salt列、改UserService两个方法,测试时先把老数据清掉重注册几个账号。

第三个切入点是前后端分离。不用一步到位换 Spring Boot,可以先挑一个模块试水:把game/list改成返回 JSON,页面用原生fetch拉数据渲染。切换时校验点很清楚——同一组筛选条件下,JSP 版和 JSON 版输出的游戏 id 顺序、总条数必须完全一致。差异出在分页参数名上(page/size还是pageNum/pageSize),改一处页面同步改一处即可。

第四个切入点是订单状态机。现在的状态是散在各处的 if 里,建议抽一个OrderStatus枚举,写死合法迁移关系:

当前状态允许的下一步触发动作
0 待支付1 已支付 / 4 已取消支付回调 / 用户取消
1 已支付2 已发货后台发货
2 已发货3 已完成用户确认收货
3 已完成无只读

所有改状态的地方统一调changeStatus(orderId, from, to),SQL 带where status = ?,影响行数为 0 直接提示“订单状态已变化,请刷新”。我踩过一次坑:后台和前台同时操作同一笔订单,两边都以为改成功了,结果库里的状态被相互覆盖,查了半天才定位到没做状态校验。从那以后我每次改状态都强制走一遍from + to的双参数校验,再补一个并发点的日志,哪怕项目再小也不省这一步。希望这些能帮你少走点弯路。

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

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

面试官:线上 MySQL 的自增 id 用尽怎么办?

MySQL 的自增 id 都定义了初始值&#xff0c;然后不断加步长。虽然自然数没有上限&#xff0c;但定义了表示这个数的字节长度&#xff0c;计算机存储就有上限。比如&#xff0c;无符号整型 (unsigned int) 是 4 个字节&#xff0c;上限就是 2^32 - 1。那自增 id 用完&#xff0…

作者头像 李华
网站建设 2026/11/4 12:12:26

大一自学网安第13天

今天轮到html区块的学习。块元素块级元素通常从新行开始&#xff0c;并占据整行宽度&#xff0c;因此在页面上会呈现一块独立的内容块。 可以包含其他块级元素和行内元素。常见块元素&#xff1a;&#xff1c;div&#xff1e;&#xff1c;p&#xff1e;&#xff1c;h1&#xff…

作者头像 李华
网站建设 2026/11/4 12:11:53

质性资料编码用软件还是手工:什么时候该上软件、什么时候表格就够

访谈做完&#xff0c;三十多份转写稿静静躺在硬盘里&#xff0c;每份两万字上下。编到第五份时&#xff0c;多数人会卡在同一个念头&#xff1a;手上这张表格真撑得住后面的分析吗&#xff0c;还是得先花两周把某款质性分析软件啃熟。这个纠结很少靠「哪款软件更专业」解决&…

作者头像 李华
网站建设 2026/11/4 12:11:25

从肺主皮毛到肾其华在发:掉发的生理逻辑拆解

很多技术同学习惯先搞清楚"原理"再动手。养发也一样&#xff0c;先把三条生理逻辑拆开&#xff0c;才知道自己该补哪块。其一&#xff1a;肺与皮毛传统养生认为"肺主皮毛"&#xff0c;肺通过宣发把津液和卫气送到体表&#xff0c;濡润皮肤和毛发。入秋后燥…

作者头像 李华
网站建设 2026/11/4 12:11:11

验布这个岗位,为什么越来越难招人?

听到老板抱怨最多的不是订单&#xff0c;是招人。尤其是验布岗位&#xff0c;年轻人不愿干&#xff0c;老师傅陆续退休&#xff0c;青黄不接。验布为什么难招&#xff1f;原因不复杂。第一&#xff0c;这活费眼睛。一天盯着高速运动的布面&#xff0c;眼睛酸、流泪&#xff0c;…

作者头像 李华