前阵子帮学生改了一套前后端分离的雪具销售系统,技术栈就是SpringBoot+Vue+MyBatis+MySQL。改完代码又顺手把部署流程从头跑了一遍,结果发现很多拿着源码就能写CRUD的同学,卡在部署和联调上:本地启动连不上MySQL、前后端接口对不齐、Nginx配完刷新就404。这套系统如果只看代码不看全链路,价值少了一大半。本文就按我实际走通的路子,把设计思路、实现要点、数据层细节和完整部署过程一次讲清楚,适合正在做毕业设计、想找前后端分离实战项目练手、或者准备SpringBoot方向面试的读者参考。
1. 雪具商城系统的核心业务设计:不是简单地堆增删改查
1.1 功能模块拆分:用户端与管理端到底该做哪些事
很多同学拿到这类系统题目就开始写表,先把商品表、用户表建出来,然后对着页面一个个堆接口。这么做的结果往往是功能看着都有,但聊起来一盘散沙。我一般先按角色拆业务域。
这套雪具销售系统我按两端四个模块来划分:
- 用户端主要有商品浏览、商品搜索、详情查看、购物车、下单、订单查询和个人中心;
- 管理端主要有分类管理、商品管理、轮播图管理、订单处理、用户管理和销售统计。
商品浏览不是简单查一张表就完事,还要承接分类筛选、按价格或销量排序、分页这几件事。购物车看起来是临时数据,但它牵扯到库存校验、价格变化、登录状态,是前后端交互最密集的地方。订单模块更是这样,它不是一个表就能说明白的,订单主表、订单明细、商品快照、库存扣减、状态流转,每个环节都会暴露出不同的技术点。
管理端并没有多高深,但它是检验后端设计是否合理的重要考场。商品管理要处理图片上传、字段校验、上下架状态;订单处理要操作状态流转(待付款、已付款、已发货、已完成、已取消);销售统计则需要聚合查询,如果一开始没预留好字段,后面写统计SQL会很痛苦。
1.2 数据库表设计:从商品SKU到订单明细
数据库是这个系统的地基。我设计表的时候有个习惯:先把订单相关的表定下来,因为订单表能反过来约束商品表、用户表的设计。
核心表拆开大概是这几张:用户表(user)、分类表(category)、商品表(product)、购物车表(cart)、订单主表(orders)、订单明细表(order_item)。另外可以加一张轮播图表,用来支撑管理端首页配置。
商品表是重点。雪具商品和普通服饰不一样,同样的商品会有不同长度、不同固定器尺码、左右脚区分,所以商品表要保留规格信息字段,我用spec字段存JSON串,例如雪板长度、硬度、适用水平;而价格和库存这种参与计算的核心字段单独拎出来放price和stock。这样设计的好处是,规格变化不会改动表结构,SKU级别的复杂拆分会留给后续系统演进,前期不会过度设计。
订单明细为什么要做商品快照?因为订单一旦生成,商品名称、图片、单价这些信息不应该再去读商品表。如果商品改价了或者下架了,历史订单里显示的仍然应该是下单那一刻的信息。这个细节很多课程项目不做,但只要你做过一版真实上线的交易系统,就会明白它有多重要。别问我怎么知道的。
建表时还可以顺手定几个约定:所有表主键用bigint自增,时间字段统一datetime,状态字段用tinyint并写清楚注释。用注释写清楚状态含义,后面写代码会省很多事。
1.3 为什么这套技术组合是稳妥的长线选择
SpringBoot + Vue + MyBatis + MySQL 这套组合,看起来没有新鲜感,但它确实是目前中小型系统里最稳妥、资料最多、面试最好聊的组合。
先说SpringBoot,它把Spring的配置复杂度降下来了,内嵌Tomcat让项目能直接打jar包跑起来,这对前后端分离项目的部署特别重要。Vue负责页面渲染和交互,数据通过axios异步请求拿到,DOM操作基本不用手碰。MyBatis的价值在于SQL可控,尤其像销售统计这种需要写复杂SQL的场景,在XML里调优比在ORM里绕来绕去痛快多了。MySQL则完全够用,一套电商demo跑几百并发绰绰有余。
前后端分离、SpringBoot、Vue、MyBatis、MySQL这组关键词,也是目前招聘市场上出现频率最高的一套。用这套东西做完一个完整项目,简历上有得写,面试官有得聊,后续想扩展也容易找到参考。
2. 后端SpringBoot落地:分层架构与鉴权方案怎么定
2.1 工程目录与统一返回结构
后端工程如果不规划目录,写到最后一定是大泥球。我按标准四层结构走:controller接收参数并做简单校验,service写业务逻辑,mapper负责SQL交互,entity做数据映射。另外单独放一个common包管理统一返回结构、全局异常和通用工具类。
统一返回结构我建议用一个Result<T>泛型类,包含 code、message、data 三个字段。code为0表示成功,非0表示业务失败。千万别把HTTP状态码和业务状态码混着用,我见过有人返回200但是业务失败,也有人直接返回500给前端当成网络错误,后期排查非常痛苦。
代码大致长这样:
@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.code = 0; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.code = 500; result.message = message; return result; } }这样前端拿到响应后,只需要判断code === 0即可走正常逻辑,异常统一在@RestControllerAdvice里拦截,然后转成相同的结构抛出去。全局异常处理这块一定要加,否则数据库报错或参数校验失败时,前端拿到的是一堆Tomcat默认的错误JSON,根本无法友好展示。
2.2 JWT登录鉴权:拦截器、白名单与用户上下文
这个系统我用的JWT做无状态鉴权。客户端登录成功后,后端签发一个token,前端后续每次请求放在请求头Authorization里,后端拦截器验签通过后放行。
但这里面有几个容易出问题的地方。第一是拦截器注册顺序和拦截路径。我习惯把登录、注册、商品列表、商品详情这些接口放白名单,其余需要登录后才能访问。管理端接口额外校验角色,余额检查或者越权校验可以在service层判断。
拦截器里拿到token之后,把用户id存进ThreadLocal里,这样后续业务代码随时可以取到当前登录用户,不用每个Controller都从参数里再传一遍。这里有个大坑:一定要在请求结束或异常时调用ThreadLocal.remove(),否则Tomcat线程池复用会串用户数据。我自己第一次写的时候就踩过这个,线上用户A能看到用户B的购物车,排查半天。
给一个简化版的拦截器核心逻辑:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.hasText(token) && JwtUtil.verify(token)) { Integer userId = JwtUtil.getUserId(token); UserContext.set(userId); return true; } throw new BusinessException("未登录或登录已过期"); } @Override public void afterCompletion(...) { UserContext.clear(); } }密码存储一定用BCrypt加密,不要自己写MD5加盐,更不要明文存。登录注册这种接口容易被扫描攻击,我在全局过滤器里顺手做了XSS和SQL关键字的简易过滤,也能挡掉一部分最基础的注入尝试。别指望过滤器能挡住所有攻击,但聊胜于无,项目展示时这也是一个说得出口的细节。
2.3 MyBatis与MyBatis-Plus怎么配合用才顺手
很多教程会把MyBatis和MyBatis-Plus对立起来讲,其实它们是互补关系。简单单表CRUD用MyBatis-Plus的BaseMapper能省下大量样板代码,比如用户表、轮播图表基本不需要写XML;但是复杂查询、多表关联、统计报表,还是自己写SQL更清晰。
我的习惯是:实体类和Mapper接口用MyBatis-Plus的注解或代码生成器搞定,复杂查询写在Mapper XML里。比如后台的销售统计,按日分组求成交金额,这种SQL用MyBatis-Plus的Wrapper反而不太好写,XML里两句SQL就出来了。
还有一个容易被忽略的配置:数据库表字段是下划线风格(create_time),Java属性是驼峰风格(createTime),必须在application.yml里开启驼峰映射:
mybatis: configuration: map-underscore-to-camel-case: true不开启的话,你会发现在实体类里给每个字段写一遍@TableField和@TableName注解也不是不行,就是特别累。再就是XML文件一定要放在能被扫描到的位置,IDEA里默认不会把src/main/java下的XML打包进target,很多人部署到服务器才发现Invalid bound statement,这个问题我在后面部署章节会再提一次。
2.4 订单流程与库存扣减的并发细节
订单模块是最容易杀人的地方。流程本身不复杂:校验商品、计算金额、扣库存、生成订单、返回订单号。但扣库存和生成订单之间如果并发没控制好,超卖是必然的。
我采用的方案是乐观扣减。在product表上直接执行一行SQL:
UPDATE product SET stock = stock - #{num} WHERE id = #{id} AND stock >= #{num}返回受影响行数为1说明扣减成功,为0说明库存不足,这样能规避大部分并发问题。代码逻辑上,先扣库存,再插入订单记录,两条操作放到同一个事务里,其中任何一条失败都整体回滚。这里需要强调的是,不要在Java代码里先select stock再到内存里判断,因为两个请求同时读到旧库存,判断就会失真。
订单号我用的规则是:时间戳 + 用户id后四位 + 随机数。别只用自增id当订单号暴露给用户,让别人通过订单号就能推断出你们的业务量,不合适。
3. 前端Vue落地:接口封装、路由守卫与页面组件
3.1 从脚手架搭建到目录规划
前端这块我用的Vue 3。创建项目直接用官方脚手架:
npm create vue@latest选择TS还是JS看个人习惯,如果刚开始用Vue不久,我建议先用JS把业务跑通,不要一边学组合式API一边和类型系统搏斗。项目结构上我习惯按功能分包:views放页面组件,components放通用组件,router放路由配置,store放状态管理,api放接口请求方法,utils放工具函数。
目录规划真正解决的是团队协作问题。前后端分离项目里,前端页面往往是按路由拆的,一个人负责商品模块,一个人负责订单模块,如果各自在页面里到处写axios,后面合并代码会恨不得重写一遍。
3.2 axios二次封装:请求拦截与401处理
axios不能每个页面裸着用,一定要做二次封装。核心就两个点:请求拦截器里加上token,响应拦截器里统一处理业务码和登录过期。
const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) service.interceptors.response.use( (response) => { const res = response.data if (res.code !== 0) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, (error) => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )这里有个小细节:响应拦截器里判断res.code !== 0时,可以直接用Element Plus的ElMessage弹提示。这样业务代码里就不用每个接口都写一遍错误处理了。我一般还会在这里统一做404和500的提示,前端能少写很多重复代码。
baseURL的取值要配合环境变量做区分。本地开发时走Vite的代理指向http://localhost:8080,生产构建时走Nginx的同源路径,这样打包出来的文件无论部署到哪里,都不用改代码。
3.3 路由守卫与前端权限控制
前端权限如果做得很重,比如按钮级别权限、角色动态路由,那是另一个复杂度。这个系统我控制在两级:未登录用户访问需要登录的页面,直接跳回登录页;非管理员访问管理端页面,直接提示无权访问。
Vue Router的beforeEach守卫里判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requiresAuth && !token) { next('/login') } else if (to.meta.requiresAdmin && role !== 'ADMIN') { next('/403') } else { next() } })这里要注意的是,前后端分离项目里,前端路由守卫只是体验优化,真正的权限校验永远在后端接口上。前端跳过了只是看不到页面,但直接调接口还是能拿到数据,所以后端接口的管理员权限校验一定不能省。我在后端用了两个拦截器:一个校验登录态,一个校验管理员角色,两个同时生效。
3.4 商品列表、购物车与订单页的组件化实现
商品列表和购物车是用户端使用频率最高的页面。商品卡片我抽成了ProductCard组件,接收一个商品对象,展示图片、名称、价格和库存状态。列表页负责请求分页数据,然后循环渲染组件。
购物车我用Pinia管理,并做了localStorage持久化。好处是用户刷新页面购物车不丢,也能在未登录状态下把本地选中的商品合并到登录后的购物车。合并逻辑虽然不复杂,但很锻炼处理边界情况的能力,比如同一个商品用户可能选了两件不同尺码,合并时不能直接覆盖,要按规格维度累加。
订单页提交时,前端需要同时把商品列表、收货地址、备注信息发给后端。这里我建议前端只组装参数,不做金额计算,因为金额计算和优惠逻辑都应该在后端完成。前端加上价格判断很容易被请求篡改,金额以服务端为准。
4. 数据层这几个坑,实战中一定会碰到
4.1 MyBatis缓存的正确打开方式
MyBatis的一级缓存是SqlSession级别的,默认开启。在Spring中每个请求会新建SqlSession,请求结束就关闭,所以一级缓存的影响不大。二级缓存是namespace级别的,也就是一个Mapper一个缓存,开启方式是在XML里加<cache/>,但它的坑在于数据一致性。
比如你查询商品列表时缓存了一份数据,管理端后台修改了商品价格,如果不主动清缓存,前端拿到的还是旧数据。这个小项目里我没有开二级缓存,因为查询压力没那么大,不开少很多麻烦。如果后续并发真的上来了,比起在MyBatis层面开二级缓存,我更建议在Redis里做商品缓存并设置过期时间,可控性会好得多。
4.2 分页、模糊搜索与字段安全问题
商品列表页必然要分页。我用了MyBatis-Plus的分页插件,在配置类里加一个MybatisPlusInterceptor,注册PaginationInnerInterceptor就行。
模糊搜索的写法要注意SQL注入。规范化做法是XML里的like不直接拼字符串,而是用CONCAT拼接:
<select id="pageByKeyword" resultType="com.example.entity.Product"> SELECT * FROM product WHERE name LIKE CONCAT('%', #{keyword}, '%') <if test="categoryId != null"> AND category_id = #{categoryId} </if> ORDER BY create_time DESC </select>这里坚持用#{}而不是${}。#{}是预编译参数占位符,${}是字符串拼接,后者一旦把前端传的值拼进SQL,等于把注入漏洞敞开了。很多同学的第一个SQL注入漏洞就是这么来的。
4.3 一上来就连不上MySQL,先查这几个地方
本地启动这个项目时报错最多的就是数据库连接问题,尤其MySQL 8会遇到一段乱码时间区报错,原因是默认时区不对,我见过的典型报错长这样:
java.sql.SQLException: The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized解决办法是在JDBC连接串里显式指定时区:
url: jdbc:mysql://localhost:3306/ski_shop?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8还有一类常见问题是Linux服务器上用命令行连本机MySQL时报socket错误:
ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'这种多半是MySQL服务没起来,或者socket文件路径不对。直接用service mysqld status或systemctl status mysql先看服务状态比什么都快。还有个坑是密码认证插件,MySQL 8默认用caching_sha2_password,有些老客户端连不上,可以在建用户时指定mysql_native_password,但要权衡安全性,不能为了兼容老客户端牺牲太多。
5. 前后端联调与部署上线的完整过程
5.1 联调期最容易卡壳的三个问题
第一是跨域。前端的开发服务器和後端端口不一样,Vite开发环境里可以直接配proxy,把/api代理到http://localhost:8080,这样浏览器里看到的是同源请求,基本不会触发CORS。如果后端单独配置了CorsFilter或者接口没走代理,那就得在后端允许跨域。两种方案能选代理就别开跨域,少一个变量少一个问题。
第二是历史路由刷新404。Vue Router用history模式时,开发环境正常,部署到Nginx后一刷新页面就404。原因很好理解:前端路由是浏览器端的,服务器上并没有/product/detail这个真实文件,Nginx找不到就报404了。解决办法是在Nginx配置里加try_files。
第三是字段命名不统一。后端如果返回user_name,前端用到的是userName,就会出现页面显示undefined但接口明明有数据的情况。我的建议是后端统一返回小驼峰命名,SpringBoot默认的JSON映射是能处理JavaBean转驼峰的,配合前面的驼峰映射配置基本不会出问题。前端拿到数据后,如果能用强类型定义一下购物车商品和订单对象,能减少很多低级错误。
5.2 Nginx托管前端并反向代理API
生产环境我把前端dist目录交给Nginx,后端SpringBoot进程独立跑在8080端口,由Nginx把/api开头的请求转发到后端口。Nginx配置可以这样写:
server { listen 80; server_name your-server-ip; root /usr/share/nginx/html/ski-shop/dist; 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; } location / { try_files $uri $uri/ /index.html; } }这里proxy_pass用的没有尾斜杠的写法,转发时会保留/api前缀,后端所以Controller统一加了/api前缀,两边就能对上。这一段是整个部署过程最容易出乱子的地方,我见过有人把proxy_pass写到http://localhost:8080/然后找不到接口的。
5.3 后端打包:jar包部署与war包部署到Tomcat
默认SpringBoot项目打出来是jar包,内置了Tomcat,直接执行:
mvn clean package -DskipTests java -jar target/ski-shop.jar --spring.profiles.active=prod如果你一定要部署到外部Tomcat,比如公司环境里Tomcat版本固定,那就把项目改成war包,步骤有三个:
pom.xml里<packaging>war</packaging>;- 依赖里把
spring-boot-starter-tomcat的scope设为provided; - 启动类继承
SpringBootServletInitializer并重写configure。
@SpringBootApplication public class SkiShopApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(SkiShopApplication.class); } }打出来的war放到webapps/ROOT,启动Tomcat后访问http://ip:8080/就能看到接口。要注意war包方式下,内置的静态资源处理会被外部Tomcat接管,所以前端还是放在Nginx下更干净,这也是前后端分离的一个好处。
5.4 服务器端部署后的失败排查清单
部署完跑不起来是常态,我这里列一份实战排查顺序:
- 进程是否在跑:
ps -ef | grep java,别上来就改代码; - 端口是否监听:
ss -lntp | grep 8080,确认端口没被占用; - 后端日志有没有报错:用
nohup java -jar xxx.jar > app.log 2>&1 &启动后tail -f app.log,数据库连接、SQL错误都会在这里暴露; - MySQL是否允许远程访问:检查用户是否授权了对应IP,
GRANT ALL PRIVILEGES ON ski_shop.* TO 'user'@'%' IDENTIFIED BY 'password';后FLUSH PRIVILEGES;; - 前端404:先确认Nginx root路径是否正确,再确认有没有
try_files。
另外分享一个小技巧:如果线上出问题但手头没有源码,只有一份jar包,可以直接用IDEA打开jar,它会自动反编译class文件,配合FernFlower能比较清楚地看到代码逻辑,排查问题、学习别人的写法都很有用。
6. 拿到这套源码之后,我建议你这样用起来
6.1 三步在本地跑起来
第一,导入数据库脚本。创建一个ski_shop数据库,把项目里的SQL文件导进去。第二,改数据库连接配置。把application里的用户名密码改成自己本地的,时区参数保留。第三,分别启动后端和前端。后端点IDEA里的main方法运行,前端npm install后npm run dev启动。顺序上先跑后端,因为前端启动之后马上会发请求,后端没起的话页面会一直空白加上报错提示。
有两点特别提示:一是确保本机JDK版本和项目要求一致,至少Java 8以上,我这边用的Java 8就能跑;二是如果改了项目端口,前端Vite的proxy配置也要同步改,否则接口会全部走错。
6.2 面试讲解时怎么突出项目亮点
这套系统的面试价值不在“我做了个商城”,而在你能否把关键细节讲清楚。被问到的时候,我建议主动聊这几个点:订单扣库存用乐观锁的思路,防止超卖;JWT登录的无状态设计和拦截器白名单;为什么用MyBatis-Plus还要写XML;部署过程中怎么排查404和数据库连接问题。面试官最喜欢听的反而是你踩过的坑和你是怎么解决它的,光背概念没有说服力。
还有一点,简历上不要只写“负责商品模块开发”,而是写“负责商品搜索与分页接口,通过SQL优化将列表页响应时间从X秒降到Y毫秒”这种有感知的描述。数字不一定非要特别夸张,但要真实可查。
6.3 后续可以扩展的方向
如果这个项目你打算继续玩下去,有几个方向性价比挺高:
- 用Redis缓存商品详情和验证码,减轻数据库压力;
- 用MinIO搭一套独立的图片上传服务,把商品图和轮播图从本地目录迁移出去;
- 用ElasticSearch做商品搜索,解决MySQL like查询在大数据量下的性能问题;
- 参考若依这类成熟的代码生成框架,把后端CRUD部分改为自动生成,提升后续开发效率。
这些扩展方向能让项目在简历和面试里立体很多,但核心还是先把当前这套SpringBoot+Vue+MyBatis+MySQL前后端分离链路走完整,前面这些坑踩通了,后面加什么都只是时间问题。
我个人在跑完这套项目后的体感是:前后端分离真正的难点从来不在写接口,而在部署、环境、字段对齐和并发边界。这套雪具销售系统把这些点覆盖得还算全面,适合自己完整过一遍。