前后端分离做的小区购物系统,后台管理加上用户端小程序风格页面,SpringBoot、Vue、MyBatis、MySQL这套技术栈,是我个人目前用过最顺手、也最推荐新手去做毕业设计或者练手的组合。这篇文章我把它完完整整拆开,从设计思路、表结构、核心代码、部署上线到常见坑位,全部讲透。
1. 项目整体设计与思路拆解
1.1 这个项目解决了什么问题
做这个系统的时候,客观需求非常明确:小区居民需要线上买菜、买日用品,而小区管理员需要批量统计订单、集中配送。传统的线下登记、微信群接龙方式,人一多就乱,统计困难,配送单也容易漏掉。
我接手开发时先定了核心业务闭环:
居民端:注册登录 -> 浏览商品 -> 加入购物车 -> 提交订单 -> 在线支付(模拟) -> 查看配送状态 管理端:分类管理 -> 商品上下架 -> 订单处理 -> 配送统计 -> 用户管理
这两个端口的信息流是同一套数据模型,只是视角不同。这就是典型的前后端分离里“一套接口,两个客户端”的思想——后端只做业务逻辑和数据存取,前端只做展示与交互。
1.2 为什么选了这套技术栈
技术选型是动手之前最先要确定的事,我后来越做越觉得这套组合选得挺稳。
SpringBoot作为后端核心框架,最大的价值是“约定优于配置”:不用像早年SSH那样写一堆XML配置文件,内嵌Tomcat一次启动就完事。配合Maven能够一条命令打包运行。
Vue作为前端框架,我选的是Vue 2 + Element UI + Axios这套经典组合。它说不上最新,但胜在稳定,相关资料多,遇到问题几乎都能搜到答案。对没有那个折腾新版本的心气儿的人来说,Vue 2.6这个版本时期生态最成熟,UI组件库的封装度也最高。
MyBatis作为持久层框架,半自动化的SQL控制方式是它最大的特点。相比JPA/Hibernate的全自动化,MyBatis允许手写SQL,碰到多表关联、统计报表这类复杂查询时,心智负担明显更小。
MySQL不用说,开源、主流、轻量,和SpringBoot搭配起来几乎零门槛。
1.3 整体架构:从浏览器到数据库
访问链路其实不复杂,但新手容易卡在“数据到底怎么从前端到后端”上。我把链路拆开:
浏览器(Vue页面) -> Axios发送HTTP请求 -> SpringBoot Controller -> Service(业务逻辑) -> Mapper接口 -> MyBatis(SQL) -> MySQL数据库 响应数据按原路返回 -> V-bind渲染到页面没有经过复杂网关,也没有额外的消息队列,就是一个标准的单体后端加纯前后端分离。开发时双端独立跑在局域网不同端口,调试方便,后期部署也灵活。
1.4 这系统对新手友好在哪里
我接触过不少学员在做类似项目时问:为什么要做前后端分离?在本地联调时不就得跨域配置吗?确实。但好处是工作职责清晰:前端只管界面,后端只管接口,二者通过JSON格式交互,前端不必关心SQL怎么写,后端不必关心弹窗怎么实现。
另一个关键好处是可复用性。这套后端接口以后如果要做小程序端或者移动App,直接复用,不必重写。Vue那一层的页面组件也能抽出来做拼装,扩展成本低。
2. 数据库设计:核心表结构与应用场景
2.1 用ER图思维拆解数据模型
我一开始没有急着写SQL,而是先用文字理清实体之间的关系。整个系统涉及的核心实体有六类:用户、商品分类、商品、购物车、订单、订单明细。
他们之间的关系是:
- 用户和购物车:一对一(逻辑上)
- 用户和订单:一对多
- 订单和订单明细:一对多(一个订单对应多个商品)
- 商品分类和商品:一对多
这里面最容易被忽略的是“订单明细表”。很多新手只建订单表,把商品详情直接存成字符串,想省事,结果后台统计SKU时就知道有多难用了。订单和商品是多对多关系,必须单独用一张订单明细表来解耦。
2.2 核心建表语句与字段设计要点
表数量不用太多,关键在于字段设计合理。我以订单表和购物车表为例,直接给出可用SQL。
CREATE TABLE `orders` ( `order_id` int NOT NULL AUTO_INCREMENT COMMENT '订单id', `order_sn` varchar(64) DEFAULT NULL COMMENT '订单编号', `user_id` int DEFAULT NULL COMMENT '用户id', `total_amount` decimal(10,2) DEFAULT NULL COMMENT '订单总金额', `pay_amount` decimal(10,2) DEFAULT NULL COMMENT '应付金额', `pay_type` tinyint DEFAULT NULL COMMENT '支付方式:1微信2支付宝3模拟', `status` tinyint DEFAULT '0' COMMENT '订单状态:0待支付1已支付2已发货3已取消', `receiver_name` varchar(32) DEFAULT NULL COMMENT '收货人', `receiver_phone` varchar(20) DEFAULT NULL COMMENT '收货人电话', `receiver_address` varchar(255) DEFAULT NULL COMMENT '收货地址', `remark` varchar(255) DEFAULT NULL COMMENT '订单备注', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `payment_time` datetime DEFAULT NULL COMMENT '支付时间', `delivery_time` datetime DEFAULT NULL COMMENT '发货时间', PRIMARY KEY (`order_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;字段类型上有个细节:金额字段一律用decimal(10,2),不要用float或double。浮点数在计算金额时会因为二进制存储产生误差,这在支付场景下是不可接受的。
订单编号我用了“时间戳+随机数”方式生成,比如2024120110304528371,在一定程度上避免重复。实际中要更严谨可以加用户ID前缀。
购物车表设计时加了唯一索引,用户和商品不能重复记录,解决了重复加购问题:
CREATE TABLE `cart` ( `cart_id` int NOT NULL AUTO_INCREMENT, `user_id` int DEFAULT NULL, `product_id` int DEFAULT NULL COMMENT '商品id', `quantity` int DEFAULT '1' COMMENT '数量', `checked` tinyint DEFAULT '1' COMMENT '是否选中', PRIMARY KEY (`cart_id`), UNIQUE KEY `uk_user_product` (`user_id`,`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;2.3 表关系设计的三个心得
第一,冗余设计要克制。比如订单表里出现了receiver_name、receiver_phone,这实际上是从用户表冗余过来的快照数据。订单生成后用户改了手机号,不应该影响历史订单,所以快照是必要的。但除了这类必要的快照,别到处加冗余字段,否则改数据会改到崩溃。
第二,状态字段用int不用varchar。订单状态用0待支付、1已支付这样,在代码里写枚举对比很容易,数据库查询也快,前端再根据数字映射成中文标签。用字符串存状态不仅占空间,而且拼写不一致就会出现脏数据。
第三,添加时间字段是好习惯。每个表都有create_time,有的表还要update_time。做管理列表排序、统计订单量、用户注册趋势都靠它,漏了后面再补就麻烦了。
3. 后端核心模块实现与接口详解
3.1 后端整体目录结构
我习惯按照业务模块分包,而不是按层分包。也就是说,不建controller包里面放100个类,而是建user包、product包、order包,各包下面放controller、service、mapper。项目大了以后这样找文件非常快。
com.example.shop ├── common(通用返回结果、异常处理) ├── config(配置类比如跨域配置) ├── controller(接口层) ├── entity(实体类) ├── mapper(MyBatis接口) ├── service(业务逻辑) └── utils(工具类)3.2 统一返回结果:前后端协作的基石
这个模块看起来不起眼,但实际价值巨大。如果每个接口返回的数据结构都不一样,前端需要针对每个接口单独解析,代码写起来痛苦不堪。我定义了一个统一返回体:
@Data public class Result<T> { private Integer code; // 200成功 500失败 401未登录 private String message; // 提示信息 private T data; // 数据 }所有Controller返回值统一用Result包装,前端axios拦截器里统一判断code,如果不是200就弹出message,不用每个页面单独处理错误逻辑。这样即使用户账号被顶下线,后端只要返回code=401,前端就能自动跳登录页。
3.3 商品分类的树形结构返回
前端菜单树和分类页都需要返回树形JSON,后端一般是先查出全部分类,再手动组装父子结构,不推荐在数据库里用递归查。实现思路如下:
// 将扁平List转为树形结构 public List<CategoryVO> buildTree(List<Category> allCategories) { Map<Integer, CategoryVO> map = new HashMap<>(); for (Category c : allCategories) { CategoryVO vo = new CategoryVO(); BeanUtils.copyProperties(c, vo); map.put(c.getId(), vo); } List<CategoryVO> roots = new ArrayList<>(); for (Category c : allCategories) { CategoryVO vo = map.get(c.getId()); if (c.getParentId() == 0) { roots.add(vo); } else { CategoryVO parent = map.get(c.getParentId()); if (parent != null) { parent.getChildren().add(vo); } } } return roots; }3.4 使用模拟支付,不涉及第三方平台对接
系统里做了一个模拟支付接口,前端点了“立即支付”按钮后,把订单号传到后端,后端直接更新订单状态为已支付,并写入当前时间。这样做的好处很明显:不需要注册任何支付商户号,不需要回调,不需要证书配置,在本地跑起来就能完整演示下单支付流程。
实际生产环境中,这里应当对接第三方支付服务商,引入支付回调、签名验证等流程,但模拟支付已足以展示订单核心业务流程。
@PostMapping("/pay") public Result<String> pay(@RequestBody PayDTO dto) { Orders order = ordersMapper.selectByOrderSn(dto.getOrderSn()); if (order == null) { return Result.error("订单不存在"); } if (order.getStatus() == 1) { return Result.error("该订单已支付,请勿重复操作"); } order.setStatus(1); order.setPaymentTime(new Date()); ordersMapper.updateById(order); return Result.success("支付成功"); }3.5 减库存操作的并发与事务问题
下订单减库存时,有两个坑:一是超卖,二是不一致。我的做法是直接在SQL里做条件更新,而不是先查库存再计算新库存:
@Update("UPDATE product SET stock = stock - #{quantity} WHERE product_id = #{productId} AND stock >= #{quantity}") int deductStock(@Param("productId") Integer productId, @Param("quantity") Integer quantity);这段SQL利用数据库的原子性保证并发时不会超卖。如果返回值是0,说明库存不足,直接提示用户。事务方面,在Service层加上@Transactional注解,保证扣减库存和创建订单要么全成功要么全回滚。
3.6 双端登录如何理解
“用户端”和“管理后台”我用的是一张用户表,通过role字段区分:0是普通用户,1是管理员。登录时同时校验用户名、密码和角色。好处是权限体系简单清晰,前端路由也根据角色做不同展示。这种方式适合体量不大的单体应用,如果后续要做精细化权限,再引入Spring Security加RBAC模型也不迟。
登录接口返回一个简单的token(UUID形式),存放到Redis里可以、存放到内存里也可以。初学者为了不引入额外依赖,可以选择是先不接入Redis,直接在内存里维护一个token对应用户的Map。生产环境需要换成JWT或Redis方案。
4. 前端Vue实现细节:从页面配置到接口对接
4.1 Vue项目结构设计
前端我按功能模块划分view目录,后台管理端和用户端是两个大的目录,这样可以保证代码组织清晰。
src ├── api(接口请求封装) │ ├── product.js │ ├── cart.js │ └── order.js ├── router(路由配置) ├── views │ ├── admin(后台管理页面) │ └── mall(用户端页面) ├── store(Vuex状态管理) └── utils ├── request.js(axios封装) └── auth.js(token存取)4.2 axios封装里的统一鉴权
用户端和管理端都会带着token请求接口,我把这一部分抽出来统一处理,避免每个页面都写请求头。核心做法是axios请求拦截器自动从本地存储拿token,加到请求头:
service.interceptors.request.use(config => { const token = getToken(); if (token) { config.headers['Authorization'] = token; } return config; });响应拦截器里做通用错误处理:后端返回401就跳登录页,返回500就Message提示“服务器开小差了”。这样页面上只需要关心成功的业务数据,不需要关心错误弹窗的统一逻辑。
4.3 商品分类菜单的动态渲染
前端导航栏的分类菜单是通过接口返回的树形结构渲染的。Element UI的el-menu支持通过v-for嵌套渲染多级菜单。这里有个容易踩的坑——如果分类只有两级,第一级是“蔬菜水果”,第二级是“叶菜类”“根茎类”,那么要注意用el-submenu包二层,不能直接把第三层塞到第一层下面。
4.4 购物车和订单状态管理
购物车页面我用了全选、单选联动计算总价。这个业务看着不复杂,但状态管理要做好。我选择直接在组件里用computed方式计算选中商品总价,没有引入Vuex。对购物车这样局部状态,不必全局管理,代码更清晰。
当用户提交订单后,购物车里选中的商品要清掉。顺序要控制好:先创建订单,再清购物车;如果先清购物车,创建订单失败时购物车数据就丢了。
4.5 使用Vue Router做路由守卫
用户没登录就访问购物车页面,应自动跳转到登录页。路由守卫代码也很简单:
router.beforeEach((to, from, next) => { const token = getToken(); if (to.meta.requireAuth && !token) { next('/login'); } else { next(); } });这个全局守卫是前端控制页面跳转最核心的一环。管理端页面额外加上meta.role判断,如果当前用户的role不是1,就禁止进入管理后台。
4.6 部署时解决跨域问题
本地开发用Vue的代理转发请求,后端不用开启跨域配置。部署时后端的Controller上加跨域注解,或者配一个全局的CorsFilter。我这里采用了后者,因为全局配置一次,以后新建接口不用惦记跨域问题。
实际开发中,如果环境允许,前后端接口使用Nginx统一代到同域下,前端请求写相对的/api路径,就不存在跨域问题。这也是我最终部署采用的方案。
5. 完整部署流程:从开发机到可访问系统
5.1 环境准备清单
我建议先把下面这些环境在电脑上准备好,版本要求放出来给初学者对照:
- JDK 1.8+(推荐使用JDK8,稳定)
- Maven 3.6以上
- Node.js 14以上(推荐16)
- MySQL 5.7或8.x
- Navicat等数据库可视化工具
5.2 后端启动步骤
先把项目里的SQL文件导入数据库。打开Navicat,新建数据库(字符集utf8mb4),右键运行SQL文件。
然后修改后端配置文件里的数据库连接、账号密码。
server.port=8088 spring.datasource.url=jdbc:mysql://localhost:3306/shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=yourpassword用IDEA打开后端项目,等待Maven依赖下载完成,运行Application类的主方法。看到类似“Tomcat started on port(s): 8088”的日志就说明后端启动成功。之后可以用接口文档工具先测试一下登录和商品接口。
5.3 前端启动步骤
命令行进入前端项目目录,先执行依赖安装,再启动开发服务器:
npm install npm run serve启动后浏览器访问http://localhost:8080。打开页面,如果接口不通,优先检查前端配置里代理服务器地址是否指向了http://localhost:8088。
5.4 使用宝塔面板部署到服务器
本地跑通之后要部署到服务器,最省心的是用宝塔面板,操作门槛低。流程是:服务器安装宝塔,装好NGINX和MySQL环境,上传后端jar包并启动;前端打包成静态文件,放到NGINX的www目录下,配置好反向代理。
# 前端打包 npm run build打包完成后,dist目录里的文件就是最终要上线的静态资源,把它整个传到服务器对应目录即可。
NGINX配置里核心是这一段,把所有/api的请求都转给后端:
location /api/ { proxy_pass http://127.0.0.1:8088; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }5.5 常见部署问题速查
以下是我帮别人排查时最常遇到的问题,整理出来方便自查:
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 前端页面显示接口404 | 后端接口未启动或代理配置错误 | 检查后端端口是否启动,检查代理路径 |
| 数据库连接报错 | 密码错误或时区配置问题 | 修改配置文件中的密码和时区 |
| 前端登录失败,提示跨域 | 后端未开启CORS或代理没生效 | 后端加全局跨域配置,开发期用代理 |
| 端口被占用 | 本地多个项目同时启动 | 修改配置文件端口或释放被占端口 |
| 页面可打开但无法登录 | 数据库表里没有初始化管理员账号 | 执行初始化SQL脚本或手动插入管理员数据 |
6. 从0到1实操记录:我在开发中踩过的坑
6.1 前端页面交互与后端字段不一致
最初开发时商品列表里面包含一个“库存状态”字段,前端判断库存大于0显示“有货”,否则显示“缺货”。但我一开始没有统一字段类型,后端返回stock=0时前端判断!data.stock为真,而返回stock=10的时候它也为真,因为数字10被隐式转换为了true。发现问题之后我改成了显式判断data.stock > 0,不再依赖JS的隐式转换。
6.2 MyBatis多表查询时resultMap映射错乱
订单列表需要关联用户名,我用JOIN查询。MyBatis的自动映射在实体类属性名和数据库字段名对不上时,会返回null。这个错误特别隐蔽,因为控制台不报错,只是页面上的用户名是空的。
解决方法是配置驼峰映射,让数据库下划线字段自动映射到Java驼峰属性:
mybatis: configuration: map-underscore-to-camel-case: true6.3 前端npm依赖安装慢,卡住不动
第一次跑项目时npm install等了十几分钟还没完成,后来配置了国内镜像源,速度提升非常明显。配置方法很简单:
npm config set registry https://registry.npmmirror.com6.4 服务器上数据库数据导入编码问题
本地开发环境中文数据正常,但打包部署到服务器后,页面显示中文乱码。原因是数据库表默认字符集不是utf8mb4。重新建库时在SQL中显式指定字符集,这个问题可以彻底避免。
6.5 开发中如何保证代码修改后快速生效
前后端分离开发不必每次都打完整包。后端修改代码后IDEA热部署插件自动重启;前端保存后热更新,页面直接刷新。我建议开发期把后端端口和前端代理都配好,不要到最后统一联调,越早联调越早发现问题。
7. 接口调试与联调技巧
7.1 用好Postman进行后端接口测试
写Controller时,建议每完成一个接口用Postman测一下,不要等前端写完了再测。配合统一返回体,在Postman里可以一眼看到接口返回什么结构。
我习惯在测试时使用环境变量管理服务器地址,比如{{baseUrl}},这样开发环境、测试环境切换时,只需要改环境变量即可。
7.2 抓包分析前端请求
当页面功能异常时,F12浏览器开发者工具控制台的Network标签页能直接看到请求状态码、请求时长、请求与响应体。很多前后端联调问题通过这一步就能定位:如果请求发出去了、后端也返回200,那问题大概率在前端渲染逻辑;如果连请求都没发出去,就要检查前端代码里的调用逻辑。
7.3 联调阶段的沟通规范
接口对接时,前端和后端要约定好字段命名和错误码。例如:code=500时,前端统一弹出后端message;code=401时自动退出登录。省得前端为了适配不同接口写一堆if判断。
8. 写在最后:我对这个项目的一点体会
技术栈本身并不新鲜,但这套组合胜在稳定、生态成熟、入门曲线平缓。做这类系统,前期把表结构和接口规划好,后面写代码的效率会翻倍;相反,如果一开始急急忙忙写代码,中途改字段、改接口、改页面,时间成本远超预期。
个人经验上,我更建议拿到源码后不要直接跑起来就完事,而是先断网逐行读一遍后端核心接口逻辑,再尝试自己加一个功能模块(比如收藏夹、积分商城),这个实践过程能大幅提升对框架的理解。因为源码里都自带前后端完整结构,你只需跟着已有模式模仿,就能轻松扩展新功能。
我在这类项目上做的最后一个扩展是加了一个简单的数据统计仪表盘页面:当日订单数、交易金额、热销商品Top5。利用GROUP BY和SUM函数对订单表做聚合,前端用图表组件可视化。这一步做下来,整套系统的完成度立刻高了一个档次,也让我对这个项目的技术复述和面试表达能力提升了不少。