
简介一套网上书店系统设计代码面向Web开发初学者与毕业设计学生采用B/S架构覆盖图书查询、选购下单、用户管理、后台维护等典型场景可作为JSP/ASP等动态网页课程设计的完整参考。压缩包共60个文件其中20个ASP页面承担主要业务逻辑JPG/GIF图片提供界面素材另含MDB/DB数据库文件、DOC说明文档和首页HTM页面整体大小约2.19MB结构紧凑便于下载后直接浏览。目前已有54人学习浏览。代码包含用户注册登录、图书列表、购物车、订单核对、管理员增删改图书等模块数据库与文档一并打包可帮助理解动态网站前后端交互、数据库读写和页面跳转尤其适合需要快速搭建网上书店课设或毕设系统的读者。1. 需求与选型为什么一个JSP网上书店系统仍是课设与毕设的最佳选择如果你正在为课程设计或毕业设计选题网上书店系统几乎是出现频率最高的题目之一。这个题目的业务链路很完整用户注册、图书浏览、购物车、下单结算、后台图书管理和订单处理每一步都能演示每一步也都能出问题。很多人上来就选 Spring Boot 加 Vue堆了一堆依赖最后连登录流程都讲不明白。这套方案反其道而行选择 JSP Servlet JavaBean 的经典组合目的是用最小成本把完整业务跑通。方案里包含数据库设计、目录结构、核心代码、避坑点以及验收清单适合需要交代码和设计文档的学生也适合要快速搭一个可演示项目的从业者。拿到手之后改包名、改数据库连接就能跑起来再按自己的需求加字段和页面整体不会翻车。2. 系统总体架构设计JSP Servlet JavaBean 三层结构与六个数据表2.1 为什么选JSP而不是前后端分离很多同学做网上书店系统时一上来就问要不要用 Vue 配 Spring Boot我能理解毕竟现在前端框架很流行。但作为课设或者毕设你需要考虑的是一个月内能不能把代码写完、答辩时能不能把一个完整业务链路讲清楚。JSP 这套老技术栈最大的好处是页面和后端代码放在同一个工程里运行到一半断了能从页面一层层追到 SQL每一个环节都是可见的。前后端分离则把逻辑拆到两个项目里光讲部署和跨域就要花五分钟业务本身反而没时间讲。这里用 JSP Servlet JavaBean 的三层结构其实对应了 MVC 思想。JSP 承担 View负责显示Servlet 承担 Controller负责接收请求和跳转JavaBean 以及 DAO 负责 Model把数据库记录映射成对象。三层分下来之后每个类的职责都很单一答辩提问时你能说清楚“哪一层处理了什么”。如果导师问为什么不用 Spring Boot可以直接回答该项目的周期要求快速交付没有复杂的分布式需求JSP 的页面直出方式更适合演示场景。我一般会在工程里避免把业务逻辑堆在 JSP 的% %里虽然 JSP 允许这么写但一旦混写页面就变成了一个黑匣子后期改需求非常痛苦。通常的做法是JSP 里只放 JSTL 标签和 EL 表达式任何 Java 代码都进 Servlet。这样即使出问题可以通过后台打印定位而不是在 HTML 里找脚本片段。配套技术栈上JDK 用 8 或 11服务器用 Tomcat 8.5 或 9数据库用 MySQL 5.7依赖就是 Servlet API、JSTL、文件上传组件和连接池。这些是课设环境里最不容易翻车的组合。2.2 数据库设计六个核心表网上书店的核心数据围绕“人、书、订单”三个关键词。人就是用户书就是图书及分类订单是用户和书发生交易时留下的记录。我把数据库拆成用户表、图书表、分类表、购物车表、订单表、订单明细表共六张。CREATE DATABASE bookstore DEFAULT CHARACTER SET utf8mb4; USE bookstore; CREATE TABLE user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, email VARCHAR(100), role TINYINT DEFAULT 0, created_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE category ( category_id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL ); CREATE TABLE book ( book_id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, title VARCHAR(200) NOT NULL, author VARCHAR(100), publisher VARCHAR(100), price DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0, sales INT DEFAULT 0, cover VARCHAR(255), description TEXT, is_deleted TINYINT DEFAULT 0, FOREIGN KEY (category_id) REFERENCES category(category_id) ); CREATE TABLE cart ( cart_id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, quantity INT DEFAULT 1, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_book (user_id, book_id), FOREIGN KEY (user_id) REFERENCES user(user_id), FOREIGN KEY (book_id) REFERENCES book(book_id) ); CREATE TABLE orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, deliver_time DATETIME DEFAULT NULL, finish_time DATETIME DEFAULT NULL, FOREIGN KEY (user_id) REFERENCES user(user_id) ); CREATE TABLE order_item ( item_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, book_id INT, book_title VARCHAR(200), price DECIMAL(10,2), quantity INT, FOREIGN KEY (order_id) REFERENCES orders(order_id) );几个关键设计点值得说清楚。user 表里存的是加盐后的 MD5 或 SHA-256 散列不要把明文密码落库role 字段用来区分普通用户和管理员。book 表加了 is_deleted 字段这是为了避免删除图书时导致历史订单的关联查询报错管理员下架图书只置为 1不物理删除。orders 和 order_item 拆开因为一张订单会包含多本图书并且订单明细里复制了 book_title 和 price这是快照防止图书改价或删除后订单数据失效。stock 字段管理库存下单时要做“库存足额才扣减”的判断。六个表的关系是category 一对多 bookuser 一对多 cart 和 ordersorders 一对多 order_itembook 对 cart 和 order_item 都是一对多。购物车在实际课设里经常用 Session 临时存放cart 表作为登录后多端同步的扩展方案这套方案里 Session 做法会在第 3 章给出。数据库统一用 utf8mb4否则中文照样乱码这一点在避坑部分会展开。2.3 项目目录结构与一次请求的完整流转工程采用的 Maven 目录结构即使不是 Maven 工程也建议保持这样的分包逻辑bookstore/ ├── src/main/java/com/bookstore/ │ ├── entity/ # User、Book、CartItem、Order 等实体类 │ ├── dao/ # UserDao、BookDao、CartDao、OrderDao │ ├── servlet/ # UserServlet、BookServlet、CartServlet、OrderServlet、AdminServlet │ └── util/ # DBUtil、MD5Util、EncodingFilter ├── src/main/webapp/ │ ├── WEB-INF/web.xml │ ├── index.jsp │ ├── user/ login.jsp register.jsp │ ├── book/ book_list.jsp book_detail.jsp │ ├── cart/ cart.jsp │ ├── order/ checkout.jsp order_list.jsp │ ├── admin/ admin_login.jsp book_manage.jsp order_manage.jsp │ └── css/ js/ uploads/一次完整的“用户看书并下单”的流程可以这样描述用户在 index.jsp 点击书名链接指向 book_detail.do?bookId3浏览器把请求发给 BookServletBookServlet 调用 BookDao.findById 拿到 Book 对象转发给 book_detail.jsp页面用 EL 表达式展示书名价格用户点“加入购物车”请求进入 CartServletCartServlet 把 Book 对象和数量放入 HttpSession 中的 Map点“去结算”进入 OrderServletOrderServlet 在事务里生成订单、扣库存并清空购物车最后转发到 order_list.jsp 展示订单状态。web.xml 里至少需要配置欢迎文件让访问根路径时默认加载 index.jspweb-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1 welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list /web-app这段配置的作用是指定根路径的默认页面。如果用 Servlet 注解不需要在 web.xml 里注册每一个 Servlet但 welcome-file 还是需要。注意控制层 Servlet 的映射尽量用 /xxx.do 后缀不要用/拦全部请求否则会挡住 CSS、图片等静态资源这也是后面避坑章节要单独说的问题。3. 核心模块实现登录、分页检索、购物车三个关键链路的代码设计3.1 用户登录与会话保持用户模块的核心页面是 login.jsp表单提交到 UserServlet参数为 username 和 password。下面这段是 UserServlet 的 doPost 方法核心片段WebServlet(/login.do) public class UserServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(utf-8); String username request.getParameter(username); String password MD5Util.md5(request.getParameter(password)); UserDao userDao new UserDao(); User user userDao.findByUsernameAndPassword(username, password); if (user ! null) { HttpSession session request.getSession(); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); response.sendRedirect(request.getContextPath() /index.do); } else { request.setAttribute(errorMsg, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } } }这里做了几件事请求编码固定为 UTF-8密码用工具类散列后再比对避免数据库里出明文登录成功把 User 对象放进 Session设置 30 分钟超时跳转用 redirect 而不是 forward这样刷新页面不会重复提交表单。比较常见的错误是把密码直接拼进 SQL比如写成 where password password 一旦用户名输入 or 11就会绕过校验变成注入。用 PreparedStatement 参数化查询是最基本的安全习惯findByUsernameAndPassword 内部就应该用占位符。注册逻辑和登录类似先检查用户名是否已存在存在则返回“该用户名已被注册”不存在则插入记录再跳回登录页。注册时可以用正则校验邮箱格式。在 JSP 端登录状态通常用 JSTL 判断c:if test${not empty sessionScope.loginUser} span欢迎${loginUser.username}/span a href${pageContext.request.contextPath}/logout.do退出/a /c:if c:if test${empty sessionScope.loginUser} a href${pageContext.request.contextPath}/login.jsp登录/a | a href${pageContext.request.contextPath}/register.jsp注册/a /c:if这段代码要注意 EL 表达式取 Session 变量时写的是 sessionScope.loginUser不要直接把对象 toString 输出。需要给 User 类重写 toString 或者只取 username 属性否则页面上会出现一串对象地址。3.2 图书分页检索书店的书会越来越多列表页必须分页。搜索条件通常有关键字 title 和分类 categoryId。在 BookDao 里写一个支持条件组合的方法public ListBook searchBooks(String keyword, Integer categoryId, int offset, int limit) { StringBuilder sql new StringBuilder(); sql.append(SELECT * FROM book WHERE is_deleted 0 ); ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { sql.append(AND title LIKE ? ); params.add(% keyword.trim() %); } if (categoryId ! null categoryId 0) { sql.append(AND category_id ? ); params.add(categoryId); } sql.append(ORDER BY sales DESC LIMIT ?, ?); params.add(offset); params.add(limit); return queryBookList(sql.toString(), params); }这串 SQL 的关键点在于用 StringBuilder 先拼固定片段再根据条件追加所有变量都放进参数列表不拼在 SQL 字符串里LIMIT 的两个参数是 offset 和 limitoffset 是当前页码减一乘以每页条数limit 是每页条数。分类过滤用精确等值因为分类表是单层的如果 categoryId 为空不能写成 category_id null而要用 is null。这个习惯在 DAO 里一定要记住否则查不出数据。分页还需要计算总页数通常单独执行 SELECT count(*) FROM book WHERE is_deleted 0再根据同样条件传参。控制层拿到当前页码后把 bookList、pageNow、totalPages、keyword、categoryId 一并放进 request转发给 book_list.jsp。JSP 里用 JSTL 循环输出c:forEach varbook items${bookList} div classbook-card a href${pageContext.request.contextPath}/book_detail.do?bookId${book.bookId} img src${pageContext.request.contextPath}/uploads/${book.cover} alt${book.title} /a p${book.title}/p p${book.price}/p /div /c:forEach封面路径一定记得加上 contextPath否则在子页面会相对于当前 URL 解析图片自然裂掉。分页链接上也要带上下文路径和原有查询参数这样切换页码时关键字不会丢。3.3 购物车操作购物车有几种做法存在 Session 里最简单存在数据库里更正式。这套方案用 Session 保存临时购物车cart 表作为登录后同步的扩展。CartServlet 里的核心数据结构是 MapMapString, CartItem cart (MapString, CartItem) session.getAttribute(cart); if (cart null) { cart new LinkedHashMap(); session.setAttribute(cart, cart); } String bookId request.getParameter(bookId); Book book bookDao.findById(Integer.parseInt(bookId)); CartItem item cart.get(bookId); if (item null) { item new CartItem(); item.setBook(book); item.setQuantity(1); cart.put(bookId, item); } else { item.setQuantity(item.getQuantity() 1); }为什么用 LinkedHashMap 而不是普通 HashMap因为 key 是 bookId重复加同一本书直接累加数量而 LinkedHashMap 保持插入顺序展示购物车时不会乱跳。每次加入前最好检查库存和已有数量的和超过库存要提示。商品信息可以直接引用 Book 对象但注意 Session 里存的是引用后台改价后购物车显示也可能变正式系统应该在加购时把价格快照进 CartItem课设里引用对象也可以。购物车列表页 cart.jsp 用 c:forEach 遍历 Map 的 entry显示书名、单价、数量和单行小计。数量修改用表单提交到 CartServlet 的 update.do删除用 remove.do。更新数量时数量不能小于 1删除则直接 cart.remove(bookId)。用户未登录就加购物车时登录后可以把 Session 数据同步到 cart 表。总金额在订单页统一计算不要轻信前端传来的总价避免用户改页面参数下单不付钱。4. 订单与后台管理模块结算事务、图书上架与订单状态流转4.1 订单生成事务保证购物车转订单不出现半单下单是整个系统里最需要严谨的地方因为涉及多表同时写入。用户从购物车点结算实际过程是读取 Session 购物车计算总金额生成订单主表记录循环生成订单明细扣减图书库存最后清空购物车。一旦中途异常要么全部成功要么全部回滚否则会出现有订单没明细或者扣了库存却没下单。下面这段是 OrderService 创建订单的核心骨架重点看事务的三段式结构public int createOrder(int userId, MapString, CartItem cart) throws Exception { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); double total 0; for (CartItem item : cart.values()) { total item.getBook().getPrice() * item.getQuantity(); } String orderNo String.valueOf(System.currentTimeMillis()) String.format(%03d, new Random().nextInt(1000)); // 1. 插入订单主表 orders拿到自增 order_id // 2. 遍历购物车插入 order_item同时 UPDATE book // SET stock stock - ? WHERE book_id ? AND stock ? // 3. 清空 Session 购物车 conn.commit(); return orderId; } catch (Exception e) { if (conn ! null) conn.rollback(); throw e; } finally { DBUtil.close(null, null, conn); } }这里容易翻车的地方有几个。插入主表后要用PreparedStatement.RETURN_GENERATED_KEYS再 getGeneratedKeys 拿到自增主键不能去查最大 order_id并发时后者会取错。扣库存的 SQL 必须写成UPDATE book SET stock stock - ? WHERE book_id ? AND stock ?返回受影响行数为 1 才继续否则说明库存不够抛异常回滚。order_no 用时间戳加三位随机数在课设够用并发量高时应换 UUID。把这些细节写在设计文档里答辩时是加分项。4.2 后台图书管理增删改查与封面图片上传后台管理入口是 admin/admin_login.jsp管理员通过 user 表 role1 区分。图书管理页最关键的是新增图书表单包含标题、作者、出版社、价格、库存、分类和封面文件提交时是 multipart/form-data不能再直接用 request.getParameter 获取普通字段。用 Commons FileUpload 解析的核心片段如下ServletFileUpload upload new ServletFileUpload(new DiskFileItemFactory()); ListFileItem items upload.parseRequest(request); for (FileItem item : items) { if (item.isFormField()) { String name item.getFieldName(); String value item.getString(utf-8); if (title.equals(name)) title value; } else { String uploadPath getServletContext().getRealPath(/uploads); String fileName UUID.randomUUID().toString() _ item.getName(); item.write(new File(uploadPath, fileName)); cover fileName; } }文件名用 UUID 加原始文件名避免不同作者上传同名封面互相覆盖。getRealPath 获取的是当前 Web 应用的物理路径部署后目录变更也能适配。解析上传字段时注意 getString(utf-8)否则输入框里的中文可能乱码。文件上传成功后数据库里存的是相对文件名页面展示时拼上 contextPath 和 /uploads 前缀。图书删除建议做逻辑删除也就是之前表里设计的 is_deleted 字段。为什么不做物理删除因为历史订单明细表里记录了 book_id直接把 book 记录删掉后台订单详情关联查询会得到空对象页面容易报空指针。逻辑删除后前台查询统一过滤 is_deleted0后台仍能恢复已下架图书。4.3 订单状态流转从支付到发货、收货的闭环订单状态用 0 到 4 表示0 待支付1 已支付2 已发货3 已完成4 已取消。演示时为了不接真实支付可以在订单详情页放“模拟支付”按钮点击之后调用 pay.do把 status 从 0 改成 1 并记录 pay_time。管理员后台调用 deliver.do把 status 从 1 改成 2 并记录 deliver_time用户确认收货调用 finish.dostatus 从 2 改成 3。状态更新要带前置条件不能无条件 update。比如支付操作的 SQLUPDATE orders SET status 1, pay_time NOW() WHERE order_id ? AND status 0加 status 0 是为了防止用户连点两次支付或者管理员先发货后用户又点支付把已发货订单改成已支付。返回更新行数为 1 才算成功否则提示“订单状态已更新请刷新页面”。这也能顺带解决演示时的并发误操作。状态流转的核心不是堆多个按钮而是用一个状态字段驱动页面展示。在 JSP 里用 c:choose 根据 status 输出不同操作按钮0 显示“模拟支付”和“取消订单”1 显示“等待发货”2 显示“确认收货”3 显示“已完成”。把这套逻辑写在 order_detail.jsp 里比每个按钮单独写判断整洁很多。5. JSP网上书店项目避坑清单配置、乱码、路径、连接池的四个常见问题5.1 Servlet映射404注解和web.xml打架现象Tomcat 能启动但点击登录或跳转页面时浏览器报 404控制台没有明确异常。原因网上资料有的用 WebServlet 注解有的用 web.xml 配置如果两个同时出现在项目中部分 Servlet 映射会被容器覆盖或漏配还有一种是表单 action 写成了 login.do 而不是 /login.do少了根路径部署到带项目名的目录后就找不到。解决统一用注解注册 Servlet确保 WebServlet(/login.do) 里的字符串与表单 action 一致。表单提交地址建议用${pageContext.request.contextPath}/login.do这样部署在任意上下文路径下都不会错。排查顺序是先看浏览器地址栏 URL 是否带项目名再看 Tomcat 日志里实际匹配到了哪个 Mapping。5.2 中文乱码项目里所有中文都变成了问号现象注册用户名“张三”显示成“?”图书名称在页面上正常存到库里乱码或者从数据库读出来又乱码。原因四个位置不统一request 请求参数编码、response 响应编码、JDBC 连接 URL、数据库表的字符集缺一个就会出现连锁问题。解决在 web.xml 里配置一个 EncodingFilter 或者写过滤器统一设置编码public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(utf-8); resp.setCharacterEncoding(utf-8); chain.doFilter(req, resp); } }同时 JDBC URL 一定要写成jdbc:mysql://localhost:3306/bookstore?useUnicodetruecharacterEncodingutf8建库时用 utf8mb4。注意配置里不要出现成对的 useSSLtrue否则连接警告会占满控制台看起来很像乱码实际上不是。5.3 静态资源加载失败CSS不见、图片裂掉现象列表页样式在本地正常打成 WAR 包部署到独立 Tomcat 后页面光秃秃封面图片显示裂图。原因JSP 页面里直接用 hrefcss/style.css这个相对路径会相对于当前 URL 解析。如果你在 /book_detail.do 请求下浏览器会去 /book_detail.do/css/style.css自然找不到。另一个常见原因是有 Filter 拦截了/*把 CSS 和图片也放进了过滤链。解决统一用绝对路径EL 表达式拼上下文路径link relstylesheet href${pageContext.request.contextPath}/css/style.css img src${pageContext.request.contextPath}/uploads/${book.cover}如果加了自定义 Filter注意不要把/*匹配全部路径要么在 Filter 内部放行 .css、.js、.png要么把 Servlet 映射改成 .do 后缀。排查时直接看浏览器 F12 的 Network 面板看请求 URL 是不是多了一层项目路径一眼就能定位。5.4 数据库连接耗尽运行一会儿就Timeout现象连续点了几十次查询后页面突然很慢日志报 “Too many connections” 或 “Connection is not available, request timed out”。原因DAO 方法里每次通过 DriverManager.getConnection 获取连接用完没有关闭或者关闭顺序不对只关了 Statement 没关 Connection。连接池里的连接数是有限的泄漏到一定数量就全部耗死。解决用 DBUtil 统一管理连接和关闭在 finally 里按后开先关的顺序关闭 ResultSet、Statement、Connection。这是我一直使用的写法public static void close(ResultSet rs, Statement stmt, Connection conn) { try { if (rs ! null) rs.close(); } catch (Exception e) {} try { if (stmt ! null) stmt.close(); } catch (Exception e) {} try { if (conn ! null) conn.close(); } catch (Exception e) {} }这里有一个反直觉的地方Connection.close() 在连接池中不是真正断开而是把连接归还给池子所以用完必须调用否则连接挂在池外。如果项目里使用了 DruidDataSource从数据源 getConnection 后再关闭也是一样的道理。写完一个 DAO 方法就检查一遍 finally能省掉后面大量查连接的超时时间。6. 验收与答辩功能测试清单和十五分钟演示脚本6.1 功能测试清单验收前我通常把系统当作用户一页页点一遍而不是只在代码里自测。下面这张清单可以作为你的验收基准模块测试操作预期结果用户注册输入可用用户名、邮箱、密码并提交数据库新增记录密码非明文用户登录错误密码提交再正确密码提交错误提示正确跳转首页图书检索输入关键字、选择分类能筛出对应图书分页正常购物车同一本书加两次数量累加超库存被拦截下单购物车多本书结算订单含多条明细库存减少购物车清空后台发货管理员处理已支付订单状态变为已发货用户可确认收货这套流程跑通后再补测试异常场景比如未登录就下单、重复支付、库存为 0 的下单请求确保不会报错一片红。6.2 十五分钟答辩演示脚本答辩时间通常不长我建议按这个顺序走先用 30 秒说题目和整体结构再用 4 分钟走用户流程注册、登录、搜索、加购物车、下单接着 4 分钟走管理员流程登录后台、新增图书、发货处理剩下时间用来讲数据库表关系、分页、事务和防注入这些设计点。还要准备一句标准答案解释为什么选 JSP技术栈简单页面和后端在一套代码里能直观看到数据从页面到数据库的完整流转非常适合中小型教学项目。演示前一定重新初始化数据库避免上次测试的脏数据影响效果。这套方案对应的完整源码和初始化脚本可以直接下载参考下载后先执行建表 SQL再改数据库连接和上传路径就能跑通整个闭环。从那以后我每次交付项目都会强制自己先跑一遍从用户注册到管理员发货的完整流程再写设计文档绝不把没联调过的代码留给答辩。希望帮到你。本文还有配套的精品资源点击获取