ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot + Vue 农产品电商平台实战:从商品建模到Docker部署

Spring Boot + Vue 农产品电商平台实战:从商品建模到Docker部署 去年年底我接了一个农产品电商平台的项目需求方是一家做精品果蔬的农业合作社要求做一套能跑“前台购物后台管理订单配送”的完整系统。技术栈他们倒没指定只提了要前后端分离、能维护、能部署。我选了Spring Boot Vue的组合用了大概三个月把平台搭起来上线。这篇东西不是教程是我在这个项目里实际踩过的路包括为什么这么拆模块、哪些坑花了我大量时间以及如果你也想做类似系统可以从哪几块下手。这个项目涵盖商品展示、购物车、下单支付、订单追踪、会员积分、营销活动、后台商品审核、农户入驻、配送端接单等一堆功能属于典型的中小型电商系统。对于正在做毕业设计、或者想用Spring Boot Vue完整练一个真实项目的同学来说应该能帮你在动手前把技术路线和业务建模想清楚。1. 农产品电商的特殊需求为什么这个平台不能照搬通用商城很多现成的商城代码一搜一大把但农产品电商直接套模板一定会出问题。原因很简单农产品是非标品而通用商城系统是为标品设计的。1.1 非标品的商品模型与定价策略普通电商的SPU/SKU模型是“一件衣服多个尺码颜色”SKU的字段比较固定颜色、尺码、库存、价格。而农产品一筐苹果可以卖论斤也可以卖5斤装、10斤装、礼盒装对应不同价格同样是红富士产地不同、采摘批次不同零售价和批发价也不一样。我最终把商品模型设计成了三层商品product、规格spec、批次batch。商品层名称、主图、详情、上架状态、所属店铺规格层规格名称、价格、库存量、单位斤/件/盒批次层批次号、产地、溯源档案、采摘日期、检测报告、上下架时间这样的好处是下单时订单快照里可以直接记录“商品规格批次价格”后续做产地溯源和售后问题时也查得清。坏处是后台的商品编辑页比普通商城复杂前端Vue表单要联动三个层级所以开发时一定要预留足够的接口粒度。1.2 物流履约与批次追溯带来的数据建模差异农产品容易坏物流履约不是简单填个快递单号。平台需要记录配送方式普通快递、冷链、运费试算规则按重量和地区、以及预计送达时间。运费试算必须在购物车页面就实时算不能等到下单后再说。为了存这批数据我在订单表上增加了两个逻辑订单明细携带batch_no方便后续按批次查询哪些订单卖的是同一批货delivery_type字段区分冷链/常温关于超重补运费、偏远区域不配送这些规则我单独建了一张shipping_rule表前端在购物车页根据重量和收货地址调后端接口试算如果你用的是现成商城代码大概率没有这些字段硬塞会非常别扭。1.3 用户群体与多角色权限的设计思路这个系统有三类前端用户和两类后台用户普通买家、入驻农户、配送员以及平台管理员和客服运营。权限上不能只区分“用户/管理员”而是要在Spring Security里做RBAC细粒度控制。实际操作中我把菜单和按钮权限都做了动态分配买家端我的订单、积分、优惠券、收货地址农户端商品管理、批次管理、订单发货、对账单配送员端待接单、配送中、异常上报管理员端所有权限包括商品审核、退款仲裁、数据统计用户角色表、菜单权限表、角色菜单关联表必须在一开始就建好不然后面每加一个角色都要改登录逻辑会非常痛苦。2. 技术选型与项目骨架Spring Boot Vue 的组合并不是随便拍脑袋很多新手会觉得选型无所谓能跑就行。但真正做项目时选型决定了开发效率、招人难度、部署成本。我在这里直接说我的判断逻辑。2.1 为什么选Spring Boot Vue而不是其他方案对比过几个方向完全用模板渲染Thymeleaf开发快但前后端耦合后面加小程序、App都要重做接口Spring Boot ReactReact也不错但团队熟Vue而且国内社区和UI组件库对Vue更友好Spring Boot Vue 3 Element Plus前后端彻底分离后端专注接口前端做交互维护起来最清晰这个项目规模其实用单体架构就够了没必要上微服务。Spring Boot 2.7 MyBatis-Plus MySQL 8 Redis Vue 3 Vite Pinia整套组合运行稳定、资料多、遇到问题搜一下基本有答案。2.2 项目目录结构与数据库表设计要点后端目录我按业务模块分包而不是按技术分层com.example.farm ├── common # 通用返回体、异常处理、工具类 ├── config # Security、Redis、MyBatis-Plus配置 ├── controller # 接口层 ├── service # 业务层 ├── mapper # 数据访问层 ├── entity # 数据库实体 ├── dto # 接口入参/出参对象 └── job # 定时任务订单超时取消、优惠券过期等前端目录按页面维度划分src ├── api # 每个模块的接口请求单独建文件 ├── assets ├── components # 公共组件上传、富文本、批次选择器 ├── router # 路由配置 动态路由 ├── stores # Pinia状态管理 ├── utils # axios封装、日期处理、权限指令 └── views ├── Home ├── ProductList ├── Cart ├── Order ├── UserCenter └── Admin数据库表核心有user、role、menu、role_menu、user_role、product、product_spec、product_batch、cart、order、order_item、shipping_rule、after_sale。我建议你把order状态字段用tinyint但代码里一定用枚举不要散落一堆魔法数字。2.3 环境准备IDEA、Maven、Node、Vue CLI的版本避坑我用的是IDEA 2023 JDK 8 Spring Boot 2.7.18。这里注意Spring Boot 3.x要求JDK 17如果你还不太熟新的Jakarta命名空间先用2.7最稳。前端环境我吃了不少亏Node版本别用太旧的我当时用的Node 16.20Vite 4要求Node 16如果你用Vite 5建议直接Node 18安装依赖时npm install经常卡住后来我换成了pnpm install速度快很多。如果你必须要用npm记得设置国内镜像源Vue DevTools插件一定要装调试组件状态和Vue Router跳转省太多事创建Spring Boot项目用IDEA自带的Spring Initializr即可注意勾选Web、Security、MyBatis、Redis、MySQL Driver等依赖。Vite项目用npm create vitelatest创建模板选vue。3. 前端Vue实战路由、状态、组件封装前端这一块看着简单但真正写起来最容易乱的是状态管理和路由权限。3.1 动态路由与菜单权限控制后台管理端的菜单不能全部写死在路由表里。用户登录后后端会返回该用户有权限的菜单和按钮标识前端再动态注册路由。核心是router.addRoute()我在src/router/index.js里先放公共路由登录页、404然后登录后把动态路由组件映射好再加进去const modules import.meta.glob(../views/**/*.vue); function buildRoutes(menuList) { const routes []; menuList.forEach((item) { if (item.component) { routes.push({ path: item.path, name: item.name, component: modules[../views/${item.component}.vue], meta: { title: item.title, icon: item.icon, roles: item.roles } }); } if (item.children) { routes.push(...buildRoutes(item.children)); } }); return routes; } export function setupDynamicRoute(menuList) { const routes buildRoutes(menuList); routes.forEach((route) { router.addRoute(route); }); }后端返回菜单时组件路径一定要和views目录对得上比如Admin/ProductList否则Vite的import.meta.glob会匹配不到。3.2 购物车状态管理与Vue Router参数传递购物车数据会频繁变化我用了Pinia来统一管理。打开前端页面时从后端拉一次购物车列表增删改的返回值再更新store。这里有个很容易踩的坑页面刷新后Pinia的数据会清空。所以刷新后必须重新拉取接口不要在刷新后依赖内存里的数据。订单页跳转时如果只是传订单id我建议用query方式router.push({ path: /order/detail, query: { orderId: id } });这样刷新页面URL里还带着orderId页面照样能取到数据。如果用params传参刷新后参数会丢失这是一个很经典的Vue路由问题。3.3 自定义v-model做了一个农产品批次选择器普通的下拉选择框不够用我封装了一个“批次选择器”组件让父组件可以通过v-model直接拿到选中的批次对象。Vue 3里自定义v-model的写法是这样的template div el-select :model-valuemodelValue changehandleChange placeholder请选择批次 el-option v-foritem in batchList :keyitem.id :label${item.batchNo} - ${item.origin} - ${item.price}元/${item.unit} :valueitem / /el-select /div /template script setup defineProps({ modelValue: Object, }); const emit defineEmits([update:modelValue]); function handleChange(val) { emit(update:modelValue, val); } /script父组件里就这样用BatchSelect v-modelselectedBatch /选到批次后库存、产地、检测报告都会跟着一起展示。这个组件在商品详情页和后台批次管理页都复用了省了很多事件传参的烦恼。3.4 axios封装与接口统一错误处理前端所有请求必须走一个统一封装的axios实例否则后端返回401、500、业务异常时你会写一堆重复代码。我在utils/request.js里做了三件事请求头注入token、响应统一解包、错误码统一弹提示。另外还加了“防重复提交”的处理同一接口如果在2秒内重复点击直接忽略。const pendingSet new Set(); service.interceptors.request.use((config) { const requestKey ${config.method}:${config.url}; if (pendingSet.has(requestKey)) { return Promise.reject(new Error(请勿重复请求)); } pendingSet.add(requestKey); setTimeout(() pendingSet.delete(requestKey), 2000); const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; });这样处理后用户在点击“提交订单”按钮时即使连续点多次也只会发一次请求后端库存压力小很多。4. 后端Spring Boot核心实现安全、数据、文件上传后端是平台的底盘。这里挑几个让我花时间最多的点讲。4.1 Spring Security JWT实现多角色登录鉴权Spring Security配置比较绕但千万不能省。我实现的是登录接口放行其余请求走JWT过滤器角色权限用注解控制。JWT过滤器核心逻辑public class JwtAuthenticationFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims JwtUtil.parseToken(token); // 从Redis里拿用户权限集合 ListString roles redisTemplate.opsForSet().members(role: claims.get(userId)); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(claims.get(userId), null, roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { SecurityContextHolder.clearContext(); } } chain.doFilter(request, response); } }在Controller里我直接用PreAuthorize(hasRole(ADMIN))管理后台接口买家接口通过用户id从token取避免前端随便传userId。4.2 MyBatis-Plus分页插件与复杂查询优化列表页、订单页都用到分页。MyBatis-Plus的分页一定要加拦截器不然Page对象只会返回全表数据Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }分页查询时推荐用IPage接收IPageOrderVO page orderMapper.selectOrderPage( new Page(pageNum, pageSize), queryWrapper);列表页还有一个优化不要select *只查需要的列。农产品订单列表经常要关联商品名、批次号、用户昵称我分开写了两条SQL一条查订单主表分页一条批量查关联信息避免大SQL把数据库拖垮。4.3 全局过滤器处理XSS与上传PDF的安全问题电商后台经常要上传检测报告PDF但这也给了黑客可乘之机。我一开始只做了文件类型判断后来发现直接传一个改后缀的恶意文件也能过于是做了两层防护普通请求参数用Jsoup对富文本内容做HTML标签白名单过滤防止XSSmultipart上传先校验文件大小和Content-Type再读取文件头判断真实格式。PDF的文件头通常是%PDF不是就拒绝全局过滤器实现了一个OncePerRequestFilter放行了登录接口、静态资源、下载接口其余请求统一处理if (contentType ! null contentType.contains(multipart/form-data)) { // 只做文件名和文件头校验不对stream做改动 } else { String body IOUtils.toString(request.getInputStream(), StandardCharsets.UTF_8); String cleanBody Jsoup.clean(body, Safelist.relaxed()); request new CustomRequestWrapper(request, cleanBody); }这个过滤器一定要谨慎处理放行规则否则会把下载、文件预览一并拦截用户会一脸懵。4.4 订单库存并发Redis预扣 乐观锁兜底农产品秒杀活动时库存容易超卖。我的方案是Redis里预扣库存数据库扣减用乐观锁版本号兜底。用户点击立刻购买时先走Redis Lua脚本原子扣减库存如果扣减成功才生成订单如果失败直接提示库存不足。订单支付超时未支付再通过定时任务回补库存。数据库的库存表加了version字段更新时条件带上版本号UPDATE product_spec SET stock stock - 1, version version 1 WHERE id #{specId} AND stock 1 AND version #{version}如果更新影响行数为0说明库存被别人抢了需要回滚订单。这套方案双十一那种级别人群肯定扛不住但对于中小型农产品平台完全够用而且代码简单好维护。5. 部署与运维Docker化Spring Boot Vue的完整流程本地跑通只是第一步部署上线才是考验。我把前后端都用Docker打包最终用docker-compose一键拉起整套服务。5.1 后端镜像制作与配置外部化后端Dockerfile我用了多阶段构建避免把Maven镜像和Java运行镜像混在一起FROM maven:3.8-openjdk-8 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM openjdk:8-jre-alpine WORKDIR /app COPY --frombuilder /app/target/farm-platform.jar /app/app.jar ENV TZAsia/Shanghai EXPOSE 8080 ENTRYPOINT [java, -jar, /app/app.jar]配置环境变量是关键。我不在镜像里写死数据库密码而是通过application.yml的占位符读取环境变量spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicodetruecharacterEncodingutf8 username: ${DB_USER} password: ${DB_PASSWORD}这样每个环境各改各的env文件不会把密码提交到代码仓库。5.2 前端构建与Nginx静态资源部署Vue项目build后会生成静态文件用Nginx托管。有个坑Vue Router用了history模式必须配try_files否则刷新页面会404。server { listen 80; server_name farm.example.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } gzip on; gzip_types text/plain text/css application/json application/javascript; }前端镜像直接把构建后的dist目录打进Nginx镜像里就行但我个人习惯是先构建、再单独把dist挂载到nginx容器这样前端代码更新时不用重新构建镜像。5.3 docker-compose编排MySQL、Redis、后端、前端我本地写了一个docker-compose.yml开发环境和测试环境都用它起version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: farm_platform TZ: Asia/Shanghai volumes: - mysql-data:/var/lib/mysql - ./init.sql:/docker-entrypoint-initdb.d/init.sql ports: - 3306:3306 redis: image: redis:6-alpine ports: - 6379:6379 backend: build: ./backend environment: DB_HOST: mysql DB_PORT: 3306 DB_NAME: farm_platform DB_USER: root DB_PASSWORD: rootpass REDIS_HOST: redis depends_on: - mysql - redis ports: - 8080:8080 frontend: build: ./frontend ports: - 80:80 depends_on: - backend注意compose里的容器名解析后端访问数据库时DB_HOST填的是服务名mysql不是localhost。当初我有一次没改环境变量直接在容器里连localhost结果连了半天最后才意识到容器之间要靠服务名互通。5.4 上线后日志排查与性能调优常用命令部署完最常用的几个命令docker compose ps看服务状态docker compose logs -f backend跟后端日志docker stats看容器CPU、内存占用curl -v https://xxx/api/product/list确认接口是否通如果接口响应慢我先看是不是慢SQL。在Spring Boot生产环境开了MyBatis-Plus的慢SQL日志超过2秒的SQL会在日志里输出再结合MySQL的EXPLAIN看有没有走索引。农产品商品列表经常按销售排序我给product表的sales字段加了普通索引查询速度提升明显。6. 踩坑记录那些花了整个下午才解决的问题这里不写那种一搜就能解决的报错只写几个我反复折腾过的业务和技术问题。6.1 农产品重量和金额的精度丢失最初我用double存金额和重量结果计算运费时出现一堆小数尾巴之后对账对不上。后来把所有金额、重量字段全部换成BigDecimal数据库用decimal(10,2)。JSON序列化时也对BigDecimal做了统一处理避免前端拿到科学计数法。/**一定记住涉及钱和重量的字段永远是BigDecimal永远不要用double。 */6.2 库存超卖在秒杀场景下差点出事上线第一周搞了一次“1元秒杀10斤苹果”结果Redis和数据库库存没有同步导致页面显示有货下单后却提示库存不足。后来用了Lua脚本扣减库存并让“库存缓存”和“数据库库存”保持一致超卖问题才算解决。如果只靠数据库乐观锁库存不多时可以硬扛但秒杀场景下Redis预扣才是体验最好的方案。6.3 订单状态机与售后退款边界不清一开始订单状态只用了几个数字0待支付、1已支付、2已发货、3已完成、4已取消。但后来加了申请退款、退款中、退款完成、拒绝退款后数字堆到9个代码里到处是if status3 or status4改起来极容易出错。我重构后用枚举定义状态机每个状态允许的流转动作写清楚public enum OrderStatus { WAIT_PAY(0, 待支付), PAID(1, 已支付), SHIPPED(2, 已发货), COMPLETED(3, 已完成), CANCELED(4, 已取消), REFUND_APPLY(5, 退款申请), REFUNDING(6, 退款中), REFUNDED(7, 已退款), REFUND_REJECTED(8, 拒绝退款); private final int code; private final String desc; }在OrderStatusService里专门写了canChange(from, to)方法凡是不合法的状态流转直接抛异常。这样就算后端接口被恶意调用状态也不会乱。6.4 Vue路由参数刷新丢失和store数据不同步有一个页面是“农户后台-批次管理”我刚开始用params传当前批次ID结果用户一刷新ID没了页面白屏。后来统一改成query传参并在onMounted里判断如果有query参数就加载数据没有就返回列表页。还有购物车数量我一直以为改Pinia就行后来发现多个组件没有同步刷新就是因为忘了Pinia的state变化不会自动通知非组件业务代码。解决方法是把购物车操作统一抽到stores/cart.js的action里所有组件都调action不在组件里直接改cart.items。7. 如果再做一次我会提前注意的事我踩过不少坑之后最大的体会是农产品电商平台的核心不在页面多炫、接口多花哨而在数据模型是否贴近业务。批次、溯源、非标规格、履约规则这些如果你在数据库设计阶段没有想清楚后期就是一夜回到解放前。如果再让我重新接一个类似项目我会先花一周时间梳理业务把商品模型和订单状态机画成文档再让前后端一起评审接口文档而不是急着搭脚手架。前端那边动态路由和权限判断一定要在一开始就铺垫不然后台菜单越来越多后面再改造会非常痛。部署上也别再手动敲命令了从第一天就把Docker Compose配好所有人拉下来一键启动开发体验会舒服很多。这套Spring Boot Vue的农产品电商平台现在已经稳定跑了几个月。农产品电商看似和普通商城差不多但每一处细节都暗藏业务逻辑希望这篇记录能让你少走我走过的弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进