ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java+SSM毕业设计商城:从框架整合到订单事务的完整实战解析

Java+SSM毕业设计商城:从框架整合到订单事务的完整实战解析

简介:基于java与SSM框架的水果蔬菜农产品购物商城,是一套完整的毕业设计项目源码包,主要面向计算机相关专业学生用于毕业设计、课程设计或项目演示,也适合有Java基础的学习者用于进阶实践。项目采用Spring、SpringMVC、MyBatis搭建后端,实现水果蔬菜等农产品的浏览、购物车、订单管理、用户管理等商城常见功能,前端页面与数据库脚本均已包含,可直接部署运行。压缩包内共1575个文件,大小约20.4MB,其中以png、jpg、gif等图片素材,css、js等前端资源,java、jsp、xml等后端代码及配置,以及sql数据库脚本为主,分层结构清晰,便于按模块阅读改造。资料包已获导师认可,答辩成绩95分,代码在mac与Windows10/11环境验证通过,附使用文档、数据库及全部工程文件,读者可快速启动项目,也可在其基础上扩展营销活动、配送管理等新功能。目前已有166人学习下载,整体适合需要完整可运行代码作为毕设参考的学生。

1. 毕业设计做商城,从 Java+SSM 这个题目能拿到什么

每年毕业季,Java+SSM 水果蔬菜农产品购物商城这类题目都会以「源码 + 数据库 + 使用文档」打包的形式出现。你拿到的不只是一套能跑起来的商城代码,而是一条从 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.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <spring.version>5.2.15.RELEASE</spring.version> <mybatis.version>3.5.10</mybatis.version> <mysql.version>8.0.30</mysql.version> </properties> <dependencies> <!-- Spring 核心 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</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.7</version> </dependency> <!-- 数据库驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.version}</version> <scope>runtime</scope> </dependency> <!-- 连接池,德鲁伊比 c3p0 更好排查问题 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.9</version> </dependency> <!-- JSP / JSTL --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.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 和 Mapper,spring-mvc.xml管 Controller 和视图解析,jdbc.properties放数据库连接参数。

先看jdbc.properties,这里有个血泪经验:url 里必须带上useSSL=false&serverTimezone=Asia/Shanghai,否则 MySQL 8.0 会强制 SSL 握手,本地连接慢,而且报时区错误:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=123456

allowPublicKeyRetrieval=true这一段是给 MySQL 8.0 用的,不加上会报Public Key Retrieval is not allowed。如果你用 MySQL 5.7 的驱动,这段不加也没问题,但为了通用性建议留着。

然后是spring-context.xml,负责把数据源、SqlSessionFactory、Mapper 扫描、Service 扫描、事务管理器串起来:

<context:property-placeholder location="classpath:jdbc.properties"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.shop.pojo"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.shop.dao"/> </bean> <context:component-scan base-package="com.shop.service"/> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

配置里有几个点要强调。mapperLocations指定 XML 映射文件路径,如果你的 mapper XML 没放到classpath:mapper/下,MyBatis 启动时不会报错,但运行时一调用 DAO 方法就抛Invalid bound statement (not found),这个错后面避坑章节会细说。typeAliasesPackage配置后,mapper XML 里写resultType="Item"就能直接用类名,不用写全限定名。事务管理器在这里声明,再通过<tx:annotation-driven>开启注解事务,@Transactional才会生效。

最后是spring-mvc.xml,只管 Controller 层,配置静态资源和视图解析器:

<context:component-scan base-package="com.shop.controller"/> <mvc:default-servlet-handler/> <mvc:annotation-driven/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" 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 class="com.shop.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>

