JSP+Servlet+DAO网上书店:三层架构与订单事务实战
简介:jsp+servlet网上书店是一套面向Java Web初学者与课程设计学习者的完整示例项目,围绕MVC分层实现图书展示、用户登录、购物车管理与分页浏览等典型电商流程,可用于理解JSP视图、Servlet控制、DAO持久化及JNDI数据库连接之间的协作方式。压缩包共161个文件、约768KB,包含27个java源码与27个class、17个jsp页面,以及gif/jpg界面素材、js脚本、xml配置和sql建库脚本等,基本覆盖从页面到数据库的完整结构。已有169人学习,适合对照课程实验梳理分层职责与调试思路,也可作为小型书店类项目的参考骨架,帮助读者快速定位登录过滤、分页标签、购物车会话管理和数据库访问等实现要点。
1. 网上书店的闭环不在页面上:jsp+servlet+dao 到底管哪几件事
接手一套 jsp+servlet+dao 网上书店源码时,最容易被首页图书列表骗过去:页面能开、图片能显示,就以为项目跑通了。真到下单环节,购物车刷新后数量对不上、订单生成了库存没扣、后台删书后前台还挂着链接,这些问题才暴露三层结构的真实边界。这套项目适合想补齐 Java Web 基础链路的开发者:Servlet 接请求,JSP 做视图,DAO 封 JDBC,Filter 管编码和登录态。它不追求框架堆叠,价值在于把图书查询、购物车、订单落库串成一个能讲清的闭环。你要改的是页面皮肤,还是业务边界,最好在动手前就想明白。
2. 项目骨架与运行链路:JSP+Servlet+DAO 三层怎么落地
这套网上书店源码的骨架不复杂,复杂的是每个目录该放什么、请求从 Tomcat 进来后先经过谁、DAO 的边界画在哪里。常见做法是src下按包分层,WebContent或webapp放 JSP 和静态资源,WEB-INF/lib放 JDBC 驱动与 JSTL,web.xml做 Servlet 映射和过滤器声明。刚拿到源码时不要急着改页面,先把目录和启动链路对一遍,否则后面出现 404、空指针、乱码时,排查会像开黑匣子。
2.1 目录结构与依赖清单
我一般先看目录树,因为它能暴露项目有没有把业务写进 JSP。下面是一个典型结构,实际源码里包名可能不同,但分层逻辑基本一致:
# 典型 JSP+Servlet+DAO 网上书店目录 bookstore/ ├── src/ │ ├── com/bookstore/bean/ # Book、User、Order、CartItem 等实体 │ ├── com/bookstore/dao/ # BookDao、UserDao、OrderDao 接口 │ ├── com/bookstore/dao/impl/ # JDBC 实现类 │ ├── com/bookstore/service/ # 购物车、订单等业务编排 │ ├── com/bookstore/servlet/ # 页面控制器 │ ├── com/bookstore/filter/ # 编码、登录过滤器 │ └── com/bookstore/util/ # DBUtil、分页工具、字符串工具 ├── WebContent/ │ ├── WEB-INF/ │ │ ├── web.xml # Servlet、Filter、欢迎页配置 │ │ └── lib/ # 驱动与 JSTL │ ├── jsp/ # 前台与后台页面 │ ├── images/ # 封面图 │ └── css/ # 样式 └── sql/ └── bookstore.sql # 建库建表脚本这个目录里,bean只放字段和 getter/setter,dao只碰 SQL 和 ResultSet,service负责组合多个 DAO 并控制事务,servlet只做参数接收、调用 service、转发 JSP。参数说明上,包名不是重点,重点是WEB-INF/lib必须包含数据库驱动和 JSTL,否则 JSP 里的<c:forEach>会直接报标签未解析。依赖清单常见为 Servlet API、JSP API、JSTL、MySQL 驱动,连接池可选 Druid 或 DBCP;如果源码里没有连接池,先别急着加,等把 JDBC 链路跑通再替换。
web.xml要重点看三处:Servlet 映射、过滤器顺序、欢迎页。过滤器顺序会决定编码过滤器是否在登录过滤器之前执行,顺序错了会出现登录页面本身乱码的玄学现象。
<!-- web.xml 关键片段:编码过滤器必须在登录过滤器之前 --> <filter> <filter-name>encodingFilter</filter-name> <filter-class>com.bookstore.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>authFilter</filter-name> <filter-class>com.bookstore.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>authFilter</filter-name> <url-pattern>/admin/*</url-pattern> </filter-mapping>这段配置的逻辑是:所有请求先过EncodingFilter,再按路径决定是否进AuthFilter。url-pattern用/*覆盖 JSP 和 Servlet,/admin/*只保护后台入口。参数说明里,encoding建议固定 UTF-8,不要依赖浏览器默认编码;如果过滤器里只写了request.setCharacterEncoding,还要确认响应端设置了response.setContentType("text/html;charset=UTF-8"),否则中文仍可能变成问号。
2.2 从 Tomcat 启动到首个请求的链路
运行这套项目,常见步骤是:装 JDK 和 Tomcat,建 MySQL 库,导入sql/bookstore.sql,改DBUtil里的连接地址、账号、密码,把项目部署到 Tomcat 的webapps或 IDE 的 Server 视图,启动后访问http://localhost:8080/bookstore/。这里最容易翻车的是数据库名和项目上下文路径不一致:web.xml里欢迎页写的是index.jsp,但 JSP 里跳转写死/bookstore/bookList,项目名一改就 404。
建表脚本建议先看清外键和字符集。下面是一个最小可跑的表结构片段:
-- 图书表与订单表的核心字段,字符集统一 utf8mb4 CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(120) NOT NULL, author VARCHAR(80), price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, category_id INT, cover VARCHAR(255), status TINYINT DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, book_id INT NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段 SQL 的逻辑是:book.stock承担库存扣减,orders记录订单主表,order_item记录下单时的价格快照。参数说明里,金额用DECIMAL而不是DOUBLE,避免购物车累加出现 0.30000000000000004 这种尴尬;order_no加唯一索引,后面防重复提交时能兜底;status用 Tinyint 表示上下架或订单状态,比字符串更省空间。我一般会先把book插几条测试数据,再启动项目,否则首页空列表会让人误以为查询代码坏了。
2.3 数据库表与 DAO 的职责边界
DAO 层最容易写歪:有人把分页、库存判断、订单状态流转全塞进一个BookDao,最后 Servlet 变成传参工具,JSP 变成第二套业务逻辑。我的习惯是 DAO 只做单表 CRUD 和明确查询,跨表事务放在 Service。接口先定出来,实现类再补 JDBC 细节,这样二次开发时替换连接池或加缓存不至于满屏改。
// BookDao 接口:只暴露查询与库存更新,不在这里拼订单业务 public interface BookDao { List<Book> findPage(String keyword, int offset, int size); int count(String keyword); Book findById(int id); int reduceStock(Connection conn, int bookId, int quantity) throws SQLException; }这个接口的逻辑说明:findPage负责分页列表,count给分页总数,findById给详情和购物车校验用,reduceStock接收外部Connection,是为了让订单 Service 把扣库存和写订单放在同一个事务里。参数说明里,offset是起始行,size是每页条数,keyword允许为空;reduceStock返回受影响行数,返回 0 就代表库存不足或图书不存在,Service 必须回滚。把Connection作为参数传进 DAO 看起来不优雅,但在没有 Spring 的项目里,这是控制事务边界最直接的办法;如果让 DAO 自己拿连接,订单和库存就会各用各的连接,事务直接失效。
3. 核心模块拆解:图书检索、购物车、订单三段的代码走读
网上书店的页面很多,真正决定能不能用的只有三段:图书检索把商品摆出来,购物车把选择暂存住,订单把选择变成不可逆的落库记录。这三段分别对应查询、Session 状态和事务,任何一段偷懒,后面都会用 bug 还债。下面按代码走读,重点看参数怎么进、SQL 怎么拼、异常怎么处理。
3.1 图书检索与分页查询
图书列表通常由一个 Servlet 接收page、size、keyword,调用 DAO 查列表和总数,再转发到 JSP。分页不要用LIMIT page, size直接拼页码,页码要从请求参数转成 offset,并做上下界保护,否则用户手动改 URL 就能把负数或超大页码传进 SQL。
// BookListServlet:接收分页参数,组装分页结果 protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int page = parsePositiveInt(request.getParameter("page"), 1); int size = parsePositiveInt(request.getParameter("size"), 8); String keyword = trimToEmpty(request.getParameter("keyword")); int total = bookDao.count(keyword); int totalPages = Math.max(1, (total + size - 1) / size); page = Math.min(page, totalPages); // 防止页码越界 int offset = (page - 1) * size; List<Book> books = bookDao.findPage(keyword, offset, size); request.setAttribute("books", books); request.setAttribute("page", page); request.setAttribute("totalPages", totalPages); request.setAttribute("keyword", keyword); request.getRequestDispatcher("/jsp/bookList.jsp").forward(request, response); }这段代码的逻辑是:先算总数,再算总页数,再修正当前页,最后查当前页数据。参数说明里,parsePositiveInt负责把空值、非数字、负数兜底成默认值;size不要开放太大,常见限制在 20 以内;keyword要 trim,空字符串走全量查询。totalPages至少为 1,是为了让 JSP 分页条在零结果时也不出现0/0。另一个容易忽略的点是forward而不是redirect,因为查询结果放在 request 域里,重定向会丢数据。
JSP 端用 JSTL 渲染,不要把for循环写成 scriptlet。下面这段只做展示,价格格式化用<fmt:formatNumber>:
<!-- bookList.jsp:只负责渲染,不查数据库 --> <c:forEach items="${books}" var="book"> <div class="book-card"> <img src="${pageContext.request.contextPath}/images/${book.cover}" alt="${book.title}"> <h3>${book.title}</h3> <p>${book.author}</p> <p>¥<fmt:formatNumber value="${book.price}" pattern="0.00"/></p> <a href="${pageContext.request.contextPath}/bookDetail?id=${book.id}">查看详情</a> </div> </c:forEach>这段 JSP 的逻辑说明:items="${books}"接 Servlet 放进 request 的列表,pageContext.request.contextPath保证项目名变化时图片和链接仍然正确,fmt:formatNumber统一金额显示。参数说明里,book.cover建议只存文件名,不存绝对路径,方便部署到不同机器;alt用书名,既照顾可访问性,也方便排查图片 404。JSP 里一旦出现new BookDao()或 JDBC 代码,就说明分层被击穿了,后面改字段要同时翻 JSP 和 DAO,血泪经验就是这种项目最先烂在视图层。
3.2 购物车 Session 结构与数量更新
小型网上书店常见做法是把购物车放在 Session 里,键用cart,值用Map<Integer, CartItem>,key 是图书 id,value 是数量和价格快照。这样加书、改数量、删除都不需要频繁写库,结算时再统一转订单。关键点是数量边界和金额计算:数量不能小于 1,不能超过库存;金额用BigDecimal累加,不要用 double。
// CartService:Session 购物车的增删改,保持数量与金额一致 public class CartService { public static final String CART_KEY = "cart"; public void add(HttpSession session, Book book, int quantity) { Map<Integer, CartItem> cart = getCart(session); CartItem item = cart.get(book.getId()); int newQty = (item == null ? 0 : item.getQuantity()) + quantity; if (newQty > book.getStock()) { newQty = book.getStock(); // 不超过库存 } if (newQty <= 0) { cart.remove(book.getId()); } else { cart.put(book.getId(), new CartItem(book.getId(), book.getTitle(), book.getPrice(), newQty)); } } @SuppressWarnings("unchecked") public Map<Integer, CartItem> getCart(HttpSession session) { Object value = session.getAttribute(CART_KEY); if (value == null) { value = new LinkedHashMap<Integer, CartItem>(); session.setAttribute(CART_KEY, value); } return (Map<Integer, CartItem>) value; } }这段代码的逻辑是:从 Session 取购物车,没有就新建;同一本书累加数量;超过库存就压到库存上限;数量小于等于 0 就从购物车移除。参数说明里,quantity来自商品详情页的输入框,Servlet 层要先做数字校验;CartItem里保存价格快照,避免后台改价后购物车金额突然变化。LinkedHashMap是为了保持加入顺序,页面展示更稳定。Session 购物车不是永久数据,用户换浏览器或清 Cookie 就会丢,如果项目要求跨设备同步,就得加cart表,但这会牵出登录态和合并逻辑,不建议新手在第一轮改造里动。
3.3 订单生成与事务边界
订单是整套项目最不能糊弄的地方。常见错误是先用 DAO 查库存,再另开连接扣库存,最后插订单,中间任何一步失败都不回滚,结果订单有了、库存没扣,或者库存扣了、订单没生成。正确做法是在 Service 里拿一个Connection,关闭自动提交,按“校验库存→写订单主表→写订单明细→扣库存→清购物车”的顺序执行,异常统一 rollback。
// OrderService:一个连接内完成订单与库存,异常必须回滚 public String createOrder(int userId, Map<Integer, CartItem> cart) throws SQLException { if (cart == null || cart.isEmpty()) { throw new IllegalArgumentException("购物车为空"); } Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); BigDecimal total = BigDecimal.ZERO; for (CartItem item : cart.values()) { int rows = bookDao.reduceStock(conn, item.getBookId(), item.getQuantity()); if (rows == 0) { throw new SQLException("库存不足: " + item.getTitle()); } total = total.add(item.getPrice().multiply( BigDecimal.valueOf(item.getQuantity()))); } String orderNo = "NO" + System.currentTimeMillis(); int orderId = orderDao.insertOrder(conn, orderNo, userId, total); for (CartItem item : cart.values()) { orderDao.insertItem(conn, orderId, item); } conn.commit(); cart.clear(); return orderNo; } catch (Exception e) { if (conn != null) { conn.rollback(); } throw new SQLException("下单失败", e); } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } }这段代码的逻辑说明:setAutoCommit(false)开启事务,reduceStock返回 0 就抛异常触发回滚,订单主表和明细都使用同一个conn,成功后统一commit。参数说明里,userId从 Session 登录用户取,不能由前端传;cart是 Session 里的购物车副本;orderNo用时间戳只是示例,生产环境要加用户 id 或随机段避免并发重复。finally里恢复自动提交再关闭连接,是给连接池复用的基本礼貌,少了这一步,连接池可能把带事务状态的连接借给下一个请求,排查起来像玄学。
4. 权限与后台管理:登录、过滤器、图书维护的落地做法
前台决定用户买不买,后台决定运营能不能维护。网上书店的后台通常包括登录、图书增删改、订单查看,部分项目还有分类管理。权限不要在每个 Servlet 里手写if (session.getAttribute("user") == null),那样漏一个入口就多一个后门。常见做法是登录 Servlet 写 Session,过滤器统一拦/admin/*,JSP 只做展示,不承担鉴权。
4.1 登录会话与角色校验
登录流程是:表单提交用户名密码,UserDao按用户名查记录,密码校验通过后把用户对象放进 Session,重定向到后台首页。密码不要明文存,老项目里常见 MD5,我一般会改成 SHA-256 加盐,至少不在数据库里裸奔。角色字段用role区分普通用户和管理员,后台入口只允许管理员。
// LoginServlet:登录成功写 Session,失败回登录页 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username"); String password = request.getParameter("password"); User user = userDao.findByUsername(username); if (user == null || !PasswordUtil.matches(password, user.getPassword())) { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("/jsp/login.jsp").forward(request, response); return; } if (user.getRole() != 1) { request.setAttribute("error", "无后台权限"); request.getRequestDispatcher("/jsp/login.jsp").forward(request, response); return; } request.getSession().setAttribute("loginUser", user); response.sendRedirect(request.getContextPath() + "/admin/bookList"); }这段代码的逻辑说明:先查用户,再校验密码,再校验角色,最后写 Session 并重定向。参数说明里,role=1代表管理员,具体值要跟数据库一致;PasswordUtil.matches负责哈希比对,不要直接equals明文;sendRedirect用contextPath拼路径,避免项目名变化后跳到根路径。登录失败用forward保留错误信息,成功用重定向避免刷新重复提交表单,这是最基础的 PRG 习惯。
4.2 编码过滤器与登录过滤器
编码过滤器解决请求和响应乱码,登录过滤器解决后台越权。两个过滤器都实现Filter接口,doFilter里要么放行chain.doFilter,要么重定向到登录页。注意登录过滤器要放行登录页、登录 Servlet 和静态资源,否则会出现登录页也被拦截、重定向死循环的翻车现场。
// AuthFilter:只拦后台,放行登录相关路径 public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); String context = request.getContextPath(); if (uri.endsWith("/login.jsp") || uri.endsWith("/login") || uri.contains("/css/") || uri.contains("/images/")) { chain.doFilter(req, resp); return; } Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(context + "/jsp/login.jsp"); return; } chain.doFilter(req, resp); }这段代码的逻辑说明:先拿 URI 和上下文路径,命中白名单直接放行,没登录就重定向,已登录继续走 Servlet。参数说明里,白名单不要只写/login,JSP 登录页和静态资源也要放;uri.contains只是示例,更稳的做法是用startsWith(context + "/css/")。过滤器里不要查数据库,只做会话判断,重查询会拖慢每个后台请求。
4.3 后台图书维护与文件上传
后台图书维护一般是列表、新增、编辑、删除四个入口。新增和编辑会涉及封面图上传,Servlet 3.0 可以用@MultipartConfig和request.getPart,老项目可能用 commons-fileupload。文件上传要限制扩展名和大小,保存文件名用 UUID,不要用用户原始文件名,否则同名覆盖和路径穿越都会找上门。
// BookAdminServlet:新增图书并保存封面,文件名做随机化 @MultipartConfig(maxFileSize = 2 * 1024 * 1024) public class BookAdminServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String title = request.getParameter("title"); BigDecimal price = new BigDecimal(request.getParameter("price")); int stock = Integer.parseInt(request.getParameter("stock")); Part coverPart = request.getPart("cover"); String cover = ""; if (coverPart != null && coverPart.getSize() > 0) { String original = coverPart.getSubmittedFileName(); String ext = original.substring(original.lastIndexOf('.')); cover = UUID.randomUUID().toString().replace("-", "") + ext; String dir = getServletContext().getRealPath("/images"); coverPart.write(dir + File.separator + cover); } Book book = new Book(title, price, stock, cover); bookDao.insert(book); response.sendRedirect(request.getContextPath() + "/admin/bookList"); } }这段代码的逻辑说明:接收表单字段,处理上传文件,生成随机文件名,写到images目录,最后插入数据库并重定向。参数说明里,maxFileSize限制 2MB 只是示例,按项目需要调整;ext要校验白名单,不能只截最后一个点;getRealPath在打包成 war 后可能指向临时目录,部署时最好配置独立图片目录。删除图书建议做逻辑删除,把status置 0,不要直接删物理记录,否则历史订单明细会失去关联,后面查订单只能看到一串 book_id。
5. 避坑与排查:JSP+Servlet+DAO 项目最容易翻车的 5 个现场
这套 jsp+servlet+dao 网上书店的坑,大多不是语法错误,而是运行环境和数据边界没对齐。下面五条是我在类似项目里反复见到的现场,每条按“现象→原因→解决”拆开,能少走一些弯路。
5.1 中文乱码:POST 正常、GET 变问号
现象是后台新增图书时,POST 提交的中文书名正常,翻页链接带keyword=java查中文时变成问号,JSP 页面标题也偶尔乱码。原因通常有两个:请求体编码和 URL 参数编码不是同一套处理,Tomcat 对 GET 参数的默认解码依赖URIEncoding,而过滤器里的setCharacterEncoding只对 POST 体生效;响应端又没统一设置Content-Type。
解决分三步:过滤器里同时设置请求和响应编码;GET 参数在拼接 URL 时用URLEncoder.encode(keyword, "UTF-8");Tomcat 的server.xml里确认 Connector 使用 UTF-8 解码。很多“玄学乱码”其实是页面、请求、数据库连接三处字符集不一致,建库时用 utf8mb4,JDBC URL 带characterEncoding=utf8,JSP 页头写pageEncoding="UTF-8",三处对齐后基本就稳了。
5.2 连接池耗尽与事务未回滚
现象是项目刚启动正常,点几下后台就卡住,日志里出现waiting for connection或连接超时;重启后又好一阵。原因常见为 DAO 里拿了连接没关、ResultSet和PreparedStatement没关,或者订单事务抛异常后没有rollback,连接带着未提交事务被还回池里,后续请求读到脏数据。新手项目里DBUtil.getConnection()到处 new,最容易出这类血泪经验。
解决是用 try-with-resources 管住 JDBC 资源,Service 里显式setAutoCommit(false)、commit、rollback,并在finally恢复自动提交再关闭。连接池要配最大连接数和超时时间,不要无限等。排查时先看连接池活跃数,再看慢 SQL,最后看是否有未关闭的Connection,顺序反了容易在业务代码里绕圈。
5.3 JSP 里写业务导致的维护灾难
现象是改一个图书字段,要同时改 JSP、Servlet、DAO 三个地方;JSP 顶部一大段<% %>里连数据库,页面打开慢,报错还只显示 500。原因是视图层直接new BookDao()或写 JDBC,业务逻辑和 HTML 混在一起,JSTL 只用来做了一半。这样的项目二次开发时,谁都不敢动页面,因为不知道哪行 scriptlet 会影响下单。
解决是把 JSP 里的查询、计算、循环取数全部挪到 Servlet 或 Service,JSP 只保留<c:forEach>、<c:if>和格式化标签。先做小步替换:把 JSP 顶部查列表的代码搬到 Servlet 的doGet,用 request 域传值,页面改成${books};再逐步清理 scriptlet。不要一次性重写所有页面,先改首页和详情页,跑通后再动后台,翻车概率会低很多。
5.4 路径与静态资源 404
现象是本地 IDE 里图片能显示,部署到 Tomcat 后 CSS 和封面全 404,跳转链接还跑到根路径。原因是 JSP 里写死了/bookstore/images/...或../images/...,项目上下文路径一改就失效;Servlet 重定向时又没拼request.getContextPath()。这类问题不难,但排查时容易怀疑到 Tomcat 配置上。
解决是统一用${pageContext.request.contextPath}拼前台资源路径,Servlet 里用request.getContextPath() + "/目标路径"。静态资源建议放WebContent/static下按 css、js、images 分目录,过滤器和拦截器白名单也要同步放行。部署后先访问一个静态图片确认路径,再看 Servlet 映射,别一上来就改web.xml。
5.5 订单重复提交:刷新一下就多一单
现象是用户点“提交订单”后页面刷新,订单列表多出一条相同订单,库存被扣两次;网络慢时连点按钮也会重复。原因是订单 Servlet 用forward回结果页,浏览器刷新会重发 POST;页面按钮也没做防重复。更隐蔽的是订单号用时间戳,并发时可能撞唯一索引,用户看到报错但库存已经扣了。
解决分两层:提交成功后用sendRedirect跳到订单详情,形成 PRG;下单前在 Session 放一个 token,表单隐藏域带上,Service 校验并删除 token,重复提交直接拒绝。数据库层给orders.order_no加唯一索引兜底,捕获唯一键异常后回滚并提示“请勿重复提交”。我一般还会在提交按钮上加一次禁用,但前端禁用只是体验,后端 token 才是后悔药。
6. 二次开发:从“能跑”到“能改”的三板斧
6.1 先固化 DAO 接口与事务模板
二次开发最怕上来就改页面,我的习惯是先看 DAO 接口有没有把单表查询和跨表事务混在一起。把BookDao、OrderDao的接口方法列出来,凡是需要多个 DAO 协作的动作,统一收进 Service,并套一个事务模板:拿连接、关自动提交、执行业务、提交、异常回滚、finally 归还。这样后面加优惠券、改库存策略,只需要在 Service 里插步骤,不用满项目找 Connection。
6.2 用三组用例验证改造
改完不要只点首页,至少跑三组用例:未登录访问后台应跳登录页;购物车数量超过库存应被压回上限;下单过程中手动制造库存不足应整单回滚。下面这张表可以贴在项目根目录当自检清单:
| 验证点 | 操作 | 预期 |
|---|---|---|
| 权限拦截 | 未登录访问/admin/bookList | 302 到登录页 |
| 购物车边界 | 数量改为 999 | 不超过库存 |
| 订单事务 | 下单时把库存改成 0 | 订单与明细都不落库 |
我现在每次改这类 jsp+servlet+dao 网上书店,都先把事务边界和验证用例走一遍,再动 JSP 皮肤;吃过一次“页面改完才发现库存扣重”的亏之后,这个顺序就不敢省。希望帮到你。
本文还有配套的精品资源,点击获取