
简介面向计算机相关专业学生的JavaWeb在线图书销售系统源码与数据库打包是一份经导师指导并获98分评审的高分期末大作业。系统实现用户登录注册、图书分类检索、购物车结算、订单处理以及管理员后台的图书增删改查与库存管理等完整功能采用Servlet、JSP、MVC、JDBC与MySQL技术栈前端用HTML、CSS配合JavaScript和Ajax优化交互符合课程设计或期末大作业的综合要求。资源包共125个文件整体约5.44MB包含43个Java源文件、19个JSP页面、11个依赖JAR包、1个SQL数据库脚本另有XML配置、属性配置、样式脚本及文档图片等组成清晰便于直接导入项目进行学习。随包附带需求分析、系统设计、数据库设计、功能实现说明与测试用例等文档能够帮助学习者完整走通从建库到前后端联调的开发流程。目前已有53人学习对需要完成JavaWeb大作业或希望深入理解三层架构的项目实战者具有较高参考价值。1. 这学期能拿出手的 JavaWeb 在线图书销售系统差距往往不在登录页期末项目拿到「JavaWeb 在线图书销售系统」这个题目时大部分人的第一反应是做个登录注册加图书列表再挂一个购物车就交差。但既然叫「高级版」评审老师真正想看的是工程化的东西代码分层是否清晰、数据库设计是否满足第三范式、订单和库存的一致性怎么保证、SQL 注入有没有防住、分页在多数据量下会不会卡。这篇文章要讲的就是把这些点落到实处的一套完整方案——基于 Servlet JSP MySQL 的传统 JavaWeb 技术栈不依赖 Spring 全家桶纯手写 MVC 也能把期末项目做出毕业设计的质感。这套方案适合正在做 JavaWeb 课程设计、数据库课程设计或者想把手上的「简易图书系统」升级成「高级版」的人。读完你能直接照着建库、写代码、跑通全流程更重要的是知道每个设计决策背后的理由——答辩时老师问「为什么这么设计」你能讲出道理。2. 先把项目骨架定好Servlet JSP MySQL 的分层与建库脚本2.1 为什么不用框架期末项目里手写 MVC 反而更稳很多同学一上来就想用 Spring Boot但仔细看期末项目的要求通常明确写的是「基于 JavaWeb 技术」考核点也围绕 Servlet、JSP、JDBC 这些基础内容展开。用框架反而可能被质疑「这不是你写的」。常见的做法是手写 MVC 三层架构View 层JSP 负责渲染用 JSTL 做循环和条件判断避免在页面里写 Java 脚本片段Controller 层Servlet 接收请求、解析参数、调用 Service、控制页面跳转Service 层处理业务逻辑比如下单时的库存校验、订单状态流转DAO 层用 JDBC 操作 MySQL封装增删改查。推荐用 Maven 管理依赖即使项目不大也值得因为 Tomcat 9 Servlet 4.0 JSTL 1.2 这几个坐标写进pom.xml同组的人拉下来就能跑不会出现「在我电脑上是好的」这种情况。2.2 数据库设计五张表能覆盖百分之八十的评分点数据库是期末项目里最好拿分的部分也是「高级版」的直观体现。一个在线图书销售系统至少要有用户表、图书表、购物车表、订单表、订单明细表再加一张图书分类表会更完整。下面是精简但核心的建表脚本-- 用户表区分普通用户和管理员 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, -- 建议存 SHA-256 哈希别存明文 role TINYINT NOT NULL DEFAULT 0, -- 0普通用户 1管理员 create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 图书分类表先有分类再有书符合范式 CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 图书表 CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, title VARCHAR(200) NOT NULL, author VARCHAR(100) DEFAULT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, sales INT NOT NULL DEFAULT 0, cover_url VARCHAR(500) DEFAULT NULL, description TEXT, status TINYINT NOT NULL DEFAULT 1, -- 1上架 0下架 KEY idx_category (category_id), CONSTRAINT fk_book_category FOREIGN KEY (category_id) REFERENCES category (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 购物车表联合主键防止同一用户重复加同一本书 CREATE TABLE cart_item ( user_id INT NOT NULL, book_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, PRIMARY KEY (user_id, book_id), CONSTRAINT fk_cart_user FOREIGN KEY (user_id) REFERENCES user (id), CONSTRAINT fk_cart_book FOREIGN KEY (book_id) REFERENCES book (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表冗余了总金额和收货信息避免下单后用户改资料影响历史订单 CREATE TABLE orders ( id BIGINT 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, -- 0待付款 1已付款 2已发货 3已完成 4已取消 receiver_name VARCHAR(50) NOT NULL, receiver_phone VARCHAR(20) NOT NULL, receiver_address VARCHAR(200) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, KEY idx_user (user_id), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES user (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表冗余了下单时的快照价格 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, book_id INT NOT NULL, title VARCHAR(200) NOT NULL, -- 冗余图书标题订单表独立成档 price DECIMAL(10,2) NOT NULL, -- 下单时的单价后续改价不影响 quantity INT NOT NULL, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表脚本里的几个设计点值得你答辩时候展开讲。第一订单表和订单明细表为什么要分开因为一张订单可能包含多本书如果把多本书塞进一个字段就违背了第一范式。第二order_item里为什么要冗余title和price因为图书表里的信息可能会改但订单历史记录必须保持下单那一刻的快照。第三购物车采用(user_id, book_id)联合主键天然防止重复加购业务层只需要处理「加一」还是「新增」的逻辑。提示所有表都用InnoDB引擎因为期末项目必然会涉及订单和库存的事务操作MyISAM不支持行级锁和事务答辩时老师大概率会问这一点。2.3 JDBC 工具类与数据库连接池配置有了表接下来是连接数据库的工具类。这里推荐直接用 Druid 连接池理由很简单它自带监控页面答辩演示的时候把 Druid 的统计页面打开能看出老师对你项目的「高级感」有加分作用。配置在src/main/resources/druid.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/bookstore?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue usernameroot password你的密码 initialSize5 maxActive20 maxWait60000对应的工具类写法public class DBUtil { private static DruidDataSource dataSource; static { try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(druid.properties)) { Properties props new Properties(); props.load(in); dataSource (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(数据库连接池初始化失败: e.getMessage()); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这段代码本质上是把连接池的创建过程放在静态代码块里类加载时只执行一次后续所有 DAO 拿连接都从连接池里借用完了由连接池自动回收。要注意url里的serverTimezoneAsia/Shanghai和useSSLfalse是 MySQL 8.x 时代的必备参数不加会出现时区报错和 SSL 握手警告。如果你用的是 MySQL 5.7驱动类改成com.mysql.jdbc.Driver其他保持不变。3. 核心交易链路实现购物车、订单提交与库存扣减3.1 从加购到下单的请求流转先梳理清楚整条链路的请求走向。用户浏览图书列表点击「加入购物车」时前端发送POST /cart/add?bookId3quantity1到CartServlet。CartServlet拿到当前登录用户id调用CartService.addToCart()这个方法内部先查购物车表里是否已有这条记录——有就执行UPDATE quantity quantity ?没有就INSERT。整个过程不涉及复杂事务因为单条语句自带原子性。到用户点击「去结算」时请求进入OrderServlet。这一个操作背后要连续做几件事读取购物车里的所有条目、校验每本书的库存是否充足、计算订单总金额、往orders表插入一条记录、往order_item表插入订单明细、清空购物车、扣减图书库存。这几步要么全部成功要么全部失败必须包在同一个数据库事务里——这也是整个项目里最核心的技术考点。3.2 订单提交的事务控制库存扣减与数据一致性下面这段代码是OrderService里createOrder方法的核心骨架重点看事务的边界和库存扣减的方式public Order createOrder(int userId, String receiverName, String receiverPhone, String receiverAddress) { Connection conn null; Savepoint savepoint null; try { conn DBUtil.getConnection(); // 1. 开启事务关闭自动提交 conn.setAutoCommit(false); // 2. 设置隔离级别为可重复读防止并发下的脏读和不可重复读 conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); // 3. 查询购物车条目 ListCartItem items cartDao.findItemsByUserId(conn, userId); if (items null || items.isEmpty()) { throw new BusinessException(购物车为空无法下单); } // 4. 生成订单号并插入订单主表 String orderNo generateOrderNo(); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setReceiverName(receiverName); order.setReceiverPhone(receiverPhone); order.setReceiverAddress(receiverAddress); order.setStatus(0); // 先把订单插入表中拿到自增主键 orderDao.insert(conn, order); // 5. 遍历明细校验库存、扣减库存、写入明细 BigDecimal total BigDecimal.ZERO; for (CartItem item : items) { // 关键扣减库存时带上 stock ? 条件 int affected bookDao.deductStock(conn, item.getBookId(), item.getQuantity()); if (affected 0) { // 库存不足回滚到保存点 throw new BusinessException(图书《 item.getTitle() 》库存不足); } orderItemDao.insert(conn, order.getId(), item); total total.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 6. 回填订单总金额并更新 orderDao.updateTotalAmount(conn, order.getId(), total); // 7. 清空购物车 cartDao.clearCart(conn, userId); // 8. 一切成功才提交 conn.commit(); return order; } catch (Exception e) { // 9. 出错则回滚 if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } if (e instanceof BusinessException) { throw (BusinessException) e; } throw new RuntimeException(创建订单失败, e); } finally { if (conn ! null) { try { conn.setAutoCommit(true); } catch (SQLException e) { e.printStackTrace(); } try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这段代码里最值得借鉴的是bookDao.deductStock()的写法。它对应的 SQL 是UPDATE book SET stock stock - ?, sales sales ? WHERE id ? AND stock ?;注意这里的WHERE条件带了stock ?这一手可以在一个原子操作里同时完成「扣库存」和「检查库存是否充足」两件事。Java 代码里通过检查受影响行数是否为 0 来判断是否扣减成功如果受影响行数为 0说明扣减时发现库存不足直接抛出异常触发回滚。这种做法比「先 SELECT 查询库存、再 UPDATE 扣减」要安全得多——在高并发场景下两条语句之间有其他请求插入会破坏一致性而原子的条件更新从根上避免了超卖问题。3.3 订单号生成别用自增主键当订单号订单表里虽然有自增id但对外展示的order_no不要直接用这个自增数字。原因有两点第一自增 ID 会暴露平台的订单量属于信息泄露第二订单号通常需要具备一定的可读性。项目里常用这种格式生成订单号public static String generateOrderNo() { SimpleDateFormat sdf new SimpleDateFormat(yyyyMMddHHmmss); String timePart sdf.format(new Date()); int randomPart (int)((Math.random() * 9 1) * 1000); // 四位数随机数 return timePart randomPart; }这种方案的优点是代码量少、可读性强缺点是理论上同一秒内可能碰撞。对期末项目来说完全够用但如果想让方案更严谨可以在后面加上用户 ID 后缀或者用UUID去掉横线后截取一段。答辩时如果能主动提一句「随机数有碰撞可能但概率极低生产环境可替换为雪花算法」会显示出你考虑过分布式 ID 的问题。4. 管理与查询模块分页、多条件检索、订单状态流转4.1 图书列表的分页实现LIMIT 与 PageBean 封装图书列表是首页的主展示区也是高频查询场景。当数据量达到几百条时一次性查出全部记录已经会拖慢页面更不用说期末项目里常见的「复用这套代码去撑起一个演示数据集」。所以分页是必须的。常见的做法是封装一个PageBeanT用于承载分页数据核心字段包括pageNum、pageSize、total、totalPages、data。对应的查询 DAO 方法核心就一条 SQLSELECT id, title, author, price, stock, sales, cover_url FROM book WHERE status 1 ORDER BY id DESC LIMIT ?, ?;参数?分别是(pageNum - 1) * pageSize和pageSize。这个偏移量的计算是分页最容易写错的地方第一页的偏移是 0所以要用pageNum - 1去乘。JSP 前端用 JSTL 渲染分页按钮c:if test${pageBean.pageNum 1} a hrefbook/list?pageNum${pageBean.pageNum - 1}上一页/a /c:if c:forEach begin1 end${pageBean.totalPages} vari a hrefbook/list?pageNum${i} class${i pageBean.pageNum ? active : }${i}/a /c:forEach c:if test${pageBean.pageNum pageBean.totalPages} a hrefbook/list?pageNum${pageBean.pageNum 1}下一页/a /c:iftotal需要单独查一次SELECT COUNT(*)这个查询和分页数据查询要放在同一个 DAO 方法里完成避免调用方分两次拿数据时数据量发生变化导致总页数不一致。关于性能一个提分的点是MySQL 的LIMIT在大偏移量时效率骤降比如查第 10000 页的数据MySQL 仍然需要扫描前面所有行。如果是演示用数据量几百到几千条完全不用优化如果老师追问就回答「可以改成基于游标的分页也就是记录上一页最后一条记录的 ID用WHERE id ?代替LIMIT偏移」这一点足以体现你真正理解分页的代价。4.2 多条件检索动态 SQL 拼接与防注入图书管理后台通常需要按照书名、分类、价格区间来筛选图书。这时候 SQL 的条件是动态拼接的也是容易出错的地方。一个典型的安全写法是在 DAO 里用参数预处理来动态组合public ListBook searchBooks(String keyword, Integer categoryId, BigDecimal minPrice, BigDecimal maxPrice, int offset, int limit) { StringBuilder sql new StringBuilder( SELECT * FROM book WHERE status 1); ListObject params new ArrayList(); // 书名模糊搜索 if (StringUtils.hasText(keyword)) { sql.append( AND title LIKE ?); params.add(% keyword %); } // 分类筛选 if (categoryId ! null categoryId 0) { sql.append( AND category_id ?); params.add(categoryId); } // 价格区间 if (minPrice ! null) { sql.append( AND price ?); params.add(minPrice); } if (maxPrice ! null) { sql.append( AND price ?); params.add(maxPrice); } sql.append( ORDER BY sales DESC, id DESC LIMIT ?, ?); params.add(offset); params.add(limit); // 使用 PreparedStatement 执行参数由驱动程序转义 return jdbcTemplate.query(sql.toString(), params.toArray()); }这段代码的关键点在于虽然 SQL 字符串是动态拼接的但所有用户输入都是通过?占位符传入的没有直接拼接到 SQL 字符串里。这样做能有效防 SQL 注入。常见的做法是使用PreparedStatement在 Java 代码里就是preparedStatement.setString(1, % keyword %)这样来设置参数。答辩时老师如果问「怎么防 SQL 注入」你答「参数化查询让数据库驱动把值转义成普通字符串而不是 SQL 片段」就够了。4.3 订单状态流转后台发货与取消逻辑订单状态管理的核心是限定状态的合法转移路径。用一张表来约束会比在代码里写一堆if更可维护当前状态允许的操作目标状态待付款(0)用户取消已取消(4)待付款(0)模拟支付已付款(1)已付款(1)管理员发货已发货(2)已发货(2)用户确认收货已完成(3)待付款(0)管理员强制取消并回补库存已取消(4)在代码里实现状态流转时要注意两个细节。一是防重复操作比如用户已经取消了订单不能再次取消。解决方案是使用条件更新UPDATE orders SET status ? WHERE id ? AND status ?;第一个?是目标状态第二个?是订单 ID第三个?是前置状态。如果受影响行数为 0说明状态已变化直接提示「操作失败订单状态已更新」。二是取消订单要回补库存。用户下单时扣了库存取消后数量要还回去这个操作依然放在事务里完成。5. 答辩前必调的性能与安全细节5.1 PreparedStatement 与 SQL 注入的边界很多项目里喜欢用Statement直接拼接字符串例如SELECT * FROM book WHERE title keyword 。在有索引的title字段上这行 SQL 也可以跑但问题出在keyword如果带上了 OR 11拼接出来的 SQL 会变成WHERE title OR 11整张表的数据都被查出来。更严重的还可以用分号拼接DELETE语句。使用PreparedStatement之后参数值会被当作纯字符串处理数据库驱动会转义掉其中的单引号和分号攻击者构造的输入最多只是查询条件的一部分不可能改变 SQL 的语义。给期末项目做一次自检的方式是在搜索框里输入 OR 11 --如果页面返回了全部图书说明你的 DAO 层存在注入点需要立即整改。5.2 数据源关闭的先后顺序与内存泄漏排查初学者写 JDBC 代码最常见的问题是忘记关连接或者只关了Connection忘了关ResultSet。在使用了连接池之后「关闭连接」其实是把连接归还给连接池而不是真的断开但如果ResultSet和Statement不关连接被归还后堆里的游标资源仍然占着内存时间长了会报「too many connections」或者 OOM。推荐的 finally 关闭顺序是先关闭ResultSet再关闭Statement最后关闭Connection。如果不想每个方法里写三遍try-catch-finally可以用 JDK 7 的 try-with-resources 写法try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { // 处理结果集 } }这种写法下rs、ps、conn会按创建顺序的逆序自动关闭。要注意的是如果你把Connection放进了这个try块里那么块结束时连接会自动归还连接池不需要手动close()。答辩时可以讲一句「连接池复用连接但是游标和语句需要及时释放否则连接池被耗尽」这句话能直接体现你对资源生命周期的理解。5.3 购物车重复提交与库存预占的取舍期末演示流程里最容易暴露的问题不是功能做不出来而是按钮被快速点了两次以后数据出乱子。「提交订单」按钮双击造成同一张订单被创建两次这是演示翻车重灾区。前端可以加防抖但真正的保障要在接口层做幂等控制。简单的方案是为订单表加一个client_token字段前端在结算页生成一个随机字符串放隐藏域里后端先把insert语句写成INSERT INTO orders (order_no, user_id, ..., client_token) VALUES (?, ?, ..., ?);client_token字段在数据库层面加唯一索引第二次请求插入了相同的 token会触发DuplicateKeyException在 Service 层捕获后直接返回「订单已提交请勿重复操作」。这种方案不需要查一次库再决定实现简单且可靠。最后一个提分技巧把「生成订单号、扣库存、插明细、清购物车」整个链路封装成一个自定义注解加拦截器的方式比如模拟Transactional在 Servlet 的init()方法里输出一行项目初始化日志——这个小动作会让人觉得你的代码有工程素养而不仅仅是能跑。期末项目拿高分的核心不在于功能多时髦而在于每个环节都能说出「为什么这样做」这五章内容正好覆盖了从建库到交易再到查询的完整闭环建议按顺序实现最后把注意力放在事务和参数化查询这两个点上它们是最能拉开差距的地方。本文还有配套的精品资源点击获取