简介:这份资源是面向高校计算机相关专业毕业生的SSM框架生鲜配送系统完整设计与实现方案,适合正在准备毕业设计、需要真实项目参考或答辩演练的同学。压缩包共1320个文件,约18.86MB,包含124个Java源文件、148个JSP页面、359个JavaScript脚本、165个CSS样式及大量图片素材,另有2个SQL数据库脚本、21个XML配置、2个properties配置和1份doc文档,覆盖后端业务逻辑、前端页面渲染、数据库建表与项目配置等完整环节。资源同时提供配套论文与答辩教程,可帮助读者理解系统架构、功能模块划分与关键代码实现,快速搭建可运行环境并梳理答辩思路。目前已有28人学习下载,适合作为课程设计或毕业设计的实战参考。
1. 生鲜配送系统到底在解决什么问题:从一单凌晨的配送说起
凌晨四点,某生鲜配送站的调度员打开后台,看到三十多张待配送订单,每张订单背后是不同品类的商品——叶菜要冷链、冻品要分区、活鲜要单独打包。如果靠人工排单,光是核对库存和路线就要花掉两个小时,等配送员出发时,早高峰已经堵在路上。生鲜配送系统要解决的核心问题,就是把这套「接单—分拣—调度—配送—签收」的流程从纸面搬到线上,让订单状态可追踪、库存扣减不超卖、配送路线能按区域聚合。
这个方向适合两类人:一类是正在做毕业设计、需要一套完整可演示的 Web 系统的学生;另一类是想了解 SSM 框架如何落地到真实业务场景的初级开发者。标题里的 SSM 指的是 Spring + SpringMVC + MyBatis 这套经典组合,JSP 作为视图层技术,虽然现在新项目用得少了,但在教学和传统企业项目里依然常见。整套系统的技术门槛不算高,但业务逻辑的完整度决定了它能不能真正跑起来——库存并发扣减、订单状态机、配送区域划分,这三个点是最容易翻车的地方。
2. 技术选型与架构拆解:为什么用 SSM + JSP 而不是 Spring Boot
2.1 分层架构的职责边界
SSM 的核心价值在于把「控制层—业务层—持久层」拆开,每层只做自己该做的事。Controller 负责接收前端请求、校验参数、返回 JSON 或跳转 JSP;Service 层承载业务规则,比如下单时先查库存再扣减、生成订单号、计算配送费;DAO 层通过 MyBatis 的 Mapper 接口与 XML 映射文件操作数据库。JSP 在这里的角色是服务端渲染视图,配合 JSTL 标签库展示商品列表、订单详情等页面。
这种分层的好处是调试时能快速定位问题:页面数据不对,先看 Controller 返回的 Model 里有没有值;值有但不对,查 Service 的计算逻辑;计算逻辑没问题,再看 SQL 查出来的原始数据。相比把逻辑全塞进 Servlet 的写法,SSM 的可维护性高出一个量级。
2.2 数据库表设计的六个核心表
生鲜配送系统的表结构不需要太复杂,但下面六张表是必须的,缺一张业务就跑不通:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| user | 用户信息 | id, username, password, phone, address |
| product | 商品信息 | id, name, category, price, stock, unit |
| order | 订单主表 | id, user_id, total_amount, status, create_time |
| order_item | 订单明细 | id, order_id, product_id, quantity, price |
| delivery | 配送记录 | id, order_id, courier_id, status, delivery_time |
| category | 商品分类 | id, name, parent_id, sort_order |
其中 order 表的 status 字段是状态机的核心,建议用整数枚举:0 待支付、1 已支付待分拣、2 配送中、3 已完成、4 已取消。每次状态变更都要在 Service 层做校验,比如「已完成」的订单不能再取消,「待支付」的订单不能直接跳到「配送中」。
2.3 项目目录结构与依赖配置
用 Maven 管理依赖时,pom.xml 里必须包含以下核心包。版本号根据实际 JDK 版本调整,JDK 8 配 Spring 5.x 比较稳:
<!-- Spring 核心 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.30</version> </dependency> <!-- SpringMVC --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.30</version> </dependency> <!-- MyBatis --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <!-- MyBatis 与 Spring 整合 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.1</version> </dependency> <!-- 数据库驱动,以 MySQL 为例 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- JSTL 标签库,JSP 页面用 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>目录结构按标准 Maven 布局走:src/main/java下分controller、service、dao、entity四个包;src/main/resources放spring-core.xml、spring-mvc.xml、mybatis-config.xml和jdbc.properties;src/main/webapp/WEB-INF/jsp放所有 JSP 页面。web.xml 里配置 DispatcherServlet 和 CharacterEncodingFilter,前者拦截所有请求,后者统一设 UTF-8 编码,避免中文乱码。
注意:jdbc.properties 里的数据库密码不要明文提交到代码仓库,本地开发可以用环境变量覆盖,或者至少加一层简单的加密。
3. 从零跑通核心业务:下单、扣库存、生成配送单
3.1 商品列表与详情页的数据流转
商品列表页的请求路径是/product/list,Controller 接收后调用 Service 的listAll()方法,Service 再通过 ProductMapper 的selectAll()查数据库。返回的 List 塞进 Model,JSP 用<c:forEach>遍历渲染。这里有个细节:生鲜商品的图片路径建议存相对路径,比如/static/img/product/001.jpg,不要存绝对路径,否则换服务器就挂。
@Controller @RequestMapping("/product") public class ProductController { @Autowired private ProductService productService; @RequestMapping("/list") public String list(Model model) { // 查询所有上架商品,按分类排序 List<Product> products = productService.listAll(); model.addAttribute("products", products); // 返回 /WEB-INF/jsp/product/list.jsp return "product/list"; } @RequestMapping("/detail/{id}") public String detail(@PathVariable Integer id, Model model) { Product product = productService.getById(id); if (product == null) { // 商品不存在时跳转到错误页 return "error/404"; } model.addAttribute("product", product); return "product/detail"; } }@RequestMapping定义在类上时,所有方法的路径都会带上/product前缀。@PathVariable用来取 URL 里的占位符,比如/product/detail/5会把 5 赋给 id 参数。Model 是 SpringMVC 提供的传值容器,JSP 里用${products}就能拿到。
3.2 下单接口的库存扣减与事务控制
下单是整个系统最容易出问题的地方。用户点「提交订单」后,后端要做四件事:校验库存、扣减库存、写入订单主表和明细表、返回订单号。这四步必须在一个事务里完成,任何一步失败都要回滚。
@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired private ProductMapper productMapper; @Autowired private OrderItemMapper orderItemMapper; @Override @Transactional(rollbackFor = Exception.class) public String createOrder(Integer userId, List<OrderItem> items) { // 1. 先校验库存并扣减,用行锁防止并发超卖 for (OrderItem item : items) { Product product = productMapper.selectForUpdate(item.getProductId()); if (product == null || product.getStock() < item.getQuantity()) { throw new RuntimeException("商品库存不足:" + item.getProductId()); } // 扣减库存,update 语句带 stock >= quantity 条件 int affected = productMapper.reduceStock( item.getProductId(), item.getQuantity()); if (affected == 0) { throw new RuntimeException("库存扣减失败,可能已被其他订单占用"); } } // 2. 生成订单号,格式:年月日时分秒 + 用户ID后四位 String orderNo = generateOrderNo(userId); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setStatus(1); // 已支付待分拣 order.setCreateTime(new Date()); orderMapper.insert(order); // 3. 写入订单明细 for (OrderItem item : items) { item.setOrderId(order.getId()); orderItemMapper.insert(item); } return orderNo; } }@Transactional的rollbackFor = Exception.class很关键,默认只回滚 RuntimeException,加上这个配置后所有异常都会触发回滚。selectForUpdate对应 SQL 里的SELECT ... FOR UPDATE,会锁住行记录,防止两个请求同时读到相同库存。reduceStock的 SQL 要写成UPDATE product SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},用数据库层面的条件判断兜底。
3.3 配送单生成与配送员分配
订单支付成功后,系统需要生成配送单并分配给配送员。分配逻辑可以简单按区域轮询,也可以让配送员手动抢单。教学项目里用轮询就够了,核心是根据用户地址里的区域字段匹配配送员负责的区域。
@Service public class DeliveryServiceImpl implements DeliveryService { @Autowired private DeliveryMapper deliveryMapper; @Autowired private CourierMapper courierMapper; @Override @Transactional(rollbackFor = Exception.class) public void assignCourier(Integer orderId, String area) { // 查询该区域当前配送单最少的配送员 Courier courier = courierMapper.selectLeastLoadedByArea(area); if (courier == null) { throw new RuntimeException("该区域暂无可用配送员"); } Delivery delivery = new Delivery(); delivery.setOrderId(orderId); delivery.setCourierId(courier.getId()); delivery.setStatus(0); // 待取货 delivery.setCreateTime(new Date()); deliveryMapper.insert(delivery); // 更新配送员当前负载数 courierMapper.incrementLoad(courier.getId()); } }selectLeastLoadedByArea的 SQL 用ORDER BY current_load ASC LIMIT 1实现,简单但有效。配送单的 status 字段独立于订单状态,0 待取货、1 配送中、2 已送达,配送员在小程序或 App 端更新这个状态,后台再同步更新订单主表的状态。
4. 避坑与排查:六个让系统跑不起来的细节
4.1 中文乱码从请求到响应全链路排查
现象:JSP 页面提交的表单数据存进数据库变成问号,或者查询出来的中文在页面显示为乱码。
原因通常有三层:第一层是 web.xml 里没配 CharacterEncodingFilter,或者配了但forceEncoding没设 true;第二层是数据库连接 URL 没加useUnicode=true&characterEncoding=utf8;第三层是 JSP 页面顶部没写<%@ page contentType="text/html;charset=UTF-8" %>。
解决:三层都检查一遍。web.xml 的 filter 配置要放在所有 filter 最前面,数据库 URL 加上编码参数,JSP 页面统一加 page 指令。如果用的是 MySQL 8,还要确认数据库和表的字符集是 utf8mb4,否则 emoji 和部分生僻字会丢。
4.2 MyBatis 映射文件找不到或字段名不匹配
现象:启动时报Invalid bound statement (not found),或者查询结果里某些字段为 null。
原因:Mapper XML 文件没放在 resources 下对应的包路径里,或者mybatis-config.xml里的mapperLocations配置路径不对。字段为 null 通常是数据库列名和实体类属性名不一致,比如数据库是create_time,实体类是createTime,但没开驼峰映射。
解决:在mybatis-config.xml里加<setting name="mapUnderscoreToCamelCase" value="true"/>,或者在 SQL 里用AS起别名。Mapper XML 的 namespace 必须和 Mapper 接口的全限定名完全一致,差一个字母都会报错。
4.3 事务不生效的三种典型场景
现象:下单时库存扣了但订单没生成,或者订单生成了但库存没扣。
原因一:@Transactional注解的方法不是 public 的,Spring 代理不会拦截。原因二:同类内部方法直接调用,比如 Service 的 A 方法调 B 方法,B 方法上的事务注解不会生效。原因三:异常被 catch 了没往外抛,事务管理器感知不到异常。
解决:事务方法必须是 public;同类调用要么拆到另一个 Service,要么用 AopContext.currentProxy() 拿代理对象;catch 块里要么重新抛出异常,要么手动调TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
4.4 库存超卖的并发问题
现象:压测时两个请求同时下单,库存只剩 1 件,结果两个订单都成功了。
原因:先查库存再扣减的写法在并发下会读到相同的旧值。比如两个线程都查到库存为 1,都判断通过,然后都执行扣减,最终库存变成 -1。
解决:用SELECT ... FOR UPDATE锁行,或者直接用UPDATE product SET stock = stock - #{qty} WHERE id = #{id} AND stock >= #{qty},根据 affected rows 判断是否成功。后者性能更好,因为不需要显式锁等待。如果业务允许,还可以在数据库层加stock >= 0的 CHECK 约束兜底。
4.5 JSP 页面引入静态资源 404
现象:CSS、JS、图片全部加载失败,浏览器控制台报 404。
原因:DispatcherServlet 在 web.xml 里配了/拦截所有请求,静态资源也被当成 Controller 请求处理了。
解决:在 spring-mvc.xml 里加<mvc:default-servlet-handler/>或<mvc:resources mapping="/static/**" location="/static/"/>。前者把找不到的请求交回给容器默认的 Servlet 处理,后者显式指定静态资源路径。推荐用后者,路径更清晰。
4.6 订单状态回退导致的数据不一致
现象:配送员点了「已送达」,但用户端显示的还是「配送中」,或者订单已经完成又被改回配送中。
原因:状态更新没有做前置校验,或者配送单状态和订单状态更新不在同一个事务里。
解决:在 Service 层写一个状态流转校验方法,定义合法的状态迁移路径,比如只允许 1→2、2→3,不允许 3→2。配送单和订单的状态更新放在同一个@Transactional方法里,任何一步失败都回滚。
5. 进阶技巧:用拦截器做登录校验与操作日志
登录校验如果每个 Controller 方法里都写一遍session.getAttribute("user"),代码会非常冗余。用 SpringMVC 的 HandlerInterceptor 可以在请求进入 Controller 之前统一拦截。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录页和静态资源 String uri = request.getRequestURI(); if (uri.contains("/login") || uri.contains("/static/")) { return true; } HttpSession session = request.getSession(); if (session.getAttribute("loginUser") == null) { // 未登录,重定向到登录页 response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }拦截器写完后要在 spring-mvc.xml 里注册,指定拦截路径和排除路径。<mvc:interceptor>里的<mvc:mapping path="/**"/>拦截所有请求,<mvc:exclude-mapping path="/login"/>排除登录接口。这样配置后,所有业务接口都会先走 preHandle,未登录直接跳转,Controller 里就不用再重复判断了。
操作日志可以用 AOP 做。定义一个@Log注解,在需要记录的方法上标注,然后写一个切面拦截这个注解,把方法名、参数、当前用户、时间写入日志表。日志表建议加索引在create_time和user_id上,方便后续按时间范围或用户查询。切面的@Around通知里要注意异常处理,日志写入失败不能影响主业务,所以日志操作要单独 try-catch。
还有一个容易被忽略的点:JSP 页面里的表单重复提交。用户点两次提交按钮就会生成两笔订单。解决办法是在下单接口里加 token 校验,页面渲染时生成一个 UUID 存进 session 和隐藏域,提交时比对,一致才处理并立即清除 session 里的 token。这个逻辑放在拦截器或 Controller 里都行,关键是 token 只能用一次。
我自己做这类系统时养成的习惯是:每加一个功能,先在纸上画出数据流向——从哪个页面发起、经过哪个 Controller、调了哪个 Service、最终改了哪张表。画完再写代码,能省掉大量调试时间。生鲜配送系统的业务复杂度不算高,但状态和库存这两个东西一旦出错就是连锁反应,宁可前期多花半小时理清,也别等上线后半夜被叫起来查数据。希望帮到你。
本文还有配套的精品资源,点击获取