做过农产品预售平台或者正准备做这类管理系统的同学应该都有同感:这类项目表面上看就是“管理系统增删改查”,但真正动手之后会发现,预售逻辑、库存控制、订单状态流转、前后端联调,每一步都有不少隐藏的坑。今天我就拿一个完整落地、可以跑通的源码项目当模板,围绕SpringBoot、Vue、Java、MySQL、MyBatis这套主流技术栈,把这个“农产品预售平台管理系统”从业务设计到代码实现,再到问题排查,一条线讲透。这套思路不仅适合毕业设计、课程设计,也适合想独立上手一个全栈项目的人参考。
先说清楚这个系统是做什么的。农产品预售说白了就是“先下单、后发货”:消费者在商品还没完全成熟或者还没有现货时,先预付定金或者全款下单,农户根据订单量安排种植、采摘和发货。它要解决的是农产品季节性强、易腐烂、产量波动大的痛点。所以这个平台不是一个简单的电商网站,它必须处理好预售活动、库存上限、发货时间、订单状态这些农产品特有的业务规则。下面整篇文章就围绕这套逻辑展开,我会把核心代码思路、数据库设计、关键配置和踩过的坑都摊开来说。
1. 项目整体拆解:为什么选这套技术栈
1.1 农产品预售的业务逻辑到底是什么
很多同学一看“预售平台”就把产品思维往普通电商上套,结果做成一个带购物车的商城,完全跑偏了。预售模式和普通即时售卖的核心区别在于:用户在购买时,商品还不存在或者还没到可发货状态。
以农产品种植场景举例,一个火龙果种植基地想在5月预售6月成熟的火龙果。系统需要承载的信息包括:
- 这个预售活动从哪天开始、哪天结束;
- 预计发货日期是哪天;
- 预售总量是多少、单个用户限购多少;
- 现在到底卖出去多少、还有多少可以卖;
- 用户下的是定金单还是全款单,尾款怎么付。
如果把这些字段全部放在普通的商品表里,也能跑,但后期会非常痛苦。因为同一个批次可以分多期预售,同一个商品也可以在不同季节上架不同的预售活动。把活动和商品耦合在一张表里,一旦需求涉及“同商品反复开活动”,表结构就会变得不伦不类。
所以在这个项目里,最核心的实体不是“商品”而是“预售活动”。商品是静态信息,预售活动才是真正参与交易流转的动态载体。理解了这一点,整个数据库设计才立得住。系统整体上需要完成四件事:管理预售活动、处理用户下单、控制活动库存、跟踪订单履约状态,管理后台在此基础上加用户管理、分类管理、订单管理和统计展示。
1.2 技术选型的底层逻辑:四件套为什么是标配
SpringBoot负责后端接口。它最大的价值是自动配置和内置容器,省掉了Spring MVC时代大量XML配置,程序打成一个jar包直接就能跑。对毕业设计来说,你不需要在机房反复部署Tomcat,也不用手写一堆Bean,开发效率高,答辩演示的时候也省心。
Vue承担前端页面。这个项目采用前后端分离结构,前端通过axios发请求到后端接口,后端返回JSON数据。Vue的组件化特别适合这类管理平台:商品表格、活动表单、订单状态Tab,每个模块抽成独立组件,维护起来不混乱。配合Element-UI,后台页面不用自己憋CSS,拖出一个像样的界面只需要几十行代码。
MySQL存数据。农产品预售涉及的订单、用户、商品之间是强关系型数据,天然适合用关系数据库做关联查询。MySQL免费、入门快,面试也常问,是做Java项目最稳妥的选择。
MyBatis处理持久层。它不躲开SQL,反而把SQL放在自己手里控制。像活动列表多条件筛选、订单关联商品查询、库存扣减这种逻辑,用动态SQL写在XML里一清二楚,排查慢查询也比查封装好的ORM更直观。
这里多说一句,有的同学会把MyBatis换成MyBatis-Plus。MyBatis-Plus确实方便,自带单表CRUD,但如果你想展示自己对SQL和框架的理解,原生MyBatis更有说服力,项目答辩时也更能体现细节。本项目用原生MyBatis,就是想让整个SQL逻辑透明可控。
2. 系统功能模块与数据库设计
2.1 功能模块划分:用户端与管理端
一个完整的农产品预售平台管理系统,至少要拆出两个端:前台用户端和后台管理端。注意这里说的是“端”而不是“独立项目”,在工程里就是两套页面,共用一个后端。
前台用户端承担的是浏览和下单:
- 用户注册登录;
- 首页展示进行中的预售活动、新品推荐、活动轮播图;
- 活动详情页展示农产品介绍、预售价格、预计发货时间、剩余可售数量;
- 用户填写收货地址、选择购买数量、提交订单;
- 我的订单按状态筛选,待支付、待发货、已完成、已取消;
- 个人中心维护头像昵称和收货地址。
后台管理端是本项目的重点,也是系统叫做“管理系统”的原因:
- 用户管理:查看注册用户列表、禁用异常账号;
- 分类管理:维护水果、蔬菜、粮油等商品分类;
- 商品管理:对商品基础信息做增删改查;
- 预售活动管理:创建活动,设置起止时间、预售价格、发货日期、库存总量、每单限购数量,同时能看到当前销量;
- 订单管理:查看所有订单,按订单号搜索,点击发货更新物流状态;
- 数据看板:统计总销售额、进行中的活动数量、各分类销量占比。
如果是在答辩场景,我建议把重点模块放在预售活动管理和订单管理上,这两个模块最能把农产品预售业务和技术细节串起来。比如在管理端创建一个预售活动后,前台立刻能看到并下单,这就是一个完整的业务闭环演示路径。
2.2 核心表结构设计:预售活动表是关键
这个项目数据库我建议至少设计六张表:用户表、商品表、预售活动表、订单表、订单明细表、收货地址表。如果要做后台统计,再加一张简单的登录日志表也够用。我这里把每张表的核心字段列出来,大家建库建表的时候可以对照参考。
| 数据表 | 核心字段 | 作用说明 |
|---|---|---|
| user | id、username、password、phone、avatar、status、create_time | 用户账号,status标记是否禁用 |
| category | id、name、sort_order | 商品分类 |
| product | id、category_id、name、image、description、price、stock、status | 商品基础信息,价格是市场参考价 |
| presale_activity | id、product_id、presale_price、deposit、start_time、end_time、delivery_time、total_stock、sold_stock、limit_per_user、status | 预售活动,核心表 |
| orders | id、order_no、user_id、activity_id、address_id、product_name、product_image、price、quantity、total_amount、status、create_time、pay_time、delivery_time | 订单主表 |
| order_item | id、order_id、product_id、product_name、price、quantity | 订单明细,冗余商品快照 |
| address | id、user_id、receiver_name、receiver_phone、province、city、detail | 收货地址 |
几个容易出错的地方一定注意。
订单表和明细表为什么商品信息要冗余?因为商品名称和价格后续可能修改,直接用外键关联实时查product表,会导致历史订单显示的价格和名称漂移。订单里冗余一份商品名称、图片、单价,下单时从product查出来写进订单表,以后商品怎么改都不影响历史订单展示。这不是偷懒,是电商系统的通用做法。
为什么product要单独保留一个stock字段?这里stock表示正常现货库存。预售活动有自己的total_stock和sold_stock,预售卖的是“期货”,和现货库存是两个维度。可以做活动时把活动总量关联到商品库存上,也可以完全独立,这取决于实际规则。本项目里用独立字段更清晰,也方便后续扩展“现货+预售”并存模式。
2.3 状态机设计:让订单流转安全可控
订单和活动都建议用int状态字段,不要用字符串存中文状态,更不要在前端页面上写死一堆状态判断逻辑。后端定义统一的常量或者枚举类,比如:
public class OrderStatus { public static final int UNPAID = 0; // 待支付 public static final int PAID = 1; // 已支付/待发货 public static final int SHIPPED = 2; // 已发货 public static final int COMPLETED = 3; // 已完成 public static final int CANCELED = 4; // 已取消 }用户端和管理端都要进行状态流转控制。典型流转路径是:
- 用户下单成功,订单状态为待支付;
- 用户模拟支付或真实支付到账,状态变为待发货;
- 管理端点击发货,填写物流单号,状态变为已发货;
- 用户确认收货或者系统在发货后N天自动确认,状态变为已完成;
- 用户在待支付状态取消订单,状态变为已取消。
活动状态建议用0未开始、1预售中、2已售罄、3已结束四个状态。这个状态不是存在数据库里就完事了,它必须结合当前时间去判断,否则一个活动到点了还显示“预售中”,业务就乱了。我的做法是在查询列表SQL里直接带上时间条件,同时启动一个定时任务,每分钟扫一遍活动表,把结束时间早于当前时间的活动批量改成已结束。后端查询接口里也要二次校验,不能只靠定时器,防止刚好在切换时间的空档用户提交了订单。
3. 后端核心实现:SpringBoot + MyBatis 实战
3.1 工程结构与分层规范
拿到一套源码千万别急着跑,先看它的包结构。如果包结构混乱,后期找代码就是灾难。这套项目的后端结构我是按标准分层搭的:
com.example.presale ├── controller // 控制层,只负责接收参数和返回结果 ├── service // 业务层,处理业务规则 │ └── impl ├── mapper // MyBatis的Mapper接口层 ├── entity // 数据库实体类 ├── vo // 视图对象,给前端返回的数据结构 ├── common // 统一响应、常量、异常处理 └── config // 配置类分层的好处是职责清晰。Controller层不做业务判断,只做参数接收和结果包装;Service层处理业务规则,比如下单时的状态校验、库存扣减;Mapper层只负责和数据库打交道。这样的结构无论是一个人写还是小组协作,都很难把代码写乱。
统一返回结果类是前后端联调的关键。我习惯把返回格式固定为code、message、data三要素,正常返回code为200,异常统一走全局异常处理器。
提示:如果发现前端拿到后端的返回数据后还要猜是成功还是失败,那一定是后端返回结构不统一。统一返回结构要编码为清零的这种。
3.2 配置文件与MyBatis基础配置
SpringBoot项目的核心配置都在application.yml里。这里贴一份关键配置,大家可以直接抄,但数据库账号密码一定要改成自己的。
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/presale_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.presale.entity configuration: map-underscore-to-camel-case: true几个配置项的坑这里提前说。
serverTimezone=Asia/Shanghai必须加上,否则MySQL 8版本连接时会报时区异常;useSSL=false建议加上,本地开发不用SSL,不然控制台会打出一堆警告;map-underscore-to-camel-case开启后,数据库的下划线字段能自动映射到Java的驼峰属性,create_time自动变成createTime,省掉大批resultMap手写映射;- 注意
mapper-locations指向的是XML文件的路径,如果这个路径配错,启动时经常报空白,运行时报Invalid bound statement (not found)。
3.3 MyBatis动态SQL与多表联查
MyBatis的灵魂就是动态SQL。以预售活动列表查询为例,管理端通常需要按活动名称、状态进行筛选,SQL不能写死,MyBatis的<where>和<if>组合就派上用场了。
<select id="selectActivityList" resultType="com.example.presale.vo.ActivityVO"> SELECT a.id, a.product_id, p.name AS product_name, p.image, a.presale_price, a.deposit, a.start_time, a.end_time, a.delivery_time, a.total_stock, a.sold_stock, a.limit_per_user, a.status FROM presale_activity a LEFT JOIN product p ON a.product_id = p.id <where> <if test="status != null"> AND a.status = #{status} </if> <if test="keyword != null and keyword != ''"> AND p.name LIKE CONCAT('%', #{keyword}, '%') </if> AND a.deleted = 0 </where> ORDER BY a.create_time DESC </select>很多新手写动态SQL最担心多条件查询时第一个条件前面多出一个累赘的AND。MyBatis的<where>标签会自动去掉多余的AND或OR,这个特性一定要用起来,比拼接字符串加where 1=1要规范得多。
个人建议把带条件的多表查询、分页查询、统计报表都放到XML里写。因为这些SQL相对复杂,写在XML里可以通过格式化插件看清楚缩紧关系,如果写Java注解拼接,SQL一长就没法看了。
关于分页,推荐使用PageHelper分页插件,在Service层分页前调用PageHelper.startPage(pageNum, pageSize),后面的第一次查询就会自动拼接LIMIT。它对原生MyBatis非常友好。
3.4 预售下单核心业务与并发控制
预售下单是整个项目最应该好好讲的部分,因为这里跳出普通CRUD,直接进入真正的业务逻辑。后端下单接口要做的事情,我整理成一个完整流程:
- 验证用户是否登录;
- 根据activityId查询预售活动,判断活动状态是否为预售中;
- 判断当前时间是否在startTime和endTime之间;
- 查询该用户当前活动已下订单数量,判断是否超出限购;
- 判断活动剩余库存;
- 生成订单号和订单数据,写入orders和order_item表;
- 更新活动的sold_stock(已售数量);
- 全部成功才提交事务,任何一步失败都回滚。
看起来简单,真正的问题出在第7步。如果直接执行UPDATE presale_activity SET sold_stock = sold_stock + #{quantity} WHERE id = #{activityId},在多个用户同时下单时会出大问题。假设剩余库存是1,两个人同时查到库存为1,都通过了第5步校验,然后都执行更新,最终sold_stock变成2,库存被超卖。这就是并发场景下的经典竞态问题。
我的处理办法是用带条件的更新SQL来防超卖:
UPDATE presale_activity SET sold_stock = sold_stock + #{quantity} WHERE id = #{activityId} AND sold_stock + #{quantity} <= total_stock这条SQL的关键在WHERE条件里的AND sold_stock + #{quantity} <= total_stock,它保证只有剩余库存足够时更新才会成功。然后通过MyBatis更新的返回值判断影响行数,如果返回值为1说明扣减成功,如果为0说明库存不足,直接抛出“手慢了,库存不足”的异常。这个方法不需要额外加锁,性能比悲观锁高,代码也不复杂,是处理类似超卖问题的高性价比方案。
注意:使用
@Transactional时,不要把整个方法直接加上就算完事,还要注意rollbackFor = Exception.class。Spring默认只回滚运行时异常,如果业务层抛出的是受检异常,不加这个参数事务不会回滚,库存和订单就会产生脏数据。
下单的幂等控制也值得注意。用户快速点击两次提交按钮,可能产生两笔一模一样的订单。前端要做一个禁用按钮的防抖处理,后端同样要做校验:查询当前用户对该活动是否存在待支付订单,有则直接返回“订单已存在,请前往支付”,这样用户在支付页刷新也不会重复下单。这个处理虽然简单,但在实际演示时非常出效果。
4. 前端Vue实现:从环境搭建到页面落地
4.1 环境搭建与工程初始化
前端能用vue create命令把项目骨架搭建出来,但环境没配好之前千万不要急于写代码。我见过很多同学卡在第一步,半天跑不起来。
先用Node.js安装环境,建议用LTS版本,然后安装Vue CLI:
node -v npm -v npm install -g @vue/cli vue --version vue create presale-front cd presale-front这里有个经验:网络状况不好时直接用官方源装依赖非常慢,可以先把镜像切到国内源:
npm config set registry https://registry.npmmirror.com项目创建完成后,装本项目需要的前端依赖:
npm install axios element-ui vue-router vuex然后在main.js里引入Element-UI和路由。Element-UI在Vue 2项目里比较顺滑,配两行代码就能全局引入。
开发阶段最头疼的是跨域。前端开发服务器默认跑在http://localhost:8081,后端接口在http://localhost:8080,浏览器出于同源策略会拦截跨域请求。最省事的做法是在vue.config.js里配置代理:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这样一来,前端请求/api/activity/list,会被代理转发到http://localhost:8080/activity/list,跨域问题在开发环境就解决了,后端不需要额外开CORS。注意后端接口路径不带/api前缀,代理配置里要写pathRewrite把前缀去掉。
4.2 路由与登录拦截:前后端分离的权限骨架
Vue项目的页面跳转靠Vue Router。路由文件里我建议把页面分成两组:一组是不需要登录就能访问的,比如登录页、注册页、首页活动列表;另一组是需要登录才能访问的,比如下单页、订单列表、个人中心,管理端则需要额外的管理员标识。
这部分的重点在于“前端拦截”至少要把未登录状态挡在页面之外。用Vue Router的全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })不过要时刻记住,前端守卫只是用户体验层面的拦截,真正的权限控制永远在后端接口上。后端在SpringBoot中应该写一个拦截器,统一拦截需要登录的接口,从请求头取token校验登录态,校验失败直接返回401。前端在axios响应拦截器里看到401就跳转到登录页。
登录状态推荐用token,而不是Session。前后端分离后,后端接口是无状态的,用户登录成功后后端签发一个token返回给前端,前端存到localStorage,后续每次请求在请求头里带上Authorization: token即可。项目里如果不想引入JWT的加解密复杂度,可以直接生成一个UUID存到数据库的token字段,然后通过拦截器查库校验。简单,可解释,答辩也能讲清楚。
4.3 核心页面与接口联调:套餐怎么打
前端页面看起来多,但核心就几类,掌握套路之后其他页面都是重复劳动。
首页是入口,也是演示时最先看到的效果页面。首页布局一般是顶部导航栏、广告轮播图、预售活动列表。轮播图可以用Element-UI的el-carousel,活动列表就是一张卡片列表,每张卡片显示商品图、预售价格、已售数量、发货日期和“去抢购”按钮。首页调用的接口是/api/activity/list?status=1,后端把预售中的活动按热度排序列出来。
活动详情页展示预售信息卡片,包括商品图、名称、预售价格、定金、发货时间、库存进度条、限购数量。这里最好加一个倒计时,显示活动距离结束还有多久,前端用定时器每秒更新剩余时间。倒计时到零后按钮变成“活动已结束”并禁用。展示库存进度条是个很实用的细节,一条el-progress就能带动整个页面的氛围,这在答辩展示时尤其加分。
下单页需要完成三件事:选择收货地址、填写购买数量、提交订单。数量输入框要绑定限购校验,下单成功后跳转到订单列表待支付Tab页。模拟支付功能建议直接在后端接口里把支付状态改为已支付,然后跳转到待发货Tab。这样做虽然不涉及真实资金,但业务流程完整度非常高。
订单列表页是按状态切换Tab的典型页面,实现方式就是用el-tabs切换状态,再根据状态请求不同接口。这里有一个容易忽视的问题:如果每个Tab都单独发一次请求来回切换,体验会很差。更合理的做法是接口支持status参数,切换Tab时重新请求对应状态数据,同时在订单数据变化后,比如取消订单或确认收货,调用统一刷新方法重置当前列表。
后台管理页优先实现商品表格和活动表格。所有管理页面都可以用一套组合拳:el-table展示数据、el-dialog嵌套表单新增和编辑、el-button做操作入口。这个组合拳掌握住,后台管理页面的开发效率至少提升一倍。
axios请求封装建议单独抽取一个request.js:
import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { this.$message.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { this.$message.error('网络请求异常') return Promise.reject(error) } ) export default request这个封装的核心价值在于:所有接口统一的baseURL、统一的登录身份携带、统一的错误提示,页面代码里只要关注接口返回的数据本身,不需要重复写一堆if (res.code === 200)判断。
5. 常见问题与排查技巧实录
5.1 数据库侧:连接与配置的坑
做这种JavaWeb项目,最容易出问题的其实是环境问题,代码本身反而不太容易错。数据库侧最常见的几类报错我都遇到过了。
MySQL 8以下版本连接时会报SSL告警或者干脆连不上,因为高版本JDBC默认对SSL握手要求更严格。解决办法是连接URL加上useSSL=false。时区报错一般表现是The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,加了serverTimezone=Asia/Shanghai就正常。这两项配置看起来小,但在演示现场暴雷的场面我见太多了,所以大家配置完连接串一定要先在Navicat里测试能连上,再跑SpringBoot。
MySQL版本和mysql-connector-java版本也要匹配。用MySQL 8建议选择8.0.x版本的驱动,驱动类是com.mysql.cj.jdbc.Driver;用MySQL 5.7则用5.1.x版本驱动,驱动类是com.mysql.jdbc.Driver。把驱动类配错,控制台会直接报ClassNotFoundException。
还有一种情况是3306端口被占用,SpringBoot启动没报错,但日志显示数据库连接失败,仔细看会发现端口改了或者被某个隐藏进程占着。Windows下用netstat -ano | findstr 3306查端口占用,查到PID后到任务管理器结束进程即可。
5.2 后端侧:启动报错与MyBatis绑定异常
后端项目最劝退新手的报错是Invalid bound statement (not found): com.example.presale.mapper.ActivityMapper.selectList。这个报错看着像方法不存在,其实是MyBatis没找到对应的XML。八成原因是三个:一是application.yml里的mapper-locations配成了classpath:mapper/*.xml,但XML文件实际不在resources/mapper目录下;二是XML文件的namespace写的和接口全限定名不一致;三是mapper接口和XML文件的方法id对不上,少写了一个或者拼错了一个字母。
排查顺序也很固定:第一步看resources/mapper目录下的XML文件在不在,第二步看namespace是不是接口的完整路径,第三步看方法id和接口方法名是否完全一致。这套排查流程走下来,绝大多数绑定异常都能解决。
另一个高频问题是Field xxx in com.example.xxx.xxx required a bean of type 'xxx' that could not be found。这个一般在Service注入Mapper时发生。原因一是Mapper接口没加@Mapper注解或者启动类没加@MapperScan,Spring容器根本不知道这个Mapper接口存在;原因二是Mapper接口所在的包路径不在启动类扫描范围内。解决办法是启动类上加上@MapperScan("com.example.presale.mapper"),一次性把Mapper包都扫进来。
Lombok相关的问题也很常见。如果项目依赖里引入了Lombok但IDE没有安装对应插件,实体类上写的@Data注解不会生效,编译时所有getter和setter都无法生成,代码里却引用了一大堆方法,控制台报一堆找不到符号。我经常劝大家要么项目里不用Lombok,要么确保每个协作人都装了Lombok插件,千万不要一半人用一半人不用。
5.3 前端与联调侧:跨域、传参、渲染
前后端联调是另一个重灾区。最常见的现象是前端请求接口,F12控制台显示请求发出去了,后端日志也打印了,但页面拿不到数据。问题大多出在以下几点。
跨域配置没有生效。修改vue.config.js后忘记了重启前端服务,代理配置不会自动加载。改了配置文件一定要重启npm run serve,这是个很容易忽略的细节。
GET和POST的参数风格没分清。后端接口如果是@RequestParam接收参数,前端用axios的get请求要把参数放在params里;后端如果是@RequestBody接收JSON对象,前端就必须用post请求,并且把数据对象直接传给axios。两个风格一旦混用,后端要么收到null,要么直接报HttpMessageNotReadableException。
时间格式对不上。后端返回给前端的时间默认是时间戳或者带T格式,前端直接渲染就会出现类似2025-06-01T10:30:00.000+00:00这种奇怪字符串。解决办法是后端在application.yml里配置Jackson的日期格式,同时设置time-zone: GMT+8。如果其中有特定字段需要不同格式,就在字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")。
接口返回数据是null。这个往往是从数据库字段映射到Java对象再映射到JSON的过程中断了。比如数据库字段是limit_per_user,Java属性写成limitPerUser,但如果没开驼峰映射,查询结果里这个字段永远是null。开启map-underscore-to-camel-case: true,或者查出来在VO层重新组装。
5.4 预售业务本身的特殊雷区
普通电商项目的坑,这个项目基本都有,但预售本身还会带来额外的坑。
第一个是活动结束时间与下单校验的竞态。假设一个活动在中午12点结束,用户在11点59分50秒点下了提交订单按钮。如果校验只依赖前端倒计时,那么后端完全可能在活动结束后依然收到这个订单。所以后端下单接口必须对活动状态和当前时间做二次校验,查到活动不在预售中直接拒绝。
第二个是取消订单后的库存回补。待支付订单取消后,活动的sold_stock要减掉对应的数量,这些放出来的库存才能被其他用户购买。如果漏掉回补,会出现订单取消了好几次,库存却越卖越少,最后商品明明没人买却显示售罄的情况。但已经支付并且发货的订单不能随意取消,业务上要区分取消时机。
第三个是活动到期后定时任务和接口查询的一致性。用@Scheduled定时扫描活动表更新状态是一种方案,但定时任务有一个缺陷:如果参数配置的是fixedDelay=60000,那么每个整分钟才会扫描一次,如果活动在半点结束,最高会有60秒的“过期仍在售”的窗口期。我更推荐在查询端直接动态判断状态:status=1 AND start_time <= NOW() AND end_time >= NOW(),SQL里带上时间条件才是最可靠的,定时任务只作为一个兜底去更新冗余的状态字段。
6. 实操心得与后续扩展方向
绕开那些死板的配置和代码,这套项目真正给我留下深刻印象的地方在于:它完整覆盖了一个业务系统从零到一的全过程,而不是一堆技术的堆砌。我一开始做的时候也走了不少弯路,比如在普通商品表硬加预售字段,比如订单表不冗余商品快照,比如库存扣减只做查询判断不加条件限制。后来跑了一轮测试数据才意识到,预售系统的核心不是界面,而是状态和库存这两个模型的精准控制。
我特别想告诉大家的是,在动手写代码之前,务必先用文档把业务规则列出来。哪怕只写十行“什么时候能下单、什么时候不能下单、取消订单库存怎么办”,也比直接建表写代码强一百倍,因为业务规则一旦理清,数据库设计几乎是水到渠成的事情。这套源码我拿到手之后也是先看SQL脚本再看业务代码,把表结构和状态流转搞明白了,再去跑前端,整个项目一周内就能吃透。
如果想把项目继续扩展下去,我会优先考虑三个方向。一是对接微信小程序端,把同一个后端接口直接复用,商品展示移到小程序首页;二是接入真实的第三方支付,把模拟支付替换成真实支付回调,只需要在支付成功回调里更新订单状态即可;三是给活动增加“定金+尾款”两段式支付,下单时付定金,发货前再提醒付尾款,这对农产品的预售场景非常贴合。三条路里任何一条做好,项目都能明显往深里走一层,放到实习求职的项目经历里也是能讲故事的内容。