首页 / 资讯中心 / 文章详情

前后端分离水果商城系统:从技术选型到部署实践

前后端分离水果商城系统:从技术选型到部署实践 ★ FEATURED ARTICLE
前后端分离卖水果这个题目听起来简单但真正做完你会发现它把电商系统该有的核心链路全给串起来了商品展示、购物车、下单、订单状态流转、后台管理、图片上传、权限控制。而且水果这个品类还有个特点——订单量大、客单价不高、商品更新频繁对列表检索和详情展示的要求比较典型非常适合用来练手或当作简历项目。整套源码配着部署教程从本地跑通到上线访问链路很完整。下面我会把技术选型思路、数据库设计、前后端核心实现、部署过程和踩坑记录全部过一遍如果你也打算拿这套系统做二次开发或者学习参考可以直接照着我这个流程来。1. 系统整体设计与方案选型1.1 为什么必须选前后端分离先说一个实际感受我是从传统单体 JSP 项目转到前后端分离的转过来之后最大的感触不是技术多高级而是团队协作和调试体验发生了根本变化。前端只关心数据怎么展示、交互怎么做后端只关心接口稳不稳定、数据返没返对。两边通过 JSON 对接接口定义清楚了开发就能并行推进谁也不用等谁。这个水果销售系统采用前后端分离还有一个实际好处后台管理和前台商城可以拆成两个不同的前端页面但共用同一套后端接口。比如商品管理页面调的是/api/product/admin/xxx商城首页调的是/api/product/list后端的 Controller 只需要区分角色权限前端却能各走各的构建和发布流程。如果你后面想给这个系统加一个小程序端或者移动端后端几乎不用动新端直接复用同一套接口就行。1.2 技术栈选型背后的几个现实考量选 SpringBoot Vue MyBatis MySQL不是因为这些技术最潮而是因为它们组合在一起性价比最高资源也最容易找。后端用 SpringBoot图的是低配置化和生态成熟。依赖管理有 start 场景启动器内嵌 Tomcat 让部署变成打一个 jar 扔服务器上就能跑。这对中小型系统来说比繁琐的 XML 配置或者微服务那一套要实在得多。持久层选 MyBatis我是特意做了对比的。JPA 那种全自动 ORM 在单表 CRUD 时很爽但遇到商品多条件筛选、订单明细和商品表联查这类稍微复杂的查询写 JPQL 或者 Specification 就会绕。MyBatis 的核心优势是SQL 自己掌控查询性能、连表逻辑、字段筛选都能精确控制出了问题我可以直接拿着 SQL 去数据库里跑一遍排查效率快很多。代价就是多写一些 XML 映射但对于这种业务逻辑不复杂的系统完全能接受。前端选 Vue 而不是 React主要是上手曲线问题。Vue 的单文件组件写法、模板语法、指令系统几乎就是为快速实现页面切图和数据绑定设计的。配合 Element UI 的组件库后台管理的表格、表单、弹窗基本不用自己写样式能省掉大量时间。这个项目用 Vue 3 Vite 或者 Vue 2 Element UI 都可以跑源码里用的是主流的 Vue 3 组合式 API 写法开发体验和构建速度都更好。MySQL 这边就更没什么好纠结的了。它本身是国人最熟悉的关系型数据库环境问题少的可怜生产环境用 5.7 或 8.0 都很稳定。对于水果商城的并发量用主从、分表那套纯属过度设计单机 MySQL 做好索引就够了。1.3 功能模块划分与业务闭环这套系统的功能划分我把它理成一条完整的主线方便你理解代码结构用户端注册/登录、商品分类浏览、商品详情查看、关键字搜索、购物车增删改、提交订单、订单列表及状态查看、个人信息维护。管理端商品分类管理、商品上下架与库存维护、订单状态处理发货/完成/取消、用户列表查看、销售数据统计展示。为什么这个模型能形成闭环因为用户从看到水果到下单付款再到管理员后台处理订单发货每个环节都落到了数据库表中加上权限隔离两端的操作不会串数据。对于初学者来说理解这个闭环比单纯写 CRUD 接口重要得多——它决定了你表结构怎么设计接口怎么分工页面之间怎么跳转。1.4 前端项目结构速览这是源码里前端的目录结构我贴出来让你有个直观印象fruit-shop-web ├── public/ // 静态资源favicon等 ├── src │ ├── api/ // 按模块封装的后端接口调用 │ ├── assets/ // 图片、公共样式 │ ├── components/ // 通用组件轮播、商品卡片等 │ ├── router/ // 路由配置含动态路由和守卫 │ ├── store/ // Vuex/Pinia 状态管理 │ ├── views │ │ ├── home/ // 商城首页 │ │ ├── product/ // 商品列表、详情 │ │ ├── cart/ // 购物车 │ │ ├── order/ // 下单、订单列表 │ │ ├── user/ // 用户中心 │ │ └── admin/ // 后台管理页面 │ ├── utils/ // 请求封装、工具函数 │ └── App.vue你拿到源码第一件事我建议先从这个目录结构下手。把api目录里的接口都看一遍再对照后端 Controller 的 RequestMapping前后端是怎么对应的心里就有谱了。2. 数据库设计要点与后端核心实现2.1 数据表是怎么设计的水果商城的表数量不需要太多但每一张表的信息我都建议你仔细看尤其是字段类型和约束。下面是核心表的清单和作用表名主要字段作用userid, username, password, nickname, phone, avatar, create_time前台用户信息密码加密存储categoryid, name, sort, status水果分类比如时令水果进口水果productid, category_id, name, cover, price, original_price, stock, unit, sales, detail, status商品主表封面图、价格、库存cartid, user_id, product_id, quantity, checked购物车条目ordersid, order_no, user_id, total_price, status, receiver_name, receiver_phone, receiver_address, create_time, pay_time订单主表状态区分待付款/已付款/已发货等order_itemid, order_id, product_id, product_name, product_price, quantity订单明细下单时快照商品信息几个字段设计上的细节我要单独拎出来说因为这些是实际开发中很容易踩坑的地方价格字段用 decimal 不要用 float。float 会有精度丢失金额计算错一分钱的后果很严重。Java 端对应使用BigDecimalMyBatis 里直接映射为 decimal 即可。订单状态用 tinyint 加注释。0 待付款1 待发货2 待收货3 已完成4 已取消。不要在数据库里存中文状态字符串一方面冗余另一方面后续如果要改状态名还得写 SQL 去 update纯属自己给自己找事。状态含义在前端维护一个枚举映射就行。order_item需要冗余商品名称和价格。这是一个非常关键的设计细节。用户下单后如果管理员把商品改了名字或者改了价格订单明细里的记录不应该跟着变。所以下单时要把商品快照写入明细表而不是留着商品 ID 去联查最新数据。2.2 SpringBoot 后端分层与关键配置后端包结构我强烈建议你照着看com.fruit.shop ├── config/ // 跨域配置、拦截器注册、虚拟路径映射 ├── controller/ // 接收请求返回统一JSON ├── service/ // 业务逻辑层 ├── mapper/ // MyBatis的Mapper接口 ├── entity/ // 实体类 ├── common/ // 统一返回结果、异常处理 └── utils/ // JWT、MD5等工具为什么这么分层Controller 只做参数接收和结果封装Service 处理业务逻辑Mapper 只负责数据库交互。这样改任何一层都不会影响其他层也好写单元测试。核心配置文件application.yml重点看这几项server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/fruit_shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 10MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.fruit.shop.entity configuration: map-underscore-to-camel-case: true jwt: secret: fruit-shop-secret expire: 604800注意两个地方一是context-path: /api这样后端所有接口都统一挂在/api前缀下前端代理和 Nginx 转发都会省很多事。二是 MyBatis 的map-underscore-to-camel-case必须开启这样数据库的create_time才能自动映射到 Java 里的createTime不用每个字段都写映射关系。2.3 用户登录认证与 JWT 是怎么运作的用户登录这块是这个系统的核心链路之一用的方案是 JWT即无状态令牌认证。每次请求后端都需要知道你是谁传统方案的 Session 需要服务端保存会话状态对于前后端分离、后端可能多实例部署的场景不太方便。JWT 把用户信息加密签进 token 里后端不保存任何会话只需要校验 token 签名。流程拆开是这样用户输入用户名密码后端从库里查出用户密码加密比对成功后生成 token 返回前端。前端拿到 token 后存到 localStorage 或 Pinia 里之后的每次请求都在 Header 里带Authorization: Bearer token。后端通过拦截器统一拦截需要认证的接口解析 token校验通过就放行并把用户 ID 塞进 request 上下文。密钥配置在application.yml里我用的是 jjwt 库来实现签发和解析。这里要注意token 一般设置 7 天过期过期后前端需要重新登录。如果要做记住我功能可以适当延长过期时间。用户密码存储不要用明文。源码里统一用的 MD5 加盐的方式。虽然现在更推荐 BCrypt但对这个项目而言 MD5 加盐已经能挡住绝大多数初级的拖库风险。如果你要做得更正规把 MD5 换成 BCrypt 也只有几个方法的事。2.4 MyBatis 动态 SQL 的高频使用场景商品列表查询是 MyBatis 动态 SQL 用得最多的地方。比如筛选条件可能是按分类 价格区间 水果名称模糊搜索字段不同组合有很多种。如果每个组合写一条 SQL那会写出花来。用if标签动态拼接就能完美解决select idsearchProducts resultTypecom.fruit.shop.entity.Product SELECT * FROM product where if testcategoryId ! null AND category_id #{categoryId} /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY id DESC /selectwhere标签有个隐藏优势如果所有条件都不成立它不会生成WHERE关键字避免了 SQL 语法错误。另外注意和在 XML 里要转义成gt;和lt;否则 XML 解析直接报错。商品列表的分页源码用的是 PageHelper 插件一行代码搞定PageHelper.startPage(pageNum, pageSize); ListProduct list productMapper.selectPage(condition); PageInfoProduct pageInfo new PageInfo(list);这个插件的好处是物理分页它会自动生成LIMIT ? , ?拼到 SQL 后面返回的PageInfo里已经带好了总条数和总页数前端分页直接取数。用的时候记住PageHelper.startPage后面必须紧跟第一条查询语句中间不要插入其他查询否则分页会失效这个坑我踩过不止一次。2.5 商品图片上传与虚拟路径映射商品图片上传常见的方案是传到 OSS 或者本地磁盘。这个系统用的是本地磁盘存储简单直接。上传接口接收 MultipartFile然后按日期分目录存到服务器磁盘String fileName UUID.randomUUID().toString().replace(-, ) . suffix; String filePath uploadDir / dateStr / fileName; file.transferTo(new File(filePath));但这里有个前后端对接很关键的问题后端把图片保存到磁盘后怎么让前端通过 URL 访问到它答案就是 WebMvcConfigurer 里的虚拟路径映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/images/**) .addResourceHandler(file: uploadDir /); } }这样前端请求/api/images/20251120/xxx.jpg后端就能把/images/**映射到本地的uploadDir目录去读取文件。部署到服务器时只需要把uploadDir改成服务器上的绝对路径比如/data/fruit-images/并且在 Nginx 里也配置一个虚拟路径映射图片访问压力就不会全部打在 Tomcat 上。3. 前端 Vue 实现与前后端联调3.1 前端核心页面拆解商城首页是个标准的电商首页布局顶部导航带搜索框、轮播图、分类导航、热销商品列表。这里面的搜索和分类跳转我建议你重点看路由传参的设计——点击某个分类时通过router.push({ path: /products, query: { categoryId: 3 } })带参数跳转商品列表页再根据 query 去调用接口。这种方式的好处是 URL 可以直接分享别人打开你的链接也能看到同一分类的商品。商品详情页更要点这样几个功能商品图片展示、切换到购物车的联动、库存判断。库存这块尤其要注意当购买数量超过库存时前端要直接禁用提交按钮同时后端 Service 也要做一次库存校验因为前端的校验永远不能作为安全边界。购物车页面我调了挺久。逻辑上要处理的问题很典型修改数量怎么同步后端取消勾选怎么重新计算总价批量删除和单个删除怎么统一源码里的做法是本地维护购物车列表的状态每次数量加减立即调后端接口更新成功后再刷新总价。虽然多了一些请求但数据的准确性优先。3.2 Axios 封装与请求拦截器前端对接后端接口最忌讳每个页面写一大段重复的axios.get(...)。源码里把 axios 封装到了utils/request.js里统一做了三件事const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器自动带上token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) // 响应拦截器统一处理业务错误和登录过期 service.interceptors.response.use( response { const res response.data if (res.code ! 200) { if (res.code 401) { // token失效跳转登录页 router.push(/login) } return Promise.reject(new Error(res.msg)) } return res.data }, error { return Promise.reject(error) } )你注意看baseURL: /api这个配置。前端开发环境里走的是代理方式把/api开头的请求转发到后端的localhost:8080生产环境里 Nginx 也是把/api转发到后端的 jar 服务。这样前端代码里接口路径就不用写死 IP 和端口换环境时只需要改代理配置或 Nginx 配置前端一行不用动这是前后端分离项目里很重要的一步。3.3 开发环境跨域问题怎么根治联调阶段你大概率会碰到这个报错Access to XMLHttpRequest at http://localhost:8080/api/xxx from origin http://localhost:5173 has been blocked by CORS policy。这就是跨域问题。解决思路有两个我建议两个都配置上后端在 WebConfig 里加跨域支持registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600);前端 Vite 开发环境配代理server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }为什么两个都配开发环境前端是 5173 端口、后端 8080端口不同就是跨域用 Vite 代理解决。但有时候你直接用浏览器访问后端 Swagger 或者后期打包后的前端放在 Nginx 里后端如果没开 CORS 就会出现接口通不了的情况。所以两个都配了才能保证开发和生产都稳定。我想提醒的是allowedOriginPatterns用*在带 cookie 的场景下会有问题但 JWT 认证本身不依赖 cookie所以问题不大。3.4 购物车与订单状态同步的交互细节购物车到订单这个环节我遇到一个很典型的交互问题用户勾选了 2 件水果点结算到了订单确认页发现库存不够这时候怎么提示最合理源码里做的是在订单创建接口里统一校验返回明确错误码和提示。前端收到库存不足的提示后自动刷新购物车列表并定位到出问题的商品打上提示标记。这个体验虽然实现上多写了一些状态管理代码但用户实际使用时会明显觉得系统很聪明。订单创建成功后购物车里下单的商品会被清掉前端通过订单明细回显待付款状态。用户点击模拟支付后端把订单状态更新为待发货。管理后台看到待发货订单后点发货订单状态更新为待收货。4. 完整部署流程与实操记录4.1 环境准备清单部署之前先把环境列清楚这里我踩过的坑是以为自己早就装好了然后事到临头发现版本不对。顺序建议软件版本建议说明JDK1.8 或 111.8 兼容性最好11 也可以Maven3.6后端打包MySQL5.7 或 8.0注意驱动不一致的问题Node.js14 / 16前端构建环境Vue3 需要更高版本Nginx1.20前端部署和反向代理这里必须单独强调如果 MySQL 是 8.0application.yml 里的驱动必须是com.mysql.cj.jdbc.Driver。换成 MySQL 5.7 时如果直接用 8.0 的驱动类虽然也能跑但连接 URL 里serverTimezone参数可能会引发报错。最稳妥的做法是确认环境后按对应版本改好配置再打包。4.2 数据库初始化数据库的初始化文件是fruit_shop.sql拿到后直接在 MySQL 里执行mysql -u root -p fruit_shop.sql执行完成后检查一下user表里是否插入了默认管理员账号admin用户的密码一般是经过 MD5 加密存储的前端管理端登录时走的就是同一个 JWT 接口只不过后端判断了role字段。顺便说一句初始化脚本里的账密建议上线后立刻改掉。很多演示项目因为默认密码是公开的放在公网上不到一天就会被扫描工具拖库改数据。我自己的习惯是部署完第一件事就是改掉默认 admin 密码。4.3 后端打包与运行后端打包用 Maven 的 package 命令打包前先执行测试会花时间。如果确认源码没有问题可以跳过测试直接打包mvn clean package -DskipTests打包产物在target/目录下是一个可执行的 jar 包。复制到服务器后通过如下命令后台运行nohup java -jar fruit-shop-server.jar logs/run.log 21 如果要改端口或数据库密码有两种方式改application.yml再重新打包或者启动命令后用--spring.config.additional-location指定外部配置文件。我更推荐后者——配置和代码分离换环境时不用重新打包。启动成功后可以先在服务器本地验证一下curl http://localhost:8080/api/product/list如果返回 JSON 数据说明后端服务正常。要是提示 404先检查context-path是不是/api再检查接口路径是否拼对。4.4 前端构建与 Nginx 部署前端构建前先在package.json的 scripts 里确认 build 命令npm install npm run build构建产物在dist/目录。然后把dist上传到 Nginx 的 web 根目录比如/data/www/fruit-shop/接着修改 Nginx 配置server { listen 80; server_name your-domain.com; root /data/www/fruit-shop; index index.html; 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; } location /images/ { alias /data/fruit-images/; } location / { try_files $uri $uri/ /index.html; } }这个配置里的三个 location 我解释一下/api/是后端接口反向代理把前端请求转发给后端 jar。/images/是商品图片的静态访问路径别名指向图片上传目录。/里的try_files是前端 history 路由模式必须的配置。如果不加用户直接访问/product/3这类 URL 时会 404因为 Nginx 找不到对应的物理文件。改完配置记得nginx -t检查语法然后nginx -s reload重载。前端项目里的baseURL是/api所以用户浏览器访问时请求的是同一个域名的/api路径Nginx 再把它转发到后端完美避开跨域。4.5 部署后的验收清单部署完不是能打开就算完我建议按下面的清单逐项过一遍访问域名首页能正常显示轮播图和商品列表。搜索关键字商品列表能刷新结果。注册一个新用户、登录、修改个人信息。添加商品到购物车修改数量、取消勾选、删除。提交订单在后台能看到这笔订单。点击模拟支付订单状态变为待发货。管理员在后台操作发货用户端订单状态同步。管理员新增一个商品分类和商品上传图片前台能看到。直接用/product/xxxx这类 URL 刷新页面不出现 404。这条链路走通整套系统基本就可以用了。如果某个环节卡住对照下一节的排查清单大部分都能对上号。5. 常见问题与排查技巧实录5.1 前端能打开页面但接口返回 404这个问题九成出在 Nginx 的反向代理配置上。先确认后端 jar 是否正常运行再确认 Nginx 里location /api/的 proxy_pass 是否写对。有个特别容易犯的细节proxy_pass http://127.0.0.1:8080;结尾不带斜杠和带斜杠的效果不一样。不带斜杠请求/api/product/list会原样转发后端因为context-path正好是/api能正确处理。如果你把它写成http://127.0.0.1:8080/那么转发到后端的路径就变成了/product/list后端自然会返回 404。这个问题我在给别人排错时遇到好几次。5.2 数据库连接失败和时区报错如果你是 MySQL 8.0驱动类名用com.mysql.cj.jdbc.DriverURL 里带serverTimezoneAsia/Shanghai。如果是 5.7旧的驱动com.mysql.jdbc.Driver也能用但建议统一用cj新版驱动。报错The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这类乱码时区提示直接改成serverTimezoneAsia/Shanghai或GMT%2B8就能解决。这个报错本质是 MySQL 8.0 默认时区和系统时区不一致导致的和代码无关。5.3 中文乱码问题中文乱码出现的位置主要是两个。一是前端页面上商品名称乱码二是后台管理的导入/导出数据乱码。页面展示乱码先检查数据库连接 URL 里是否带了characterEncodingutf8。注意utf8和utf8mb4的区别MySQL 的utf8实际是utf8mb3不支持下表情符号emoji 的 4 字节编码如果商品名称或备注里可能包含特殊字符建议连接参数和数据库字符集统一用utf8mb4。管理端导出乱码一般是 CSV 文件的编码问题。UTF-8 编码的 CSV 拿 Excel 打开时如果乱码可以在导出时给文件加 UTF-8 BOM 头这是最常用也最省事的解决办法。5.4 MyBatis 映射文件相关的坑MyBatis 的坑集中在几个地方。一是resultType和resultMap混淆。实体类字段和数据库列名能对上开启驼峰映射后对不上也能转就直接用resultType。但有联表查询、需要自定义字段映射时必须用resultMap否则查询结果里部分字段数据会是 null。二是mapper-locations配置不对启动直接报Invalid bound statement (not found)。注意 classpath 路径写法classpath:mapper/*.xml对应的目录是resources/mapper/XML 文件必须放在这个位置而不是和 Mapper 接口放一起。这个错位问题新手最常犯。三是 XML 里写符号报错。原因很简单——XML 里是特殊字符必须转义为lt;或者用![CDATA[ ]]包裹。我比较推荐动态 SQL 里用lt;常量 SQL 里用 CDATA。5.5 图片上传之后前端访问不到图片上传成功但前端的img标签显示不出来。这时候先做三步排查确认图片上传到了哪个目录用ls看看文件是否存在。直接访问图片 URL比如http://your-domain.com/images/20251120/xxxx.jpg看 Nginx 是否返回图片。如果 Nginx 返回 404检查location /images/的 alias 和实际磁盘路径是否匹配注意 alias 路径末尾的斜杠不能少。如果直接访问后端 Tomcat 的图片地址能通那就是 Nginx 的映射没有配置。我遇到过直接访问后端 8080 端口图片正常但前端通过 80 端口就 404 的情况最后发现是 Nginx conf 里alias /data/fruit-images少写了一个末尾斜杠。这种细节问题排查起来非常磨人所以我把路径末尾斜杠当成铁律来记。5.6 端口冲突和进程残留java -jar启动时报端口被占用大概率是之前有一次启动没彻底停掉或者用了 kill 之后进程还在。排查命令lsof -i:8080 # 或者 netstat -ano | grep 8080找到 PID 后kill -9强杀再重新启动。这里提醒一个细节nohup ... 启动的进程如果你直接关闭终端进程可能因为挂到 init 进程被保留而继续运行也可能直接挂掉。最好的方式是用setsid命令或者借助 systemd 服务管理这样进程生命周期才能稳定可控。最后说点个人心得这个水果商城系统我前后过手了不止一遍。第一次跑通花了整整一个周末一半时间在调跨域一半时间在找 MyBatis 的 XML 错误。现在回头看这些踩坑其实都有共性——它们都是环境问题和细节问题而不是架构问题。骨架搭好了剩下的全是细心活。如果你拿这套源码做二次开发我建议你从这几点入手扩展给订单表加一个优惠券字段并设计对应的核销逻辑给商品列表加一个 Redis 缓存减少数据库压力给后端接口加一个 Swagger 文档自动生成。这些扩展每一项都能让项目更有看点面试时也更有东西能讲。跑项目最快的路径永远是先把数据库导入、后端启起来、前端跑起来、页面能看到数据然后再逐行读代码。代码读一遍的效果远不如改一遍改一遍的效果远不如自己从零写一遍。希望这篇记录能帮你少走几公里弯路。
阅读完成 · 觉得有帮助?
咨询建站