news 2026/9/9 14:12:36

SSM+Vue宠物店商城系统实战:从数据库设计到部署全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSM+Vue宠物店商城系统实战:从数据库设计到部署全解析

说句实话,接到“宠物店商城管理系统”这个需求时,我第一反应是“又是SSM课设”。但真正把 Vue 前端和 SSM 后端从零搭起来、把订单流程跑通之后,我发现这个看似老套的组合里,藏着不少教科书里不会明说、但实际开发一定会遇到的坑。这篇文章我打算完整拆一遍这个项目:技术选型怎么定、数据库怎么设计、后端事务和并发怎么处理、前端路由和购物车状态怎么管理,最后是打包部署阶段那些容易让人卡到凌晨的报错。不管你是正在做毕业设计,还是想用这套经典前后端分离架构练手,按这条线路走一遍,应该能少走不少弯路。

1. 宠物店商城为什么选SSM + Vue这套组合

1.1 宠物店业务规模与技术栈的现实匹配

很多人选技术栈的时候喜欢追新,看到微服务、云原生就两眼放光。但放到宠物店商城的真实场景里,业务量级决定了技术复杂度:商品SKU(宠物品种、猫粮狗粮、宠物用品)一般就是几百到几千个,日订单量能到几百单已经算生意不错,并发高峰无非是节假日活动那几分钟。这种规模下,SSM这套经典组合完全撑得住,而且团队维护成本低、托管环境要求低,一台2核4G的服务器就能跑得很舒服。

Vue 作为前端层,核心价值在于把页面渲染和接口数据解耦。宠物店的运营方经常要改首页横幅、促销专区、商品展示排序,前后端分离之后,前端迭代完全不需要拖着后端一起发布,改个页面重新打包上传就行。而且同一个后端接口可以同时服务 PC 管理后台、H5 商城甚至以后的小程序,扩展路径很清晰。

1.2 SSM依然能打的三个原因与两个边界

SSM 是 Spring + SpringMVC + MyBatis 的组合,放在今天确实不算新,但它能长期占据教学和中小型项目的主流位置,靠的是三个实打实的优点。

第一,Spring 的 IoC 和 AOP 让事务管理变得非常干净,声明式事务一个@Transactional注解就能搞定,不用手写一堆 JDBC 事务模板代码。第二,SpringMVC 的请求映射和参数绑定很直观,前端传 JSON、后端用@RequestBody接对象,联调的时候心智负担很小。第三,MyBatis 的 SQL 自由度极高,宠物商城这类业务有大量多条件组合查询,比如“按品种+价格区间+年龄筛选”,MyBatis 里写动态 SQL 比 JPA 那种自动生成的查询要直观得多。

但边界也要说清楚。如果你预测峰值并发每秒上千单,或者要做分布式事务、多级缓存、消息队列削峰,那 SSM 确实不合适,这类需求应该直接上 Spring Boot + Spring Cloud 那一套。宠物店商城属于典型的“中小型业务系统”,选 SSM 不是落后,是匹配。

2. 数据库设计与接口契约:先把表和接口定下来再写代码

2.1 六张核心表的设计思路

我这人有个习惯,写代码之前必须先把表结构理清楚,表设计错了,后面所有接口都要返工。宠物店商城我最终沉淀出六张核心表,下面逐个说设计思路。

用户表就不过多展开了,重点提一点:密码字段只存加密后的摘要,长度设为64,给 bcrypt 这类算法留足空间,别用 32 长度的 MD5 字段。商品表我用的是pet而不是product,因为这个系统里核心商品是活体宠物和宠物用品混着卖的,字段设计上要兼容两种形态。我的建议是拆一张主表存公共字段(名称、分类、价格、库存、上下架状态),再扩展一个pet_detail表存活体宠物特有的字段:品种、月龄、性别、是否已驱虫、疫苗状态。

价格字段直接用DECIMAL(10,2)是很多人的默认选择,但电商项目我强烈建议用整数分存储,也就是 BIGINT 类型存“分”。为什么?浮点数在金额计算里会有精度丢失,你算出来 0.1 + 0.2 = 0.30000000000000004,到时候订单金额对不上账就尴尬了。接口传输时你可以转成字符串“19.90”,前端展示没问题,后端计算全是整数。潜在收益是非常大的。

