农产品电商系统核心设计:订单状态机与库存一致性实战
2026/9/19 13:00:39 网站建设 项目流程

简介:一份基于Java的农产品网上销售系统设计与实现文档,面向计算机相关专业毕业生、Java Web开发初学者及需要完成课程设计的学生。文档以实际应用为背景,围绕农产品网上销售这一场景,重点阐述电子商务相较实体店在降低成本、信息传递及时等方面的优势,并规划了系统的需求分析、功能设计、数据库设计以及后期维护等完整流程,技术路线采用JSP与MYSQL数据库,结构清晰,可作为毕业设计论文撰写或系统开发初期的参考模板。资源仅含1个docx文档,压缩包大小1.27MB,包含中文摘要、英文摘要、目录以及正文内容,便于直接阅读、批注与修改。已有71人学习下载,适合需要借鉴农产品电商系统设计思路、了解JSP+MYSQL开发流程并完成论文写作的读者。

1. 先想清楚系统边界,再做农产品电商

农产品网上销售系统和普通商城最大的区别,不在页面而在库存:蔬菜水果有规格、有冷链、有损耗,订单一旦超出实际库存,售后成本远比数码产品高。所以这类系统的设计与实现,核心是围绕「库存—订单—履约」三条线做一致性设计,而不是把前端页面堆完就算交付。下面按 Java 技术栈为主线,讲清楚从需求分析、表设计、订单状态机到代码落地的每一步,中间给出可以直接抄走的 SQL、Service 层代码和部署命令。适合正在做课程设计、毕业设计,或打算在简历里补一个完整交易系统项目的 Java 开发者。读完你应该能回答「为什么状态机比大量 if 判断可靠」「为什么下单要用数据库行锁」这类面试追问。

2. 需求与数据建模:把「卖菜」翻译成数据库表

2.1 角色与用例:这类系统通常有三个边界

农产品销售系统的角色比普通 B2C 商城简单,常见做法是分成三级:前台用户(逛、下单、查物流)、后台运营(上架、调价、发货、处理售后)和系统管理员(账号、权限、数据统计)。如果做毕业设计,不必把后台再做成一堆角色权限的 RBAC 大杂烩,用户表加一个 role 字段就够了,功能上把「运营」和「管理员」合并成一个后台入口更贴近真实小团队的使用方式。

用例图建议在需求分析阶段就画,但要控制粒度。比如把「用户下单」拆成「加入购物车、提交订单、支付订单、取消订单」,把「后台发货」拆成一个独立用例,这样 E-R 图和数据字典能对齐到用例,避免论文里用例图和表结构两张皮。我在建模时习惯先定 5~6 个核心用例,每个用例对应一个 Service 方法组,后续写代码就是照着用例清单填补。

提示:把用例图画得过于复杂是常见问题。答辩老师问「为什么这里有这张表」,答不上来比少画一张图更掉分。

2.2 核心表设计:四张表,带数据字典

订单类系统的核心表按「一单一品」或「一单多品」区分。农产品卖的是散装称重或按箱起批,同一个订单里通常同时有「土豆 x 3 斤」和「黄瓜 x 2 箱」,所以订单主表 + 订单明细表是标准做法。下面给出商品表结构,数据字典需要包含字段注释。

CREATE TABLE product ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, product_name VARCHAR(100) NOT NULL COMMENT '商品名称', unit VARCHAR(10) NOT NULL DEFAULT '斤' COMMENT '销售单位:斤/箱/份', price DECIMAL(10,2) NOT NULL COMMENT '单价,保留两位小数', stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '剩余库存', category_id BIGINT NOT NULL COMMENT '商品分类ID', cover_url VARCHAR(255) DEFAULT '' COMMENT '图片地址', shelf_status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='农产品商品表';

使用DECIMAL(10,2)而不是 double 类型,因为 Java 的 double 在做金额运算时会出现精度丢失,涉及钱的字段必须在数据库和 Java 两端都用十进制类型。stock使用 INT UNSIGNED,避免批量导入时出现负库存。表与表之间加外键也可以,但生产环境基本不会依赖数据库外键,而是通过 Service 层保证引用完整性——外键在删除、更新时容易造成锁竞争。课程设计里可以保留外键用于展示 E-R 关系,但业务代码不要依赖它。

订单和订单明细表的设计是重点:

CREATE TABLE orders ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT '业务订单号,供用户查询', user_id BIGINT NOT NULL COMMENT '下单用户ID', total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总额', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:见订单状态机', consignee VARCHAR(50) NOT NULL COMMENT '收货人', phone VARCHAR(20) NOT NULL, address VARCHAR(200) NOT NULL, remark VARCHAR(255) DEFAULT '', pay_time DATETIME DEFAULT NULL COMMENT '支付时间', ship_time DATETIME DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; CREATE TABLE order_item ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, product_name VARCHAR(100) NOT NULL COMMENT '下单时商品名快照', unit VARCHAR(10) NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', quantity INT NOT NULL COMMENT '购买数量', amount DECIMAL(10,2) NOT NULL COMMENT '该项小计' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';

两个设计点要特别说明。order_item里冗余存了product_namepriceunit,这叫作「快照」。上线之后商品可以改名、调价、下架,但订单打印出来的必须是用户下单当时的信息。如果订单明细再 join 商品表取名称,商品一改历史订单就跟着串味。orders表的 order_no 是业务单号,要和数据库主键 id 分开。用 id 直接给用户看,等于把数据库容量和增长情况暴露出去,也容易被遍历。

2.3 订单状态机:从 if 堆砌到可审计流转

订单状态是这类系统的灵魂。最简单的做法是把状态塞成一堆字符串,在 Service 方法里到处if (order.getStatus() == 1) { ... },写起来一时爽,后面改需求时每个方法都要翻一遍。更可靠的思路是先整理出状态表,明确谁允许从哪个状态跳到哪个状态,再落代码。

以农产品电商为例,常见状态定义可以这样安排:

状态值含义允许进入的动作可跳转状态
0待支付用户提交订单已取消、已支付
1已支付待发货用户支付成功已取消、已发货
2已发货运营后台填写物流单号已完成、退款中
3已完成用户确认收货或系统自动确认无(终态)
4已取消用户取消或超时未支付无(终态)
5退款中用户发起退款申请已取消

其中「已支付待发货 → 已取消」这个分支看起来不合理,但现实中会发生:用户付了钱没发货,申请退款,后台审核通过,订单就得从已支付回到已取消。如果不允许这个流转,退款单会跟订单状态对不上账。

状态机在 Java 里的落地方式是定义一个枚举:

