要说现在网上出镜率最高的练手项目,前后端分离的网上宠物店系统绝对算一个。SpringBoot + Vue + MyBatis + MySQL 这套组合,不管是课程设计、毕业设计,还是想系统梳理一遍真实项目开发全流程,都非常合适。 我最近完整整理了一套宠物店系统的源码和部署文档,从数据库设计、后端接口、前端页面到服务器上线,从头到尾跑了几遍,这里把核心实现细节、部署过程和踩过的坑一并写出来,希望能帮你少走弯路。
这套项目不是那种花架子demo,而是把电商类系统的基本闭环都走通了:用户登录注册、宠物分类浏览、商品详情、购物车管理、订单结算、个人中心,以及后台的商品管理功能。如果你正准备做类似的前后端分离项目,或者刚学完 SpringBoot 和 Vue 想找一个完整的综合实践,这篇内容应该能给你一个比较清晰的参考。
1. 架构选型与模块拆解:宠物店系统的需求边界在哪里
1.1 宠物店系统到底需要做哪些业务模块
很多人拿到"网上宠物店系统"这个题目,第一反应是"不就是个卖猫卖狗的商城吗"。真动手拆需求的时候就会发现,它和普通电商系统有不少差异。
我从业务角度把系统的功能模块分成了两类:前台用户端和后台管理端。前台用户端需要支持:
- 用户体系:注册、登录、个人信息查看修改。密码不能明文存,这个后面讲。
- 商品展示:宠物分类浏览(猫、狗、水族、爬宠等)、关键字搜索、价格筛选、分页列表、商品详情页。
- 购物车:加入购物车、修改数量、勾选/取消勾选、删除、结算。
- 订单流程:从购物车生成订单、填写收货地址、支付状态模拟、订单列表和详情。
- 个人中心:我的订单、我的收藏、收货地址管理。
后台管理端则是典型的管理员 CRUD:分类管理、宠物商品管理(上下架、库存修改、价格调整)、订单管理(发货、取消)、用户管理。
这里有一个非常容易被忽略的点:宠物商品和普通商品不一样,它的属性维度更多。普通T恤只需要颜色、尺码、价格、库存;一只宠物需要品种、年龄、性别、体重、疫苗情况、健康证书编号、驱虫记录。所以我在商品表基础上单独设计了宠物档案扩展表,避免在主表里堆太多冗余字段,也让详情页展示更灵活。
另外,宠物店系统还有一个特殊场景:宠物数量通常不会像普通商品那样有大量库存,一只猫可能就一只。所以订单流程里必须做严格的库存校验,超卖问题在这类系统里更容易暴露。
1.2 为什么选 SpringBoot + Vue + MyBatis + MySQL 这套组合
选型这件事,不同场景答案不一样。课程设计和毕设场景讲究的是"技术覆盖广、学习曲线平缓、资料多",SpringBoot + Vue 这套组合刚好命中。
后端用 SpringBoot,核心原因是它把项目启动、配置管理、依赖集成的大量重复工作简化了,内置 Tomcat,一个 java -jar 就能跑起来,不用像传统 SSM 项目一样去单独配置一堆 XML。而且 SpringBoot 的生态对 RESTful API 支持很友好,配合 jackson 直接返回 JSON,前后端分离对接非常顺畅。
持久层我用的 MyBatis 而不是 MyBatis-Plus,不是说不让用 Plus,而是用原生 MyBatis 能更好理解 SQL 和对象的映射过程,尤其是动态 SQL 在商品筛选场景里非常重要。MyBatis-Plus 虽然省代码,但动态 SQL 这块的学习反而是绕不过去的,先把原生 MyBatis 跑明白,以后用 Plus 会非常轻松。
前端用 Vue 的主要原因是组件化和数据驱动的特性非常适合后台管理系统和商城页面这类交互密集型应用,而且整套工具链相对成熟。至于 Vue 2 还是 Vue 3,我的建议是:如果是新学,直接用 Vue 3 + Element Plus,避免一上来就学旧语法。
MySQL 就不用多说了,电商类项目里数据一致性要求比较高,MySQL 的事务、行级锁、外键约束这些能力都够用,而且部署运维资料非常多,本地跑和服务器部署都不折腾。
1.3 前后端工程结构设计
项目结构上,我前后端完全分离,分成两个独立工程。后端采用经典分层结构:
com.petstore ├── controller // 接收前端请求,返回统一结果 ├── service // 业务逻辑层,事务控制在这一层 ├── mapper // MyBatis Mapper接口,对应XML ├── entity // 数据库实体类 ├── dto // 数据传输对象,避免直接暴露实体 ├── common // 统一返回结果、异常处理、工具类 ├── config // 拦截器、CORS、静态资源映射配置 └── interceptor // 登录态拦截器前端 Vue 项目的目录这样组织:
src ├── api // axios请求封装,按模块拆文件 ├── router // 路由配置,含路由守卫 ├── store // 全局状态管理 ├── views // 页面组件,按用户端/后台分目录 ├── components // 通用组件,比如商品卡片、分页 ├── utils // request工具、日期格式化等 └── styles // 全局样式这样的结构有个好处:前后端分工清晰,后端同学只管接口,前端同学只管页面。即使你是一个人干两个人的活,模块拆清楚之后,调试的时候定位问题也快得多。
2. 数据库设计与 MyBatis 动态 SQL:先把表的关联关系钉死
2.1 核心表结构和设计意图
数据库设计我花的时间比写代码还多,因为表结构一旦定错,后面改起来非常痛苦。我的核心表一共 7 张:
| 表名 | 作用 | 关键字段说明 |
|---|---|---|
| user | 用户表 | id、username、password、phone、email、avatar、create_time |
| category | 宠物分类表 | id、name、parent_id(支持两级分类) |
| pet_product | 宠物商品表 | id、category_id、name、breed、price、original_price、stock、sale_count、cover_image、certificate_no、status |
| pet_profile | 宠物档案表 | id、product_id、gender、age_months、weight_kg、vaccine_info、deworm_info、description |
| cart | 购物车表 | id、user_id、product_id、quantity、checked |
| orders | 订单主表 | id、order_no、user_id、total_price、receiver_name、receiver_phone、receiver_address、status、create_time、pay_time |
| order_detail | 订单明细表 | id、order_id、product_id、product_name、product_image、price、quantity |
这里我特意把订单明细里的商品名称、图片、价格都做了冗余存储。为什么?
因为用户下单之后,商家可能修改商品价格、下架商品甚至删除商品。如果订单明细里只存 product_id,到时候查历史订单就会关联出"商品已下线"或者"价格变了"的尴尬情况。电商系统的订单快照设计就是这个思路——订单必须忠实记录交易发生那一刻的商品信息,而不是动态去查商品表。
orders 表里的 status 字段我用的是 tinyint,0 待付款、1 待发货、2 已发货、3 已完成、4 已取消。这个设计配合订单列表页的状态筛选非常方便。
2.2 宠物商品筛选场景的动态 SQL
商品列表页是查询最复杂的接口,因为它需要同时处理分类筛选、关键字搜索、价格区间、排序方式、分页。这时候 MyBatis 的动态 SQL 就派上用场了。
我的 mapper XML 大概长这样:
<select id="selectProductList" resultType="com.petstore.entity.PetProduct"> SELECT p.id, p.category_id, p.name, p.breed, p.price, p.original_price, p.stock, p.cover_image, p.sale_count, p.status FROM pet_product p <where> <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (p.name LIKE CONCAT('%', #{keyword}, '%') OR p.breed LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="minPrice != null"> AND p.price >= #{minPrice} </if> <if test="maxPrice != null"> AND p.price <= #{maxPrice} </if> <if test="status != null"> AND p.status = #{status} </if> </where> ORDER BY <choose> <when test="sort == 'price_asc'">p.price ASC</when> <when test="sort == 'price_desc'">p.price DESC</when> <when test="sort == 'sale_desc'">p.sale_count DESC</when> <otherwise>p.create_time DESC</otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>这里有几个细节值得说。<where>标签会自动处理第一个条件前面的 AND,不会因为筛选条件为空拼出多余的 WHERE 关键字,比自己手动拼字符串安全得多。价格区间判断里>=是 XML 转义写法,直接写>=会报 XML 解析错误,这是一个很容易踩的坑。
分页我这里用的是原生 LIMIT,offset 在 Service 层算好传进来。这个方案对中小型项目完全够用,没必要为了分页专门引入 PageHelper 插件,少一层依赖少一层坑。
2.3 库存字段和订单号的唯一性设计
库存扣减是宠物店系统里数据一致性要求最高的地方。我的 pet_product 表里 stock 字段是 int,订单创建时不能只做"先查库存再减库存"这种两个步骤的分离操作,因为并发场景下两次请求可能同时读到同一个库存值,导致超卖。
正确做法是使用条件更新,后面的章节会详细写 SQL。这里先说表设计层面的两个关键点:
- stock 字段不允许为负。虽然条件更新本身就保证了不会扣成负数,但数据库层面加一个 unsigned 约束相当于多一道保险。
- order_no 订单号必须唯一。我生成订单号的规则是:日期时间 + 用户ID + 4位随机数,比如 20250612153012345678901234。虽然理论上随机数可能重复,但配合唯一索引,一旦插入失败就重新生成,实际运行中是可靠的。
数据库初始化脚本我会把建库语句的字符集显式指定为 utf8mb4,这一步非常关键:
CREATE DATABASE petstore DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE petstore;utf8mb4 不是 utf8,utf8 在 MySQL 里其实最多只有 3 字节,存不了 emoji 和部分生僻字。用户填收货地址的时候如果写了特殊符号或者表情,用 utf8 入库会报错。所以统一用 utf8mb4,前后端、连接串、表结构全链路保持这个字符集。
3. 后端核心接口链路:登录、购物车、下单的事务闭环
3.1 登录鉴权:从 JWT 生成到拦截器校验
用户登录这块我用的是 JWT(JSON Web Token),流程很简单:用户提交用户名密码,后端校验通过后生成一个 token 返回,前端存到 localStorage,后续请求在 header 里带上 token,后端拦截器校验通过就放行。
这里有个非常重要的设计点——密码存储。数据库里的 password 字段存的是 BCrypt 加密后的哈希值,而不是明文,也不是简单的 MD5。MD5 虽然也能用,但彩虹表攻击风险很大,BCrypt 是自带盐的哈希算法,每次加密结果都不同,安全性高一个量级。Spring Security 里可以直接用 BCryptPasswordEncoder,即使不引入整个 Spring Security,单独引入 spring-security-crypto 这个依赖也没问题。
JWT 的生成和校验我封装成了一个 JwtUtil 工具类,核心逻辑是:
public String generateToken(Integer userId, String username) { long expire = System.currentTimeMillis() + 86400000L; // 24小时有效期 return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .setIssuedAt(new Date()) .setExpiration(new Date(expire)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }拦截器里我在 preHandle 中从请求头获取 token,解析成功就放行,失败就返回 401 状态码。注意自定义响应体要和业务接口统一,让前端能够统一处理,否则前端拿到不规则的错误结构会很抓狂。
需要拦截的路径要做一个规划:商品列表、商品详情这些公开接口不拦截,用户信息、购物车、订单相关接口全部拦截。我的做法是拦截所有/api/**,然后排除白名单,白名单里放/api/user/login、/api/user/register、/api/product/**等公开路径。
3.2 购物车接口设计:选中状态与结算联动
购物车模块在后端比较简单,主要是 5 个接口:
| 接口 | 功能 | 说明 |
|---|---|---|
| GET /api/cart/list | 获取当前用户购物车 | 关联商品表查商品信息,按创建时间倒序 |
| POST /api/cart/add | 加入购物车 | 已存在同商品则数量加一,否则新增记录 |
| PUT /api/cart/quantity | 修改数量 | 数量最少为1,最大不能超过库存 |
| PUT /api/cart/checked | 更新勾选状态 | 参数 checked 为 0 或 1 |
| DELETE /api/cart/{id} | 删除购物车项 | 物理删除即可 |
购物车表里我加了一个 checked 字段,这个字段在结算时很重要。用户可以在购物车页面手动取消某些商品的勾选,结算时只统计当前勾选的商品。这个交互逻辑不复杂,但如果 checked 字段设计不到位,结算页和购物车页的数据就得对不上。
添加购物车时,后端要先查一下用户购物车里是否已有同一个商品,有的就直接更新数量,没有的才新增。这个逻辑不能丢,否则用户点了两次加入购物车,购物车会出现两条相同商品记录。
3.3 订单创建:事务、库存条件更新与明细快照
下单是整个系统最核心的接口,也是最容易出问题的地方。我把它的完整流程拆成 5 步:
- 根据购物车中勾选的 cart_id 列表查询购物车项和对应商品信息。
- 校验商品状态正常、库存足够。
- 循环扣减库存,用条件更新保证不超卖。
- 插入订单主表,拿到订单ID,再批量插入订单明细表。
- 删除本次下单涉及到的购物车记录。
这 5 步必须包在同一个事务里,任何一步失败都要整体回滚。我在 Service 方法上直接加@Transactional(rollbackFor = Exception.class),注意如果不指定 rollbackFor,遇到某些异常时事务可能不会回滚,这个细节必须注意。
库存扣减的 SQL 长这样:
UPDATE pet_product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}这段 SQL 之所以能防止超卖,是因为 UPDATE 语句会锁定命中的行,并且stock >= #{quantity}条件保证库存不足时影响行数为 0。判断影响行数,为 0 就直接抛出业务异常触发回滚。这个方案在大多数电商项目里都是够用的,简单可靠,不需要引入 Redis 分布式锁之类的重型方案。
订单号我单独写了一个生成方法:yyyyMMddHHmmss + 用户ID补齐6位 + 4位随机数。插入订单表前调用,如果插入时报唯一索引冲突就重新生成一次,兜底逻辑虽然简单,但很实用。
事务提交之后,理论上数据就一致了。这里还要注意一个问题:订单明细表的商品名称、价格、图片都是从宠物商品表查出来之后写进去的,而不是关联查询存 product_id 就完事。这就是前面说的快照设计,订单永远显示的是下单那一刻的价格和信息。
3.4 统一返回结构和全局异常处理
如果每个接口返回的 JSON 格式都不一样,前端写代码会极其痛苦。我定义一个泛型结果类:
public class Result<T> { private Integer code; // 200成功,401未登录,500业务错误 private String message; private T data; public static <T> Result<T> success(T data) { ... } public static <T> Result<T> error(String message) { ... } }然后写一个@RestControllerAdvice全局异常处理类,捕获自定义的业务异常、参数校验异常、兜底异常,统一转成 Result 结构返回。这样前端 axios 拦截器收到非 200 的 code 就能统一弹提示,不用每个接口单独做异常判断。
全局异常处理后端代码虽然看着不起眼,但实际开发里价值很高。没有它的话,后端一报错前端拿到的是默认的 500 页面或堆栈 JSON,又难看又难解析。加了这个统一出口之后,前端和后端的联调体验都会直线上升。
4. 前端 Vue 实现与接口联调:页面和接口怎么对齐
4.1 路由设计和页面拆分
前端工程用 Vite 创建 Vue 3 项目,UI 组件库用 Element Plus。页面路由我是这样规划的:
/ 首页 /product/list 商品列表(分类筛选) /product/detail 商品详情(带动态路由参数) /login 登录 /register 注册 /cart 购物车 /checkout 结算下单页 /order 订单列表 /order/detail 订单详情 /user/profile 个人中心 /admin/** 后台管理(独立布局)路由守卫这里必须处理。我的做法是在全局前置守卫里判断:如果目标路由的 meta 里配置了 requiresAuth 为 true,就检查 localStorage 里是否有 token,没有就跳转登录页,并带上 redirect 参数,登录成功后自动跳回原页面:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })这里的细节是:不能只在跳转时判断一次,前端路由守卫只是体验优化的一部分,后端拦截器才是真正保障数据安全的那道闸门。前后端双端校验,这是前后端分离项目的基本安全意识。
4.2 Axios 封装和 token 注入
Axios 封装我单独建了 utils/request.js,做了两层拦截器。
请求拦截器在每次请求发出前从 localStorage 拿 token 并注入 header。需要注意的是 token 的 header 名称要前后端统一,我用的是Authorization:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config })响应拦截器做统一的数据解包。后端返回的{ code, message, data }在这里被拆开,业务 code 不为 200 时统一弹错误提示。401 表示登录失效,这里要清掉 localStorage 的 token 并跳转登录页:
service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )这里我碰到一个比较常见的坑:后端拦截器返回 401 时,http 状态码也可能是 401。那么 axios 会走进 error 分支而不是 success 分支,导致业务 code 的判断逻辑拿不到。所以这里两边要约定清楚:要么后端始终返回 http 200 + 业务 code,要么前端在 error 分支也做一次 res.code 判断。我采用的方案是后端拦截器返回 http 200 + code 401,这样前端统一走 success 分支处理业务状态,逻辑简单统一。
4.3 商品列表页的分页、筛选与加载状态
商品列表页是前端交互最复杂的页面之一。左侧分类菜单、顶部搜索框、价格区间、排序方式、右侧商品卡片网格、底部翻页,所有筛选条件变化都要重新请求接口。
我用了组合式 API 的 reactive 对象来管理查询条件:
const queryParams = reactive({ categoryId: null, keyword: '', minPrice: null, maxPrice: null, sort: 'default', pageNum: 1, pageSize: 12 }) const fetchList = async () => { loading.value = true const data = await getProductList(queryParams) productList.value = data.list total.value = data.total loading.value = false }分类点击、价格筛选、翻页操作都只需要修改 queryParams 对应的字段,然后调用 fetchList。唯一要注意的是:切换分类或修改筛选条件时,pageNum 要重置回 1,否则可能会出现"当前在第3页,筛选后只显示1页"的空白列表问题。
商品卡片展示上,我加了一个 sale_count 销量字段,排序方式里提供"销量优先"选项,让商城更有真实感。封面图用了统一比例的占位容器,防止因为图片尺寸不一致导致卡片高度参差。
4.4 购物车选中联动和结算逻辑
购物车页面是全选/单选联动逻辑的典型场景。页面左侧是商品项,每项前面一个复选框,顶栏一个全选复选框,底部固定栏实时显示已选商品数量和合计金额。
核心逻辑用 computed 实现:
const checkedItems = computed(() => { return cartList.value.filter(item => item.checked === 1) }) const totalPrice = computed(() => { return checkedItems.value.reduce((sum, item) => { return sum + item.price * item.quantity }, 0) }) const isAllChecked = computed(() => { return cartList.value.length > 0 && checkedItems.value.length === cartList.value.length })修改单个勾选状态时调用后端接口更新 checked,同时本地也要同步更新数组里的值。全选按钮用 isAllChecked 驱动,切换时把所有商品项的 checked 统一改成 0 或 1,然后一次性调用后端批量更新接口。
这里我踩过的一个坑是:前端不能只靠本地计算就以为后端已经同步了勾选状态。下单接口是根据后端的 checked 状态来统计商品的,前端本地临时改了状态但没调接口,下单时就会对不上。所以勾选状态的每一次变更都要及时同步到后端,前端展示永远以后端接口返回的数据为准。
4.5 商品详情页和宠物档案展示
宠物商品详情页除了基本的价格和库存,我专门展示了宠物档案:性别、年龄、体重、疫苗情况、驱虫记录、健康证书编号。这些字段都在 pet_profile 表里,后端接口把商品信息和档案信息封装在一起返回。
前端详情页的布局比普通商品多了一个"宠物档案"卡片区域,用了描述列表组件逐项展示。这里没有做太复杂的动态表单,因为宠物档案字段相对固定,直接用后端返回的对象渲染即可。
收藏功能我也顺手加上了。favorite 表存 user_id 和 product_id,详情页右上角一个收藏按钮,点击切换收藏状态。这个功能代码量不大,但能让系统的功能更完整,也是一个非常容易加分的小模块。
5. 从源码到线上:本地跑通与服务器部署的完整步骤
5.1 本地开发环境准备
在跑源码之前,先把本地环境确认一遍,这是整个部署流程的基石。我整理了一份最小环境清单:
| 软件 | 版本要求 | 说明 |
|---|---|---|
| JDK | 1.8+ | 我用的是 JDK 8,JDK 11/17 均可 |
| Maven | 3.6+ | 项目依赖管理 |
| Node.js | 16+ | 前端构建需要,推荐 LTS 版本 |
| MySQL | 5.7+ 或 8.0 | 数据库,8.0 需要注意驱动版本 |
| IDEA | 不限版本 | 后端开发IDE,社区版即可 |
| VS Code | 不限版本 | 前端开发编辑器 |
本地跑通这个环节,最容易卡的其实不是代码本身,而是环境版本的兼容。SpringBoot 版本和 JDK 版本要匹配,Node 版本和前端构建工具要匹配。所以最好按源码里的版本说明来装环境,不要盲目用最新版,尤其不要用刚发布没几个月的新版本,版本踩坑的排查成本非常高。
5.2 数据库初始化和后端配置
拿到源码仓库第一件事不是急着启动,而是先建库。项目里会带一个 SQL 脚本,比如 petstore.sql,在 MySQL 里执行:
mysql -u root -p < petstore.sql然后修改后端的配置文件。我习惯把数据库连接写在 application.yml 里,本地开发用 dev 环境:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/petstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=convertToNull username: root password: 你的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver连接串里的三个参数一个都不能少。characterEncoding=utf8保证中文不乱码,serverTimezone=Asia/Shanghai解决日期时间时区报错,zeroDateTimeBehavior=convertToNull防止日期字段出现 0000-00-00 时插入报错。
启动后端之前,先确认数据库服务和端口没问题,然后在 IDEA 里直接运行主类,看到 Spring Boot 启动成功的日志并且端口 8080 没有被占用,后端就起来了。
5.3 后端打包与 jar 运行
本地 IDEA 里跑通没问题后,生产环境要用打包后的 jar 包来运行。在项目根目录执行:
mvn clean package -DskipTeststarget 目录下会生成一个 jar 包,比如 petstore-admin.jar。在服务器上执行:
java -jar petstore-admin.jar --spring.profiles.active=prod这里我给了 prod 环境一个配置文件 application-prod.yml,数据库连接指向服务器本身的 MySQL,端口不变。用 nohup 后台运行更好:
nohup java -jar petstore-admin.jar --spring.profiles.active=prod > app.log 2>&1 &日志输出到 app.log,方便之后排查问题。查看启动日志用tail -f app.log。
jar 包内存配置上,我给了一个保守的参数:-Xms256m -Xmx512m。很多学生服务器的内存只有 1G 或 2G,不限制堆内存的话,MySQL 和 jar 同时跑容易内存不足直接把进程杀掉。
5.4 前端构建与 nginx 部署
前端本地开发跑npm run dev,生产环境需要先构建出静态文件:
npm install npm run build构建完成后,项目根目录下会生成 dist 目录,这个目录就是需要部署到服务器上的静态文件。
服务器上我用 nginx 来托管前端静态文件,并配置反向代理,把/api/前缀的请求转发到后端 jar 包的 8080 端口。nginx 配置的核心部分:
server { listen 80; server_name your_server_ip; root /usr/share/nginx/html; index index.html; # 前端静态页面 location / { 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里面的门道不少。第一,try_files $uri $uri/ /index.html这句必须写,否则 Vue Router 使用 history 模式时,刷新详情页或者直接访问某个路由地址会报 404。这句的作用是:当请求的路由在磁盘上找不到对应文件时,回退到 index.html,由前端路由接管。
第二,location /api/里的proxy_pass http://127.0.0.1:8080/;结尾带了斜杠,这会把请求路径里的/api/前缀去掉再转发。前端请求/api/product/list,后端收到的是/product/list。如果后端接口路径本身就带了/api前缀,那这里就不带斜杠,直接proxy_pass http://127.0.0.1:8080;。
第三,前端 axios 封装的 baseURL 要设置成/api,这样请求走同源代理,不存在跨域问题。用 nginx 代理解决跨域比在 SpringBoot 里写 CORS 配置优雅得多,生产环境推荐这种方式。
5.5 服务器环境安装 checklist
服务器上的软件安装看起来简单,实际跑的时候还是有不少细节。我把完整的部署顺序列出来:
- 安装 JDK:
apt install openjdk-8-jdk或yum install java-1.8.0-openjdk,装完执行java -version确认。 - 安装 MySQL:root 密码设置后别直接用默认密码,先用 ALTER USER 改掉。
- 导入数据库脚本:清空原有的 petstore 库再导入,避免残留数据导致外键报错。
- 上传 jar 包和 dist 目录。
- 启动后端 jar,确认 app.log 里没有异常。
- 安装并配置 nginx,把 dist 目录路径和代理配置写好。
- 启动 nginx,浏览器访问服务器 IP,测试全流程。
这里提醒一个容易忽略的点:云服务器的安全组或防火墙规则里,80 端口必须放行。否则 nginx 明明启动了,浏览器还是访问不了。
6. 部署后真机踩坑记录:跨域、乱码、超卖与图片路径
6.1 跨域问题的完整排查链路
前后端分离项目开发环境跨域是必然遇到的。浏览器直接访问 8080 后端的接口,页面在 5173 端口,两个端口不同源,浏览器就会拦截。
开发环境我建议在后端加一个 CORS 配置类,而不是用前端 proxy 去解决,因为前端 proxy 只对开发环境有效,而且配置方式不够直观。后端配置方式:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这里有个很多人踩过的坑:allowCredentials(true)的时候,allowedOrigins不能直接用"*",否则请求会被浏览器拒绝。SpringBoot 的解决办法是用allowedOriginPatterns("*"),这样才能和 allowCredentials 兼容。
另外,前端的 axios 请求里带了自定义 headerAuthorization,所以在 allowedHeaders 里必须放行,否则浏览器会认为这是一个非简单请求被预检拦截。
生产环境有了 nginx 同源代理,其实就不需要 CORS 配置了,这也是我前面说的部署最优解。如果生产环境还开 CORS,反而引入安全问题,没有必要。
6.2 中文乱码:从页面到数据库的字符集链路
中文乱码这个问题看似基础,但排查链路其实很长,我遇到过很多次。现象是:前端表单填中文,提交后数据库里变成问号"???"。
排查顺序是这样的:
- 先确认数据库连接串里带了 characterEncoding=utf8。没有它,Java 程序到 MySQL 的传输过程中就可能丢失字符集信息。
- 确认数据库和表字符集是 utf8mb4。用 SHOW CREATE TABLE 查看表定义,不是的话直接改:
ALTER TABLE pet_product CONVERT TO CHARACTER SET utf8mb4。 - 确认后端接收请求时设置了 UTF-8 编码。SpringBoot 里可以用 CharacterEncodingFilter 显式设置,或者确认 server.servlet.encoding 配置。
- 确认前端页面编码是 UTF-8。Vue 项目默认如此,除非用了非标准的模板改过 index.html 的 meta。
排查完就会发现,99% 的乱码问题都出在连接串或者数据库表字符集这两个环节。字符集这条链路任何一环断裂,中文数据都会出问题,这个排查顺序建议保存,以后遇到乱码直接按这个链路走。
6.3 库存超卖问题的兜底验证
我在 3.3 节写了条件更新扣库存的方案,这里补充一下压测验证方法。启动两个线程同时购买同一件库存只有 1 的商品,可以写成一个小测试:
@Test public void testConcurrentOrder() throws InterruptedException { int threadCount = 2; CountDownLatch latch = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { new Thread(() -> { try { orderService.createOrder(userId, cartIds); } catch (Exception e) { System.out.println("下单失败:" + e.getMessage()); } finally { latch.countDown(); } }).start(); } latch.await(); // 验证库存为 0,且订单只有 1 条 }跑完检查数据库:pet_product 表库存应该为 0,orders 表应该有且仅有一条订单记录。我在实际测试中第一次跑就发现库存被扣到 -1,正是因为没有加条件更新,用的还是"先查再减"。换成条件更新后再次验证,数据就正确了。
如果项目规模再大一些,比如库存量很大且并发非常高,条件更新也可能因为行锁竞争影响性能。更进一步的方案是给表加一个 version 字段做乐观锁,或者用 Redis 预扣库存。但对宠物店这种业务场景来说,条件更新已经足够,重点是事务边界要清晰,不要搞半天事务都不知道提交点在哪。
6.4 图片上传与回显路径问题
商品图片的上传和回显,在本地开发和服务器部署环境下处理方式不太一样。
我在后端做的是一个简单的本地上传接口:前端把图片文件 POST 到/api/upload,后端保存到服务器某个目录,返回一个访问 URL。本地开发时,图片保存路径设置为D:/petstore/upload/这种,回显用 SpringBoot 静态资源映射:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:D:/petstore/upload/"); } }这样前端<img src="/upload/xxx.jpg">就能直接访问到磁盘文件。
部署上线时,路径要改成服务器上的绝对路径,比如/home/petstore/upload/。这里务必定一个全局配置项,不要写死在代码里,不然每次换环境都要改代码重新打包。nginx 方面直接把/upload/前缀的请求映射到本地目录也行,不过我用 SpringBoot 映射其实已经够了。
图片上传还有一个体验问题:商品图片如果特别大,页面加载会很久,所以上传接口最好做一下大小限制,比如 SpringBoot 的spring.servlet.multipart.max-file-size=5MB,并且前端在上传前做一个简单的格式和大小校验,能省掉很多后端日志排查时间。
7. 项目二次开发方向:这套源码还能怎么扩展
宠物店系统跑通之后,如果时间充裕,可以从几个方向继续扩展,让项目亮点更突出。
第一个方向是支付回调的完整模拟。目前订单里的支付状态是手动点击"模拟支付",可以进一步对接沙箱支付接口,体验真实支付回调流程,理解异步通知和本地校验的机制。
第二个方向是后台数据统计。宠物店后台可以加一个简单仪表盘:今日订单数、销售额趋势、宠物品类销量排行。SQL 只需要 group by 和统计函数,数据可视化可以用 ECharts,前端展示效果会比较显著。
第三个方向是缓存优化。商品列表和详情页是读多写少的场景,可以引入 Redis 缓存热门商品和分类信息,降低数据库压力。这是面试时很好的一个优化点,也是一般项目表达"我有性能优化意识"的加分项。
第四个方向是搜索增强。目前的关键字搜索走的是 LIKE 模糊查询,数据量小没问题。如果商品数量层级上来了,可以接入全文检索方案如 Elasticsearch 或者用 MySQL 全文索引,体验会有明显提升。
如果你是想做毕设的在校学生,我建议不要只满足于把功能跑通,选其中一个方向做深,能显著提升项目的完成度和答辩时的亮点。
8. 最后分享几个实用小技巧
根据我多次搭建这个系统、带项目跑通的经验,最后补充几个大家实际动手时容易忽略的小技巧。
第一,建表脚本一定要带好测试数据。系统刚启动时,如果数据库是空的,首页一片空白,体验感会很差。我在 SQL 脚本里预置了十几个分类、几十条宠物商品数据、一个测试账号,登录之后马上能看到完整的商城效果,排查问题也更方便。
第二,后端接口日志必须打好。我在每个 Controller 的入口打印了请求路径、参数、耗时,出现问题时日志能直接帮你定位是接口问题还是前端问题。裸跑后端不打印日志,排查问题靠猜,会非常浪费时间。
第三,前端请求统一走封装的 axios 实例,禁止每个页面单独引入 axios。这个约束能让拦截器、baseURL、错误处理逻辑都收敛到一处,改一处全局生效,维护成本低很多。
第四,数据库连接密码一定不要写死在代码里。本地环境用配置中心占位符或者环境变量注入,哪怕只是简单地把密码放到 application-prod.yml 并限定权限,也能避免密码跟着源码到处泄露。这个习惯越早养成越好。
第五,域名访问比 IP 访问体验好很多。如果条件允许,给服务器配一个域名并且做好 HTTPS 证书,不仅地址栏更好看,也避免了一些浏览器对非 HTTPS 环境 API 请求的限制。
这个宠物店系统我自己从零搭过几遍,每次重搭都还能发现一些可以优化的细节。电商类项目之所以适合作为前后端分离的综合实战,是因为它足够"真实",有用户、有商品、有购物车、有订单,每一个模块都是实际业务中必然会遇到的问题。只要把数据表关系和核心事务链路想明白,整个项目其实就没有真正的难点,剩下的工作就是按部就班把功能逐个实现而已。