做这套网上宠物店系统起因其实挺简单——有位做校园实训项目的朋友问我要一套完整的前后端分离案例既要有电商的业务逻辑又要覆盖主流技术栈。想着与其临时拼凑不如把平时积累的东西整理成一套能跑、可扩展的系统。于是就有了这套基于 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, rolepet_category宠物分类表id, name, parent_id, sortpet宠物商品表id, name, category_id, price, stock, status, image, descriptioncart购物车表id, user_id, pet_id, quantitypet_order订单表id, order_no, user_id, total_amount, status, pay_timeorder_item订单明细表id, order_id, pet_id, pet_name, price, quantityaddress收货地址表id, user_id, receiver, phone, detail, is_defaultbanner轮播图表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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version3.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?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/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 idselectPetPage resultTypecom.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 testkeyword ! null and keyword ! AND p.name LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND p.category_id #{categoryId} /if if testminPrice ! null AND p.price gt; #{minPrice} /if if testmaxPrice ! null AND p.price lt; #{maxPrice} /if /where ORDER BY p.create_time DESC LIMIT #{offset}, #{pageSize} /select这里有个细节和在 XML 里必须转义成gt;和lt;否则文件解析直接报错。不熟悉的人第一次写很容易被这个卡住。分页这里我没引入 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 vitelatest pet-shop-frontend cd pet-shop-frontend npm install npm install vue-router4 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要带上 tokenel-upload action/api/upload/image :headersuploadHeaders :on-successhandleUploadSuccess list-typepicture /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 21 后台运行。前端构建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。明明连接串加了serverTimezoneAsia/Shanghai插入数据库的时间还是比本地时间早 8 小时。排查顺序依次是MySQL 时区配置show variables like %time_zone%、JDBC 连接串、实体类 LocalDateTime 的序列化格式。大多时候是应用服务器 JVM 默认时区不对可以在启动命令里加-Duser.timezoneAsia/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 groupIdorg.springdoc/groupId artifactIdspringdoc-openapi-ui/artifactId version1.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 客户端也就是几十行代码的事。根据我个人经验技术债的积累往往是看不见摸不着的越到后期越难还。做这类系统时始终保持“先想清业务再动手写代码”的习惯宁可前期多花两天设计也别后期用两周填坑。这套系统目前跑下来的稳定性我还是比较满意的后续如果有新的迭代再单独分享。