SpringBoot3+Vue3校园小卖部系统全栈开发实战
开学第三周接了个校园小卖部的Web开发活儿。老板给的需求很简单——学生扫个码能看到所有商品、价格能下单老板娘在后台能改价、能看订单汇总。技术栈直接定了SpringBoot3Vue3前者负责接口和业务逻辑后者负责页面交互和用户体验。项目不大但五脏俱全从数据库设计到接口联调再到部署跑通我把整个过程的思路、踩过的坑、值得记下来的点一次性写出来希望能给正在做同类全栈项目的朋友一点参考。这个项目很适合拿来练手原因有三第一业务模型贴近真实生产场景商品、库存、订单、购物车、用户登录全是企业级项目的骨架第二SpringBoot3和Vue3都是当前主流版本跟网上那些旧教程完全不同版本差异带来的坑非常典型第三小卖部场景不复杂但足够覆盖CRUD、状态流转、鉴权、文件上传这些基本能力做完之后迁移到任何管理系统都不费劲。1. 项目整体设想小卖部系统到底要解决什么问题1.1 核心需求拆解从“人”到“流程”接手项目的第一步永远是搞清楚谁在用、怎样用。校园小卖部这个场景涉及的“人”其实有三类而不是通常以为的两类。第一类是学生也就是普通用户。学生要什么不是登录之后去点一堆功能按钮而是打开页面就能看到今天有什么零食、饮料、文具价格是多少有没有库存然后像逛超市一样把东西丢进购物车最后结账。所以前端首页的第一屏必须是商品陈列而不是登录表单。第二类是老板娘也就是管理员。她的需求很朴素新增一个到货商品、修改某个商品的价格、下架临期食品、查看今天卖了多少单、以及每个商品还剩多少库存。这里面有一个细节很多人做管理系统容易忽略——老板娘不会用Excel式的表格去思考她更习惯“看得到图、点得到按钮”的直观操作。所以后台的商品管理页每个商品都要有缩略图、价格、库存数字编辑操作尽量做到原位完成不要弹出一堆表单。第三类其实是被忽略的系统本身。小卖部的营业时间固定比如上午8点到晚上10点晚上10点之后应该不允许下单库存扣减必须和下单动作强一致同时校园场景下的订单量虽然不像电商大促那样恐怖但集中在课间十分钟瞬时并发也挺可观的。这些约束在需求分析阶段就要想清楚不然后期改起来非常痛苦。基于以上拆解我把项目功能梳理成了四个模块用户端商品浏览、商品搜索、购物车管理、下单、订单查询管理端商品管理、分类管理、订单处理、库存预警公共模块用户登录与鉴权、图片上传、全局异常处理基础设施数据库初始化脚本、统一响应格式、跨域配置1.2 为什么不做成单页的小Demo而要完整分离前后端这个项目在初期有个很自然的纠结要不要用传统的Thymeleaf一个SpringBoot工程里把页面和接口全搞定毕竟小卖部的功能量不大模板引擎更省事不用处理跨域不用配Nginx部署也简单。但我还是坚持做了前后端分离而且是彻底的分离。原因不只是“Vue3是趋势”这种空话而是有实际考量的。从业务上讲学生端和管理端的使用场景完全不同。学生用的是手机浏览器课间打开要求页面轻、加载快、样式清爽。老板娘用的是店里那台老Windows电脑上的浏览器她要盯着后台数据需要的是信息密度高、操作效率高的界面。这两个端的视觉风格、交互逻辑差异很大用一个模板引擎强行塞进同一个工程代码会非常拧巴。前后端分离之后学生端是一个Vue应用管理端是另一个Vue应用后端只负责提供接口各端各司其职。从开发协作上讲前后端分离之后接口契约就变得清晰了。后端定义好Restful接口返回统一格式的JSON前端按接口文档开发页面。哪怕后面想加一个给学校后勤处看数据的大屏后端接口完全不用动前端新开一个Vite工程就能接上。从技术演进上讲SpringBoot3配合Vue3后端是Java17以上的生态前端是组合式API的写法用的都是当前主流的技术形态。做完这个项目再去看其他用同样技术栈的开源项目不会有任何版本代沟。1.3 技术栈全景SpringBoot3 Vue3 各自扮演的角色整个系统的技术栈可以分成三条线每条线都非常清晰。后端这条线SpringBoot3.2作为基础框架Java17作为运行环境Spring Web提供接口能力Spring Data JPA做数据库访问Spring Validation做参数校验Spring Security结合JWT做登录鉴权。持久层我选了JPA而不是MyBatis原因后面细说。数据库用MySQL8文件存储直接用本机磁盘目录通过Nginx做静态映射。前端这条线Vue3.4配合Vite5构建状态管理用Pinia路由用Vue Router4UI组件库选了Element Plus。请求库用Axios统一封装了拦截器处理Token注入和错误提示。样式部分用SCSS写了少量自定义变量配合Element Plus的默认主题整体风格干净。基础设施这条线开发环境用Maven管理依赖前后端分别跑在8080和5173端口通过Vite的代理解决开发期跨域。生产环境用Maven打包成Jar前端构建成静态文件放到Nginx的html目录下Nginx统一代理前端页面和后端接口。这三个部分各司其职在后续章节我会逐步展开每一个环节的实现细节。2. 技术选型解析SpringBoot3 Vue3 的组合逻辑2.1 为什么选择SpringBoot3而不是SpringBoot2这个问题其实值得掰开揉碎讲一讲因为SpringBoot3带来的不只是版本号变了而是一整套基础底座的重构。最核心的变化是Java版本基线。SpringBoot3强制要求Java17以上而Java17带来的特性对开发体验的提升非常明显。比如text blocks写SQL语句再也不用拼一长串字符串加\n了record类型定义DTO几行代码就能搞定一个原本要写一堆getter/setter的数据类switch表达式也更简洁。这些功能在Java8下完全没法用用惯了再回去写Java8会非常痛苦。第二个核心变化是Jakarta EE命名空间的迁移。这个坑最容易炸在依赖导入上。网上大量老教程里写的javax.persistence.*、javax.validation.*在SpringBoot3里全部变成了jakarta.persistence.*、jakarta.validation.*。如果你直接照抄老代码编译器会报红得怀疑人生。其实原理很简单Oracle把Java EE捐给Eclipse基金会之后由于商标问题所有包名必须从javax改成jakarta这属于一次全面但机械的迁移。第三个是Spring Security的配置方式改变了。SpringBoot3基于Spring Security6原本那些WebSecurityConfigurerAdapter已经被移除现在全部走SecurityFilterChain的Bean声明式配置。逻辑反而更清晰了但老项目迁移上来需要较大的改动量。既然SpringBoot3是当前的主流新项目起点没必要逆着潮流选老版本直接上手。2.2 持久层选型JPA还是MyBatis我为什么选JPA持久层这块Java生态里绕不开JPA和MyBatis两派之争。很多小卖部项目用MyBatis因为SQL可控想怎么写就怎么写。但我这个项目选了Spring Data JPA决策过程值得说清楚。第一个原因这个项目的数据模型继承关系少、查询相对标准。商品、订单、用户都是典型的主子表结构CRUD操作占90%以上JPA的findById、save、deleteById这类方法直接覆盖了绝大多数场景根本不需要手写SQL。JPA会根据方法名自动生成查询比如findByCategoryIdAndStatus方法名本身即是语义也是查询逻辑代码量立减。第二个原因是JPA的OneToMany、ManyToOne关系映射能自动帮我们管理关联对象的持久化。比如创建订单时订单里有ListOrderItem只要把关系配好保存订单的时候JPA会自动先保存订单主表再批量保存明细表外键也能自动关联上。用MyBatis的话这些都要手动维护多条SQL和insert语句。但是JPA并不是银弹。当遇到复杂统计SQL时比如“统计每个分类下销量前十的商品”用JPQL写起来很拗口这时候需要借助Query注解写原生SQL来兜底。我的做法是标准CRUD全部走JPA方法名推理复杂统计用原生SQL两条路都堵死了再考虑MyBatis。对于这个项目来说JPA最终把Mapper层代码节省了差不多一半。2.3 前端为什么用Vue3的组合式API而不是选项式APIVue3其实完全兼容选项式API我刚上手时也犹豫过但写了两天之后果断投入组合式API的怀抱。最直观的差异是逻辑组织方式。选项式API把同一个功能的data、methods、computed拆散到不同的代码块里改一个购物车逻辑要在三个区域来回跳。组合式API则把相关逻辑放在一起比如所有购物车的状态和方法都放在useCart这个组合函数里组件的代码变成了若干个组合函数的调用阅读体验完全是两回事。Vue3的另一个来了就回不去的特性是响应式代理。Vue2的响应式基于Object.defineProperty对数组下标修改、新增属性这些操作天然支持不完整所以总能看到this.$set这种补丁式写法。Vue3换成了Proxy代理无论新增对象属性还是修改数组元素都是自动响应式的少了很多心智负担。还有一个必须提的是script setup语法糖。它让单文件组件的代码量大幅缩减——import进来的组件直接能在模板里用不需要再配一次componentsdefineProps和defineEmits是编译器宏不用import就能用顶层的 const 变量和函数会自动暴露给模板。这套东西写起来非常爽基本就是写一个普通的JavaScript函数再配一段模板。这套技术组合整体给人的感觉是——后端代码规整、前端代码轻盈两边的开发体验都很现代。3. 数据库设计与核心表结构3.1 表设计思路怎么用最少的主子表支撑整个业务数据库是系统的地基表结构设计得好不好直接决定后续写代码是顺畅还是别扭。我画完E-R模型之后一共落成了六张表一张多余的都没有。第一张是user用户表。虽然校园场景里没有复杂的组织结构但用户表还是得保留角色字段不然学生和管理员没法区分。字段包含id、username、password存BCrypt加密后的密文、nickname、role、create_time。role就用字符串简单粗暴管理端接口统一校验角色。第二张是category商品分类表。小卖部的商品分类不多但分类是商品管理和前端导航的基础所以单独建一张表包含id、name、sort_ordersort_order用于控制前端分类Tab的排序。第三张是product商品表。这是系统的核心表字段包括id、category_id、name、description、price、stock、image_url、status、create_time、update_time。status控制商品上下架状态0表示下架1表示上架。价格单位我直接用了整数分避免浮点数计算的精度问题。比如标价3.5元存储就是350前端展示时再除以100。这个习惯是从支付系统带过来的强烈推荐。第四张是cart购物车表。购物车本质是用户与商品的关联表包含id、user_id、product_id、quantity、checked。checked字段表示这个条目在下单时是否被选中。第五张是orders订单主表。字段包括id、order_no订单编号、user_id、total_amount、status、create_time、pay_time。状态用tinyint0待支付、1已支付、2已完成、3已取消。管理端核心关注的就是这张表的status变化。第六张是order_item订单明细表。字段包括id、order_id、product_id、product_name冗余快照、price下单时快照、quantity、subtotal。之所以要冗余product_name和price快照是因为商品本身可以被管理员修改、删除但订单一旦生成交易快照必须保持当时的事实不能跟着商品变化。六张表用户体系一张、商品体系两张、交易体系三张逻辑上正好闭环。建表时我额外加了create_time和update_time两个字段配合JPA的PrePersist和PreUpdate自动维护统一审计省心很多。3.2 关键字段设计细节价格、库存、状态这些坑表设计中有几个字段的处理我觉得值得单独说说因为这些是实际跑起来才会发现的坑。价格单位。电商领域有个常识——金额在数据库层面不用浮点类型用整数分。在Java代码里也一样涉及金额的计算就用Integer或Long表示分避免0.1 0.2这种二进制浮点带来的误差。这个项目金额范围很小用Integer就够。前端展示的时候在JS里除以100并保留两位小数不会出任何精度问题。库存扣减。这个表加上stock字段很容易但并发下单时怎么扣减才是真正的考点。我的核心逻辑是UPDATE product SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}。这个语句利用数据库行锁和条件更新保证了原子性——如果库存不足影响行数为0代码里据此判断直接抛异常绝不会出现超卖。这一点我会在第四章详细展开。订单状态机。订单状态加个注释表是非常好的习惯尤其是在代码里状态都用魔法数字的情况下。0表示待支付、1表示已支付、2表示已完成、3表示已取消。状态的流转不是任意跳转待支付只能去已支付或已取消已支付只能去已完成。这个约束在后端service里做判断不要只指望前端禁用按钮。逻辑删除。商品表不要物理删除。小卖部的历史订单可能引用了某个商品物理删掉商品会导致订单详情里查不到商品名。所以我在表里加了deleted字段默认0删除操作只是把值改成1查询时JPA的Where注解自动过滤。这也是实际项目里的通用处理。3.3 初始化数据让项目跑起来的第一步数据库表建好之后我顺手写了初始化数据脚本这是让项目第一次跑起来的关键一步。初始化数据里写了两个用户一个管理员账号admin、一个测试学生账号student、六个商品分类以及每个分类下三五个示例商品。小卖部的商品数据要真实一点比如农夫山泉、乐事薯片这类价格也按市场价来这样页面渲染出来才像那么回事。这里我特别用了一个小技巧分类表的sort_order从1开始递增学生端首页的分类Tab就按这个字段排序展示把“饮料”排在最前面因为小卖部饮料是硬通货。测试数据写完直接复制到数据库工具里执行一遍确认没有外键报错再进入下一步。数据库没有初始化数据的状态下调试前端页面页面上只能是空白一片容易误判成接口问题所以初始化数据这一步千万不要省。4. 后端核心实现商品接口与订单流转4.1 统一响应结构与全局异常处理后端代码写之前我先定义了一套统一的JSON响应结构。这是所有前后端接口通信的基础约定也是我不管做什么项目都会最先搭的地基。响应结构固定为三个字段code表示业务状态码200表示成功其他数值表示各种失败message是给人看的提示信息data是真正的业务数据可以是对象、数组或null。实际返回的JSON长这样{ code: 200, message: success, data: { id: 1, name: 可乐, price: 300 } }Java侧我定义了一个泛型类ResultT提供静态方法Result.success(data)、Result.error(msg)。Controller里所有接口都返回ResultT否知前端就得手动处理各种乱七八糟的返回结构那联调起来就是灾难。有了统一响应结构下一步是全局异常处理。我在代码里加了一个RestControllerAdvice注解的类捕获三类异常业务异常自定义的BizException、参数校验异常MethodArgumentNotValidException、兜底异常Exception。这样代码里要报错时只需要throw new BizException(库存不足)框架自动转换为统一的错误JSON返回给前端不需要每个Controller里都写try-catch。4.2 JWT登录鉴权的完整实现用户登录是接口安全的门禁这个项目我用JWT做无状态鉴权过程分为三个阶段。第一阶段是登录接口。用户提交username和password后端先通过用户名查用户然后用BCryptPasswordEncoder校验密码。校验通过后生成一个JWT字符串返回给前端。JWT的payload里放了userId和role两个字段签名密钥放在配置文件里。为什么用JWT而不是Session因为校园小卖部将来很可能是多个端共用同一套后端接口——手机端、管理后台、甚至大屏JWT天然支持跨域跨端的无状态认证服务端不用存session水平扩展零成本。第二阶段是拦截请求。Spring Security里声明一个SecurityFilterChain的Bean配置哪些路径放行、哪些需要认证。我的配置是/api/user/login、/api/user/register、/api/product/**商品浏览是公开的放行其他路径都需要认证。这里有一个很关键的坑Spring Security6的csrf防护默认开启如果直接POST请求会被403拦截。解决方法是显式关闭csrf并配置SessionCreationPolicy.STATELESS同时编写一个自定义的JWT过滤器在用户名密码过滤器之前执行从请求头的Authorization字段里解析Token解析成功就把用户信息放进SecurityContext。第三阶段是权限控制。管理端接口商品管理、订单处理要求登录用户的角色必须是admin我在接口上标注PreAuthorize(hasRole(ADMIN))并开启EnableGlobalMethodSecurity由Spring Security统一拦截处理。学生端的接口只要求登录即可没有更细的权限区分这样已经够用。整套鉴权逻辑跑通之后前端只需要在登录成功后把Token存进localStorageAxios请求拦截器每次都带上响应拦截器遇到401就跳转登录页链路非常顺畅。4.3 商品管理接口从列表查询到库存扣减商品模块的后端接口覆盖了用户端和管理员端的全部需求。用户端是两个接口商品列表和商品详情。管理端是五个接口新增、修改、删除、上下架、分页查询。用户端商品列表接口的核心在条件查询——支持按分类、按关键字、按价格排序。JPA的Specification在这里派上了用场动态拼接查询条件代码比较清晰。比如前端传了categoryId就加上product.category.id categoryId条件传了keyword就加product.name like %keyword%条件。因为只查询上架商品Specification里默认加上status 1。管理端的分页查询也走了Pageable前端Element Plus的表格组件直接对接数据分页参数封装得干干净净。库存扣减的逻辑在订单模块中我会在4.4里完整展开。商品管理接口本身没有特别多的技术含量但上传图片这个点值得提一句。我实现了一个简单的文件上传接口接收MultipartFile校验文件类型和后缀只允许jpg、png、webp校验大小不超过2MB然后生成UUID作为文件名写进磁盘目录。之所以用UUID是为了避免中文文件名和重复文件名带来的各种乱码和覆盖问题。上传成功的文件URL保存在product的image_url字段里前端通过Nginx映射访问。4.4 订单流程购物车下单、库存扣减、状态流转订单模块是后端业务逻辑最重的部分我把完整链路按顺序走了一遍每个环节都有代码级别的细节。下单的入口是“从购物车提交订单”这个接口。前端会传一个商品条目列表从购物车里勾选的商品后端按照下面的步骤处理Transactional public OrderVO createOrder(OrderCreateRequest request) { // 1. 组装订单主表信息 // 2. 循环处理每个商品执行原子扣减库存 // 3. 扣减成功后创建订单明细 // 4. 计算总金额 // 5. 保存订单主表和明细表 // 6. 删除购物车中已下单的条目 }第一步很简单查出当前登录用户生成订单编号。订单编号的格式是yyyyMMddHHmmss加6位随机数没有用数据库自增id做单号因为单号需要可读性和唯一性。第二步和第三步是重点。循环每个商品条目时我得先查出商品并校验状态和库存然后用4.2提到的原子更新语句扣减库存int affected productRepository.deductStock(productId, quantity); if (affected 0) { throw new BizException(商品[ product.getName() ]库存不足); }这里的核心原理是UPDATE ... SET stock stock - :quantity WHERE stock :quantity数据库的行锁保证了同一时刻只有一个事务能修改同一个商品的库存天然防超卖。第四步计算总金额这里一定要用明细里的快照价格去算不要重新查商品价格。因为从查询商品到计算金额这个间隙里管理员可能已经改了价如果还用当时的实时价格总金额跟客户看到的不一致就会出问题。第五步把订单主表和明细表一起保存。由于我在Order实体里配置了OneToMany(cascade CascadeType.ALL)JPA会级联保存明细表外键自动填上省掉大量手动代码。第六步删除购物车条目。注意这里只要删掉下单的条目不是清空整个购物车。方法标注了Transactional意味着以上任何一步抛出异常数据库事务都会整体回滚——库存不会多扣、订单不会产生一半脏数据。严谨的数据一致性就在这里体现。订单创建之后用户端剩下的操作就是查询订单列表和订单详情。管理员端则多了一个“发货/完成”的操作把订单状态从1(已支付)改成2(已完成)。整个订单状态机的流转我用一个朴素的方式控制Service方法里先判断当前状态是否允许目标状态不允许就抛异常。状态字段用tinyint存储代码里用常量或者枚举定义避免魔法数字满天飞。5. 前端页面拆解Vue3 组件化开发5.1 路由设计与页面骨架前端的页面组织我分成了四个部分分别对应两个端的不同使用场景。学生端的首页/home是商品陈列页顶部是搜索框下面是分类Tab点击分类切换商品列表商品卡片上直接显示图片、名称、价格和库存点击“加入购物车”按钮就加入。购物车页面/cart展示所有购物车条目支持修改数量、勾选/取消勾选、结算下单。订单列表页/orders展示历史订单每张订单卡片上有状态标签、商品缩略图、总金额和创建时间。管理端的商品管理页/admin/products、订单管理页/admin/orders、分类管理页/admin/categories都是后台表格式布局。路由设计上我用Vue Router的meta字段做了登录守卫和角色守卫。全局前置守卫里先判断路由是否需要认证需要认证就检查本地有没有Token同时判断是否需要管理员角色需要的话再检查本地存储里的用户role。没登录就跳登录页非管理员访问管理端就跳首页。这个守卫逻辑非常简单可靠解决了大部分越权访问的问题。页面骨架用了一个自研的布局组件左侧是导航菜单根据角色动态渲染菜单项顶部是用户信息栏右侧是内容区。学生端和管理端共用了这个布局组件只是菜单项不同避免了大量重复代码。5.2 商品列表与购物车的核心交互学生端首页最有交互感的部分是商品卡片和购物车。商品卡片我用了一个ProductCard组件接收一个product对象作为prop。卡片上展示商品图、名称、价格、库存底部放“加入购物车”按钮。点击按钮时调用useCart组合函数里的addToCart(product)方法这个方法会调用后端接口把商品加入购物车。这里我做了一个体验优化加购成功之后页面右上角的购物车角标数字立即1不需要刷新页面。购物车页面实现时有一个非常值得记录的细节——数量输入框和库存上限。Element Plus的el-input-number组件支持min和max属性我在渲染购物车条目时把max动态设置为商品剩余库存这样用户在前端就不可能买到超过库存的数量。但前端限制只是体验优化真正的库存校验在后端下单接口里前后端双保险才能保证数据不出错。购物车选择结算时前端把勾选的条目整理成一个数组传给下单接口。这里要注意传给后端的字段productId和quantity就够了后端自己根据商品查价格前端传价格会有篡改风险。这一点我在做项目演示时经常被人问到属于非常重要的安全习惯。5.3 Element Plus后台表格的快速搭建管理端的页面开发Element Plus的表格组件功不可没。商品管理页用了el-table绑定了分页数据。每一行右侧放操作按钮编辑、上架/下架、删除。编辑操作我直接弹出一个el-dialog对话框里面是el-form表单表单有商品名称、价格、库存、分类、商品图等字段。提交表单时调用后端“修改商品”接口成功之后自动刷新表格。这整个页面核心代码不超过200行Element Plus把表格、表单、弹窗、分页的复杂性全部封装好了。订单管理页稍微复杂一点因为订单表格里有多个商品明细。我用了一行展开的功能展开之后渲染一个子表格显示订单下的所有商品明细包括商品名、单价、数量、小计。这个展开式交互在后台管理系统里非常常见Element Plus的el-table有一个typeexpand列直接支持。图片上传部分我封装了一个ImageUpload组件内部使用el-upload对接后端上传接口上传成功后把返回的URL回填到表单的image_url字段里。这里有个细节上传的预览图要加一个兜底逻辑如果图片URL为空或加载失败就显示一个默认的占位图不然商品表格里会出现一片裂开的图片图标。5.4 请求封装与用户状态管理前端所有接口请求都走了一套封装好的Axios实例这部分代码虽然不复杂但非常关键。Axios实例的配置有三件事。第一设置baseURL开发环境下用Vite代理转发到后端8080端口生产环境下用同一域名下的/api前缀第二请求拦截器里从Pinia的user store中读Token如果存在就塞进Authorization请求头第三响应拦截器统一处理返回结果code200直接返回datacode!200弹错误提示HTTP状态401清空登录状态并跳转登录页。用户状态管理用的是Pinia。userstore保存着Token、用户信息id、username、role有login、logout两个action。这个store和Vue Router的守卫之间是协作关系页面刷新时store从localStorage恢复用户信息保证路由守卫能正常判权。整个前端的状态管理就这一个store购物车和订单数据都由页面局部状态管理够用即可不折腾多余设计。6. 联调测试与常见问题排查6.1 前端跨域问题开发环境代理与生产环境Nginx前后端分离项目跨域是绕不开的第一道坎。开发环境下前端跑在5173端口后端接口跑在8080端口直接发请求会触发浏览器的跨域限制。我的解决方案是在Vite配置文件里写一个代理export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端页面里的请求都写相对路径/api/xxxVite开发服务器会把它原样转发给8080端口浏览器感知不到跨域。这个方案比在后端写CORS过滤器更优雅因为生产环境不需要CORSCORS配置只会在开发期带来安全面的暴露。生产环境下的处理方式完全不同。前端构建之后是dist目录里的静态文件后端打成一个Jar包。我在Nginx配置里做了一层转发所有/路径指向前端静态文件所有/api路径代理到后端的8080端口。这样前端访问页面和后端接口时用的是同一个域名压根不存在跨域问题。6.2 开发中遇到的典型问题与排查思路项目从零到跑通踩过的坑不少我把典型的几个问题整理成了一份简易速查表每个问题都给出了排查思路和解决方案方便大家对照参考。问题1前端请求接口一直404。排查思路是先用Postman直接请求后端接口确认后端返回正常再检查前端请求路径是否拼写错误最后看Nginx或Vite代理是否生效。这个项目里曾经出现过一次原因是前端把/api/product/list写成了/api/products/list后端没这个路径自然404。维护一份接口清单非常有必要。问题2JWT拦截器导致OPTIONS请求被拦截。浏览器跨域请求前会先发一个OPTIONS预检请求如果拦截器对OPTIONS也走了鉴权逻辑就会返回401。解决方法是自定义拦截器先判断HttpMethod.OPTIONS直接放行不处理。问题3商品图片上传成功但页面不显示。这不是接口问题而是Nginx没有配置静态文件目录的映射。后端的图片保存在/var/www/shop-images目录下Nginx里需要加location /images/ { alias /var/www/shop-images/; }再把前端页面的image地址对应的域名调整好。问题4库存扣减出现负数。出现这个问题的原因很简单就是扣减的时候没有加stock quantity的条件导致并发情况下多条请求同时把库存扣成负数。排查思路是看数据库里的日志确认UPDATE语句有没有条件。这个问题的标准化解决方案就是4.3里那个原子更新SQL。问题5Spring Security登录后接口返回403。403通常不是因为权限不足而是CSRF防护没关闭。Spring Security6默认开启了CSRF防护对于无状态JWT接口来说毫无意义必须显式关闭。这些问题合在一起覆盖了前后端分离项目最常见的几个故障类型。多做记录、多备工具遇到问题先定位再下手比混沌式debug高效得多。7. 打包部署与全流程总结7.1 前端构建与后端打包的完整步骤开发完成之后进入部署阶段。前后端分离项目的部署比单体项目多了一个构建步骤但整体流程并不复杂。前端需要先执行npm run buildVite会把所有Vue组件编译成静态的HTML、CSS、JavaScript文件输出到dist目录。这里要注意一个问题如果前端请求的后端接口前缀在路由结构里是动态配置的就不要在代码里写死baseURL。我用的方式是在构建时通过环境变量注入生产环境的Nginx把/api代理到后端前端代码里所有请求都用/api开头构建产物无需任何修改。后端执行mvn clean package -DskipTestsMaven把SpringBoot应用打成一个可执行的Jar包。SpringBoot3内嵌了Tomcat所以这个Jar可以直接java -jar跑起来默认监听8080端口。生产环境的启动命令我习惯加上一些参数java -jar shop-server.jar --spring.profiles.activeprod。配置文件里区分了dev和prod两套环境prod环境里数据库连接、文件上传目录都指向生产环境的值这样开发和生产互不干扰。7.2 部署后的自检清单部署完成不代表项目结束了上线前的自检必不可少。我的自检清单包括访问前端首页确认页面正常渲染注册一个新学生用户登录并浏览商品加入购物车、提交一个订单确认订单状态、库存扣减、购物车清空都正确用管理员登录管理后台确认订单列表里能看到刚才的订单修改一个商品的价格回到前台确认价格变了上传一个商品图片确认图片能正常访问。全部通过之后这个项目才算真正交付。7.3 项目可扩展的方向这个项目整体结构比较完整但留出了不少扩展空间。最现实的是接入微信小程序端后端接口不用怎么动前端新起一个uniapp工程就能对接也可以做一个数据大屏展示实时销售数据、热销商品排行后端新增几个统计接口就行支付接入也可以考虑但校园场景下更多的是走校园卡这需要跟学校支付系统对接涉及到的就不是技术问题了。我自己做完这个项目最大的体会是一个“小卖部系统”听起来土但它商业逻辑过滤出来的技术点鉴权、事务、并发、部署覆盖面非常广认真做一遍很容易补齐全栈链路的盲区。特别是SpringBoot3和Vue3这套新版本组合带来的版本迁移坑网上资料相对少自己踩一遍之后记忆会非常深刻。如果还有余力建议再把这个项目往工程化方向打磨一下给后端加单元测试给接口写Swagger文档给前端加ESLint规范给整个项目写README部署文档。这些看着不是功能但是在真正的工作环境里都是必须交付的内容。磨刀不误砍柴工这个项目的价值远不止于“能跑”。