public enum OrderStatus { PENDING_PAYMENT(0, "待支付"), PAID(1, "已支付待发货"), SHIPPED(2, "已发货"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"), REFUNDING(5, "退款中"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public boolean canTransitTo(OrderStatus target) { switch (this) { case PENDING_PAYMENT: return target == PAID || target == CANCELLED; case PAID: return target == SHIPPED || target == REFUNDING; case SHIPPED: return target == COMPLETED || target == REFUNDING; default: return false; } } }

这里canTransitTo把允许流转放到一个方法里,后面不管是在 Controller 层做校验还是在 Service 层做校验,都是调这一个方法,不会出现「一个地方允许、另一个地方漏了」的偏差。状态机的另一个好处是:将来新增状态(比如「待配货」),只需要改枚举和数据库字典,不需要把所有 Service 方法翻一遍。

注意:枚举类里不要直接写业务逻辑,比如「是否允许退款」应该放到 Service 层判断。状态机只负责回答「能不能转换」,不负责回答「为什么要转换」。

3. 技术选型与项目骨架:Spring Boot 为主,兼顾 SSM 认知

3.1 单体架构与分层:为什么课程设计不该一上来就微服务

农产品网上销售系统最常见的方案是单体应用 + MySQL,需要缓存热点商品时加 Redis。Spring Boot 作为启动框架,里面本质还是 Spring MVC 那套 Controller-Service-Mapper 流程。如果毕业设计论文要求写「基于 SSM」,SSM 和 Spring Boot 的区别只是装配方式不同,把 Spring Boot 项目改成 XML 配置的 SSM 工程也不难,但建议优先用 Spring Boot,因为自动装配、内嵌 Tomcat 和 starter 机制能省掉大半配置时间,省下来的时间正好用在后端业务一致性设计上。

注意,网上销售系统的「设计」不只是画架构图,更是划分代码边界。整个工程推荐按包结构划分:

com.farm.shop ├── controller # HTTP 入参校验与响应封装 ├── service # 业务逻辑:库存、订单、退款 ├── mapper # MyBatis 接口,配合 XML 写 SQL ├── entity # 数据库表对应的实体类 ├── dto # 前端入参对象,与 entity 解耦 ├── common # 统一响应、异常、状态枚举 └── config # 拦截器、跨域、序列化配置

这里要特别注意 entity 和 dto 分开。不少人图省事,把数据库实体直接暴露给前端,前端传什么字段就往实体类上映射。订单提交这种场景,前端要是多传一个id或者totalAmount,就可能把后台金额覆盖掉。正确做法是 Controller 接收一个CreateOrderDTO,里面只有商品 ID 列表、数量、收货地址等必要字段,金额一律在 Service 层按数据库单价重新计算。

3.2 依赖与配置:最小可运行 pom 与连接池参数

pom.xml 只需要引入 spring-boot-starter-web、mybatis-spring-boot-starter 和 mysql-connector-j。如果项目里要做缓存,再加 spring-boot-starter-data-redis。这里给一份常用配置:

spring.datasource.url=jdbc:mysql://localhost:3306/farm_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false spring.datasource.username=root spring.datasource.password=your_password spring.datasource.hikari.maximum-pool-size=20 spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=30000 mybatis.mapper-locations=classpath:mapper/*.xml mybatis.type-aliases-package=com.farm.shop.entity

连接池参数被问的概率很高。maximum-pool-size 不建议调太大,MySQL 默认连接数有限,20 足够支撑演示场景。connection-timeout 设 30 秒表示拿不到连接时报错而不是一直卡死,这决定了系统在数据库抖动时是「快速失败」还是「线程全部挂起」。mybatis.mapper-locations 这一行别漏,漏了 XML 里的 SQL 全部不会被加载,运行时直接报Invalid bound statement (not found)

3.3 Controller、Service、Mapper 的调用约定

一个典型的三层调用链是:Controller 接收 POST 请求,把 JSON 反序列化成 DTO 后调用 Service;Service 做库存校验、下单、扣库存,全部包在一个事务里;Mapper 是接口,真正 SQL 写在 XML。需要注意的边界是 Controller 只做「参数整理 + 结果封装」,不允许出现业务操作,连「查询商品列表后判断一下库存」都算业务,应该放到 Service 层。

@RestController @RequestMapping("/api/product") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @GetMapping("/page") public Result<PageResult<ProductVO>> page( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String keyword) { PageResult<ProductVO> result = productService.queryPage(pageNum, pageSize, keyword); return Result.success(result); } }

这里使用构造器注入而不是@Autowired字段注入,在 Spring 4.3+ 下是常见做法,好处是依赖关系在对象创建时就被固定,单元测试可以直接 new Service 并传入 mock,不用启动 Spring 容器。默认分页值写在@RequestParam里,前端不传也不会抛异常。

Mapper 接口写法为:

public interface ProductMapper { List<Product> selectPage(@Param("keyword") String keyword, @Param("offset") int offset, @Param("limit") int limit); int countPage(@Param("keyword") String keyword); }

这里 count 和 list 分开写,是为了分页组件能拿到总数。如果合并成一个查询再数数量,数据量大时内存会爆掉,而且总数和列表分页的统计口径容易不一致。

4. 交易链路代码落地:分页、下单、扣库存与发货

4.1 商品分页查询:PageHelper 好用,但小心 TOTAL

商品列表是访问量最大的查询,常见做法是 MyBatis 配合 PageHelper。PageHelper 的原理是拦截下一次执行的 SQL,自动拼上LIMIT ?,同时通过 COUNT 查询得到总数。使用起来只有三行代码:

public PageResult<ProductVO> queryPage(int pageNum, int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); List<Product> productList = productMapper.selectPage(keyword); PageInfo<Product> pageInfo = new PageInfo<>(productList); List<ProductVO> voList = productList.stream() .map(this::toVO) .collect(Collectors.toList()); PageResult<ProductVO> result = new PageResult<>(); result.setList(voList); result.setTotal(pageInfo.getTotal()); result.setPageNum(pageNum); result.setPageSize(pageSize); return result; }

PageHelper.startPage必须紧挨着 Mapper 调用,中间不要再查别的表,不然分页插件会把不相关的 SQL 也拼上 limit。这一点在 Mapper 中先做了 count 统计再分页时会踩坑。另一个坑是分页之后productList被 PageHelper 包装成Page对象,取getTotal()可以走PageInfo,但不能在 Controller 层直接把这个 Page 对象返回给前端,因为它的字段结构不稳定。

如果要完全避开 PageHelper 这种「魔法」,手写 SQL 更直白:

