简介:面向毕业设计、课程设计及期末大作业场景的计算机相关专业学生,这是一套基于SSM(Spring+SpringMVC+Mybatis)+MySQL+JSP开发的水果蔬菜商城系统完整项目,已通过导师指导并获得高分,下载后导入IntelliJ IDEA并按文档配置Tomcat8.0、JDK1.8和MySQL5.7以上环境即可运行。系统分为客户端和管理端:客户端支持主页浏览、用户注册登录、个人中心、购物车与订单管理;管理端支持订单、客户、商品、类目、公告及留言管理,功能覆盖商城常见业务闭环。资源共2000个文件,以jpg/png图片素材、css/js前端样式脚本、xml配置、jar依赖和Java源码为主,同时包含JSP页面、SQL数据库脚本及项目说明文档,压缩包约227.54MB,目录结构完整,便于直接部署和学习参考。前端页面采用JSP结合CSS/JS实现界面展示与交互,已有129人学习下载,适合需要快速获取可运行项目源码、梳理SSM整合流程和商城模块设计的读者。
1. 基于SSM+MySQL+JSP做水果蔬菜商城:为什么这个老组合仍是毕业设计里的高分答案
如果你此刻正坐在毕设选题列表前,大概率会看到一堆“基于Spring Boot+Vue”的新鲜题目,而这个“基于SSM+MySQL+JSP的水果蔬菜商城系统”显得有点老派。但恰恰是这套组合,每年都能稳定产出一批高分毕设:SSM负责Spring的容器管理、SpringMVC的请求分发、MyBatis的数据持久化,MySQL存业务数据,JSP直接渲染页面,从浏览器到底层数据库的整条链路全部暴露在你眼前,没有任何黑匣子可藏。它解决的是“独立完成前后端打通、数据库设计规范、事务和会话处理到位”的JavaWeb完整训练问题。适合三类人:被学校指定JavaWeb技术栈的、想避开Spring Boot全家桶答辩雷区的、以及需要快速搭出可演示闭环的同学。下文按环境搭建、数据库设计、后端业务、JSP渲染、避坑排查、答辩提分的顺序走一遍。
2. 选型与运行环境:SSM组合为什么还能打,以及怎么把空壳跑起来
2.1 为什么SSM还没被淘汰,答辩时反而更占便宜
先回答一个很多人的疑问:Spring Boot都流行这么多年了,为什么还选SSM?
Spring Boot的优点是自动装配,缺点也正是它:你在答辩时说不清那个启动注解背后帮你干了什么,老师一旦追问“自动配置原理”,不少同学当场卡壳。SSM是另一个极端:Spring容器、SpringMVC分发器、MyBatis的SqlSessionFactory全部写在XML里,每一个bean、每一条扫描路径、每一项配置都肉眼可见。老师问“请求怎么进来的”,你可以从web.xml的DispatcherServlet一路说到Controller;问“数据库连接怎么管理的”,直接打开配置文件给老师看SqlSessionFactoryBean。这种透明感在课程设计答辩里非常值钱。
另外,很多学校的JavaWeb课程还是以JSP为主,教材、机房环境都按SSM搭好了。你不用从零接触Spring Boot的starter概念,也不用学Vue去写前后端分离;JSP在服务端渲染,天然适合“登录后把用户名显示在右上角”这种简单会话场景,学习曲线能控制在两周以内。用这套组合去完成一个水果蔬菜商城,本质上是把课堂里学的三层架构完整落到一个真实业务上,而不是重复造一个CRUD轮子。
2.2 版本组合怎么选:JDK、Tomcat、MySQL 8.0还是5.7
这里给一套保守配置,能帮你少踩一半坑。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 | 不要用11+,Tomcat和编译器版本组合容易出链接错误 |
| Maven | 3.6.x | IDEA自带即可 |
| Tomcat | 8.5 | 配JDK8最稳,换9或10会遇到servlet命名空间差异 |
| MySQL | 8.0.x / 5.7.x | 两者连接参数有差异,见下方说明 |
| IDE | IDEA 2021+ | 装好Lombok插件可选项,不用Lombok也行 |
MySQL版本这里单独说两句。学校机房常用5.7,你如果按网上的MySQL 8.x安装配置教程装完8.0,驱动类名和URL参数都会不一样。MySQL 8.0之后驱动类从com.mysql.jdbc.Driver变成了com.mysql.cj.jdbc.Driver,URL还需要额外指定serverTimezone;如果用5.7而代码按8.0写,也会报奇怪的时区或认证错误。建议:开发机装MySQL 8.0,指导教师的验收机如果是5.7,只需要改驱动和URL两处,驱动统一用mysql-connector-java 5.1.49,它可以两边通吃。
2.3 最小pom.xml:给项目装上依赖骨架
先建一个空的Maven war工程,把依赖塞进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> </properties> <dependencies> <!-- Spring核心+SpringMVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.1.8.RELEASE</version> </dependency> <!-- MyBatis与Spring整合 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.3</version> </dependency> <!-- MySQL驱动:5.1.49同时兼容MySQL5.7和8.0 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> <!-- 数据库连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.1.20</version> </dependency> <!-- JSP与JSTL --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> <dependency> <groupId>jstl</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>逻辑说明:spring-webmvc一个依赖会把spring-core、spring-context等核心模块连带拉进来,不用一个个写。mybatis和mybatis-spring要版本匹配,3.5.x配2.0.x没问题。javax.servlet-api必须标provided,否则Tomcat自带的servlet容器类和项目里的冲突,启动直接报LinkageError。连接池选Druid,中文文档多,监控页面还能在答辩时当亮点展示。
版本参数说明:这里刻意没上Spring 5.2+,5.1.x对javax.命名空间兼容性最好。Spring 6已经改成jakarta.,和Tomcat 8.5、JDK8根本不搭,你如果从别的教程复制了新版本坐标,第一件事检查javax还是jakarta。
2.4 三个XML配置:把SpringMVC和MyBatis手动接起来
web.xml负责请求入口和容器初始化。下面这段是SSM项目能不能启动的分水岭:
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <filter> <filter-name>encoding</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> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> </web-app>这里有两个容易翻车的地方。第一,DispatcherServlet的url-pattern配了“/”,所有请求都会进来,静态资源也会被它经过一遍,后面第5章会专门讲怎么放行。第二,编码过滤器必须放在最前面,filter-mapping的url-pattern写“/*”而不是“/”。如果你漏掉CharacterEncodingFilter,POST表单的中文到Controller就已经乱码,后面再调都费劲。
spring-mvc.xml专注Web层:
<context:component-scan base-package="com.fruit.controller"/> <mvc:annotation-driven/> <mvc:default-servlet-handler/> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" value=".jsp"/> </bean>说明:InternalResourceViewResolver把Controller返回的字符串视图名拼成/WEB-INF/jsp/xxx.jsp。把JSP放在WEB-INF下的好处是外部不能直接通过URL访问,必须经过Controller,这个习惯值得保留。
applicationContext.xml管业务层和持久层:
<context:component-scan base-package="com.fruit.service"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/fruit_shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="你的密码"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.fruit.dao"/> </bean>URL里的&是XML转义,写错了MySQL连接参数就会串。serverTimezone=Asia/Shanghai解决MySQL 8.0的时区报错,useSSL=false避免连接时多做SSL握手,本地开发足够。
最后写一个最小Controller和hello.jsp验证空壳能不能跑:
@Controller public class HelloController { @RequestMapping("/hello") public String hello(Model model) { model.addAttribute("tip", "SSM链路已通"); return "hello"; } }<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <body> <h2>${tip}</h2> </body> </html>打war包扔进Tomcat的webapps,启动后访问http://localhost:8080/项目名/hello,页面显示“SSM链路已通”,说明Spring容器、SpringMVC分发、视图解析全都没断。到了这一步再往里填功能,就不用再在几百行业务代码里排查环境问题了。
3. 数据库设计:从用户到订单项的表建模,以及MyBatis映射边界
3.1 六张核心表怎么划分职责
建表之前先说设计思路。商城系统的核心实体是用户、商品、订单,围绕它们衍生出分类、购物车、订单项。我第一次做这类项目时习惯加一张送货地址表,后来发现课设阶段用户下单时填一个地址字段就够了,独立建表反而让下单流程复杂。把表控制在下面六张:
| 表名 | 作用 | 核心字段 |
|---|---|---|
| t_user | 前台用户和管理员共用 | id, username, password, real_name, phone, role, create_time |
| t_category | 商品分类 | id, name, sort |
| t_product | 商品 | id, category_id, name, price, stock, unit, origin, image, status, create_time |
| t_cart | 购物车 | id, user_id, product_id, quantity |
| t_order | 订单主表 | id, order_no, user_id, total_price, receiver, address, state, create_time |
| t_order_item | 订单项 | id, order_id, product_id, product_name, price, quantity |
购物车单独建表的原因有两个:一是你需要在“我的购物车”页面展示多商品汇总和合计金额,纯存Session虽然也能做,但刷新页面就丢;二是课设要求展示三层架构的完整CRUD,购物车表是展示“新增、删除、修改数量、按用户查询”四个操作的最佳载体,答辩时能顺着表把Service层方法一个个指给老师看。
水果蔬菜这个品类比较特殊:单位不是“件”而是“斤/个/盒”,价格随行情浮动,所以商品表里加unit(单位)、origin(产地)和status(上架状态)。上下架字段很重要,它能让“用户看不到已下架商品”和“管理员后台管理商品”形成功能对比,又提供了一个很自然的Service层方法:updateStatus。
3.2 完整建表DDL:字符集和引擎的细节别偷懒
用MySQL命令行或Navicat执行下面的SQL。注意字符集统一utf8mb4,排序规则utf8mb4_general_ci,引擎使用InnoDB。购物车表加唯一索引(user_id, product_id),防止同一件商品被插入两行;订单表的order_no建唯一索引,后面答辩提分点还会用到它。
CREATE DATABASE IF NOT EXISTS fruit_shop DEFAULT CHARSET utf8mb4; USE fruit_shop; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(30), phone VARCHAR(20), role TINYINT NOT NULL DEFAULT 0 COMMENT '0-前台用户 1-管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30) NOT NULL, sort INT DEFAULT 0 COMMENT '分类排序值,越小越靠前' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_product ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, unit VARCHAR(10) DEFAULT '斤', origin VARCHAR(50), image VARCHAR(200) COMMENT '商品图片的相对路径', status TINYINT DEFAULT 1 COMMENT '1-上架 0-下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_cart ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_product (user_id, product_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 VARCHAR(30) NOT NULL, address VARCHAR(200) NOT NULL, state TINYINT DEFAULT 0 COMMENT '0-待付款 1-待发货 2-待收货 3-已完成 4-已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, 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, product_id INT NOT NULL, product_name VARCHAR(100) NOT NULL COMMENT '下单时的商品名快照', price DECIMAL(10,2) NOT NULL COMMENT '下单时的单价快照', quantity INT NOT NULL, KEY idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;说明:t_order_item里故意冗余了product_name和price,这是电商订单系统的常见做法。商品表里的价格是可以被管理员改的,如果订单项只存product_id,下单三个月后你查这张订单,商品价格早就变了,对账就全乱。做课设时把这个设计讲给老师听,比说一堆概念都加分。外键我全部没用物理外键,只用索引和业务逻辑保证一致性,原因放在下一小节。
3.3 逻辑外键与事务边界:什么时候不用物理外键
关于是否使用FOREIGN KEY,很多同学在课设里建表直接加物理外键,看着很规范,真正做起来却处处难受。物理外键带来的问题包括:删一个商品可能需要先删购物车、订单项,依赖顺序多;如果成绩要求严格,外键的级联路径和性能损耗也是老师爱问的刁钻点。我更建议用逻辑外键,也就是字段上建索引、业务层控制。理由有三:
第一,MySQL的InnoDB外键在INSERT时会对父表加共享锁,批量导入测试数据时容易锁等待;第二,项目里的“删除”多数是逻辑删除,商品只置status=0,用户只改状态位,物理外键会逼你做物理级联,和业务背道而驰;第三,MyBatis的多表查询用resultMap或联表SQL足够,不需要数据库层面的外键来帮忙。
真正需要数据库兜底的是唯一约束和索引:order_no唯一索引、购物车(user_id, product_id)唯一索引、商品表category_id索引。这三处索引在答辩时可以扯到“mysql性能调优”上,见第6章。
3.4 MyBatis多表映射:商品列表带分类名,订单详情带商品快照
在t_product表里只存了category_id,用户点开首页看到的是“苹果 水果 5.00元/斤”,分类名称要从t_category里查出来。常见做法是在mapper里联表查询:
<select id="listProductWithCategory" resultType="com.fruit.entity.ProductVO"> SELECT p.id, p.name, p.price, p.stock, p.unit, p.origin, p.image, c.name AS categoryName FROM t_product p LEFT JOIN t_category c ON p.category_id = c.id WHERE p.status = 1 <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND p.name LIKE CONCAT('%', #{keyword}, '%') </if> ORDER BY p.create_time DESC </select>逻辑说明:这里的WHERE条件是动态拼装的,categoryId和keyword都有可能是空值,用MyBatis的if标签做判断,避免一查keywords为空就把所有商品过滤掉。LIKE拼接用了CONCAT('%', #{keyword}, '%')而不是直接写'%${keyword}%',后者会被SQL注入——我见过同学的搜索框里输个单引号页面直接报错,那就是${}的锅。ProductVO是一个比Product多一个categoryName字段的扩展类,直接继承Product也行,但用VO更干净。
订单详情页需要把订单项的商品名称、单价、小计列出来,查询这样写:
<select id="listByOrderId" resultType="com.fruit.entity.OrderItem"> SELECT id, product_id, product_name, price, quantity FROM t_order_item WHERE order_id = #{orderId} </select>这里就不需要联表了,因为t_order_item已经存了商品名和价格快照,这是3.2小节冗余设计的回报,这条查询没有尾巴,页面渲染逻辑会非常短。
3.5 存储过程和触发器的取舍
MySQL的存储过程在课设项目里几乎不该出现。有几个实际原因:第一,存储过程的调试体验在Navicat里很糟,报错信息不直观,出了问题你很难跟老师解释;第二,同一个业务逻辑如果用Java写一遍、又用存储过程写一遍,维护者要跨语言理解;第三,MySQL 8.0之前存储过程每次调用都要重新解析,性能未必比Spring事务里的几条SQL更好。触发器和存储过程同理,建议把库存扣减、订单状态流转这类逻辑放在Service层统一控制,用事务保证一致性。如果老师问“为什么不用”,你就回答“事务边界放在业务层更可控,数据库只负责唯一约束和索引”,这个回答能反映出你真正权衡过。
4. 后端与JSP贯通:登录拦截、分页商品、购物车与下单事务一次打通
4.1 包结构与分层边界
后端代码我一般按下面的包结构组织,Controller只做参数接收和视图跳转,Service层放业务规则,Dao层只放SQL:
com.fruit ├── controller │ ├── UserController │ ├── ProductController │ ├── CartController │ └── OrderController ├── service │ ├── UserService │ ├── ProductService │ ├── CartService │ └── OrderService ├── dao │ ├── UserDao │ ├── ProductDao │ ├── CartDao │ └── OrderDao ├── entity │ ├── User.java │ ├── Product.java │ ├── ProductVO.java │ └── Order.java └── interceptor └── LoginInterceptor.java看起来平淡无奇,但批改同学课设时我见过很多人把SQL逻辑揉在Controller里,一个方法干三件事,后面想加拦截器都无从下手。分层不是格式问题,是你能不能独立扩展的问题。Controller里不要出现new User()传参进Dao这种写法——那说明Service层被跳过了,分层的意义也就没了。
4.2 登录会话与访问拦截
登录是第一个要打通的功能。密码校验通过后把用户对象放进Session,拦截器检查Session里有没有用户,没有就重定向到login.jsp:
@Controller public class UserController { @Resource private UserService userService; @RequestMapping("/login") public String login(String username, String password, HttpSession session, Model model) { User user = userService.login(username, password); if (user == null) { model.addAttribute("error", "用户名或密码错误"); return "login"; } session.setAttribute("loginUser", user); return "redirect:/product/list"; } }public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); if (session.getAttribute("loginUser") != null) { return true; } // 未登录用户一律回到登录页 response.sendRedirect(request.getContextPath() + "/login"); return false; } }LoginInterceptor要注册到spring-mvc.xml,并明确哪些路径放行:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/register"/> <mvc:exclude-mapping path="/product/**"/> <mvc:exclude-mapping path="/static/**"/> <mvc:exclude-mapping path="/category/**"/> <mvc:exclude-mapping path="/hello"/> </mvc:interceptor> </mvc:interceptors>逻辑说明:用户没登录时只能看商品列表、商品详情和登录注册页,一点“加入购物车”就会被踢回登录页。这里有一个典型坑:图片放在/static/images下如果没放行,登录页的验证码和商品图也会被拦截,表现为图片404,第5章会细拆。
service层的登录方法里注意密码不要明文存。课程设计虽然不要求高强度加密,但至少用MD5加盐:
public User login(String username, String password) { // password为前端传来的明文,这里做一次摘要后查库 String hashed = DigestUtils.md5DigestAsHex((salt + password).getBytes(StandardCharsets.UTF_8)); return userDao.findByUsernameAndPassword(username, hashed); }以后写简历如果写“实现用户登录”,被追问密码存储方式答不上来会很难看。MD5加盐本身强度一般,但比明文强得多,答辩时还能带出一句“加盐能防彩虹表”,这是稳赚的提问点。
4.3 分页商品列表:PageHelper参数与JSP渲染细节
商品列表不分页,测试数据一多页面就拖长,而且分页是MySQL语句层面的经典考点:LIMIT offset, pageSize。我一般用PageHelper插件来省去手写count查询的重复工作。pom.xml补上:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.1.11</version> </dependency>在MyBatis配置里注册PageHelper插件后,Service层这样写:
public PageInfo<ProductVO> pageProducts(Integer categoryId, String keyword, int pageNum, int pageSize) { // 页码最小值保护 if (pageNum < 1) { pageNum = 1; } PageHelper.startPage(pageNum, pageSize); List<ProductVO> list = productDao.listProductWithCategory(categoryId, keyword); return new PageInfo<>(list); }逻辑说明:PageHelper.startPage(pageNum, pageSize)只对紧随其后的第一条查询生效,所以调用后那行必须是productDao.listProductWithCategory,中间不能插别的SQL操作。PageInfo封装了总记录数、总页数、当前页这些分页元数据,直接丢给前端渲染。默认pageNum从1开始,如果你前端从0开始传,pageNum必须减1换算,这是分页插件最常见的参数错位来源。
在JSP里用JSTL的c:if和c:forEach渲染商品卡片和分页条:
<c:if test="${empty pageInfo.list}"> <div class="empty-tip">没有找到符合条件的商品</div> </c:if> <c:forEach items="${pageInfo.list}" var="p"> <div class="product-card"> <img src="${p.image}" alt="${p.name}"> <h3><a href="${ctx}/product/detail/${p.id}">${p.name}</a></h3> <p class="price">${p.price} 元/${p.unit}</p> <p class="origin">产地:${p.origin}</p> <a href="${ctx}/cart/add?productId=${p.id}" class="btn">加入购物车</a> </div> </c:forEach>${ctx}是一个在公共JSP片段里用pageContext.setAttribute("ctx", request.getContextPath())预设好的上下文路径变量,这样做有两点好处:一是项目不管部署成ROOT还是带路径的项目,链接都不会断;二是避免在页面里硬编码“/fruit_shop”这种前缀,以后改部署方式不用改页面。
分页条放在商品列表底部,页码通过URL参数pageNum传给Controller:
<c:if test="${pageInfo.pages > 1}"> <a href="${ctx}/product/list?pageNum=${pageInfo.prePage}">上一页</a> <span>第 ${pageInfo.pageNum} / ${pageInfo.pages} 页</span> <a href="${ctx}/product/list?pageNum=${pageInfo.nextPage}">下一页</a> </c:if>PageHelper的nextPage在下一页不存在时会返回当前页,加上前面pageNum < 1的保护,分页就能平安跑完演示流程。到这里,商品列表、分类筛选、关键词搜索三块已经能在页面上交互了。
4.4 购物车实现:表方案和Session方案怎么取舍
购物车有两种做法,你可以都实现一遍再选择交哪套。Session购物车适合“游客临时加购、登录后合并”的前后端分离设计,但课设把登录和购物车绑定更直观,直接操作t_cart表。每次加购先判断用户是否登录,再判断购物车里是否已有同一商品,有则数量加1,没有则插入新行:
public void addToCart(Integer userId, Integer productId) { Cart cart = cartDao.findByUserIdAndProductId(userId, productId); if (cart == null) { Cart newCart = new Cart(); newCart.setUserId(userId); newCart.setProductId(productId); newCart.setQuantity(1); cartDao.insert(newCart); } else { // 已存在则数量+1,上限10个,避免用户捣乱 cartDao.increaseQuantity(cart.getId()); } }有一点要提醒:加购时只插了product_id,商品价格在展示购物车时再查关联表获取,不要在t_cart里存价格快照。购物车是“待确认”状态,用户在结算前可能修改数量,存了反而要处理价格同步问题。真正锁价格的地方是下单事务。
购物车页面的JSP渲染和商品列表类似,区别在于每个商品行多一个删除按钮和一个数量修改输入框:删除按钮直接调/cart/delete?id=,数量修改调/cart/update?id=&quantity=。Controller里两个方法都不复杂,关键是改完数量后要重新计算合计,这个计算放Service层而不是页面里,否则JSP里的脚本表达式会很难看。
4.5 下单事务:为什么必须用@Transactional和SELECT ... FOR UPDATE
下单是最容易出并发问题的环节。用户A和B同时买最后一件商品,如果各自读库存都是1,两个都扣成0,实际库存就会变成负数。常见做法是在事务里先锁定商品行,再判断库存。我一般把下单方法写成下面这样:
@Transactional(rollbackFor = Exception.class) public Long createOrder(Integer userId, List<CartItem> cartItems, String receiver, String address) { // 1. 生成订单主表,order_no用时间戳+随机数生成 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); BigDecimal total = BigDecimal.ZERO; List<OrderItem> itemList = new ArrayList<>(); for (CartItem item : cartItems) { // 2. 锁定商品行,防止超卖 Product product = productDao.selectByIdForUpdate(item.getProductId()); if (product == null || product.getStatus() != 1) { throw new RuntimeException("商品已下架"); } if (product.getStock() < item.getQuantity()) { throw new RuntimeException("库存不足:" + product.getName()); } // 3. 扣减库存并记录订单项快照 productDao.decreaseStock(product.getId(), item.getQuantity()); OrderItem orderItem = new OrderItem(); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getName()); orderItem.setPrice(product.getPrice()); orderItem.setQuantity(item.getQuantity()); itemList.add(orderItem); total = total.add(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } order.setTotalPrice(total); orderDao.insert(order); // 4. 批量插入订单项 for (OrderItem oi : itemList) { oi.setOrderId(order.getId()); orderItemDao.insert(oi); } // 5. 清空已购买的购物车项 cartDao.deleteByCartItemIds(cartItems.stream().map(CartItem::getId).collect(Collectors.toList())); return order.getId(); }对应Mapper里锁和扣库存的SQL:
<select id="selectByIdForUpdate" resultType="com.fruit.entity.Product"> SELECT id, name, price, stock, status FROM t_product WHERE id = #{id} FOR UPDATE </select> <update id="decreaseStock"> UPDATE t_product SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity} </update>逻辑说明:selectByIdForUpdate会给商品行加排他锁,事务提交前其他事务不能修改这一行,这就是MySQL锁分类里的悲观锁场景。decreaseStock的WHERE再带一个stock >= #{quantity}做兜底校验,双保险下来库存不会为负。@Transactional的rollbackFor = Exception.class必须写,Spring默认只回滚RuntimeException,如果你在某处抛了受检异常,回滚不会触发,库存和订单就会不一致——这是一个很隐蔽的坑。
还有一点细节:订单总价要用BigDecimal的add和multiply,不能用double累加。水果价格有两位小数,double在计算0.1+0.2时会给你一个3.0000000000000004,页面显示尴尬,数据库Decimal(10,2)也会在四舍五入时和程序里对不上账。到这里,登录、浏览、加购、下单扣库存这条主链路已经全部走通,剩下的收尾工作是把我上面提到的JSP页面补齐成一套能演示的完整前端,以及把图片上传、批量删除订单项这类提升体验的细节做干净。
5. 从连不上库到图片裂图:SSM商城项目的5个高频事故与排查清单
5.1 MySQL 8连不上:timezone和Public Key Retrieval双报错
现象:启动Tomcat访问首页就抛Communications link failure,堆栈里要么带serverTimezone,要么带Public Key Retrieval is not allowed。
原因:MySQL 8.0默认认证插件是caching_sha2_password,使用5.1.49老驱动连接时,如果没有先在服务端注册公钥,登录阶段就会报Public Key Retrieval;另外MySQL 8.0的时区默认值从Docker或压缩包安装后往往是UTC,驱动自带的时区映射又对不上本地时间。
解决:先把JDBC URL补齐这三个参数:useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true。如果仍然报认证问题,再用MySQL命令行登录执行:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码'; FLUSH PRIVILEGES;注意第二条命令要执行,作用是让权限立即生效。如果用的是mysql-connector-java 8.x,驱动类名还要同步改成com.mysql.cj.jdbc.Driver,两处不一致时最典型的现象就是启动日志里频繁出现ClassNotFoundException。
5.2 中文乱码:页面乱、库里问号、URL参数乱是三件事
现象:页面显示中文乱码,数据库里插入的“水果”变成了“??”,或者登录用户名是英文正常、中文就登录失败。
原因:这往往是三个层面同时出问题。JSP页面没设置pageEncoding;请求进来没走CharacterEncodingFilter;MySQL连接URL没带characterEncoding=utf8。这三处只要有一处没做对,表现就可能一样:页面上的字你认识,存进去的就变问号。
解决:按顺序做四步。第一,每个JSP顶部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>;第二,确认web.xml里的CharacterEncodingFilter存在且url-pattern是/*;第三,JDBC URL里加characterEncoding=utf8;第四,建表语句强制utf8mb4,ALTER TABLE t_product CONVERT TO CHARACTER SET utf8mb4;可以用来补救已经建好的表。排查时用命令行看当前连接编码:
mysql -uroot -p SHOW VARIABLES LIKE 'character_set%';如果character_set_server不是utf8mb4,说明my.cnf没配好,这是MySQL数据库常用命令最直接的应用场景。
5.3 加了登录拦截器后,图片和CSS全部404
现象:功能都正常,唯独登录页的样式全丢,图片裂图,浏览器F12里全是404。把拦截器注释掉就恢复正常。
原因:拦截器里<mvc:mapping path="/**"/>匹配了所有请求,静态资源请求也被拦截下来,而preHandle里判断没有loginUser就重定向,于是浏览器请求/static/css/main.css被硬生生踢去了/login,样式自然全丢。
解决:在spring-mvc.xml里把静态资源路径加进排除列表,并补上静态资源映射:
<mvc:resources mapping="/static/**" location="/static/"/>同时,在拦截器配置的exclude-mapping里保留/static/**。这两个配置缺一不可:前者让DispatcherServlet知道去哪找静态文件,后者让静态请求不经过Session校验。血泪经验是:先把 mvc:resources/ 配好再谈拦截器,不然你会排查到怀疑人生。
5.4 上传的商品图片显示不出来:物理路径和URL路径对不上
现象:管理员后台传图片成功,文件也确实在磁盘上,但前台商品列表的img标签显示裂图,打开图片地址是404。
原因:后端把图片写到了磁盘的绝对路径,比如D:/upload/xxx.jpg,而浏览器访问的是http://localhost:8080/fruit_shop/upload/xxx.jpg,这个URL对应的是Tomcat的webapps目录,磁盘上的D:/upload和URL路径八竿子打不着。
解决:给上传文件单独配一个资源映射,把磁盘目录映射成URL访问路径。在spring-mvc.xml里加:
<mvc:resources mapping="/upload/**" location="file:D:/fruit_shop/upload/"/>Controller里存图片路径时,只存相对部分“upload/xxx.jpg”,页面渲染时用${ctx}/upload/xxx.jpg拼接完整URL。这样既不用动Tomcat的server.xml,也不会出现换一个Tomcat就裂图的问题。要注意location里的file路径结尾必须带斜杠,否则映射会失败。
5.5 PageHelper分页逻辑不对:页码错位和线程污染
现象:第一页和第三页显示相同数据;总页数比实际少一页;或者某次点击后整个列表数据数量怪怪的,一直在变。
原因:PageHelper用ThreadLocal保存分页参数,startPage之后跟着执行了不该分页的查询,同一个线程里的第二条SQL也被强行套上了LIMIT。另一个常见原因是前端传的pageNum从0开始,而PageHelper要求从1开始,传0时它会按默认参数处理,看起来就是“第一页重复”。
解决:严格执行startPage和方法调用之间不插入任何其他Mapper操作的原则。如果确实需要连着查两张表,在第二次查询前执行PageHelper.clearPage()。同时给PageHelper设置合理的边界参数:
<plugin interceptor="com.github.pagehelper.PageInterceptor"> <property name="reasonable" value="true"/> <property name="supportMethodsArguments" value="true"/> </plugin>reasonable=true表示pageNum超出页码范围时自动矫正到第一页或最后一页,supportMethodsArguments表示方法参数里如果带有pageNum/pageSize也能被自动识别,这两个参数能消化掉大部分前端手抖带来的分页异常。这一套下来,分页这块的玄学味道基本就消除了。
6. 答辩现场:三个提分点和一个完整的验证清单
先列一份验证清单,建议你在答辩前一天把整条链路亲手跑一遍:注册新用户、登录、按分类筛选商品、搜索关键词、加入购物车、修改购物车数量、提交订单、管理员后台发货、用户确认收货。每一步都要截屏或录屏,防止现场网络或数据出问题。验证时打开MySQL命令行盯表数据变化,下单完成后检查t_order和t_order_item的记录数,确认库存字段同步减少,这两张表的数据一旦对不上,演示就翻车了。
提分点第一是订单项快照。老师在t_order_item表看到product_name和price,问为什么冗余,你回答“防止商品改价后历史订单对账不清”,这个回答能直接拉开和普通CRUD项目差距。提分点第二是悲观锁扣库存,把selectByIdForUpdate的SQL和@Transactional的配置一起打开给老师看,讲清楚“为什么两个用户同时买最后一斤苹果库存不会变成负数”,这是并发思维的直接证据。提分点第三是密码加盐,展开讲加盐防彩虹表的原理,你甚至可以在项目里顺手做一个登录失败次数的简单限制,又多了半个亮点。
如果你想再拔高一点,可以在答辩自由提问环节主动聊索引。指向order_no唯一索引和category_id普通索引,说一句“商品表数据量大以后,这类WHERE和排序条件就是索引发挥价值的地方”,老师会顺着问你联合索引或索引失效,答得上来就用,答不上来就主动收住。
最后分享一个我的习惯:每次做完一个课设,我都会把“我当时卡住的那个点”单独记一行笔记。SSM这个项目里,我最难忘的不是某个高端框架特性,而是t_order_item表里那一列product_name——它让我明白,设计表的时候多想一步业务,后面能省下无数复杂查询。那位老师当年点头的那一瞬间,我知道这一页代码没白写。希望帮到你。
本文还有配套的精品资源,点击获取