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

📅 发布时间:2026/9/9 14:12:39
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_id和quantity价格一律实时去商品表查。但订单明细表必须存快照因为下单那一刻的价格和商品信息必须固定下来以后商家改价不能影响历史订单。订单表的重点是状态字段。我用 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 容器管 BeanSpringMVC 管请求分发MyBatis 管数据库访问。很多人整合不成功问题几乎都出在配置的加载顺序和扫描范围上。我用的方案是三个 XML 配置文件也可以改成配置类但 XML 结构更直观applicationContext.xml扫描除Controller外的所有组件加载数据源、事务管理器和 MyBatisspring-mvc.xml只扫描Controller开启注解驱动和静态资源放行mybatis-config.xml放 MyBatis 全局配置。翻车点一Mapper 接口扫描不到。在applicationContext.xml里配置MapperScannerConfigurerbasePackage一定要写到 Mapper 接口所在的包写错一个字母应用启动就报NoSuchBeanDefinitionException。翻车点二MyBatis 驼峰映射没开。数据库字段user_name映射不到 Java 属性userName查询结果全是 null。在mybatis-config.xml里开启settings setting namemapUnderscoreToCamelCase valuetrue/ setting namelogImpl valueSTDOUT_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, ListCartItemVO 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 模式createWebHashHistoryURL 会带个#/虽然不好看但刷新不会 404。报错二Tomcat 启动后访问 404 或者 500控制台报 MySQL 连接失败。排查下来是 MySQL 8 的驱动类和时区问题。JDBC URL 要写成jdbc:mysql://localhost:3306/pet_shop?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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 里需要加一个 locationlocation /files/ { alias /data/pet_shop/upload/; }alias和root的区别一定要搞清楚root /data/upload会把/files/1.jpg映射到/data/upload/files/1.jpgalias /data/upload/才会映射到/data/upload/1.jpg用错就是图片 404。这个坑我印象极深因为改一行配置就能解决但不知道原理的人可能排查一整天。最后再分享一个经验SSM 项目如果控制台报错看不明白先看 MyBatis 的 SQL 日志把logImpl设为STDOUT_LOGGINGSQL 语句和参数值都会打印出来。我之前遇到过动态 SQL 拼接错误导致语法异常前端一直报 500后端日志里却没有堆栈信息开了这个日志之后 30 秒就定位到了问题。宠物店商城这个项目做完你对 Spring 的 Bean 管理、MyBatis 的 SQL 执行机制、Vue 的状态管理肯定都会有一个质的理解这套底子换到其他业务系统上依然扎实。