做这套网上宠物店系统,起因其实挺简单——有位做校园实训项目的朋友问我要一套完整的前后端分离案例,既要有电商的业务逻辑,又要覆盖主流技术栈。想着与其临时拼凑,不如把平时积累的东西整理成一套能跑、可扩展的系统。于是就有了这套基于 Java SpringBoot + Vue3 + MyBatis 的网上宠物店系统源码,前后端完全分离,数据库走 MySQL。
这套系统适合谁?刚学完 SpringBoot 和 Vue 基础知识、想通过完整项目巩固技能的开发者,想快速搭一套可演示、可扩展的电商框架的人,甚至是准备做毕业设计的同学,都能从中找到能直接抄作业的部分。
整套系统的技术底子是:后端 SpringBoot 2.7 + MyBatis 操作 MySQL,前端 Vue3 + Vite + Element Plus 搭建,登录鉴权走 JWT,文件上传对接本地存储。商品(宠物)、购物车、订单、模拟支付、后台管理等电商核心链路都覆盖到了。本文就把从数据库设计到前后端联调、再到部署落地的整个过程完整过一遍,踩过的坑和优化思路也一并整理出来。
1. 项目整体设计与思路拆解
1.1 为什么是 SpringBoot + Vue3 + MyBatis 这套组合
先说选型。Java 生态里做后端,SpringBoot 在中小型项目和教学场景里几乎是最稳妥的选择。它的自动配置让项目启动成本极低,starter 机制按需引入依赖,社区资料也足够厚,遇到问题基本都能搜到答案。搭配 MyBatis 而不是 JPA,是因为电商类系统的 SQL 经常涉及多表联查、动态条件过滤(比如按品种、价格区间筛选宠物),MyBatis 的 XML Mapper 写起来更直观,性能和可控性也更好。
前端选 Vue3 + Vite 而不是 Vue2 + Webpack,核心原因是开发体验。Vue3 的 Composition API 在逻辑复用上比 Options API 舒服太多,<script setup>语法让组件代码更简洁;Vite 的冷启动速度和热更新反馈是质变,试过就回不去。Element Plus 作为组件库,表格、表单、弹窗、分页这些后台管理的高频组件都是现成的,不用自己从零搓轮子。
需要说明的是,这套系统里状态管理用的 Pinia,路由用的 Vue Router 4。这些都属于当前 Vue3 生态的事实标准组合,资料丰富,后期维护不愁。
1.2 网上宠物店系统的核心业务链路
聊完选型,再看业务。网上宠物店和普通电商系统最大的区别在于:它卖的是“活体 + 服务”的混合体。普通电商卖标品,SKU、库存、价格都规整;宠物店卖的除了宠物本身,还有宠物食品、用品、医疗美容预约服务,甚至领养信息。这决定了数据库设计不能照搬纯电商模板。
我在设计这套系统时,把业务拆成三大块:
- 前台用户端:注册登录、浏览宠物、加入购物车、下单、模拟支付、订单跟踪。
- 后台管理端:宠物管理、库存管理、订单处理、用户管理、分类管理。
- 通用能力层:JWT 登录鉴权、文件上传、全局异常处理、统一返回结构。
其中最容易忽略的,是宠物和普通商品在库存处理上的差异。普通商品下单扣减库存没问题,但活体宠物往往涉及“预订-保留-成交”的状态流转。所以我给宠物表专门设计了 status 字段(待售 / 已预订 / 已售出),而不是简单地用库存数字表达。这个细节看似小,实际上决定了订单流程怎么写。
1.3 工程目录结构规划
工程组织上,我选了单模块 SpringBoot 工程 + 独立前端工程,没有搞多模块。理由很朴素:这个体量的项目单模块足够清晰,多模块反而会让导入工程的人被依赖关系搞晕。后端包结构按业务分层:
com.petshop ├── controller // 控制层,接收请求返回结果 ├── service // 业务层,核心逻辑都在这 ├── mapper // MyBatis Mapper 接口 ├── entity // 数据库实体类 ├── dto // 入参数据传输对象 ├── vo // 出参视图对象 ├── config // 配置类(跨域、拦截器、上传等) ├── common // 统一返回、异常定义、常量 └── utils // 工具类(JWT 等)前端工程是标准 Vite 结构,src/api统一放接口请求,src/views按页面分目录,src/router管路由,src/store放 Pinia。这种“前后端各管各的、通过接口协议对接”的方式,就是前后端分离的核心玩法。
2. 数据库设计与核心表结构
2.1 数据库选型与设计原则
存储这块没有悬念,直接用 MySQL。MySQL 在中小型 Web 应用里性价比最高,部署简单、生态成熟、和 SpringBoot 配合的资料一抓一大把。数据库名我起的是pet_shop,字符集统一utf8mb4,排序规则utf8mb4_general_ci。
为什么用 utf8mb4 而不是 utf8?因为 utf8mb4 才能存下 emoji 和生僻字。宠物店系统里用户昵称、宠物名字都可能带表情符号(比如“🐱 小橘”),用 utf8 插入会直接报错。这个坑我实际踩过,后面会展开说。
2.2 主要表结构清单
整个系统一共设计了 8 张核心表:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| user | 用户表 | id, username, password, phone, avatar, role |
| pet_category | 宠物分类表 | id, name, parent_id, sort |
| pet | 宠物商品表 | id, name, category_id, price, stock, status, image, description |
| cart | 购物车表 | id, user_id, pet_id, quantity |
| pet_order | 订单表 | id, order_no, user_id, total_amount, status, pay_time |
| order_item | 订单明细表 | id, order_id, pet_id, pet_name, price, quantity |
| address | 收货地址表 | id, user_id, receiver, phone, detail, is_default |
| banner | 轮播图表 | id, image, url, sort, status |
这里重点说说 order_item 的设计。为什么要冗余一份 pet_name 和 price,而不是只存 pet_id?因为商品名称和价格会变,如果只存外键,将来商品改名或调价,历史订单显示就会出错。电商系统里这种“快照冗余”是基本操作,宁可多占一点存储,也要保证订单历史的不可变性。做开发的人可能觉得违反了“表结构规范化”的原则,但实际业务场景里,规范化过度反而会带来麻烦。
2.3 订单号生成策略
订单号这块,我用了一个比较朴素的做法:时间戳 + 用户ID后四位 + 随机数,拼成一个 20 位左右的字符串。比如2025060714283500128361。虽然分布式的环境下这种方案有碰撞概率,但在单机应用里完全够用。更重要的是,这个订单号在日志里能直接看出下单时间,排查问题的时候非常方便。
如果想更严谨一些,可以引入雪花算法或者 Redis 自增序列,但那是进入微服务阶段才需要考虑的事。对小体量系统来说,简单方案带来的可维护性反而是最大的优势。
3. 后端 SpringBoot 核心实现
3.1 工程初始化与依赖配置
创建一个 SpringBoot 2.7.x 项目,pom.xml 里核心依赖就这几样:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.19.2</version> </dependency>注意 mybatis-spring-boot-starter 要单独指定版本,因为从 Spring Boot 2.5 开始官方不再统一管理 MyBatis 集成包的版本。这个细节很容易被忽略,直接用 SpringBoot 默认版本管理会导致依赖解析失败。
application.yml 里的核心配置:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/pet_shop?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.petshop.entity configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case: true必须开启,否则数据库里create_time这种下划线字段映射不到实体类的createTime属性上,跑起来到处是 null 值,排查起来非常痛苦。
3.2 JWT 登录鉴权实现
登录鉴权是前后端分离绕不开的一环。选 JWT 而不是 Session,核心在于无状态——服务器不用保存用户会话,天然适合接口型架构,前端拿到 token 后自己保存,请求时带上即可。
实现上分三步:
- 登录接口先校验用户名密码,密码用 BCrypt 加密存储,绝不存明文。
- 校验通过后生成 JWT,把用户 ID、角色等信息放进 payload,过期时间设 7 天。
- 前端把 token 放在请求头的
Authorization字段里,后端用拦截器统一校验,通过后把用户信息放进 ThreadLocal 供业务层取用。
拦截器核心代码:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { throw new RuntimeException("未登录或登录已过期"); } Integer userId = JwtUtil.getUserId(token); UserContext.set(userId); return true; } }在配置类里注册拦截器,并放行登录、注册、商品列表、宠物详情等接口:
registry.addInterceptor(new JwtInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/**", "/api/pet/list", "/api/pet/detail/**", "/api/banner/**");放行哪些接口这块要仔细,漏了任何一个前端都会莫名其妙 401,而放行多了又会暴露需要权限的数据。我习惯的做法是:默认全部拦截,逐个给无需登录的接口加白名单。
3.3 MyBatis Mapper 的写法实践
MyBatis 这块,简单 CRUD 用注解,复杂查询用 XML。这里所谓“简单”,指的是单表、无动态条件的操作;凡是涉及多表联查、动态条件,一律走 XML。
宠物分页列表就是一个典型场景,需要联查分类名称,还要支持关键字、分类、价格区间筛选。动态 SQL 写 XML 里最舒服:
<select id="selectPetPage" resultType="com.petshop.vo.PetVO"> SELECT p.*, c.name AS category_name FROM pet p LEFT JOIN pet_category c ON p.category_id = c.id <where> <if test="keyword != null and keyword != ''"> AND p.name LIKE CONCAT('%', #{keyword}, '%') </if> <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> <if test="minPrice != null"> AND p.price >= #{minPrice} </if> <if test="maxPrice != null"> AND p.price <= #{maxPrice} </if> </where> ORDER BY p.create_time DESC LIMIT #{offset}, #{pageSize} </select>这里有个细节:>和<在 XML 里必须转义成>和<,否则文件解析直接报错。不熟悉的人第一次写很容易被这个卡住。
分页这里我没引入 PageHelper 插件,而是手动算 offset 和 limit。原因很简单:PageHelper 的线程局部变量在自定义多表查询场景下偶尔会出现串页的诡异 bug,排查成本远高于手动分页那两行代码。对于这套系统的数据量,性能差异微乎其微,可控制性却是实打实的。
3.4 下单事务与库存扣减
下单是整套系统里最核心、最容易出问题的环节。整个操作必须是一个原子操作,任何一个步骤失败都要回滚。我把它封装成一个事务方法:
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(CreateOrderDTO dto) { // 1. 校验收货地址 // 2. 遍历购物车明细,计算总金额 // 3. 扣减库存(UPDATE pet SET stock = stock - 1 WHERE id = ? AND stock > 0) // 4. 生成订单主表和订单明细 // 5. 清空购物车 // 6. 返回订单号和待支付金额 }扣库存这里用了乐观锁思路,SQL 里加AND stock > 0条件。如果更新影响行数为 0,说明库存不足,直接抛异常回滚。这种方式比“先查询再判断”更安全,能避免并发场景下的超卖问题。
关于@Transactional有个必须记住的细节:rollbackFor一定要设成Exception.class。Spring 默认只在遇到 RuntimeException 时才回滚,如果你在业务里抛了个自定义受检异常而不指定 rollbackFor,事务会悄悄提交,数据就错了。这个坑我在早期开发时踩过,印象极其深刻。
4. 前端 Vue3 核心实现
4.1 前端工程搭建与路由规划
前端用 Vite 创建 Vue3 工程,过程不复杂:
npm create vite@latest pet-shop-frontend cd pet-shop-frontend npm install npm install vue-router@4 pinia axios element-plus路由规划上,我按“用户端 + 管理端”双布局来实现。用户端路由挂在/下,包括首页、宠物列表、宠物详情、购物车、订单列表、个人中心;管理端路由挂在/admin下,包括仪表盘、宠物管理、订单管理、用户管理、分类管理。
为了让需要登录的页面受保护,写一个全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else { next(); } });对于管理员页面,则在守卫里额外校验角色,不是 admin 就直接跳首页并弹提示。
4.2 API 请求封装与统一响应
axios 封装的核心在拦截器。请求拦截器统一把 token 放进 header,响应拦截器统一处理业务码和 401 跳转:
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 !== 200) { ElMessage.error(res.msg); if (res.code === 401) { router.push('/login'); } return Promise.reject(new Error(res.msg)); } return res; }, error => { ElMessage.error('网络请求失败'); return Promise.reject(error); } );这里有个实际开发中容易踩的坑:拦截器里引用了 router,而 router 文件又依赖各个页面组件,如果不小心形成循环引用,会出现“undefined”的诡异报错。我的做法是把 router 实例放在单独文件导出,拦截器文件里直接 import,就能规避这个问题。
4.3 商品列表页与分类筛选
商品列表页是用户访问最多的页面。我用 el-card 做宠物卡片展示,配 el-pagination 分页,筛选条件用 el-select 和 el-input 组合。搜索逻辑大体是:
const queryParams = reactive({ page: 1, pageSize: 12, keyword: '', categoryId: null, minPrice: null, maxPrice: null }); const fetchPets = async () => { const res = await api.getPetList(queryParams); petList.value = res.data.records; total.value = res.data.total; };这里有个特别容易忽略的细节:搜索条件变化后,页码必须重置为 1。很多新手只改查询参数忘了重置页码,结果在第五页筛选出的结果只有两三条,列表直接显示“暂无数据”,体验很割裂。我的做法是监听筛选字段变化时,先queryParams.page = 1再重新请求。
4.4 购物车与下单流程
购物车状态我放在 Pinia 里管理,主要是为了跨页面共享、避免重复请求。核心 store 大概是:
export const useCartStore = defineStore('cart', { state: () => ({ items: [], totalCount: 0 }), actions: { async fetchCart() { const res = await api.getCart(); this.items = res.data; this.totalCount = this.items.reduce((sum, item) => sum + item.quantity, 0); } } });下单流程是几步串起来的:购物车页勾选商品 -> 点击“去结算” -> 选择收货地址 -> 确认订单 -> 调 createOrder 接口 -> 跳转支付页。支付这里我做的是模拟支付——前端点击“模拟支付成功”按钮,调后端支付接口把订单状态改成已支付,然后跳转订单详情页。
用户端订单列表页还提供了“取消订单”和“确认收货”两个操作,取消订单会走后端逻辑恢复库存。
4.5 图片上传与回显
宠物图片上传用的 Element Plus 的 el-upload 组件,配后端文件上传接口。关键点有两个:action属性指向后端上传接口,同时headers要带上 token:
<el-upload action="/api/upload/image" :headers="uploadHeaders" :on-success="handleUploadSuccess" list-type="picture"> </el-upload>上传成功后后端返回相对路径,前端拼上服务器地址回显。注意生产环境下图片地址要用完整的 URL 拼接,否则在 Nginx 静态资源目录里会找不到图片。
5. 前后端联调与部署实践
5.1 开发环境跨域处理
前后端分离最头疼的是跨域。开发环境下我用 Vite 的 proxy 解决,不让后端额外配置 CORS:
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } });这样前端请求/api/pet/list时,开发服务器会自动转发到后端的http://localhost:8080/api/pet/list,浏览器层面不存在跨域,也就省掉了后端 CORS 配置这一环。
生产环境更简单,后端打成 jar,前端 build 出 dist,放在同一台服务器,用 Nginx 反向代理统一入口。
5.2 生产环境部署步骤
实践下来,一套可以直接照做的部署清单是这样的:
- 后端构建:
mvn clean package -DskipTests,在 target 目录得到 jar 包。 - 后端启动:
nohup java -jar petshop.jar > logs/petshop.log 2>&1 &后台运行。 - 前端构建:
npm run build,生成 dist 目录,上传到服务器/var/www/petshop/dist。 - 配置 Nginx 反向代理,
nginx -s reload生效。 - 检查 MySQL 的
max_allowed_packet,默认 4M 在传大图时可能不够,改成 64M。
Nginx 配置示例:
server { listen 80; server_name pet.example.com; location / { root /var/www/petshop/dist; 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 $uri $uri/ /index.html这一行是必须的。前端路由开启 history 模式后,刷新非首页路径会出现 404,这一步的作用就是让所有未知路径都落到 index.html,由前端路由接管。
5.3 文件上传路径配置
文件上传这块有个特别容易踩的坑:SpringBoot 默认单文件大小限制是 1MB,超过就报 413。在 application.yml 里显式放开:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB另外,上传的文件如果直接存到项目运行目录,重启后容易被 jar 包重新解压覆盖或直接丢失。我建议把上传目录配置成独立路径,比如/data/petshop/upload/,并在 Nginx 里单独映射一个location /upload/指向这个目录,让图片生命周期不依赖应用进程。
6. 常见问题与排查技巧实录
6.1 数据库时间相差 8 小时
这个问题的经典程度不亚于 Hello World。明明连接串加了serverTimezone=Asia/Shanghai,插入数据库的时间还是比本地时间早 8 小时。排查顺序依次是:MySQL 时区配置(show variables like '%time_zone%')、JDBC 连接串、实体类 LocalDateTime 的序列化格式。大多时候是应用服务器 JVM 默认时区不对,可以在启动命令里加-Duser.timezone=Asia/Shanghai解决。
6.2 前端请求 404 但后端接口明明存在
这种情况多半不是接口不存在,而是 Nginx 的location /api/和 proxy_pass 路径拼接出问题。如果 proxy_pass 不带 URI,代理时会保留原始路径,后端 controller 的/api前缀能对上;如果带 URI,比如proxy_pass http://127.0.0.1:8080/;,原始路径的/api前缀就被吞掉了。建议统一写成不带 URI 的形式,配合后端 controller 统一加/api前缀,可以避免这类问题。
6.3 订单超时未支付的补偿方案
电商系统绕不开“超时未支付订单自动取消”这个需求。严格来说,单体应用里用定时任务轮询其实是比较重的方案,更优雅的是引入消息队列的延迟消息。但考虑到这套系统的教学属性和部署成本,我最终选了定时任务 + 状态标记:每隔 5 分钟扫一次订单表,把创建时间超过 30 分钟且状态为“待支付”的订单改为“已取消”,并恢复对应库存。
这里有个容易漏掉的配置:定时任务类要加@Scheduled(cron = "0 */5 * * * ?")注解,并且启动类上必须开启@EnableScheduling,否则任务静默不执行,订单永远不会被取消。这个细节我调试了半天才发现。
6.4 接口文档怎么维护
项目一开始我用手写 Markdown 维护接口文档,接口一多就顾不过来了。后来换成了 SpringDoc 自动生成在线接口文档,controller 上加几个注解,前端同学直接看页面调试,省了大量沟通成本。
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-ui</artifactId> <version>1.7.0</version> </dependency>启动后访问http://localhost:8080/swagger-ui.html即可。唯一要注意的是,生产环境务必关闭文档开关,避免接口信息暴露。
6.5 测试数据初始化
系统跑起来后,最影响体验的就是数据库空荡荡。我在项目里放了一个 data.sql 初始化脚本,包含分类、宠物商品、轮播图、测试账号等基础数据。测试账号约定:管理员admin/admin123,普通用户user/user123。脚本里的插入语句都用INSERT IGNORE包裹,保证重复执行不会产生脏数据。
INSERT IGNORE INTO user (id, username, password, role, phone) VALUES (1, 'admin', '$2a$10$...', 'ADMIN', '13800000000');注意密码字段必须填 BCrypt 加密后的密文,不能直接写明文,否则登录时密码校验永远不通过。
7. 个人实操心得与扩展建议
最后说点实实在在的体会。这套系统从数据库设计到前后端联调完整跑通,我最大的感受是:一个系统能不能真正“落地”,技术的占比其实只有一半,另一半在于对业务细节的理解。活体宠物与普通商品在库存逻辑上的差异、订单明细里的快照冗余、超时未支付的补偿策略,这些都是动手写代码之前就该想清楚的事。
如果你打算在这套系统上继续扩展,我觉得有几个方向很值得投入:
第一,接入真实的第三方支付。把模拟支付换成真实的支付通道,会涉及对称和非对称加密、回调验签、订单幂等处理,这些都是电商领域非常硬核的知识点。
第二,引入 Redis 做热点缓存。宠物列表页的访问量通常最大,用 Redis 缓存分类和商品热点数据,能明显减轻数据库压力。同时可以用 Redis 做更完善的 token 刷新机制。
第三,容器化部署。把 SpringBoot 后端、Vue3 前端、MySQL 分别做成 Docker 镜像,用 Docker Compose 一键编排。这个方案对环境的要求低,迁移也方便,学习成本也不高。
第四,订单状态实时推送。用 WebSocket 在订单状态变化时主动推送给用户,比轮询的体验好很多,Vue3 里接入 socket 客户端也就是几十行代码的事。
根据我个人经验,技术债的积累往往是看不见摸不着的,越到后期越难还。做这类系统时,始终保持“先想清业务,再动手写代码”的习惯,宁可前期多花两天设计,也别后期用两周填坑。这套系统目前跑下来的稳定性我还是比较满意的,后续如果有新的迭代,再单独分享。