SpringBoot+Vue3服装电商系统开发实战:从数据库到部署全解析
做服装类电商系统一直是很多开发者练手的热门方向一是业务链路完整从前台展示到购物车、订单、支付模拟一套流程下来基本覆盖了常见业务系统的核心场景二是前后端分离模式在真实项目中用得最普遍学完直接能对标公司里的开发模式。“衣依”就是这个定位下的一个典型项目Java SpringBootVue3MyBatisMySQL前后端完全分离代码结构清晰非常适合想搞懂电商业务闭环、或者准备做课程设计/毕设/面试项目的朋友。这篇博文我会把这个项目的设计思路、数据库建模、后端核心逻辑、前端页面联调、本地部署和避坑经验一次性讲透全部基于我可以直接复现的实践经验来写。1. 项目整体定位与技术选型拆解1.1 为什么是SpringBootVue3MyBatis这套组合先说技术栈的选型逻辑。市面上做电商系统可以选的技术组合很多比如SpringCloud微服务、Python Django、Go Gin等等但这个项目用“SpringBootVue3MyBatis”这套经典组合其实是充分考虑到了“规模”和“学习成本”的匹配。SpringBoot的优势不用多说它把Spring大量繁琐的XML配置全部自动化了。对于服装销售平台这种单体应用来说SpringBoot内置Tomcat起步就是mvn spring-boot:run不用单独部署Web容器开发效率非常高。尤其是我这种习惯“先跑起来再改”的开发者SpringBoot的自动配置能在十分钟内把项目骨架搭好。前端选Vue3是自然的趋势。Vue3的组合式APIComposition API在逻辑复用和代码组织上比Vue2的选项式API更适合中大型页面。衣依项目里商品列表、购物车、个人中心这些页面之间有大量共享状态比如购物车数量、用户登录态用组合式API配合状态管理库逻辑会清楚很多。MyBatis作为持久层框架它跟SpringBoot的兼容性极好核心思路就是“SQL由开发者自己控制”。电商系统里有大量复杂查询比如商品多条件筛选、订单明细联查、报表统计MyBatis能让我们对SQL有完全的掌控力不会像JPA那样在复杂查询时被迫生成低效语句。当然代价是要手写不少XML或注解SQL但这本身就是学习数据库和SQL的好机会。1.2 服装销售平台的业务边界与功能模块划分在动手写代码之前我一直强调要想清楚业务边界。“衣依”定位是B2C服装销售平台用户端前台和管理端后台是两个天然的大模块。前台面向普通消费者核心流程是注册登录 - 浏览商品 - 分类筛选 - 查看详情 - 加入购物车 - 提交订单 - 模拟支付 - 查看订单状态。围绕这条主链还应该有个人中心收货地址管理、订单查询、商品搜索、商品收藏等功能。后台面向管理员核心职责是商品管理上架/下架/编辑、分类管理、订单管理发货/查看明细、用户管理、轮播图管理。后台不需要太复杂但所有对商品和订单的写操作必须完整能支撑前台业务流转起来。注意这里有两个在需求阶段就要确定的点第一支付环节做的是“模拟支付”——用户点击支付按钮后直接将订单状态置为已支付不接任何真实支付渠道这是绝大多数学习型项目的合理取舍第二商品不考虑SKU比如颜色尺码组合以单规格商品建模。如果想扩展SKU需要另建商品规格表这个我在后面会提到。2. 数据库设计服装电商领域的建模关键点2.1 核心表结构与实体关系梳理“衣依”数据库我按业务域拆成了用户域、商品域、交易域、营销域四部分。其中核心表给大家列一下member用户表id、username、password、nickname、avatar、phone、status、create_timeaddress收货地址表id、member_id、receiver_name、receiver_phone、province、city、district、detail、is_defaultcategory商品分类表id、parent_id、name、icon、sort、statusproduct商品表id、category_id、name、subtitle、main_image、detail、price、stock、sales、status、create_timeproduct_image商品图片表id、product_id、url、sortcart购物车表id、member_id、product_id、quantity、checked、create_timeorders订单表id、order_no、member_id、address_id、total_amount、pay_amount、pay_type、status、receiver_info收货信息快照、create_time、pay_time、ship_time、finish_timeorder_item订单明细表id、order_id、product_id、product_name、product_image、product_price、quantity、total_pricecollect收藏表id、member_id、product_id、create_timecomment评论表id、member_id、product_id、content、rating、create_timebanner轮播图表id、image、url、sort、status从关系上看category对product是一对多member对orders和cart是一对多orders对order_item是一对多。address挂在用户下面每次下单时从用户地址列表里选一个。建表时我给所有表都加了create_time和update_time虽然增加了一点写入负担但排查问题时非常有用强烈建议保留。另外所有金额字段用decimal(10,2)数量字段用int库存字段不允许负数——这个在MySQL里可以加CHECK约束但更靠谱的还是代码层面扣库存前先判断。2.2 订单明细里为什么要存“商品快照”这是整个数据库设计里我特别想强调的一点。订单明细表order_item里除了product_id外还冗余存储了product_name、product_image、product_price这几个字段。很多初学者不理解为什么不直接通过product_id去关联查询商品表因为在真实电商场景中商品不是永恒不变的。一件衣服可能改价、可能改名、可能下架但用户的历史订单是不能被这些变更影响的。用户下单时买的是“79元的那件衬衫”三个月后商家把价格改成99元如果订单明细直接关联商品表用户查看历史订单时看到的就会是99元这就会引发纠纷甚至涉及法律层面的电子凭证问题。用“快照”的方式把下单那一刻的商品名称、图片、单价、数量冗余存储到订单明细里订单就成了不可变的历史事实。这也是为什么理论上订单明细可以不关联商品表只保留product_id用于反查。我的建议是页面展示优先读取快照字段product_id仅作为关联查询入口。2.3 订单状态机与事务一致性设计订单状态是整个交易链路的核心我在设计时用tinyint类型的status字段来标识含义如下状态值含义场景说明0待支付订单已创建用户尚未完成模拟支付1待发货已支付模拟支付成功后2待收货已发货管理员在后台点击发货后3已完成用户确认收货或系统自动确认4已取消用户主动取消或超时未支付状态流转在代码里是严格单向的不允许从待支付直接跳到已完成。我在Service层定义了一个orderStatusChange的方法内部通过synchronized锁或者数据库乐观锁update ... where status ?保证并发场景下的状态正确性。事务一致性的重点在于“下单减库存”这个动作。异常情况有两大类一是用户疯狂点击提交订单按钮导致重复下单二是多个用户同时购买同一件库存只剩1件的商品。我的处理方案是在orders表里用唯一订单号order_no做防重在扣库存的SQL里加上WHERE stock quantity的条件受影响行数为0说明库存不足直接抛异常回滚。整个下单方法打上Transactional任何一个环节出错订单和库存数据都能恢复到操作前状态。3. 后端SpringBoot核心实现解析3.1 工程目录结构与统一响应模型后端工程我习惯按“Controller-Service-Mapper”三层划分包名按模块组织和普通公司项目保持一致。目录结构大致是这样com.yiyi ├── common // 全局异常、统一结果、常量、工具类 ├── controller // 接口层ProductController、OrderController等 ├── service // 业务层接口实现类 ├── mapper // MyBatis的Mapper接口 ├── entity // 数据库实体类与表字段一一对应 ├── dto // 前端传输对象接收参数和返回视图对象 ├── config // 配置类跨域、拦截器、MyBatis-Plus相关配置统一响应对象是前后端分离项目的基石。如果没有它每个接口返回的字段结构就会五花八门前端完全没法写统一的处理逻辑。我在common包里定义了一个Result类public class ResultT { private Integer code; // 200成功 500失败 401未登录 private String message; private T data; public static T ResultT success(T data) { ... } public static T ResultT error(String message) { ... } }配合全局异常处理器RestControllerAdviceservice层一旦抛出BizException自定义业务异常前端能收到结构一致的错误响应。我一直认为这部分代码虽然看起来“不起眼”但它决定了前后端联调时是事半功倍还是事倍功半。3.2 商品查询分页多条件筛选的实现商品列表页是用户进入系统后最先接触的页面对查询接口的要求是支持分类筛选、商品名模糊搜索、价格排序、分页加载。我直接在Mapper层写动态SQL用where标签和if标签来拼条件select idselectProductPage resultTypecom.yiyi.entity.Product SELECT * FROM product where status 1 if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if /where choose when testsort price_ascORDER BY price ASC/when when testsort price_descORDER BY price DESC/when otherwiseORDER BY id DESC/otherwise /choose /xml分页我用的是MyBatis的分页插件PageHelper调用方式极其简单在查询前执行PageHelper.startPage(pageNum, pageSize)紧接着的第一次SQL查询就会自动拼接LIMIT同时会生成一个PageInfo对象里面包含total、pages、list等分页数据。这里有个经验之谈PageHelper的startPage只对紧跟着的第一个查询生效所以如果查询前要先做别的操作比如先查分类再查商品就会导致分页失效甚至报错。我在页面加载时要同时返回分类列表和商品列表就拆成了两个独立接口前端并行请求完美避开这个坑。3.3 购物车下单与库存扣减的核心流程下单接口是“衣依”系统里业务逻辑最集中的地方核心Service方法我来拆解一下整体流程Transactional public OrderVO submitOrder(OrderSubmitDTO dto) { // 1. 校验用户登录态并获取收货地址 Address address addressMapper.selectByPrimaryKey(dto.getAddressId()); if (address null) throw new BizException(收货地址不存在); // 2. 根据购物车ID列表查询所有勾选商品 ListCartItem cartItems cartMapper.selectCheckedItems(dto.getMemberId()); // 3. 逐一校验商品状态和库存 for (CartItem item : cartItems) { Product product productMapper.selectByPrimaryKey(item.getProductId()); if (product.getStatus() ! 1) throw new BizException(商品已下架); if (product.getStock() item.getQuantity()) throw new BizException(商品库存不足); } // 4. 生成唯一订单号并创建订单主记录 String orderNo YY System.currentTimeMillis() RandomUtil.randomNumbers(4); // 计算总金额写入订单表含收货信息快照 // 5. 生成订单明细快照同时扣减库存 for (CartItem item : cartItems) { Product product productMapper.selectByPrimaryKey(item.getProductId()); orderItemMapper.insert(...); // 存入商品名、图片、价格快照 int rows productMapper.reduceStock(item.getProductId(), item.getQuantity()); if (rows 0) throw new BizException(库存扣减失败); } // 6. 清空已下单的购物车 cartMapper.deleteByIds(...); return buildOrderVO(order); }整个方法只有Transactional一个注解但数据库事务却做了四件事插订单、插明细、扣库存、清购物车。一旦中间任何一步失败全部回滚不会出现“订单创建了但库存没减”或者“库存减了但购物车没清”的数据不一致问题。这里再补充一个细节模拟支付接口做的事其实特别简单就是校验订单属于当前用户且状态为待支付然后UPDATE orders SET status 1, pay_time NOW() WHERE order_no ? AND status 0受影响行数为1即支付成功。用条件更新可以再次防止并发下的状态错误。3.4 登录鉴权JWT还是Session“衣依”作为前后端分离项目登录态管理最合适的方案是JWT。服务器的处理逻辑是用户登录成功后用userId和username生成一个带过期时间的token返回给前端前端每次请求在Authorization头里带上token后端通过拦截器HandlerInterceptor统一解析未登录请求直接返回401。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 || !JwtUtil.verify(token)) { // 返回401并写出Result对象 return false; } // 从token中取出userId放入request request.setAttribute(userId, JwtUtil.getUserId(token)); return true; } }注册登录的密码存储我用了MD5加盐处理虽然现在更推荐BCrypt但对于学习项目用MD5固定盐也够演示。拦截器放行规则要注意商品查询、分类查询、首页轮播图这些公开接口必须放行涉及个人中心、购物车、下单的接口必须拦截。另外要处理一个容易忽略的问题用户下单、查看订单时后端绝不能直接信任前端传来的userId而必须从token解析出来的用户身份为准否则就会出现“水平越权”漏洞。我在“衣依”里所有订单查询方法都强制带上前端传的memberId和token里的userId做二次比对。4. 前端Vue3实现与联调关键点4.1 前端工程结构与状态管理前端使用Vite作为构建工具目录和组件划分如下src ├── api // 接口请求封装按模块拆分 ├── router // 路由配置与守卫 ├── store // 状态管理用户信息、购物车数量 ├── views // 页面组件 │ ├── Home.vue // 首页 │ ├── ProductList.vue // 商品列表 │ ├── ProductDetail.vue │ ├── Cart.vue // 购物车 │ ├── OrderConfirm.vue │ ├── OrderList.vue │ ├── Login.vue │ └── UserCenter.vue └── components // 公共组件Header、Footer、商品卡片等状态管理选择上我用了Pinia比Vuex更简洁且对TypeScript更友好。用户登录成功后会同时把token和用户基本信息存进Pinia并用localStorage做持久化刷新页面后还能保持登录状态。另外购物车角标的数量我单独放在一个useCartStore里每次增删购物车后都会重新拉取数量保证Header展示的角标和实际数据一致。路由守卫是前端鉴权的重要一环在router.beforeEach里检查要访问的页面是否需要登录通过路由meta字段标记如果未登录则跳转到登录页登录成功后通过redirect参数回到原页面。这个体验细节很加分。4.2 首页、商品列表与购物车交互首页一般由轮播图、分类导航、热门商品几个区域组成。我把首页看作一个“组合页面”通过几个子组件拼装数据统一在Home.vue里通过onMounted并发请求三个接口加载。这样页面加载最快每个接口独立loading一个接口失败不会影响其他区域展示。商品列表页是重点用户通过顶部导航或首页分类进入。我用了watch监听路由参数categoryId和查询参数keyword的变化参数一变就重新请求列表数据。分页用的是滚动到底部自动加载更多而不是传统页码翻页更符合商城类App的使用习惯。这个用onMounted监听window.scroll事件实现注意在组件卸载时移除事件监听避免内存泄漏。购物车页面有一个细节要处理好勾选商品、修改数量、计算总价都是纯前端状态操作只有点击“去结算”时才把购物车ID列表提交到后端生成订单。前端实时计算总价用computed依赖购物车项里的checked和quantity不需要每次点击都请求后端接口体验非常流畅。4.3 axios封装与开发环境跨域处理前后端分离开发时前端地址是localhost:5173后端是localhost:8080浏览器跨域问题不可避免。我在Vite的配置文件里加了代理// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, } } } })这样前端请求/api/product/list实际会代理到http://localhost:8080/api/product/list浏览器不再直接跨域而后端的跨域配置只是兜底方案。我习惯统一在axios实例里设置baseURL: /api所有接口以/api开头这样后面如果部署到nginx也只需要调整nginx的路径转发规则。axios的拦截器做了两件事请求拦截器把Pinia里的token加到请求头Authorization响应拦截器只处理Result对象如果code不为200就弹出提示信息同时单独处理401token过期跳转到登录页。这样业务代码里就不需要反复写错误处理逻辑了。5. 本地部署与运行实操5.1 环境准备与数据库初始化要把“衣依”系统在本地跑起来需要准备以下基础环境JDK 1.8或11、Maven 3.6以上、MySQL 5.7或8.0、Node.js 16以上推荐18。IDE我个人喜欢用IntelliJ IDEA前端用VS Code。拿到源码后第一步是执行项目根目录下的yiyi.sql脚本。里面有建库建表和初始数据建议直接用命令行或Navicat整体执行。执行完确认数据库yiyi下能看到十几张表和若干条测试数据。项目里自带的测试账号有两个——前台用户账号密码通常是admin/123456管理端是manager/123456跑起来后可以直接登录体验。第二步是修改后端application.yml中的数据库连接信息server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/yiyi?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver这里要特别注意serverTimezoneAsia/Shanghai这个参数。如果省略国内服务器用默认时区可能会导致时间差8小时甚至启动报错。5.2 后端启动与接口自测后端启动非常简单在项目根目录执行mvn spring-boot:run看到“Started Application in xx seconds”日志就代表启动成功。如果Maven下载依赖时速度太慢可以在~/.m2/settings.xml里配置国内镜像源。接口自测我强烈推荐用Apifox或者直接用Postman。先调一次登录接口获取token然后测试商品查询、加入购物车、提交订单的接口。注意测试下单前先在数据库里给测试用户添加一条收货地址不然下单会提示地址不存在。对于“衣依”这种自带Swagger配置的项目也可以直接访问http://localhost:8080/swagger-ui.html进行可视化接口调试比纯手写请求体更直观。5.3 前端启动与全流程验证前端在项目前端的目录下执行npm install npm run dev启动成功后终端会打印Vite的访问地址一般是http://localhost:5173。首次启动要是遇到npm install报错优先检查Node版本Vite3要求Node14.18以上Vite5要求Node18以上。打开页面后完整走一遍核心流程注册一个全新账号 - 首页浏览 - 进入商品详情 - 加入购物车 - 结算下单 - 模拟支付 - 在个人中心查看订单。同时用管理端账号登录后台把订单标记发货再回到用户端确认收货。这套流程全部走通就说明前后端所有核心接口都正常了。6. 常见问题排查与实战避坑6.1 启动与运行常见问题速查表问题现象可能原因排查与解决方案后端启动报数据库连接失败数据库没启动 / 账号密码错 / URL里的库名不对先用Navicat测试连接检查application.yml的三项配置前端启动成功但页面白屏接口全部404或500打开浏览器F12看Network的具体状态码排查代理配置和后端是否启动登录接口返回401token生成或拦截器逻辑问题检查拦截器放行了登录接口检查JWT密钥是否一致MyBatis查询出null字段表字段下划线转驼峰未配置在application.yml开启map-underscore-to-camel-case: true时间字段显示为时间戳Jackson序列化配置缺失在application.yml配置时间格式化或使用JsonFormat注解提交订单报库存不足并发扣减/测试数据库存为0检查reduceStock的SQL是否带stock quantity条件前端刷新页面404路由模式为history开发环境用Vite的historyApiFallback生产环境由nginx配置try_files处理6.2 我踩过的几个坑给各位提个醒第一个坑是MyBatis的驼峰映射。数据库字段是create_time实体类属性是createTime如果不开启驼峰映射这个字段永远是null。新手很容易排查半天才发现是配置项漏了。我的做法是在application.yml里统一加上mybatis: configuration: map-underscore-to-camel-case: true第二坑是查询列表时把大字段全查出来了。商品表里有个detail字段存的是很长的HTML详情如果列表接口SELECT *不仅传输慢还拖垮渲染。我的做法是列表接口用单独的SQL只查id, name, price, main_image, subtitle详情接口才查完整字段。这算是很基础但非常实用的性能优化。第三个坑是图片上传后的访问路径。项目里的商品图片是上传到本地磁盘某个目录的如果上传成功了但页面加载不出图片八成是上传路径和静态资源映射目录对不上。后端的资源映射配置要指向实际文件目录否则前端访问不到。最简单的做法是上传接口返回完整的可访问URL前端直接展示避免二次拼接出错。第四个坑是前端路由的history模式部署问题。开发环境下一切正常但打包部署到服务器后刷新页面除了首页其他路径全部404。原因是路由切换是前端路由但刷新时浏览器会以当前路径请求服务器服务器没有对应的后端路由自然报404。解决办法是在nginx上配置try_files $uri $uri/ /index.html;把不存在的路径都fallback到前端入口。这个在项目部署文档里一定要写清楚。第五个坑是删除购物车数据时用foreach逐条删除性能不好且事务太长。我后来改成了按用户ID和商品ID集合批量删除一条SQL搞定。类似的批量操作还有订单列表查询不要用循环查数据库要学会用IN查询一次取出所有关联数据。做这类项目我最大的体会是技术本身没有太多高深的东西真正的难点在于把每个模块间的数据流转理清楚把边界条件想透。“衣依”这个系统单看代码量不算大但商品、购物车、订单、库存这些环节串起来后就是一个小而完整的电商闭环。如果你跟我一样是冲着练手来的建议拿到源码后先别看实现自己先画一遍业务流程图和数据表关系图再对照代码看差异这样收获会大得多。最后再分享一个扩展方向这个项目最值得升级的地方是商品SKU管理和优惠券模块。商品表加一个sku表把颜色、尺码、库存下沉到规格层级优惠券则牵涉到领取、核销和订单金额计算能把业务复杂度提升一个档次。把这两块啃下来你对电商系统的理解会比现在深很多。