mvc:default-servlet-handler和mvc:annotation-driven必须同时出现,前者放行静态资源,后者注册注解驱动。很多新手项目里图片、CSS、JS 全部加载不出来,就是少了default-servlet-handler这一行。拦截器要特别说明一下:/item/**是商品浏览,通常允许游客访问;/static/**放行静态资源;剩下的/cart/**、/order/**、/user/**都要走登录校验。拦截器里从 Session 拿用户,拿不到就重定向到登录页,这个逻辑虽然简单,但是属于答辩必问的「登录状态校验」考点。

到这里,框架骨架就搭起来了。跑通这一层之后,接下来最重要的事情是设计数据库表结构——商城项目所有业务逻辑都是围着这几张表转的。

3. 数据库表设计:水果蔬菜农产品商城的核心表结构与初始化 SQL

3.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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品分类表(支持两级分类) 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 -- 排序权重,数值越大越靠前 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 商品表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 购物车表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单主表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

建表时几个容易被忽略的细节:第一,所有表都用utf8mb4而不是utf8,utf8在 MySQL 里最多支持 3 字节,像苹果的 emoji 表情「🍎」入库直接变乱码,农产品商城里用户收货地址偶尔会带表情符号,utf8mb4是唯一选择。第二,联合索引的字段顺序要根据查询来定,比如商品表要支持「按分类查并按销量排序」,就应该建(category_id, status, sales)这类索引,而不是只看单列。第三,库存字段stock用INT DEFAULT 0,不要用无符号类型,否则代码里做库存扣减后变成负数时报错,排查起来比业务逻辑本身还费劲。

建完表之后,记得补两条管理员和测试商品的 INSERT 语句,然后再启动项目。不然第一次启动,前台商城一片空白,你还得现去后台上传商品图片——这个流程放到答辩前临时做,心态很容易崩。

4. 核心业务链路:商品检索、购物车与下单扣库存的实现细节

4.1 商品列表与分类检索:MyBatis 动态 SQL 的三种常见写法

商城的第一步是把商品展示出来。用户端的首页、分类页、搜索页,本质上都是同一个查询:按条件查商品、按销量或时间排序。这里 MyBatis 的动态 SQL 是重头戏,也是答辩时老师最喜欢让你现场改条件的考点。ItemMapper.xml里的核心查询我一般这么写:

<select id="selectItemList" resultType="Item"> SELECT id, category_id, name, subtitle, main_image, price, stock, unit, origin_place, sales, status FROM t_item <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR subtitle LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="originPlace != null and originPlace != ''"> AND origin_place = #{originPlace} </if> AND status = 1 </where> <choose> <when test="sort == 'sales'"> ORDER BY sales DESC, id DESC </when> <when test="sort == 'price_asc'"> ORDER BY price ASC, id DESC </when> <otherwise> ORDER BY create_time DESC, id DESC </otherwise> </choose> </select>

<where>标签会自动去掉第一个条件前面的AND,避免拼出WHERE AND category_id = ?这种语法错误。<choose>相当于 Java 里的 switch,三个分支处理三种排序:按销量、按价格升序、按默认时间。排序字段没有走参数直接拼接,因为ORDER BY后面不能预编译占位符,必须由后端白名单控制,直接拿用户传参拼接是最常见的 SQL 注入隐患。这里sort参数在 Controller 层做了校验,只允许传三个白名单值。

对应地,ItemDao.java接口需要配合使用:

@Repository public interface ItemDao { List<Item> 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 还是 update:

@Service 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 List<Cart> 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; List<OrderItem> 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.compiler=NONE。

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 是完整的那串,重点核对serverTimezone=Asia/Shanghai和useSSL=false。然后在命令行执行show variables like '%time_zone%'看数据库时区,必要时SET GLOBAL time_zone = '+8:00'。改完重启应用,八成能救回来。剩下两成是连接池的testWhileIdle配置,Druid 下加<property name="validationQuery" value="SELECT 1"/>即可。

5.3 中文乱码:JSP、Tomcat、MySQL 三处编码要同时改

现象:页面显示???,或者数据库表里中文正常但 JSP 页面输出乱码,再或者表单提交的中文到数据库变成乱码。

原因:字符集链条断了一环。Tomcat 8+ 默认 GET 请求已是 UTF-8,但 POST 请求解码还是 ISO-8859-1;JSP 页面本身的contentType没指定 UTF-8;MySQL 表用了latin1或连接没指定characterEncoding=utf8——三处只要有一处是旧的,中文就会乱。

解决:第一处,JSP 文件顶部加<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>;第二处,spring-mvc.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>

第三处,确认建表 SQL 里每个表都带DEFAULT CHARSET=utf8mb4,而不是建表后再改。这个环节没有太多玄学,就是按「页面 → 容器 → 驱动 → 数据库」逐层排查。

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.path=D:/shop_upload,代码里用绝对路径保存,图片访问路径通过虚拟映射指向它。不要在代码里写死成自己电脑的 C 盘路径,交到老师手里部署时一定会踩坑。Tomcat 部署后的图片访问路径可以在 server.xml 的 Host 节点加<Context docBase="D:/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 ...。你会看到type=ALL,说明做的全表扫描。然后在category_id上补一个索引:ALTER TABLE t_item ADD INDEX idx_category_status (category_id, status);再跑一遍EXPLAIN,type变为ref,rows从 500 降到个位数。答辩现场把这一步对比展示出来,比十页文字描述更能说明你理解索引的价值。

我自己的习惯是每次做完一个模块,先把异常路径走一遍:支付时库存不足、添加购物车时商品已下架、未登录直接访问订单页。这些边角场景跑顺了,演示时才不会翻车。源码包里带的数据库脚本通常只是最小数据集,一定要自己再补一轮造数。最后按「注册登录 → 浏览商品 → 加入购物车 → 提交订单 → 后台发货」这条主线走一遍,全程不超过三分钟,但覆盖了所有核心表。这是我从第一次答辩翻车之后养成的习惯,希望能帮到你——把异常路径当成正常路径一样对待,项目才算真正做完。

本文还有配套的精品资源,点击获取

返回列表