SpringBoot+Vue果园预售管理系统设计与实现全复盘

📅 发布时间:2026/10/20 22:59:46
SpringBoot+Vue果园预售管理系统设计与实现全复盘
这段时间帮朋友做了一套果园预售管理系统前后从需求梳理到联调上线大概花了两周半。起因很简单本庄村的果园往年都是等果子熟了再找渠道价格被压得厉害遇上丰收年反而愁卖。所以就想做一个基于SpringBootVue的预售系统后端用JavaMyBatis操作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 前后端分离架构的收益既然用了SpringBootVue自然走前后端分离的路线。开发时前端用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/**只能让role2的用户访问普通用户访问直接返回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包里放统一返回结果类ResultT、异常处理器、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 统一返回结果与异常处理接口返回格式我定了一套固定规范所有接口统一返回ResultT结构是{code: 200, message: success, data: {...}}。这个设计是为了前端联调省心一个工具函数就能统一判断接口是否成功不需要每个接口单独处理异常分支。同时写了一个全局异常处理器用RestControllerAdvice捕获业务异常和系统异常业务异常返回对应错误码比如库存不足返回5001系统异常统一返回500并记录日志。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT 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表引擎是InnoDBMyISAM是不支持事务回滚的这个坑也顺手记一下。6.4 前端打包部署的静态资源问题前端构建之后dist目录里的js、css文件名都是带hash的直接扔到后端静态资源目录时如果后端配置了接口前缀拦截很容易把静态资源请求也拦了页面打开白屏。最终我的方案是静态资源走Nginx单独托管/api前缀反向代理到后端端口页面和接口完全分离。如果是单机部署图省事就把Restful接口统一加上/api前缀把静态资源目录在WebMvcConfig里设置成放行路径拦截器只拦接口路径也可以跑通。7. 实操心得与后续扩展方向整套系统做完第一个体会是业务边界比技术更重要。预售系统听起来高大上拆开看就是库存、订单、商品、用户这四个模块的联动。把这个业务闭环想透了数据库表自然就设计出来了接口也自然就分好了。不要一上来就想堆微服务、Redis缓存、消息队列对于这种体量的项目MySQL事务加细心的SQL设计完全够用过度的技术选型反而是负担。第二个体会是调试环节的时间往往会超过开发时间。我在联调阶段踩了跨域、日期格式、事务失效、动态SQL空条件这些坑每一个看起来都不起眼但排查起来耗时。如果时间紧建议先把统一返回结果、统一异常处理、日期格式配置这些地基代码写好后面能省下大量排查时间。这套系统后续可以扩展的方向不少。比如给商品表加一个分类字段支持多品种水果订单流程接入微信支付替换掉现在的模拟支付后台加一个销售数据统计看板按月份展示预售额和订单量短信提醒用户发货通知也是一块成熟的应用。如果想把系统推向真实使用还可以加入佣金分销逻辑让村里的人帮忙推广拿提成营销玩法马上就丰富起来了。最后分享一个小技巧这种前后端分离的项目建议从写接口文档开始开发。哪怕不用Swagger在代码注释里把每个接口的入参、出参、业务规则写清楚前端同时开工联调效率能提升一大截。我这次就是先和后端接口定义对齐再分头开发基本没出现大返工。这也是这两个项目周里最值钱的经验。