ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

农产品电商系统核心设计:订单状态机与库存一致性实战

农产品电商系统核心设计:订单状态机与库存一致性实战 简介一份基于Java的农产品网上销售系统设计与实现文档面向计算机相关专业毕业生、Java Web开发初学者及需要完成课程设计的学生。文档以实际应用为背景围绕农产品网上销售这一场景重点阐述电子商务相较实体店在降低成本、信息传递及时等方面的优势并规划了系统的需求分析、功能设计、数据库设计以及后期维护等完整流程技术路线采用JSP与MYSQL数据库结构清晰可作为毕业设计论文撰写或系统开发初期的参考模板。资源仅含1个docx文档压缩包大小1.27MB包含中文摘要、英文摘要、目录以及正文内容便于直接阅读、批注与修改。已有71人学习下载适合需要借鉴农产品电商系统设计思路、了解JSPMYSQL开发流程并完成论文写作的读者。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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 该项小计 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;两个设计点要特别说明。order_item里冗余存了product_name、price和unit这叫作「快照」。上线之后商品可以改名、调价、下架但订单打印出来的必须是用户下单当时的信息。如果订单明细再 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.urljdbc:mysql://localhost:3306/farm_shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse spring.datasource.usernameroot spring.datasource.passwordyour_password spring.datasource.hikari.maximum-pool-size20 spring.datasource.hikari.minimum-idle5 spring.datasource.hikari.connection-timeout30000 mybatis.mapper-locationsclasspath:mapper/*.xml mybatis.type-aliases-packagecom.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 后调用 ServiceService 做库存校验、下单、扣库存全部包在一个事务里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 ResultPageResultProductVO page( RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize, RequestParam(required false) String keyword) { PageResultProductVO result productService.queryPage(pageNum, pageSize, keyword); return Result.success(result); } }这里使用构造器注入而不是Autowired字段注入在 Spring 4.3 下是常见做法好处是依赖关系在对象创建时就被固定单元测试可以直接 new Service 并传入 mock不用启动 Spring 容器。默认分页值写在RequestParam里前端不传也不会抛异常。Mapper 接口写法为public interface ProductMapper { ListProduct 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 PageResultProductVO queryPage(int pageNum, int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); ListProduct productList productMapper.selectPage(keyword); PageInfoProduct pageInfo new PageInfo(productList); ListProductVO voList productList.stream() .map(this::toVO) .collect(Collectors.toList()); PageResultProductVO 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 idselectPage resultTypecom.farm.shop.entity.Product SELECT id, product_name, unit, price, stock, cover_url FROM product WHERE shelf_status 1 if testkeyword ! 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 11 --整个商品表就被人拉走了。所有接受用户输入的位置都应该检查是不是用了#{}。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; ListOrderItem 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 判断支付环节在真实项目中对接微信支付或支付宝需要商户号、证书、回调验签成本较高。课程设计和简历项目里使用「模拟支付」接口即可用户在前端点「确认支付」后端直接把它当作支付成功回调把待支付订单改成已支付。重点是回调接口要做幂等不能因为前端点了两次订单就被处理两次。状态更新建议写成一个带条件的 UPDATEUPDATE 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(当前状态不可取消); } ListOrderItem 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.activedev-DskipTests是跳过测试编译和执行适合演示前快速构建如果改了数据访问层代码建议去掉这个参数跑一遍测试类防止上线后才发现 SQL 写错。启动日志里出现 Started FarmShopApplication 字样才说明启动成功如果看到端口占用或数据库连接失败优先检查application.yml中的端口和数据库地址。实际操作中把nohup java -Xms256m -Xmx512m -jar farm-shop.jar app.log 21 放到脚本里下次重启就不用重新敲一遍长命令。5.2 安全拦截与跨浏览器兼容性检查Mapper 层用#{}防 SQL 注入接口层同样要防 XSS。Spring Boot 可以注册一个过滤器把请求参数里的script、onerror等关键字转义或直接拒绝。商品名称、收货地址这类允许用户输入的字段存入数据库之前必须经过清理否则后台管理页面一打开就执行了恶意脚本。农产品销售系统的用户信任度比普通内容平台更敏感一个被注入的页面可能让整个演示翻车。跨浏览器验证按「旧 Edge、Chrome、Firefox、手机浏览器」四个环境检查一遍商品列表到下单的完整路径。常见坑不是 CSS 兼容而是后端返回的 JSON 包含非 UTF-8 编码的中文个别浏览器会乱码。把 MySQL 连接参数里的characterEncodingutf8写对前端统一用 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?productId1第一条请求把已支付订单取消第二条立刻查商品详情stock字段回到下单前的数字说明取消订单和库存回补在同一个事务里生效了。把这条命令记到项目 README 里以后每次改完订单相关代码都先跑一遍再提交比在页面上手工点十次更可靠。整个验证链路跑通就不怕现场演示时被追问并发和状态一致性问题。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进