资讯详情

资讯详情

Spring Boot + Vue打造果园水果预售系统:全链路设计与实践

前阵子帮一位做果园的朋友落地了一套水果预售系统技术栈选的是 Spring Boot Vue。他说自家果园主打当季杨梅和水蜜桃成熟期就那么三四周以前靠朋友圈接龙接单手写订单、人工对账、发货前挨个打电话问地址每年都手忙脚乱。其实这种情况在中小果园里非常普遍痛点就一句话上市时间短、订单分散、履约全靠人肉。做一套预售系统本质上不是把水果挂到网上卖那么简单而是要把预售活动、订单支付、库存锁定、批次发货、提货核销这一整条链路理顺。这篇文章我把整个设计和实现过程完整拆一遍包括业务模型、数据库设计、后端核心逻辑、前端页面、部署上线以及我实际踩过的坑给打算做类似项目的人一个可以直接参考的路线。1. 果园预售到底在预什么1.1 一个果园的真实预售痛点先还原一下真实场景。果农朋友的果园每年五月底到六月是杨梅季七月中到八月初是水蜜桃季中间还有一波被他忽略的柑橘类但他之前从来没想过把柑橘挂到网上因为柑橘上市期长走传统批发渠道就能出掉。杨梅和水蜜桃就不一样了成熟期极短采摘窗口可能就两周一旦错过最佳采摘时间损耗率直线上升。以前他在微信群里搞接龙用户报数量、发红包、留地址他需要一个一个核对然后等到采摘前一天按订单量去摘摘完再装箱发货。整个过程全凭经验最怕的是两件事一是接单接多了实际产量不够发不出货二是用户下了单没付款他按全额订单量去摘结果最后跑单多摘的果子只能低价处理。所以果园预售系统要解决的不是简单的“商品上架”而是要把“我先卖、后摘、再发货”这个期货式的销售模型做成线上闭环。系统里每一个预售活动本质上是向用户承诺未来某个时间点会有一批特定品质、特定数量的水果交付而果园则需要在活动上线前就把产量预估、规格包装、物流方式全部定好。1.2 预售与普通电商的三大差异平时做商城系统的人第一次接触预售最容易犯的错就是把预售单当成现货单来做。预售和普通电商至少有三个核心差异直接决定了数据库设计和代码逻辑第一库存模型不同。普通电商的库存是确定数入库多少就是多少卖完下架。预售的库存是“预估数”果园按去年的产量、今年的天气、树龄和开花情况估一个可售量这个数字很可能不准确所以库存设计上必须预留“可超卖比例”和“锁库上限”。第二支付时机不同。普通电商通常下单后立即支付或者货到付款。预售常见两种玩法全款预售和定金预售。全款预售逻辑简单用户下单支付全款成熟后统一发货定金预售更复杂用户先付一笔定金锁定名额等到发货前再补尾款。很多果园用的是全款预售因为省事但如果你做成定金尾款模式系统要处理尾款支付、逾期未付尾款的订单处理等额外逻辑复杂度会明显上升。第三履约方式不同。现货订单是仓库有货、随时打包发货。预售订单必须等果品成熟而且为了控制采摘和发货节奏通常会按批次发货比如第一批发 5 月 20 日之前下单的第二批发 5 月 21 日到 5 月 25 日下单的。这就需要在订单上打一个“发货批次”的标记而不是发货时才临时拼凑。1.3 角色与功能模块划分整个系统的用户角色分成三类用户、管理员果园主、操作员负责发货和核销的人。功能模块主要是这么几块用户端预售活动列表、活动详情、下单支付、订单列表、提货码展示、售后申请。 管理端预售活动管理创建活动、设置规格和价格、设置预售时间和发货批次、订单管理查看订单、处理退款、发货管理按批次生成发货单、录入物流单号、提货核销扫码或输入提货码。 基础能力登录注册、权限控制、文件上传放产品图和详情图、短信通知可选。最开始朋友觉得功能不多一个小程序就够了。但聊下来才发现真正复杂的是“批次”这个维度因为它牵涉到订单状态、库存扣减、发货计划三处逻辑。这也是我坚持用前后端分离做独立系统的原因这套东西如果塞在微信小程序里光是订单状态流转就能把代码写得很难维护。2. 技术方案选型为什么是 Spring Boot Vue2.1 后端选型的实际考量选 Spring Boot不是因为市面上教程多而是这个场景下 Spring Boot 确实合适。果园预售系统的核心是订单、支付、库存这些场景强依赖数据库事务比如下单锁库存必须和创建订单在同一个事务里一旦某一步失败库存不能白白扣掉。Spring Boot 的声明式事务非常成熟Transactional 一个注解就能搞定出了问题回滚规则也清晰。用别的语言不是不行但 Spring Boot 在这个领域属于“开箱即用”的水平尤其对中小团队和教学项目来说生态和社区支持都很完善。版本上我直接用了当前主流的 Spring Boot 3.x搭配 JDK 17依赖管理用 Maven。如果你或者你的团队对 Java 8 更熟用 Spring Boot 2.7 也行核心业务代码基本不用改。我选择 JDK 17 的原因是长期维护版本不用迁就老项目。2.2 前端选型的实际考量前端选了 Vue 3 Vite Pinia Element Plus用户端用了 Vant 做移动端适配。为什么不选 React完全是因为团队熟 Vue而且 Vue 的单文件组件写这种管理后台和信息展示类页面效率更高。Vite 做开发服务器是真的快改代码热更新几乎是秒级的调试体验比旧版的 webpack 舒服太多。用户端页面我在手机浏览器里跑没有单独做 App 或小程序。原因很实在果园的客户大多是老客和同城社区团购用户大家习惯在微信里直接点链接下单手机浏览器响应式页面完全够用。如果后续要推小程序Vue 3 的代码可以迁到 uni-app大多数业务代码可以复用。管理后台直接用 Element Plus表格和表单组件很全审批、筛选、分页这些功能基本不用自己造轮子。2.3 数据库与中间件选择数据库就是 MySQL 8.0存储订单、用户、活动这些核心数据也是整个系统里最关键的一层。Redis 在初期我并没有上线因为预估并发量并不高。但我在代码层面预留了 Redis 的位置活动详情页、热点数据缓存、预占库存计数这类场景用 Redis 会舒服很多后面果农的预售活动一开卖瞬间流量会集中在前几分钟这时候数据库扛不住频繁的库存 updateRedis 就能发挥作用。如果只是课程设计或内部小范围使用不引入 Redis 也没有问题用 MySQL 乐观锁完全可以支撑几千单的并发。权限认证我用了 Sa-Token 而不是 Spring Security。原因非常主观Spring Security 体系庞大OAuth2 和各种过滤器配置对业务开发来说有点重Sa-Token 提供注解鉴权和 Redis 会话集成代码侵入性低适合这种以业务为主的小系统。3. 数据库设计几个关键表把预售逻辑立起来3.1 核心表清单数据库设计是这套系统最值得花时间的地方。我的表结构总共八张表核心是活动、库存、订单、批次四张表。下面是一个表清单先把整体看在脑子里。表名用途说明user用户表存储账号、姓名、手机号、收货地址等activity预售活动表一个活动就是一个预售批次的水果如“杨梅季第一波”activity_spec活动规格表同一活动下的不同规格如 5 斤装、10 斤装order订单主表核心表记录下单用户、活动、金额、状态order_item订单明细表如果一个订单买了两种规格就需要拆成多个明细实际业务中我直接一个订单一个 spec保留明细表是为了扩展payment_record支付流水表记录支付平台返回的流水号、金额、支付状态delivery_batch发货批次表把一个活动拆成多个发货批次比如 6 月 5 日发第一批、6 月 12 日发第二批pickup_record提货核销记录表自提场景下用户出示提货码操作员核销后写入该表3.2 核心表字段设计拿预售活动表来说它的字段会影响后面所有逻辑。下面是简化后的建表 SQL我加了一些注释重点看库存设计和预售时间的字段CREATE TABLE activity ( id bigint NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 活动名称如高山杨梅首批预售, fruit_name varchar(50) NOT NULL COMMENT 果品名称, cover_url varchar(255) DEFAULT NULL COMMENT 封面图, price decimal(10,2) NOT NULL COMMENT 默认规格价格活动列表页展示用, original_price decimal(10,2) DEFAULT NULL COMMENT 原价用于划线展示, total_stock int NOT NULL COMMENT 预售总量预估产量, locked_stock int NOT NULL DEFAULT 0 COMMENT 已锁定库存下单未支付已支付, sold_stock int NOT NULL DEFAULT 0 COMMENT 实际已支付销量, status tinyint NOT NULL DEFAULT 0 COMMENT 0未发布 1进行中 2已结束 3已作废, start_time datetime NOT NULL COMMENT 预售开始时间, end_time datetime NOT NULL COMMENT 预售结束时间, delivery_start_time datetime DEFAULT NULL COMMENT 计划发货开始时间, delivery_end_time datetime DEFAULT NULL COMMENT 计划发货结束时间, pickup_type 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, PRIMARY KEY (id), KEY idx_status_time (status, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预售活动表;这里我把 total_stock、locked_stock、sold_stock 分开目的是让库存状态可计算。比如实际活动可售数量是 total_stock - locked_stock下单时预占库存就是把 locked_stock 加 1支付成功后再把 sold_stock 加 1。为什么不直接用 total_stock - sold_stock 做剩余量因为用户下单后还没支付这部分库存也不能再卖给别人必须预占住否则就会超卖。但预占的库存又不能算已经卖出所以需要单独一个 locked_stock 字段。订单表的核心字段我简化如下CREATE TABLE pre_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号UUID或雪花算法生成, user_id bigint NOT NULL, activity_id bigint NOT NULL, spec_id bigint NOT NULL COMMENT 活动规格id, quantity int NOT NULL COMMENT 购买数量, amount decimal(10,2) NOT NULL COMMENT 订单总额, freight_amount decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 运费, status tinyint NOT NULL COMMENT 0待支付 1已支付待发货 2已发货 3已完成 4已取消 5已退款, delivery_batch_id bigint DEFAULT NULL COMMENT 所属发货批次, pickup_code varchar(20) DEFAULT NULL COMMENT 提货码自提订单才有, receiver_name varchar(50) DEFAULT NULL, receiver_phone varchar(20) DEFAULT NULL, receiver_address varchar(200) DEFAULT NULL, pay_time datetime DEFAULT NULL, delivery_time datetime DEFAULT NULL, remark varchar(255) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status), KEY idx_activity_batch (activity_id, delivery_batch_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预售订单表;订单号和提货码我都做了唯一约束。提货码是自提场景的关键我用 12 位随机数字生成防止用户在订单详情页乱猜别人的码。3.3 库存锁定与订单状态机设计订单状态一共五个待支付、已支付待发货、已发货、已完成、已取消、已退款。注意这里我把“已支付”和“待发货”合一了因为预售订单支付成功后不可能立即发货要等采摘和批次安排。这是预售系统和普通商城最大的状态差异普通商城支付后通常马上进入仓库处理流程预售订单支付后可能要在“待发货”状态里躺好几周。库存锁定我用的是“两阶段锁定”下单阶段把活动表的 locked_stock 1预占库存支付阶段确认锁定并让 sold_stock 1如果订单超时未支付定时任务把状态改成已取消同时 locked_stock -1把库存释放出来。这套状态机画清楚之后后端代码写起来就有章可循了。很多项目做到一半逻辑混乱就是因为没做状态梳理就直接撸代码导致订单状态乱七八糟比如支付回调来了但订单已经被取消这种情况如果没有状态机规则后面只能到处打补丁。4. 后端核心实现订单状态流转、库存扣减、支付回调4.1 下单接口与预占库存后端最核心的接口就是预售后台下单接口。业务流程如下校验活动状态和预售时间 → 校验规格库存 → 预占库存 → 生成订单 → 返回支付参数。这里最关键的是库存扣减必须用原子操作不能用“先查询再更新”这种方案因为并发场景下两次查询都读到剩余库存一样都会执行 update结果就是超卖。我的做法是直接执行一条带条件的 SQLTransactional(rollbackFor Exception.class) public PreOrder createPreSaleOrder(CreateOrderRequest request, Long userId) { Activity activity activityMapper.selectById(request.getActivityId()); if (activity null || activity.getStatus() ! 1) { throw new BizException(预售活动不存在或不在进行中); } // 校验活动时间和购买数量略 ActivitySpec spec activitySpecMapper.selectById(request.getSpecId()); int rows activitySpecMapper.lockStock(spec.getId(), request.getQuantity()); if (rows 0) { throw new BizException(库存不足); } String orderNo generateOrderNo(); PreOrder order buildOrder(orderNo, userId, activity, spec, request); preOrderMapper.insert(order); return order; }lockStock 对应的 SQL 是UPDATE activity_spec SET locked_stock locked_stock #{quantity} WHERE id #{id} AND locked_stock #{quantity} total_stock这条 update 是关键它的条件里把“当前锁定量 本次购买量不超过库存总量”写进去数据库的行锁保证同一时刻只有一个事务能成功更新这就是最简单可靠的不超卖方案。如果你用了 Redis 做库存预扣也要在数据库这一层做最终一致性兜底Redis 挂了不能让库存直接变成负数。生成订单号的时候我踩过一个坑最开始用 SimpleDateFormat 随机数直接拼在并发量稍微起来之后出现了重复导致唯一索引报错。后来改成用“时间戳 用户ID尾号 随机字符串”生成实测下来没问题。有条件的话直接用雪花算法Spring Boot 里集成一个 hutool 工具的 IdUtil.getSnowflakeNextId() 就是现成的。4.2 支付回调处理与幂等设计支付回调是整个系统最容易出 bug 的地方。对接第三方支付平台时回调接口是支付平台服务器主动请求我们的后端订单支付成功后会立刻推送一条结果过来这个请求可能因为网络原因被重发多次所以回调必须做幂等。我的回调处理逻辑分三步第一步验签。支付平台的回调报文里有签名必须用平台公钥验签防止伪造回调。这一步做不好别人只要知道你的回调地址就能自己构造一个“支付成功”的请求订单直接变成已支付。第二步查订单状态。如果订单已经处于“已支付”状态说明回调重复直接返回成功响应不再重复更新数据库这就是幂等。第三步在同一个事务里更新订单状态、支付流水、活动销量。更新订单表时也要带状态条件比如 UPDATE pre_order SET status1, pay_timeNOW() WHERE order_no? AND status0这样即使两个回调同时进来也只有一个能更新成功。回调正确处理之后还要处理一个问题支付成功但本地订单已经被定时任务取消。这种情况在用户支付动作很慢、差点超时才支付时会发生。我的处理方式是支付回调如果发现订单状态是已取消就把支付金额原路退回同时更新订单状态为已退款并记录一条退款说明。这个场景很多人不做最后用户付了钱但订单显示已取消投诉就来了。4.3 定时关单与发货批次生成预售系统一定要有定时任务至少两个。第一个是超时未支付订单自动取消。用户提交订单后如果在 30 分钟内没有支付系统自动把订单置为已取消并释放预占库存。这个定时任务我用 Spring 的 Scheduled 实现每 5 分钟扫描一次把创建时间大于 30 分钟且状态为待支付的订单批量取消。任务不能直接一次性加载全部待支付订单要分页或按时间范围扫描不然数据库压力太大。第二个是发货批次生成。这个任务会在活动发货时间到达时把订单按创建时间切分成批次。比如发货计划是从 6 月 10 日开始每天一个批次系统就把订单按照“创建时间在 6 月 1 号之前” “6 月 1 号到 6 月 5 号”这样的规则分组为每个分组生成一个 delivery_batch并把订单的 delivery_batch_id 更新到对应批次。批次逻辑也可以做成手动触发管理员在后台看订单量差不多了点一下“生成下一批发货计划”更灵活。我当时两者都做了默认自动生成管理员也可以在管理后台手动调整批次。5. 前端 Vue 实现用户端与管理后台的页面与交互5.1 用户端页面与下单流程用户端我做了五个页面活动列表页、活动详情页、提交订单页、订单列表页、订单详情页。整个流程是用户在活动列表看到预售水果点进去看详情选择规格、输入数量、填地址提交订单后跳到支付页支付完成回到订单列表。活动详情页是整个用户端交互最重的地方。上面展示水果图片、活动价格、预售时间、预计发货时间中间是规格选择下面是库存进度条。我用 Vant 的 Stepper 组件让用户选数量同时在前端限制最大购买数量比如一个用户最多买 5 箱这个限制在后端也要校验前端限制只是提升体验。倒计时组件是预售系统必备的用户在预售还没开始前看到的就是“距离开抢还剩 xx 天 xx 时 xx 分”开始后就变成“已开抢”状态。我直接用 dayjs 做时间差计算setInterval 每秒更新一次。5.2 管理后台页面与核心交互管理后台的页面主要分三块活动管理管理员创建预售活动填活动名称、果品名称、价格、库存、预售时间、发货时间然后上传图片。这里用 Element Plus 的 Form 组件加自定义校验日期选择器会限制结束时间不能早于开始时间。活动创建后默认是未发布状态管理员确认无误后点击发布活动才会在用户端展示。订单管理订单列表按状态筛选支持待支付、已支付、已发货、已完成、已退款几个 Tab。管理员可以看到每个订单的用户信息和支付状态。这里我加了一个很实用的功能订单导出 CSV果园发货前需要把订单导出来给打包的人核对总不能让人家抱着电脑在仓库里看。发货管理管理员按批次选择订单给每个订单填写物流单号系统支持批量填写和单条填写。填完之后订单状态变成已发货用户端会收到状态更新。5.3 axios 封装与状态管理前端接口请求我用 axios 做了统一封装拦截器里做两件事在请求头带上 token响应里如果状态码是 401 就跳转登录页。import axios from 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) { Toast.fail(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { router.push(/login) } Toast.fail(网络异常) return Promise.reject(error) } )状态管理我只用了 Pinia 管理用户信息和购物车之类轻量数据。其实果园预售系统没有传统电商那种强购物车需求用户看中一个规格直接下单就行所以 “购物车”功能我直接就砍掉了这也是一种取舍功能越多越容易失控核心链路跑通比堆功能重要。6. 部署与问题排查从本地到服务器的最后一公里6.1 前后端打包与部署后端打包用 Maven命令是 mvn clean package -DskipTests生成一个可执行的 JAR 包。我用的部署方式是 systemd 管理 Java 进程服务器上装好 JDK 17把 JAR 放到 /opt/orchard/ 目录写一个简单的 service 文件启动、停止、开机自启都方便。[Unit] DescriptionOrchard Pre-sale System Afternetwork.target [Service] Typesimple Userroot WorkingDirectory/opt/orchard ExecStart/usr/bin/java -jar /opt/orchard/orchard-server.jar Restarton-failure [Install] WantedBymulti-user.target前端构建命令是 npm run build产物是一个 dist 目录放到 Nginx 的 html 目录下。Nginx 需要配置两个关键点一是把 /api 开头的请求反向代理到后端服务的 8080 端口二是配置 history 路由的 fallback不然用户刷新页面会 404。server { listen 80; server_name your-domain.com; location / { root /var/www/orchard; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }6.2 常见问题与排查实录我把从开发到部署遇到的高频问题整理成了一张表这些问题在类似项目里大概率也会遇到。问题表现原因解决方案并发下单导致库存变负数多个用户同时下单时活动库存出现负数先查询后更新并发读到了相同库存使用 UPDATE 语句条件扣减或 Redis 预扣库存支付回调重复处理同一订单被支付回调更新两次订单状态错乱支付平台可能因网络原因回调多次回调接口做幂等判断订单状态条件更新用户支付成功但订单被取消用户刚好卡在超时边缘完成支付定时关单任务扫描到待支付订单时支付还未回调回调中检查订单是否已取消已取消则自动退款前端刷新页面 404点击页面内链接正常按 F5 刷新就白屏Vue Router 使用 history 模式Nginx 没配置 fallback配置 try_files 指向 index.html跨域问题前端请求报 CORS 错误前后端分离部署在不同域名或端口Nginx 反向代理后同域名访问基本无跨域开发环境用 Vite proxy数据库时间相差 8 小时订单创建时间比本地时间慢MySQL 连接时区未配置连接串加 serverTimezoneAsia/Shanghai支付宝回调验签失败回调被判定为伪造没有使用支付平台官方 SDK 或公钥配置错误用官方 SDK 的验签方法配置支付平台的公钥这里面的“用户支付成功但订单被取消”是我认为最隐蔽的坑。因为系统上线后不可能保证用户一定在 30 分钟内完成支付如果用户在 29 分 59 秒完成支付但回调因为网络问题延迟了 5 秒才到后端定时关单任务可能已经先把订单取消了。所以回调处理里一定要兼容“订单已取消但支付成功”的分支要么自动退款要么把订单重新激活为已支付。我的选择是自动退款因为用户确实在超时边缘付了款果园如果这时候再强制履约备货计划已经被取消单任务影响了强行恢复容易出现履约事故。另外还有一个小问题用户在详情页点了“立即购买”但活动还没到预售开始时间后端需要返回一个明确的错误提示而不是简单返回 400。我当时统一封装了 BizException拦截器里把 message 返回给前端前端弹提示用户一看就知道是活动还没开始。7. 重新复盘这套系统最值得分享的几个坑做完整套系统再回头看最深的体会不是某个技术点而是“预售”的业务模型必须先在纸面上画清楚。前端页面可以做得很漂亮后端接口可以写得很优雅但只要订单状态机和库存模型有一点含糊联调阶段就开始到处冒 bug越往后越难收。我最后一次复盘时列了几条自己的硬性要求给要做类似项目的人一个参考。第一所有涉及金额和库存的操作一定要在服务端做二次校验。前端限制用户最多买 5 箱接口也要校验因为有人可以直接抓包绕过页面前端把价格展示为 0.1 元接口绝不能信任前端传过来的金额商品单价必须去数据库查。这个道理说了很多遍但每次还是有人踩。第二预售系统的库存不需要精确到一单不差。果园预估产量本来就是估的超卖几个订单比少卖要好处理超卖的可以退款道歉少卖了损失的是利润。所以库存锁定逻辑可以给一个缓冲的余量不要设置得死板。第三如果只是课程设计或校内项目不要过度设计。我见过很多同学做类似系统一上来就整 Redis 缓存、消息队列、分布式事务最后数据库一张订单表都没设计明白。小项目先把 Spring Boot Vue MySQL 这条链路跑通把订单状态、库存扣减、支付回调这三个核心点写扎实比堆砌一堆中间件有用得多。等真正有高并发需求时再引入 Redis 做库存预扣引入消息队列做异步发货到时候代码改动的路径反而更清晰。这套果园预售系统最后稳定跑过了整个杨梅季中间出了一次批次设置不合理导致一部分用户晚发货的情况但整体上把果农从朋友圈接单的泥潭里拉了出来。如果你也准备做一个类似的项目建议从业务状态机入手先把订单流转画在纸上再开始写第一行代码。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →