简介:网上书店系统设计代码是一份面向计算机专业课程设计、毕业设计学习者的B/S架构电商案例,围绕在线图书查询、购买、购物车、订单与后台管理展开业务流程。压缩包共60个文件、约2.19MB,以20个ASP页面和Access数据库(mdb、db)为核心,辅以21张jpg与8个gif商品及界面素材、3份doc说明文档和htm、asa等配置文件,可帮助读者梳理前台展示、用户校验、图书维护与订单处理等模块。需要说明的是,标题与标签强调JSP技术,但包内实际页面主要为ASP脚本,更适合作为系统设计思路、数据库结构与页面原型的参考,按JSP实现时需自行迁移。目前已有54人学习,适合用来对照毕业设计文档、理解B/S三层结构与书店业务闭环。
1. 先别急着写增删改查:网上书店系统真正卡人的是状态与链路
网上书店系统这几个字一摆出来,很多人第一反应是「增删改查嘛,两天能写完」。真动手才知道,JSP 这套技术栈的坑集中在状态管理和请求链路上:购物车放哪个作用域、后台改价之后购物车里的价格要不要跟着变、下单那一刻怎么保证库存不超卖。这套方案用 JSP + Servlet + JavaBean 做一套可演示、可答辩、代码量控制在两三千行的网上书店系统,覆盖图书浏览、分类检索、购物车、下单、订单查询、后台图书管理六块功能,适合要交 Java Web 课程设计或毕业设计的学生,也适合需要快速搭一个电商原型给业务方过流程的从业者。下面按选型、库表、模块、避坑、验收的顺序,把这套东西完整摊开。
2. 技术选型与总体架构:不套框架,用三层结构加一条请求链路撑住
2.1 为什么这套系统坚持 JSP + Servlet,而不是直接上 Spring Boot
网上书店系统设计的交付场景很固定:一台装了 Tomcat 的机器、一个 MySQL 实例、JDK 8 环境。这类场景里,原生 JSP + Servlet 的最大优势是依赖极少——除了 mysql-connector-java 和 JSTL 两个 jar,不需要额外安装;把目录复制进 webapps 就能启动,换台机器也一样。很多同学答辩前一晚出问题,不是业务逻辑写错了,而是构建工具拉不到依赖、框架版本和 JDK 对不上,这类问题在课程设计的时间窗口里非常致命。
第二层考虑是可解释性。答辩时被问到「这个请求从浏览器到数据库中间经过了什么」,原生方案能一层层指出来:Servlet 接参数、Service 做校验、DAO 拼 SQL、JSP 渲染。换成框架,大量环节由自动配置完成,回答不上来就会被追问。这不是说框架不好,而是交付目标不同——课设要的是「每一行代码都能讲清楚」。
| 维度 | JSP + Servlet 原生 | Spring Boot + Thymeleaf |
|---|---|---|
| 运行依赖 | Tomcat + 两个 jar | JDK 17 + 构建工具 + 网络 |
| 首次可运行耗时 | 部署即跑,分钟级 | 首次构建常在十分钟以上 |
| 每层可解释性 | 四层职责清晰可指认 | 自动配置需要额外解释 |
| 样板代码量 | 多写约三成 | 少写样板,配置项变多 |
| 适配场景 | 课设、毕设、单机演示 | 团队协作、长期迭代 |
选型的另一半是给 JSP 划定边界。这个项目里我一般要求 JSP 页面只出现 EL 表达式和 JSTL 标签,禁止<% %>脚本片段。脚本片段一旦放开,页面很快会变成「SQL 写在 JSP 里、循环写在 JSP 里」的混合体,改一个字段名要去五六个页面里找。用<c:forEach>渲染图书卡片、用${book.price}取值,页面只管展示,逻辑全在 Java 层。
<!-- 商品列表页只做渲染,不出现任何 Java 脚本片段 --> <c:forEach items="${page.rows}" var="book"> <div class="card"> <img src="${pageContext.request.contextPath}${book.coverUrl}" alt="${book.title}"> <h3>${book.title}</h3> <p>${book.author}</p> <span class="price">¥${book.price}</span> <a href="${pageContext.request.contextPath}/book/detail?id=${book.id}">查看</a> </div> </c:forEach>这段特别注意${pageContext.request.contextPath}前缀:部署到 Tomcat 时项目名就是上下文路径,写死/static/会在换部署目录名之后全部 404,这个坑在第 5 章还会展开。
2.2 包结构:五个包把职责切干净
一个能长期改得动的 Java Web 项目,包划分比类设计更重要。这套系统的源码目录按职责切成 entity、dao、service、web、filter、util 六块,JSP 统一放在WEB-INF/views下面。
src/main/java/com/example/bookstore ├── entity Book / User / Category / Order / OrderItem,和库表一一对应 ├── dao 只写 SQL 与 ResultSet 映射,不出现业务判断 ├── service 业务规则与事务边界,一个方法对应一个完整动作 ├── web Servlet,只做参数接收、调用 Service、选择跳转 ├── filter 编码过滤、登录拦截、后台权限 └── util DBUtil / PageBean / OrderNoUtil / StringUtil webapp ├── WEB-INF │ ├── web.xml │ └── views 所有 JSP,浏览器无法直接访问 ├── static css / js / images └── index.jsp 仅做一次 redirect 到图书列表JSP 放进WEB-INF是这个结构里最关键的一条。WEB-INF目录下的资源浏览器无法直接请求,只能由 Servlet 转发进入,这就从物理上堵住了「用户直接在地址栏敲/views/admin/bookList.jsp绕过登录」这条路径。很多课程设计的后台之所以形同虚设,就是因为后台页面放在 webapp 根目录下,谁都能访问。
DAO 层只做数据搬运,一个方法里不允许出现「库存够不够」「用户有没有权限」这类判断;Service 层是唯一允许开事务的地方;Servlet 层不允许出现 SQL 和业务规则。这三条边界守住了,后面加功能才不会牵一发动全身。
2.3 一条「加入购物车」请求走完的四层
架构讲再多不如看一条真实链路。从用户点「加入购物车」到页面跳回购物车列表,中间经过 Servlet 接参数、Service 校验、Session 中的购物车对象更新、最后重定向,四步各有各的失败点。
@WebServlet("/cart/add") public class AddCartServlet extends HttpServlet { private final BookService bookService = new BookService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 参数校验:id 缺失或不是数字时直接回列表,不抛 NumberFormatException String idStr = req.getParameter("bookId"); if (idStr == null || !idStr.matches("\\d{1,9}")) { resp.sendRedirect(req.getContextPath() + "/books"); return; } int bookId = Integer.parseInt(idStr); // 2. 业务校验:书必须存在且处于上架状态,防止改 URL 加购下架书 Book book = bookService.findById(bookId); if (book == null || book.getStatus() != 1) { resp.sendRedirect(req.getContextPath() + "/books?msg=unavailable"); return; } // 3. 购物车挂在 Session 上,没有就新建一个存进去 HttpSession session = req.getSession(); Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); } cart.add(book, 1); // 4. PRG 模式:POST 之后重定向,用户按 F5 不会重复加购 resp.sendRedirect(req.getContextPath() + "/cart"); } }四个参数的取舍值得说清楚。bookId用正则先挡掉非数字,避免Integer.parseInt直接抛异常导致 500 页面;book.getStatus() != 1这一层业务校验很多人会省掉,结果就是在后台把书下架之后,老用户从收藏夹点进来还能加购、还能下单,最后库存对不上;getSession()不带参数表示「有就用、没有就建」,购物车对象本身存在 Session 里而不是每次 new;第四步的重定向属于 PRG(Post-Redirect-Get)模式,它解决的是浏览器刷新重复提交这个经典问题,少了这一步,用户手一抖按两下 F5,购物车里就多出两本书。
这套链路放在整个系统里是一致的:列表页由BookListServlet查库后转发到bookList.jsp,详情页由BookDetailServlet转发到bookDetail.jsp,所有写操作统一走「POST → 处理 → 重定向」,读操作统一走「GET → 查询 → 转发」。路径风格定死之后,后面写三十个 Servlet 都不会乱。
3. 数据库设计:六张表把图书、用户和订单关系理清楚
3.1 表清单与字段取舍
这套系统的库表不多,六张就够支撑全部功能,关键是每张表的职责要单一。
| 表名 | 作用 | 关键字段 | 预估量级 |
|---|---|---|---|
| user | 前台会员与后台管理员共用 | username、password、role、phone | 百级 |
| address | 收货地址,一个用户多条 | user_id、receiver、phone、detail | 百级 |
| category | 图书分类,两级足够 | name、parent_id、sort_no | 十级 |
| book | 图书主数据 | isbn、title、price、stock、status | 千级 |
| orders | 订单主表 | order_no、user_id、total_amount、status | 万级 |
| order_item | 订单明细,含价格快照 | order_id、book_id、book_title、unit_price | 十万级 |
几个字段的取舍是有讲究的。user.role用 0/1 区分普通会员和管理员,不单独建一张 admin 表,因为登录逻辑完全一样,只差一个权限判断,拆表反而要写两套登录。book.status用 1 上架、0 下架,删除图书时只改状态不做物理删除,因为历史订单里还引用着这本书。category.parent_id为 0 表示一级分类,两级分类足够覆盖「文学 → 小说」「计算机 → 编程语言」这种常见结构,再深就得写递归查询,对课设来说性价比很低。
购物车没有单独建表。这是有意为之:购物车是临时状态,用户关掉浏览器就该清掉,用 Session 存内存对象足够了;真正需要持久化的是订单,而订单在提交那一刻已经由购物车「冻结」成明细快照,两者职责不重叠。这条决策在第 4 章和第 6 章还会分别从实现和答辩两个角度再讲一遍。
3.2 建表脚本:字符集、金额类型和索引
CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, -- 业务唯一键,防重复录入 title VARCHAR(120) NOT NULL, author VARCHAR(60) NOT NULL, category_id INT NOT NULL, price DECIMAL(10,2) NOT NULL, -- 金额绝不用 FLOAT stock INT NOT NULL DEFAULT 0, cover_url VARCHAR(255) DEFAULT '', description TEXT, status TINYINT NOT NULL DEFAULT 1, -- 1 上架 0 下架 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id, status), -- 分类页最常用的组合 KEY idx_title (title) -- 支持按书名模糊查 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, -- 对外单号,不用自增 id user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待付款 1已付款 2已发货 3已完成 4已取消 receiver_name VARCHAR(30) NOT NULL, receiver_phone VARCHAR(20) NOT NULL, receiver_address VARCHAR(200) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, created_at) -- 「我的订单」按时间倒序 ) 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, book_title VARCHAR(120) NOT NULL, -- 快照,见 3.3 unit_price DECIMAL(10,2) NOT NULL, -- 快照,见 3.3 quantity INT NOT NULL, KEY idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字符集统一用utf8mb4,不是utf8。原因是 MySQL 里的utf8实际是三个字节的版本,存不下书名里偶尔出现的特殊符号和 emoji,插进去直接报错或者变成问号。这个细节在验收时最容易被抓,特别是后台录入一本外版书的时候。
金额字段一律DECIMAL(10,2)。用FLOAT或DOUBLE存价格,做购物车合计时会出现0.1 + 0.2 = 0.30000000000000004这种事,前端显示成¥0.30看不出来,一旦做「满 100 减 20」这类促销计算就会对不上账。
索引只加了两类:book表的(category_id, status)组合索引对应分类页查询,orders表的(user_id, created_at)对应「我的订单」列表。外键约束在这个项目里不建,改用应用层保证——课设环境经常要手工清数据或者重导 SQL,外键会让TRUNCATE失败,排查起来很浪费时间。这个取舍需要写进设计文档,答辩时被问到「为什么不用外键」能答得上来。
3.3 订单明细为什么要冗余书名和单价
订单明细表里同时存了book_id、book_title、unit_price三个字段,看起来像是重复存了一份图书信息。这恰恰是订单系统必须做的事,专业说法叫快照。
设想一个场景:用户三个月前买的那本书当时是 45 元,后台今天把价格调到 39 元。如果订单明细只存book_id,展示历史订单时 join 一下book表,用户会看到自己「当初花了 39 元买的」。更严重的是书名被编辑过、或者这本书被下架甚至物理删除,历史订单就直接展示成空白。订单是交易凭证,它记录的是「成交那一刻的事实」,不能随着主数据变化而变化。
所以明细表里的book_title和unit_price是从购物车提交那一刻原样复制进去的,book_id只作为关联查询用的弱引用,展示时永远读快照字段。这条规则也解释了为什么图书只能下架不能删除:主数据可以逻辑失效,凭证不能消失。
-- 下单时把购物车内容写成快照,price 取的是提交那一刻的 book.price INSERT INTO order_item (order_id, book_id, book_title, unit_price, quantity) VALUES (?, ?, ?, ?, ?);4. 核心模块实现:购物车、分页、登录拦截与下单事务的落地代码
4.1 购物车:Session 里的有序 Map,金额用 BigDecimal
购物车对象的设计决定了后续一堆功能好不好写。核心诉求是「同一本书只占一行、数量累加、顺序稳定、金额精确」。
public class Cart implements Serializable { // key 用 bookId,天然去重;LinkedHashMap 保证加入顺序不乱跳 private final Map<Integer, CartItem> items = new LinkedHashMap<>(); public void add(Book book, int qty) { CartItem item = items.get(book.getId()); if (item == null) { items.put(book.getId(), new CartItem( book.getId(), book.getTitle(), book.getPrice(), qty)); } else { // 这里是累加而不是覆盖,用户连点两次「加购」就是两本 item.setQuantity(item.getQuantity() + qty); } } public void updateQty(int bookId, int qty) { if (qty <= 0) { items.remove(bookId); return; } CartItem item = items.get(bookId); if (item != null) item.setQuantity(qty); } public BigDecimal getTotal() { BigDecimal sum = BigDecimal.ZERO; for (CartItem i : items.values()) { sum = sum.add(i.getSubtotal()); // 每行已用 BigDecimal 算好 } return sum; } public int getCount() { return items.values().stream().mapToInt(CartItem::getQuantity).sum(); } }Serializable这个接口不是可选项。Tomcat 在重启或者内存紧张时会把 Session 序列化到磁盘,购物车对象没实现这个接口就会抛NotSerializableException,表现为用户重新打开页面购物车被清空。LinkedHashMap而不是HashMap,是为了让购物车列表的显示顺序和用户加购顺序一致,用HashMap时顺序是散列出来的,用户会觉得列表在乱跳。
这里有一处必须提前定死:购物车里的price是加购那一刻从数据库读出来的,它可能已经过期。所以下单时必须重新按book_id查一次当前价格和库存,不能拿购物车里的价格直接算钱。这是「展示用旧价、结算用新价」的原则,很多同学把它写反了,被后台改价测出漏洞。
4.2 分页:一个 PageBean 加两条共用 where 的 SQL
图书列表、搜索结果、后台图书管理、我的订单,四处都要分页。写一套通用的分页封装,能省掉大量重复代码。
public class PageBean<T> { private int pageNo = 1; private int pageSize = 8; private int total; // 总记录数,由 count SQL 填充 private List<T> rows; public int getOffset() { return (pageNo - 1) * pageSize; } public int getTotalPages() { return total % pageSize == 0 ? total / pageSize : total / pageSize + 1; } public boolean isHasPrev() { return pageNo > 1; } public boolean isHasNext() { return pageNo < getTotalPages(); } // getter / setter 略 }DAO 里最关键的一点是:count 查询和 list 查询共用同一段 where 条件,避免两处条件写得不一致导致「总数说有 30 条,翻到第 3 页是空的」。
public PageBean<Book> pageByCategory(Integer categoryId, String keyword, int pageNo, int pageSize) { PageBean<Book> page = new PageBean<>(); page.setPageNo(pageNo); page.setPageSize(pageSize); StringBuilder where = new StringBuilder(" FROM book WHERE status = 1 "); List<Object> args = new ArrayList<>(); if (categoryId != null) { where.append(" AND category_id = ? "); args.add(categoryId); } if (keyword != null && !keyword.trim().isEmpty()) { where.append(" AND (title LIKE ? OR author LIKE ?) "); args.add("%" + keyword.trim() + "%"); args.add("%" + keyword.trim() + "%"); } page.setTotal(queryCount("SELECT COUNT(*)" + where, args)); // LIMIT 的两个参数必须追加在 args 末尾,顺序不能错 List<Object> listArgs = new ArrayList<>(args); listArgs.add(page.getOffset()); listArgs.add(pageSize); page.setRows(queryList("SELECT *" + where + " ORDER BY id DESC LIMIT ?, ?", listArgs)); return page; }两个参数说明。pageNo必须做一次下限保护,用户手改成pageNo=0或负数时offset会变成负值,MySQL 直接报语法错误,通常的做法是在 Servlet 里Math.max(1, pageNo)兜一下。pageSize后台管理可以给 15、前台给 8,但要设上限(一般 50),否则有人传pageSize=99999就等于把整张表拉进内存。
4.3 登录拦截:白名单、两种响应、以及 Filter 的执行顺序
登录拦截用 Filter 实现,难点不在拦截本身,在于「哪些该放行」和「AJAX 请求怎么响应」。
@WebFilter(urlPatterns = "/*") public class AuthFilter implements Filter { // 白名单:登录注册、静态资源、图书浏览与详情,未登录也能看 private static final List<String> WHITE = Arrays.asList( "/login", "/register", "/static", "/books", "/book/detail"); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 去掉上下文路径后的相对路径,才能和白名单正确比对 String path = req.getRequestURI().substring(req.getContextPath().length()); if (isWhite(path) || req.getSession().getAttribute("loginUser") != null) { chain.doFilter(request, response); return; } // 同步请求跳登录页并带上回跳地址;AJAX 请求返回 401 让前端处理 if ("XMLHttpRequest".equals(req.getHeader("X-Requested-With"))) { resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); } else { resp.sendRedirect(req.getContextPath() + "/login?redirect=" + URLEncoder.encode(path, "UTF-8")); } } private boolean isWhite(String path) { return WHITE.stream().anyMatch(path::startsWith); } }第一个要点是路径比对前要去掉上下文路径,getRequestURI()拿到的是/bookstore/cart/add,直接和白名单里的/cart比永远不匹配,结果就是购物车页面在白名单外、登录页在白名单内,表现正常,等你测到「未登录能不能看图书列表」时才发现整条白名单失效。
第二个要点是同步与异步要分开响应。拦截器对 AJAX 请求直接sendRedirect会返回一个 HTML 登录页,前端fetch拿到的是一段 HTML 而不是 401,JSON 解析直接崩,控制台报的是语法错误,很难定位到是登录过期。
第三个要点是 Filter 顺序。用@WebFilter注解声明多个过滤器时,执行顺序由容器决定,不同 Tomcat 版本可能不一致。编码过滤必须在登录拦截之前执行,否则登录表单里的中文用户名在进入拦截器时还是乱码。稳妥做法是在web.xml里用<filter-mapping>显式声明顺序,注解只留urlPatterns。
4.4 下单事务:条件更新扣库存,一条 SQL 挡住超卖
下单是整个系统里唯一必须开事务的地方,它要同时完成扣库存、写订单主表、写订单明细三件事,任何一步失败都必须整体回滚。
public String submit(int userId, Cart cart, Address addr) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,事务开始 // 1. 按 bookId 升序扣库存,避免多事务交叉加锁造成死锁 List<CartItem> sorted = new ArrayList<>(cart.getItems()); sorted.sort(Comparator.comparingInt(CartItem::getBookId)); for (CartItem item : sorted) { int affected = bookDao.decreaseStock(conn, item.getBookId(), item.getQuantity()); if (affected == 0) { // 影响行数为 0 说明库存不足 conn.rollback(); return "库存不足:" + item.getTitle(); } } // 2. 写订单主表并取回自增主键,供明细表使用 int orderId = orderDao.insert(conn, buildOrder(userId, addr, cart, sorted)); // 3. 批量写明细,书名和单价在这里被快照固定下来 orderItemDao.insertBatch(conn, orderId, sorted); conn.commit(); cart.clear(); // 成功后才清购物车 return null; // null 表示成功 } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignore) {} } throw new RuntimeException("下单失败", e); } finally { DBUtil.close(conn); // 连接一定要还回连接池 } }扣库存那条 SQL 是整套代码里最值得盯的一行:
UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?它把「判断库存够不够」和「扣减库存」合并成了一个原子操作。换成常见写法「先SELECT stock判断够不够、再UPDATE扣减」,两个用户同时下单时都会读到够,然后都扣成功,库存变成负数——这就是超卖的经典复现路径,单机压测很容易撞上,答辩演示时撞上就很难看。
方法返回String而不是抛异常,是为了让「库存不足」这种业务结果和「数据库挂了」这种系统错误区分开:前者要把用户带回购物车并提示书名,后者应该打日志并跳错误页。cart.clear()放在commit()之后,保证事务回滚时购物车内容还在,用户不用重新加购。
5. 避坑与问题排查:JSP 项目里最容易翻车的五处
5.1 中文乱码:只在 JSP 上写 charset 是不够的
现象:后台录入一本《深入理解计算机系统》,前台列表显示成问号;搜索框输入中文,返回零条结果。
原因:乱码有三个入口——请求体、响应体、数据库连接。只在 JSP 页头写<%@ page contentType="text/html;charset=UTF-8" %>只管响应,POST 表单提交的数据在到达 Servlet 之前就已经按容器默认编码解过一次了,再设也没用。
解决:加一个编码 Filter 并放在最前面,同时 JDBC 连接串显式声明编码。
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); // 只管 POST 请求体,GET 靠 server.xml resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }连接串写成jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai,三个参数缺一个都可能出问题。另外 Tomcat 8 以后 GET 请求默认按 UTF-8 解析,但如果部署在旧容器上,还要在server.xml的 Connector 上加URIEncoding="UTF-8"。排查顺序建议从数据库往回查:先用客户端直接执行一条带中文的 insert,能存进去说明库没问题,再往上一层找。
5.2 刷新页面弹「是否重新提交表单」,订单变成两笔
现象:用户在订单确认页点提交,跳转到支付页后按了一下 F5,订单列表里出现两笔一模一样的订单。
原因:表单提交后直接把请求转发到结果页,浏览器地址栏还停在 POST 请求上,刷新就等于重发一次 POST。
解决:所有写操作统一走 PRG 模式,处理完成后sendRedirect到一个 GET 页面。同时给订单表加order_no唯一索引作为最后一道防线,重复提交会因为唯一键冲突被数据库挡下来。更严格一点可以在下单表单里放一个一次性 token,提交时校验并删除,这道保险在课设里通常不强制,但写进设计文档会加分。
5.3 跑一段时间就报「Too many connections」
现象:本地测半天没问题,连续点几十次页面之后开始报连接池耗尽,只能重启 Tomcat。
原因:DAO 里每次DriverManager.getConnection()拿连接,用完忘了close(),或者在 catch 分支里 return 提前跳过了关闭逻辑。
解决:把连接的获取和释放都收进DBUtil,用 try-with-resources 保证异常路径也能关;查询方法统一用「拿连接 → 执行 → finally 关闭」的模板,不允许在 DAO 里手写裸连接。项目规模小可以直接用 Tomcat 自带的连接池,在META-INF/context.xml里配maxActive、maxIdle、maxWait三个参数,maxWait设成 3000 毫秒,连接不够时尽快失败并打日志,而不是无限等待把线程堆死。
5.4 后台页面被直接访问,登录形同虚设
现象:不登录,直接在地址栏输入后台图书管理页的地址,页面正常打开,还能删书。
原因:JSP 放在 webapp 根目录下,路径可被浏览器直接请求;或者拦截器只拦了 Servlet 路径,漏掉了.jsp后缀。
解决:JSP 全部挪进WEB-INF/views,浏览器无法直接访问;拦截器白名单用「前缀匹配」而不是「完全相等」,/static要能放行/static/css/app.css;后台路径统一加/admin前缀,在同一个 Filter 里额外判断loginUser.getRole() == 1,普通会员访问后台直接返回 403。测这条最直接的办法是开一个无痕窗口,不登录把系统里所有能想到的路径挨个敲一遍。
5.5 换台机器部署,样式全丢、图片全部 404
现象:在自己电脑上好好的,拷到同学机器上或者改了项目名,页面变成纯文本,图片全部裂开。
原因:JSP 和 CSS 里写死了绝对路径/static/css/app.css,项目部署名从bookstore换成bookstore_v2之后,实际路径变成/bookstore_v2/static/...,绝对路径指向了根目录。
解决:所有静态资源引用统一加${pageContext.request.contextPath}前缀,或者用<c:url>标签包一层。数据库里存的封面路径也只存相对路径(如/static/covers/1001.jpg),拼接上下文路径的工作交给页面。这条看着是小问题,但在答辩现场换机器演示时是最常见的翻车点,血泪经验。
6. 验收与答辩:用一份可复现的清单证明系统真的跑通了
课程设计和毕业设计最常见的失分点不是功能少,而是「说不清哪里能用、哪里不能用」。我一般会在交付前跑一遍固定清单,每一项都写清楚操作路径和预期结果,答辩时直接把这张表贴进 PPT 的最后一页。
| 验收项 | 操作路径 | 预期结果 | 检查要点 |
|---|---|---|---|
| 图书列表与分页 | 首页 → 翻到第 2 页 | 每页 8 条,页码可点,总数正确 | 总数与列表条数一致 |
| 分类与搜索组合 | 选「计算机」+ 输入作者名 | 结果为交集,无结果时有空态提示 | where 条件未互相覆盖 |
| 未登录拦截 | 无痕窗口直接访问 /cart | 跳登录页,登录后回到购物车 | 回跳地址正确 |
| 加购与改数量 | 同一本书加两次 → 改数量为 3 | 购物车一行,数量 3 | 累加而非覆盖 |
| 库存边界 | 库存 1 的书下单 2 本 | 提示库存不足,购物车保留 | 事务回滚生效 |
| 重复提交 | 提交订单后按 F5 | 不产生第二笔订单 | PRG + 唯一索引 |
| 后台权限 | 普通账号访问 /admin/book | 返回 403 或跳提示页 | JSP 在 WEB-INF 下 |
| 订单快照 | 下单后后台改价 → 看历史订单 | 订单金额仍是成交价 | 明细读快照字段 |
答辩演示控制在五分钟以内,顺序建议是:先给一张架构图讲清四层职责,再用「搜索 → 加购 → 下单 → 查订单」走一遍主流程,最后主动抛出一个自己解决过的问题,比如超卖。主动讲坑比被动被问坑要好得多,因为这证明你理解代码为什么这么写,而不只是能跑。
被问到的概率最高的几个问题,提前准备好答案:购物车为什么放 Session 不放数据库(临时状态、无需持久化、结算时重新校验价格);订单号怎么生成(日期 + 用户 id 后四位 + 随机数,不用自增 id 是为了不暴露业务量);并发下单怎么保证不超卖(条件更新stock >= ?一步完成判断与扣减,影响行数为 0 即失败);如果要做分布式该怎么办(库存放到 Redis 做预扣,数据库只做最终扣减)。
从那以后我每次交这类系统之前,都会强制自己用一个全新的数据库、一个全新的浏览器无痕窗口,从注册开始把上面那张表从头走一遍,一步都不跳。因为演示时坐在你对面的那个人,一定会点你最没测过的那个按钮。希望帮到你。
本文还有配套的精品资源,点击获取