<select id="selectPage" resultType="com.farm.shop.entity.Product"> SELECT id, product_name, unit, price, stock, cover_url FROM product WHERE shelf_status = 1 <if test="keyword != null and keyword != ''"> AND product_name LIKE CONCAT('%', #{keyword}, '%') </if> ORDER BY create_time DESC LIMIT #{offset}, #{limit} </select>

注意这里使用了#{keyword}而不是${keyword},MyBatis 会把前者解析为预编译占位符,有效避免 SQL 注入。有人为了图方便写${keyword}拼字符串,用户往商品名称里传一个' OR 1=1 --,整个商品表就被人拉走了。所有接受用户输入的位置,都应该检查是不是用了#{}

4.2 下单扣库存:事务、行锁与防超卖

下单的并发正确性是整个系统最容易被追问的地方。假设库里还剩 3 斤苹果,两个用户同时下单 2 斤,如果 Service 层代码是「先查库存,再判断,再更新」,两个请求都可能查到 3,然后各自减 2,最后库存变成 -1,这就是经典超卖。比较稳妥的落地做法是:更新时把库存判断条件直接写进 SQL,依赖 MySQL 行锁保证原子性。

UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}

执行这条 SQL 时,如果影响行数为 0,说明库存不足,直接抛异常提示用户「库存不够」。整个过程不需要先 SELECT 再 UPDATE,也不需要给整张表加锁。库存充足时,这一条 UPDATE 就完成了校验和扣减,后续不管并发来多少请求,数据库行锁会保证它们是串行更新的。

完整下单方法的框架如下:

