简介:一套基于SSM的网上服装销售系统毕业设计项目,面向Java方向毕业生与课程设计学习者,适用于论文配套、答辩演示或二次开发。系统按管理员、用户双角色划分权限,覆盖商品分类、商品信息、订单、个人中心、我的收藏、购物车、新闻资讯等前后台功能,结构完整,贴合电商业务场景。资源包共1302个文件,压缩包约15.94MB。142个JSP与110个Java源码构成业务与页面主体,358个JS、165个CSS及字体图标负责前端交互与样式,另有SQL脚本、XML配置、文档和图片素材,便于导入运行和参考修改。目录按SSM分层组织,便于学习框架整合与权限设计。已有176人学习。解压后可直接用SQL脚本初始化数据库,借助Eclipse或IntelliJ IDEA部署,并结合源码注释和功能模块理解管理员与用户操作流程,为论文撰写和系统演示提供完整参照。
1. 这套网上服装销售系统,到底要交付什么
做毕业论文的 Java 开发者,十个里有八个会碰一次网上服装销售系统。它不是电商大厂那种高并发架构,而是一条完整走通的业务链路:用户注册登录、浏览商品、加购物车、下单支付、后台管理商品和订单。你可能觉得这很普通,但恰恰是「普通」才安全——论文系统的评分逻辑是业务闭环完整、技术选型合理、代码能演示,而不是用了多冷门的技术。网上服装销售系统的本质是一套基于 SSM(Spring + SpringMVC + MyBatis)或 Spring Boot 的单体应用,核心价值在于把商品、用户、订单三张表的关系讲透。
我这篇文章会带你从选型、建库、写代码到答辩演示走一遍,所有代码都是可以直接抄进你自己工程里的那种,不玩虚的。适合刚准备开题、或者代码写到一半卡住的人。提前说一句:这套系统翻车通常不在技术上,而在事务处理、图片路径和部署环境这三个细节上,我会在第 5 章把这几个坑全摊开。
2. 技术选型和工程骨架:为什么我选 SSM 而不是纯 Spring Boot
「网上服装销售系统」这个标题决定了你必须用 Java 系技术栈。如果你问导师能不能用 PHP,他大概率会让你换题。Java Web 的经典组合就两条路:SSM 和 Spring Boot,但论文系统我更推荐 SSM,原因很实际——分层明显,架构图好画,详细设计好写,答辩时老师顺着 Controller → Service → DAO 三层问下去,你每一层都有话讲。Spring Boot 不是不能用,而是它把配置全自动了,反而不好展示「设计」这件事。
2.1 为什么是 SSM:论文图纸比性能更重要
SSM 的价值不在于性能,而在于每一层都是显式的。Controller 接参和校验,Service 写业务规则,DAO 管数据库交互。这个结构直接对应毕业论文里的「系统总体设计」章节,你甚至可以原样把包名画成架构图。换成 Spring Boot 之后,约定优于配置,很多东西被隐掉了,老师问到「你们的国际化怎么做的」「异常是怎么统一处理的」,你反而不好接。
另外一个现实因素是版本兼容性。网上服装销售系统一般跑的是 JDK 8 + Maven 3 + Tomcat 8.5,这套组合是所有高校机房和旧笔记本最稳的环境。Spring Boot 现在普遍要求 JDK 17 甚至 21,一旦本机 JDK 版本不对,启动直接报错,光是折腾环境就能磨掉你两天时间。虽然我文章里给了 Spring Boot 的迁移方向,但你第一次跑通最好别用。
2.2 Maven 工程目录与核心依赖配置
我一般会建一个标准的 Maven war 工程,目录结构长这样:
clothing-sales-system/ ├── pom.xml ├── src │ ├── main │ │ ├── java/com/example/clothing │ │ │ ├── controller │ │ │ ├── service │ │ │ ├── dao │ │ │ ├── entity │ │ │ ├── interceptor │ │ │ └── common │ │ ├── resources │ │ │ ├── mybatis │ │ │ ├── spring │ │ │ └── jdbc.properties │ │ └── webapp │ │ ├── WEB-INF │ │ ├── static │ │ └── views这个结构的核心思想:controller 只做路由,service 只做业务,dao 只做 SQL。任何一个类放错包,代码能跑,但答辩的时候解释起来别扭。
pom.xml 里最关键的依赖是这五个,版本号我直接给到能用的范围:
<properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties> <dependencies> <!-- Spring 核心,5.2.x 是兼容 JDK 8 的最后一个稳定大版本 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.2.15.RELEASE</version> </dependency> <!-- SpringMVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.2.15.RELEASE</version> </dependency> <!-- MyBatis 与 Spring 整合包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.7</version> </dependency> <!-- MySQL 驱动,8.0.26 对应 JDK 8 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.26</version> </dependency> <!-- 分页插件,网上服装销售系统必然要分页 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.1</version> </dependency> </dependencies>逻辑说明:Spring 5.2.15 是最后一代支持 JDK 8 的稳定线,再往上就是 5.3.x,虽然也能跑但没必要。MyBatis-Spring 2.0.x 对应 MyBatis 3.5.x,别混用 1.x。PageHelper 1.4.1 是 Spring Boot 版本号,我这篇文章里用的是普通 SSM,实际要引入的是它的非 starter 版本,这一点下面会有补充说明。
参数说明:如果你非要现在就用 Spring Boot 3.x,注意两点:一是 JDK 必须 17+,二是 MyBatis 的 starter 用的是mybatis-spring-boot-starter3.0.x,不再是 2.x。写论文的话,我强烈建议你在写「技术选型」那一章时明确写「选用 JDK 8 + SSM 是因为其稳定性和广泛部署兼容性」,这句话很多导师都吃。
2.3 通用返回结构:先定协议再写功能
网上服装销售系统的前端不复杂,JSP 或 HTML + Ajax 都行。但不管用哪种,后端接口的返回格式必须统一,否则你写十几个接口之后会疯。我一般用一个叫Result的类统一包装:
package com.example.clothing.common; public class Result<T> { private Integer code; // 200 成功,500 失败 private String message; // 给用户看的提示文字 private T data; // 真正的业务数据 // 这三个静态方法调用频率最高 public static <T> Result<T> success(T data) { return new Result<>(200, "操作成功", data); } public static <T> Result<T> successMsg(String msg) { return new Result<>(200, msg, null); } public static <T> Result<T> error(String msg) { return new Result<>(500, msg, null); } }逻辑说明:code 不等于 HTTP 状态码,它是你自己定的业务码。HTTP 200 代表请求到了服务器,但业务不一定成功,比如你查询一个已被删除的商品,HTTP 还是 200,code 却是 500。前端拿 code 来判断有没有出错,只有 200 才取 data,其余的直接弹 message。这一层没有技术含量,但它是整个系统前后端不吵架的基石。
参数说明:message的设计原则是「给用户看,不给程序员看」。比如下单失败,message 应该写「库存不足」,而不是写NullPointerException at OrderServiceImpl.java:88。很多论文系统在这点翻车——把异常堆栈直接甩在页面上,显得很不专业;实际上做一个全局异常处理器(@ControllerAdvice)统一捕获并转成 Result 返回,这在一线评估里属于「加分项」。
3. 数据库设计与 ER 映射:三张表讲透电商老路子
网上服装销售系统的数据库设计是整个论文的核心图纸。你画的 ER 图画得漂不漂亮,直接决定了论文里的「数据库设计」一章好不好写。但别贪多,四个实体足矣:用户、商品、购物车(可以并入订单项)、订单。加一个管理员表给后台用,一共五张,再多就是纯炫技。这里我给出一套能用且不会让评委挑刺的建表方案。
3.1 核心表的结构与类型选择
商品表和订单表是查得最多的,字段类型选得不好,后期改表结构会非常痛苦。我按实际跑通的经验把 SQL 贴出来,你直接复制换上自己的库名就行:
CREATE DATABASE IF NOT EXISTS clothing_sales DEFAULT CHARSET utf8mb4; USE clothing_sales; -- 用户表 CREATE TABLE `user` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `username` VARCHAR(32) NOT NULL UNIQUE COMMENT '登录名', `password` VARCHAR(128) NOT NULL COMMENT '加密后的密码', `nickname` VARCHAR(32) DEFAULT NULL COMMENT '昵称', `phone` VARCHAR(20) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_username (`username`) ) ENGINE=InnoDB COMMENT='用户表'; -- 商品表 CREATE TABLE `product` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(64) NOT NULL COMMENT '商品名', `description` TEXT COMMENT '详情描述', `price` DECIMAL(10,2) NOT NULL COMMENT '单价,用 DECIMAL 不用 DOUBLE', `stock` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '库存', `image_url` VARCHAR(255) DEFAULT NULL COMMENT '图片路径,存相对路径', `category` VARCHAR(16) DEFAULT NULL COMMENT '分类:上衣/裤子/裙装...', `sales_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '销量,列表排序用', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_category (`category`) ) ENGINE=InnoDB COMMENT='商品表';逻辑说明:注意三个细节,价格用DECIMAL(10,2)而不是DOUBLE。DOUBLE 有浮点误差,算金额会在小数点后第 N 位出现奇怪的数字,演示时被老师看到会非常尴尬。password是 VARCHAR(128) 是因为你大概率会用 MD5 加盐或 BCrypt 加密,固定 32 位也行但 128 给你留了换算法的余地。image_url只存相对路径如/images/product/101.jpg,不存http://...绝对地址,否则换部署环境时图片全挂。
参数说明:引擎一定要写InnoDB,这是为了事务。订单表和订单项表还要靠外键逻辑关联,但我不建议在物理层建 FOREIGN KEY——原因在第 5 章「避坑记录」里细说,那是踩过的一个大坑。
订单部分直接关系着事务,我把订单表和订单项表一起给出:
-- 订单表 CREATE TABLE `order` ( `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `order_no` VARCHAR(32) NOT NULL UNIQUE COMMENT '业务单号,不是主键', `user_id` INT UNSIGNED NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1待发货 2待收货 3已完成 4已取消', `receiver_name` VARCHAR(32) NOT NULL COMMENT '收货人', `receiver_phone VARCHAR(20) NOT NULL, `receiver_addr` VARCHAR(200) NOT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_id (`user_id`) ) ENGINE=InnoDB COMMENT='订单表'; -- 订单项表:存下单时的商品快照 CREATE TABLE `order_item` ( `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `order_id` BIGINT UNSIGNED NOT NULL, `product_id` INT UNSIGNED NOT NULL, `product_name` VARCHAR(64) NOT NULL COMMENT '冗余商品名', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时的成交价', `quantity` INT UNSIGNED NOT NULL COMMENT '数量' ) ENGINE=InnoDB COMMENT='订单项表';这里有一个概念必须分清:order_item里存的商品名和价格是「快照」,也就是说,下单之后哪怕管理员把商品改名或者改价,订单里的记录不能变。为什么不能直接 JOINproduct表查?因为商品可能被删除或改价,那你打印出来的历史订单就成了「一纸空文」。我见过不少同学在这个问题上偷懒,最后答辩演示时被老师当着面改商品价格,再翻历史订单,数据对不上,直接翻车。
3.2 订单状态流转和库存扣减时机
订单状态是这套系统的灵魂,状态机一定要对应论文里的流程图。电商史上传闻的经典错误就是把「删除订单」做成物理删除DELETE FROM order WHERE...。正确做法是逻辑删除,加一个status = 4(已取消)标记。物理删除订单会连带破坏销售统计的完整性和你的闭环逻辑。而库存扣减的时机,应该发生在「用户下单成功」的瞬间,而不是支付成功——否则会出现超卖:用户 A 和 B 同时下单,都扣了库存,最后只有 B 完成支付,A 取消订单,但库存没有加回来。折中方案就是在取消订单的 Service 方法里同时执行「扣减库存 + 解锁库存」,这两个动作放到同一个事务里。
实际代码里我一般这样写:
@Override @Transactional(rollbackFor = Exception.class) public void cancelOrder(Long orderId) { Order order = orderDao.selectById(orderId); if (order == null || order.getStatus() != 0) { throw new BusinessException("订单不存在或状态不可取消"); } // 事务关键点:先改订单状态,再回补库存 orderDao.updateStatus(orderId, 4); // 4=已取消 for (OrderItem item : orderItemDao.selectByOrderId(orderId)) { productDao.increaseStock(item.getProductId(), item.getQuantity()); } }逻辑说明:为什么我要先把这段代码放出来?因为它是第 5 章事务埋坑的一个引子。你看到@Transactional就会以为安全,但如果这个类里的updateStatus和increaseStock是两个不同表的 DAO 操作,且底层数据源没有开启事务管理,或者代理没生效,这段代码会在第一个 SQL 成功后、第二个 SQL 失败时出现「订单显示已取消,但库存没加回来」的脏数据。所以@Transactional不是万能钥匙,它得配合 spring 的DataSourceTransactionManager一起用,配置方法在第 5 章。
3.3 表结构设计时就要想到的索引和查询边界
商品列表分页、按分类筛选、按销量排序、按价格排序——这四个查询是前台的主流程。普通体量(几千商品)你甚至不需要索引都能跑,但论文里写了「性能优化」章节的话,必须得有点东西。idx_category保证分类过滤走索引,sales_count如果你要按销量排序,建议建一个INDEX idx_sales (sales_count)。真正需要小心的不是这些超简单查询,而是模糊搜索:LIKE '%关键词%'这种写法,MySQL 索引完全失效,全表扫描。如果只是演示,先不加全文搜索,把关键词匹配放到name字段用LIKE CONCAT('%', #{keyword}, '%')就好,并在论文里坦然写出「内网环境、数据量万级以内,全表扫描性能可接受」——这句话非常加分,显得你懂取舍。
4. 核心功能实现:登录拦截、购物车与下单事务、后台管理
骨架和数据库定下来之后,就到了写功能的时候。网上服装销售系统的功能看似多,但只要抓住三条线就不会乱:前台用户下单闭环、后台管理员管货管单、会话与权限贯穿始终。这一章的代码量最大,也最值得你在论文里大书特书。
4.1 登录与拦截:用 Session 还是 Token
这类项目是浏览器直接访问,用 Session 最实在,不需要上 JWT 这种无状态方案。原因:单体应用、服务端渲染或简单 Ajax、没有移动端,Session 天然防伪且可强制过期。你写论文时选型理由这句话一定要写上:「系统为单体架构且无明显跨域需求,选择基于 Session 的认证机制。」
拦截器配置在 SpringMVC 的 XML 里:
<mvc:interceptors> <mvc:interceptor> <!-- 需要登录才能访问的路径 --> <mvc:mapping path="/cart/**"/> <mvc:mapping path="/order/**"/> <mvc:mapping path="/user/**"/> <!-- 后台管理员路径,单独拦截 --> <mvc:mapping path="/admin/**"/> <!-- 放行这些公共资源 --> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/register"/> <mvc:exclude-mapping path="/product/**"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.example.clothing.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>对应的 Java 拦截器:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { // AJAX 请求和页面跳转要区分,否则会拦截跳转失效 String requestedWith = request.getHeader("X-Requested-With"); if ("XMLHttpRequest".equals(requestedWith)) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"请先登录\"}"); } else { response.sendRedirect(request.getContextPath() + "/login"); } return false; } return true; } }逻辑说明:判断是否登录就一句话的事,但前端是 Ajax 还是页面跳转,处理方式完全不同。如果你直接sendRedirect,那么用 Ajax 提交购物车请求时会收到一段 HTML 而不是 JSON,前端解析直接报错,这是集成时最常见的鬼问题。所以我这里用X-Requested-With头区分。参数说明:/admin/**应该单独再配一个 AdminInterceptor 判断 session 里的adminUser是不是 null,并且查一下角色权限。网上服装销售系统一般不搞复杂的 RBAC,但至少要保证普通用户不能访问后台页面——直接在拦截器里判断一下就完事,不用上 Shiro/Spring Security 这种重武器,答辩时如果被问到为什么不用,你就说「系统角色少且边界清晰,拦截器足够,避免过度设计」。
4.2 购物车与下单:事务回滚的正确打开方式
购物车没必要单独建表,存 Session 就行。为什么?访客没有登录也可以加购物车,你没法拿 user_id 绑定数据。把购物车塞到 Session 里,用户关浏览器就没了,这符合线下逛街随手拿、不买就走的心理模型。真正需要建表的是订单。
用户点「提交订单」时,服务端要做四件事:往order表插一条主记录、往order_item表插 N 条明细、扣减商品库存、清空购物车。这四件事只要有一个失败,前边做的必须全部回滚。下面是核心代码:
@Override @Transactional(rollbackFor = Exception.class) public Order submitOrder(Long userId, List<CartItem> cartItems, Address address) { // 1. 先校验一遍库存,前端校验不可信 for (CartItem item : cartItems) { Product p = productDao.selectByIdForUpdate(item.getProductId()); if (p == null || p.getStock() < item.getQuantity()) { throw new BusinessException("商品 [" + p.getName() + "] 库存不足"); } } // 2. 生成订单主记录 Order order = new Order(); order.setOrderNo(generateOrderNo()); // 业务单号 order.setUserId(userId); order.setStatus(0); // ... 设置收货人信息 orderDao.insert(order); // 3. 插入订单项 + 扣库存,一步步来 for (CartItem item : cartItems) { OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setProductId(item.getProductId()); oi.setProductName(item.getProductName()); // 快照 oi.setPrice(item.getPrice()); oi.setQuantity(item.getQuantity()); orderItemDao.insert(oi); int rows = productDao.deductStock(item.getProductId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("商品库存扣减失败,可能库存变化"); } } return order; }逻辑说明:这个实现里有三个关键决策值得你在论文里展开写。一是selectByIdForUpdate,这是行级锁,防止两个用户同时下单同一件商品时出现超卖。普通selectById不加锁的话,在高并发下会丢失更新。二是deductStock的 SQL 必须写成「条件更新」,而不是「先查库存再 update」。正确 SQL 是UPDATE product SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},这样即使不用锁也不会扣成负数,这条 SQL 我放在下面的参数说明里。三是order_no一定要用业务单号,别直接用主键 ID 暴露给用户。生成方式最省事的是yyyyMMddHHmmss + 4位随机数,但你论文里写「使用 Redis 自增单号」而实际没用,答辩会被拆穿,不如老实写「采用时间戳拼接随机数」。
参数说明:deductStock的 Mapper 如下,注意更新行数rows的返回值是这次扣减是否生效的信号。如果库存不够,WHERE 条件不满足,受影响行数是 0,Java 层拿到 0 就直接抛异常触发回滚。这种写法比「先 SELECT 判断够不够、再 UPDATE」少了一次交互,也避免了两步之间的并发窗口。核心是让数据库代替你判断库存条件,这是电商订单编程里最基础的一个风格选择。
-- 条件更新:只有库存充足时才扣减,并将受影响行数作为判断依据 UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND status = 1 AND stock >= #{quantity}4.3 后台管理:商品 CRUD 与订单处理
后台管理不复杂,但有两个地方和前台逻辑不同。一是商品图片上传,二是订单发货操作的状态校验。
商品新增和修改时图片的处理,我建议「上传到本地目录 + 数据库存相对路径」。本地目录指向webapp/static/images/product/,上传成功后在image_url里存/static/images/product/xx.jpg。这样在 JSP 页面里直接<img src="${product.imageUrl}">就能展示。如果你存了D:\upload\xx.jpg这种绝对路径,部署到别的机器上必碎。至于上传控件,用 CommonsMultipartResolver 配置一下:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="defaultEncoding" value="UTF-8"/> <!-- 单文件最大 5MB --> <property name="maxUploadSize" value="5242880"/> <!-- 内存缓冲阈值,超过则写临时文件 --> <property name="maxInMemorySize" value="1048576"/> </bean>逻辑说明:maxUploadSize5MB 对商品图来说足够,你不可能在论文系统里传高清原图。maxInMemorySize1MB 表示小于 1MB 的文件直接存内存,大于 1MB 再写临时文件——这对服务器 IO 是种保护。但要注意,image_url你存相对路径,JSP 在${pageContext.request.contextPath}下拼接时才能访问到。
订单处理的后台操作很简单:管理员看到待发货订单,点「发货」,就把状态从 1 改成 2。这里必须做状态校验:如果用户在前台取消了订单,管理员这边已经打开的页面没法实时感知,这时候你把一个已取消的订单点成「已发货」,就是业务事故。代码里要做的就是UPDATE order SET status = 2 WHERE id = #{id} AND status = 1,受影响行数为 0 就提示「订单状态已变化,请刷新」,而不是死板地updateById。这是一个很小的细节,但很多论文系统就是倒在这一步——没有条件更新,写完就完,一演示就出脏数据。
5. 部署、验证与避坑:先把环境跑通,再谈答辩
代码写完成不等于能演示,本地能跑也不等于换台电脑还能跑。网上服装销售系统的深度坑大多集中在这几个位置:Maven 依赖冲突、数据库版本和连接串、静态资源 404、以及事务不生效的玄学。我把常见的坑都摊开讲清楚,每一条都是血泪经验换来的。
5.1 本地部署跑通的最小命令序列
先确认三件套环境:JDK 8、Maven 3.6.x、MySQL 5.7 或 8.0 都行。然后把项目拉下来,以命令行为准:
# 1. 初始化数据库:把建表 SQL 灌进 MySQL mysql -uroot -p123456 < clothing_sales.sql # 2. 修改 jdbc.properties 里的连接信息 # jdbc.url=jdbc:mysql://localhost:3306/clothing_sales?serverTimezone=Asia/Shanghai&useSSL=false&characterEncoding=utf8mb4 # 3. 编译打包 mvn clean package -DskipTests # 4. 部署到 Tomcat 的 webapps 目录,或直接在 IDE 里配置 Tomcat 跑 # 启动 Tomcat $CATALINA_HOME/bin/startup.sh逻辑说明:第 1 步建库时务必看清 SQL 文件是不是 UTF-8 编码,Windows 下用记事本编辑过的 SQL 文件经常是 GBK,导致中文乱码。第 2 步的serverTimezone=Asia/Shanghai是 MySQL 8.0 的强制要求,少这个参数 JDBC 直接报时区错误。第 3 步-DskipTests跳过测试避免测试类拖慢打包。第 4 步如果打的是 war 包,直接丢到webapps下,Tomcat 自动解压。注意 Tomcat 的目录名问题:如果你的系统要求访问路径是/clothing,工程里 artifactId 就要叫clothing,或者把 war 包改名。
5.2 避坑记录一:PageHelper 分页失效或查出全表
现象:分页插件配好了,首页列表却把所有数据一次性查出来,而且控制台打印的 SQL 没有LIMIT。
原因:八成是 PageHelper 的版本和 MyBatis 版本不兼容,或者插件配置的helperDialect没设。另一个高频原因是你在 pageHelper.startPage() 之后又做了一次非查询操作,分页线程变量被吞掉。
解决:统一用PageHelper.startPage(pageNum, pageSize)接着的第一个查询必须是你要分页的查询,中间不能有别的 SQL。配置上确认 dialect 写成mysql。如果还不行,直接 debug 看 MyBatis 的 Interceptor 有没有被注册到 SqlSessionFactory 上——Spring 里这一步极容易漏,@Bean没有生效,代码跑了但插件是哑的。
5.3 避坑记录二:事务不生效,日志里也没报错
现象:下单成功后故意让扣库存的 SQL 报错,但订单还是插入成功了,或者反过来库存没回补。
原因:最常见的是@Transactional加在了「类内部的同类调用」方法上。Spring 的声明式事务是基于代理的,类内部this.submitOrder()调cancelOrder()不会走代理,所以事务控制压根没生效。另一个原因是 Service 类没有被 Spring 管理,比如忘了加@Service;还有一个原因是自测时打开了多个数据源但没有配置对事务管理器。
解决:第一,事务方法不要同类内部调用,要用注入的自身代理或是拆到另一个类里。第二,检查spring-context.xml里到底有没有<tx:annotation-driven transaction-manager="transactionManager"/>,只配了DataSourceTransactionManager是不够的,这个开关不开,注解等于没写。第三,@Transactional(rollbackFor = Exception.class)必须显式声明,默认只回滚 RuntimeException。论文里把这个坑写进「系统测试与异常处理」章节,会显得你非常有工程意识。
5.4 避坑记录三:图片上传后刷新页面就 404
现象:后台成功上传了商品图,数据库里路径也是对的,但<img src="/static/images/product/xx.jpg">打不开,控制台报 404。
原因:很可能是你用了 Spring MVC 的addResourceHandlers但路径映射没配对。如果你把图片写在webapp/static/images/下面,那 Tomcat 默认静态资源处理器就能找到;如果你写到磁盘比如D:/uploads/,就必须显式做资源映射。另一个常见原因是你存到数据库里的路径带了${pageContext.request.contextPath}前缀字符串,导致拼接后变成/clothing/static/...再叠一层上下文,路径重复。
解决:统一规则。数据库image_url存/static/images/product/20250101.jpg。页面取值时统一加<c:url value="${product.imageUrl}"/>让 JSP 自动带上上下文路径。如果图片存磁盘,SpringMVC 配置里加一句:
<mvc:resources mapping="/static/images/**" location="file:D:/uploads/"/>location的末尾斜杠必须写,漏了这个斜杠 Spring 会拼接出错误的文件路径,这是配置系的经典翻车点。
5.5 避坑记录四:MySQL 8.0 驱动和旧版方言不兼容
现象:MyBatis 里写LIMIT #{start}, #{rows}或者用SELECT LAST_INSERT_ID(),在 MySQL 8.0 上忽然不好使,或者主键回填拿到的是 0。
原因:MySQL 8.0 的驱动从com.mysql.jdbc.Driver换成了com.mysql.cj.jdbc.Driver,老驱动在 8.0 上直接连不上;同时allowMultiQueries默认关闭,某些批量操作会静默失败。主键回填拿 0 的原因,八成是实体类的主键字段没有标@GeneratedValue或者 MyBatis 里没用useGeneratedKeys="true" keyProperty="id"。
解决:驱动类写com.mysql.cj.jdbc.Driver,URL 加useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true。insert 语句加上useGeneratedKeys="true" keyProperty="id",这样orderDao.insert(order)执行完后order.getId()才有值,后面插入订单项才能拿到order_id。这个问题隐蔽就隐蔽在它不报错,只是 ID 一直是 0,直到你发现订单项全部成了孤儿数据。
6. 再进一步:给系统加一个接口自测脚本,答辩前不再手点页面
到这一步,你的系统已经能跑通了,但离「放心走上答辩台」还差一口气。这一章我给你一个顺手就能做的小改进:写一个基于 Spring 的接口自测脚本,把注册、登录、加购、下单、后台发货这五步串成一条链路自动跑一遍。为什么要做这个?因为现场演示最怕的就是临时手点页面,手一抖点错一个按钮,全程被打断。自动脚本跑一遍,三秒给出结论,把「演示」变成「验证」,这是答辩观感上一个非常明显的提升。
思路是这样的:用 JUnit + Spring 的MockMvc,不需要起 Tomcat,直接在测试环境里模拟 HTTP 请求,打到你自己的 Controller 上。核心代码:
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(locations = {"classpath:spring/spring-mvc.xml"}) @WebAppConfiguration public class ApiSmokeTest { private MockMvc mvc; @Before public void setup() { mvc = MockMvcBuilders.webAppContextSetup(context).build(); } @Test public void fullOrderFlow() throws Exception { // 1. 注册一个新用户 mvc.perform(post("/register") .param("username", "tester01") .param("password", "123456")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)); // 2. 登录,拿到 Session MvcResult login = mvc.perform(post("/login") .param("username", "tester01") .param("password", "123456")) .andExpect(status().isOk()) .andReturn(); // 3. 模拟会话:拿登录后的 Cookie 继续后续请求 MockHttpSession session = (MockHttpSession) login.getRequest().getSession(); // 4. 加购 mvc.perform(post("/cart/add").session(session) .param("productId", "1") .param("quantity", "2")) .andExpect(jsonPath("$.code").value(200)); // 5. 提交订单 mvc.perform(post("/order/submit").session(session) .param("receiverName", "张三") .param("receiverPhone", "13800000000") .param("receiverAddr", "某市某区某路1号")) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.orderNo").isNotEmpty()); // 6. 模拟管理员发货 mvc.perform(post("/admin/order/deliver").session(session) .param("orderId", "1")) .andExpect(jsonPath("$.code").value(200)); } }逻辑说明:MockMvc是 Spring 自带的测试工具,它不需要真的启动 Tomcat,就把 DispatcherServlet 拉起来跑请求。核心技巧是这里用MockHttpSession把「用户身份」传递到后续每一步请求里,真实地模拟了一次浏览器会话。千万别小看这个测试类,它直接验证了「注册→登录→加购→下单→发货」整条链路没断。答辩前在 IDE 里右键运行它,绿色条一亮,比你打开浏览器一顿操作有说服力得多。被问到「系统怎么测试的」时,你可以很自然地回答:「除了功能测试,我写了核心链路冒烟测试」,然后演示这个类。这一步加分的分量相当可观。
参数说明:jsonPath("$.code")是 JSON 路径表达式,$代表根节点,.code取的是 Result 里的 code 字段。你断言的严格程度可以自己调:只断言code == 200算冒烟级别,断言data.orderNo非空算功能级别,再往下可以断言数据库里的订单项数量。写论文时,把「系统测试」那一章的内容从「我点了点页面没问题」升级成「核心路径自动化冒烟测试通过」,软实力直接上一个台阶。最后说一句我的个人习惯:每次改完 Service 层代码,我第一件事不是开浏览器,而是跑一遍这个全链路测试类,绿了再往下写。这套习惯帮我挡掉了不少答辩前的低级翻车,希望帮到你。
本文还有配套的精品资源,点击获取