简介基于java与SSM框架的水果蔬菜农产品购物商城是一套完整的毕业设计项目源码包主要面向计算机相关专业学生用于毕业设计、课程设计或项目演示也适合有Java基础的学习者用于进阶实践。项目采用Spring、SpringMVC、MyBatis搭建后端实现水果蔬菜等农产品的浏览、购物车、订单管理、用户管理等商城常见功能前端页面与数据库脚本均已包含可直接部署运行。压缩包内共1575个文件大小约20.4MB其中以png、jpg、gif等图片素材css、js等前端资源java、jsp、xml等后端代码及配置以及sql数据库脚本为主分层结构清晰便于按模块阅读改造。资料包已获导师认可答辩成绩95分代码在mac与Windows10/11环境验证通过附使用文档、数据库及全部工程文件读者可快速启动项目也可在其基础上扩展营销活动、配送管理等新功能。目前已有166人学习下载整体适合需要完整可运行代码作为毕设参考的学生。1. 毕业设计做商城从 JavaSSM 这个题目能拿到什么每年毕业季JavaSSM 水果蔬菜农产品购物商城这类题目都会以「源码 数据库 使用文档」打包的形式出现。你拿到的不只是一套能跑起来的商城代码而是一条从 Spring、SpringMVC、MyBatis 三层框架整合到商品、购物车、订单主链路打通的完整训练路径。这个题目适合两类人一类是想要高分毕设、论文里有真实数据和图表支撑的应届生另一类是刚学完 MyBatis 和 Spring想用手写 SSM 替代 Spring Boot 黑匣子、把基本功打牢的入门工程师。下面按「框架搭建 → 表结构设计 → 核心业务链路 → 排错避坑 → 答辩演示」五个阶段拆开讲每一段都会给到可以直接抄的配置和代码。2. SSM 框架整合与项目骨架先把手写配置跑通再去谈业务2.1 为什么毕业设计选 SSM 而不是 Spring Boot答辩深度不一样很多同学一上来就问2025 年做毕设直接用 Spring Boot MyBatis 不香吗香但要注意毕设答辩的底层逻辑。Spring Boot 把 starter 依赖、自动配置、内嵌 Tomcat 全包了你的项目跑起来很快但答辩时老师问 spring.factories 里写了什么、自动配置是怎么触发的、内嵌 Tomcat 和外部 Tomcat 部署有什么区别答不上来就容易露怯。SSM 的优势在于三层结构和 XML 配置都是显式可见的每一条 bean、每一个 mapper 映射、每一段事务配置都摆在纸面上老师问什么你都能指给他看。SSM 里 Spring 管对象、SpringMVC 管请求分发、MyBatis 管数据库访问三者各司其职。Maven 依赖也不需要走 Spring Boot 的 parent 锁定所有版本自己声明出了问题自己调版本这本身就是一次很好的依赖管理训练。从实用角度看SSM 商城项目做完之后你把它改造成 Spring Boot 版本只需要一到两周反过来却不一定成立。所以这个选题的「高分」通常不是因为项目多复杂而是因为每一行配置都能讲出道理。从做项目的角度我会把系统按「表现层 → 业务层 → 持久层」拆成三个包再按业务模块横向切分。这样做有两个直接好处一是论文里的系统架构图不需要现编照着包结构画就行二是后期排错时一个异常从 Controller 传到 Mapper链路清晰日志一翻就知道卡在哪一层。2.2 Maven 工程结构单模块也能拆出清晰分层拿到源码后不要急着改业务先花半小时把工程结构确认一遍。常见做法是一个 Maven war 包项目JDK 1.8 Tomcat 8/9 MySQL 5.7/8.0这套组合在毕设场景下最稳。pom.xml里最关键的是下面这几个依赖版本以你本机环境为准properties project.build.sourceEncodingUTF-8/project.build.sourceEncoding maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target spring.version5.2.15.RELEASE/spring.version mybatis.version3.5.10/mybatis.version mysql.version8.0.30/mysql.version /properties dependencies !-- Spring 核心 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- MyBatis 与 Spring 整合 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency !-- 数据库驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version${mysql.version}/version scoperuntime/scope /dependency !-- 连接池德鲁伊比 c3p0 更好排查问题 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.9/version /dependency !-- JSP / JSTL -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency /dependencies版本的坑集中在两点一是 Spring 5.2.x 要求 JDK 至少是 1.8如果你本机装了高版本 JDK编译时容易报invalid source release二是 MySQL 连接驱动版本要和数据库大版本匹配MySQL 8.0 配mysql-connector-java8.0.x 基本没毛病但如果你本地是 MySQL 5.7用 8.0 驱动也没问题反过来就不行。目录结构我建议按下面这样组织controller / service / mapper / pojo 四层不要所有类都堆在一个包下src/main/java/com/shop/ ├── controller/ # 前端请求入口 │ ├── UserController.java │ ├── ItemController.java │ └── OrderController.java ├── service/ # 业务层接口 │ └── impl/ # 业务层实现 ├── dao/ # MyBatis Mapper 接口 ├── pojo/ # 实体类 │ ├── User.java │ ├── Item.java │ └── Order.java ├── interceptor/ # 登录拦截器等 └── common/ # 公共工具类、结果封装代码里 service 接口和 impl 拆开是毕设的加分项论文里可以写「面向接口编程」。不过要注意如果你的项目本身只有十几个业务方法接口和实现完全一一对应反而显得形式化。更合理的做法是只在有复杂业务逻辑的地方抽接口比如订单服务简单增删改查直接类实现就好。2.3 三段配置Spring 容器、SpringMVC 分发、MyBatis 映射SSM 的配置是最容易翻车的环节配置顺序错了项目启动直接黑屏。我一般会建三个配置文件spring-context.xml管 Service 和 Mapperspring-mvc.xml管 Controller 和视图解析jdbc.properties放数据库连接参数。先看jdbc.properties这里有个血泪经验url 里必须带上useSSLfalseserverTimezoneAsia/Shanghai否则 MySQL 8.0 会强制 SSL 握手本地连接慢而且报时区错误jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/shop?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.password123456allowPublicKeyRetrievaltrue这一段是给 MySQL 8.0 用的不加上会报Public Key Retrieval is not allowed。如果你用 MySQL 5.7 的驱动这段不加也没问题但为了通用性建议留着。然后是spring-context.xml负责把数据源、SqlSessionFactory、Mapper 扫描、Service 扫描、事务管理器串起来context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.shop.pojo/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.shop.dao/ /bean context:component-scan base-packagecom.shop.service/ bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/配置里有几个点要强调。mapperLocations指定 XML 映射文件路径如果你的 mapper XML 没放到classpath:mapper/下MyBatis 启动时不会报错但运行时一调用 DAO 方法就抛Invalid bound statement (not found)这个错后面避坑章节会细说。typeAliasesPackage配置后mapper XML 里写resultTypeItem就能直接用类名不用写全限定名。事务管理器在这里声明再通过tx:annotation-driven开启注解事务Transactional才会生效。最后是spring-mvc.xml只管 Controller 层配置静态资源和视图解析器context:component-scan base-packagecom.shop.controller/ mvc:default-servlet-handler/ mvc:annotation-driven/ bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/jsp// property namesuffix value.jsp/ /bean mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/register/ mvc:exclude-mapping path/item/**/ mvc:exclude-mapping path/static/**/ bean classcom.shop.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptorsmvc:default-servlet-handler和mvc:annotation-driven必须同时出现前者放行静态资源后者注册注解驱动。很多新手项目里图片、CSS、JS 全部加载不出来就是少了default-servlet-handler这一行。拦截器要特别说明一下/item/**是商品浏览通常允许游客访问/static/**放行静态资源剩下的/cart/**、/order/**、/user/**都要走登录校验。拦截器里从 Session 拿用户拿不到就重定向到登录页这个逻辑虽然简单但是属于答辩必问的「登录状态校验」考点。到这里框架骨架就搭起来了。跑通这一层之后接下来最重要的事情是设计数据库表结构——商城项目所有业务逻辑都是围着这几张表转的。3. 数据库表设计水果蔬菜农产品商城的核心表结构与初始化 SQL3.1 商品、分类、用户、订单四组表的职责划分与字段要点商城类毕设的表结构有非常成熟的套路一般不会少于五张表用户表、商品分类表、商品表、购物车表、订单主表、订单明细表。订单为什么要拆成主表和明细表两张因为一张订单里可能有多种商品主表存订单整体信息总价、收货人、状态、下单时间明细表存每一件商品的下单快照。拆开后你需要统计「某个商品卖了多少」时直接查明细表需要查「某个订单多少钱」时从主表一次性拿到。这在论文的数据流图里是标准的 1 对 N 关系。农产品商城和普通服饰商城有个字段层面的区别商品表里通常要加origin_place产地、unit销售单位斤/盒/个、stock_unit库存单位和on_sale_time上架时间/上市季节。原因很简单水果蔬菜是时令商品论文里写到「系统支持按产地和季节筛选商品」时这些字段是必要条件。另外价格字段必须用DECIMAL(10, 2)不要用float或double否则算订单总价时出现 0.30000000000000004 这种浮点误差这在答辩演示时非常尴尬。我看到不止一个项目在商品表里用 double 存价格下单总额对不上最后全表刷数据才救回来。用户表和购物车表相对简单但要注意用户表的密码字段长度至少留 64 位因为加密后的密文通常是 32 位或 64 位十六进制字符串。很多初始化的 SQL 里password只设了VARCHAR(20)然后代码里用 MD5 加密后存进去长度直接超了数据库报 data too long或者被 MySQL 静默截断导致登录永远失败——这属于比较隐蔽的坑。3.2 购物车表为什么需要冗余一个商品快照购物车表的设计有两个流派一种是不建表用 Session 存另一种是建表把购物车持久化到 MySQL。毕设项目普遍选择建表因为论文里可以有完整的增删改查数据流转更完整。购物车表的核心字段是user_id、item_id、item_name、item_price、item_image、quantity、checked和create_time。这里有个设计细节值得关注item_name、item_price、item_image这三个字段看起来冗余——明明通过item_id关联商品表就能拿到。但购物车列表页每次渲染都要查一遍商品表更重要的是商品价格可能调整而用户加入购物车时看到的价格应当保留。把这些字段快照进购物车表用户下单时即使商品价格变了购物车里显示的仍是加入时的价格。这种冗余在商城设计里叫「冗余商品快照」答辩时主动提这个设计能说明你理解数据一致性问题属于加分项。3.3 初始化 SQL一张建表脚本的落地细节拿到带数据库的源码包后通常先执行CREATE DATABASE再选择数据库执行.sql文件。建议用 navicat 或命令行 source 导入。下面是一份核心建表脚本覆盖了商城主链路CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE shop; -- 用户表 CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, phone VARCHAR(11), address VARCHAR(255), role TINYINT DEFAULT 0, -- 0 普通用户1 管理员 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 商品分类表支持两级分类 CREATE TABLE t_category ( id INT PRIMARY KEY AUTO_INCREMENT, parent_id INT DEFAULT 0, -- 0 表示一级分类 name VARCHAR(50) NOT NULL, sort_order INT DEFAULT 0 -- 排序权重数值越大越靠前 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 商品表 CREATE TABLE t_item ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, name VARCHAR(100) NOT NULL, subtitle VARCHAR(255), -- 副标题展示在卡片页 main_image VARCHAR(255), -- 主图 URL detail TEXT, -- 详情富文本 price DECIMAL(10,2) NOT NULL, stock INT DEFAULT 0, unit VARCHAR(10) DEFAULT 斤, -- 销售单位 origin_place VARCHAR(50), -- 产地 on_sale_time DATE, -- 上市时间/时令季节 sales INT DEFAULT 0, -- 销量用于排序 status TINYINT DEFAULT 1, -- 1 上架0 下架 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id), KEY idx_status_sales (status, sales) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 购物车表 CREATE TABLE t_cart ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, item_id INT NOT NULL, item_name VARCHAR(100) NOT NULL, -- 商品快照 item_price DECIMAL(10,2) NOT NULL, -- 商品快照 item_image VARCHAR(255), quantity INT DEFAULT 1, checked TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_item (user_id, item_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单主表 CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, -- 订单号由代码生成 user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, receiver_name VARCHAR(50), receiver_phone VARCHAR(11), receiver_address VARCHAR(255), status TINYINT DEFAULT 0, -- 0 待付款1 已付款2 已发货3 已完成4 已取消 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME, KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表 CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, item_id INT NOT NULL, item_name VARCHAR(100) NOT NULL, -- 商品快照 item_image VARCHAR(255), item_price DECIMAL(10,2) NOT NULL, -- 下单时价格快照 quantity INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表时几个容易被忽略的细节第一所有表都用utf8mb4而不是utf8utf8在 MySQL 里最多支持 3 字节像苹果的 emoji 表情「」入库直接变乱码农产品商城里用户收货地址偶尔会带表情符号utf8mb4是唯一选择。第二联合索引的字段顺序要根据查询来定比如商品表要支持「按分类查并按销量排序」就应该建(category_id, status, sales)这类索引而不是只看单列。第三库存字段stock用INT DEFAULT 0不要用无符号类型否则代码里做库存扣减后变成负数时报错排查起来比业务逻辑本身还费劲。建完表之后记得补两条管理员和测试商品的 INSERT 语句然后再启动项目。不然第一次启动前台商城一片空白你还得现去后台上传商品图片——这个流程放到答辩前临时做心态很容易崩。4. 核心业务链路商品检索、购物车与下单扣库存的实现细节4.1 商品列表与分类检索MyBatis 动态 SQL 的三种常见写法商城的第一步是把商品展示出来。用户端的首页、分类页、搜索页本质上都是同一个查询按条件查商品、按销量或时间排序。这里 MyBatis 的动态 SQL 是重头戏也是答辩时老师最喜欢让你现场改条件的考点。ItemMapper.xml里的核心查询我一般这么写select idselectItemList resultTypeItem SELECT id, category_id, name, subtitle, main_image, price, stock, unit, origin_place, sales, status FROM t_item where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR subtitle LIKE CONCAT(%, #{keyword}, %)) /if if testoriginPlace ! null and originPlace ! AND origin_place #{originPlace} /if AND status 1 /where choose when testsort sales ORDER BY sales DESC, id DESC /when when testsort price_asc ORDER BY price ASC, id DESC /when otherwise ORDER BY create_time DESC, id DESC /otherwise /choose /selectwhere标签会自动去掉第一个条件前面的AND避免拼出WHERE AND category_id ?这种语法错误。choose相当于 Java 里的 switch三个分支处理三种排序按销量、按价格升序、按默认时间。排序字段没有走参数直接拼接因为ORDER BY后面不能预编译占位符必须由后端白名单控制直接拿用户传参拼接是最常见的 SQL 注入隐患。这里sort参数在 Controller 层做了校验只允许传三个白名单值。对应地ItemDao.java接口需要配合使用Repository public interface ItemDao { ListItem selectItemList(Param(categoryId) Integer categoryId, Param(keyword) String keyword, Param(originPlace) String originPlace, Param(sort) String sort); }用Param注解显式声明参数名这样 XML 里的#{categoryId}才能正确绑定。如果你不加Param项目编译时又开了-parameters参数XML 里可能也能拿到名字但大概率会报There is no getter for property named categoryId——这个错我见过太多人卡了一下午最后就是用Param解决的。这也侧面说明SSM 项目里接口和 XML 的每一处对应关系都是手写的平等地对待每一个#{}引用能少踩很多坑数据库增删改查的基本功就是在这种反复排查中练出来的。4.2 加入购物车与购物车列表快照数据从哪里来购物车模块的核心逻辑是「幂等加购」。同一个用户对同一件商品点两次「加入购物车」正确行为是把数量加一而不是插入两条记录。这正好用得上建表时的唯一键uk_user_item (user_id, item_id)。CartService里我习惯先查询再决定 insert 还是 updateService public class CartServiceImpl implements CartService { Autowired private CartDao cartDao; Autowired private ItemDao itemDao; Override public void addToCart(Integer userId, Integer itemId, Integer quantity) { Cart cart cartDao.selectByUserIdAndItemId(userId, itemId); if (cart null) { Item item itemDao.selectById(itemId); if (item null || item.getStatus() ! 1) { throw new BusinessException(商品不存在或已下架); } Cart newCart new Cart(); newCart.setUserId(userId); newCart.setItemId(itemId); newCart.setItemName(item.getName()); newCart.setItemPrice(item.getPrice()); newCart.setItemImage(item.getMainImage()); newCart.setQuantity(quantity); newCart.setChecked(1); cartDao.insert(newCart); } else { cartDao.increaseQuantity(cart.getId(), quantity); } } Override public ListCart listCart(Integer userId) { return cartDao.selectByUserId(userId); } }新增购物车记录时从商品表读取快照这是之前表结构设计里「冗余商品快照」的落地。increaseQuantity的 SQL 是UPDATE t_cart SET quantity quantity #{quantity} WHERE id #{id}直接在 SQL 层面自增避免先查后改的并发覆盖问题。注意BusinessException是自定义运行时异常配合 SpringMVC 的ControllerAdvice做全局异常处理前端能拿到「商品已下架」的提示而不是一坨堆栈信息。购物车列表查询按时间倒序前端展示时逐行加总item_price * quantity得到总价。这部分逻辑简单但有一个页面上经常翻车的点如果购物车表里的商品快照价格和当前商品表价格不一致以谁为准答案是购物车页以快照价格为准因为这是用户加购时看到的价格但提交订单时正确做法是重新从商品表读取一遍最新价格来计算总价防止有人改前端页面绕过限制。这个「前端展示用快照、后端结算用实时价」的差异点也是很能体现工程意识的答辩素材。4.3 下单扣库存事务、防超卖与订单号生成下单是整个商城最核心的事务链路。用户在购物车勾选商品后点提交订单后端要做的事串起来是校验库存 → 生成订单主表和明细表 → 扣减库存 → 清空购物车对应记录。这里最怕的是并发场景下库存扣成负数也就是「超卖」。解决超卖的思路不是用悲观锁把整张表锁住而是用 SQL 层面的条件更新UPDATE t_item SET stock stock - #{quantity} WHERE id #{itemId} AND stock #{quantity}这一句的意思是库存扣减在一条 SQL 内完成并且只有当当前库存大于等于购买数量时才更新。affectRows返回 1 说明扣减成功返回 0 说明库存不足事务回滚。代码层再配上Transactional保证订单生成和库存扣减要么都成功要么都不成功Transactional(rollbackFor Exception.class) public Order createOrder(Integer userId, Integer[] itemIds, Integer[] quantities, String receiverName, String receiverPhone, String receiverAddress) { // 1. 生成订单号 String orderNo SO System.currentTimeMillis() String.format(%04d, new Random().nextInt(10000)); // 2. 创建订单主表记录先插入获取主键 ID Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setReceiverName(receiverName); order.setReceiverPhone(receiverPhone); order.setReceiverAddress(receiverAddress); order.setStatus(0); // 3. 遍历购物车商品逐项校验并扣库存 BigDecimal totalPrice BigDecimal.ZERO; ListOrderItem orderItemList new ArrayList(); for (int i 0; i itemIds.length; i) { Item item itemDao.selectByIdForUpdate(itemIds[i]); if (item.getStatus() ! 1) { throw new BusinessException(商品已下架: item.getName()); } int rows itemDao.deductStock(itemIds[i], quantities[i]); if (rows 0) { throw new BusinessException(库存不足: item.getName()); } // 创建订单明细快照 OrderItem oi new OrderItem(); oi.setItemId(item.getId()); oi.setItemName(item.getName()); oi.setItemImage(item.getMainImage()); oi.setItemPrice(item.getPrice()); oi.setQuantity(quantities[i]); oi.setTotalPrice(item.getPrice().multiply(BigDecimal.valueOf(quantities[i]))); orderItemList.add(oi); totalPrice totalPrice.add(oi.getTotalPrice()); cartDao.deleteByUserIdAndItemId(userId, itemIds[i]); } order.setTotalPrice(totalPrice); orderDao.insert(order); for (OrderItem oi : orderItemList) { oi.setOrderId(order.getId()); orderItemDao.insert(oi); } return order; }有两点值得展开。第一selectByIdForUpdate使用了SELECT ... FOR UPDATE行级锁把这一行商品锁住确保后续的deductStock操作在这一事务内不会被其他事务干扰。这里我选择了「先锁再扣」的策略因为下单还要校验商品状态、读取价格、创建快照这一串读操作需要一个稳定的一致性视图。如果直接用「无条件更新 RETURNING」在 MySQL 里实现就得依赖UPDATE返回值再补查绕一圈不如锁行清晰。要注意FOR UPDATE只能锁 InnoDB 的行前提是查询条件必须命中索引WHERE id ?走主键索引没有问题。第二事务加了rollbackFor Exception.class因为 Spring 默认只在运行时异常时回滚而检查异常不会触发回滚。如果漏掉这个属性订单明细插入失败时事务可能不回滚库存已经扣了但订单没建出来后台数据就对不上了。这种问题在测试环境很难复现通常是在并发压测或手工造脏数据时才会暴露属于那种「查到一次就出一身冷汗」的隐藏陷阱。订单号生成没有用复杂的雪花算法而是SO 毫秒时间戳 四位随机数。这个方案的缺点是同一毫秒内并发超过一万个订单才可能撞号毕设场景下完全够用。答辩时如果老师问高并发怎么办你可以回答「生产环境会换雪花算法或 Redis 自增序列」然后顺势把订单号结构里的时间戳、随机位含义讲清楚。5. SSM 商城项目避坑指南从环境到部署的五个典型翻车点5.1 Java 启动失败端口占用和 Tomcat 发布路径对不上现象STS 或 IDEA 里启动 Tomcat 报Port 8080 required by Tomcat v9.0 Server is already in use或者启动成功但浏览器访问首页 404。原因前者是 8080 被别的进程占用。后者多半是项目没有成功部署到 Tomcat 的 webapps 目录或者是访问路径里少了项目名。检查一下直接看Tomcat 的 server.xml里的Context配置以及 Running 的 Modules 里 Application context 是不是/shop访问地址应当是http://localhost:8080/shop/而不是http://localhost:8080/。解决第一步命令行执行netstat -ano | findstr 8080找到占用进程的 PID 后在任务管理器结束进程或者改 Tomcat 端口为 8081。第二步在 IDEA 的 Run Configurations 里确认 Deployment 标签页有shop:war exploded且 Application context 填了/shop。第三步在 Tomcat 的 VM 参数里加这段让 JSP 编译不挑 JDK 版本-Djava.compilerNONE。5.2 数据库连接失败时区、驱动和 SSL 是一套组合拳现象项目启动时日志报Cannot create PoolableConnectionFactory (The server time zone value X is unrecognized...)或Could not retrieve transation read-only status JDBC Connection又或者Public Key Retrieval is not allowed。原因MySQL 8.0 默认启用 SSL 连接同时要求驱动层和数据库之间的时区一致。常见做法里大家只写jdbc:mysql://localhost:3306/shop省略了时区和 SSL 参数连接池初始化时直接挂掉。解决回到 2.3 节的jdbc.properties确认 URL 是完整的那串重点核对serverTimezoneAsia/Shanghai和useSSLfalse。然后在命令行执行show variables like %time_zone%看数据库时区必要时SET GLOBAL time_zone 8:00。改完重启应用八成能救回来。剩下两成是连接池的testWhileIdle配置Druid 下加property namevalidationQuery valueSELECT 1/即可。5.3 中文乱码JSP、Tomcat、MySQL 三处编码要同时改现象页面显示???或者数据库表里中文正常但 JSP 页面输出乱码再或者表单提交的中文到数据库变成乱码。原因字符集链条断了一环。Tomcat 8 默认 GET 请求已是 UTF-8但 POST 请求解码还是 ISO-8859-1JSP 页面本身的contentType没指定 UTF-8MySQL 表用了latin1或连接没指定characterEncodingutf8——三处只要有一处是旧的中文就会乱。解决第一处JSP 文件顶部加% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8%第二处spring-mvc.xml里配一个字符编码过滤器filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping第三处确认建表 SQL 里每个表都带DEFAULT CHARSETutf8mb4而不是建表后再改。这个环节没有太多玄学就是按「页面 → 容器 → 驱动 → 数据库」逐层排查。5.4 静态资源和上传图片加载不出来SpringMVC 拦截了不该拦的现象页面 HTML 出来了但 CSS、JS、商品图片全部 404或者后台管理上传商品图后前台图片裂开。原因spring-mvc.xml里的mvc:default-servlet-handler/没配置导致前端控制器把/css/*、/upload/*的请求也当成了 Controller 映射。上传图片 404 的另一个原因是把图片保存到了 IDE 的临时部署目录里重启后文件被清掉。解决静态资源拦截问题加mvc:default-servlet-handler/和mvc:resources mapping/static/** location/static//。图片存储路径我一般会在jdbc.properties同目录建一个config.properties存upload.pathD:/shop_upload代码里用绝对路径保存图片访问路径通过虚拟映射指向它。不要在代码里写死成自己电脑的 C 盘路径交到老师手里部署时一定会踩坑。Tomcat 部署后的图片访问路径可以在 server.xml 的 Host 节点加Context docBaseD:/shop_upload path/upload/这种虚拟目录映射这样图片 URL 是http://localhost:8080/upload/xxx.jpg与项目相对独立。5.5 事务回滚不生效同一个类里方法自调用现象下单时故意让库存不足抛出异常但订单主表记录还在或者商品库存扣了订单明细没插入数据对不上。原因这是 Spring 事务的一个经典陷阱。Transactional是基于 AOP 代理实现的只有从外部调用createOrder方法时才会经过代理对象事务注解才生效。如果你在OrderServiceImpl内部写了一个私有方法或者createOrder被同类中的submitOrder通过this.createOrder()调用事务拦截器感知不到自然就不会回滚。解决最直观的解决办法是把需要事务的方法放到一个专门的OrderService接口里由 Controller 注入接口调用不要在实现类内部用this调用带事务的公有方法。如果确实需要在类内部调用可以在类中注入自身代理比如Autowired自己这个接口然后用代理对象调用。这个坑在答辩演示时很难主动暴露但一旦老师问到「事务边界在哪」或者让你现场加一个异常测试你会很被动。平时写代码时养成习惯凡是写入操作优先考虑事务方法从 Controller 入口进而不是从 Service 内部穿。6. 答辩演示的进阶准备造数脚本、索引验证与演示路线毕设演示最尴尬的瞬间不是系统崩了而是商品列表太干净、销量全是 0、订单页空荡荡。数据库里没有数据系统的每个功能看起来都像半成品。所以答辩前一周要做两件事造数据、验证查询性能。造数不要用手工一条条 INSERT直接用存储过程批量跑。下面这个是往商品表插 500 条测试商品的脚本分类 id 在 1 到 8 之间随机价格在 5 到 80 元之间随机销量模拟正态分布同时把on_sale_time分布在一年内DELIMITER $$ CREATE PROCEDURE batch_insert_item() BEGIN DECLARE i INT DEFAULT 1; WHILE i 500 DO INSERT INTO t_item (category_id, name, subtitle, main_image, price, stock, unit, origin_place, on_sale_time, sales, status, create_time) VALUES ( FLOOR(1 RAND() * 8), CONCAT(测试水果, i), CONCAT(第, i, 号测试商品), /static/img/item/default.jpg, ROUND(5 RAND() * 75, 2), FLOOR(20 RAND() * 200), 斤, ELT(FLOOR(1 RAND() * 4), 山东, 海南, 新疆, 云南), DATE_SUB(2025-12-31, INTERVAL FLOOR(RAND() * 365) DAY), FLOOR(1 RAND() * 1000), 1, NOW() ); SET i i 1; END WHILE; END$$ DELIMITER ; CALL batch_insert_item();造完数据后再去分析一条慢查询。比如在商品列表页翻到最后一页把耗时最长的 SQL 从 MyBatis 日志里捞出来去命令行跑EXPLAIN SELECT ...。你会看到typeALL说明做的全表扫描。然后在category_id上补一个索引ALTER TABLE t_item ADD INDEX idx_category_status (category_id, status);再跑一遍EXPLAINtype变为refrows从 500 降到个位数。答辩现场把这一步对比展示出来比十页文字描述更能说明你理解索引的价值。我自己的习惯是每次做完一个模块先把异常路径走一遍支付时库存不足、添加购物车时商品已下架、未登录直接访问订单页。这些边角场景跑顺了演示时才不会翻车。源码包里带的数据库脚本通常只是最小数据集一定要自己再补一轮造数。最后按「注册登录 → 浏览商品 → 加入购物车 → 提交订单 → 后台发货」这条主线走一遍全程不超过三分钟但覆盖了所有核心表。这是我从第一次答辩翻车之后养成的习惯希望能帮到你——把异常路径当成正常路径一样对待项目才算真正做完。本文还有配套的精品资源点击获取