@Transactional(rollbackFor = Exception.class) public Long createOrder(Long userId, CreateOrderDTO dto) { // 1. 计算总金额并预扣库存 BigDecimal total = BigDecimal.ZERO; List<OrderItem> items = new ArrayList<>(); for (CartItemDTO item : dto.getItems()) { int updated = productMapper.deductStock(item.getProductId(), item.getQuantity()); if (updated == 0) { throw new BizException("商品库存不足"); } Product product = productMapper.selectById(item.getProductId()); BigDecimal amount = product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())); total = total.add(amount); items.add(buildOrderItem(product, item.getQuantity(), amount)); } // 2. 写订单主表和明细 Order order = buildOrder(dto, total); orderMapper.insert(order); items.forEach(item -> { item.setOrderId(order.getId()); orderItemMapper.insert(item); }); // 3. 返回业务单号 return order.getId(); }

@Transactional里的rollbackFor = Exception.class必须显式声明。Spring 默认只在遇到运行时异常时回滚,如果 SQL 操作抛的是受检异常,不写这个参数,事务会处于已提交但数据没写全的状态。事务内部的原则是:先做校验和扣减,再写订单数据;任何一步失败,前面扣掉的库存都通过回滚恢复,不会出现「订单没建成功,库存却少了」的中间状态。

注意:deductStock返回影响行数之后,不要在同一个事务里去读 product 的旧库存再判断,否则又回到「先查后改」的并发漏洞。

如果项目用到了 Redis 做库存预热,下单也常见「Redis 预扣减 + 数据库兜底」的做法,但课程设计阶段可以先不做双重校验,把数据库这条 UPDATE 写正确,再谈缓存。

4.3 支付回调与订单状态流转:用条件更新代替 if 判断

支付环节在真实项目中对接微信支付或支付宝,需要商户号、证书、回调验签,成本较高。课程设计和简历项目里使用「模拟支付」接口即可:用户在前端点「确认支付」,后端直接把它当作支付成功回调,把待支付订单改成已支付。重点是回调接口要做幂等,不能因为前端点了两次,订单就被处理两次。

状态更新建议写成一个带条件的 UPDATE:

UPDATE orders SET status = #{targetStatus}, pay_time = NOW() WHERE id = #{orderId} AND status = #{expectStatus}

这条 SQL 把「当前状态必须是待支付」变成数据库层面的约束。两个并发请求同时来支付,只有第一个能更新成功,第二个影响行数为 0,直接提示「订单已处理」。用expectStatus这种乐观锁条件,比在 Java 代码里if (order.getStatus() == 0)判断更可靠,因为 Java 层的判断和数据库更新之间存在时间窗口,两个线程都可能通过判断。

后台发货也是同样的套路:

public void ship(Long orderId, String trackingNo, Long operatorId) { Order order = orderMapper.selectById(orderId); if (order == null || !OrderStatus.PAID.isCode(order.getStatus())) { throw new BizException("订单状态不正确,无法发货"); } int updated = orderMapper.updateStatusWithCondition(orderId, OrderStatus.SHIPPED.getCode(), OrderStatus.PAID.getCode()); if (updated == 0) { throw new BizException("订单已变化,请刷新后重试"); } logisticsMapper.insert(orderId, trackingNo, operatorId); }

这里的 Java 层判断不是用来保证并发的,而是为了给用户一个更友好的错误提示;真正保证状态不重不漏的是那条条件 UPDATE。发货前先查一次订单、再更新状态,是一个「快速失败」的设计思路,避免无效的数据库写入。

4.4 已支付订单取消与退款

订单取消不是简单把状态改掉,还涉及库存回补。取消已支付订单时,应该把明细里的商品数量加回库存表中,这一操作必须和状态更新放到同一个事务里。常见错误是后台点「取消订单」,用户看到状态变成已取消,但库存没有加回来,导致商品明明没货却还在卖。

@Transactional(rollbackFor = Exception.class) public void cancelPaidOrder(Long orderId, Long operatorId) { int updated = orderMapper.updateStatusWithCondition(orderId, OrderStatus.CANCELLED.getCode(), OrderStatus.PAID.getCode()); if (updated == 0) { throw new BizException("当前状态不可取消"); } List<OrderItem> items = orderItemMapper.selectByOrderId(orderId); for (OrderItem item : items) { productMapper.restoreStock(item.getProductId(), item.getQuantity()); } refundRecordMapper.insert(orderId, operatorId, "模拟退款成功"); }

模拟支付系统做到这个程度,已经覆盖「下单—支付—发货—取消—退款」最完整的链路。如果要再补一个功能,建议做订单超时未支付自动取消,用 Spring @Scheduled 每分钟扫描待支付超过 15 分钟的订单,调用上面的 cancel 方法即可,代码量不大,但能展示对定时任务和状态一致性的理解。

5. 安全部署与验证:答辩前把系统调到能演示、能追问的状态

5.1 部署启动与日志排查要用对命令

打包部署直接在项目目录执行mvn clean package -DskipTests,产物是一个可执行 jar。先用 dev 配置本地跑通,再切到 prod 配置上线:

mvn clean package -DskipTests java -jar target/farm-shop-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev

-DskipTests是跳过测试编译和执行,适合演示前快速构建;如果改了数据访问层代码,建议去掉这个参数跑一遍测试类,防止上线后才发现 SQL 写错。启动日志里出现 "Started FarmShopApplication" 字样才说明启动成功,如果看到端口占用或数据库连接失败,优先检查application.yml中的端口和数据库地址。实际操作中,把nohup java -Xms256m -Xmx512m -jar farm-shop.jar > app.log 2>&1 &放到脚本里,下次重启就不用重新敲一遍长命令。

5.2 安全拦截与跨浏览器兼容性检查

Mapper 层用#{}防 SQL 注入,接口层同样要防 XSS。Spring Boot 可以注册一个过滤器,把请求参数里的<script>onerror=等关键字转义或直接拒绝。商品名称、收货地址这类允许用户输入的字段,存入数据库之前必须经过清理,否则后台管理页面一打开就执行了恶意脚本。农产品销售系统的用户信任度比普通内容平台更敏感,一个被注入的页面可能让整个演示翻车。

跨浏览器验证按「旧 Edge、Chrome、Firefox、手机浏览器」四个环境检查一遍商品列表到下单的完整路径。常见坑不是 CSS 兼容,而是后端返回的 JSON 包含非 UTF-8 编码的中文,个别浏览器会乱码。把 MySQL 连接参数里的characterEncoding=utf8写对,前端统一用 axios 默认的 JSON 解析,基本不会出问题。

5.3 答辩前 10 分钟的自测路径

准备两个浏览器打开同一件只剩 1 库存的商品,同时提交订单,确认只有一个成功,另一个返回「库存不足」;把订单改成已发货后尝试取消,确认提示「当前状态不可取消」。这两条验证通过,事务和状态机的正确性就站得住脚。

下单后的库存回补,用 curl 一条命令也能验证:

curl -X POST http://localhost:8080/api/order/cancel \ -H "Content-Type: application/json" \ -d '{"orderId": 1024}' \ && curl "http://localhost:8080/api/product/detail?productId=1"

第一条请求把已支付订单取消,第二条立刻查商品详情,stock字段回到下单前的数字,说明取消订单和库存回补在同一个事务里生效了。把这条命令记到项目 README 里,以后每次改完订单相关代码,都先跑一遍再提交,比在页面上手工点十次更可靠。整个验证链路跑通,就不怕现场演示时被追问并发和状态一致性问题。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询