☰
SpringBoot+Vue3+MyBatis+MySQL打造网上宠物店电商系统全栈实战
2026/10/13 7:23:50 网站建设 项目流程

做网上宠物店这种项目,技术方案多到让人眼花缭乱,但如果只挑一套能快速跑起来、结构又清楚到能当教学范例的组合,SpringBoot + Vue3 + MyBatis + MySQL 绝对是我这几年做下来最顺手的搭配之一。这个系统解决的需求很实在:用户注册登录、浏览宠物和宠物用品、加购物车、下单购买,管理员在后台维护商品上架、库存、订单状态。就这么一套闭环,几乎覆盖了电商系统最小可用版本的全部核心流程。适合正在学后端准备做项目练手的人,适合毕业设计选这个方向的学生,也适合想搞清楚前后端分离到底怎么落地、前后端怎么联调、数据库表怎么设计的开发者。

如果你之前只写过单体服务,或者前端后端混在一个工程里,那这套源码的拆解方式尤其值得看。因为前后端分离带来的是两个独立工程,后端只提供接口,前端只负责渲染页面,中间通过 JSON 沟通。这种模式下你被迫去关注接口设计、数据格式约定、跨域、Token 鉴权这些真正在实际工作中躲不掉的问题。这篇文章我就把这个系统的整体设计、表结构、核心接口、前端配合方案、部署跑通流程和踩过的坑按顺序拆开讲,尽量让每个环节都能直接对照着落地。

1. 项目整体设计与技术选型:为什么偏偏是这套组合

1.1 技术栈选择的真实考量

先说后端。SpringBoot 几乎是当前 Java 后端默认起点,它最大价值是帮你把配置地狱收拾干净。以前用 SSM(Spring + SpringMVC + MyBatis)搭建一个 Web 项目,要写一堆 XML 配置,配数据源、配事务、配扫描包,新手很容易在配置阶段就放弃。SpringBoot 默认自动配置、内嵌 Tomcat,一个spring-boot-starter-web拉进来,写个 Controller 就能直接跑,这种体验上的收益无可替代。

Vue3 那边,组合式 API(Composition API)确实比 Vue2 的选项式更贴合复杂业务逻辑的组织:一个购物车模块的响应式状态、计算属性、方法可以放到同一个setup作用域里,代码内聚性更高。配合 Vite 开发服务器,热更新速度比我之前用 Webpack 那套快太多了,改个组件样式几乎是瞬间生效,这对开发体验的提升非常明显。

MyBatis 和 JPA(Spring Data JPA)之间,我在这类电商项目中更倾向 MyBatis。原因是订单、商品这类查询往往伴随多表联查,比如“查订单同时带出用户昵称、宠物名和分类名”,MyBatis 里写 SQL 可以直接控制 JOIN、子查询、动态条件,SQL 优化起来心里有底。JPA 虽然开发速度快,但遇到复杂查询时要么写 JPQL 要么用原生 SQL,总感觉很隔一层。电商系统的很多瓶颈最终都落在 SQL 上,MyBatis 让你保持对 SQL 完全掌控,这一点在排查慢查询时特别有价值。

MySQL 则是这个量级项目的标准答案,数据量不大、并发不极端、部署成本低,社区资料多到任何报错都能搜到解法。如果你担心 MySQL 性能不够,其实这个系统现阶段根本到不了数据库瓶颈,真正值得关注的是代码里有没有 N+1 查询、索引建得合不合理。

1.2 前后端分离架构的设计快照

前后端分离的核心是“职责分离 + 接口契约”。前端工程负责页面交互和状态管理,后端工程负责业务逻辑和数据持久化,两者只通过 RESTful 接口交换数据。具体到这个项目里,前端跑在 Vite 的 5173 端口,后端跑在 SpringBoot 的 8080 端口,开发阶段靠 Vite 代理转发请求解决跨域,生产环境下前端打包出静态文件交给 Nginx 托管,后端只作为 API 服务运行。

这种架构下,接口设计就变成了最重要的约定。我见过很多失败的前后端协作项目,问题不在技术,而在接口格式不统一。所以这个系统的所有接口都约定一个统一的返回结构:状态码、消息、数据体。前端拿到响应后先看状态码,再用数据体渲染页面,这套约定让前后端不需要互相追问“你这个接口到底返回了什么”。下面这就是后端统一返回结构的核心类,整个系统的接口基座就是它:

