做这个项目之前,我对攀枝花的印象基本停留在“钢铁之都”这个词上。真正去了一趟果农的合作社才发现,金沙江河谷的干热气候把这里变成了水果产区——晚熟芒果能一直卖到十月,早春枇杷错峰上市,价格比海南货高出一截。这套基于springboot+vue的水果在线销售系统,最开始就是给这个合作社做的线上直营渠道。项目本身不算大,但涵盖了前后端分离、支付流程、库存并发控制、订单状态机这些电商系统的核心骨架,做完之后确实沉淀了不少东西。这篇文章我会把整个项目的思考链路、工程实现和踩坑记录都翻出来,从需求到数据库、从接口到部署一次说清,适合正在做类似选题的开发者直接参考。
1. 攀枝花水果产业的真实痛点与系统需求来源
1.1 传统出货模式的痛点和系统切入点
攀枝花的水果其实一直面临“产地好货、终端低价”的尴尬。果农把芒果按筐批发给收购商,收购商再层层分发到全国的水果店和电商平台,中间至少被盘剥两道。合作社之前试过在生鲜电商平台开店,但平台抽佣高、流量费用贵,最后算下来利润还是薄。所以他们真正需要的不是又一个店铺,而是一个能把“产地直发”和“预售订单”跑通的自有渠道。
我在做需求调研的时候,跟着合作社负责人跑了几个果园,观察到的几个关键场景:
- 每年五月到十月是芒果集中上市期,外地客商压价最狠,果农没有议价权。
- 早春枇杷上市期短,只有几个星期,必须在短时间内完成订单消化。
- 水果是生鲜品,物流破损和售后问题直接影响复购,需要精细的订单状态管理。
这些一线观察直接影响了系统设计:我要做的不是一个单纯展示商品的货架,而是要把“产地选品—预售下单—采摘打包—冷链发货—售后追踪”这条完整链路支撑起来。
1.2 角色划分与功能边界
明确了痛点之后,我把系统拆成了三个角色,每个角色的功能边界都很清楚:
- 普通用户:注册登录、浏览商品、加入购物车、下单支付、查看订单、评价商品、管理收货地址。
- 合作社商家:管理商品上下架、处理订单发货、更新库存、查看销售数据。
- 平台管理员:审核商家、管理用户、查看全站订单和销售统计。
功能看起来多,但核心主流程只有一个:用户选商品、加购物车、下单、支付、商家发货、用户确认收货。这个闭环就是电商系统的骨架,其他功能都是围绕它长出来的枝叶。我在第一版的需求清单里刻意砍掉了优惠券、积分商城这类花哨但非核心的功能,避免开发周期被拖长。
1.3 为什么没做成传统JSP单体应用
跟合作社沟通技术方案的时候,其实讨论过要不要用最传统的JSP加Servlet,因为维护成本最低。但考虑到后续还可能要对接小程序、微信公众号商城,最后定了前后端分离:后端统一提供REST接口,前端用Vue渲染页面。这样以后要出移动端,后端接口可以直接复用,不用重新撸一遍业务逻辑。
2. 为什么是Spring Boot + Vue:技术选型的底层逻辑
2.1 后端Spring Boot的几点选择理由
后端栈选的是Spring Boot 2.7 + MyBatis-Plus + MySQL + Redis,这套组合在中小型电商项目里很常见,选择它有明确的理由:
第一,Spring Boot的starter机制帮我省了大量依赖配置。以前搞SSH整合,光xml配置文件就要写半天,Spring Boot一把梭,spring-boot-starter-web、spring-boot-starter-validation这些直接引入就能跑。
第二,Spring Boot的声明式事务极其好用。电商的下单过程涉及多次数据库写操作,只要在service方法上加@Transactional,任何一个环节抛异常整个事务都会回滚。这对生鲜电商尤其重要——库存扣了但订单没生成,或者订单生成了但库存没扣,都是不可接受的。
第三,Java生态里做并发控制、消息队列、定时任务的成熟方案最多。这次虽然只用了Redis做缓存和验证码存储,但以后要加延迟关单、秒杀活动这些功能,Spring Boot天然支持Spring Data Redis、RabbitMQ,扩展空间是够的。
2.2 前端Vue和Element UI的组合优势
前端选择Vue 2 + Element UI。没有选Vue 3,不是因为它不好,而是考虑到Element UI组件库成熟,坑少,团队上手快。Vue的双向绑定在购物车编辑、表单校验这些场景下写起来非常顺手,组件化开发也让页面结构很清晰。
项目里的后台管理界面,像商品列表、订单列表、用户管理这些页面,大量使用了Element UI的el-table、el-form、el-dialog、el-pagination组件。搭一个带有搜索、分页、弹窗编辑功能的列表页,基本就是两三个小时的事。如果换成纯手写DOM,工作量至少翻三倍。
2.3 备选方案和最终取舍
我在方案评审阶段也认真考虑过其他组合:
| 备选方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| Node.js + Express + Vue | 语言统一,开发节奏快 | 后端生态在事务、ORM、定时任务方面明显弱于Java | 放弃 |
| Spring Boot + Thymeleaf | 部署简单,学习成本低 | 前后端耦合严重,手机端复用困难 | 放弃 |
| Python + Django + Vue | 管理后台省事 | 高并发处理能力和生产运维资料不如Java扎实 | 放弃 |
| 直接购买开源商城系统 | 省时省力 | 定制化受限制,无法贴合攀枝花产地预售场景 | 放弃 |
最终拍板Spring Boot + Vue,核心考量是:电商这个领域Java生态积累最厚,出现任何问题都能找到解决方案,同时前端分离部署也留足了扩展空间。
3. 后端设计:从用户鉴权到订单闭环的接口实现
3.1 工程结构设计
后端工程我按功能分包,没有用传统的按层分包,这样每个模块的代码集中在同一个包下,定位问题快。
com.pzh.fruit ├── controller # 接口层,只做参数校验和结果返回 ├── service # 业务逻辑层,事务在此层控制 ├── mapper # 数据访问层,继承MyBatis-Plus的BaseMapper ├── entity # 数据库实体 ├── dto # 参数接收对象 ├── vo # 返回视图对象 ├── config # 跨域、拦截器、Redis等配置 ├── common # 统一返回结果、异常处理器 └── utils # JWT、加密等工具类这样一个包就是一个完整业务模块,比如用户在我刚才说的这个分包方式下,controller里只有参数接收和调用service,不写任何业务逻辑。好处是接口层很薄,出了问题不容易互相甩锅。
3.2 统一返回结果和全局异常处理
前后端分离开发的第一件事,是先把接口的返回格式定死。我定义了一个Result<T>类,结构如下:
{ "code": 200, "msg": "success", "data": {} }业务层抛出的所有异常,通过@RestControllerAdvice全局异常处理器统一拦下来,转换成对应的code和msg。比如库存不足抛BusinessException("库存不足"),前端拿到code 500就知道是业务错误,弹个提示框就行,不用关心后端到底怎么处理的。
跨域问题也在config层统一解决。用WebMvcConfigurer添加CORS映射,允许前端开发服务器的地址跨域访问,同时允许携带Authorization请求头。这个配置不加的话,本地联调时前端请求会被浏览器直接拦截。
3.3 JWT用户鉴权:比Session更适合前后端分离
用户模块的鉴权我选择了JWT(JSON Web Token)方案。流程是这样的:
- 注册时用户提交手机号和密码,密码用BCrypt加密存储,绝不存明文。
- 登录成功后后端签发JWT,token里包含用户id和角色信息,有效期设为24小时。
- 前端拿到token存在localStorage里,每次请求通过Axios拦截器自动加到Authorization头。
- 后端写一个拦截器,对所有需要登录的接口校验token,校验通过才放行。
选JWT而不是Session的关键原因是:Session存储依赖服务器内存,前后端分离部署时还得配置Session共享,而JWT是无状态的,水平扩展后不需要额外的同步机制。缺点是token一旦签发没法在服务端主动作废,所以我把有效期控制得比较短,同时在Redis里存一个黑名单,用户退出登录或修改密码时把token拉黑。
3.4 商品、购物车和订单的接口实现
商品接口:GET /api/product/list做分页查询,支持按关键字搜名称、按分类过滤、按价格倒序排序。接口内部用MyBatis-Plus的QueryWrapper拼条件,没上Elasticsearch,数据量在几百个SKU级别完全够用。每个商品详情接口返回商品主图、轮播图、克重规格、产地、发货方式等字段。
购物车接口:
- 加入购物车:
POST /api/cart/add,如果该商品已经在购物车中则累加数量,否则新建记录。 - 修改数量:
PUT /api/cart/update,前端每次加减数量都会调用一次,实时把最新数量同步给后端,这样不同设备之间购物车不会不一致。 - 删除和清空:都是常规操作。
这里有个关键设计:购物车的选中状态也存后端,而不是只存在前端。因为用户在手机上看了一下,再到电脑上下单,选中状态应该保持一致。
订单接口:
- 提交订单:
POST /api/order/create,接收参数为地址id和购物车id列表。 - 支付订单:
POST /api/order/pay/{orderNo},实际项目里这里对接的是微信支付平台,但开发环境我用的是模拟支付回调。 - 取消订单:
DELETE /api/order/{orderNo},只有待支付状态的订单可以取消。 - 确认收货:
PUT /api/order/confirm/{orderNo},确认后订单完成,不能再发起售后。
下单接口是整个系统最复杂的一个方法,我拆成了几步:
1. 校验每个商品的状态和库存 2. 根据购物车明细计算订单总金额 3. 创建订单主表,状态置为待支付 4. 创建订单明细表,保存商品快照 5. 扣减商品库存 6. 清空对应购物车记录 7. 返回订单号这个流程整体被@Transactional包裹,任何一个步骤失败,前面写的数据全部回滚。后面我会单独讲库存扣减的并发细节,那是另一个大坑。
4. 前端设计:Vue组件化、路由权限与购物车状态管理
4.1 项目初始化和目录规划
前端用Vue CLI 4创建的项目,目录结构规划成下面这样:
src ├── api # 所有接口请求封装,按模块拆分 ├── assets # 静态资源 ├── components # 公共组件(商品卡片、支付弹窗、数量选择器) ├── router # 路由配置和前置守卫 ├── store # Vuex 状态管理 ├── views # 页面级组件 ├── utils # 请求封装、token存储等工具 └── App.vue这样的结构保持了职责单一:views里只负责页面组装和交互,api里只负责接口调用,store里共享全局状态。后续如果来了新需求,新页面直接往views里加一个目录,对应api里加一个模块就行。
4.2 Axios二次封装:统一处理请求和错误
所有请求都走utils/request.js这个统一出口,不用每个页面重复写请求逻辑。核心做了三件事:
- 请求拦截器:从localStorage读token,存在就加到Authorization头。
- 响应拦截器:统一解包后端的Result结构。code为200时直接把data返回给业务层;code为401时清除登录态并跳转登录页;其他code统一弹ElMessage提示后端返回的msg。
- 超时控制:请求超时时间设为10秒,超过后提示网络异常。
这样封装之后,页面里调用接口就变得很干净,比如获取商品列表:
const res = await getProductList({ page: 1, size: 10 }) // res.data 就是返回的商品数组业务代码里不需要关心token怎么加、错误怎么提示,这些横切关注点都在拦截器里处理完了。
4.3 购物车状态管理:从本地暂存到登录合并
购物车是全局状态里最复杂的一块,因为涉及未登录用户和登录用户两种场景。
未登录时,用户的加购操作直接存到localStorage,key为cart_tmp,数据结构是[{ productId, name, price, image, quantity, checked }]。这样游客也能正常浏览和加购,不会因为没登录就把用户赶走。
登录后,前端登录接口成功后做一次购物车合并:把localStorage里的临时购物车数据提交到后端,后端按照商品id把数量累加到用户真实购物车里,合并完成后清空本地缓存并重新拉取购物车列表。整个过程用户无感,体验很顺。
Vuex里的购物车模块主要保存这几个状态:购物车列表cartList、购物车总数cartCount、选中商品总数selectedCount、总金额totalPrice。所有和购物车有关的展示,包括导航栏角标、订单提交页,都从Vuex里取数据,保证页面间同步。
4.4 路由权限控制的两个层级
路由权限我分了两层。第一层是登录守卫,所有需要登录的页面在路由meta里标记requiresAuth: true,然后在router.beforeEach里判断:
router.beforeEach((to, from, next) => { if (to.meta.requiresAuth && !getToken()) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })这个做法的好处是用户没登录时点到购物车、订单中心会被自动弹到登录页,登录成功后还能跳回原来的页面。
第二层是角色权限。后台管理页面的路由meta里标记roles: ['admin', 'merchant'],进入时判断当前用户角色是否匹配,不匹配就提示无权限。前端做这层控制更多是为了用户体验,真正安全的后端接口鉴权才是核心,前端就算绕过去,后端也会拦截。
4.5 商品列表和订单列表的性能细节
商品列表页数据加载时,做了骨架屏效果,避免白屏闪烁。图片统一用懒加载指令v-lazy,页面滚动到图片位置时才加载,首屏速度提升明显。订单列表页则用了分页加载,每次只请求10条,滚动到底部自动加载下一页,避免一次性渲染几百条订单导致页面卡顿。
5. 数据库表设计、库存防超卖与订单状态流转
5.1 核心表结构说明
数据库用的MySQL 8.0,表用utf8mb4字符集。核心表有六张:用户表、商品表、购物车表、订单主表、订单明细表、收货地址表。
业务上最核心的订单表结构大致如下:
CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` bigint NOT NULL COMMENT '下单用户', `total_amount` decimal(10,2) NOT NULL COMMENT '总金额', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付 1待发货 2待收货 3已完成 4已取消 5已退款', `receiver_name` varchar(50) NOT NULL, `receiver_phone` varchar(20) NOT NULL, `receiver_address` varchar(255) NOT NULL, `pay_time` datetime DEFAULT NULL, `ship_time` datetime DEFAULT NULL, `finish_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单明细表除了商品id之外,一定要冗余一份商品名称、商品主图、单价、规格。这就是俗称的“商品快照”。当初我没有在明细里冗余这些字段,结果商品改价之后,历史订单显示的金总额和明细对不上,用户投诉说收款金额不对。加上商品快照之后,历史订单永远按下单那一刻的价格和商品信息展示,再也不会出这种幺蛾子。
5.2 库存扣减:经典超卖问题的两种解法
超卖是电商系统绕不开的坑。场景很典型:一件攀枝花大芒果只剩3个库存,3个用户同时下单,每个用户都读到库存为3,都执行了扣减,最后库存变成0但订单超卖了。我第一次上线时就遇到了这个问题。
我用了两套方案解决。
方案一:SQL层面的原子扣减,也就是在更新语句里加条件:
UPDATE product SET stock = stock - 1 WHERE id = ? AND stock >= 1这样数据库层面保证了只有库存大于0才能扣减成功,如果更新影响行数为0,说明库存已被抢空,订单创建直接抛库存不足异常。使用这个方案时,扣减库存和创建订单必须在同一个事务里。
方案二:Redis预扣库存。这个用于秒杀或大促场景,先把库存加载到Redis,扣减时用Redis的DECR命令原子递减,库存减到负数就拒绝下单。Redis的原子性比数据库锁更轻量,适合高并发。考虑到项目并发量不高,最终我在生产环境用的是SQL原子扣减方案,Redis只用来做验证码缓存和登录token管理。
5.3 订单状态机的流转与超时关单
订单状态的每一次流转都是后端驱动的,不允许前端随意伪造状态:
待支付(0) --用户付款--> 待发货(1) --商家发货--> 待收货(2) --确认--> 已完成(3) 待支付(0) --用户取消/超时--> 已取消(4) 待发货(1) --用户申请退款/商家同意--> 已退款(5)超时关单我用的是Spring的@Scheduled定时任务,每30秒扫描一次超过30分钟未支付的订单,把状态改成已取消并把库存加回来。定时任务的并发问题要注意,集群部署时最好加一个分布式锁,不然多台机器同时跑扫描会重复关单。这次项目单机部署,所以没上分布式锁,但代码里用了乐观锁更新状态,保证了状态变更的幂等性。
5.4 生鲜电商特有的预售和批次设计
因为攀枝花水果有强烈的季节性,系统还支持了预售功能。商品可以设置“预售开始时间”和“预计发货时间”,预售商品库存字段与普通库存分开。用户支付预售订单后,订单状态是“待发货”,但实际发货时间由商家在后台手动更新。这个设计参考了拼多多的产地直发思路,效果很不错——合作社在芒果开花期就开始收预售定金,资金提前回笼,果农心里有底。
6. 本地联调和云服务器上线实录
6.1 本地开发环境的搭建细节
本地开发我用了Spring Boot默认端口8080,Vue开发服务器端口3000,通过package.json里的proxy配置解决跨域:
{ "devServer": { "proxy": { "/api": { "target": "http://localhost:8080", "changeOrigin": true } } } }这个配置的意思是,前端开发服务器把所有以/api开头的请求转发到后端的8080端口,前端代码里请求地址统一写成/api/xxx,浏览器看到的是同源请求,跨域问题在开发阶段直接消失。
6.2 前后端分离部署上线
生产环境我买了一台2核4G的云服务器,CentOS 7系统。前端打包后生成dist目录,用Nginx托管;后端打成jar包用nohupjava -jar后台运行。
Nginx的关键配置:
server { listen 80; server_name pzh.example.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /uploads/ { alias /data/pzh/uploads/; } }这里有个容易踩的坑:proxy_pass后面如果带了/api/,那么实际转发到后端的路径就是http://127.0.0.1:8080/api/xxx;如果没带,8080端口收到的请求就没有/api前缀,后端路由会全部404。我因为这个问题排查了将近半天,最后发现是proxy_pass的值里少写了一层目录。
6.3 上线后踩到的真实问题
第一个是商品图片上传后无法访问。本地开发时图片存在项目目录下的upload文件夹,Nginx直接能访问。上线之后我改用服务器绝对路径/data/pzh/uploads存储,但Nginx的alias路径配置和Spring Boot的上传路径不一致,导致图片访问404。解决方法是把上传路径做成配置项,Spring Boot的application.yml里配置file.upload-path,启动时检查目录存在性,Nginx的alias路径和这个配置保持一致。
第二个是MySQL时区问题。服务器默认时区是UTC,而数据库连接串没指定时区,导致订单创建时间的CURRENT_TIMESTAMP比北京时间早8个小时。排查时订单时间显示成凌晨四点,折腾了半天才发现是连接串缺少serverTimezone=Asia/Shanghai参数。
第三个是生产环境的JVM参数调优。2核4G的服务器上,后端jar包默认堆内存是物理内存的四分之一,也就是1G,跑了一段时间后频繁Full GC。我调整了JVM启动参数:
nohup java -Xms512m -Xmx512m -jar pzh-fruit.jar --spring.profiles.active=prod > app.log 2>&1 &固定堆内存512M,避免动态扩容带来的性能抖动,GC次数下降了很多。
6.4 日常运维的一点建议
最后分享一下我维护这套系统的习惯。数据库每天凌晨自动备份一次,备份文件保留7天;Nginx的access.log每天做日志切割,避免单个日志文件越来越大;每周检查一次后端日志里的异常告警,重点关注重复的数据库死锁和库存异常记录。上生产环境前,简历里也好、面试里也好,能把这些细节讲清楚,比单纯说“我会Spring Boot”有说服力得多。
这套系统从需求调研到稳定运行,前后大概用了三个月。真正有价值的不是代码本身,而是那些在文档里查不到、只有跑过生产环境才会懂的判断——订单快照为什么要冗余、库存扣减为什么一定要原子操作、Nginx反代少一个斜杠为什么会404。做类似项目的时候如果绕过了这些坑,你写出来的系统会比大多数课程demo工程扎实得多。