SpringBoot+Vue果园预售系统实战:从订单设计到防超卖部署 1. 果园预售的业务形态为什么这个系统值得做1.1 从“树上现货”到“预售模式”的转变先聊个实际场景。果园里种的水果传统做法是成熟了摘下来再通过批发市场或者电商一件件卖。问题是成熟期集中的时候采摘、仓储、周转的压力全挤在一起遇到行情不好果子堆在库里一天一个价还没熟透的时候又没有人知道这批货将来能卖出多少资金全压在树上。预售模式恰好把这些痛点拆解开果子还在树上通过系统发起预售批次消费者提前下单预订果园拿到订单后心里有数成熟的果子按批次采摘、配送。对果园来说资金提前回笼采摘计划可以按订单安排对消费者来说锁定的是直采价格成熟后按批次发货通常比当季零售便宜。我做的这个系统就是给某村镇果园标题里的具体村名这里就不展开了下文统一叫“某果园”设计的一套预售管理平台。整个项目用SpringBoot搭后端、Vue做管理端和用户端、MyBatis操作MySQL从商品批次发布到用户下单支付再到后台对账发货走完一条完整的预售闭环。1.2 核心角色与业务流程梳理系统里有三类角色游客/注册用户、果园管理员、系统维护者。游客可以浏览预售批次但下单前必须注册登录管理员负责发布批次、维护品种、处理订单发货维护者做数据初始化和系统配置实际项目里通常和管理员合并。业务流程可以概括成一条链路管理员创建品种信息比如“金秋蜜桃”“红心猕猴桃”管理员按成熟期发起预售批次填写预估产量、预售单价、预计采摘日期、发货方式用户在小程序/H5端看到批次详情把商品加入购物车或直接购买用户提交订单并完成支付系统锁定该批次的预售库存后台订单状态变为“待采摘/待发货”管理员按订单安排采摘、打包、发货用户确认收货后流程结束。这里的关键和普通电商有一个明显差别普通电商卖的是现货库存是确定的静态数字果园预售卖的是“未来的果子”库存是预估产量发货时间也是区间。这个差异决定了数据库设计和接口设计都不能照搬电商模板。1.3 预售系统与传统电商的差异点做这个项目之前我一度想直接用开源商城改一改后来发现改起来比从零做还费劲原因就在三个差异上库存维度不同。商城库存对应具体SKU预售库存对应“批次品种”还要区分“已预订”“可预订”“余量”这些状态有些果园还会按大小果分级一个批次拆成多个规格。价格策略不同。预售通常按批次定价可能同一个品种两个批次的单价不一样改价只能影响后续购买不能改动已支付订单所以订单里必须存价格快照。交付节点不同。预售订单的履约时间是“预计采摘后N天内发货”不是立即发货系统里要能按批次批量查询订单并生成出库单。把这些差异想清楚再去设计表结构和接口后面写代码基本不会返工。这也是我这次最想分享的第一点先吃透业务约束再谈技术选型。2. 从需求到表结构数据库设计的取舍2.1 核心表结构与字段含义数据库我设计成围绕“预售批次”这个核心实体展开没有走通用的商品SPU/SKU三层模型因为果园的SKU粒度很轻过度设计反而是负担。先看建表的关键片段CREATE TABLE fruit_variety ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 品种ID, name VARCHAR(32) NOT NULL COMMENT 品种名称如金秋蜜桃, unit VARCHAR(16) NOT NULL DEFAULT 斤 COMMENT 销售单位, description VARCHAR(512) COMMENT 品种描述, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE pre_sale_batch ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 预售批次ID, variety_id BIGINT NOT NULL COMMENT 品种ID, batch_no VARCHAR(32) NOT NULL COMMENT 批次编号如MHT-2025-01, title VARCHAR(128) NOT NULL COMMENT 批次标题用于前端展示, price DECIMAL(10,2) NOT NULL COMMENT 预售单价, total_stock INT NOT NULL COMMENT 预估总产量/可预售总量, stock INT NOT NULL COMMENT 剩余可预订数量, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, harvest_date DATE NOT NULL COMMENT 预计采摘日期, delivery_type VARCHAR(16) NOT NULL COMMENT DELIVERY/SELF_PICKUP, status TINYINT NOT NULL DEFAULT 1 COMMENT 1预售中 2已截止 3已结束, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_variety (variety_id), KEY idx_status (status) );订单表是另一块重头。我的设计是订单主表加订单明细表分开存好处是后续要拆包裹、按批次批量发货时明细数据还在不会因为主表结构变动而返工。CREATE TABLE order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号冗余展示用, user_id BIGINT NOT NULL, batch_id BIGINT NOT NULL, quantity INT NOT NULL COMMENT 购买数量, price_snapshot DECIMAL(10,2) NOT NULL COMMENT 下单时单价快照, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL COMMENT 10待支付 20已支付 30已发货 40已收货 50已取消, receiver_name VARCHAR(32), receiver_phone VARCHAR(20), receiver_address VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_batch_status (batch_id, status) );为什么要把price_snapshot单独存出来因为预售价格会随批次调整。如果订单表只存当时的单价后面批次改价、管理员调价格历史订单数据就对不上了。对账的时候售卖方和购买方都必须以订单里的快照金额为准。2.2 订单状态机与支付流水订单状态我设计成一组有序流转待支付 → 已支付 → 已发货 → 已收货中间穿插“已取消”这个分支。这里强调一下取消不是随便取消要区分是超时未支付自动取消还是用户手动取消或是管理员因为批次问题强制取消。不同取消来源退款流程不一样所以我专门加了一个cancel_reason字段记录来源。支付流水表单独建记录每次支付的请求号、支付渠道流水号、金额、状态。它的作用不是替代订单状态表而是和订单形成对账关系。一个订单可以有多次支付尝试比如第一次支付超时用户重新发起只有最后一次支付成功才回写订单状态。支付流水表还能在接入真实支付之前先用模拟支付把整套流程跑通。2.3 设计时容易忽略的三个字段第一version乐观锁字段。果园预售的热点数据集中在有限的几个批次上某个热门品种可能同时有几百个用户在看下单集中在同一行pre_sale_batch。这时用数据库行锁容易造成锁等待用悲观锁 SELECT FOR UPDATE 也不是不行但并发量上来以后简单乐观锁配合原子更新会更稳后面我会讲具体写法。第二批次状态和库存字段分离。预售截止之后剩余库存可能变成0也可能还有余量但不再销售这两种情况不能都用stock0表达所以status和stock要分开。前端页面根据status判断显示“已售罄”还是“预售已截止”语义更清楚。第三收货信息的冗余设计。果园预售从下单到发货可能间隔几十天用户收货地址可能改了只存外键关联地址表发货时还要回查地址麻烦不说历史数据还容易丢。我把receiver_name、receiver_phone、receiver_address三个字段直接冗余到订单表下单时复制一份后续地址变更不影响已支付订单的履约信息。3. SpringBoot后端核心接口与业务逻辑实现3.1 项目分层与MyBatis的映射细节后端我用了标准的分层结构Controller接收请求、Service写业务逻辑、Mapper层用MyBatis操作数据库。分包名就是常见的controller / service / mapper / entity / dto这个架构不花哨但足够清晰。MyBatis配置里有几个容易踩坑的点确实有必要单独说下划线转驼峰。数据库字段是order_no、create_time实体类是orderNo、createTime必须在application.yml里开启map-underscore-to-camel-case: true。不开启的话查询结果字段全是null还没有报错排查起来很费劲。批量操作。预售订单经常要按批次批量查询、批量更新用 foreach 动态SQL要注意参数长度限制数据量大时拆批处理。#{}和${}的区别。排序字段、动态表名只能用${}值是拼SQL的其他所有条件值都用#{}走预编译防SQL注入。这个写过一篇总结这次项目里凡是涉及用户输入作为排序条件的地方我都用了白名单校验避免直接拼接。3.2 并发下单与防超卖乐观锁的实现这是整个项目里我踩得最深的坑也大概率是评审老师最爱问的点怎么防止超卖先说我一开始的错误写法下单时先SELECT stock FROM pre_sale_batch WHERE id ?在Java里判断stock quantity成立再执行UPDATE pre_sale_batch SET stock stock - quantity WHERE id ?。单测和页面手工测试都正常但用并发脚本压测100个线程抢一个只有80库存的批次最后订单数超过80。原因在于“先查后更”不是一个原子操作两个线程同时查到stock80都认为可以买都执行扣减库存可能变成负数或者只扣了一次但生成了两个订单。修复思路是把判断和扣减合并成一个原子操作在Service层用一条更新语句完成UPDATE pre_sale_batch SET stock stock - #{quantity}, version version 1 WHERE id #{batchId} AND stock #{quantity} AND status 1对应MyBatis的Mapper方法int deductStock(Param(batchId) Long batchId, Param(quantity) Integer quantity);Service里判断int rows preSaleBatchMapper.deductStock(batch.getBatchId(), quantity); if (rows 0) { throw new BizException(库存不足或批次不在预售期); }注意这里没有用version #{version}作为条件而是用stock quantity直接做原子扣减。这样更简单也不用先查版本号。想保留乐观锁就两个条件一起用效果更稳。同时version字段仍然维护着后续做对账数据防篡改用得上。为了让文章有完整的复现路径我把下单价到扣库存的Service方法整理成下面的伪代码。真实项目中还要加分布式锁但单机部署情况下原子更新已经足够Transactional(rollbackFor Exception.class) public Long createOrder(CreateOrderRequest req) { int rows preSaleBatchMapper.deductStock(req.getBatchId(), req.getQuantity()); if (rows 0) throw new BizException(库存不足); Order order buildOrder(req); orderMapper.insert(order); return order.getId(); }3.3 事务边界与支付回调的幂等处理事务边界是我写代码时反复掂量的地方。一个下单流程涉及的数据库操作包括扣库存、创建订单、写流水、清购物车这四个操作必须在一个事务里任何一个失败都要回滚否则就出现“订单没建但库存扣了或者库存没扣但订单建了”的不一致状态。支付回调的幂等处理也要特别留意。接入支付渠道后支付成功通知可能到达不止一次网络重试、渠道重发等。如果每次回调都无条件把订单改为已支付可能覆盖用户主动取消的状态造成数据错乱。我的处理方式是回调接口先按订单号查询当前订单状态只有“待支付”状态才更新为“已支付”其他状态直接返回成功更新时用WHERE status 10作为条件影响行数为0就说明状态已变更直接忽略本次通知支付流水表记录本次通知的流水号重复流水号不二次处理。int rows orderMapper.updateStatusPaid(orderNo, 20, 10); if (rows 1) { paymentRecordMapper.updateSuccess(payNo); }这套逻辑在演示时也能自圆其说无论支付平台发几次通知订单都只被处理一次不会重复发货。4. Vue前端从页面原型到组件化联调4.1 用户端与管理端的功能划分前端我用Vue 2配合Element UI完成虽然Vue 3生态已经很完善但项目组原有的组件和后台模板基于Vue 2复用成本更低。如果从零起步直接选Vue 3也没有问题核心交互逻辑是通用的。用户端页面不多首页展示预售批次卡片、批次详情页展示“预计采摘时间”、“剩余数量”、“预售倒计时”、购物车/立即购买、个人中心我的订单。管理端页面反而更重品种管理、预售批次管理、订单管理、发货管理。一个比较实用的设计批次详情页的“剩余数量”和“预售倒计时”不依赖刷新而是进入页面时从后端拉取一次详情再用定时器做本地倒计时。库存数字用后端返回的最新值初始渲染避免反复刷新接口。更激进一点可以走WebSocket推送实时余量但演示场景下定时轮询已经够用我选择了定时器轮询的方式避免给新手增加不必要的复杂度。4.2 Token鉴权与路由守卫前端和后端分离后用Token做鉴权。用户登录成功后端返回一个带有效期的Token前端存到本地localStorage或pinia之后每次请求在axios拦截器里带上Authorization请求头。axios封装的核心写法const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message || 请求失败)) } return res }, error { return Promise.reject(error) } )路由守卫则负责页面层级鉴权。用户端只要登录就能访问管理端需要额外校验角色。我在路由配置里给管理端页面添加meta: { requiresAdmin: true }然后在全局前置守卫里判断router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) return } const role localStorage.getItem(role) if (to.meta.requiresAdmin role ! ADMIN) { next(/) return } next() })这个方案简单直观安全性足够满足内部系统需求。真实生产环境还要配合后端接口二次校验角色不能只靠前端路由挡。4.3 前后端联调的注意点开发期联调遇到最多的是跨域问题。我的做法是前端配置Vue CLI的devServer代理把/api开头的请求全部转发到http://localhost:8080devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样浏览器看到的前端地址和后端地址同源不需要后端额外配置CORS联调体验顺滑很多。另一个容易被忽略的地方是后端统一返回格式。我定义了全局响应体{ code, message, data }正常返回code200参数错误返回code400未登录返回code401服务端异常返回code500。前端拦截器只用判断code不用各种try-catch嵌套代码整洁不少。这个习惯从一开始就要坚持不然后端接口写多了返回格式五花八门前端联调和后期维护都非常痛苦。5. 从本地到服务器环境搭建与部署细节5.1 本地开发环境项目开发环境用的是JDK 1.8、Maven 3.6.3、MySQL 5.7、Node.js 14。这些版本组合非常成熟网上资料也多遇到问题好查。数据库初始化我准备了三个脚本建库脚本、建表脚本、演示数据脚本。演示数据脚本里插入两个品种、三个预售批次一个预售中、一个已截止、一个已结束以及一个管理员账号、一个测试用户账号。这样项目clone下来之后只需要顺序执行脚本再启动后端和前端就能看到完整数据。5.2 打包部署常见坑后端打包用mvn clean package -DskipTests得到jar包后java -jar直接跑。前端npm run build生成dist目录部署方式有两种第一种前端数据量小直接把dist下的静态文件复制到后端src/main/resources/static/里重新打包成一个jar访问http://ip:8080就是完整系统。优点是只启动一个进程部署简单适合演示项目。第二种前端单独部署到Nginx后端只听API请求。Nginx配置反向代理把/api/前缀的请求转发到后端服务server { listen 80; server_name example.com; location / { root /opt/fruit-ui/dist; index index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; } }我个人推荐第二种前后端分离部署后续迭代时可以分别重启互不影响。MySQL连接串里一定要显式指定时区。jdbc:mysql://localhost:3306/fruit_sale?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai不加serverTimezone本地连接时会报时区错误这是MySQL 8.0之后的常见问题。5.3 演示时的数据初始化技巧每次给评审或客户演示前我都建议把数据库恢复到初始状态。方法是写一个重置脚本把订单表和批次表清空、批次库存重置回初始值。演示过程中一旦出现数据乱了执行一下脚本就能满血复活不用手动删数据。这里分享一个小技巧把重置脚本绑定一个固定命令比如在项目根目录放一个reset_demo.sh里面连着执行DROP DATABASE、CREATE DATABASE、导入三个SQL文件。演示前跑一次全程不用五分钟。观众看到的效果就是无论前面演示怎么操作最后一键回到干净状态。6. 实测中的三类问题与解决方案6.1 超卖问题复现与修复完整链路前面提到过超卖这里把排查过程完整还原一遍。现象出现在第三轮联调。两个浏览器同时登录两个账号同屏抢购同一个只有2个库存的批次结果是两个订单都成功批次库存变成0看似没超卖。但是再用三个账号抢三个订单全部成功库存变成0订单数超了1。排查第一步我先在后端Service打个临时日志打印每个请求进入方法时的库存值。结果发现三个请求查到的库存都是2。原因正是MySQL默认隔离级别REPEATABLE READ下普通读是快照读三个事务同时开启查到同一份快照数据都在Java层判断“库存充足”然后各自执行UPDATE。这条UPDATE语句如果带了WHERE stock quantityMySQL才会在写操作时做当前读触发锁等待和行级判断而我当时写的是不判断库存的直接扣减所以最终只有第一个事务的扣减有效库存变成0另外两个事务更新0行却不报错Service也没拿返回值判断。修复方案就是前面代码里的“原子扣减影响行数判断”。改完后我用模拟并发脚本测启动50个线程同时对库存为30的批次下单最终订单数严格等于30库存归零多余请求全部抛出“库存不足”异常。这里补充一句影响行数是MyBatis更新操作的关键返回值很多人习惯忽略它但它是这类并发场景最重要的判断依据。6.2 支付回调重复通知导致的数据脏写第一次接模拟支付时我把支付状态更新写成order.setStatus(20); orderMapper.updateById(order);结果回调重试两次后我手动把订单状态改成“已取消”过五分钟支付回调又来了直接把状态改回“已支付”。虽然模拟支付环境问题不大但真实支付场景下这就是资损级别的bug。修好后的逻辑在3.3节提过核心是“只有待支付状态才允许改成已支付”。用WHERE status 10做条件更新并把支付通知的流水号存进流水表做唯一约束这样同一笔通知只会成功处理一次。现在不管模拟支付平台发多少次通知订单状态都非常稳定。6.3 前端跨域与登录态失效的连带问题系统做完后我把前端部署到Nginx后端用java -jar在8080端口启动。结果登录接口能通但登录后获取用户信息老是提示未登录。最初我以为是Token存漏了在浏览器控制台查看请求头发现Authorization已经带上了。再看后端日志发现每次请求都走到了“解析Token失败”的分支。排查过程是这样的先怀疑后端Token校验逻辑但本地联调明明正常再怀疑Nginx配置发现/api/已经代理到8080最后看请求日志才发现Nginx把/api/user/info转发到后端时Host头变成了127.0.0.1:8080且请求每次经过Nginx时被识别成不同的来源。这个问题再加上后端某些接口读到了Session而Session在跨域跨端口场景下默认不共享导致登录态判定失败。解决办法是在后端关闭Session依赖统一走Token鉴权注册一个拦截器在请求头里取Token不再从 Session 里取用户信息。前端也把所有状态从 redux/pinia 持久化到localStorage刷新页面不丢登录态。这两个改动之后Nginx部署场景彻底正常。三类问题解决完这个系统才算是真正能拿得出手的完整项目。我自己的体会是果园预售系统虽然业务并不复杂但坑都藏在并发、状态流转、部署环境这些容易被“教科书式教程”忽略的细节里。把这三个问题彻底搞清楚比多写十个CRUD接口都值。希望这篇分享能给正在做类似毕设或课程项目的同学一些参考。