public class Result<T> { private Integer code; // 200 成功,400 参数错误,401 未登录,500 服务器错误 private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }

为什么要把状态码设计成数字而不是直接用true/false?因为 boolean 只能表达成功和失败两种状态,而真实接口会遇到参数校验失败、未登录、无权限、服务器异常等多种情况,数字状态码可以区分错误类型,前端也能据此做不同的提示和跳转逻辑。

2. 后端核心模块拆解与实现要点

2.1 用户认证与权限控制的实现思路

用户模块是所有电商系统的地基,没有用户,购物车和订单无从谈起。这个项目使用 JWT(JSON Web Token)做无状态认证。流程是这样的:用户提交手机号和密码,后端校验通过后生成一个 Token 返回给前端,前端把 Token 存在本地,之后每次请求在请求头里带上 Token,后端通过拦截器解析 Token 识别用户身份。

为什么用 JWT 而不是 Session?因为 JWT 天然适合前后端分离和后续横向扩展。Session 依赖服务器内存保存登录状态,如果以后部署多台后端机器就得引入 Redis 共享 Session,会增加复杂度。JWT 把用户信息编码进 Token 本身,后端只需要验签就能确认身份,不需要额外存储,无状态就意味着任意一台后端实例都能处理任意请求。

密码存储环节,必须要强调一点:绝对不能明文存库。这个系统的方案是对密码加盐后做哈希,盐是一串随机字符串,每个用户的盐都不一样,即使两个用户密码相同,存储的哈希值也完全不同。如果使用BCryptPasswordEncoder,每次加密都会自动生成随机盐,校验时也只要调用一个matches方法,非常省心。之前我在自己项目里第一次用 MD5 直接加密存储,后来发现撞库风险巨大,立刻改成了 BCrypt,这个教训希望你别再踩一遍。

权限控制上,我用拦截器实现了基础的双角色模型:普通用户和管理员。注册的账号默认为普通用户,管理员由数据库手工标记。拦截器在白名单之外的请求中解析 Token,拿到用户角色,如果访问的是管理员接口而角色不是管理员,直接返回 403。这种设计足够覆盖宠物店的全部业务需求,比引入 Spring Security 全家桶轻量得多,也更方便源码阅读者理解整个鉴权流程。

2.2 商品、购物车与订单的闭环设计

宠物店的核心商品有两种:活体宠物和宠物用品。活体宠物有品种、年龄、性别、健康状态这些字段,普通商品则是名称、规格、库存。在设计上,我把它们统一放到商品表,通过分类字段区分,这样购物车、订单模块不需要区分商品类型,逻辑统一很多。

购物车的实现比想象中简单:一张购物车表,字段包括用户 ID、商品 ID、数量、选中状态,加入购物车就是执行一次插入或更新数量的操作。真正容易出错的是订单模块的状态流转。这个系统定义了五个订单状态:待支付、待发货、待收货、已完成、已取消。前端根据状态码渲染不同的操作按钮,后端在接口中通过状态机的思想限制跳转,不允许从“待支付”直接跳到“已完成”,必须走完整个流程。

订单和购物车的联动是这个模块的关键难点。下单动作必须同时做三件事:校验购物车中的商品是否还在售、创建订单主表记录并计算总金额、生成订单明细表快照每个商品的信息。这三个操作必须在同一个数据库事务里完成,任何一个失败都要回滚,否则会出现“订单创建成功但商品明细丢失”或“库存扣了但订单不存在”的严重数据不一致问题。后面我会详细展开事务和锁的处理。

2.3 MyBatis 使用中的关键细节

MyBatis 是这个系统数据访问层的核心,用得好不好,直接决定开发效率和系统稳定性。先说一个高频踩坑点:#{}和${}的区别。#{}是预编译参数占位符,MyBatis 会把它编译成?再传值,能够有效避免 SQL 注入;${}是字符串拼接,直接把值拼进 SQL 语句,虽然某些动态表名、排序字段的场景只能用${},但只要能不用就绝对不要用。凡是用户输入的值,一律走#{}。

动态 SQL 是这个系统里非常常用的功能。比如后台商品列表支持按分类筛选、按价格区间筛选、按上架状态筛选,如果为每一种组合写一条 SQL,代码会膨胀到无法维护。MyBatis 的<where>加<if>标签让这些条件可以自由拼装:

<select id="selectProductList" resultType="Product"> select * from product <where> <if test="categoryId != null"> and category_id = #{categoryId} </if> <if test="minPrice != null"> and price &gt;= #{minPrice} </if> <if test="maxPrice != null"> and price &lt;= #{maxPrice} </if> <if test="status != null"> and status = #{status} </if> </where> order by create_time desc </select>

还有 Mapper 接口扫描的问题。SpringBoot 启动类上一定要加@MapperScan注解,用来告诉 Spring 去哪里找 Mapper 接口。如果你用的是 XML 方式写 SQL,还要在application.yml里配置mybatis.mapper-locations,把它指向classpath:mapper/*.xml。我见过不止一个同事在这些小配置上卡了一个下午,最后发现只是 mapper-locations 路径写错了。

3. 前端 Vue3 的实现与联调细节

3.1 搭前端工程与代码组织

前端工程用 Vite 创建,Node 环境要求 16 以上。整个工程目录按业务模块划分,而不是按文件类型堆在一起,这个组织习惯对维护性影响很大。我的做法是:每个一级目录对应一个业务域,比如api目录放接口请求函数,router目录放路由配置,store目录放全局状态,views目录放页面级组件,components目录放通用子组件。这样新人接手时能根据“我要改购物车页面”快速定位到views/cart目录,而不是在一堆命名相似的 Vue 文件里像捉迷藏一样找。

组件逻辑编写统一用组合式 API。比如购物车页面,把获取列表、修改数量、删除商品、计算总价这些逻辑全部写在setup里,用ref管理商品列表数据,用computed计算总价,逻辑关系一眼能看懂。状态管理选择 Pinia,比 Vuex 更简洁,TypeScript 支持也更友好。全局只需要管理两个核心状态:用户登录信息和购物车商品数量。购物车数量在用户每次添加商品后更新,导航栏的角标可以实时响应。

3.2 Axios 封装与接口联调经验

前端请求库选择 Axios,主要因为拦截器机制非常好用。我在封装时做两层拦截:请求拦截器负责在每次请求的请求头中自动加入 Token,响应拦截器负责统一处理后端返回的Result结构。

// request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', // 开发环境由 Vite 代理转发 timeout: 10000 }) // 请求拦截器:附加 Token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config }) // 响应拦截器:统一处理返回结构 request.interceptors.response.use( response => { const res = response.data if (res.code === 401) { // 登录过期,清除本地状态,跳转登录页 localStorage.removeItem('token') router.push('/login') ElMessage.warning('登录已过期,请重新登录') return Promise.reject(new Error('unauthorized')) } if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message || 'Error')) } return res.data }, error => { ElMessage.error(error.response?.data?.message || '网络异常,请稍后重试') return Promise.reject(error) } ) export default request

开发阶段最大的坑是跨域。前端跑在 5173,后端在 8080,浏览器会拦截跨域请求。我推荐的解决方式是在 Vite 配置文件里配置代理,让前端把/api开头的请求转发到后端,这样浏览器看到的始终是同源请求,不需要后端开启 CORS 放开所有域名,生产环境也更安全。

// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } } })

注意代理配置里的rewrite很关键。如果后端接口路径是/user/login,而前端请求路径带/api前缀,那么代理转发时必须把前缀剥掉,否则后端接到的请求路径会变成/api/user/login,404 没跑。

3.3 路由守卫与状态管理的实际用法

前端路由需要和后端权限配合起来。这个项目在根路由上配置一个前置守卫:每次路由跳转前检查用户是否已登录以及目标页面是否需要登录权限。如果用户直接访问购物车页面但没有 Token,就强制重定向到登录页;如果已经登录,就不允许再访问登录页,避免“已经登录了还看到登录表单”这种不合理的交互。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (to.path === '/login' && token) { next({ path: '/' }) return } next() })

登录之后拿到的用户信息里包含角色字段,前端在路由 meta 中标记管理页面的访问权限。在渲染侧边栏和菜单时,管理员和普通用户能看到不同的菜单项。不过这里要强调一点:前端权限控制只是提升用户体验的手段,真正的安全防线在后端,因为接口是可以被直接调用的。前端隐藏了某个按钮不代表接口就安全了,后端必须对每个管理接口做权限校验,这条原则一定要刻在脑子里。

4. 数据库设计、事务与安全防线

4.1 表结构设计与字段规划

数据库设计是整个系统最见功力的一环。这个系统我规划了六张核心表:用户表、分类表、商品表、购物车表、订单表、订单明细表。每张表的字段设计都有实际业务考量。

以商品表为例,除了商品名、价格、库存、图片这些常规字段,我额外加了状态字段区分上架和下架,加了销量字段用于排序推荐,加了创建时间和更新时间用于后台列表排序。价格字段我用的是decimal(10,2),为什么不用 float?因为 float 是近似存储,计算金额会产生精度误差,这是电商系统的大忌,比如 0.1 加 0.2 在浮点数环境下可能得到 0.30000000000000004。decimal 是精确数值,金额计算用它才能保证一分不差。

订单表的设计是另一个容易踩坑的地方。我的订单表字段包括:订单号、用户 ID、订单总金额、订单状态、收货人信息、下单时间、支付时间、发货时间。注意订单号不能依赖数据库自增 ID,因为自增 ID 在业务上太容易暴露订单数量信息,而且后续做分库分表时会冲突。我的做法是用时间戳加随机数拼接生成一个业务订单号,比如yyyyMMddHHmmss再加四位随机数,保证并发下不重复。

订单明细表是很多新手会忽略的表。它的核心作用是记录“下单那一刻”的商品快照:商品名称、价格、数量、图片等。为什么不能直接关联商品表取价格?因为商品价格会变,如果用户下单时商品是 100 元,后来涨到 120 元,订单明细关联商品表就只能取到 120 元,那就出大问题了。正确做法是下单时把价格和商品名称原样复制进订单明细表,之后商品表怎么改,都不影响历史订单的准确性。

4.2 事务与并发控制,这几点很容易被忽略

订单模块有个经典问题:超卖。假设商品库存只有 1 件,两个用户同时下单,如果代码先查库存再判断再扣减,那么两个请求都可能查到库存为 1,都判断“有货”,然后都扣减成功,结果卖出了 2 件。

解决方式有很多,我在这个系统里用的是乐观锁思路:直接在扣减库存的 SQL 上加库存充足条件,让数据库帮我们保证原子性。

update product set stock = stock - 1 where id = #{productId} and stock >= 1

这条 SQL 利用数据库行锁机制,如果库存不足,更新影响行数为 0,我们据此判断库存不足并提示用户。关键是不要在 Java 代码里先查库存再更新,因为“先查后改”这两个操作之间存在时间窗口。整个下单流程必须放在一个事务里,方法是给 Service 方法加上@Transactional注解。

事务是这里必须强调的重点:扣减库存、创建订单主表、生成订单明细这三件事要么全成功,要么全失败。假如没有事务,扣库存成功了但订单创建失败,商品少了一件但用户没买到,账就乱了。@Transactional的默认传播行为是 REQUIRED,也就是当前方法内所有数据库操作都纳入同一个事务,任何一步抛出 RuntimeException 都会触发整体回滚。

事务还有一个细节需要注意:@Transactional默认只在抛出 RuntimeException 时回滚,如果抛出的是受检异常(Checked Exception),默认是不回滚的。我习惯在注解上显式声明rollbackFor = Exception.class,确保任何异常都会触发回滚,这个小细节能避免很多线上事故。

4.3 常见安全风险与防护手段

宠物店系统面对的威胁包括但不限于 SQL 注入、XSS 跨站脚本、越权访问。逐一防护并不复杂,但每一项都不能漏。

SQL 注入的防护在 MyBatis 中基本靠#{}就能解决。前面已经强调过,唯一例外是动态表名、排序字段等场景,但这时候必须自己确保传入值在白名单内。用户登录接口是攻击者重点关注的目标,如果有人构造一个特殊的账号尝试万能密码,参数化查询会让这招彻底失效。

XSS 攻击主要是防止用户在富文本编辑器中写入恶意脚本。这个系统的后台商品描述用的是普通文本域,不开放富文本,这本身就少了一类风险。如果后续要接入富文本编辑器,建议对内容做白名单过滤,只保留p、img、span等安全标签,同时使用第三方库转义处理后再输出到前端。

越权访问是前后端分离时代特别容易犯的错误。防御原则是:所有操作资源必须校验资源归属。比如用户修改订单接口,先判断订单的用户 ID 是否等于当前登录用户的 ID,不相等直接返回 403。只看 Token 能不能解析是不够的,权限控制必须落到每个资源操作的细粒度上。

5. 本地跑通流程与高频问题排查

5.1 从拉代码到启动的完整步骤

拿到源码之后怎么跑起来?很多人在这个环节就被卡住,因为环境问题五花八门。我按顺序说一下我的标准流程,照着做基本一次通过。

第一步准备环境。后端需要 JDK 8 及以上(建议直接用 JDK 17)、Maven 3.6 以上;前端需要 Node 16 以上(建议 18 LTS);数据库用 MySQL 5.7 或 8.0,本地要安装好并记住 root 账号密码。

第二步初始化数据库。在 MySQL 中创建一个新数据库,比如pet_shop,字符集选 utf8mb4,因为 utf8mb4 支持完整的 Unicode 字符(包括 emoji 和生僻字),然后导入项目里附带的schema.sql和data.sql。schema.sql是建表语句,data.sql是初始化数据,包括分类数据、管理员账号和示例商品。

第三步改配置。在 SpringBoot 的application.yml里修改数据库连接信息:

spring: datasource: url: jdbc:mysql://localhost:3306/pet_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true

这里要特别提醒:serverTimezone参数不写的话,MySQL 8.0 连接时可能报时区错误;map-underscore-to-camel-case配置成 true 之后,数据库里的user_name字段就能自动映射到 Java 的userName属性,不用为每个字段写 ResultMap,这是必须开启的开关。

第四步启动后端。在项目根目录执行mvn spring-boot:run,看到日志输出Started Application in xxx seconds就说明成功了。也可以用 IDEA 打开工程直接运行主类,效果一样。

第五步启动前端。进到frontend目录,依次执行npm install安装依赖(国内网络建议配镜像源),然后npm run dev,看到 Vite 输出的本地地址http://localhost:5173就成功了。浏览器访问这个地址,应该能看到系统首页。

如果你是第一次跑这套流程,npm install是最可能出错的一步,常见报错是依赖版本冲突或者 node-sass 安装失败。解决办法是删掉node_modules目录和package-lock.json,重新执行安装命令。如果是权限问题,用管理员权限重新打开终端再试。

5.2 高频问题与大坑实录

我整理了实际开发中几个最常遇到的问题,做成一个速查表,方便你对号入座。

问题现象根本原因解决方法
前端请求报405 Method Not Allowed请求方法和后端接口定义不一致,比如后端用 POST 定义但前端发的是 GET检查 Controller 的@PostMapping/@GetMapping注解和前端请求方法是否一致
请求直接报跨域错误没有走 Vite 代理,浏览器直接请求了 8080 端口确认所有请求路径带/api前缀,检查代理配置
登录接口返回401但浏览器控制台看不到请求Token 过期或无效,被拦截器提前拦下重新登录拿新 Token,检查本地 Token 是否被清理
新增商品后列表不显示商品状态默认是下架状态,前端默认只查上架商品在后台把商品状态改为上架,或者检查接口条件
数据库查询时报Unknown column 'xxx' in field listJava 实体字段和下划线字段映射没开确认map-underscore-to-camel-case已配置为 true
打包部署后接口 404前端刷新直接访问路由,Nginx 找不到对应的静态页面Nginx 配置try_files指令,将所有非静态请求回退到index.html

还有一个不太容易排查的坑:事务不生效。有些同学给 Service 方法加了@Transactional,但发现异常了数据还是写进去了。最常见原因是方法被同类内部调用,比如类 A 的方法甲调用方法乙,方法乙上面虽然有@Transactional,但事务不会开启。原因在于 Spring 的事务是基于代理实现的,同类内部调用绕过了代理。解决办法是把需要事务的方法拆到不同 Service 类里,或者通过注入自身代理的方式调用。

再说一个并发下单时容易踩的坑。如果你在扣库存前执行Thread.sleep模拟延迟,或者在事务里调用远程接口,会大大拉长事务时间。长事务会让数据库连接长时间被占用,并发一高就会出现连接池耗尽,这是极其危险的反模式。事务里只做必要的数据库操作,任何外部调用、耗时操作都要挪到事务外。


这个系统现在跑通之后,我自己的感受是它给二次开发留了不错的地基。比如要接入第三方支付,只需要在订单支付接口预留的pay_type和pay_time字段上扩展;要做商品搜索,可以在 MySQL 基础上加个全文索引先顶着,后续数据量大了再考虑专门的搜索中间件。踩过几次坑之后我越来越觉得,一个干净的项目不在于功能多炫,而在于边界清晰、事务可靠、前后端各司其职。后续你在扩展的时候,优先做订单超时自动取消和库存预警这两个功能,它们对真实运营的价值最直接,也最能检验你对这套代码的掌握程度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询