前两天一个学弟跑过来问我,毕设题目选了“Java基于SSM+JSP的农产品信息发布与交易”,问我这个题现在做还有没有价值。我跟他讲,这个题目不是有没有价值的问题,而是你怎么把SSM三层架构、JSP服务端渲染、以及农产品交易这条业务线串成一个真正能跑起来的系统。我去年完整做过一套类似的,从数据库设计到后台管理再到前台交易流程都摸了一遍,今天把整个项目的拆解思路、技术取舍和踩坑记录整理出来,希望能帮到正在做这类JavaWeb课程设计,或者打算拿SSM+JSP练手的朋友。
先说清楚这个系统是干什么的:它实际上是一个面向农产品领域的B2C小型电商平台,农户或商家在后台发布农产品(名称、分类、价格、库存、图片、描述),普通用户注册登录后可以浏览商品、按分类或关键词筛选、把商品加入购物车、下单购买,管理员负责商品审核、用户管理和订单监管。技术栈就是Spring + Spring MVC + MyBatis(即SSM),前端页面用JSP配合JSTL标签渲染。整个项目没有涉及分布式、微服务这些重型概念,是一个特别标准的三层架构案例,非常适合用来理解JavaWeb开发的完整链路。
1. 业务场景拆解:农产品交易系统到底要管哪些事
很多同学拿到这类题目上来就画表、写代码,结果做到一半才发现业务逻辑没理清。我的建议是,先花一天时间把场景走一遍,搞清楚谁在用、用了什么功能、数据怎么流转,再动手。
1.1 三种角色划分与权限边界
这类系统一般分三种角色:管理员、卖家(农产品提供方)、买家(普通用户)。权限边界是设计所有功能的前提。
- 管理员:管理用户(禁用、启用、重置密码)、审核商品、处理订单纠纷、管理商品分类,看到的是全量数据。
- 卖家:发布商品、下架商品、修改库存、处理自己的订单(发货、填写物流单号),只能操作自己创建的数据。
- 买家:浏览商品、搜索筛选、加入购物车、下单、取消订单、确认收货,能查看订单流转状态。
这里的权限控制我建议直接用拦截器完成,不要上Spring Security,理由是学习成本。项目里定义一个LoginInterceptor,在Spring MVC配置里注册拦截路径,再往Session里存一个user对象,每次请求进来判断user和user.userType就可以完成大部分鉴权。管理员接口单独要求userType == 0,卖家操作要求userType == 1,否则直接重定向到登录页或者403页面。这种方式代码量少,逻辑直观,答辩的时候也好讲。
1.2 核心业务流程:从发布到成交
农产品交易的主流程,按顺序拆是这么几条链路:
- 卖家登录,进入“商品管理”页,点击“发布商品”,填写信息并上传图片,提交后商品状态为“待审核”。
- 管理员在后台审核,通过后商品状态变成“在售”,买家前端才能看到。
- 买家把商品加入购物车(购物车可以放Session里),去结算,生成订单。
- 订单生成后状态为“待付款”,买家可以模拟支付(本项目通常不做真实支付),支付后变成“待发货”。
- 卖家看到待发货订单,点击发货,填写物流信息,状态变“待收货”。
- 买家确认收货,状态变“已完成”。买家也可以在一定时间内取消订单,取消后库存回滚。
如果你把这个流程用文字画成一个时序图,你会发现有至少三个地方需要校验状态:下单时校验商品是否在售且库存充足;支付时校验订单是否属于当前用户且处于待付款状态;发货时校验订单是否处于待发货状态。这些校验漏掉一个,项目演示的时候就会出大问题。
1.3 容易忽略的隐藏需求
很多初做项目的人会忽略几个看似不起眼但非常重要的点:
- 商品图片存储:上传到本地某个目录还是存数据库?路径怎么映射?
- 订单超时未支付:数据库里什么时候把超时订单取消掉,释放库存?
- 商品上下架:卖家下架后,已经加进购物车里的商品在结算时怎么处理?
- 搜索分页:列表页翻到第几页,排序规则是什么?
- 分类管理:分类是固定写死还是后台可以动态增删?
这些如果不在设计阶段列一个清单,开发过程中大概率会反复改表结构、改接口,非常痛苦。
2. 技术选型复盘:SSM+JSP组合的真实定位与取舍
现在市面上Spring Boot + Vue满天飞,为什么我还要推荐SSM+JSP?因为这套组合在学习路径里扮演的是“骨架课”的角色。你在面试的时候被问得最多的Spring IoC、AOP、Spring MVC执行流程、MyBatis动态SQL,恰恰就是SSM项目里每天在写的东西。直接上手Spring Boot确实快,但很多东西被自动配置遮住了,反而不容易深入。
2.1 SSM各层的职责划分
- Spring:全局容器,管理Service、Mapper等Bean,同时承担事务管理。我的做法是在
applicationContext.xml里配置数据源、事务管理器,然后让Service层的方法参与事务。农产品下单涉及“扣库存”和“生成订单”两个操作,必须放在同一个事务方法里,如果中途抛出运行时异常,两个操作一起回滚。 - Spring MVC:负责请求分发和视图渲染。Controller层只做参数接收、调用Service、把结果放进Model,不写业务逻辑。JSP文件放在
WEB-INF/views目录下,由InternalResourceViewResolver解析。 - MyBatis:负责数据访问。写SQL的地方是
*Mapper.xml,动态SQL标签很关键,像商品列表的条件筛选,分类ID可能为空、关键词可能为空,用<if>标签拼接WHERE条件非常方便。
2.2 JSP在什么情况下不算过时
JSP本质上是服务端渲染技术,每次请求由服务器把页面拼好再返回给浏览器。它在高并发场景下确实不如前后端分离来得灵活,但在课程设计、毕设、企业内部老系统维护这几种场景里依然有大量存量需求。尤其当要求“打包成WAR包部署在Tomcat里”时,JSP就像主场作战,因为它天然被Tomcat容器支持,不需要额外写前端工程、解决跨域、做Nginx转发,部署逻辑简单太多。
另外我个人的感觉是,用JSP写页面其实很适合快速开发一个管理后台。JSTL的<c:forEach>循环、<c:if>分支判断,写起来非常直接,改一个字段值刷新页面就能看到效果,不用像前后端分离那样开两个服务联调。
2.3 环境版本搭配与准备
我自己用的这套搭配,踩坑最少:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8 | 不要用17,部分老框架对高版本JDK兼容有问题 |
| Tomcat | 8.5/9.0 | 内嵌在IDEA里运行即可 |
| Maven | 3.6+ | 管理依赖和打包 |
| MySQL | 5.7/8.0 | 字符集一定建库时指定utf8mb4 |
| IDEA | 2023+ | 新建Web项目的姿势和旧版有差异 |
创建项目的时候,我建议在IDEA里用Maven原型创建maven-archetype-webapp,然后把src/main/java、src/main/resources手动建出来,再把pom.xml里缺的依赖补齐。很多新人对“IDEA新建JSP项目”不熟,其实关键就是两点:第一,项目必须是War类型打包方式;第二,必须配置Web目录与Artifact,否则JSP页面无法被Tomcat访问。如果看到404错误,大概率是Artifact没有正确配置“lib”依赖。
依赖方面,需要注意jstl一定要用1.2版本(1.1和1.0的坑很多),servlet-api、jsp-api打provided作用域,不要把Tomcat自带的类打进去。
3. 数据库设计的几个关键决策
数据库设计是整个系统最不能回头的东西。表结构一旦定了,后面所有代码都围绕它转。我当时设计的时候反复推翻过几次,最后沉淀下来六个核心表:用户表、分类表、商品表、订单表、订单明细表、评论表。
3.1 核心表结构与关系
tb_user:用户表。字段有id、username、password(MD5或BCrypt加密)、phone、address、user_type(0管理员/1卖家/2买家)、status、create_time。tb_category:分类表。id、name、sort、status。农产品可以分“蔬菜”“水果”“粮油”“畜禽”等。tb_product:商品表。id、category_id、seller_id、name、sub_title、description、main_image、price、stock、sales、status(0待审核/1在售/2下架)、create_time、update_time。tb_order:订单表。id、order_no(业务单号)、buyer_id、seller_id、total_price、status、receiver_name、receiver_phone、receiver_address、create_time、pay_time、deliver_time、finish_time。tb_order_item:订单明细表。id、order_id、product_id、product_name、product_image、price、quantity、subtotal。tb_comment:评论表。id、product_id、buyer_id、content、rating、create_time。
关系上,商品表通过seller_id关联用户表,通过category_id关联分类表;订单通过buyer_id和seller_id分别关联买卖双方;订单明细指向商品ID。
3.2 字段类型与设计细节
有几个字段选择想重点说一下:
价格字段用DECIMAL(10,2),不用float和double。为什么?因为二进制浮点数无法精确表示小数,算总价的时候会出现10.999这种尴尬结果。有人问那Java实体类用什么类型?项目里我用的BigDecimal,在MyBatis里写了typeHandler自动转换。如果不追求极致规范,用Double配合四舍五入也能做,但作为经验分享,我还是推荐整条链路都用BigDecimal。
状态字段用TINYINT,同时配一个常量类统一管理。像商品状态,0待审核、1在售、2下架、3审核不通过,如果直接写死数字在代码里,后面维护时根本分不清。我的做法是建一个ProductStatus常量接口,把0/1/2/3定义成静态成员,代码里永远写常量名不写裸数字。
商品表里冗余了seller_name和category_name,订单明细里冗余了product_name、product_image、price。这在“三范式”看来是不合格的,但我认为小型项目里这种冗余非常值得推广。原因是:商品名称和价格可能随时改动,订单必须保存一张“历史快照”,不能让用户订单里的商品价格因为商家改价而变动。这是订单系统的基本素养。
3.3 索引设计与SQL示例
给高频查询字段加索引,让列表页不卡:
ALTER TABLE tb_product ADD INDEX idx_category_status (category_id, status); ALTER TABLE tb_product ADD INDEX idx_seller_id (seller_id); ALTER TABLE tb_order ADD INDEX idx_buyer_status (buyer_id, status);商品表的建表SQL核心片段大概是:
CREATE TABLE tb_product ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT NOT NULL, seller_id INT NOT NULL, name VARCHAR(100) NOT NULL, sub_title VARCHAR(200) DEFAULT '', description TEXT, main_image VARCHAR(255) DEFAULT '', price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, sales INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_category_status (category_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;我把status索引放在联合索引里,因为后台列表和前端列表都按“分类+状态”查询,这样可以走索引避免全表扫描。
4. 三大核心链路的代码实现细节
整个系统里最容易挂二维码、最容易被老师提问的就是这三块:商品发布上传、分页条件查询、下单与库存扣减。我一个个拆开讲。
4.1 农产品发布链路:文件上传与表单提交
发布页面是一个多部分表单,注意JSP里要给<form>加enctype="multipart/form-data",否则文件不会传到服务器。
Spring MVC里要做两件事:
第一,在spring-mvc.xml里配置上传解析器:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="maxUploadSize" value="5242880"/> <property name="defaultEncoding" value="UTF-8"/> </bean>maxUploadSize我设置的是5MB,你需要根据实际场景调整。不配这个Bean的话,Controller里用MultipartFile接参永远是null。
第二,Controller里接收文件并保存:
@PostMapping("/seller/product/add") public String addProduct(Product product, @RequestParam("file") MultipartFile file, HttpSession session) throws IOException { if (file.isEmpty()) { throw new BusinessException("请选择商品图片"); } // 保存文件到本地,文件名用UUID防止冲突 String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString().replace("-", "") + ext; String filePath = uploadDir + File.separator + fileName; file.transferTo(new File(filePath)); User seller = (User) session.getAttribute("user"); product.setSellerId(seller.getId()); product.setMainImage("/upload/" + fileName); product.setStatus(ProductStatus.PENDING); // 待审核 productService.addProduct(product); return "redirect:/seller/product/list"; }这里最容易踩的坑是文件保存路径。我不建议写死绝对路径,比如D:/xxx/upload,因为换台机器就要改代码。最好在配置文件里定义upload.dir,再用@Value注入。另外,file.transferTo你可能会遇到FileNotFoundException,原因是目标目录不存在。所以在保存前判断一下,目录不存在就先mkdirs()。
图片访问的静态资源映射也要配好。在你的spring-mvc.xml中加入:
<mvc:resources mapping="/upload/**" location="file:${upload.dir}/"/>这里需要注意,location必须以file:开头指向本地磁盘目录,否则Spring MVC默认去WEB-INF或者classpath里找,肯定404。
4.2 商品分页与条件检索链路
农产品列表页往往需要支持分类筛选、关键词搜索、价格排序、分页展示。手写LIMIT #{offset}, #{pageSize}并不复杂,但更省力的方案是引入PageHelper。
先在pom.xml引入:
<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.3.0</version> </dependency>然后在MyBatis配置里注册插件:
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="plugins"> <array> <bean class="com.github.pagehelper.PageInterceptor"> <property name="properties"> <value>helperDialect=mysql</value> </property> </bean> </array> </property> </bean>使用的时候,在Service方法里先调用PageHelper.startPage(pageNum, pageSize),紧接着执行的查询就是分页查询:
public PageInfo<Product> searchProducts(Integer categoryId, String keyword, Integer pageNum, Integer pageSize) { PageHelper.startPage(pageNum, pageSize); List<Product> productList = productMapper.selectByCondition(categoryId, keyword); return new PageInfo<>(productList); }Mapper XML里就是动态SQL拼接:
<select id="selectByCondition" resultType="com.example.entity.Product"> SELECT * FROM tb_product <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR sub_title LIKE CONCAT('%', #{keyword}, '%')) </if> AND status = 1 </where> ORDER BY create_time DESC </select>你特别注意一点,PageHelper.startPage只对紧接着的第一次SQL查询生效。如果startPage之后又执行了其他查询,分页就会被污染。所以Service方法里别在分页前做多余查询。
4.3 下单与库存扣减链路
这是整个系统最需要动脑子的地方。我先把思路说清楚:购物车建议直接放Session,因为游客也能加购物车,没必要强制登录才能加。但是结算时必须要求登录,而且要重新校验一遍购物车里的商品是否还能买。
购物车的数据结构用List<CartItem>,每个CartItem里面有productId、productName、price、quantity。加到购物车时先把Session取出来,有就追加,没有就新建。
下单的Service方法,核心逻辑分四步:
- 根据购物车里的productId集合,批量查出最新的商品信息。
- 与购物车里的商品逐一比对,包括商品是否在售、库存是否足够、价格是否变动(如果管理员或卖家改过价格,按最新价格为准)。
- 生成订单主表和订单明细表。
- 扣减库存,增加销量。
第4步扣库存的时候,MyBatis里用这一条Update完成“原子扣减”:
UPDATE tb_product SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{productId} AND stock >= #{quantity}为什么强调stock >= #{quantity}?因为这是防止超卖的兜底条件。在单机、单库的情况下,这条SQL本身就能保证不会扣成负数。执行完以后判断updateCount == 1,如果为0说明库存不足,直接抛异常终止整个事务。
接下来是订单号生成,我用的策略是yyyyMMddHHmmss + 随机四位 + 用户ID后两位,不用数据库自增ID当订单号,因为订单号要给人看、要尽量唯一。生成之后放到tb_order.order_no字段。
整个方法必须加上@Transactional注解,因为多个商品一起下单时,任何一个商品库存不足,前面的扣减都要回滚,不能让用户买一半成功一半失败。
@Transactional(rollbackFor = Exception.class) public Long createOrder(Long buyerId, List<CartItem> cartItems, OrderAddress address) { // ...校验库存、计算总价、生成订单、扣减库存 }这里有个细节:rollbackFor一定要指定为Exception.class。默认情况下Spring事务只回滚RuntimeException,如果Service里抛出的是受检异常,事务不会回滚,库存就白白扣掉了。我见过很多人在这个问题上翻车,演示的时候数据错乱。
5. 部署与排错:从本地Tomcat到WAR包的心路历程
JSP项目的部署方式跟Spring Boot完全不同,不依赖内嵌Tomcat,而是要打WAR包放到外部容器。很多第一次接触传统JSP项目的人看到“打包war”这个需求就慌了,其实流程很固定。
5.1 WAR包构建步骤
在IDEA右侧Maven面板执行mvn clean package,构建完成后在target目录下能找到.war文件。如果你是直接在IDEA里用内置Tomcat启动,那不需要自己打WAR包,IDEA会自动处理Artifact。两种方式我都在用,平时调试用内置Tomcat,最后交付演示我把WAR包丢到外置Tomcat的webapps目录,启动Tomcat后自动解压部署。访问路径通常是http://localhost:8080/项目名/,其中项目名默认是WAR包文件名。
有一种编译路径问题非常典型:jsp文件修改后不生效。原因是Tomcat对JSP的预编译或缓存机制,新版本Tomcat默认开启JSP缓存。排查时可以先在Tomcat的work目录下看有没有生成对应的Java和Class文件,如果没有,说明JSP没被编译,那大概率是web.xml里JSP servlet映射或者jsp-api依赖的问题。
5.2 经典故障排查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 列表页图片裂开 | 静态资源映射没配/路径不对 | F12看图片URL,再到浏览器直接访问该URL看返回码 |
| 中文乱码 | JSP编码不一致/请求编码过滤器缺失 | JSP头部加pageEncoding="UTF-8",web.xml配置CharacterEncodingFilter |
| 表单提交后400 | 实体类字段与表单name不匹配 | 检查Product字段和input的name属性 |
| 上传报FileNotFoundException | upload.dir目录不存在 | 代码里先mkdirs()再transferTo |
| 数据库连接失败 | MySQL版本驱动不匹配 | MySQL 8必须用com.mysql.cj.jdbc.Driver并加时区参数 |
| 下载的模板页面样式全丢 | JSP引用了绝对路径但项目部署在子目录 | 使用${pageContext.request.contextPath}拼接静态资源路径 |
第6条我想展开讲一下,这是JSP项目里高频出现的路径问题。你如果在JSP里写/css/style.css,部署到根目录没问题,但部署到/demo/子目录下就全丢了。最好统一这样写:
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/style.css">或者用<% String basePath = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + request.getContextPath() + "/"; %>,在JSP页面顶部拼一个base变量,后面所有路径都基于它。这个做法很老套,但真的稳。
5.3 一个典型的完整排错链路
我拿一次真实的“上传图片后列表页不显示图片”问题来复盘完整排查思路:
第一步,打开浏览器开发者工具,看到图片请求返回404。
第二步,复制图片地址,形如http://localhost:8080/upload/abc.jpg,直接在浏览器打开,发现确实404。
第三步,查看本地磁盘目录,确认D:/project/upload/abc.jpg这个文件是存在的。
第四步,由此断定问题不在文件写入,而在URL到磁盘文件之间的映射。于是去看spring-mvc.xml,发现我把<mvc:resources mapping="/upload/**" location="file:${upload.dir}/"/>写成了location="file:${upload.dir}",少了结尾的斜杠,Spring MVC无法将abc.jpg拼到目录末尾。
第五步,加上斜杠重启,图片正常显示。
这个例子虽然简单,但足以说明JSP项目排错的核心方法论:先判断问题是出在页面层、控制层、服务层还是存储层,然后逐层缩小范围。不要一上来就怀疑代码逻辑,先用浏览器的Network面板看HTTP状态码,能过滤掉一半的问题。
6. 做完这个项目之后:值得扩展的四个方向
如果你学有余力,或者答辩时想跟别人拉开差距,有几个方向非常推荐在原有代码上加一加。
第一个是引入Redis做热点缓存。农产品首页的商品列表、热销榜单这些数据读多写少,完全可以查一次数据库后存Redis,设置5分钟过期。下单扣库存后主动删缓存,保证数据最终一致。这个小改动工作量不大,但能讲出的内容很多,比如缓存穿透、缓存雪崩、缓存与DB一致性。
第二个是把搜索功能换成全文检索。现在关键词搜索用的是LIKE '%keyword%',数据量大了会慢。你可以用Elasticsearch,或者更轻量的方案用MySQL全文索引,再进阶一点接一个倒排索引的讲解。这个是加分项,不需要真的写很多代码,把思路和实现讲清楚就很棒。
第三个是订单超时自动取消。农产品交易里,买家下单不付款很常见。可以写一个定时任务,每分钟扫描一次tb_order表,把超过15分钟未支付的待付款订单改成已取消,同时回滚库存。注意既要改订单状态,也要把扣掉的库存加回去,这两个操作放在同一个事务里。实现不复杂,但业务完整性一下子提升不少。
第四个是考虑往Spring Boot迁移。你了解了SSM的每个配置之后,会深刻体会到Spring Boot“约定优于配置”到底帮你做了什么。迁移过程本身也是一个很好的学习过程,比如web.xml变成配置类、DispatchServlet内嵌自动配置、MyBatis的starter引入。面试时被问“SSM和Spring Boot的区别”,你就有切身体会了。
做这个项目最大的收获,不是写了几百行代码,而是终于把JavaWeb里那些零散的知识点串成了一条线。从浏览器发请求,到Tomcat找到对应的Servlet,再到Spring MVC分发给Controller,Service处理业务,MyBatis操作数据库,最后把Model数据渲染到JSP页面,一次完整的请求闭环在你脑子里清晰得像地图一样。以后再学Spring Boot、再学微服务,底层逻辑都是这个骨架在撑着。
最后给个小建议:如果你也正在做类似课程设计,别急着写代码。先花两天时间把表结构设计好,把状态流转图(文字版也行)画出来,把页面的跳转关系列清楚。我踩过的坑,绝大多数都是因为设计阶段偷懒造成的。把这个项目完整走一遍,你对Java后端开发的信心会提升一大截。