
简介这是一套基于JSP技术栈的网上图书销售系统源码面向计算机专业学生、Java Web初学者及需要课程设计或毕业设计参考的开发者可帮助快速理解电商类系统的完整实现思路。压缩包共243个文件约541KB其中58个java源文件与59个class编译文件构成后台业务逻辑50个jsp页面负责前台展示与交互另有css样式、gif与jpg图片资源、inc公共包含文件、html静态页及mdb数据库文件整体结构清晰便于按模块阅读与二次开发。内容覆盖图书列表展示、购物车、订单管理、用户管理、图书维护及后台登录等典型功能读者可据此掌握JSP与JavaBean协同开发、数据库连接及页面跳转的完整流程。目前已有163人学习下载适合作为Java Web入门练手或课程设计参考下载后按说明书配置即可运行。1. 图书销售系统为什么还在用 JSP一个被低估的落地场景打开招聘网站搜 Java 后端满屏都是 Spring Boot 加 VueJSP 似乎早该进博物馆了。但如果你去翻高校毕设选题库、中小企业内部管理系统、甚至某些区县级图书零售的进销存后台JSP 图书销售系统的出现频率高得离谱。原因不复杂它把 JavaBean 封装数据、Servlet 控制流程、JSP 渲染页面这三件事串成了一条最短路径不需要前后端分离不需要 Node 环境一台装好 Tomcat 的机器就能跑完整业务闭环。对于要在一到两周内交出可演示、可答辩、可二次开发的图书销售系统的人来说JSP 的 Model2 模式反而是最稳的选择。这篇笔记就按一线做项目的顺序把环境搭建、数据库设计、核心功能实现、分页与购物车这些硬骨头以及部署时最容易翻车的地方一次讲透。2. 环境与数据库把 JSP 图书销售系统的地基打牢2.1 JDK、Tomcat、MySQL 的版本搭配与目录约定做 JSP 图书销售系统第一步不是写代码而是把版本对齐。我见过太多人卡在ClassNotFoundException或者中文乱码上最后发现是 JDK 和 Tomcat 版本不匹配。稳妥组合是 JDK 8 或 JDK 11Tomcat 8.5 或 9.0MySQL 5.7 或 8.0。JDK 17 配 Tomcat 9 也能跑但有些老教程里的javax.servlet包名在 Tomcat 10 之后变成了jakarta.servlet直接导致所有 Servlet 编译报错。如果你拿到的代码包是javax.servlet开头就老老实实用 Tomcat 9。目录结构按 Model2 的标准来src下放com.bookstore.entity、com.bookstore.dao、com.bookstore.servlet、com.bookstore.util四个包WebContent或webapp下放WEB-INF、css、js、images和各个 JSP 页面。WEB-INF里必须有web.xml和lib目录MySQL 驱动 jar 包丢进lib不要只挂在项目的 Build Path 里否则部署到 Tomcat 后一定报驱动找不到。# 验证 JDK 与 Tomcat 版本是否匹配 java -version # 输出应为 1.8.x 或 11.x # 查看 Tomcat 版本 cd /opt/tomcat/bin ./version.sh # 确认 Server version 为 Apache Tomcat/9.0.x # MySQL 连接测试确认驱动可用 mysql -u root -p -e SELECT VERSION();上面三条命令分别确认 JDK、Tomcat、MySQL 的就绪状态。java -version看主版本号Tomcat 的version.sh看 Servlet 规范版本MySQL 的SELECT VERSION()确认服务端能连上。参数上唯一要改的是 MySQL 8 的时区连接串里必须加serverTimezoneAsia/Shanghai否则插入订单时间会差 8 小时。2.2 图书销售系统的表结构设计与建表 SQL图书销售系统的核心表不多但字段类型选错后面分页和金额计算全是坑。我一般建五张表book存图书category存分类user存用户orders存订单主表order_item存订单明细。金额字段一律用DECIMAL(10,2)不要用FLOAT或DOUBLE否则 0.1 加 0.2 在数据库里算出来是 0.30000000000000004对账时能把你逼疯。CREATE DATABASE bookstore DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE bookstore; CREATE TABLE category ( cid INT PRIMARY KEY AUTO_INCREMENT, cname VARCHAR(50) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE book ( bid INT PRIMARY KEY AUTO_INCREMENT, bname VARCHAR(100) NOT NULL, author VARCHAR(50), price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0, image VARCHAR(200), cid INT, intro TEXT, FOREIGN KEY (cid) REFERENCES category(cid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE user ( uid INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(64) NOT NULL, phone VARCHAR(20), address VARCHAR(200), regtime DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( oid INT PRIMARY KEY AUTO_INCREMENT, uid INT, total DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0, createtime DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (uid) REFERENCES user(uid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( itemid INT PRIMARY KEY AUTO_INCREMENT, oid INT, bid INT, quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL, FOREIGN KEY (oid) REFERENCES orders(oid), FOREIGN KEY (bid) REFERENCES book(bid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表时三个关键决策字符集用utf8mb4而不是utf8因为图书简介里可能出现生僻字或特殊符号book表的外键cid关联分类删除分类前必须先处理图书否则外键约束会直接报错orders表的status用TINYINT表示 0 待付款、1 已付款、2 已发货、3 已完成比字符串省空间且查询快。order_item里的subtotal是冗余字段下单时由程序算好写入避免每次查询都做乘法。提示建完表后立刻插入两三条测试数据用SELECT * FROM book确认中文没有变成问号。如果乱码检查连接串是否带了useUnicodetruecharacterEncodingutf8。3. 从登录到下单JSP 图书销售系统核心链路的代码实现3.1 用 Model2 思想实现用户注册与登录的 Servlet 与 JSP 分工Model2 的核心就一句话JSP 只负责显示Servlet 负责接收请求和跳转JavaBean 负责装数据。以注册为例register.jsp里只放表单action指向RegisterServletServlet 里调UserDao的insert方法成功就response.sendRedirect(login.jsp)失败就request.setAttribute(msg, 用户名已存在)然后forward回注册页。这样 JSP 页面里不会出现一行 Java 逻辑代码后期改页面不用碰业务。// RegisterServlet.java WebServlet(/register) public class RegisterServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); // 密码做 MD5 摘要不存明文 String md5Pwd DigestUtils.md5Hex(password); User user new User(); user.setUsername(username); user.setPassword(md5Pwd); UserDao dao new UserDao(); if (dao.findByUsername(username) ! null) { req.setAttribute(msg, 用户名已存在); req.getRequestDispatcher(register.jsp).forward(req, resp); return; } dao.insert(user); resp.sendRedirect(login.jsp); } }这段代码里req.setCharacterEncoding(UTF-8)必须放在取参数之前否则中文用户名会乱码。密码用DigestUtils.md5Hex做摘要虽然 MD5 现在不算强哈希但对于课程设计和内部系统足够至少不存明文。sendRedirect和forward的区别要记牢注册成功用重定向防止刷新页面重复提交注册失败用转发因为要把错误信息带到 JSP 显示。登录逻辑类似但多一步 Session 写入。LoginServlet验证通过后执行req.getSession().setAttribute(user, user)之后所有需要登录才能访问的页面比如购物车、订单都在开头用if (session.getAttribute(user) null)判断并跳回登录页。这个判断可以写成一个LoginFilter在web.xml里配置拦截/cart/*、/order/*路径比每个 JSP 里手写省事得多。3.2 图书列表分页查询SQL LIMIT 与 JSP 页码条的配合图书列表是访问量最大的页面不分页的话几百本书一次性查出来页面加载慢不说浏览器渲染也卡。分页的核心是两条 SQL一条查总数一条查当前页数据。LIMIT的第一个参数是偏移量计算公式是(当前页码 - 1) * 每页条数。// BookDao.java 分页查询方法 public ListBook findByPage(int page, int pageSize) { ListBook list new ArrayList(); String sql SELECT * FROM book ORDER BY bid DESC LIMIT ?, ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, (page - 1) * pageSize); ps.setInt(2, pageSize); ResultSet rs ps.executeQuery(); while (rs.next()) { Book b new Book(); b.setBid(rs.getInt(bid)); b.setBname(rs.getString(bname)); b.setPrice(rs.getBigDecimal(price)); b.setStock(rs.getInt(stock)); b.setImage(rs.getString(image)); list.add(b); } } catch (SQLException e) { e.printStackTrace(); } return list; } // 查总数 public int countAll() { String sql SELECT COUNT(*) FROM book; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { if (rs.next()) return rs.getInt(1); } catch (SQLException e) { e.printStackTrace(); } return 0; }pageSize一般设 8 或 12取决于页面一行放几本书。ORDER BY bid DESC让新书排在前面如果按销量排就改成ORDER BY sales DESC。JSP 里接收page参数时要做空值保护int page request.getParameter(page) null ? 1 : Integer.parseInt(request.getParameter(page));同时要限制page不能小于 1也不能大于总页数否则LIMIT偏移量超出范围会返回空列表页面看起来像坏了。页码条在 JSP 里用循环生成总页数totalPage (totalCount pageSize - 1) / pageSize。上一页的链接是list.jsp?page%page-1%下一页是list.jsp?page%page1%当前页加粗显示。如果分类筛选和分页要同时生效链接里还得带上cid参数比如list.jsp?page2cid3Servlet 里两个条件拼 SQL 时注意WHERE cid ?和LIMIT的顺序。3.3 购物车的 Session 实现与订单入库的事务处理购物车有两种做法存数据库和存 Session。图书销售系统我推荐 Session因为用户没登录也能加购登录后合并即可实现简单且不用频繁读写数据库。Session 里放一个MapInteger, CartItemkey 是图书 IDvalue 是包含图书信息和数量的对象。// CartServlet.java 加入购物车 WebServlet(/cart/add) public class CartServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int bid Integer.parseInt(req.getParameter(bid)); HttpSession session req.getSession(); MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { cart new HashMap(); session.setAttribute(cart, cart); } BookDao bookDao new BookDao(); Book book bookDao.findById(bid); if (cart.containsKey(bid)) { CartItem item cart.get(bid); item.setQuantity(item.getQuantity() 1); } else { CartItem item new CartItem(); item.setBook(book); item.setQuantity(1); cart.put(bid, item); } resp.sendRedirect(req.getContextPath() /cart.jsp); } }下单是整个系统里唯一必须用事务的地方。因为要同时往orders插一条、往order_item插多条、还要扣减book表的库存任何一步失败都必须回滚。我一般把这三步写在一个 Service 方法里用Connection的手动提交控制。// OrderService.java 下单事务 public boolean createOrder(int uid, MapInteger, CartItem cart) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 插入订单主表 String orderSql INSERT INTO orders(uid, total, status) VALUES(?, ?, 0); PreparedStatement ps conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS); BigDecimal total cart.values().stream() .map(i - i.getBook().getPrice().multiply(new BigDecimal(i.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); ps.setInt(1, uid); ps.setBigDecimal(2, total); ps.executeUpdate(); ResultSet keys ps.getGeneratedKeys(); keys.next(); int oid keys.getInt(1); // 2. 插入订单明细并扣库存 String itemSql INSERT INTO order_item(oid, bid, quantity, subtotal) VALUES(?, ?, ?, ?); String stockSql UPDATE book SET stock stock - ? WHERE bid ? AND stock ?; for (CartItem item : cart.values()) { PreparedStatement psItem conn.prepareStatement(itemSql); psItem.setInt(1, oid); psItem.setInt(2, item.getBook().getBid()); psItem.setInt(3, item.getQuantity()); psItem.setBigDecimal(4, item.getBook().getPrice() .multiply(new BigDecimal(item.getQuantity()))); psItem.executeUpdate(); PreparedStatement psStock conn.prepareStatement(stockSql); psStock.setInt(1, item.getQuantity()); psStock.setInt(2, item.getBook().getBid()); psStock.setInt(3, item.getQuantity()); if (psStock.executeUpdate() 0) { throw new SQLException(库存不足: item.getBook().getBname()); } } conn.commit(); // 全部成功才提交 return true; } catch (SQLException e) { if (conn ! null) try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { if (conn ! null) try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }Statement.RETURN_GENERATED_KEYS用来拿刚插入订单的自增主键这是 MySQL 驱动提供的标准做法。扣库存的 SQL 里AND stock ?是防超卖的关键如果库存不够executeUpdate返回 0直接抛异常触发回滚。conn.setAutoCommit(false)之后所有操作都在同一个事务里要么全成要么全败。下单成功后记得session.removeAttribute(cart)清空购物车否则用户刷新页面会重复下单。4. 部署与联调JSP 图书销售系统最容易翻车的五个地方4.1 中文乱码从 JSP 页面到数据库连接串的完整排查链现象图书名称在列表页显示正常但插入数据库后变成问号或者从数据库读出来在 JSP 上显示乱码。原因通常有三层JSP 页面头部没声明% page contentTypetext/html;charsetUTF-8 %Servlet 里没写req.setCharacterEncoding(UTF-8)数据库连接串没带characterEncodingutf8。解决方法是三层同时检查缺一不可。另外 Tomcat 8 以上默认 URI 编码是 UTF-8但 GET 请求的中文参数如果乱码需要在server.xml的 Connector 里加URIEncodingUTF-8。4.2 数据库驱动加载失败ClassNotFoundException 的三种触发场景现象启动 Tomcat 后访问登录页后台报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因一是驱动 jar 没放进WEB-INF/lib只加在了 IDE 的 Build Path原因二是 MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver而代码里还写着老版本的com.mysql.jdbc.Driver原因三是 Tomcat 的lib目录里有一个旧版驱动和项目里的冲突。解决方法是统一把驱动 jar 放WEB-INF/lib类名改成com.mysql.cj.jdbc.Driver并清理 Tomcatlib下的重复 jar。4.3 购物车数量错乱Session 与浏览器多标签页的冲突现象用户在两个标签页同时打开图书列表分别加购不同图书回到购物车发现数量不对或者商品丢失。原因是 Session 是同一个但两个标签页的请求并发修改了同一个MapHashMap不是线程安全的。解决方法是在CartServlet里对cart对象加synchronized块或者把HashMap换成ConcurrentHashMap。更稳妥的做法是购物车操作走数据库用行锁保证一致性但代码量会大不少。4.4 分页最后一页数据重复LIMIT 偏移量计算错误现象翻到最后一页时出现了上一页已经显示过的图书。原因是总页数计算用了totalCount / pageSize取整没有向上取整导致最后一页的页码算错偏移量回退。解决方法是总页数用(totalCount pageSize - 1) / pageSize并且在 Servlet 里加判断如果page totalPage就把page强制设为totalPage。另外ORDER BY字段如果有重复值分页结果可能不稳定最好用主键bid做最终排序。4.5 订单提交后库存没扣事务未提交或自动提交未关闭现象下单成功订单表有记录但图书库存还是原值。原因是OrderService里用了conn.setAutoCommit(false)但最后忘了conn.commit()或者DBUtil返回的连接已经被上层代码设成了自动提交。解决方法是确保事务方法内显式调用commit()并且DBUtil.getConnection()返回的连接默认是自动提交进入事务方法后第一件事就是关掉它。如果用了连接池还要注意连接归还前必须恢复自动提交状态否则下一个请求会继承事务上下文。5. 让图书销售系统更耐用的三个进阶技巧5.1 用 Filter 统一处理编码与登录校验与其在每个 Servlet 里写req.setCharacterEncoding(UTF-8)不如写一个EncodingFilter在web.xml里配置拦截/*。登录校验同理LoginFilter拦截/cart/*、/order/*、/admin/*判断 Session 里有没有user没有就重定向到登录页。Filter 的好处是新增页面时不用改代码只要路径在拦截范围内就自动生效。!-- web.xml 配置 Filter -- filter filter-nameEncodingFilter/filter-name filter-classcom.bookstore.filter.EncodingFilter/filter-class /filter filter-mapping filter-nameEncodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping filter filter-nameLoginFilter/filter-name filter-classcom.bookstore.filter.LoginFilter/filter-class /filter filter-mapping filter-nameLoginFilter/filter-name url-pattern/cart/*/url-pattern url-pattern/order/*/url-pattern /filter-mappingEncodingFilter的doFilter里先设请求和响应的编码再chain.doFilter(request, response)放行。LoginFilter里判断session.getAttribute(user)为空就response.sendRedirect(request.getContextPath() /login.jsp)注意要return否则继续执行后面的chain.doFilter会报错。5.2 图书封面上传与图片路径的存储策略图书封面不建议存进数据库的BLOB字段那样备份和迁移都痛苦。常见做法是存到服务器磁盘的/upload目录数据库image字段只存文件名。上传用commons-fileupload或 Servlet 3.0 的Part接口限制文件大小和类型。JSP 显示时用img src${pageContext.request.contextPath}/upload/${book.image}contextPath保证项目改名后路径依然正确。如果部署在 Linux 上注意/upload目录的写权限Tomcat 进程用户没有写权限时上传会静默失败。5.3 用 JSTL 替代 JSP 脚本片段让页面更好维护JSP 里写% for(Book b : list) { %这种脚本片段页面看起来像一锅粥而且前端人员没法改。用 JSTL 的c:forEach和c:if可以把逻辑标签化页面结构清晰得多。需要引入jstl.jar和standard.jar页面头部加% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %。金额格式化用fmt:formatNumber value${book.price} pattern0.00/日期格式化用fmt:formatDate。迁移时把循环和判断逐个替换替换完页面里几乎看不到%符号后期改样式不用再找 Java 代码在哪结束。c:forEach items${bookList} varbook div classbook-item img src${pageContext.request.contextPath}/upload/${book.image} alt${book.bname} h3${book.bname}/h3 p作者${book.author}/p p价格fmt:formatNumber value${book.price} pattern0.00/ 元/p a href${pageContext.request.contextPath}/cart/add?bid${book.bid}加入购物车/a /div /c:forEach这段 JSTL 代码替代了原来的 Java 循环items是 Servlet 里request.setAttribute(bookList, list)传过来的集合var是每次迭代的变量名。${pageContext.request.contextPath}动态获取项目根路径避免硬编码/bookstore。fmt:formatNumber保证价格始终显示两位小数不会出现12.5这种不整齐的格式。我做了这么多套 JSP 图书销售系统最大的习惯就是每建一张表先插测试数据每写一个 Servlet 先用 Postman 或浏览器直接访问确认返回值每配一个 Filter 先在web.xml里注释掉再逐步打开。这样出问题时排查范围小不会一上来就面对一堆报错。希望帮到你。本文还有配套的精品资源点击获取