做无人智慧超市这个选题之前,我刚把前后端分离的作业项目写完打回三次,原因都不复杂——接口路径乱写、异常没处理、前端拿着后端字段对不上。这次做“SpringBoot+Vue 无人智慧超市管理系统平台”,我特意在动手前把整个项目当成一个真实的交付物来规划,而不是只想着“把功能做出来”。项目源码里带 SQL脚本、接口文档、前端Vue工程、后端SpringBoot工程,这四块恰好对应了一套完整商业系统从设计到落地的全部环节。这篇文章我就按这套交付结构,把需求拆分、表结构设计、接口逻辑、前端页面、打包部署和排查记录完整梳理一遍。
这个项目适合三类人:第一类是正在选Java Web毕业设计题目的在校生,无人超市这个业务够新、够具象,但不会难到手写算法或模型训练那个级别;第二类是已经学过SpringBoot和Vue基础、想通过一个完整项目把技术点串起来的开发者;第三类是准备做毕设答辩,需要把“为什么要这么设计”讲清楚的同学。我尽量把每个选择背后的原因也写出来,保证你被老师追问的时候顶得住。
1. 项目到底在解决什么问题——需求拆分与功能设计思路
1.1 无人超市的业务本质是什么
传统超市的运营链条里,顾客从进门到出门要经历:找商品、排队结账、扫码付款、开发票、带走小票。超市老板要干的活儿更多:每天盘点库存、核对销售数据、调整价格标签、防止漏单被盗。无人智慧超市的核心思路,是把顾客自助和后台自动管理这两件事用一套Web系统串起来。
顾客端看到的是一套能浏览商品、加购物车、模拟扫码结算、查看订单记录的页面;管理端看到的是一套能维护商品信息、管理分类、查看订单流水、处理库存预警的后台。业务看起来简单,但落到系统设计上,它涉及用户权限体系、商品上下架状态、库存扣减一致性、订单状态流转、前端交互状态同步等问题,每个点都能展开做深度优化。
我当时做需求梳理时,给自己提了一个标准:这个系统的业务流程必须自洽,不能有“能下单但库存不改”或者“订单生成但没有商品明细”这种逻辑漏洞。无人超市的钱货关系是:顾客付款 → 订单生成 → 库存扣减 → 销售数据更新。这四个动作必须是一个完整的事务链条,这也是整个系统设计的核心约束。
1.2 功能模块怎么拆,边界画在哪
按角色划分,系统可以分成两条主业务线。第一条线是顾客自助购物线,对应的功能模块有:用户注册登录、商品分类浏览、商品详情查看、购物车管理、订单提交、订单列表与详情查询。第二条线是管理员运营线,对应的功能模块有:管理员登录、商品管理(增删改查、上下架)、分类管理、订单管理(查看、发货/完成)、用户管理、统计概览(销售数量、销售额、库存预警)。
模块的边界我是按“数据归属”来画的。顾客线只操作自己的购物车、订单和个人信息;管理员线只操作商品、分类和全局订单。商品库存字段被订单模块引用,但库存数值只有结算方法会更新,管理员手动改库存也走独立的库存调整接口,避免多路并发写同一个字段导致数据异常。
还有一个容易忽略的模块是“模拟支付”。毕业设计里一般不做真实支付对接,我会在结算接口里加入一个支付状态字段,默认置为已支付,但接口路径上保留“创建订单”和“支付回调”两个接口,方便演示和扩展。这样既体现你对业务的理解,又不会因为没接支付宝被老师扣分。
1.3 为什么这个选题适合Java Web毕设
毕业设计最怕的不是功能多,而是“能做但在答辩时讲不清楚”。无人超市系统最大的优势在于:每一行代码都能对应到一个具体的业务解释。
比如你写了一个InventoryService.deductStock()方法,你可以解释为“当用户提交订单时,系统必须原子性地扣减对应商品的库存,防止超卖”;你写了一段Vue的watch监听购物车数量变化,你可以解释为“前端需要实时计算总金额,并在数量变化时同步订单明细”。这种“代码—业务”的一一映射关系,在答辩时非常重要,能让你在评委提问时引回自己熟悉的模块,而不是被带进陌生的技术细节。
从技术覆盖面来说,这个项目也压得足够满:SpringBoot的自动配置、AOP日志、统一异常处理、MyBatis映射、事务管理、JWT登录鉴权、Vue组件通信、路由守卫、Axios拦截器、数据库设计范式。没有深度学习路径的依赖,不用GPU服务器,一台开发机从零到部署完成大概一周能做完,属于“投入产出比”很高的选题。
2. 技术选型与工程结构——SpringBoot和Vue为什么这么搭
2.1 后端选SpringBoot而不是SSH/Servlet的原因
毕业设计的后端框架,每年都有同学在SpringBoot和SSH之间纠结。我的建议很直接:除非老师指定旧框架,否则一定选SpringBoot。SSH那个年代的问题在于配置繁琐——Struts的XML配置、Spring的Bean配置、Hibernate的映射配置,一套下来光配置文件就能写上百行,业务代码反而被淹没。SpringBoot通过自动配置把绝大多数默认行为都处理好了,你只需要关注自己的业务代码,这在写毕设时能节约大量的调试时间。
更关键的是,SpringBoot的生态资料太丰富了。你遇到的90%的问题,都能直接在社区里找到对应的解决方案,比如跨域配置、拦截器注册、MyBatis的Mapper扫描、全局异常处理,这些都是高频问题,搜一下就有完整答案。做毕设时时间本来就紧,没必要在框架配置上跟编译器较劲。
环境版本方面,我当时用的是 Spring Boot 2.7.x + JDK 1.8,为什么不用Spring Boot 3?因为Spring Boot 3强制要求JDK 17,而你见过的老教程、学长留下的笔记、部分框架组件版本,大都是基于JDK 8的。为了减少踩坑成本,2.7.x是我目前最推荐的生产力版本,稳定、兼容性广、资料齐全。
2.2 前端选Vue而不是JSP/Thymeleaf的原因
JSP和Thymeleaf这类服务端渲染方案,确实能让Java后端同学上手快,因为页面直接嵌在后端工程里,不用考虑跨域。但问题在于前后端职责混在一起,改个页面样式可能要重启后端服务,遇到前端报错还得去后端日志里找,维护体验很差。
Vue把前端变成了一个独立的工程,有自己的一整套开发流程:npm install装依赖,npm run dev起本地开发服务器,开发时可以直接热更新页面,和后端通过HTTP接口通信。这样的前后端分离结构,更接近真实企业开发的标准。而且Vue的组件化思想对毕设的代码组织很友好——一个商品卡片组件可以同时用在“商品列表页”和“搜索结果页”,管理员后台的表格也可以复用分页组件,减少重复代码量。
Vue工程本身也带路由和状态管理,路由解决“页面跳转”,状态管理解决“多个页面共享数据”。比如用户在商品页把商品加入购物车,跳转到购物车页面后需要显示同一个购物车数据,这就必须使用Pinia或Vuex来维护一份共享状态,而不是每次切换页面都重新请求后端。用JSP时代,这种跨页面数据共享只能靠Session或者URL参数,非常别扭。
2.3 前后端分离项目结构到底怎么摆
我先说一个常见的反面案例:很多同学把前端打包后的dist目录直接塞进后端工程的static文件夹,然后当作单一项目交给老师。这样做完全失去了前后端分离的意义,而且在开发阶段非常痛苦——前端改一行代码,都要重新打包一次。
正确的做法是建立两个独立工程目录,例如:
project-root/ ├── backend/ # SpringBoot后端工程 │ ├── src/main/java/com/supermarket/ │ │ ├── controller/ # 接口层 │ │ ├── service/ # 业务逻辑层 │ │ ├── mapper/ # MyBatis的Mapper接口 │ │ ├── entity/ # 实体类 │ │ ├── config/ # 配置类(跨域、拦截器、Swagger等) │ │ ├── common/ # 统一返回结果、全局异常、工具类 │ │ └── SupermarketApplication.java │ ├── src/main/resources/ │ │ ├── mapper/ # MyBatis的XML文件 │ │ ├── application.yml │ │ └── sql/ # 项目的SQL脚本备份 │ └── pom.xml └── frontend/ # Vue前端工程 ├── src/ │ ├── api/ # axios接口封装 │ ├── router/ # 路由配置 │ ├── views/ # 页面组件 │ ├── components/ # 公共组件 │ ├── store/ # Pinia状态管理 │ └── main.js ├── package.json └── vite.config.js两个工程独立开发、独立启动,开发阶段前端localhost:5173、后端localhost:8080,通过Vite的代理把/api请求转发到后端。这样前后端各自跑各自的,互不干扰。最终交付时再介绍“Vue打包后如何放入SpringBoot一起运行”的部署方案,我会在第6章详细说。
3. 数据库设计与SQL脚本——订单、库存、结算怎么建模
3.1 核心表结构和关键字段
数据库设计是整个项目的基础,表结构如果在前期设计错了,后期功能都会跟着跑偏。无人超市系统我建议至少设计以下这些表:
| 表名 | 用途 | 关键字段 |
|---|---|---|
user | 用户/管理员 | id,username,password,role,create_time |
category | 商品分类 | id,name,sort_order,status |
product | 商品信息 | id,category_id,name,price,stock,image,status |
cart | 购物车 | id,user_id,product_id,quantity |
order | 订单主表 | id,order_no,user_id,total_amount,status,pay_time,create_time |
order_item | 订单明细表 | id,order_id,product_id,product_name,price,quantity |
stock_log | 库存变动日志 | id,product_id,change_amount,type,create_time |
需要注意几个设计细节:
第一,密码字段不要存明文。用BCryptPasswordEncoder加密后存储,长度设为60。就算毕设项目不要求高安全性,这也是数据库设计的基本素养,答辩时能加分。
第二,价格字段用DECIMAL(10,2),不要用FLOAT或DOUBLE。浮点数在Java和数据库精度对比时会有奇怪的问题,比如0.1加0.2不等于0.3,商品定价这种敏感数据必须保证精度。
第三,订单号需要唯一标识。我使用“日期+随机数/自增ID”拼接的方式生成,比如20250607153000123456,确保并发环境下订单号不重复。
第四,order_item里冗余了product_name和price字段。为什么这么做?因为商品名称和价格后续可能会改,如果我们只存product_id,等用户重新查看历史订单时会发现商品名对不上。把下单那一刻的商品快照冗余进订单明细,是商业系统的标准做法。
3.2 库存扣减与订单生成的顺序问题
无人超市里最容易出问题的是“库存扣减”和“订单生成”这一步。如果先扣库存再生成订单,订单生成失败时库存就丢了;如果先生成订单再扣库存,扣减失败时用户会有一张无效订单。
正确做法是:在同一个数据库事务里,先插入订单主表和订单明细表,然后在库存充足的前提下扣减库存,最后提交事务。过程中任何一个环节报错,通过@Transactional注解让整个事务回滚,保证数据的原子性。这部分对应代码如下:
@Transactional public Long createOrder(OrderCreateVO vo) { // 1. 生成订单号并写入订单主表 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(vo.getUserId()); order.setTotalAmount(calculateTotal(vo.getItems())); order.setStatus(0); // 0-待支付, 1-已支付, 2-已完成, 3-已取消 orderMapper.insert(order); // 2. 写入订单明细 for (OrderItemVO item : vo.getItems()) { orderItemMapper.insert(...); } // 3. 扣减库存,库存不足则抛异常触发回滚 for (OrderItemVO item : vo.getItems()) { int rows = productMapper.deductStock(item.getProductId(), item.getQuantity()); if (rows == 0) { throw new RuntimeException("商品库存不足"); } } // 4. 记录库存变动日志 stockLogMapper.insert(...); return order.getId(); }这里使用了productMapper.deductStock(id, quantity)的SQL进行条件更新,核心写法是:
UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}通过stock >= quantity这个条件来保证不会扣成负数。UPDATE影响行数为0,就说明库存不足,抛出异常回滚整个事务。这种“乐观扣减+事务回滚”的方式,足够应对毕设级别的并发场景。
3.3 SQL脚本文件里容易被忽略的细节
项目交付时附带的supermarket.sql脚本,不只是建表语句这么简单。一份“老师体验良好”的SQL脚本,至少应该包含三部分内容:
第一部分是建库建表语句,统一使用UTF-8字符集:
CREATE DATABASE IF NOT EXISTS supermarket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE supermarket; CREATE TABLE `user` ( `id` int(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password` varchar(100) NOT NULL, `role` tinyint(2) NOT NULL DEFAULT '1' COMMENT '1-顾客 2-管理员', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;第二部分是初始化数据,也就是管理员和测试商品数据。防止评审老师拿到项目后还要自己手动录入商品才能演示,这会直接拉低印象分。我一般会准备10条左右的商品记录,覆盖三个分类,保证页面打开就有数据可看。
第三部分是演示用的说明注释,用--注释写明“管理员账号:admin / 123456;顾客测试账号:user / 123456”,这样拿到源码的人第一分钟就能跑起来。
这里有一个特别容易踩的坑:MySQL 5.7和8.0的认证插件不一致。如果你本地是MySQL 8.0,生成的SQL用了caching_sha2_password插件,拿到MySQL 5.7导入时会报认证失败。稳妥的办法是创建用户时明确指定mysql_native_password,或者在application.yml里配置useSSL=false&serverTimezone=Asia/Shanghai并确认数据库连接串没有多余的空格。关于时区的设置也要注意,不配置serverTimezone的话,JDBC连接可能直接报时区错误。
4. 后端接口实现与接口文档——从登录鉴权到无人结算闭环
4.1 接口文档到底怎么写才有用
很多同学交接口文档就是给后端每个Controller截个图,或者导出一份Swagger的JSON,老师看了毫无头绪。真正的接口文档,核心是让人看完能够独立调用这个系统。
我推荐的接口文档结构是这样的:
接口文档 ├── 1. 通用说明 │ ├── 1.1 BaseURL(http://localhost:8080/api) │ ├── 1.2 统一返回结构(code, message, data) │ └── 1.3 鉴权方式(请求头Header携带 token) ├── 2. 用户模块 │ ├── 2.1 用户注册接口 POST /api/user/register │ ├── 2.2 用户登录接口 POST /api/user/login │ └── 2.3 获取用户信息接口 GET /api/user/info ├── 3. 商品模块 │ ├── 3.1 分页查询商品列表 GET /api/product/page │ ├── 3.2 查询商品详情 GET /api/product/{id} │ └── 3.3 管理员新增商品 POST /api/admin/product ├── 4. 购物车模块 │ ├── 4.1 加入购物车 POST /api/cart/add │ ├── 4.2 查看购物车 GET /api/cart/list │ └── 4.3 修改购物车数量 PUT /api/cart/update └── 5. 订单模块 ├── 5.1 创建订单 POST /api/order/create ├── 5.2 模拟支付 POST /api/order/pay/{orderId} └── 5.3 查询订单列表 GET /api/order/list每个接口下需要写明:请求参数(字段名、类型、是否必填、说明)、响应示例、可能出现的错误码。统一返回结构建议写成:
{ "code": 200, "message": "操作成功", "data": {} }其中code=200表示成功,401表示未登录或token失效,500表示业务异常。前端拿到返回后只看code判断是否成功,不再依赖HTTP状态码。这里建议全项目用同一个Result类,避免每个接口返回结构不一致。
4.2 核心接口串联:一次结算请求会经过哪些接口
无人超市的结算链路是整个系统里最能体现设计能力的部分。用户在前端点击“去结算”按钮后,前端依次调用的接口是这样的:
第一步,校验登录状态,调用GET /api/user/info,携带请求头token: xxx,后端从JWT解析出用户ID。这一步失败会直接跳回登录页。
第二步,查询购物车,调用GET /api/cart/list,返回当前用户的购物车明细列表,包括商品ID、数量、价格,前端展示确认信息。
第三步,提交结算,调用POST /api/order/create,请求体包含用户ID和购物车里被勾选商品的明细。后端在事务中执行“写订单主表 → 写订单明细 → 扣库存 → 记库存日志”,返回订单ID和订单号。
第四步,模拟支付,调用POST /api/order/pay/{orderId},把订单状态从0-待支付改成1-已支付。这一步在真实系统中会对接支付网关,毕设里直接调用一个模拟接口即可。
第五步,查询订单详情,调用GET /api/order/detail/{orderId},前端展示“支付成功”页面和订单信息,同时清空本地购物车状态。
这个串联流程在接口文档里如果只用文字描述还有点抽象,更直观的做法是基于一个统计表格:
| 步骤 | 接口 | 作用 | 成功后状态 |
|---|---|---|---|
| 1 | GET /api/user/info | 鉴权并获取用户ID | 进入结算页 |
| 2 | GET /api/cart/list | 加载购物车数据 | 显示结算清单 |
| 3 | POST /api/order/create | 创建订单并扣库存 | 生成待支付订单 |
| 4 | POST /api/order/pay/{id} | 模拟支付成功 | 订单已支付 |
| 5 | GET /api/order/detail/{id} | 加载订单详情 | 支付完成页 |
4.3 JWT鉴权与权限控制的实际配置
无人超市系统有顾客和管理员两种角色,后台管理接口不能随便让顾客调用。工程里我采用了JWT(JSON Web Token)来做无状态鉴权。
依赖直接引:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency>JWT的逻辑是:用户登录成功后,后端用密钥签发一个包含用户ID和角色信息的token,前端把token存到localStorage,每次请求通过Axios拦截器自动携带。后端写一个拦截器,在请求进入Controller之前检查token:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); if (token == null || !JwtUtil.validate(token)) { response.setStatus(401); return false; } // 把用户ID放到request中,后面Controller可以直接用 Integer userId = JwtUtil.parseToken(token); request.setAttribute("userId", userId); return true; } }注册拦截器时,还要把“不需要登录”的接口排除掉,比如注册登录、商品列表、商品详情。用addPathPatterns("/api/**")拦截所有接口,再用excludePathPatterns白名单放行:
registry.addInterceptor(new JwtInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns( "/api/user/login", "/api/user/register", "/api/product/page", "/api/product/detail/**" );角色的权限控制我直接在Service层判断,比如管理员添加商品的服务方法里验证request.getAttribute("role")是否等于管理员值,不通过则抛业务异常。毕业设计用这种方式完全够,不需要引入Spring Security这种重型框架,因为Spring Security的SecurityContext和过滤链对初学者来说是个巨大的理解成本。
5. 前端Vue页面实现——浏览、加购、结算、后台管理的交互闭环
5.1 前端工程初始化与路由设计
Vue前端我推荐用Vite来搭建工程,命令行执行:
npm create vite@latest frontend -- --template vue创建完成后需要安装路由和状态管理依赖:
npm install vue-router@4 pinia axios element-plus这个组合是Vue 3生态下的主流方案,路由用createRouter,状态管理用defineStore,UI组件库用Element Plus。如果你用的是Vue 2,那对应的是vue-router@3和vuex,写法稍有差异,别混用。
路由设计上建议把页面按角色分组:
const routes = [ { path: '/login', component: LoginView }, { path: '/', component: Layout, children: [ { path: '', component: HomeView }, // 商品浏览首页 { path: 'cart', component: CartView }, // 购物车 { path: 'order', component: OrderListView }, // 我的订单 { path: 'order/:id', component: OrderDetailView }, { path: 'admin', component: AdminView, meta: { role: 2 } } // 管理后台 ]} ];路由守卫也很重要,未登录用户访问需要鉴权的路由时,直接重定向到登录页:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path === '/login') { next(); } else if (!token) { next('/login'); } else { next(); } });这里有一个实测心得:不要把用户信息全部存到localStorage,涉及角色的判断建议存JSON.stringify后的对象,但token单独存一个key,因为Axios拦截器取token的频率远高于用户信息。
5.2 Axios怎么封装才不用到处写重复代码
如果每个页面都直接import axios from 'axios'然后写一遍请求配置,项目代码会非常冗余,而且统一改接口地址时你会想哭。我强烈建议建一个src/api/request.js做统一封装:
import axios from 'axios'; import { ElMessage } from 'element-plus'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); // 请求拦截器:自动携带token request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['token'] = token; } return config; }); // 响应拦截器:统一处理错误码 request.interceptors.response.use( response => { const res = response.data; if (res.code === 200) { return res.data; } else if (res.code === 401) { localStorage.removeItem('token'); window.location.href = '/login'; } else { ElMessage.error(res.message); return Promise.reject(new Error(res.message)); } }, error => { ElMessage.error('网络请求失败'); return Promise.reject(error); } ); export default request;注意baseURL: '/api'这里留了个伏笔。开发环境通过Vite代理转发,生产环境通过后端Nginx或SpringBoot的路径映射转发,前端代码不用改。对应的Vite代理配置:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } });每个业务模块再建一个独立的API文件,比如src/api/product.js:
import request from './request'; export function getProductList(params) { return request({ url: '/product/page', method: 'get', params }); } export function getProductDetail(id) { return request({ url: `/product/detail/${id}`, method: 'get' }); }这样页面里调用就非常清爽:import { getProductList } from '@/api/product',数据加载逻辑全被隔离在API层,页面只管渲染。
5.3 从商城页到结算页的完整状态流转
无人超市这个场景里,前端最核心的状态管理是购物车数据。用户从商品列表点击“加入购物车”,此时前端需要做两件事:调用后端接口把购物车数据入库;同时在Pinia里更新本地的购物车数量,让导航栏的购物车角标立即变化。
我维护了一个cartStore:
export const useCartStore = defineStore('cart', { state: () => ({ cartCount: 0, cartList: [] }), actions: { async fetchCart() { const res = await getCartList(); this.cartList = res; this.cartCount = res.reduce((sum, item) => sum + item.quantity, 0); }, async addCart(productId, quantity) { await addCartItem({ productId, quantity }); await this.fetchCart(); // 重新拉服务器数据,保证一致性 } } });首页商品卡片组件里,addCart完成后调用cartStore.fetchCart(),这样无论用户在哪个页面加入购物车,导航栏的角标都能同步。这里有一个我之前踩过的坑:如果只在前端本地累加数量而不重新拉取,用户连续快速点击两次“加入购物车”,第二次点击拿到的本地库存可能还是旧值,导致提示库存不足但购物车里数量加多了。重新拉取服务器数据虽然多一次请求,但换来的数据一致性对无人超市这种场景更值得。
结算页的流程是:展示cartList,用户勾选要结算的商品,点击“去结算”按钮。前端调用创建订单接口后拿到订单ID,再调用模拟支付接口,成功之后跳转订单详情页,同时清空购物车并重新拉取cartList。这样整个“购物—结算—支付—完成”的闭环在前端就完整地串起来了。
6. 部署与联调——Vue打包进SpringBoot的两种方式
6.1 本地开发环境的联调配置
前后端分离开发时,最让人头痛的就是跨域问题。浏览器默认不允许一个域名下的页面去请求另一个域名的接口,所以后端必须允许跨域。我通常在SpringBoot里写一个全局配置类:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }但这种方法有两个隐患:一是addAllowedOriginPattern("*")不能和setAllowCredentials(true)同时使用,二是生产环境放开所有跨域有安全风险。所以我推荐的开发联调方式是:前端不用真实跨域,而是通过Vite的代理把请求转发到后端,这样浏览器看到的始终是同源请求,后端甚至不需要配置跨域。
前端启动npm run dev后,访问的是http://localhost:5173,请求/api/user/login时,Vite代理自动转发到http://localhost:8080/api/user/login,前端代码里根本不需要写后端的完整地址。这种方式是本地联调的首选,干净又安全。
6.2 生产环境打包部署的实操步骤
生产部署时,第一步是构建前端静态资源:
npm run build构建完成后,frontend/dist目录下会生成index.html、assets等静态文件。你直接把dist目录下的内容复制到后端工程的src/main/resources/static目录下,然后重新启动SpringBoot。由于SpringBoot默认会扫描classpath:/static/作为静态资源目录,浏览器访问http://localhost:8080时,SpringBoot会把index.html返回给前端,这个方案适合演示和交作业。
但这里有一个必须处理的问题——前端路由刷新404。Vue Router如果开启的是history模式,刷新/order/123这个路径时,后端没有/order/123这个Controller,会直接返回404。解决方法有两个:
第一种,Vue路由使用默认的hash模式,URL里会多一个#,例如http://localhost:8080/#/order/123,这种模式刷新不会404,因为#后面的内容不会发送到服务器。做毕设完全可以用这个模式,稳定性最高。
第二种,如果坚持用history模式,后端需要额外配置一个Controller,把所有非API和静态资源的路径转发到index.html。SpringBoot里可以写:
@Controller public class ForwardController { @RequestMapping(value = {"/", "/login", "/order/**", "/admin/**"}) public String forward() { return "forward:/index.html"; } }我个人建议毕设直接用hash模式,省心省力。你要是想在答辩时多一个技术亮点,可以提“我了解history模式和hash模式的区别,毕设用了hash模式避免刷新404”,这就够了。
6.3 部署后静态资源与API路径冲突问题
把前端打包放进SpringBoot后,最容易出现一个诡异的问题:接口请求路径和静态资源路径冲突。
比如你后端的接口是GET /api/product/page,前端构建产物里有个文件路径也可能叫/api开头的某个静态目录,这样SpringBoot在处理请求时可能把这个请求当成静态资源请求,导致接口返回的不是JSON而是文件内容。
解决方法是严格约定:所有后端接口统一放在/api前缀下,所有前端静态资源路径由构建工具自动生成在/assets目录,这样两者互不干扰。如果Vue页面里还有图标、图片等静态文件,也统一放在public目录下,构建时用/assets前缀引用。
另外有一个部署细节:SpringBoot打成的JAR包如果直接java -jar运行,内置Tomcat监听8080端口。如果服务器上8080已经被占用,需要在application.yml里改端口:
server: port: 8081启动后再用http://localhost:8081访问,前端请求还是走同一个端口,不用改前端代码。这里提醒一下,改了端口后Vite代理的target也要一起改,否则开发环境又联调不上了。
7. 踩坑记录——毕设开发中最容易遇到的10类问题
7.1 前后端联调期的典型问题
联调阶段最常遇到的第一个问题是跨域报错,浏览器控制台报CORS policy。我们前面用了Vite代理方案,这里测下来很稳定。如果你没走代理而是直连后端地址,那必须检查后端是否配置了CorsFilter,另外注意OPTIONS预检请求也需要能被后端处理。
第二个问题是JSON序列化循环引用。如果Order实体里有一个List<OrderItem>属性,OrderItem里又有一个Order属性,直接返回这个对象时,Jackson序列化会报 “Could not write JSON: Infinite recursion” 错误。解决办法是给实体类加上@JsonIgnoreProperties或在字段上用@JsonIgnore,更规范的是一开始就设计VO类,不让实体类直接暴露给前端。这个问题我当初排查了整整一个下午,最后发现就是两个双向关联字段导致的无限递归。
第三个问题是日期格式。后端返回的LocalDateTime默认序列化成2025-06-07T15:30:00,中间有个T,前端显示不友好。统一处理方式是配置Jackson:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这样接口返回的时间就变成了2025-06-07 15:30:00。
7.2 数据库与SQL执行期的典型问题
SQL脚本导入报错,最常见的原因是字符集不一致。Windows上直接用记事本打开SQL文件另存为UTF-8,可能带上BOM头,MySQL导入时会报第一个SQL语句错误。建议使用Navicat、DataGrip这类专业工具直接运行SQL脚本,不要用命令行去碰文件编码问题。
第二个常见问题是外键约束导致删除顺序不对。你的表设计里如果order_item引用了order表的主键,那删除订单时必须先删除order_item,再删除order。很多同学手动测试时会先删主表,结果被外键卡住。毕设项目里如果对数据一致性要求没那么高,我建议可以不加物理外键,而是在应用层维护关联关系。这种方式在互联网大厂也很常用,因为外键在高并发场景下会影响写入性能,而且删除数据非常麻烦。
第三类是分页查询的total问题。用MyBatis写分页时,如果你用PageHelper插件,需要注意它和自定义SQL的兼容性。尤其是包含子查询或者多表连接时,PageHelper自动生成的count语句可能会查错表。建议所有分页查询都写一个简单的count查询,别完全依赖PageHelper的自动count。
7.3 接口文档与实际代码不一致的问题
这个问题的根源是开发时不断改字段,文档却没有同步。我的习惯是:先把接口文档写成一个接口清单表格,每写完一个接口就对照清单打个勾,接口参数变了立刻更新文档。文档不是最后补的,而是从始至终和代码同步维护的。
实际操作时还可以用一个轻量方案:在SpringBoot里集成springdoc-openapi(Swagger 3),自动扫描Controller生成接口文档。启动项目后访问http://localhost:8080/swagger-ui.html,就能看到在线可测试的接口文档,这样虽然看着“偷懒”,但接口和代码完全由同一个来源生成,绝对不会出现“文档写着name参数实际代码用username”这种问题。
最后再分享一个毕设答辩时的经验:如果老师打开你的接口文档发现响应格式是统一的{code, message, data},前端页面又都是通过message展示用户提示,这本身就是一套完整的前后端协作约定。你可以在答辩时讲“为了保证前后端团队高效协作,我们约定了统一的返回结构和错误码规范”,这句话能让答辩老师觉得你不是在做一个课程作业,而是有真实的工程意识。做毕设不追求炫技,把每个环节做扎实、讲清楚,这个项目就成功了。