这段时间帮朋友做了一套果园预售管理系统,前后从需求梳理到联调上线,大概花了两周半。起因很简单:本庄村的果园往年都是等果子熟了再找渠道,价格被压得厉害,遇上丰收年反而愁卖。所以就想做一个基于SpringBoot+Vue的预售系统,后端用Java+MyBatis操作MySQL,把"先下单、后发货"的流程搬到线上。核心就是把预售商品挂出去、用户下单锁定库存、管理员后台管理订单这几件事做成一套完整闭环。这篇文章我把整个设计和实现过程完整复盘一遍,包括数据库表怎么设计、后端接口怎么分层、Vue前端怎么组织页面、联调时踩过哪些坑,适合正在做类似选题的开发者参考,也适合果园经营者了解预售系统到底能解决什么问题。
1. 项目背景:果园预售到底要解决什么问题
1.1 传统销售模式的真实痛点
我专门去聊了一圈才明白这里的门道。果园以前的销售方式,说白了就是三轮车拉出去卖,或者等水果贩子上门收购。这两个渠道都有问题:零售量小、损耗大,熟透了没卖掉只能烂在地里;批发价格完全看收购商脸色,丰产年价格腰斩是常事。更深层的问题是产销信息不透明,果农不知道今年市场要多少货,消费者也不知道哪家果园的果子质量好、什么时候成熟。
预售模式刚好能解决这个死结。果子还没熟,就已经在线上挂出预售链接,消费者看到的是果园实拍、预计发货时间、定金抵扣规则。下了单,果园就有了一个明确的订单量,可以反推需要预留多少鲜果、安排多少人工采摘和包装。这本质上就是制造业里说的"以销定产",放在农业上同样成立。
1.2 预售系统的业务闭环
这套系统的核心闭环不复杂,就四步:
- 管理员(果园主)发布预售商品,填写果品种类、单价、预售总量、预计发货日期
- 用户在小程序前端浏览预售列表,查看详情后下单
- 下单同时扣减预售库存,生成订单记录
- 管理员在后台看到所有订单,按预计发货时间安排采摘和配送
这四步看起来简单,但落到系统设计上要处理好三个关键点:库存怎么扣才不会超卖、订单状态怎么流转才能区分"待付款/已付款/已发货/已完成"、作为演示项目还要考虑不同角色的权限隔离。这些我在后面会逐个展开讲。
1.3 角色划分与功能边界
系统分两种角色,权限边界要划清楚。普通用户能做的事:注册登录、浏览预售列表、查看商品详情、下单付款(演示环境用模拟支付)、查看自己的订单列表、取消未发货的订单。管理员能做的事:维护用户列表、发布和管理预售商品、查看所有订单、修改订单发货状态。我当时的做法是在用户表里加一个role字段,前端根据角色控制页面和按钮的显隐,后端接口做一层拦截校验,而不是只在前端藏着。这种"前端控制显示、后端控制权限"的双重思路,对新手来说是最稳妥的。
注意:权限校验必须放后端。只靠前端隐藏路由按钮,别人直接调接口就绕过了,这在答辩或实际项目里都是硬伤。
2. 技术选型解析:为什么是这套组合拳
2.1 SpringBoot:后端开发的效率引擎
选SpringBoot的理由很实际。果园预售系统这类业务,本质是标准化的数据增删改查加一点业务逻辑,SpringBoot的自动配置机制把大量样板配置省掉了,一个主启动类就能跑起内置Tomcat,配合Starter生态,集成MyBatis、校验框架、JSON解析基本都是一行依赖的事。而且它在学校和企业里普及率极高,遇到问题随便一搜就有答案,对开发者来说"生态即效率"。如果换成SSH那套老框架,光配置XML就能磨掉半条命,不划算。
2.2 Vue框架:前端交互的开发主力
前端选了Vue,核心原因是组件化和响应式机制特别适合这种管理类页面。预售列表、商品卡片、订单表格、表单弹窗,这些都能拆成独立组件复用;数据变了页面自动更新,不用像jQuery时代那样手动操作DOM。配合Element UI组件库,表格、分页、日期选择器、表单校验这些高频组件拿来即用,我实际开发里至少省了三分之一写样式的时间。Vue的双向数据绑定对表单特别友好,用户在登录页、发布商品页填表单时的体验非常顺。
2.3 MySQL与MyBatis:数据持久化的稳妥搭配
数据持久化这里选的是MySQL,理由不用多说,中小型业务系统的标准配置,事务支持成熟,InnoDB引擎在并发下单这种场景下能保证数据一致性。ORM层用MyBatis,主要看中两点:一是SQL手写可控,像库存扣减这种需要精细控制的条件更新,写原生SQL反而更直观;二是动态SQL能力强,订单多条件查询、预售商品分页筛选这些场景,用<where>、<if>标签拼条件非常灵活。有的同学喜欢用MyBatis-Plus,我也试过,做CRUD确实省事,但精细控制事务和SQL优化时,还是手写XML更踏实。两者不冲突,演示项目按需选就好。
2.4 前后端分离架构的收益
既然用了SpringBoot+Vue,自然走前后端分离的路线。开发时前端用Vite起dev server,后端跑在8080端口,通过代理转发解决跨域;部署时前端构建成纯静态文件,扔进Nginx或者直接挂到后端静态资源目录下,两端互不干扰。这样做的好处是分工清晰,Vue负责交互渲染,SpringBoot只提供JSON接口,以后想加小程序或者App端,后端接口可以直接复用。对一个要写完整源码、展示设计思路的项目来说,这种架构更有说服力,答辩时老师也更爱听。
3. 数据库设计:预售系统的地基怎么打
3.1 核心数据表结构与关系
数据库我设计了四张核心表:用户表、预售商品表、订单表,另外加一张库存表。整体关系是:用户和订单是一对多,预售商品和订单是一对多,订单通过预售商品ID关联商品信息。建表原则是宁可拆细一点,不要把所有字段塞一张表里,否则后续加个配送地址、加个优惠券都要改动表结构,牵一发动全身。下面是建表的核心结构,字段做了精简,实际可以按需补充。
-- 用户表 CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(32) UNIQUE NOT NULL, `password` VARCHAR(128) NOT NULL, `real_name` VARCHAR(32), `phone` VARCHAR(20), `role` TINYINT DEFAULT 1 COMMENT '1-普通用户, 2-管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 预售商品表 CREATE TABLE `pre_sale_info` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `fruit_name` VARCHAR(64) NOT NULL, `description` TEXT, `price` DECIMAL(10,2) NOT NULL, `total_count` INT NOT NULL COMMENT '预售总数量', `sold_count` INT DEFAULT 0 COMMENT '已售数量', `expected_date` DATE COMMENT '预计发货日期', `status` TINYINT DEFAULT 1 COMMENT '1-预售中, 0-已下架', `start_time` DATETIME, `end_time` DATETIME ); -- 订单表 CREATE TABLE `order_info` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号', `user_id` INT NOT NULL, `pre_sale_id` INT NOT NULL, `quantity` INT NOT NULL, `total_price` DECIMAL(10,2) NOT NULL, `receive_address` VARCHAR(255), `status` TINYINT DEFAULT 0 COMMENT '0-待付款, 1-已付款, 2-已发货, 3-已完成, 4-已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP );我在字段设计上做了几个刻意的讲究。第一,价格一律用DECIMAL(10,2),绝对不用float或double,浮点精度问题在金额场景下是致命的,9.9能变成9.899999,对账的时候全乱。第二,金额字段不直接存折扣前的价格,而是把单价和数量都存上,总价算出来也存一份,这样后续统计收益、对账不用回查商品表当前价格,因为商品价格可能会变。第三,订单号用字符串并加唯一索引,避免在高并发场景下用自增ID当订单号暴露销量信息,也方便未来对接物流单号。
3.2 用户表与权限设计
用户表有个容易忽略的细节:密码不能明文存。我这里是用了MD5加盐的方式存储,admin用户通过初始化SQL插入,MD5后的密文串看起来很长一串,实际上就是加密后的密码。当然如果是生产环境,建议用BCrypt这种自带盐值的算法,安全性更高。role字段是权限判断的开关,后端的拦截器会读取当前登录用户的role值,管理员URL前缀/admin/**只能让role=2的用户访问,普通用户访问直接返回403。
3.3 库存扣减与数据一致性
预售系统的命门就是库存不能超卖。假设总库存100份,两个用户同时下单各买50份,如果代码写成"先查库存,大于下单量就扣减",那在高并发下两个请求都看到库存100,都放行下单,最后就卖出150份,翻车了。为了解决这个问题,我把扣减库存的SQL写成了一条条件更新语句:
UPDATE pre_sale_info SET sold_count = sold_count + #{quantity} WHERE id = #{preSaleId} AND sold_count + #{quantity} <= total_count这条语句的精妙之处在于,MySQL的UPDATE在行上会自动加锁,两个并发请求同时执行这条SQL时,第二个请求只能等第一个提交之后才能执行,此时sold_count已经变成50,条件sold_count + 50 <= 100就判断失败了,影响行数为0,程序就能据此判定"库存不足,下单失败"。这种方式叫乐观锁的思路用悲观锁的手段实现,代码里不需要显式加SELECT FOR UPDATE,性能和安全都能兼顾。这是我整个项目里最看重的一处设计,答辩时一定要讲清楚。
3.4 状态字段的语义约定
订单状态用TINYINT数字,0到4各自有明确含义。我在项目里专门写了一个常量类维护这些状态,而不是在代码里到处散落裸数字。比如待付款状态为0,但前端要展示"待付款"三个字,如果用裸数字散落着写,后期改一个状态值要全局替换,容易漏。用常量类,再写一个状态枚举工具类转换状态码和文本,前端只管拿到状态文案,逻辑集中在后端。还顺手保留了一个cancel_time字段,记录取消时间,这个字段在统计弃单率时非常有用。
4. 后端实现:SpringBoot核心模块拆解
4.1 项目分层与包结构
后端工程我分了controller、service、mapper、entity、common五层,规规矩矩的三层架构加一层辅助包。controller只负责参数接收和结果返回,不做任何业务计算;service层处理具体逻辑;mapper层对数据库操作。common包里放统一返回结果类Result<T>、异常处理器、JWT工具类、常量定义。这样分层的好处是逻辑边界清楚,出了问题能快速定位,而且后续写单元测试也方便。
包结构大概是这样的:
com.orchard.presale ├── controller │ ├── UserController.java │ ├── PreSaleController.java │ └── OrderController.java ├── service │ ├── UserService.java │ ├── PreSaleService.java │ └── OrderService.java ├── mapper │ ├── UserMapper.java │ ├── PreSaleMapper.java │ └── OrderMapper.java ├── entity │ ├── User.java │ ├── PreSaleInfo.java │ └── OrderInfo.java ├── config │ └── WebConfig.java ├── interceptor │ └── AuthInterceptor.java ├── common │ ├── Result.java │ └── JwtUtil.java └── PresaleApplication.java4.2 统一返回结果与异常处理
接口返回格式我定了一套固定规范,所有接口统一返回Result<T>,结构是{code: 200, message: "success", data: {...}}。这个设计是为了前端联调省心,一个工具函数就能统一判断接口是否成功,不需要每个接口单独处理异常分支。同时写了一个全局异常处理器,用@RestControllerAdvice捕获业务异常和系统异常,业务异常返回对应错误码(比如库存不足返回5001),系统异常统一返回500并记录日志。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }没有这套规范之前,每个接口返回值都很随意,有的直接返回Map,有的返回实体类,前端拿到数据之后要么data里套data,要么字段对不上,联调时一遍遍翻Swagger对字段。后来统一规范之后,前端完全是无脑消费,这个习惯值得坚持下去。
4.3 预留下单的核心业务流程
下单接口是整个系统逻辑最密的地方,service层的预留下单方法我拆成了四步。第一步校验用户登录态和预售商品状态,商品必须处于"预售中"且当前时间在预售时间窗口内;第二步校验下单数量,默认限制单笔不超过总库存的20%,防止一个黄牛把货全锁走;第三步执行前面说的条件更新SQL扣减库存,判断影响行数,如果为0直接抛"库存不足"异常并回滚;第四步生成订单号并插入订单记录。为了让库存扣减和订单插入这两个操作保持原子性,我在方法上加了@Transactional注解,任何一个环节抛异常,库存扣减自动回滚。
生成订单号这里有个小技巧,我用了"时间戳+用户ID后四位+三位随机数"拼接的方式,保证并发下大概率不重复,再用唯一索引兜底。如果重复了,捕获DuplicateKeyException重新生成,最多重试三次。这个场景虽然简单,但做过的同学会明白,实际项目里唯一键冲突是绕过不去的常客。
4.4 登录认证与令牌机制
登录接口用的是JWT令牌。用户登录成功后,后端用HMAC256算法签发一个token,里面带上用户ID、用户名和角色,有效期设置24小时。前端把token存到localStorage,每次请求在拦截器里加到Authorization头。后端写了一个拦截器,拦截所有/api/**接口,取出token校验并解析出用户信息放到请求上下文。这里有一个我一开始容易忽略的点:密码校验和token签发必须一步到位,不要先查出用户再单独校验密码,应该一条SQL用username和password同时查,这样即使逻辑出错也不会把"用户不存在"和"密码错误"两种信息同时暴露出去,避免被试探账号。
5. 前端实现:Vue页面与接口联调实战
5.1 前端工程结构与路由组织
前端用Vue 3加Vite初始化工程,配合Vue Router做路由管理、Pinia做状态管理(这个项目状态简单,主要是登录用户信息)。目录上我按功能拆模块,views放页面组件,components放公共组件,api放接口请求封装,router集中配置路由。路由配置里用到了动态路由的思路,管理员和普通用户登录后跳转的首页不同,靠路由meta里的roles字段控制,配合router.beforeEach做导航守卫,未登录用户一律踢回登录页。
5.2 核心页面实现拆解
预售列表页是用户第一眼看到的页面,我用了卡片布局,每张卡片展示果子图片、品种名、预售价格、销量进度条和预计发货日期。进度条是销量除库存算出来的百分比,这里要注意如果total_count为0会除零报错,需要做一个默认值兜底。商品详情页则展示大图和详细介绍,下单按钮点击后弹出数量选择器,实时计算总价。
管理后台这一侧是订单管理表格和商品管理表格,全部用Element UI的el-table加分页组件。表格操作列里,"发货"按钮只在订单状态等于已付款时显示,"取消订单"只在待付款或已付款时显示,这样从交互上就避免了用户产生歧义的操作。管理员发布商品页用了表单校验,价格字段格式化为保留两位小数,预计发货日期限定不能早于今天。
5.3 Axios封装与请求拦截
前端请求统一走封装好的request工具,基于axios。axios实例设置了baseURL为/api,请求拦截器里自动追加token,响应拦截器里统一处理业务码。后端返回code为200时直接放行,返回401时清除本地token并跳转登录页,其他错误码弹出ElMessage提示。这一层封装好之后,业务代码里完全不用关心token怎么带上、错误怎么提示,写起来干净很多。
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res } if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )5.4 前后端联调的跨域处理
开发环境联调时,前端跑在5173端口,后端跑在8080端口,必然产生跨域。我的做法是开发环境用Vite的proxy代理,把/api前缀的请求转发到后端。后端CORS配置也做了兜底放行,两个方案并存,生产环境部署时前端的请求通过Nginx同源转发,完全不需要跨域。这里有个经验,CORS的allowCredentials和allowedOrigin要一致为true或者具体域名,不能allowedOrigin配*又开credentials,否则浏览器直接报错。
6. 常见问题与排查实操记录
6.1 日期格式前后端不一致
前后端联调最常见的一个问题,后端的LocalDateTime序列化成JSON之后是一串"2024-06-01T10:30:00",前端表格里直接显示成带T的字符串,很丑。另外用户在日期选择器里选了日期传给后端,又会遇到反序列化失败。我是在后端统一加了Jackson配置,把LocalDateTime格式化成yyyy-MM-dd HH:mm:ss,前端Element的el-date-picker也统一用相同的value-format,两端对齐,这个问题就根治了。
6.2 MyBatis动态SQL的空条件陷阱
多条件查询很容易写出这种场景:用户输入关键词时按关键词过滤,不输入就查全部。结果用<if>标签拼条件时,如果关键字是空字符串,SQL就会变成WHERE fruit_name LIKE '%%',虽然结果没错但全表扫了。另外还要注意<where>标签会自动忽略掉第一个多余AND,但如果参数的List为空,用<foreach>拼IN条件时SQL会生成IN ()直接报错。规避的办法是查之前先判空处理,不给动态SQL留空集合的机会。
6.3 并发模拟测试与事务失效
项目做完之后我用Postman配合一个小脚本模拟了并发下单,20个线程同一秒抢最后10个库存。第一次跑居然超卖了,排查后发现原因很隐蔽:@Transactional注解加在了private方法上。Spring的事务代理基于动态代理,只有通过代理对象调用的public方法才会被拦截,自调用时this.xxx()直接走原对象,事务完全不生效。改成把事务方法放到Service接口暴露的public方法上,并且在另一个事务方法里调用时,要保证MySQL表引擎是InnoDB,MyISAM是不支持事务回滚的,这个坑也顺手记一下。
6.4 前端打包部署的静态资源问题
前端构建之后,dist目录里的js、css文件名都是带hash的,直接扔到后端静态资源目录时,如果后端配置了接口前缀拦截,很容易把静态资源请求也拦了,页面打开白屏。最终我的方案是静态资源走Nginx单独托管,/api前缀反向代理到后端端口,页面和接口完全分离。如果是单机部署图省事,就把Restful接口统一加上/api前缀,把静态资源目录在WebMvcConfig里设置成放行路径,拦截器只拦接口路径,也可以跑通。
7. 实操心得与后续扩展方向
整套系统做完,第一个体会是:业务边界比技术更重要。预售系统听起来高大上,拆开看就是库存、订单、商品、用户这四个模块的联动。把这个业务闭环想透了,数据库表自然就设计出来了,接口也自然就分好了。不要一上来就想堆微服务、Redis缓存、消息队列,对于这种体量的项目,MySQL事务加细心的SQL设计完全够用,过度的技术选型反而是负担。
第二个体会是调试环节的时间往往会超过开发时间。我在联调阶段踩了跨域、日期格式、事务失效、动态SQL空条件这些坑,每一个看起来都不起眼,但排查起来耗时。如果时间紧,建议先把统一返回结果、统一异常处理、日期格式配置这些"地基"代码写好,后面能省下大量排查时间。
这套系统后续可以扩展的方向不少。比如给商品表加一个分类字段,支持多品种水果;订单流程接入微信支付,替换掉现在的模拟支付;后台加一个销售数据统计看板,按月份展示预售额和订单量;短信提醒用户发货通知,也是一块成熟的应用。如果想把系统推向真实使用,还可以加入佣金分销逻辑,让村里的人帮忙推广拿提成,营销玩法马上就丰富起来了。
最后分享一个小技巧:这种前后端分离的项目,建议从写接口文档开始开发。哪怕不用Swagger,在代码注释里把每个接口的入参、出参、业务规则写清楚,前端同时开工,联调效率能提升一大截。我这次就是先和后端接口定义对齐,再分头开发,基本没出现大返工。这也是这两个项目周里最值钱的经验。