
简介面向Java Web初学者的网上购书系统课程设计资源包采用经典JspBeanServlet分层模式实现用户注册、登录、信息查询与浏览等B/S结构核心功能适合课程设计参考、期末答辩或入门项目复盘。压缩包共55个文件以Java源码、JSP页面和class编译文件为主另含SQL建表脚本、XML/Properties配置、jar依赖库以及PDF综合训练说明整体仅1.21MB工程目录区分源码与WebRoot部署目录便于快速定位。目前已有173人学习资源热度适中内容完整特别适合需要Java Web课程设计存档或二次扩展的学习者。资料可直接提供项目源码、数据库脚本与配置清单结合PDF说明可梳理Servlet控制请求、Bean封装数据、JSP展示页面的调用流程README与class文件也能帮助核对编译结果为后续增加购物车、订单管理等功能打下基础。1. 这套 JspBeanServlet 购书系统为什么值得花一周亲手写一遍很多初学者第一次接触 Java Web就是被要求做一个“简单网上购书系统”技术栈限定在 Jsp、Bean、Servlet不许用 Spring。这套组合看起来老却把一个 Web 项目的所有关键问题都暴露在了明面上请求怎么进、数据怎么存、页面怎么渲染、Session 怎么维持、事务怎么保证。它不像 Spring Boot 那样把复杂性藏起来反倒适合用来建立对 Web 项目底层流转的直觉。我在带课设和新人入门时最常推荐的也是这条路不急着上框架先用手写一遍登录、购物车、下单。等你把这一套跑通理解了 Servlet 的调度逻辑、Bean 的数据封装和 JSP 的展示分离后面学任何框架都会顺很多。这篇文章就是按这个系统从零到能演示的完整路径来讲的先拆架构、再定数据库、然后手写核心代码、最后把最常见的几个坑给你排掉照着做完就是一个可以直接交给客户验收或课设答辩的小系统。2. 理解这套组合JSP、Bean、Servlet 在购书系统里到底谁干什么2.1 为什么是 JSP Bean Servlet 而不是 Spring MVC早期 JSP 页面流行过一段“页面里直接写死 Java 代码”的时期叫作 Model 1。打开一个 JSP既能看见 HTML 标签又能看见while(rs.next())这样的 JDBC 代码数据查询、业务判断、页面展示全部混在一起。问题非常直观前端调整一个表格样式都可能踩到 Java 逻辑报错时根本说不清是页面问题还是数据问题。Servlet 出现后大家开始把请求分发交给 Servlet把 JSP 退化成纯粹的模板这套玩法后面被总结为 Model 2也就是 MVC 的雏形。JSP 对应 View负责把数据显示给用户Servlet 对应 Controller负责接收请求、参数校验、调用业务、决定下一步跳到哪Bean 对应 Model既承载数据也承载业务逻辑。所以这个购书系统虽然叫“JspBeanServlet”实际上是一套没有框架的轻量 MVC 实现。那为什么不直接上 Spring MVC因为在这个项目里你会希望把“请求从哪来到哪去”看得一清二楚。Spring MVC 一个RequestMapping注解加完请求就进方法了初学者根本不知道底层是 Servlet 在分发。手写 Servlet 时你会在doPost里亲手拿到参数、亲手调用业务对象、亲手forward或redirect这个过程多写几遍Web 应用的骨架就刻在脑子里了。2.2 一次请求的完整走查Servlet 调度、Bean 加工、JSP 渲染以“用户点击登录”为例把一次请求从头到尾走一遍。浏览器把表单数据提交到/login容器Tomcat根据映射找到对应的 LoginServlet调用doPost方法。Servlet 里request.getParameter(username)拿到用户名然后调用一个 UserService 的 Bean这个 Bean 内部通过 DAO 访问数据库比对用户名密码返回一个 User 对象或者 null。结果分两种情况登录失败Servlet 直接response.sendRedirect(login.jsp?error1)页面带上一个提示参数登录成功把 User 对象放进 Session然后重定向到图书列表页。数据到了 JSP 这一层JSP 只负责用c:forEach或脚本遍历集合把书名、作者、价格渲染成 HTML。渲染完成后容器把页面响应回浏览器用户看到最终的购物书城界面。这里有个对新手很重要的细节Servlet 和 JSP 之间传数据用request.setAttribute配合forward跳转而登录成功后用的是重定向不是转发。为什么要区分转发是服务器内部跳转地址栏不变重定向是浏览器再发一次新请求地址栏会变成目标地址。登录成功后如果使用转发用户刷新页面时会再次提交登录表单造成重复操作。这是网上购书系统里最早会踩到的状态管理问题。2.3 Bean 在购书系统里的三种角色与 JavaBean 规范Bean 这个词在标题里占了三分之一但在实际代码里它不是一个孤零零的对象而是三个角色的集合。第一类是实体 Bean比如 Book、User、CartItem属性对应数据库字段只做数据载体第二类是业务 Bean比如 UserService、OrderService封装登录校验、下单、扣库存这类业务动作第三类是数据访问 Bean也就是 DAO负责操作数据库把 JDBC 细节封装在里面。三者组合起来JSP 不碰数据库Servlet 不写业务各司其职。JavaBean 有一组基本功必须达标属性私有、提供公开的无参构造、每个属性配 getter/setter。这不是写代码洁癖而是框架和工具都在依赖这套规矩。JSP 的jsp:useBean标签内部通过反射实例化对象它默认调用的就是无参构造DBUtils 这类工具把查询结果映射成对象时靠的也是 setter。我在带项目时见过不少次实体类手滑写了带参构造、没留无参构造结果运行到那一行直接抛实例化异常这类问题放到第 5 章展开讲。从命名也能看出分层意图BookBean 里的getPrice()返回书的价格BookDao 里的findBookById()返回一条记录BookServlet 里的doGet决定是把书加到购物车还是跳转到详情页。名字接近但职责边界清晰整个系统维护成本都在这个边界上。3. 先把书架摆好网上购书系统的数据库设计与建表 SQL3.1 四张表就够书、用户、订单主表、订单明细网上购书系统的数据模型不需要做得很宏伟四张表能支撑完整购买流程book 图书表、user 用户表、order_main 订单主表、order_item 订单明细表。很多课设版本只做一张订单表把所有商品名拼在一个字符串里存演示时没问题可一旦要说清楚“订单里哪本书是谁的”就得重新拆字符串得不偿失。订单为什么拆主表和明细两张表因为一张订单下会有多本不同的书而“订单号”对每本书是一样的。如果都塞在同一张表里下单人、总价、时间这些信息每本书都要重复存一遍更新状态时还要同步改多行。拆成主表存一次公共信息明细表按行存每本书用 order_id 关联既符合第三范式也让后面查“某个订单的具体内容”变得非常直接。用户表和图书表结构很简单但字段类型选择上有几个点值得注意。比如价格不要用 float 或 double用DECIMAL(10,2)避免浮点误差库存字段用INT并让逻辑层保证不能扣成负数用户名字段上的唯一索引从数据库层拦掉重复注册。字段设计是一张表跑半年后健不健康的分水岭建表前多花十分钟后面调试少一个晚上。3.2 建表 SQL 与字段参数设计下面这段 MySQL 建表脚本可以直接拿来用。表名前缀我习惯不刻意加但字段命名统一用下划线风格Java 实体里用驼峰对应后面查问题时不至于一个字段一个叫法。CREATE DATABASE IF NOT EXISTS bookstore DEFAULT CHARACTER SET utf8mb4; USE bookstore; CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL COMMENT 建议存SHA-256摘要, real_name VARCHAR(50) DEFAULT , create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE book ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, author VARCHAR(100) DEFAULT , publisher VARCHAR(200) DEFAULT , price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0 COMMENT 库存禁止为负, cover_url VARCHAR(500) DEFAULT ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_main ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付1已支付2已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id INT AUTO_INCREMENT PRIMARY KEY, order_id INT NOT NULL, book_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, price DECIMAL(10,2) NOT NULL COMMENT 下单时的快照价, KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段脚本里最值得关注的两个设计决策。第一order_item 里存了 price 字段这不是冗余而是快照——书籍价格将来可能调整订单明细里的价格必须是用户下单那一瞬间的价格否则历史订单对不上账。第二订单主表只有 user_id 索引而没有外键约束这是我在课设系统里刻意做的简化外键在校验完整性上有效但也会让删除用户、初始化测试数据变得频繁报错。数据完整性靠 service 层保证演示项目里这种取舍是划算的。3.3 字符集、索引与外键怎么取舍字符集统一用 utf8mb4 而不是 utf8原因只有一个utf8 在 MySQL 里最多存 3 字节像 emoji 和生僻字会插入失败或乱码。购书系统里用户评价、书名副标题都可能出现这些字符建库时顺手选 utf8mb4省得将来上线后半夜收到乱码反馈。索引设计不需要贪多。user.username 建唯一索引order_main.user_id 建普通索引order_item.order_id 建普通索引这三处是查询最频繁的路径登录查用户、个人中心查订单、订单详情查明细。图书表这种数据量小的表全表扫描完全没压力不必为了演示在 title 上建索引。外键我在上一个系统里踩过坑加了外键后每次想删一条测试用户都要先删订单和明细顺序错了直接报约束错误。课程设计阶段数据不稳定删了改改了删是常态外键反而成了绊脚石。权衡之后这个项目的完整性问题交给了代码层的事务处理第 4 章下单部分会展示具体做法。4. 手写核心流程BookDao、登录、购物车与下单事务的 Java 代码4.1 搭建工程IDEA 2024 创建 web 项目、Tomcat 与依赖清单动手写代码前先把环境确认好。如果你用 IDEA 2024 版本创建 web 项目注意要新建 Java Enterprise 项目而不是普通 Java 项目然后在界面里勾选 Web ApplicationIDEA 会自动生成 web 目录和 web.xml。服务器选 Tomcat 9 最稳妥安装版自带标准目录结构和示例新手排查问题时网上的资料也最多。这套系统的外部依赖不多核心四样Servlet API、JSP API、MySQL 驱动、Apache Commons DBUtils。前两样在 Tomcat 的 lib 目录里能找到工程里按 provided 范围引入后两样需要放到 WEB-INF/lib 下并添加到项目库。DBUtils 是我推荐的 JDBC 封装层它只做结果集映射和参数填充不引入完整 ORM 的复杂度课程设计里用刚刚好。还要准备一个 JdbcUtils 类负责创建数据源和拿连接。常见做法是用 Apache DBCP2 或 C3P0 配置一个DataSource连接参数放在jdbc.properties。注意驱动类用com.mysql.cj.jdbc.DriverURL 后面跟useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8漏掉任意一个都可能在连接阶段翻车。4.2 从实体 Bean 到数据访问 BeanBook 与 BookDao实体 Bean 是整个代码里的“通货”Servlet 从请求里解析参数装进 BookDAO 把数据库行映射成 Book业务 Bean 把 Book 传给 JSP 渲染。它的标准长相如下。public class Book { private Integer id; private String title; private String author; private String publisher; private Double price; private Integer stock; private String coverUrl; public Book() { // 必须保留无参构造反射实例化依赖它 } // 省略 getter/setter }这段代码的约束只有一个但最容易被忽略无参构造必须在。DBUtils 的BeanListHandler在把 ResultSet 的每一行转换成一个 Book 对象时先通过无参构造创建实例再按字段名匹配调用 setter 完成赋值。如果你手写了带参构造却忘了无参构造查询一执行就抛InstantiationException而且报错位置在 DBUtils 内部新手看了半天都摸不着头脑。接下来是数据访问层。查询图书列表和按条件搜索是购书系统最常用的操作我一般会用 DBUtils 的QueryRunner封装。public class BookDao { private QueryRunner runner new QueryRunner(JdbcUtils.getDataSource()); public ListBook search(String keyword, Double minPrice, Double maxPrice) throws SQLException { StringBuilder sql new StringBuilder(SELECT * FROM book WHERE 11); ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { sql.append( AND (title LIKE ? OR author LIKE ?)); params.add(% keyword %); params.add(% keyword %); } if (minPrice ! null) { sql.append( AND price ?); params.add(minPrice); } if (maxPrice ! null) { sql.append( AND price ?); params.add(maxPrice); } return runner.query(sql.toString(), new BeanListHandler(Book.class), params.toArray()); } }这段代码的写法有一个地方特别想提醒你SQL 是拼接出来的但拼接的是固定的条件片段用户输入的关键词全部通过?占位符传入由 QueryRunner 预编译处理不存在把用户输入直接拼进 SQL 的注入风险。关键词两边的%是主动加上去的用来实现模糊匹配。BeanListHandler返回的是ListBook查询结果不需要你手动 while 循环取字段这正是 JavaBean 规范带来的红利。4.3 登录逻辑与 Session 的边界处理登录是整个系统中第一个真正的业务闭环。表单提交到 LoginServletServlet 调用 UserService 的 Bean 校验身份成功后把 User 对象放进 Session失败则回到登录页带一个错误参数。代码核心片段如下。WebServlet(/login) public class LoginServlet extends HttpServlet { private UserService userService new UserService(); protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username); String password request.getParameter(password); User user userService.login(username, password); if (user null) { response.sendRedirect(login.jsp?error1); return; } HttpSession session request.getSession(); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); // 30 分钟无操作自动失效 response.sendRedirect(request.getContextPath() /book/list); } }这里最关键的写法是登录成功用sendRedirect而不是forward。如果不重定向登录成功后地址栏仍停留在/login用户刷新一次页面就会重复执行一次登录校验虽然结果一样但会产生额外的数据库查询和 Session 副作用更麻烦的是在下单等其他 POST 场景会直接造成重复提交。这也是 Web 开发里的经典模式POST 后必须重定向简称 PRG。LoginServlet 里还有个边界细节值得注意。request.getSession()默认参数是 true也就是说请求里没有 Session 时也会新建一个。在登录、加购物车这类需要会话的场景没问题但如果你写了一个只读接口不希望用户一访问就制造出无用的 Session就要改成request.getSession(false)拿到 null 就代表用户没有登录态直接拒绝服务这样能避免大量无效 Session 堆积在服务器内存里。4.4 购物车为什么放 SessionCart 与 CartItem 的实现购物车属于典型的会话级状态用户选了几本书、每本几册这些信息在浏览期间必须持续存在。我一般把它放进 Session而不是存数据库。原因有两条匿名用户也要能加购此时还没有 user_id 可用购物车生命周期短加购到下单可能只有几分钟为其建表建关联得不偿失。具体的实现方式是设计一个 Cart 类内部用一个MapInteger, CartItem按图书 id 存放明细。这样加购同一本书时只需要增加数量不会出现一本书占一行、用户点两次加购就有两条记录的情况。CartItem 里则持有 Book 对象和数量金额由二者实时计算。public class Cart { private MapInteger, CartItem items new LinkedHashMap(); public void add(Book book, int quantity) { CartItem item items.get(book.getId()); if (item null) { item new CartItem(book, quantity); items.put(book.getId(), item); } else { item.setQuantity(item.getQuantity() quantity); } } public double getTotalPrice() { double total 0; for (CartItem item : items.values()) { total item.getSubtotal(); } return total; } public ListCartItem getItems() { return new ArrayList(items.values()); } }购物车对象从 Servlet 写入 Session 的代码块是另一个常见翻车点。Cart cart (Cart) session.getAttribute(cart); if (cart null) { cart new Cart(); session.setAttribute(cart, cart); } cart.add(book, quantity); response.sendRedirect(cart.jsp);为什么每次都要先 get 再判空因为 Servlet 是无状态的每次请求进来都是一个全新的执行过程Session 里没有值可能是因为用户第一次访问也可能是上次会话超时被容器清掉了。Map用LinkedHashMap而不是HashMap是为了让购物车列表按加入顺序展示不至于每次刷新顺序随机跳动这是个小体验细节。4.5 下单事务一次订单、N 条明细、扣库存的原子性下单是整个系统里最容易出逻辑事故的地方。它包含三个动作插入订单主表拿自增主键、循环插入订单明细、扣减图书库存。任何一个动作失败前面已经执行成功的操作都必须回滚否则会出现“订单有明细但库存没扣”或者“主表有订单但明细缺行”的脏数据。所以这一步必须放进数据库事务里。下面这段 OrderService 的核心代码是我在实际课设系统里使用并验证过的模板你直接照着抄就能跑通。public class OrderService { public boolean createOrder(User user, Cart cart) throws Exception { Connection conn null; try { conn JdbcUtils.getConnection(); conn.setAutoCommit(false); String insertOrder INSERT INTO order_main(user_id, total_price) VALUES(?,?); PreparedStatement ps conn.prepareStatement(insertOrder, Statement.RETURN_GENERATED_KEYS); ps.setInt(1, user.getId()); ps.setDouble(2, cart.getTotalPrice()); ps.executeUpdate(); ResultSet keys ps.getGeneratedKeys(); int orderId 0; if (keys.next()) { orderId keys.getInt(1); } for (CartItem item : cart.getItems()) { String insertItem INSERT INTO order_item(order_id, book_id, quantity, price) VALUES(?,?,?,?); PreparedStatement psItem conn.prepareStatement(insertItem); psItem.setInt(1, orderId); psItem.setInt(2, item.getBook().getId()); psItem.setInt(3, item.getQuantity()); psItem.setDouble(4, item.getBook().getPrice()); psItem.executeUpdate(); String updateStock UPDATE book SET stock stock - ? WHERE id ? AND stock ?; PreparedStatement psStock conn.prepareStatement(updateStock); psStock.setInt(1, item.getQuantity()); psStock.setInt(2, item.getBook().getId()); psStock.setInt(3, item.getQuantity()); int rows psStock.executeUpdate(); if (rows 0) { throw new RuntimeException(库存不足事务回滚); } } conn.commit(); return true; } catch (Exception e) { if (conn ! null) { conn.rollback(); } throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } } }这段代码里最值得细说的是扣库存那条更新语句。它在UPDATE时把stock ?作为更新条件利用数据库行锁的特性库存不够时影响行数为 0然后手动抛异常触发回滚。这种“条件更新”方式天然防止了超卖两个请求同时下单最后一本书时数据库串行执行后执行的那条看到库存为 0 不会更新任何行直接进入回滚分支。如果在代码里先SELECT查库存再判断再UPDATE并发环境下就会出现两个请求都查到库存为 1、最终库存变成负数的问题。下单成功后的尾部处理同样有讲究。忘记重置setAutoCommit(true)的话这个连接归还连接池后还保持着事务状态下一个请求拿到它执行任何语句都不会立即生效这种问题极其隐蔽排查起来非常痛苦。5. 网上购书系统避坑实录5 个高频翻车点与排查路径5.1 现象一Tomcat 10 下 Servlet 类找不到用 IDEA 2024 创建 Java Enterprise 项目时默认选的可能是 Tomcat 10 或更高版本。如果你照着网上老教程导入javax.servlet.http.HttpServlet编译全部报错运行起来直接ClassNotFoundException这不是代码写错了而是名称空间变了。Tomcat 10 起从 Java EE 迁移到了 Jakarta EE包名从javax.servlet.*换成了jakarta.servlet.*。网上大量资料还停留在 Tomcat 9 时代这也是这个坑如此普遍的根本原因。解决方式有两个二选一即可。一是把 Tomcat 换成 9.0 系列继续使用javax.servlet.*和老教程保持一致二是保持 Tomcat 10把所有 import 中的javax手动改成jakarta。我在这个项目中用的 Tomcat 9因为课程设计阶段参考资料多遇到问题搜出来的答案直接可用不值得在命名空间变化上浪费时间。5.2 现象二数据库里的中文全变成问号页面显示正常但数据库里存的是??或者反过来典型的编码链断裂问题。中文从浏览器到数据库要经过四道关卡请求编码、服务器字符集、JDBC 连接编码、数据库字符集。任何一关用的不是 UTF-8数据就会在这一步被转换成错误的字节。我一般会固定一套组合拳缺一不可JSP 页面顶部写pageEncodingUTF-8Servlet 里在读取任何参数前执行request.setCharacterEncoding(UTF-8)JDBC URL 拼接characterEncodingutf8数据库建表时统一使用utf8mb4。排查时按这个顺序逐层验证用浏览器开发者工具看请求头是一层在 DAO 里打印查询参数是一层直接在 MySQL 客户端看数据是最后一层。5.3 现象三刷新页面生成了两条订单这可算网上购书系统里最典型的血泪经验。用户在下单页面点了一次“提交订单”浏览器转圈没反应于是他再点一次结果订单表里出现了两条一模一样的记录。原因在于用户名下按钮是否被禁用的是前端问题而后端 Servlet 并没有识别出“这两次请求其实是同一次业务操作”。这也是我在 4.3 节强调 PRG 模式的原因。下单成功后 Servlet 应该重定向到一个订单详情页而不是转发到成功页面。但用户点击提交的那一瞬间请求已经到达 Servlet即使成功后就重定向第一次请求仍然执行了下单。真正能拦截重复提交的方案是在生成购物车结算页时创建一个一次性 Token 放进 Session提交时带上并比对比对完立即销毁。这部分代码放在第 6 章和登录拦截一起实现。5.4 现象四jsp:useBean 报 Unable to create a new instanceJSP 页面里用jsp:useBean iduser classcom.bookstore.bean.User /来实例化对象时如果运行时报出InstantiationException或javax.servlet.jsp.JspException: Unable to create a new instance九成是实体类没有无参构造函数。我在 4.2 节强调过这一点但很多人还是会在实体里只写带参构造以为方便初始化就是好的设计。解决方法是给实体类补一个显式的无参构造哪怕它什么也不做。不要依赖编译器自动生成的那个默认构造因为只要你写了任何一个有参构造默认无参构造就会被覆盖。顺手在代码评审时把这个当成硬性规范所有 JavaBean 类必须有无参构造。5.5 现象五缺 jar 包导致的 ClassNotFoundException启动 Tomcat 时加载某个类报ClassNotFoundException大概率不是代码问题而是依赖没有打进 WEB-INF/lib 目录。IDEA 里项目能编译是因为依赖加在了模块的 classpath 上但运行 Tomcat 时容器从 WEB-INF/lib 加载第三方包两边路径不一样编译通过并不代表运行能通过。解决方式是在 Artifact 配置里把第三方依赖项勾选为lib目录或者直接手动把 mysql-connector、DBUtils 的 jar 文件复制到 WEB-INF/lib。每次切换一台新电脑或拉一次新代码都要检查这个目录是否完整这个问题我用了一个小习惯来根治把依赖写进项目的 README换环境时按清单核对。6. 再往上走一步用 Filter 把登录拦截、统一编码和防重复提交收归一处6.1 一个 Filter 同时解决编码、登录拦截与静态资源放行系统里每个 Servlet 都在开头写一遍request.setCharacterEncoding(UTF-8)这属于重复劳动而且很容易漏写。把编码逻辑挪进 Filter 是一次零成本的治理。Filter 的另一个职责是登录拦截没有登录的用户即使手输 URL 访问购物车或订单页也应该被送回登录页。一次性把这两件事做成你后期新增多少个页面都不需要再写登录判断。WebFilter(/*) public class AuthFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; req.setCharacterEncoding(UTF-8); String uri req.getRequestURI(); if (uri.endsWith(/login) || uri.endsWith(/login.jsp) || uri.contains(/book/list) || uri.endsWith(.css) || uri.endsWith(.js)) { chain.doFilter(request, response); return; } if (req.getSession(false) null || req.getSession(false).getAttribute(loginUser) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }注意逻辑顺序。放行条件要精确登录接口本身、登录页面、图书浏览这类公开页面、CSS 和 JS 静态资源都要放行否则会出现页面样式全丢或者登录接口被拦截的连锁问题。判断getSession(false)时传了 false意味着没有 Session 时返回 null不会创建一个新的空 Session这样匿名访问也不会浪费服务端内存。新增页面时只要记住一条规则公开访问的加进放行列表其余全部默认拦截。6.2 配合 Token 把重复提交挡在下单入口外登录拦截只解决“没登录不让进”不解决“登录了重复下单”。第 5 章说的重复订单现在可以根治了。做法是用户打开结算页时在 Session 里生成一个随机 Token 并放进页面隐藏字段提交订单时 Servlet 比对提交的 Token 和 Session 里的 Token一致才执行下单然后立刻把 Session 里的 Token 删除。刷新页面时 Token 已经没了第二次提交直接被拒。String sessionToken (String) session.getAttribute(orderToken); String requestToken request.getParameter(orderToken); if (sessionToken null || !sessionToken.equals(requestToken)) { response.sendRedirect(cart.jsp?errorrepeat); return; } session.removeAttribute(orderToken);这段代码放在下单业务执行之前。验证失败时直接重定向回购物车页并带上错误参数购物车 JSP 里读到这个参数显示提示语。验证成功则移除 Token 再继续执行业务这样同一页面无论如何刷新、无论怎么后退都无法制造出第二张订单。把这个 Token 校验逻辑和 6.1 的 Filter 组合起来这个系统的会话管理和重复提交防线就相对完整了从课设走向企业级 web 开发差的也就是把购物车从 Session 换成数据库表、把 JDBC 换成连接池管理那只是量的扩展不再需要改架构。我亲手把这一套完整代码写过两遍之后最深的体会是网上购书系统的复杂点从来不在“买书”这件事上而是在请求流转、会话保持和事务边界这些看不见的细节里。JSP、Bean、Servlet 这套老技术组合恰恰把这些细节全部暴露在你面前每一个坑都是未来调试 Spring 项目时的重要经验。希望这篇笔记能帮你少熬几个夜尽快跑通属于自己的那套购书系统。本文还有配套的精品资源点击获取