
在动手做这个系统之前我其实挺纠结的。市面上电商系统教程一抓一大把随便搜都是XX商城但大部分都停留在玩具级别——写几个 CRUD 接口、套个前端模板就算完事。真正做一套能卖给果农合作社、能在芒果成熟季扛住并发下单的系统完全是另一码事。攀枝花这个地方的水果很有意思芒果、石榴、枇杷、樱桃都是地域名片但销售模式一直比较原始。很多果园还是靠微信接单、Excel 记账遇到预售季集中爆单的时候人工处理订单根本忙不过来。我这次接到的需求就是给一家做产地直销的果品合作社搭建一套在线销售系统最终技术路线定为 Java SpringBoot Vue MySQL 这套经典组合。整篇文章我会按照一个完整项目的推进顺序来写从需求分析、技术选型、数据库设计到前后端核心实现、部署上线和踩坑复盘全程记录真实决策过程希望能给正在做类似毕设或小型电商项目的朋友提供一些可以照抄的实操参考。1. 先想清楚业务逻辑水果电商和普通电商差在哪1.1 生鲜非标品带来的三个核心难题做系统之前我先跟果农和运营聊了一圈发现水果销售和卖标准工业品完全不一样。普通商品SKU明确、库存稳定、发货流程固定水果则处处都是非标。第一个难题是规格描述复杂。一箱芒果可能按5斤装10斤装区分也可以按特大果中果小果分级加上不同品种、不同产地批次一个芒果的背后其实是多层规格参数。数据库表如果按单一商品表去设计后面改价格、改库存会非常痛苦。第二个难题是库存和批次强绑定。水果不像手机同一型号可以无差别补货水果每一批的成熟度不同实际可售数量是跟着采果计划走的。也就是说系统里的库存表不能只存一个总量还要能追溯到具体批次。第三个难题是订单状态流转更复杂。从用户下单到收到货要经过待付款、已付款、待采摘、已发货、运输中、已签收这些环节中间还有退款、理赔等异常分支。订单表的状态机设计含糊的话后端接口会越写越乱。1.2 从业务场景推导出技术需求清单和用户确认完业务流程后我把需求整理成明确的模块清单用户端注册登录、商品浏览与分类筛选、加入购物车、下单支付、订单跟踪、个人中心、收货地址管理。管理端商品分类管理、商品与规格维护、库存批次管理、订单处理发货/退款、销售数据看板。基础能力图片上传、统一鉴权、接口幂等、异常处理。由此得出的技术需求也很明确前端要支持两个独立界面用户端管理后台后端要提供 RESTful API数据库要支撑订单和库存的高并发一致性。这个阶段不引入分布式事务、消息队列因为业务规模和团队维护能力都达不到单体架构加本地事务已经够用。2. 技术选型的思考过程为什么是 SpringBoot Vue MySQL2.1 后端选型的取舍对于这类中小型业务系统SpringBoot 几乎是最稳妥的选择。它的生态成熟度极高Spring Security、MyBatis-Plus、Redis、MinIO 这些组件都有现成集成方案出问题随便一搜就有答案。我这次用的是 SpringBoot 3.2 Java 17持久层 MyBatis-Plus数据库 MySQL 8.0。为什么不用 MyBatis 原生因为项目里大量简单 CRUD手写 SQL 效率太低MyBatis-Plus 的 LambdaQueryWrapper 能省掉大概 30% 的样板代码。复杂报表统计的 SQL 仍然手写放在自定义 Mapper 里两种方式混合使用效率最高。JWT 做登录态管理令牌无状态、支持跨域前端部署在 Nginx、API 在后端服务二者分离得比较干净。实际开发中 JWT 也比 Session 更适合前后端分离架构因为不需要额外维护 Session 同步。2.2 前端选型的取舍前端用 Vue 3 Vite Element Plus Pinia。选择 Vue 的核心理由是组件生态丰富、中文文档友好、上手曲线平滑。淘宝、美团这类电商场景对页面交互要求不算极端复杂Vue 的响应式数据绑定和组件复用机制完全能覆盖需求没必要上 React。Element Plus 负责后台管理界面非常合适表格、表单、弹窗、日期选择器都是现成的组件。用户端商城页面我需要更高自由度所以是自定义组件 少量 Element Plus 穿插使用。状态管理用 Pinia 替代 Vuex语法更简洁TypeScript 支持也更自然。UI 上用户端做一个偏温暖、水果风格的设计主色调可以用芒果黄搭配绿色整体页面要给人新鲜、自然的感觉。这一块虽然不直接影响功能但作为电商系统对用户信任感的建立非常重要。2.3 项目整体结构规划项目采用前后端分离仓库后端是一个标准 Maven 单模块项目目录如下fruit-sale-server/ ├── src/main/java/com/example/fruitsale/ │ ├── controller/ # 接口层只做参数接收和结果返回 │ ├── service/ # 业务逻辑层事务边界在这里 │ ├── mapper/ # MyBatis-Plus 数据访问层 │ ├── entity/ # 数据库实体 │ ├── vo/ # 视图对象接口返回数据结构 │ ├── dto/ # 数据传输对象接收前端入参 │ ├── config/ # 配置类跨域、拦截器、文件上传等 │ ├── common/ # 统一返回结果、异常处理、常量类 │ └── utils/ # 工具类JWT、日期等 └── src/main/resources/ ├── application.yml # 公共配置 ├── application-dev.yml # 开发环境配置 ├── application-prod.yml # 生产环境配置 └── mapper/ # 自定义 SQL XML前端分成两个入口用户端和管理后台通过路由区分构建时统一打包成单页应用。用户端路由有首页、商品列表、商品详情、购物车、订单确认、个人中心等管理端路由有数据看板、商品管理、订单管理等。3. 数据库设计把商品、库存、订单整合进 MySQL数据库设计是整个系统里最重要的一步后面所有接口逻辑顺畅不顺畅百分之八十取决于建表设计合理不合理。这个项目我一共设计了 11 张核心表重点讲一下设计思路。3.1 商品与库存的表结构设计商品信息拆成两张主表和一张关联表。产品表product存储商品公共信息规格表product_specification存储具体规格一张产品对应多个规格。CREATE TABLE product ( id bigint NOT NULL AUTO_INCREMENT, category_id bigint NOT NULL COMMENT 分类ID, name varchar(100) NOT NULL COMMENT 商品名称, main_image varchar(255) DEFAULT NULL COMMENT 主图地址, description text COMMENT 商品详情描述, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, sort int DEFAULT 0 COMMENT 排序权重, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个细节务必注意name、description这些字段要设置成utf8mb4字符集不要用utf8。因为 MySQL 的utf8实际上不支持完整的四字节字符用户或者运营在描述里塞几个 emoji 表情接口就会报Incorrect string value错误非常坑。规格表里商品价格保留两位小数这是电商系统遇到的最经典问题之一金额必须用DECIMAL类型严禁使用DOUBLE或FLOAT。浮点数在二进制里无法精确表示后台计算订单总价会出现类似 19.39 5.99 算出来 25.370000000000002 这种诡异结果。库存字段stock加了一个version字段这是为乐观锁准备的。芒果成熟季预售活动开始的时候很多人同时下单抢购version字段能帮我实现并发安全扣库存。这个场景在电商里属于超卖问题的重灾区后面下单流程会详细说。3.2 订单相关的四张表订单拆成order主表和order_item明细表这是标准的电商做法。为什么必须拆因为一个订单可能包含多件商品比如用户同时买了一箱芒果和一箱石榴订单主表存用户信息和总金额明细表一行存一种商品。如果不拆表要么字段冗余要么统计商品销量的时候极其困难。用户地址单独拆一张address表没有直接存在订单表。因为用户可能多个地址下单时候选择收货地址订单只需要冗余一份地址快照。注意是快照——用户的地址信息将来可能修改历史订单必须保留下单时的地址原文这个细节很容易被忽略。支付记录用payment_record表记录它和订单表是 1 对 1 关系。为什么要有这张表因为实际生产环境对账需要即使你是模拟支付也要把支付流水记录下来将来接微信支付、支付宝的时候才能对接第三方的回调数据和账单明细。订单状态字段我用的是status整型枚举值如下状态值含义下一步动作0待付款用户取消或支付1已付款/待发货管理员确认发货2已发货等待用户确认收货3已签收/已完成无4已取消无5退款中/已退款管理员处理状态流转在代码里不应该被随意修改我写了一个订单状态机每个状态只允许特定的下一个状态防止接口被人直接改数据库。3.3 统计报表与购物车销售数据看板需要按天维度统计订单量、销售额、热销商品。可以直接在查询的时候用GROUP BY实时聚合数据量不大的时候完全没压力。不过如果将来订单量大我更建议建一张每日销售汇总表用定时任务在每天凌晨汇总前一天数据查询报表会快一个量级。购物车表设计成cart_item关联用户 ID 和商品规格 ID。不过后来我发现一个问题前端购物车如果依赖后端表用户每次加购都要发一次请求改数量、勾选、计算金额响应不及时。所以这个项目里购物车主要状态存在前端 localStorage后端购物车表只是用于登录态同步。这样切换设备跨端的时候数据不会丢平时操作又极其流畅。4. 后端核心模块实现从用户登录到订单完成后端是整套系统的发动机核心模块包括认证授权、商品服务、购物车、订单交易。这块涉及的并发和事务问题最多我把踩过坑的细节逐一展开。4.1 JWT 认证机制与拦截器设计认证能力我用 JWT 做了无状态登录。用户提交用户名密码后端校验成功后生成一个 token 返回前端把 token 存进 localStorage每次请求在Authorization头里带着。后端通过拦截器统一解析 token把用户信息放进ThreadLocal里Controller 层直接取用。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、注册、商品查询等公开接口 if (request.getRequestURI().startsWith(/api/auth)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BusinessException(401, 未登录或登录已过期); } // 解析 token 拿到用户 ID放入 ThreadLocal Long userId JwtUtil.parseToken(token.substring(7)); UserContext.set(userId); return true; } }实际开发中有个细节值得提醒拦截器要放行哪些路径一定要梳理清楚。用户浏览商品、搜索、查看详情是允许匿名访问的下单、支付、查看订单才需要登录。如果一刀切全部要求登录用户访问首页都跳到登录页体验会非常差。放行规则我用一个白名单数组集中管理新增公开接口只改一处。密码存储用 BCrypt 加密千万不能用 MD5。MD5 现在跑字典非常快用户密码很容易被反查出来。Spring Security 自带的BCryptPasswordEncoder就能直接使用每个用户密码哈希加盐即使两个用户密码相同加密后的字符串也不一样。4.2 商品查询接口的实用设计商品列表接口除了基础的 CRUD我增加了三个维度的筛选能力分类、价格区间、关键词搜索排序支持综合排序和价格排序。分页用 MyBatis-Plus 的Page对象实现注意返回给前端的数据结构里除了当前页数据还必须包含总记录数前端分页组件才渲染得对。商品详情页返回的数据是综合的除了商品基础信息、规格列表、库存量还需要返回当前登录用户是否在购物车加过这件商品。因此商品详情接口内部做了三次数据库查询后组装这就是VO视图对象发挥作用的地方public class ProductDetailVO { private ProductVO product; // 商品信息 private ListSpecVO specs; // 规格列表 private Boolean inCart; // 是否已加入购物车 private Integer cartCount; // 购物车中的数量 }4.3 下单流程事务、乐观锁和防重提交下单是整个系统里最容易出问题的环节也是面试中经常被问到的高频考点。一个标准的用户下单动作涉及 5 步校验商品规格是否存在、是否上架。校验库存是否充足。锁定库存。创建订单主表和明细表。清理购物车对应商品。第 3 步锁定库存要特别小心。假设两个用户同时购买同一商品如果先 SELECT 库存发现还有货然后 UPDATE 扣库存在高并发下就可能出现超卖——两个人看到库存都是 5同时买 5 箱实际只剩 5 箱数据库却生成了 10 箱订单。解决办法是用乐观锁更新Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto, Long userId) { // 1. 校验商品 ProductSpecification spec specMapper.selectById(dto.getSpecId()); if (spec null || spec.getStatus() ! 1) { throw new BusinessException(商品不存在或已下架); } // 2. 乐观锁扣减库存带 version 条件 int rows specMapper.deductStock(dto.getSpecId(), dto.getQuantity(), spec.getVersion()); if (rows 0) { throw new BusinessException(库存不足或商品信息已变化); } // 3. 创建订单主表 // 4. 创建订单明细表 // 5. 清空购物车中对应商品 }对应的 Mapper SQL 是UPDATE product_specification SET stock stock - #{quantity}, version version 1 WHERE id #{specId} AND version #{version} AND stock #{quantity}当UPDATE影响行数为 0 时说明库存不够或者有并发修改发生服务端抛出异常事务回滚订单也不会落库。前面加Transactional(rollbackFor Exception.class)保证业务中间任何一步抛错前面的扣库存、建单都会回滚不会留下脏数据。下单接口还需要考虑幂等。用户手抖点了两次提交按钮如果不做控制会产生重复订单。解决方式是在前端提交后把按钮置灰后端再配合一个防重令牌机制进入订单确认页时后端返回一个orderToken下单时必须携带这个 token订单表添加唯一索引重复提交只有一个成功。还有一种常见处理方案是检查 Redis 里的用户下单锁但当前项目没有引入 Redis所以用订单号加唯一索引的方式处理。4.4 模拟支付回调与订单状态流转真实电商系统里支付这块要接微信支付、支付宝但本地开发可以直接做模拟支付。我在测试环境实现了一套支付受理逻辑用户点击支付前端请求后端pay接口后端生成支付流水记录状态为待支付然后前端跳转到模拟收银台点击模拟支付成功后端再次调用payCallback接口把订单状态改为已付款。Transactional(rollbackFor Exception.class) public void payCallback(String orderNo, BigDecimal amount) { Order order orderMapper.selectByOrderNo(orderNo); if (order.getStatus() ! OrderStatus.PENDING_PAYMENT) { throw new BusinessException(订单状态不正确); } order.setStatus(OrderStatus.PAID); order.setPayTime(LocalDateTime.now()); orderMapper.updateById(order); // 记录支付流水 }这个模拟支付的好处是后续接真实支付渠道的时候前端只需替换支付页面的跳转逻辑后端把模拟成功的位置换成第三方异步回调/notify接口业务逻辑完全不用动。订单状态机是整个下单流程的灵魂。用户点击取消订单只能在待付款状态执行管理员发货只能在已付款状态执行用户确认收货只能在已发货状态执行。我在每次状态变更前检查当前状态不满足条件直接抛异常。这一层保护虽然让代码多几行但真的能避免很多生产事故。5. 前端 Vue 实战用户端与后台管理页面的落地5.1 路由与权限控制前端用户端与管理后台拆分表现为两套路由所有管理端路径带有/admin前缀。路由守卫是前端权限控制的关键router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (to.path.startsWith(/admin)) { // 管理员角色校验不通过跳回首页 } next() })用户登录成功后存的 token 同时包含角色信息普通用户或管理员前端根据角色判断是否能进入后台页面。这只是前端层面的路由保护后端接口也要有对应的权限校验不能只靠前端隐藏按钮来保证安全。5.2 商品展示与购物车状态管理用户端首页由顶部轮播图、分类导航和商品卡片列表组成。商品卡片点击加入购物车时交互逻辑是这样的function addToCart(spec) { if (spec.stock 0) { ElMessage.warning(该规格暂时缺货) return } cartStore.addItem({ specId: spec.id, name: spec.name, price: spec.price, quantity: 1 }) ElMessage.success(已加入购物车) }购物车状态用 Pinia 管理数据结构是MapspecId, { specId, name, price, quantity, checked }操作购物车加减、勾选、全选、删除都是本地即时响应只有提交订单时同步到后端。购物车页顶部会实时计算出已选商品件数和总金额实现上是用 Vue 的computed对 checked 为 true 的条目做累加。这里有一个小技巧价格计算用Math.round(price * 100) / 100处理浮点数精度问题避免前端展示出 88.30000000000001 这种数字影响用户信任感。5.3 订单确认页与支付页的交互细节从购物车点击去结算后跳转到订单确认页。页面分三个区域收货地址选择、商品清单、金额汇总。地址选择做成弹窗列表用户点击某个地址后高亮显示并记住选中项。如果没有地址引导用户先去新增。订单金额计算公式订单总金额 商品金额小计 - 优惠金额 运费我的项目初期没有做复杂的满减和优惠券系统所以运费规则很简单满 99 包邮不满加收 8 元。这个规则集中放在后端OrderAmountService里计算前端只负责展示。提交订单后跳转支付页用户点击模拟支付按钮支付成功后页面上会显示成功状态并自动跳转到订单列表页。这里前端需要注意支付回调结果也要在后端验证订单状态前端不能只凭pay接口返回成功就认为支付完成。5.4 管理后台的核心页面管理后台使用 Element Plus 搭建页面相对简单但功能实用。商品管理页面用一个表格展示所有商品支持分页、关键词搜索、上下架状态切换。编辑商品采用抽屉式弹窗Drawer左侧是表单点击保存调用更新接口。商品的多规格编辑要用一个嵌套表格每行一个规格动态增删一行提交时批量更新。订单管理页面是最复杂的。表格展示订单列表默认展示待发货订单。每一行有一个查看详情按钮弹窗展示订单完整信息收货地址、商品明细、金额组成。管理员点击发货弹出输入框填写快递公司和单号提交后调用后端接口更新订单状态。这个功能在生产环境要对接快递查询接口顺丰、中通这些都有开放 API但需要申请开发阶段先用文字记录。数据看板页面展示三项核心指标今日订单数、今日销售额、待处理订单数。下面再放一个热销商品 Top10 表格按销量统计排序。图表推荐用 ECharts折线图展示近 7 天销售趋势。接口从后端拿统计数据前端只需传入正确的格式。6. 部署上线的完整过程与踩坑复盘本地跑通项目只是第一步真正上线部署才是考验的开始。项目部署环境我采用最简单的单机方案MySQL 后端 Jar 包 前端静态文件 Nginx。6.1 环境配置分离与打包部署开发环境与生产环境一定要分离配置。SpringBoot 使用application-{profile}.yml文件区分application.yml里只放公共配置spring: profiles: active: ${SPRING_PROFILES_ACTIVE:dev}生产环境单独配置数据库连接、文件上传路径、日志级别等信息。部署时通过启动参数指定 prod profile。后端构建产物是一个可执行 jar 包我用 Maven 打包后在服务器上直接用该文件启动。为了统一管理进程我写了 systemd service 文件[Unit] DescriptionFruit Sale Server Aftersyslog.target network.target [Service] Typesimple Useradmin ExecStart/usr/bin/java -jar /home/admin/app/fruit-sale-server.jar --spring.profiles.activeprod SuccessExitStatus143 Restartalways RestartSec5 [Install] WantedBymulti-user.target6.2 Nginx 配置前后端联动Nginx 在这里扮演两个角色一是托管前端打包产物dist 目录二是反向代理后端 API。配置思路如下server { listen 80; server_name your-domain.com; # 前端页面 root /home/admin/www/fruit-sale-web/dist; index index.html; # 前端路由为 history 模式必须加这条 location / { try_files $uri $uri/ /index.html; } # 后端 API 反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }有几个关键的配置点第一try_files $uri $uri/ /index.html;这一行必须写。Vue 路由用 history 模式时前端地址刷新后如果 Nginx 没有这行配置会返回 404。加了这个配置后任意深度的前端路由都回退到 index.html由前端路由接管解析。第二后端接口都在/api/前缀下Nginx 把这一部分代理给后端服务proxy_pass末尾的斜杠处理要注意。如果后端 Controller 的 RequestMapping 本来就是/api/...那么proxy_pass http://127.0.0.1:8080;保持原样就行。第三图片上传后通过 URL 访问Nginx 需要映射上传目录location /uploads/ { alias /home/admin/app/uploads/; }6.3 我在这个项目里踩过的四个高频坑坑一时区问题线上部署后订单时间比真实时间早 8 个小时。原因是 MySQL 连接串里没有指定时区默认使用服务器时区而服务器时区又是 UTC。解决方案是在 JDBC 连接串中显式指定jdbc:mysql://localhost:3306/fruit_sale?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiLinux 服务器本身也可以统一时区执行timedatectl set-timezone Asia/Shanghai就能解决。坑二跨域问题本地开发时前端跑在 5173 端口后端跑在 8080 端口直接请求会触发跨域拦截。我用两种方式解决开发环境用 Vite 的 proxy 配置把/api代理到后端生产环境由 Nginx 代理所以不必完全依赖后端 CORS。但为了保险起见后端还是加了一个全局 CORS 配置类允许指定来源跨域。坑三图片上传之后的访问地址失效开发时我把图片直接存放在本地磁盘的临时目录结果上传后刷新页面图片就 404。原因是临时目录被系统定期清理而且路径是绝对路径换到服务器后找不到。正确做法是配置一个独立的上传目录SpringBoot 通过静态资源映射把/uploads/**映射到该目录Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/uploads/**) .addResourceHandler(file: uploadPath /); }坑四并发扣库存实战验证代码写完后我用 JMeter 模拟 200 个并发用户同时抢购同一芒果规格库存设置为 50最终跑完订单数量 50 个库存归零没有超卖也没有负库存。这个测试建议每个电商项目上线前都跑一遍。如果发现依然超卖可以先排查 UPDATE 语句的stock #{quantity}条件是否写全再看是不是用了SELECT ... FOR UPDATE但事务没生效。7. 系统上线后的收尾体会与扩展建议项目上线运行一个多月后我复盘了整个过程。很多人在做这类系统时容易把精力全花在页面好不好看上而真正考验系统能力的问题几乎都在后半程数据一致性能不能保证、接口能不能扛住集中流量、出现异常订单能不能快速定位原因。这些是靠扎实的数据库设计、清晰的事务边界和完整的日志记录支撑起来的。我做接口联调时发现日志里加上方法入参、核心判断分支、关键数据变化这三个层面的输出排查问题效率翻倍。关于未来扩展我的建议是走小步快跑的路线可以先接上真实的微信支付和物流查询把支付体验和物流跟踪补全然后增加促销模块例如满减和优惠券再往后可以做库存预警芒果成熟季之前根据历史销量预测需要采摘的量。这些扩展的共同基础是当前的表设计保留的字段充分比如订单表里有冗余的规格名称快照、支付流水表和订单表分离这样新增需求时基本不需要动老的表结构。文章写到这这套基于 Java SpringBoot Vue MySQL 的攀枝花水果在线销售系统的核心内容都梳理完了。最后留一句个人的心得体会做全栈项目不要追求一步到位先把主流程跑通再逐步加固细节这个系统从能够下单到能够稳定扛住成熟季流量中间迭代了整整三周。希望我的记录能帮你少走一些弯路。