手上有毕设或者课设需求的朋友,看到“SpringBoot+Vue+MyBatis+MySQL”这套组合,基本就知道这是个标准的全栈前后端分离项目。花点时间把它跑起来、读懂里面的设计逻辑,对你理解企业级开发流程的帮助,胜过你自己从零瞎折腾一个月。这篇博文就以这套校园商铺管理系统为例子,把项目从需求拆解到数据库设计、后端接口、前端页面、本地部署的完整链路捋一遍,重点说清楚“为什么这么设计”和“实际动手时最容易卡住的地方”。
这套系统的核心业务不复杂:商铺管理、商品管理、订单流转、用户登录注册、管理员后台审核,典型的小型电商SaaS模型。但正因为业务规模不大,非常适合用来建立前后端分离开发的完整认知框架。无论你是准备毕业设计答辩,还是想在工作前补一补全栈技能的在校学生,甚至是想快速搭建一个内部管理系统的初级开发者,拿它当参考模板都很合适。
1. 项目到底在做什么:把需求先理清楚
1.1 系统角色与核心业务流程
很多新手拿到这类系统,第一反应是看代码、跑程序,其实正确的做法是先梳理角色和流程。这套系统里有三类角色:
- 普通用户(学生/消费者):注册登录、浏览商铺和商品、下单购买、查看自己的订单
- 商铺管理员(店主):申请入驻、上架商品、处理订单、查看销售概况
- 系统管理员(平台方):审核商铺入驻申请、管理用户状态、查看全平台数据
这三个角色对应的权限边界和业务动作完全不同,所有表结构、接口设计、前端菜单都是围绕这三个角色展开的。比如一个普通用户,他不需要看到“商铺审核”这个功能;一个商铺管理员,也不应该能操作其他商铺的商品。说到底,权限设计才是这类管理系统的灵魂,没有权限隔离,代码写再多也是空中楼阁。
核心业务流程大概是两条线:
- 用户看到商铺 -> 选中商品 -> 生成订单 -> 商铺管理员确认订单 —— 这是交易主链路
- 店主提交入驻申请 -> 平台管理员审核 -> 审核通过 -> 商铺上线 —— 这是店铺生命周期管理
把这两条线画出来,后端接口的 Controller 层就基本有数了,每个环节对应一组接口。我习惯先用表格把业务模块和接口方向列出来,再动手写代码,这样不会漏功能。
1.2 功能模块拆分与工作量评估
从功能模块的角度切,这套系统可以拆成六个大块:
| 模块 | 核心功能 | 前端页面 | 后端关键点 |
|---|---|---|---|
| 用户认证 | 注册、登录、Token校验 | 登录页、注册页 | JWT生成与拦截 |
| 商铺管理 | 入驻申请、商铺列表、审核 | 商铺列表页、入驻表单页 | 多状态审核流转 |
| 商品管理 | 商品的增删改查、上下架 | 商品管理页、商品详情页 | 图片上传与状态过滤 |
| 订单管理 | 下单、取消、确认、发货 | 订单列表页、购物车页 | 事务处理与库存扣减 |
| 数据统计 | 销售概况、商铺排行 | 数据看板页 | SQL聚合查询 |
| 后台管理 | 用户管理、分类管理 | 用户管理页、分类页 | 基础CRUD |
这块全做完,工作量大概在20到25个后端接口、10个左右的前端页面。加上部署和测试,一个熟悉这套技术栈的人,两周的业余时间足够。但如果要加支付、秒杀、消息通知这些复杂功能,工作量就完全不是一回事了。所以做项目前先控制范围,把核心链路做扎实,比堆砌一堆没打通的功能要强得多。
2. 技术选型为什么这么搭:SpringBoot+Vue+MyBatis+MySQL的合理性
2.1 后端框架选择的底层逻辑
SpringBoot 在这套系统里的地位,用“脚手架”来形容最贴切。它内置了 Tomcat,简化了配置,把以前 SpringMVC 时代繁琐的 XML 配置全部自动化。你只需要在 pom.xml 里引入依赖、写一个启动类,一个能跑的 Web 服务就起来了,对课设和中小型项目来说效率极高。
但选 SpringBoot,更看重的是它背后庞大的生态。做权限可以做 Spring Security,做接口文档可以接 Swagger,做持久化有 Spring Data JPA 和 MyBatis 两条路选。这套项目选择了 MyBatis 而不是 JPA,原因很实际:MyBatis 的 SQL 是自己写的,可控性强,对复杂的多表查询、统计报表、动态条件筛选非常友好。很多公司的 Java 技术栈就是 MyBatis 系,学这个对就业更对口。而且 MyBatis 的 XML 或注解方式容易调试,一个 SQL 写错了,直接拿 SQL 去数据库工具里跑一遍就知道问题在哪。
2.2 前端 Vue 与数据交互方式
Vue 前端部分用的是 Vue 2 + Vue Router + Vuex + Element UI 这一套。这套组合在 GitHub 上的开源后台管理项目里出现频率极高,新手熟悉起来也快。
Element UI 提供了表单、表格、弹窗、分页这些现成组件,配合 Vue 的双向绑定机制,写管理后台页面就像拼积木。比如商铺审核页面,无非就是一个表格展示申请数据、一个“通过/驳回”按钮调接口,Element UI 的el-table加几行代码就能搞定。Vuex 则负责存储全局状态,比如用户登录信息、Token,这样页面刷新后还能从 localStorage 恢复登录态。
前端和后端的通信方式是 JSON over HTTP。前端通过 Axios 发起请求,后端返回统一格式的 JSON。这样前后端只需要约定好接口路径、请求参数、返回结构,就可以完全并行开发。实际开发中,我用 YApi 或 Apifox 管理接口文档,前端对着文档 mock 数据,后端对着文档自测,联调时再坐在一起对一遍,效率非常高。
2.3 数据库与 ORM 的搭配考量
MySQL 是这套系统的数据底座。为什么不用 PostgreSQL 或者 Oracle?因为 MySQL 足够轻量、部署简单、大学课程里教得也最多,遇到问题随便一搜就有大量解决方案。数据量不大的场景,MySQL 的性能完全够用,没必要选更重的数据库。
ORM 层用 MyBatis,不仅在 SQL 控制上灵活,还能配合 PageHelper 做分页,配合 MyBatis Generator 生成基础 CRUD 代码。但有一点要注意:生成器生成的代码只是简单的单表操作,真正的核心查询还是要手写 SQL。比如做商铺销售统计,需要GROUP BY按天聚合,用生成的代码是写不出来的,必须自己写 Mapper 方法。这也是 MyBatis 的价值所在——越复杂的查询,越能体现它的优势。
另外,数据库连接池我用的是 Druid,它是阿里的开源连接池,自带监控页面,可以实时看到 SQL 执行情况,调试慢查询和连接有没有释放非常方便。项目里配置好了不用白不用,启动后访问druid/index.html就能看到监控面板。
3. 数据库模型设计的实战细节
3.1 核心表结构设计与字段说明
数据库设计是这种系统最见功力的地方。表结构设计好了,后面的接口和页面都顺理成章。这套系统我规划了六张核心表:
sys_user用户表:包含 id、username、password、nickname、avatar、phone、role、status、create_timeshop商铺表:包含 id、user_id(店主外键)、shop_name、shop_logo、description、address、status、create_timecategory分类表:包含 id、name、parent_id、sortproduct商品表:包含 id、shop_id、category_id、product_name、price、stock、image、status、description、create_timeorders订单表:包含 id、order_no、user_id、shop_id、total_amount、status、create_timeorder_item订单明细表:包含 id、order_id、product_id、product_name、price、count
这里有几个字段设计上的细节,值得多说几句:
- 密码字段不要存明文,用 BCrypt 加密后存哈希串。这个项目里用
spring-security-crypto里的 BCryptPasswordEncoder 工具类,一行代码就能实现密码加密比对。 - 订单表里冗余了
product_name和price,这违反了第三范式,但这是故意的。订单生成后商品可能下架改名,历史订单必须保留下单时的快照信息,这叫“冗余换一致性”。 - 金额字段用
DECIMAL(10,2),不用float或double。浮点数在比较时存在精度丢失问题,0.1+0.2 不等于 0.3,这在涉及钱的场景是致命的。 - 所有表都带
create_time字段,方便后续做统计分析和数据排查。
3.2 数据字典与常用字段的坑
数据字典是开发时最容易忽视的东西。我建议在项目 README 或者设计文档里写清楚每个状态字段的枚举含义,比如:
- 商铺 status:0 待审核,1 审核通过,2 审核拒绝,3 禁用
- 订单 status:0 待确认,1 已确认,2 已完成,3 已取消
为什么要强制规范状态?因为前后端联调时,前端要根据不同的状态渲染不同的按钮和标签。如果状态值没有统一约定,前端显示“已确认”而后端存的是“1”,排查问题会非常痛苦。我在这个项目里用常量类集中管理这些状态值,后端代码里不出现裸的数字或字符串,前端也统一从常量文件里引用,减少魔法值出现的概率。
另外,建表时字符集要统一用utf8mb4,不是用utf8。utf8在 MySQL 里最多存 3 字节,像一些生僻字或者表情符号存不进去,会直接报错,而utf8mb4完全兼容并支持 4 字节字符。这个细节,很多人在遇到报错之前根本不会注意。
4. 后端编码:从登录鉴权到业务接口
4.1 统一返回结构与全局异常处理
先设计一个普适的 Result 类,这是所有接口的返回格式。前期规范好,后面省事很多。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }所有 Controller 里的接口都返回这个统一结构,前端 Axios 拦截器里固定取code判断请求是否成功。如果code不是 200,直接弹message给用户看,不用每个页面都写一遍错误处理。
全局异常处理也别漏。SpringBoot 里用@RestControllerAdvice加@ExceptionHandler,把业务异常、参数校验异常、系统异常分别处理。这样就算后端代码因空指针崩溃,前端收到的也是一段规范的 JSON,而不是一堆堆栈信息。用户看到“系统繁忙,请稍后重试”比直接看到一堆调试信息好得多。
4.2 JWT 登录与权限拦截
登录认证这块,我选了 JWT 而不是 Session。原因是前后端分离架构下,后端不维护状态,前端把 Token 存在请求头里,服务端无状态校验,对部署在多台机器的场景也友好。具体流程:
- 用户提交用户名密码,后端校验通过后生成 Token
- Token 里携带 userId 和 role,设置过期时间(一般 24 小时)
- 前端收到 Token 存到 localStorage,之后每次请求在请求头里带上
token字段 - 后端写一个拦截器,校验请求头里的 Token,解析出用户信息后放行
这个拦截器是权限控制的重点。我写了三个拦截器分别对应管理员、店主、用户接口,或者在一个拦截器里判断注解,控制不同角色的访问。关键代码如下:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录、注册接口 if (request.getRequestURI().contains("/login") || request.getRequestURI().contains("/register")) { return true; } String token = request.getHeader("token"); if (token == null || token.isEmpty()) { throw new BusinessException(401, "未登录或登录已过期"); } Claims claims = JwtUtil.parseToken(token); // 取出用户ID和角色,存入request,供Controller使用 request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } }需要注意的是,拦截器里取出来的 userId 要往后端 Service 层传递。Controller 层直接从 request 里拿,不要信任前端传的 userId,防止用户越权操作别人的数据。这种越权漏洞在课设和真实项目里都很常见,比如普通用户把请求里的 userId 改成别人的,就能看到别人的订单。后端在查询时一律以 Token 解析出的 userId 为准,这个习惯要养成。
4.3 MyBatis 动态 SQL 与复杂查询
这套系统里最有代表性的 Mapper 是商品列表的查询,因为需要按商铺、分类、关键字、状态做动态条件过滤。用 MyBatis 的<where>和<if>标签,可以优雅地拼 SQL:
<select id="selectProductList" resultType="com.example.entity.Product"> SELECT p.*, s.shop_name AS shopName FROM product p LEFT JOIN shop s ON p.shop_id = s.id <where> <if test="shopId != null"> AND p.shop_id = #{shopId} </if> <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (p.product_name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND p.status = #{status} </if> </where> ORDER BY p.create_time DESC </select>这个 SQL 用一个方法解决了前端所有筛选场景,前端传什么条件就拼什么条件,传空就返回全列表。配合 PageHelper 使用:
PageHelper.startPage(pageNum, pageSize); List<Product> productList = productMapper.selectProductList(query); PageInfo<Product> pageInfo = new PageInfo<>(productList);PageHelper 会自动在 SQL 后面追加 LIMIT,并且返回总数,前端拿到 total 就能渲染分页条。
另一个常见的需求是统计报表。比如商家后台想看每天的销售额,用 MyBatis 写一个聚合查询:
<select id="selectDailySales" resultType="map"> SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS statDate, COUNT(*) AS orderCount, SUM(total_amount) AS totalAmount FROM orders WHERE shop_id = #{shopId} AND create_time BETWEEN #{startDate} AND #{endDate} GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY statDate </select>返回的 List<Map> 前端直接可以接,配合 ECharts 画折线图,商家每天卖了多少钱、多少单一目了然。
还有一点,下订单这个操作一定要加@Transactional事务注解。因为在生成订单的同时要扣减库存,如果库存扣减失败而订单已经生成,就会出现超卖或者数据不一致。加上事务,要么都成功,要么都回滚。
5. 前端实现:Vue 项目从搭建到页面联调
5.1 工程结构与路由设计
前端代码组织得好不好,直接影响维护体验。我的目录结构是标准的 Vue 项目风格:
src/ ├── api/ // 接口请求模块 │ ├── index.js // axios实例封装 │ ├── shop.js // 商铺相关接口 │ ├── product.js // 商品相关接口 │ └── order.js // 订单相关接口 ├── assets/ // 静态资源 ├── components/ // 公共组件 ├── router/ // 路由配置 ├── store/ // Vuex状态管理 ├── views/ // 页面组件 │ ├── admin/ // 管理员页面 │ ├── shop/ // 商铺端页面 │ └── user/ // 用户端页面 └── utils/ // 工具函数路由设计上,用路由元信息控制页面访问权限:
{ path: '/shop/product-list', name: 'ProductList', component: () => import('@/views/shop/ProductList.vue'), meta: { roles: ['shop_admin'] } }路由守卫在跳转前判断用户的角色是否在 meta.roles 里,不在就直接跳登录页。配合后端的接口拦截,前端隐藏入口、后端校验权限,双重保障。
5.2 Axios 封装与 Token 管理
每个接口请求都手动写 token 头太傻了,我在api/index.js里统一封装 Axios:
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['token'] = token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { Message.error(res.message) if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { Message.error('网络异常,请稍后重试') return Promise.reject(error) } ) export default service这个封装的精髓在于:所有接口的错误信息在前端弹一次就完,页面里不用再单独处理错误分支;Token 过期自动跳登录页,用户不用手动刷新。
5.3 核心页面功能落地要点
拿商户入驻申请页举个例子。页面就是一个表单,用 Element UI 的el-form做。这里要注意上传商铺 Logo 的处理:el-upload组件在上传前需要设置 action 指向后端的图片上传接口,headers里带上 Token。后端接收MultipartFile后保存到服务器或对象存储,返回图片访问 URL,再把这个 URL 作为表单字段提交。很多新手在图片上传这块卡很久,原因是前端传的是文件对象,后端接收参数没对应好,或者请求头的 Token 没带导致 401。
商品列表页的分页搜索是另一个常见的联调点。我的做法是:页面里绑定的 searchForm 对象,点击搜索按钮时触发 fetchData 方法,把参数传给后端,拿到数据后重新渲染表格。分页组件用el-pagination,切换页码时同样调用 fetchData,currentPage 跟着变。
订单状态流转这块,前端按钮的逻辑要跟后端状态保持一致。比如订单状态为“待确认”时,用户端显示“取消订单”按钮;店主端显示“确认订单”按钮;订单状态为“已完成”,所有按钮都隐藏。这个渲染逻辑就是后端状态枚举的前端映射,用计算属性或者一个函数根据 status 返回按钮配置,代码清晰,可维护性好。
6. 本地部署与启动实操
6.1 环境准备与项目初始化
部署这套系统,需要的前置环境非常简单:JDK 1.8+、Maven 3.6+、MySQL 5.7+、Node.js 10+。
第一步,创建数据库并初始化表结构。项目里提供 sql 文件,在 MySQL 里执行:
CREATE DATABASE IF NOT EXISTS shop_system DEFAULT CHARACTER SET utf8mb4; USE shop_system; -- 按顺序执行表结构脚本和初始化数据脚本初始化数据脚本里我会预置一个管理员账号(admin/admin123),方便启动后直接登录后台体验。
第二步,后端配置数据库连接。在application.yml里修改:
spring: datasource: url: jdbc:mysql://localhost:3306/shop_system?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4 username: root password: 你的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver这里容易踩坑的是serverTimezone。MySQL 5.7 和 8.0 的默认时区和驱动行为不一样,如果不设置serverTimezone=Asia/Shanghai,经常会报时间相关的连接错误。
第三步,前端安装依赖:
npm install npm run serve后端启动后访问localhost:8080,前端开发服务器访问localhost:8081。前端项目里的.env.development文件要把接口地址代理到后端:
VUE_APP_BASE_URL = '/api'然后在 vue.config.js 里配置 devServer 代理:
devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样前端页面里的请求/api/xxx会被代理到后端8080端口,完美避开跨域问题。整个配置跑通,项目本地就起来了。
6.2 前后端联调与打包部署经验
联调阶段我的经验是,先测登录接口,Token 拿到手,再测需要鉴权的接口,最后测业务流程。如果登录后某个接口报 401,多半是请求头里的 Token 字段名字前后端没对齐。前端传token,后端拦截器取Authorization,两边对不上就 401。
生产环境的打包分两步。后端用的 Maven 打包:
mvn clean package -DskipTests然后:
java -jar target/shop-system-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod前端打包:
npm run build生成的 dist 目录里的静态文件,可以用 Nginx 托管:
server { listen 80; server_name your-domain.com; location / { root /var/www/shop/dist; index index.html; try_files $uri $uri/ /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; } }try_files那一行很重要,因为 Vue Router 默认是 history 模式,不配置的话刷新非首页路径会 404。如果你不想折腾,也可以用 hash 模式,但 URL 里会多个#,观感不太好。
7. 高频踩坑记录与排查手册
7.1 联调阶段最容易翻车的五件事
| 问题 | 表现 | 排查思路 |
|---|---|---|
| 后端接口 404 | 前端请求 /api/shop/list 报404 | 确认后端 Controller 的 RequestMapping 是否包含 /api 前缀 |
| 跨域报错 | 浏览器提示 CORS 错误 | 确认是否走代理配置,或者后端是否开启跨域配置 |
| 登录后请求 401 | 接口返回未登录 | 检查请求头 Token 字段名是否前后端一致 |
| 中文乱码 | 数据库存储乱码 | 检查数据库连接 URL 的 characterEncoding 参数,表的字符集是否是 utf8mb4 |
| 图片无法访问 | 上传图片后页面 img 裂开 | 确认上传路径和后端静态资源映射配置是否正确 |
这里单独说一下后端静态资源映射。用户上传的图片一般放在服务器的upload目录,但 SpringBoot 默认只处理 classpath 下的静态资源,直接访问文件路径会 404。需要写一个配置类:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + System.getProperty("user.dir") + "/upload/"); } }这样前端图片地址/upload/xxx.jpg才能正确映射到磁盘上的文件。
7.2 运行时出现的奇怪问题
除了联调,运行阶段也会有一些莫名其妙的问题。内存溢出的情况,多半是 Tomcat 连接数满了,SpringBoot 默认的线程池配小了,看日志会出现Connection is not available, request timed out。可以在配置里加:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5数据库连接池满了,应用就会假死。还有一次我遇到过订单超时问题了,看日志发现是orders表的事务隔离级别问题,在高并发测试时出现了脏读。后来在方法上加@Transactional(isolation = Isolation.READ_COMMITTED)解决。
最后说下 MyBatis 的一个隐藏坑:当实体类字段和表字段的驼峰映射没配置好的时候,查询结果全是 null。解决方案是在 application.yml 里开启驼峰映射:
mybatis: configuration: map-underscore-to-camel-case: true这样shop_name就能自动映射到shopName,不然就得在每个 ResultMap 里手动写映射关系。
这类的项目,最怕的不是功能写不出来,而是基础配置不对导致系统起不来。按这篇博文的思路走,先跑通,再深挖,最后改造,每一步都有章可循。
我个人强烈建议,跑通项目后,把后端拦截器部分的代码翻来覆去读三遍。权限设计是这类管理系统的灵魂,也是面试时最容易被问到的点。再往后,你可以试着给系统增加一个商户月账单功能,把订单数据按照月份做聚合汇总导出 Excel,这样既锻炼了复杂 SQL,又学会了数据导出的实现思路。项目的价值不在于大而全,而在于把每个基础点做到位,形成可复用的经验,这对你之后接触任何业务系统都有直接帮助。