购物车表是典型的多对多关系中间表,要冗余商品当时的单价快照吗?我的答案是购物车表不存快照,只存pet_idquantity,价格一律实时去商品表查。但订单明细表必须存快照,因为下单那一刻的价格和商品信息必须固定下来,以后商家改价不能影响历史订单。

订单表的重点是状态字段。我用 TINYINT 存整数状态码:0 待付款,1 已付款,2 已发货,3 已完成,4 已取消,5 已退款。这里有一个容易忽略的设计:订单号要单独用一个字段order_no,不要用自增主键。自增主键暴露给用户会泄露销量信息,而且并发下订单号不好看。订单号我建议用“时间戳 + 随机数 + 用户ID尾号”拼一段,比如20250115103425123456,保证唯一性同时也方便客服按订单号搜索。

版本号字段(version)我要单独提一下,后面讲库存扣减并发控制会用到,这是避免超卖的关键。

2.2 统一返回体与接口契约

前后端分离项目里,接口返回格式不统一是联调阶段的灾难。我封装了一个Result类:

{ "code": 200, "message": "success", "data": { } }

code 200 表示成功,非 200 表示业务失败,比如 401001 表示未登录,402001 表示库存不足。前端 Axios 响应拦截器统一判断 code,不等于 200 就弹 toast,不需要每个页面重复判断。这个约定在后端是先定下来的,前端再照着写拦截器,能省去大量扯皮的时间。

核心接口列表在动工前要跟同伴或导师过一遍,我整理出这套系统最关键的几个:

模块接口说明
商品GET /api/pet/list分类+关键字+价格区间分页查询
商品GET /api/pet/{id}商品详情,含多图、库存
购物车GET /api/cart/list当前用户购物车列表
购物车POST /api/cart/add加入购物车
购物车PUT /api/cart/updateQuantity修改数量
订单POST /api/order/submit从购物车勾选商品下单
订单GET /api/order/page分页查询我的订单
订单POST /api/order/{orderNo}/pay模拟支付

3. 后端SSM的关键实现:从Spring容器到订单事务

3.1 SSM整合的配置顺序和最容易翻车的三个点

SSM 的整合说白了就是三件事:Spring 容器管 Bean,SpringMVC 管请求分发,MyBatis 管数据库访问。很多人整合不成功,问题几乎都出在配置的加载顺序和扫描范围上。

我用的方案是三个 XML 配置文件(也可以改成配置类,但 XML 结构更直观):applicationContext.xml扫描除@Controller外的所有组件,加载数据源、事务管理器和 MyBatis;spring-mvc.xml只扫描@Controller,开启注解驱动和静态资源放行;mybatis-config.xml放 MyBatis 全局配置。

翻车点一:Mapper 接口扫描不到。在applicationContext.xml里配置MapperScannerConfigurerbasePackage一定要写到 Mapper 接口所在的包,写错一个字母应用启动就报NoSuchBeanDefinitionException

翻车点二:MyBatis 驼峰映射没开。数据库字段user_name映射不到 Java 属性userName,查询结果全是 null。在mybatis-config.xml里开启:

<settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings>

翻车点三:Spring 版本和 JDK 版本不兼容。如果你本地是 JDK 17,但 pom 里引的还是 Spring 4.x,运行时会报一大堆NoClassDefFoundError。SSM 项目我建议直接用 Spring 5.3.x + JDK 8/11,这是实测最稳的组合。JDK 17 虽然也能跑,但需要处理模块化相关的问题,没必要给自己添堵。

3.2 购物车、下单与库存扣减的服务端逻辑

购物车本身逻辑不复杂,加购、修改数量、删除、勾选,都是围绕cart_item表的 CRUD。真正要动脑子的是下单接口,因为一组操作需要同时成功或同时失败:生成订单主表、批量生成订单明细、扣减库存、清空购物车。

我贴一段核心的 Service 层代码,这也是整个后端最关键的部分:

@Transactional(rollbackFor = Exception.class) public OrderVO submitOrder(Long userId, List<CartItemVO> checkedItems) { // 1. 参数校验:购物车为空直接抛业务异常 if (CollectionUtils.isEmpty(checkedItems)) { throw new BusinessException("请先选择要结算的商品"); } // 2. 计算订单金额并生成订单号 Long totalAmount = 0L; String orderNo = generateOrderNo(); // 3. 创建订单主表 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setStatus(0); orderMapper.insert(order); // 4. 创建订单明细并扣减库存 for (CartItemVO item : checkedItems) { Pet pet = petMapper.selectById(item.getPetId()); // 这里不是先查再判断,而是直接用乐观锁扣减 int rows = petMapper.deductStock(item.getPetId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("商品「" + pet.getPetName() + "」库存不足"); } OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setPetId(pet.getId()); orderItem.setPetName(pet.getPetName()); orderItem.setPrice(pet.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItemMapper.insert(orderItem); totalAmount += pet.getPrice() * item.getQuantity(); } // 5. 更新订单总金额 order.setTotalAmount(totalAmount); orderMapper.updateById(order); // 6. 清空购物车中已结算的商品 cartMapper.deleteByUserIdAndPetIds(userId, checkedItems.stream() .map(CartItemVO::getPetId).collect(Collectors.toList())); return OrderVO.from(order); }

这里@Transactional(rollbackFor = Exception.class)一定要写,因为 Spring 默认只对 RuntimeException 回滚。如果你抛的是自定义的检查异常,不加rollbackFor,事务不会回滚,库存扣了但订单没建成,数据就乱了。

3.3 为什么扣库存不能用“先查再改”

很多新手写扣库存是这样的:先select stock查出库存,判断stock >= quantity,再update pet set stock = stock - quantity。这个逻辑在并发下必出问题:两个用户同时查询,都发现库存还有 1,然后都执行扣减,库存就变成了 -1。

正确方案是把判断和扣减合并到一条 SQL 里,利用数据库的行锁和条件更新做原子操作:

UPDATE pet SET stock = stock - #{quantity}, version = version + 1 WHERE pet_id = #{petId} AND stock >= #{quantity}

stock >= #{quantity}这个条件就是“乐观锁”的判断,数据库在更新时会锁定这行记录,第二个请求进来发现条件不满足,影响行数为 0,代码里就能捕获到并提示“库存不足”。MyBatis 的 Mapper 返回int就是更新影响的行数,这个返回值的判断是整个并发控制最关键的一环。

另外补充一点,下单后用户迟迟不付款,订单一直占着库存可不行。我这里加了一个简单的定时任务(Spring 的@Scheduled),每 30 秒扫描一次超过 30 分钟未付款的订单,把状态置为“已取消”,同时把订单明细里的商品库存加回去。生产环境更优雅的方案是用延迟消息队列,但宠物店的项目体量下,定时任务足够。

4. 前端Vue落地:路由、购物车状态与支付衔接

4.1 路由表设计与全局登录守卫

前端我用 Vue 3 + Vue Router 4 + Pinia 这套组合,Vue 2 的用户思路是一样的,只是 API 名字略有不同。路由表我分成两块:前端商城部分和后台管理部分。

const routes = [ { path: '/', name: 'Home', component: () => import('@/views/Home.vue') }, { path: '/pet/:id', name: 'PetDetail', component: () => import('@/views/PetDetail.vue') }, { path: '/cart', name: 'Cart', component: () => import('@/views/Cart.vue') }, { path: '/checkout', name: 'Checkout', component: () => import('@/views/Checkout.vue') }, { path: '/orders', name: 'MyOrders', component: () => import('@/views/MyOrders.vue') }, { path: '/admin', component: () => import('@/layouts/AdminLayout.vue'), meta: { requiresAuth: true, role: 'ADMIN' }, children: [ { path: 'pet', name: 'AdminPetList', component: () => import('@/views/admin/PetList.vue') }, { path: 'order', name: 'AdminOrderList', component: () => import('@/views/admin/OrderList.vue') } ] } ]

全局前置守卫是最容易写错的地方,因为存在“死循环”陷阱。beforeEach里判断requiresAuth时,如果没有 token 要router.push({ name: 'Login' }),这个重定向会再次触发beforeEach,必须加一个条件:登录页本身不需要鉴权,否则守卫无限循环,页面白屏。我踩过一次,最后查是白屏了半天,F12 看到“Maximum call stack size exceeded”才反应过来。

角色权限的判断也在这里做:meta.role是 ADMIN 的页面,如果当前用户不是管理员,直接跳403页面而不是放行。后端接口里同样要校验角色,前端守卫只是用户体验层面的拦截,真正的安全防线在后端。

4.2 购物车的本地展示与服务端校验

购物车这个模块我最开始偷懒,直接在 Pinia 里维护一个cartList数组,增删改全在前端数组里操作,最后下单时才把商品列表传给后端。结果测试阶段就发现一个问题:用户在 A 浏览器加入购物车,在 B 浏览器打开购物车是空的,数据全丢了。

后来我把购物车的数据源改成了服务端,前端 Pinia 里的cartList只是服务端数据的一份缓存。页面加载时调GET /api/cart/list拉取,任何增删改操作都先调后端接口,成功后再更新本地状态。这样用户在任意设备登录,购物车都是同步的。

购物车数量变化还有一个容易忽略的点:用户在前端把数量加到 99,后端商品的真实库存可能只有 5。所以在用户点击“去结算”时,前端要展示一个“库存紧张”的提示还不够,真正要在下单接口里做最终的库存校验,因为前端的数据完全可以被篡改。我在下单接口里会对购物车里的每个petId重新查询库存,这也是前面乐观锁方案能兜住并发的原因。

4.3 模拟支付流程与订单状态轮询

支付环节在真实项目中会对接微信支付或支付宝,但在本地开发阶段,模拟支付就足够了。我的做法是后端提供一个POST /api/order/{orderNo}/pay接口,直接把订单状态从“待付款”改为“已付款”,同时把支付时间写入。前端在倒计时页面提示用户点击“模拟支付成功”,本质上就是调这个接口。

但模拟支付也有讲究,因为真实支付链路中存在“支付成功但前端没收到结果”的情况。我的处理方式是支付完成后不跳转,而是启动一个轮询定时器,每 2 秒调一次GET /api/order/{orderNo},发现订单状态已从 0 变为 1,才跳转到订单详情页并清空购物车。这个轮询逻辑能模拟真实支付回调的异步性:

function pollOrderStatus(orderNo) { const timer = setInterval(async () => { const res = await getOrderDetail(orderNo) if (res.data.status === 1) { clearInterval(timer) router.push({ name: 'OrderSuccess', query: { orderNo } }) } }, 2000) }

5. 前后端联调与打包部署的坑

5.1 开发环境的跨域配置

本地开发时,Vue 跑在http://localhost:5173,后端跑在http://localhost:8080/ssm,浏览器跨域是逃不掉的。后端加 CORS 过滤器是一种方式,但最省事的方案是前端用 Vite 的代理把请求转发到后端,这样浏览器看到的请求是同源的:

// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:8080/ssm', changeOrigin: true } } } }

这样前端代码里所有的 axios 请求路径统一写成/api/xxx,开发和生产环境不需要切换请求地址,因为部署时后端接口域名也是通过反代映射到/api

5.2 打包部署的三层结构

整个系统上线部署,我的方案是 Nginx 托管前端静态文件 + 反向代理到 Tomcat 的 SSM 接口。第一步,前端执行npm run build,生成dist目录。第二步,把dist里的文件上传到服务器的/usr/share/nginx/html目录。第三步,SSM 项目用 Maven 打成 WAR 包,丢到 Tomcat 的webapps目录。最后,Nginx 配置如下:

server { listen 80; server_name pet.example.com; # 前端静态资源 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/ssm/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

注意location /api/proxy_pass http://127.0.0.1:8080/ssm/的路径处理:如果proxy_pass后面有/ssm/这个路径,Nginx 会把/api/order/list转发成/ssm/order/list;如果没有尾斜杠,路径就是/ssm/api/order/list,这就是很多人页面能打开但接口全部 404 的原因。我自己第一次部署就被这个坑折磨了一阵,排查到最后才发现是路径拼接的问题。

5.3 三个我实测踩过的具体报错

报错一:刷新页面 404。前端路由用的 history 模式时,用户在商品详情页刷新,Nginx 拿着/pet/123去找静态文件,找不到就 404。解决办法就是上面配置里的try_files $uri $uri/ /index.html;,所有不存在的路径都回退到index.html,由前端路由接管。如果你实在不想配 Nginx,直接在 Vue Router 里用 hash 模式(createWebHashHistory),URL 会带个#/,虽然不好看,但刷新不会 404。

报错二:Tomcat 启动后访问 404 或者 500,控制台报 MySQL 连接失败。排查下来是 MySQL 8 的驱动类和时区问题。JDBC URL 要写成jdbc:mysql://localhost:3306/pet_shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai,驱动类用com.mysql.cj.jdbc.Driver。少了serverTimezone参数,MySQL 8 会直接报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,这个是乱码显示的“中国标准时间”,看到这个错别慌,补时区参数就行。

报错三:图片上传后前端访问不到。我在本地把上传目录设为 Tomcat 外的一个绝对路径(比如/data/pet_shop/upload),然后图片 URL 拼接成/files/xxx.jpg。Nginx 里需要加一个 location:

location /files/ { alias /data/pet_shop/upload/; }

aliasroot的区别一定要搞清楚,root /data/upload会把/files/1.jpg映射到/data/upload/files/1.jpgalias /data/upload/才会映射到/data/upload/1.jpg,用错就是图片 404。这个坑我印象极深,因为改一行配置就能解决,但不知道原理的人可能排查一整天。

最后再分享一个经验:SSM 项目如果控制台报错看不明白,先看 MyBatis 的 SQL 日志,把logImpl设为STDOUT_LOGGING,SQL 语句和参数值都会打印出来。我之前遇到过动态 SQL 拼接错误导致语法异常,前端一直报 500,后端日志里却没有堆栈信息,开了这个日志之后 30 秒就定位到了问题。宠物店商城这个项目做完,你对 Spring 的 Bean 管理、MyBatis 的 SQL 执行机制、Vue 的状态管理肯定都会有一个质的理解,这套底子换到其他业务系统上,依然扎实。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 14:10:16

hermes-agent 实战拆解:轻量级 AI Agent 的架构设计与工具调用机制

1. 项目概述&#xff1a;hermes-agent 是什么&#xff0c;能解决什么问题先直接说结论&#xff1a;hermes-agent 是一个以 agent 为核心的智能体项目&#xff0c;名字取自希腊神话中的信使神赫尔墨斯。在真实项目里&#xff0c;这个名字基本就暗示了它的定位——做消息与任务的…

作者头像 李华
网站建设 2026/9/9 14:10:15

DeepSeek Harness:TypeScript微服务插件化开发加速器

1. 项目概述&#xff1a;DeepSeek Harness 是什么&#xff0c;它解决的到底是什么问题&#xff1f; DeepSeek Harness 不是一个独立发布的开源框架&#xff0c;也不是 DeepSeek 官方推出的标准化开发套件——这是当前网络搜索中大量混淆的起点。我花了整整两周时间&#xff0c;…

作者头像 李华
网站建设 2026/9/9 14:09:27

WorkBuddy硬件落地实战:9款RK芯片设备与Agent边缘部署指南

1. 这不是概念炒作&#xff0c;是硬件Agent双轨落地的真实现场“腾讯All in Agent”这句口号刷屏时&#xff0c;我正蹲在深圳华强北一家嵌入式方案商的仓库里&#xff0c;手里捏着刚拆封的WorkBuddy开发套件——一块带RK3588S主控、双MIPI-CSI接口、预烧OpenCLAW固件的板子&…

作者头像 李华
网站建设 2026/9/9 14:06:43

求环(回路)长度全解析:从链表快慢指针到图论与工程实战

刷题的时候碰到“求环(回路)长度”这个标题&#xff0c;我第一反应是LeetCode第142题那一类问题&#xff1a;链表里有没有环&#xff0c;环有多长。但真到了实际编码里&#xff0c;你会发现这个概念被问得五花八门——有的是让返回环起点&#xff0c;有的是让直接给环的节点数&…

作者头像 李华
网站建设 2026/9/9 14:05:20

Win11下PL2303驱动无法识别?实测有效解决方案与原理详解

简介&#xff1a;面向升级Windows 11后PL2303芯片USB转串口设备无法识别或提示“PL2303TA不支援WINDOWS11”的用户&#xff0c;提供经过实测可用的驱动更新方案。包内共39个文件&#xff0c;包含10个C源码、10个头文件、10个makefile、5个说明文档、2个驱动安装程序、1个安装教…

作者头像 李华
网站建设 2026/9/9 14:05:04

STM32 GPIO模拟串口:单字符收发实现与避坑指南

简介&#xff1a;面向嵌入式开发者和STM32初学者的GPIO模拟UART实验资源&#xff0c;基于STM32F103C8微控制器&#xff0c;利用PA1、PA0引脚以软件方式实现UART单字节收发&#xff0c;适合在缺少专用串口或需要扩展串口的场景中使用&#xff0c;也有助于深入理解串口协议底层时…

作者头像 李华