资讯详情

JSP+Servlet网上书店系统:状态链路、购物车与下单库存

📅 2026/10/28 21:22:56 | 华诺云谱 👁 阅读
JSP+Servlet网上书店系统:状态链路、购物车与下单库存
简介网上书店系统设计代码是一份面向计算机专业课程设计、毕业设计学习者的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 两个 jarJDK 17 构建工具 网络首次可运行耗时部署即跑分钟级首次构建常在十分钟以上每层可解释性四层职责清晰可指认自动配置需要额外解释样板代码量多写约三成少写样板配置项变多适配场景课设、毕设、单机演示团队协作、长期迭代选型的另一半是给 JSP 划定边界。这个项目里我一般要求 JSP 页面只出现 EL 表达式和 JSTL 标签禁止% %脚本片段。脚本片段一旦放开页面很快会变成「SQL 写在 JSP 里、循环写在 JSP 里」的混合体改一个字段名要去五六个页面里找。用c:forEach渲染图书卡片、用${book.price}取值页面只管展示逻辑全在 Java 层。!-- 商品列表页只做渲染不出现任何 Java 脚本片段 -- c:forEach items${page.rows} varbook div classcard img src${pageContext.request.contextPath}${book.coverUrl} alt${book.title} h3${book.title}/h3 p${book.author}/p span classprice${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?msgunavailable); 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第四步的重定向属于 PRGPost-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) -- 支持按书名模糊查 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) -- 「我的订单」按时间倒序 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字符集统一用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 MapInteger, 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 PageBeanT { private int pageNo 1; private int pageSize 8; private int total; // 总记录数由 count SQL 填充 private ListT 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 PageBeanBook pageByCategory(Integer categoryId, String keyword, int pageNo, int pageSize) { PageBeanBook page new PageBean(); page.setPageNo(pageNo); page.setPageSize(pageSize); StringBuilder where new StringBuilder( FROM book WHERE status 1 ); ListObject 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 末尾顺序不能错 ListObject 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必须做一次下限保护用户手改成pageNo0或负数时offset会变成负值MySQL 直接报语法错误通常的做法是在 Servlet 里Math.max(1, pageNo)兜一下。pageSize后台管理可以给 15、前台给 8但要设上限一般 50否则有人传pageSize99999就等于把整张表拉进内存。4.3 登录拦截白名单、两种响应、以及 Filter 的执行顺序登录拦截用 Filter 实现难点不在拦截本身在于「哪些该放行」和「AJAX 请求怎么响应」。WebFilter(urlPatterns /*) public class AuthFilter implements Filter { // 白名单登录注册、静态资源、图书浏览与详情未登录也能看 private static final ListString 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 而不是 401JSON 解析直接崩控制台报的是语法错误很难定位到是登录过期。第三个要点是 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 升序扣库存避免多事务交叉加锁造成死锁 ListCartItem 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 contentTypetext/html;charsetUTF-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?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai三个参数缺一个都可能出问题。另外 Tomcat 8 以后 GET 请求默认按 UTF-8 解析但如果部署在旧容器上还要在server.xml的 Connector 上加URIEncodingUTF-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 做预扣数据库只做最终扣减。从那以后我每次交这类系统之前都会强制自己用一个全新的数据库、一个全新的浏览器无痕窗口从注册开始把上面那张表从头走一遍一步都不跳。因为演示时坐在你对面的那个人一定会点你最没测过的那个按钮。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