简介:这是一份基于SSM框架实现的电脑配件销售系统完整毕业设计项目,面向计算机相关专业学生或有商城类系统开发需求的读者,覆盖管理员与普通用户双端核心流程,既适合课程设计,也适合毕业设计答辩展示。后端采用Spring、SpringMVC、MyBatis,前端以JSP、CSS、JavaScript实现,配合Maven构建与MySQL数据库,源码可在Eclipse或IDEA中直接运行;压缩包共1344个文件、约22.21MB,包含Java源码、JSP页面、SQL脚本及大量JS、CSS、图片等静态资源,目录结构清晰,便于定位功能模块。系统后台具备登录、用户管理、商品分类与信息管理、上下架、库存调整、订单管理、轮播图管理等功能,前台支持注册登录、商品推荐、收藏、购物车、立即购买、评论、余额与积分充值、支付等真实商城业务场景。已有133人学习下载,配套完整源码和数据库脚本,适合需要快速搭建可运行毕设项目或研究SSM整合流程的读者。
1. 为什么一个SSM的电脑配件商城,到现在还有人在做毕设和练手
每年求职季的技术社群里,总能看到同一类问题:简历上写着“基于SSM框架的电脑配件销售商城”,面试官追问几句就卡壳。SSM 这个词从来不算冷门,Spring、SpringMVC、MyBatis 三者怎么分工,B/S 架构下数据从浏览器到 MySQL 再回来要过几道门,很多人其实没有完整跑通过一次源码。这个选题在毕业设计里确实已经“烂大街”,但越是烂大街的组合越能检验基本功——它把 Maven 依赖管理、三层架构、ORM 映射、声明式事务、Session 购物车、订单减库存这些 Java Web 绕不开的硬知识点串在了一起。如果你正在准备毕业设计答辩,或者想找一份带数据库的完整源码做练手项目,这篇就按从架构讲起,一路落到建表 SQL、运行步骤和最容易翻车的五个坑。
2. B/S架构下的SSM拆分:Spring、SpringMVC、MyBatis各自守着哪一层
2.1 三层架构与SSM的对应关系:谁在管请求、谁在管事务、谁在管SQL
B/S 架构里,浏览器是唯一的客户端,用户访问商城只需要输入网址,不需要安装任何桌面程序。请求从浏览器发出后,第一个接收者是 Tomcat,Tomcat 把请求交给 SpringMVC 的前端控制器 DispatcherServlet。DispatcherServlet 做的事情是“路由”:通过 HandlerMapping 找到处理这个 URL 的 Controller 方法并调用,再把返回值交给视图解析器渲染成 JSP 页面返回给浏览器。整个过程对用户来说就是打开页面、点按钮,数据交换都基于 HTTP。
Controller 这一层是表现层,它只负责接收参数、调用业务方法、封装返回结果,不直接操作数据库。真正管对象的是 Spring 容器,Service 层的实现类被 Spring 以单例 Bean 的方式管理,依赖注入让 Controller 不用自己 new Service,事务边界也由 Spring 的 @Transactional 注解控制。最底层是 MyBatis,它把 Java 接口方法映射成 SQL 语句,负责和 MySQL 打交道。这三者的职责边界可以简单记成:SpringMVC 管“请求怎么进来怎么出去”,Spring 管“业务对象生命周期和事务”,MyBatis 管“SQL 怎么写怎么执行”。
一个典型的商品查询在 SSM 里长这样,Controller、Service、Mapper 各占一层:
@Controller @RequestMapping("/parts") public class PartsController { @Autowired private PartsService partsService; @RequestMapping("/list") public String list(Integer categoryId, Model model) { List<Parts> list = partsService.findByCategory(categoryId); model.addAttribute("partsList", list); return "parts/list"; } }@Service @Transactional public class PartsServiceImpl implements PartsService { @Autowired private PartsMapper partsMapper; public List<Parts> findByCategory(Integer categoryId) { return partsMapper.selectByCategory(categoryId); } }public interface PartsMapper { List<Parts> selectByCategory(Integer categoryId); }逻辑说明:URL/parts/list带着参数categoryId进来,DispatcherServlet 根据@RequestMapping找到list方法,@Autowired注入的partsService是 Spring 在启动时就创建好的单例,方法内部一行代码把查询交给了 Mapper 接口。@Transactional加在 Service 实现类上,表示这个类里的方法都处于事务管理范围,不过只读的查询操作本身不会产生多少事务开销,真正需要事务的是后面讲的订单提交逻辑。
参数说明:@Controller注解告诉 Spring 这是一个接收 Web 请求的 Bean;类上的@RequestMapping("/parts")是 URL 前缀,方法上的@RequestMapping("/list")拼接成完整路径/parts/list;Model对象等同于 request 作用域,放入的partsList在 JSP 里可以直接用${partsList}取。Service 接口和实现类分开写,是为了后面做单元测试和 AOP 切面时能替换实现,新手图省事把 Service 直接写成类也能跑,但面试官通常不接受这种写法。
SSM 的配置会拆成三份文件:spring-mvc.xml 只扫描 Controller 层,applicationContext.xml 扫描 Service、Mapper 等业务 Bean,mybatis-config.xml 配置 MyBatis 本体的行为。这样拆分的原因是 DispatcherServlet 只会为 Controller 层提供 Web 特性(比如视图解析、JSON 转换),如果让它连带扫描 Service 包,会导致 Bean 重复实例化,最直接的症状就是 @Transactional 注解失效。新手最常见的错误就是把所有包都交给 spring-mvc.xml 扫描,结果事务怎么配都不生效,这个问题在第 5 章还要细说。
2.2 电脑配件商城的核心业务流:从商品列表到订单减库存
商城真正的核心不是展示商品,而是下单那一刻的“库存扣减+订单生成”。用户浏览配件、加入购物车、结算提交订单,整个过程可以拆成四个阶段:选品、存购物车、提交订单、支付(毕设里的支付通常用模拟按钮替代)。在 SSM 实现里,购物车有两种存法:存在 Session 里,或者存在数据库购物车表中。毕设项目普遍选 Session,因为不用每次操作都读写数据库,用户未登录也能临时加购;生产环境选数据库或 Redis 的更多,因为 Session 在集群环境下没法共享。
提交订单这段代码是整个项目里事务价值最大的地方,也是面试官最爱追着问的一段。我一般会用一个独立的 createOrder 方法把主表插入、明细插入、库存扣减串起来:
@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper orderItemMapper; @Autowired private PartsMapper partsMapper; @Transactional(rollbackFor = Exception.class) public Long createOrder(Integer userId, List<CartItem> cartItems) { Order order = new Order(); order.setUserId(userId); order.setOrderNo(generateOrderNo()); order.setStatus(0); orderMapper.insert(order); Long orderId = order.getId(); int totalAmount = 0; for (CartItem item : cartItems) { Parts parts = partsMapper.selectById(item.getPartsId()); if (parts == null || parts.getStock() < item.getNum()) { throw new RuntimeException("配件库存不足:" + item.getPartsId()); } int rows = partsMapper.reduceStock(item.getPartsId(), item.getNum()); if (rows == 0) { throw new RuntimeException("配件库存扣减失败:" + item.getPartsId()); } OrderItem oi = new OrderItem(); oi.setOrderId(orderId); oi.setPartsId(parts.getId()); oi.setPrice(parts.getPrice()); oi.setNum(item.getNum()); orderItemMapper.insert(oi); totalAmount += parts.getPrice() * item.getNum(); } order.setTotalAmount(totalAmount); orderMapper.update(order); return orderId; } private String generateOrderNo() { return System.currentTimeMillis() + "" + (int) (Math.random() * 900 + 100); } }这段代码的核心在于@Transactional(rollbackFor = Exception.class)。默认情况下 Spring 事务只对 RuntimeException 回滚,加上rollbackFor = Exception.class表示任何异常都触发回滚。有了这一句,循环里任何一步抛异常,之前插入的订单主表、明细表和扣减掉的库存全部回滚,不会出现“库存扣了订单没生成”的脏数据。代码里还有一个关键细节:reduceStock在 Mapper XML 里的 SQL 用了“带条件更新”,也就是UPDATE parts SET stock = stock - #{num} WHERE id = #{id} AND stock >= #{num},它不是先查库存再更新,而是让数据库在原子操作里判断库存是否足够,这样并发请求不会重复下单。数据量小的课设里这条 SQL 就够了,生产环境还要引入 Redis 预扣库存等措施。
代码里另一个容易被问到的点是事务边界。整个 createOrder 方法是一个事务,扣库存和插入订单在同一个事务里,要么全成要么全败。但要注意,事务和锁不是一回事——行锁在 UPDATE 执行时获得,在事务提交或回滚时释放;如果事务不提交,锁就不会释放。所以事务方法里千万别做耗时操作(比如调模拟支付接口的 Thread.sleep),否则并发下其他用户扣库存会一直阻塞等锁,压测时表现就是接口卡死、连接池耗尽。这个我在第 5 章会作为踩坑案例再说一次。
3. 把数据库表设计出来:配件分类、库存与订单的ER关系落地
3.1 六张核心表的字段设计与类型选择
电脑配件销售商城按最常见的需求拆成六张核心表:用户表、配件分类表、配件表、购物车表、订单主表、订单明细表。先看建表 SQL,再看字段为什么这么定。为了少敲命令,这里直接把外键、唯一键和索引都一并建好:
CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(64) NOT NULL COMMENT '建议存MD5或加盐哈希', `phone` VARCHAR(20) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `category` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `parts` ( `id` INT NOT NULL AUTO_INCREMENT, `category_id` INT NOT NULL, `name` VARCHAR(100) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `description` TEXT, `image_url` VARCHAR(200) DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), CONSTRAINT `fk_parts_category` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `cart` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `parts_id` INT NOT NULL, `num` INT NOT NULL DEFAULT 1, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_parts` (`user_id`, `parts_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `id` INT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `user_id` INT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` INT NOT NULL AUTO_INCREMENT, `order_id` INT NOT NULL, `parts_id` INT NOT NULL, `price` DECIMAL(10,2) NOT NULL COMMENT '下单时的成交价快照', `num` INT NOT NULL, PRIMARY KEY (`id`), KEY `idx_order` (`order_id`), CONSTRAINT `fk_item_order` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段类型这部分有四个细节容易被忽略。第一,价格必须用 DECIMAL(10,2),不能用 float 或 double,因为二进制浮点数存 10.23 这种值会出精度误差,结算金额对不上账;用 DECIMAL 虽然占用空间稍大,但商城项目这点成本可以忽略。第二,密码字段长度给 64 而不是 32,是为了兼容加盐之后的 SHA-256 哈希值,很多毕设代码里明文存密码,答辩时被问到就直接露怯。第三,order_item 里存了 price 快照——配件改价之后历史订单不能跟着变,这是关系型数据库设计里“冗余字段”的典型场景,面试官问为什么要冗余时能答上来就是加分项。第四,orders 表的 order_no 建了唯一索引,业务上靠它兜底保证订单号不重复,光靠代码里的随机数不保险。
3.2 订单号生成与库存扣减:用事务还是用锁
订单号生成在第 2 章给的临时方案是“时间戳+随机数”,放在课设里够用,但有两个隐患:一是同一毫秒并发下单可能撞号,二是数据库一旦有重复值,插入直接报错。更可靠的替代是把数量来源交给数据库,比如利用自增 id 拼接时间前缀,或者用 sequence。生产级方向是雪花算法,SSM 课设阶段不强制,但你要在论文里写清楚“为什么选时间戳方案,改进方向是什么”。
库存扣减是并发问题的重灾区。网上很多教程写“先 SELECT 查库存,再 UPDATE 减库存”,这是错的。假设库存只剩 1 件,两个用户同时下单都查到 1,都执行减库存,超卖就发生了。正确做法是让扣减语句带上库存条件,也就是第 2 章 createOrder 里的 reduceStock:
UPDATE parts SET stock = stock - #{num} WHERE id = #{id} AND stock >= #{num}这条 UPDATE 在 MySQL 的 InnoDB 引擎下会对命中行加行锁,两个并发请求同时执行时,后执行的那个会因为 stock >= #{num} 条件不满足而影响 0 行。MyBatis 里返回的 int rows 就是受影响行数,Service 层判断 rows == 0 就知道库存不足并抛异常回滚。这个方案不需要显式写 SELECT ... FOR UPDATE 的悲观锁,性能比锁表好,是电商库存扣减里最常用的一种条件更新(乐观锁)写法。
这里还要澄清一个概念:事务和锁不是一回事。事务保证的是“要么全做要么全不做”,锁保证的是“同一时刻只有一个请求能改这行数据”。上面的 UPDATE 语句本身带了行锁,所以即使在 autocommit 模式下执行,两个请求也不会同时把库存减成负数;但如果你要把“扣库存+写订单”放在一个事务里,就必须在 Service 方法上加 @Transactional,让 UPDATE 和 INSERT 在同一个数据库会话里提交,否则库存减了但订单没写成,数据就错了。
4. 跑通一个最小SSM商城的步骤:从Maven骨架到第一次CRUD
4.1 项目结构搭建与pom.xml关键依赖
先建一个标准的 Maven Web 项目,目录结构不能错。SSM 项目新手最容易在结构上翻车,约定的布局如下:
src/main/java/com/example/mall ├── controller # SpringMVC控制器 ├── service # 业务接口 │ └── impl # 业务实现类 ├── mapper # MyBatis Mapper接口 └── pojo # 实体类 src/main/resources ├── spring-mvc.xml ├── applicationContext.xml ├── mybatis-config.xml └── mapper/*.xml # SQL映射文件 src/main/webapp ├── WEB-INF/web.xml └── WEB-INF/views/*.jsppom.xml 的依赖按作用域分四组,缺一不可。Spring 5.2 配 JDK 8,MyBatis 3.5 是当前主流稳定线,MySQL 驱动版本要和本地数据库对号入座:
<properties> <spring.version>5.2.x.RELEASE</spring.version> <mybatis.version>3.5.x</mybatis.version> </properties> <dependencies> <!-- Spring核心:webmvc里包含了spring-context和spring-web --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <!-- @Transactional依赖spring-jdbc提供事务管理器 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <!-- MyBatis与Spring集成 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.x</version> </dependency> <!-- MySQL驱动与连接池 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.x</version> <scope>runtime</scope> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.x</version> </dependency> <!-- JSP / JSTL、Servlet依赖 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> </dependencies>版本段位别乱配:Spring 5.2 对应 JDK 8,Spring 6 强制要求 JDK 17,如果电脑装的是 JDK 8 而依赖却拉到了 Spring 6,启动时会直接报 UnsupportedClassVersionError。mybatis-spring 2.0.x 配 MyBatis 3.5.x 是经过大量项目验证的组合,不建议为了求新随意换大版本。
依赖里容易踩的坑是漏掉 spring-jdbc。不少教程只引 spring-webmvc,结果 @Transactional 对应的 DataSourceTransactionManager 找不到类,启动就报 ClassNotFoundException。mybatis-spring 这个桥接包负责把 MyBatis 的 SqlSessionFactory 交给 Spring 容器管理,没有它 Mapper 接口无法被自动扫描注入,你会看到所有 Mapper 注入的地方都报空指针。
4.2 用MyBatis写第一个配件商品查询:Mapper接口与XML映射
数据库表建好后,第一步先跑通“按分类查配件列表”这个闭环。实体类对应 parts 表,字段一一映射。注意用 BigDecimal 对应 DECIMAL 字段,用 Integer 对应 INT 字段,别混用:
public class Parts { private Integer id; private Integer categoryId; private String name; private BigDecimal price; private Integer stock; private String description; private String imageUrl; private Integer status; private Date createTime; // getter/setter 省略 }Mapper 接口和 XML 映射文件要保证 namespace 完全对应。接口放在 com.example.mall.mapper 下:
public interface PartsMapper { List<Parts> selectByCategory(@Param("categoryId") Integer categoryId); int reduceStock(@Param("id") Integer id, @Param("num") Integer num); }XML 放到 src/main/resources/mapper/PartsMapper.xml,applicationContext.xml 里用 mapperLocations 指向这个目录:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mall.mapper.PartsMapper"> <resultMap id="BaseResultMap" type="com.example.mall.pojo.Parts"> <id column="id" property="id" jdbcType="INTEGER"/> <result column="category_id" property="categoryId" jdbcType="INTEGER"/> <result column="name" property="name" jdbcType="VARCHAR"/> <result column="price" property="price" jdbcType="DECIMAL"/> <result column="stock" property="stock" jdbcType="INTEGER"/> <result column="description" property="description" jdbcType="VARCHAR"/> <result column="image_url" property="imageUrl" jdbcType="VARCHAR"/> <result column="status" property="status" jdbcType="TINYINT"/> <result column="create_time" property="createTime" jdbcType="TIMESTAMP"/> </resultMap> <select id="selectByCategory" resultMap="BaseResultMap"> SELECT id, category_id, name, price, stock, image_url FROM parts WHERE category_id = #{categoryId} AND status = 1 ORDER BY id DESC </select> <update id="reduceStock"> UPDATE parts SET stock = stock - #{num} WHERE id = #{id} <![CDATA[ AND stock >= #{num} ]]> </update> </mapper>逻辑说明:selectByCategory 里的 #{categoryId} 是预编译占位符,MyBatis 会转成 ? 再绑定参数,能防 SQL 注入,千万别在 XML 里写 ${categoryId} 这种字符串拼接。resultMap 的作用是把下划线列名和驼峰属性名做显式映射,Java 里属性叫 categoryId,表里列叫 category_id,不写映射就查出来全是 null——这是 MyBatis 新手最常见的“对象怎么是空”翻车原因。reduceStock 里的 CDATA 块是为了让 XML 解析器不把 <= 当成标签符号,这也是第 3 章说的并发扣库存的落地写法。
参数说明:@Param("categoryId") 让 XML 里能用 #{categoryId} 引用参数;如果不加注解,多参数的 XML 里只能用 #{param1}、#{param2} 这种位置引用,可读性差还容易写错。mybatis-config.xml 里还可以打开驼峰映射开关 mapUnderscoreToCamelCase=true,这样大部分简单字段就不用写 resultMap 了,但聚合查询和特型字段还是要手写映射。
4.3 前端页面与后端联调:B/S架构下session和购物车怎么存
后端查询通了,接下来把 JSP 页面挂上去。SSM 课设的页面一般是 JSP + JSTL 服务端渲染,浏览器发请求带上 JSESSIONID Cookie 维持会话。购物车存在 Session 里是最省事的方案,实现就两步:
@RequestMapping("/cart/add") public String addToCart(Integer partsId, Integer num, HttpSession session) { Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); session.setAttribute("cart", cart); } cart.put(partsId, cart.getOrDefault(partsId, 0) + num); return "redirect:/parts/list"; }<c:forEach items="${cart}" var="entry"> <tr> <td>${entry.key}</td> <td>${entry.value}</td> </tr> </c:forEach>这段 Controller 的逻辑是:Session 里放一个 Map<partsId, num>,用户每次点“加入购物车”就往 Map 里累加数量,购物车页面直接遍历 Map 渲染。优点是省 SQL、不建表,浏览器关闭后 Session 销毁,购物车自动清空,演示完即走;缺点是换浏览器或清 Cookie 就丢数据,生产环境必须把购物车挪到 Redis 或数据库。
联调时有个常见问题:页面上想显示配件名称而不是数字 ID,有些毕设代码会在 JSP 里直接调 Service,比如${partsService.getById(entry.key).name}。这种写法的危害是 JSP 本质是视图,直接依赖业务层会绕开 Controller 的参数校验和权限控制,而且 Service 未必在页面上下文里可用。正确做法是让 Controller 根据 Map 查出配件列表放进 Model,JSP 只负责展示。遇到页面上取不到值,先看 Model 里有没有数据,再看 EL 表达式拼写是否和属性名一致,九成空白页都是这两个原因。
提示:把 mybatis 和 spring 的日志级别调到 DEBUG,第一次联调就能在控制台看到执行的 SQL、参数和返回行数,比自己猜“为什么没数据”高效得多。
5. SSM商城避坑清单:五个让新手翻车的经典问题
SSM 商城跑通不难,但把项目从“能跑”变成“经得起问”要过不少坎。以下五条按出现频率排序,每一条都是运行日志里最常见的报错场景。排查前先做一件事:把 org.mybatis、org.springframework.jdbc、com.alibaba.druid 三个包的日志级别调到 DEBUG,让 SQL 和连接池的每一步都打印出来,很多问题一眼就能定位。
5.1 现象:Controller跳转404,但浏览器直接访问接口有数据
原因:SpringMVC 的 web.xml 里 DispatcherServlet 的 url-pattern 配成了/,静态资源、JSP 页面都被拦截,导致视图跳转后找不到页面。更隐蔽的是 InternalResourceViewResolver 的前缀后缀配错,JSP 明明在 /WEB-INF/views/ 下,前缀却写成 /WEB-INF/,解析出来的路径对不上。
解决:确认 url-pattern 是/还是*.do,配成/时必须在 spring-mvc.xml 里加<mvc:default-servlet-handler/>或<mvc:resources>放行静态资源;再看视图解析器配置:
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>一个排查技巧:浏览器直接访问/parts/list能出数据,说明 Controller 没错;跳转 404 几乎都是视图解析路径的问题。把前缀改成 /WEB-INF/views/ 后重启,九成能好。
5.2 现象:表单提交的中文到Controller变成问号,写进数据库再变成乱码
原因:CharacterEncodingFilter 过滤器没配置,或配了但 forceEncoding 没设 true。Tomcat 8+ 对 GET 请求的 URI 默认用 UTF-8 解码,但 POST 请求体的编码取决于请求头 Content-Type 和页面声明的字符集,前端页面是 UTF-8、后端按 ISO-8859-1 解码就会乱码。
解决:在 web.xml 里加字符编码过滤器,放在所有过滤器最前面:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>同时检查数据库连接 URL 里带了 characterEncoding=utf8,三处统一成 UTF-8 后乱码才不会反复。注意 JSP 页面头部的 pageEncoding 也要写 UTF-8,漏一个环节就会出现“数据库里是好的,页面显示乱码”的怪象。
5.3 现象:项目启动后第一次访问正常,隔几分钟再访问报 Communications link failure
原因:MySQL 默认 wait_timeout 是 8 小时,但开发机上的 MySQL 或云数据库会把空闲太久的连接断掉,Druid 连接池不知道,继续把旧连接发给业务,拿到连接执行 SQL 时就抛异常。表现是项目跑一会儿再访问就报错,重启 Tomcat 又好转,典型的连接池“幽灵连接”。
解决:在 Druid 连接池配置里打开探活:
<property name="testWhileIdle" value="true"/> <property name="validationQuery" value="SELECT 1"/> <property name="timeBetweenEvictionRunsMillis" value="60000"/>testWhileIdle 让连接池定期用 SELECT 1 验证空闲连接,无效就直接丢弃并新建;timeBetweenEvictionRunsMillis 控制在 60 秒一次,资源开销很小。如果项目里还有定时任务,给 Druid 的 minEvictableIdleTimeMillis 也要设一个小于 MySQL wait_timeout 的值。
5.4 现象:MyBatis 的 XML 里写 stock >= #{num} 直接报错,提示语法错误
原因:XML 中<是保留字符,解析器把<当成标签开始符号,SQL 里一旦出现小于号、小于等于号必炸。没人帮你转义,IDEA 里的 XML 校验会直接划红线。
解决:把<转义为<,或者用 CDATA 包住整个条件。我在 4.2 节的 reduceStock 里已经写了标准写法,这里单独强调一次:
<update id="reduceStock"> UPDATE parts SET stock = stock - #{num} WHERE id = #{id} <![CDATA[ AND stock >= #{num} ]]> </update>这条 SQL 就是并发扣库存的正式落地,reduceStock 返回的 int 在 Service 里判断等于 0 就是库存不足。CDATA 里的内容 XML 解析器不检查特殊字符,但要注意 CDATA 里不能再用<![CDATA[嵌套。
5.5 现象:购物车在本地测试没问题,一部署到服务器上刷新就丢
原因:Session 的存取依赖 Cookie。部署后访问 IP 或域名变了,浏览器本地的 JSESSIONID Cookie 没跟着更新;或者项目没配置 Session 超时时间,默认 30 分钟没操作就过期。另一个隐蔽原因是多节点部署时负载均衡把请求分到不同 Tomcat,Session 不共享。
解决:单机部署先在 web.xml 里调 Session 超时:
<session-config> <session-timeout>120</session-timeout> </session-config>如果项目已经上了多节点,就不要想着靠 Session 了,方向是“Session 外置”——把购物车数据从 Session 挪到 Redis,用同一个 Redis 实例做共享存储。第 6 章会简单说这个迁移怎么做。毕设如果演示环境是单机 Tomcat,Session 方案完全够用,但要在论文里写明局限。
这五条是从本地复现 SSM 商城时踩过的坑按优先级排出来的,从页面打不开到并发扣库存,每一条都对应真实的运行日志报错。把这些现象、原因、解决办法记熟,比多写一百行 CRUD 代码更能撑住答辩场面。
6. 拿这套商城做验收:三个验证手段和一个可落地的扩展方向
6.1 三个验证手段:从SQL日志到并发压测
项目跑通后别急着写论文,先用三个手段验证它真的“站得住”。第一个是 SQL 日志验证:把日志级别调到 DEBUG 后,连续操作一个完整流程——浏览配件、加购物车、提交订单、取消订单,观察每次操作对应的 SQL 是否按预期执行,尤其盯住 createOrder 方法里的 INSERT、UPDATE 是不是在同一个事务里提交,日志里能看到明显的 BEGIN 和 COMMIT。
第二个是接口验证:用 Postman 把/parts/list、/cart/add、/order/create三个接口按顺序走一遍,检查返回的状态码和重定向地址。B/S 架构下很多接口返回的是 JSP 视图名,走 Postman 看到的是 HTML 片段,不要慌,重点看 HTTP 状态是 200 还是 500。如果有 500,回服务器看日志堆栈,把第 5 章的对应条目翻出来对号入座。
第三个是简单的并发压测:本地用 JMeter 开 50 个线程同时请求/order/create,模拟两个用户抢最后一件库存的场景。跑完去数据库查 parts 表的 stock 和 orders 表记录数,如果 stock 不为负数且订单数和实际扣减库存对得上,说明 reduceStock 的条件更新生效了。这一步是答辩时最亮的加分点——几乎没人会做并发验证。
6.2 一个值得投入的扩展方向:把Session购物车换成Redis
如果还想往上走一步,最有性价比的扩展是把 Session 购物车迁移到 Redis。思路很明确:Controller 里不再从 HttpSession 取 Map,而是从一个 CartService 读取,CartService 内部用 Redis 的 Hash 结构存储 userId → partsId → num。
@Service public class CartService { @Autowired private StringRedisTemplate redisTemplate; public void add(Integer userId, Integer partsId, Integer num) { String key = "cart:" + userId; redisTemplate.opsForHash().increment(key, partsId.toString(), num); } public Map<Object, Object> getCart(Integer userId) { String key = "cart:" + userId; return redisTemplate.opsForHash().entries(key); } }这段代码的逻辑是把购物车从“会话内存”搬到“独立缓存”,对业务代码的改动只局限在 Controller 的取值方式上,订单生成的逻辑完全不用动。Redis 的 Hash 天然适合购物车这种“一个用户多个商品”的结构,increment 还能原子累加数量。迁移完成后,Session 里只剩登录状态,购物车数据不会再因为重启 Tomcat 而丢失。如果想把工程化程度再提高,把 Mapper 层换成 MyBatis-Plus,用实体类注解直接生成建表 SQL 和通用增删改查,也是课设升级里很常见的一步。
这个方向值得投入的原因很实际:它覆盖了缓存设计、Key 设计、序列化三个面试高频考点,而且改动量控制在一周以内。做完这步,再把订单号改成雪花算法,把支付状态改成调用第三方沙箱,这套课设就能从“毕设源码”升级成“简历里敢写、面试敢聊”的项目。
我自己第一次做 SSM 商城时,跑通是跑通了,但被面试官一句“库存怎么防止超卖”问得哑口无言。后来把扣库存改成条件更新,把事务边界理清楚,才敢说这个系统是自己的。希望你绕开这些坑,把电脑配件商城做得比普通课设多一点思考。希望帮到你。
本文还有配套的精品资源,点击获取