首页 / 资讯中心 / 文章详情

前后端分离实战:从零搭建SpringBoot+Vue购物网站全流程

前后端分离实战:从零搭建SpringBoot+Vue购物网站全流程 ★ FEATURED ARTICLE
前后端分离这个词很多人简历上写过但真要把一套能跑的购物网站从零搭起来还是有不少门道。我最近把一个练手的JavaWeb购物网站用前后端分离的方式重写了一遍后端走Controller-Service-Mapper三层也就是常说的MVC模式前端用Vue加axios发请求从用户登录、商品列表、购物车到下单结算全套流程都跑通了。这篇文章把我设计表结构、拆分接口、处理跨域和并发扣库存的过程完整记录下来适合正在学SpringBoot想做实战项目、或者被前后端联调折磨过的新手参考也适合想系统梳理一套业务流程怎么落地的朋友。你可以把这篇文章当成一个“抄作业”的完整案例先讲清楚前后端分离和MVC到底各司其职再讲数据库五张表怎么设计后端每个Controller、Service、Mapper写什么前端页面怎么对接接口最后是实战里最容易踩的坑。有些问题的排查思路常规教程不会告诉你但你联调时大概率会撞上。1. 项目定位这个购物网站到底在练什么1.1 前后端分离和MVC其实是两件事很多初学者容易把这两个词混在一起甚至以为它们是一套东西。我一开始也犯过这个迷糊后来想明白了这是两个维度上的事情。MVC是代码组织模式解决的是“后端代码怎么分层”的问题。Model负责数据和业务规则View负责展示Controller负责接收请求和调度。传统的JavaWeb里View是JSP页面服务端渲染好网页再返回给浏览器。但在前后端分离的项目里View这一层被拆出去了后端只返回JSON数据前端的Vue页面本身有自己的一套MVVM思想它自己管理数据绑定和视图更新。所以严格来说这个项目是“后端MVC 前端MVVM”的组合。后端Controller接收前端请求调用Service处理业务Service再通过Mapper访问数据库最终把封装好的JSON返回给前端。前端拿到JSON后自己决定页面怎么渲染。前端和后台之间唯一的联系就是那一份约定好的接口文档。这样的好处非常明显后端不用关心页面长什么样前端不用关心SQL怎么写两边可以并行开发。联调的时候只要接口约定清晰问题就能快速定位。坏处也明显跨域、Token传递、接口异常处理这些事从前没怎么操过心现在全变成了日常任务。1.2 整体模块拆解五个模块串成一条流水线购物网站听起来很庞大但主体功能拆开其实就五个模块用户、商品、购物车、订单、结算。整个系统的数据流是一条很清晰的流水线用户登录后浏览商品把商品加入购物车从购物车选择商品生成订单模拟支付完成结算最后订单状态变更。我这次实现的时候把每个模块都控制在最简单的形态但力求每个环节都能闭环。用户模块做了注册和登录密码存的是加密后的密文商品模块支持分页查询和分类筛选购物车模块支持增删改查、勾选和数量调整订单模块下单时生成订单主表和订单明细表同时扣减库存结算模块没有接真实支付用了一个模拟支付的接口把订单从未支付状态改成已支付。这个闭环虽然简单但已经把电商项目最核心的业务链路走了一遍。每个模块之间的边界也要理清楚。比如商品模块不用知道购物车怎么存数据购物车也不用关心订单怎么生成。模块之间只通过接口交互后端代码里就体现为Service类之间的调用关系尽量清晰不要相互纠缠。这个边界意识是我做完项目之后体会最深的一点。2. 数据库设计与接口约定先把前后端的规矩定下来2.1 五张核心表怎么建字段设计直接决定后面写代码的舒服程度我一开始偷懒想着表能用就行结果后面写SQL的时候反复改表结构改一次连带Java实体和前端字段全要动。后来老老实实重新设计了五张表才把开发节奏顺过来。用户表最简单字段就是用户名、密码密文、昵称、创建时间。密码不能用明文存这是基本底线我用的是加盐的哈希算法后端在注册时对原始密码做哈希处理数据库里只存哈希后的结果即使数据泄漏也不会直接暴露用户密码。商品表要稍微多花点心思商品名称、描述、价格、库存、分类、图片地址、上下架状态这些都要考虑。价格字段我强烈建议用decimal而不是float或者double浮点数在计算金额时会出精度问题比如0.1加0.2结果不是0.3这在金额计算里是绝对不能接受的。decimal(10,2)表示最高十位数、小数点后保留两位足够覆盖一般的单价。购物车表其实是一个关联表记录用户和商品之间的关系字段包括用户ID、商品ID、数量、勾选状态。这里要注意购物车表只保存“用户想买什么”的意图真正的金额和库存校验要在生成订单的时候做。有些初学者会把价格冗余到购物车表里这样反而容易造成数据不一致。订单相关的是两张表订单主表和订单明细表。订单主表保存订单号、用户ID、总金额、订单状态、创建时间。订单明细表保存每个订单里的商品快照包括商品ID、商品名称、单价、数量、小计。为什么需要明细表把商品名称和单价冗余一份因为商品价格和名称随时可能变动订单一旦生成这笔交易的历史记录就不能跟着商品表的变动而变必须用快照把当时的成交信息固定下来。这个设计思路在真实电商系统里也是通用的。建表的SQL我大概长这样实际用的时候再根据索引和字符集做调整CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(128) NOT NULL COMMENT 密码密文, nickname varchar(50) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE product ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL, description varchar(500) DEFAULT NULL, price decimal(10,2) NOT NULL, stock int(11) NOT NULL DEFAULT 0, category varchar(50) DEFAULT NULL, image_url varchar(255) DEFAULT NULL, status tinyint(4) DEFAULT 1 COMMENT 1上架 0下架, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE cart_item ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, product_id bigint(20) NOT NULL, quantity int(11) NOT NULL DEFAULT 1, checked tinyint(4) DEFAULT 1, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_product (user_id,product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 接口返回值统一封装前后端分离最关键的一步是把接口的“返回格式”定死。如果每个Controller返回的JSON结构都不一样前端对接一个接口就要看一次代码联调效率会非常低。我定义了一个统一的Result类包含三个字段code、message、data。code是业务状态码message是给前端看的提示信息data是真正的业务数据。比如登录成功返回code为200data里放Token和用户信息登录失败返回code为401message提示“用户名或密码错误”参数校验失败返回code为400。这样前端在axios的拦截器里统一处理code只有code为200时才算接口成功其他情况直接弹提示或者跳登录页不用每个接口都写一遍错误判断。我踩过的一个坑是最开始把HTTP状态码和业务状态码混着用。后来发现登录接口返回200但业务上需要前端跳转登录页这时候直接用HTTP状态码不好表达。所以业务状态码和HTTP状态码分开用HTTP状态码保证网络层正常业务状态码表达业务结果。比如用户未登录时HTTP状态码我返回200但业务code是401前端根据code决定跳转路由这样做的好处是拦截器逻辑统一不用在HTTP错误和业务错误之间做两套判断。接口路径按功能模块来划分RESTful风格。登录注册用POST /api/user/login和POST /api/user/register商品列表用GET /api/product/list商品详情用GET /api/product/{id}购物车的增删改查对应POST、DELETE、PUT操作订单用POST /api/order/create。前端所有请求都带 /api 前缀方便后端统一做过滤和拦截。3. 后端MVC分层实现三层结构不是摆设3.1 Controller层只做接收和转发别写业务逻辑Controller层在MVC里是最接近前端的一层它的职责是接收HTTP请求、解析参数、调用Service、返回结果。我一度图省事把判断用户名是否存在的逻辑直接写在Controller里后来代码越来越多一个Controller方法几百行改一个参数要从头看到尾那时候才意识到分层的重要性。现在写Controller就一句话薄。方法内部只做三件事。第一把前端传来的参数交给Service层处理第二捕获Service层的异常并转换成统一的Result返回第三返回给前端的JSON格式保持整洁。看一段最普通的登录接口逻辑很清晰RestController RequestMapping(/api/user) public class UserController { Resource private UserService userService; PostMapping(/login) public Result login(RequestBody LoginRequest request) { return userService.login(request.getUsername(), request.getPassword()); } }参数校验这块我要多说一句Controller层不能完全不管参数但也不能写一堆if判空。我习惯把校验放在Service层或者单独写一个ValidatorController里只做最基本的格式校验比如用户名和密码是否为空这种前置判断。复杂的业务校验比如库存是否充足、订单金额是否一致必须放在Service层因为那不是“格式”问题而是“业务规则”问题。3.2 Service层与事务下单流程的核心逻辑都在这里Service层是整个后端代码的灵魂。业务规则、数据一致性和事务控制都集中在这一层。以用户登录为例Service要先查用户、再比对密码哈希最后生成Token返回。商品列表的查询则是组合了分页参数和分类条件的条件查询。最重要的是下单方法。下单不是简简单单往订单表插一条数据而是一个关乎数据一致性的复合操作。我最初实现下单时是把扣库存、生成订单、清空购物车三个操作分开写的结果有一次扣了库存但订单生成失败用户没下成单库存却少了数据直接就错乱了。解决这个问题的方式就是事务。在Service方法上加上事务注解让这三个操作要么全部成功要么全部回滚。事务的作用就像一个保护罩执行过程中任何一步抛异常前面的数据库操作全部撤销不会留下半截数据。下面是下单Service的示意代码我把核心逻辑展示出来Service public class OrderServiceImpl implements OrderService { Resource private OrderMapper orderMapper; Resource private ProductMapper productMapper; Resource private CartItemMapper cartItemMapper; Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, ListCartItem checkedItems) { BigDecimal totalAmount BigDecimal.ZERO; ListOrderItem orderItems new ArrayList(); for (CartItem item : checkedItems) { Product product productMapper.selectByIdForUpdate(item.getProductId()); if (product.getStock() item.getQuantity()) { throw new BizException(商品库存不足: product.getName()); } // 计算明细和总金额 BigDecimal amount product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())); totalAmount totalAmount.add(amount); OrderItem orderItem new OrderItem(); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getName()); orderItem.setPrice(product.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItems.add(orderItem); } // 先创建订单主表记录 Order order new Order(); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setStatus(OrderStatus.UNPAID); orderMapper.insert(order); // 插入明细表 for (OrderItem orderItem : orderItems) { orderItem.setOrderId(order.getId()); orderMapper.insertOrderItem(orderItem); } // 扣减库存 for (CartItem item : checkedItems) { productMapper.decreaseStock(item.getProductId(), item.getQuantity()); } // 清空已勾选的购物车项 cartItemMapper.deleteCheckedItems(userId); return order; } }这段代码里有个容易被忽略的细节查商品时我用了selectByIdForUpdate也就是对商品行加了锁。这个处理方式是为了防止并发下单时出现超卖如果不加锁两个用户同时看到库存只剩1件同时下单买1件都通过了库存检查但实际库存只扣1件另一个订单就是超卖的。加锁之后同一时间只有一个事务能读这行商品数据其他事务必须等锁释放才能继续这样就避免了这个并发问题。3.3 Mapper层SQL要写得看得懂也要防得住我用的持久层框架是MyBatisXML文件里写SQL。Mapper层看起来就是把接口方法映射成SQL执行但实际写起来还是有几个原则要守住。第一个原则是所有的查询条件能用参数传递就用参数传递不能用字符串拼接。这个不用我多说SQL注入的风险是真实存在的。比如用户登录时如果把用户名直接拼进SQL传入一个“admin or 11”这样的字符串查询条件就被改写了这是绝对不能出现的安全漏洞。用#{}占位符的方式会把参数当作值处理而不是SQL的一部分才是安全写法。第二个原则是一对多查询要避免N1问题。查订单列表的时候很自然的想法是先查订单主表然后循环遍历每个订单去查明细表。但如果订单有100条这个循环就会执行101次SQL查询性能会急剧下降。我在实现订单列表时是先把订单主表查出来再用订单ID的集合一次性查所有明细然后按orderId分组组装成完整订单数据。这样无论多少订单SQL查询次数都只有两条。第三个原则是SQL尽量让数据库去做集成的计算比如用聚合函数统计金额、用group by分组不要在Java代码里遍历累加。数据库对这类操作做了大量优化比我们用循环逐条处理高效很多。3.4 登录鉴权与跨域配置前后端分离项目里登录鉴权是绕不开的环节。我用的是JWT方案登录成功后后端生成一个Token前端把Token存到localStorage之后每次请求都在请求头里带上这个Token后端拦截器校验Token有效才放行。JWT本身是一个三段式字符串中间包含了用户信息、签发时间和过期时间后端用密钥对它进行签名。由于Token是自包含的后端不需要在服务端保存会话状态这对前后端分离的架构非常友好。服务端重启用户登录状态也不受影响天然适合部署到多台服务器的情况。拦截器的实现逻辑不复杂定义一个拦截器在请求进入Controller之前检查Authorization请求头Token不存在就返回未登录Token存在就解析校验校验通过就把用户信息放入请求上下文。但是要小心一个边界情况用户的登录和注册接口必须放行不然用户还没登录就被拦截了。我在拦截器里维护了一个放行路径列表所有路径以“/api/user/login”和“/api/user/register”开头的请求不做Token校验其他接口一律需要Token。跨域配置也是必须处理的。前后端分离之后前端跑在其中一个端口上后端跑在另一个端口上浏览器发请求时默认会拦截跨域请求。解决方案是用CORS在后端配置允许跨域的来源、方法和请求头。最基础的方式是给WebMvcConfigurer加一个跨域映射配置。需要注意配置里的allowCredentials如果前端请求需要携带Cookie或者Token请求头allowedOrigins就不能设置为“*”要写明确的前端域名否则浏览器会拒绝响应。我在踩这个坑的时候调试了很久前端控制台始终报跨域错误后来才意识到是allowCredentials和通配符一起使用时浏览器对通配来源的校验不通过。4. 前端页面与交互把后端接口真正用起来4.1 前端工程结构与开发环境代理我用Vue搭建前端页面整个工程的主目录下页面组件放在src/views里接口请求统一放在src/api里公共组件放在src/components里。这个结构虽然简单但把页面、接口和复用组件做了基本的隔离后面扩展页面时不用到处翻代码。开发环境里经常遇到一个让人很头疼的问题前端跑前端的开发服务器后端跑后端的服务浏览器直接请求后端接口会跨域。虽然可以在后端配CORS但我更推荐在前端开发服务器上配代理。Vite的配置文件里加一段代理规则把请求路径以/api开头的请求转发到后端的本地服务地址这样对浏览器来说请求是同源的就没有跨域问题了。生产环境部署时再用Nginx做反向代理把/api路径转发到后端服务。开发阶段用代理生产阶段用Nginx这套模式是前后端分离项目最常见的部署方式。4.2 登录状态与路由守卫前端登录页调登录接口拿到Token之后把Token存到localStorage同时把用户基本信息存一份到Vue的全局状态里。页面跳转时通过路由守卫判断用户是否登录没有Token就跳转登录页。这个逻辑很直观但有两个细节需要注意Token存进localStorage后可能被脚本读取有XSS攻击时存在泄漏风险另外一个问题是Token过期后接口返回未登录前端需要统一处理我在axios响应拦截器里判断到业务code为401就清除本地Token并跳转登录页。axios的请求拦截器里把Token放进请求头。代码不多但不能漏漏掉任何一个请求头后端就会返回未登录联调时非常容易排查很久才发现是这里没加。axios.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) axios.interceptors.response.use(response { const res response.data if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return res })4.3 商品列表与购物车交互商品列表页是一个典型的列表展示场景前端调商品分页接口获取数据渲染卡片点击分类Tab切换时重新请求。这里要注意分页参数要跟着切换动作走翻页时页码重置为1筛选分类时要清空当前页数否则会出现切到第二页之后没有数据的情况。购物车模块我做了最基础的操作勾选、取消勾选、增减数量、删除、清空已勾选。前端维护一个购物车列表数据每次操作体积变化时调用后端接口同步而不是只在本地修改。为什么不能只改前端因为生成订单时必须以后端数据库里购物车的数据为准前端改的数据如果没同步到后端下单时就会对不上。购物车每一项的勾选状态也要存到后端这样用户换个设备登录购物车勾选状态还在。用户点击“去结算”之后前端把购物车里所有勾选项的productId和quantity传给后端下单接口后端再根据这些信息生成订单。这里不传价格价格以后端数据库为准前端传的价格没有任何可信度。这个设计原则要守住否则把整个计算金额的逻辑放到前端用户可以随手改请求参数篡改价格这个漏洞非常严重。4.4 下单与模拟支付下单流程本身是一个很考验前后端配合的环节。前端点击“提交订单”后调用createOrder接口后端返回订单号前端跳转到订单详情页显示待支付状态。用户点击“模拟支付”前端调用支付接口后端把订单状态改为已支付订单详情页展示支付成功。这个流程里我特别想强调一个用户体验上的细节订单提交后前端立即把购物车里已勾选的商品清掉。购物车清空的操作本来是由后端在下单事务里一起去做的但前端要在收到成功的响应后主动刷新购物车数据避免用户看到购物车里还有刚刚买过的东西。有些同学在下单成功后只跳转支付页购物车的数据一直不刷新返回时会看到商品还在以为是没下单成功其实是前端状态没同步好。订单列表页面还需要展示订单状态。我在订单主表里维护了一个状态字段未支付、已支付、已发货、已完成、已取消。这个项目里只实现了未支付到已支付的流转但状态字段预留好后面的业务扩展就不需要改表结构。5. 常见问题与排查技巧实录这几个坑我替你们踩过了5.1 跨域问题明明后端能通前端就是不行联调中最常遇到的问题就是前端控制台报跨域错误但用接口测试工具直接调后端接口却完全正常。这是因为接口测试工具不受浏览器同源策略限制而浏览器会先发一个OPTIONS预检请求检查服务器允不允许这个跨域请求。排查思路分三步。第一步确认后端有没有正确配置CORS规则包括允许的来源、方法、请求头。第二步看预检请求有没有被拦截器拦掉如果你的拦截器对所有请求都做Token校验OPTIONS预检请求没有带Token会直接返回未登录跨域配置就没机会生效。我见过很多项目跨域配置看起来没问题但预检请求被拦前端照样报跨域错误。解决方式是在拦截器里单独放行OPTIONS请求。第三步检查后端配置里有没有设置allowedOrigins和allowCredentials冲突如果两者冲突浏览器直接拒绝响应控制台通常只会提示跨域被阻止不会告诉你具体原因。5.2 事务不生效扣了库存却没有订单有段时间我遇到一个诡异的问题扣库存成功但订单表里没有记录而且不是每次都能复现。排查之后发现事务注解确实加了但问题出在同类内部自调用。Service方法A调用同类方法BB上有事务注解但A调用B时并没有走Spring的代理对象事务注解就失效了。这在Spring里是一个非常经典的坑。解决方式是把有事务的方法单独放到一个类里通过注入的方式调用或者确保调用入口在被Spring代理的外部类上。这也是为什么要坚持MVC分层的原因之一Controller调Service是跨对象的调用走的是代理事务才能生效。另外还要注意事务方法内部不要自己捕获异常再吞掉如果异常被catch住了Spring无法感知出错事务也不会回滚。正确做法是把异常抛出或者手动调用rollback方法。5.3 库存超卖并发场景下数量对不上库存超卖这个问题我从一开始就担心结果真的上线测试时还是碰到了。两个用户同时抢最后一个库存前端都显示有货都提交了订单最后库存变成负数。原因就是下单时先查库存、再扣减不是原子操作。我在前面已经提了selectByIdForUpdate的加锁方案再补充一点除了查库存时加锁扣减库存的SQL本身也要做条件更新UPDATE product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}这个SQL的意思是库存足够才更新如果库存不够影响行数为0代码再检查影响行数就知道库存不够了。即使前面查询库存的两个事务同时通过执行更新时数据库行锁也会让其中一个失败这样就能严格避免超卖。单机环境这个方案足够如果考虑更高并发可以使用Redis预扣库存和异步队列但那是更复杂的架构对于练手项目这个原子更新的方案已经能够解决问题。5.4 金额精度与中文乱码金额精度问题我在设计表的时候提过一次现在再说一个实际场景。下单计算总金额时如果用了double类型前端显示89.9999999这种数字虽然金额计算上是近似值但展示出来非常不专业。解决方式很简单后端所有金额都用BigDecimal前端请求参数传过来的是字符串或数字后端转换成BigDecimal再参与计算。比较两个金额是否相等时用compareTo而不是equals因为BigDecimal的equals会连小数点后的精度一起比较而compareTo只比较数值大小。中文乱码是JavaWeb的老问题了。请求参数的中文乱码要看前端有没有把UTF-8编码传到后端后端过滤器有没有设置编码。响应乱码要看Response的Content-Type和建议的响应编码字符集。现在的SpringBoot做了很多默认配置一般情况下不会乱但如果用原生的Servlet一定要记得在过滤器里设置请求和响应编码为UTF-8。JSON里返回中文出现乱码还要看服务器返回头的编码和前端页面声明的编码是否一致。5.5 接口返回格式不一致前端拿到的数据结构千奇百怪联调后期我整理过一遍所有接口的返回格式发现有些接口返回的是对象有些返回的是数组有些错误提示放在message里有些放在data里。前端每接一个接口都要看后端代码效率极低。这件事让我得到的教训是项目一开始就要把接口规范定好并且每个接口都按规范实现。统一后的规则很简单。成功时code为200data放真正要展示的数据message放成功提示失败时code为非200的业务码message放人类可读的错误信息data为null分页接口返回data里包含list、total、pageNum、pageSize四个字段。这条规范贴在项目文档最显眼的位置之后所有接口都按这个标准来写联调顺畅了很多。6. 写在最后几点实用心得与扩展思路这个项目做完我最直观的感受是前后端分离的难点不在写代码而在“约定”和“边界”。前后端怎么传参数、返回什么结构、错误怎么提示这些约定越早定下来后面的开发就越轻松。MVC三层也一样Controller该薄就薄Service把业务兜住Mapper只负责SQL各层不越权出了问题才能在最短时间内定位到是哪一层的责任。最后分享两个小建议。第一个是接口文档一定要边写代码边维护我一开始怕麻烦没写文档两周后回头改接口全靠翻代码回忆浪费了不少时间。第二个是商品列表这种查询接口可以早点考虑加缓存我是在压测时发现同样的查询反复打数据库性能明显下降才想到用本地缓存顶了一层后续可以扩展成分布式缓存方案。订单模块如果后续想接入真实支付要处理好支付回调的通知验签和幂等不要让一个支付结果被重复处理。这个项目虽然只有主体功能但把一条完整的电商链路走了一遍对理解真实业务和工程结构都很有帮助。如果你想继续深化可以从秒杀场景验证码、订单超时自动取消、商品SKU拆分这几个方向入手难度逐步增加但每一步都会让你对系统设计有更具体的认识。
阅读完成 · 觉得有帮助?
咨询建站