Vue+Spring Boot商城系统开发实战:从设计到部署 半个多月前我接到一个“Vue基于Java的甘肃特产商城销售系统”的项目需求标题里带了“商家_d3wdv0e7”一看就是要做带商家端角色的独立商城。这类系统在实际业务里很常见——前台面向消费者卖货后台要有一个商家角色管理商品、处理订单。甘肃特产本身有很强的地域特点像兰州百合、苦水玫瑰、定西土豆、岷县当归、民勤蜜瓜这些SKU不复杂但对库存、产地、发货地和物流信息的要求比普通电商更细。整条链路从前端Vue页面到后端Java接口再到数据库表设计和部署上线每一步都有不少值得记录的坑和决策。这篇文章就把我从项目立项、技术选型、表结构设计、前后端编码到联调部署的完整过程拆开来讲希望给正在做类似商城项目或者准备用Vue加Spring Boot做前后端分离项目的朋友提供一套可以直接复用的思路。如果你是在校学生做毕业设计或者是刚接触前后端分离开发、想拿一个完整电商项目练手的开发者这篇文章尤其适合你。我不会只丢一堆代码片段而是把每个关键决策背后的原因、踩过的坑、以及最终采用的方案都写清楚。看完之后你不仅能搭起一个能跑的特产商城还能理解为什么这么设计遇到问题的时候知道从哪里排查。1. 项目整体设计与技术选型解析1.1 为什么选 Vue Java 这套组合先说选型。这套系统既然标题已经定死了Vue和Java那前端就是Vue后端就是Java生态。用Vue做商城页面组件化开发的优势非常明显商品卡片、购物车列表、订单状态标签这些模块都可以抽象成独立组件前台和商家后台还能复用一批组件。Vue Router做页面跳转Pinia或Vuex管理用户登录态、购物车数量这些全局状态开发效率远高于传统的JSP或者模板渲染方案。后端选JavaSpring Boot是绕不开的选择。特产出商城这种系统核心业务的复杂度中等真正考验的是交易的完整性、库存的准确性以及商家和用户两个角色之间的权限隔离。Spring Boot在这一点上太成熟了Spring Security做登录鉴权、MyBatis Plus做数据持久化、Redis做缓存和分布式锁每一步都有大量现成的解决方案和踩坑记录遇到问题一搜就有答案这对开发阶段来说极其重要。1.2 业务模块划分与核心流程我做这种商城类项目习惯先不急着写代码而是把业务模块拆分清楚。这套特产商城系统从大的角度分成三个角色端消费者端、商家端和管理员端。其中标题明确标注了“商家_d3wdv0e7”说明商家角色是这个系统的重点。我最终按下面这七个模块来切分用户模块注册、登录、个人信息维护包含消费者和商家两种账号角色商品模块商品信息管理、分类管理、商品上下架、库存维护商家只能操作自己的商品购物车模块加入购物车、修改数量、选中结算、删除订单模块创建订单、订单状态流转、商家发货、消费者确认收货支付模块模拟支付流程核心是订单状态从待支付变成已支付物流模块维护发货地、物流单号、物流轨迹的简单记录统计模块商家后台的销售统计、商品销量排行核心流程就两条线。一条是消费者下单线浏览商品加入购物车然后提交订单支付成功后订单推送给商家商家发货消费者确认收货并可以完成评价。另一条是商家管理线商家入驻后创建店铺上架商品处理订单查看销售数据。两条线在订单表和商品表上相交得上架构图才清晰。1.3 甘肃特产的特殊性对设计的影响这一点很容易被忽略但恰恰是这类地域特色商城和普通电商项目最大的区别。甘肃特产里相当一部分属于农副产品比如百合和蜜瓜不同批次的品质、规格、产地都可能不一样没有办法像卖手机一样完全标准化的SPU和SKU。另外运输上存在冷链需求发货地通常比较固定同一个订单里如果同时买了不同商家的商品是不能合并发货的。这对系统的直接影响是商品表必须设计产地、发货地、保质期、储藏方式这类特产特有字段订单和商品之间要支持一单多商品但订单本身要能支持按商家拆分成子订单来处理发货库存不能只做简单的下单扣减要预留对批次管理的扩展空间我见过很多同类毕设项目商品表就放一个库存字段订单表也只有一层结构看起来也能跑但如果真拿去面对特产这种品类的实际业务满身都是洞。所以我在这套系统里把表结构和流程设计做了加固后面会详细说。2. 数据库表设计与后端核心实现2.1 表结构设计从用户到订单的全链路数据库我用MySQL 8.0表结构是整套系统的地基花时间在这里绝对值得。下面这张表是我最终整理的字段清单同时我简单说明每张表背后的设计思考。用户表sys_user字段名类型说明idbigint 主键用户唯一标识usernamevarchar(50) 唯一登录账号passwordvarchar(255)BCrypt加密后的密码roletinyint1消费者 2商家 3管理员nicknamevarchar(50)昵称phonevarchar(20)手机号statustinyint1正常 0禁用create_timedatetime创建时间用户密码必须加密存储我用的BCrypt算法。这里多说一句MD5已经不能直接存密码了哪怕加了盐在实际项目中也不被推荐。Spring Security自带的BCryptPasswordEncoder就能用别自己造轮子。商家表biz_shop字段名类型说明idbigint 主键店铺IDuser_idbigint关联用户表一个用户一个店铺shop_namevarchar(100)店铺名称shop_logovarchar(255)店铺LOGO地址shop_desctext店铺简介audit_statustinyint0待审核 1通过 2拒绝create_timedatetime创建时间因为系统有商家角色我要把“用户”和“店铺”分开设计而不是在用户表里塞一堆店铺字段。这样更灵活比如一个用户未来可能开多店或者审核驳回之后反复修改都不会污染用户表的结构。商品分类表biz_category字段比较简单id、父级id、分类名称、排序值。我用单表自关联的方式实现商品分类的树形结构一级分类是特产大类比如水果、滋补品、干货零食二级分类是具体品种比如民勤蜜瓜、兰州百合、苦水玫瑰。商品表biz_product字段名类型说明idbigint 主键商品IDshop_idbigint所属店铺category_idbigint商品分类IDnamevarchar(100)商品名称cover_imagevarchar(255)商品主图detail_imagestext商品详情的多图地址pricedecimal(10,2)商品价格stockint商品库存unitvarchar(20)售卖单位比如500g/袋、2个/箱originvarchar(100)产地ship_cityvarchar(100)发货地shelf_statustinyint1上架 0下架sales_countint销量create_timedatetime创建时间商品表里特意加了origin和ship_city这两个特产商品的专属字段就是我在第一章节里说的地域特色对表结构的直接影响。detail_images字段用text类型存多张图片的URL图片之间用逗号分隔。有人会说这不符合数据库第一范式但实际做项目的时候这种非核心字段不要把表拆得太碎读取时反而高效。购物车表biz_cart字段id、用户id、商品id、购买数量、选中状态、加入时间。这里我加了选中状态字段是为了支持购物车列表上“勾选结账”的功能比全选结账灵活。订单主表biz_order字段名类型说明idbigint 主键订单IDorder_novarchar(32) 唯一订单编号user_idbigint下单用户IDshop_idbigint店铺IDtotal_amountdecimal(10,2)订单总金额pay_amountdecimal(10,2)实付金额statustinyint0待支付 1已支付 2已发货 3已确认 4已取消 5已退款receiver_namevarchar(50)收货人姓名receiver_phonevarchar(20)收货人电话receiver_addressvarchar(255)收货地址logistics_novarchar(100)物流单号logistics_companyvarchar(50)物流公司pay_timedatetime支付时间ship_timedatetime发货时间create_timedatetime下单时间订单明细表biz_order_item字段id、订单id、商品id、商品名称快照、商品图片快照、商品单价、购买数量、小计金额。大家重点看一下订单主表的关键设计——下单时把商品名称、商品图片、商品单价都做了快照存进明细表而不是直接去关联商品表。这样做的原因很朴素商品信息后续可能会修改消费者查看历史订单的时候看到的一定要是下单那一刻的商品信息而不是改完之后的信息。这算是一个电商系统设计的基础常识但毕设项目里真正做到的不多。另外我采用了order和order_item一对多的结构配合order表里的shop_id字段。同一笔业务里如果购物车选了多个商家的商品前端会按shop_id拆分成多个订单分别提交每个订单有自己的订单编号、物流编号和物流公司。这是对特产商城“多商家、多仓库、多发货地”场景的必要适配。2.2 订单状态机让状态流转不迷路订单状态管理是商城系统的核心逻辑。我在这套系统里用了状态机枚举来统一管理相比单纯在代码里写魔法数字清晰太多了。Getter public enum OrderStatus { PENDING_PAYMENT(0, 待支付), PAID(1, 已支付), SHIPPED(2, 已发货), COMPLETED(3, 已完成), CANCELLED(4, 已取消), REFUNDED(5, 已退款); private final Integer code; private final String desc; OrderStatus(Integer code, String desc) { this.code code; this.desc desc; } }状态流转我控制的比较严格待支付可以在有效期之后取消也可以主动取消待支付只有支付成功才能变成已支付已支付后商家发货变成已发货已发货之后消费者确认收货变成已完成退款的场景这里简化为已支付之后可以发起退款退款成功直接变成已退款每次状态变更的时候我会在Service层写一个校验方法判断当前状态是否允许转换到目标状态不允许就直接抛业务异常。这样能防止因为前端按钮误操作或接口参数篡改导致订单状态乱掉。2.3 商家权限隔离只能操作自己的商品“商家_d3wdv0e7”这个编号在实际项目中对应的是一套完整的商家权限体系。我没有用Spring Security特别重的权限模型而是基于JWT完成登录认证再通过业务层的强制校验做数据权限隔离。具体做法是后端在解析JWT的时候把当前登录用户的id和角色放进一个ThreadLocal上下文对象。商家操作商品、订单这些资源的时候Service层统一先从上下文里取出商家店铺ID再把店铺ID拼进查询条件。比如商家商品列表的查询语句就一定是这样LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.eq(Product::getShopId, currentShopId); wrapper.eq(Product::getShelfStatus, status ! null ? status : null);这里有一个非常关键的经验数据权限隔离一定要在Service层做不能只依赖前端的菜单隐藏和按钮控制。前端隐藏只是用户体验后端的强制校验才是真正的数据安全防线。我见过太多项目商家A修改商品ID参数直接把商家B的商品改掉了就是因为只在查询的时候带了店铺ID但更新和删除的时候漏掉了这个条件。2.4 Redis在购物车、库存和登录态中的应用Java后端我引入了Redis主要用在三个地方每一个都踩过不同层次的坑。第一是登录态。用户登录成功后生成JWT但同时在Redis里存储当前登录用户的信息设置有效期。这样如果用户被管理员封禁或者后台想强制让某用户重新登录可以直接删掉Redis里的会话实现“秒下线”。第二是购物车。理论上购物车完全可以只存MySQL但用户频繁变更购物车数量会产生大量数据库写入我用Redis的Hash结构存储购物车字段是商品ID值是数量。用户每次操作购物车只是操作Redis结算时才把购物车数据同步到MySQL生成订单。这样做还有一个额外的好处Redis的TTL机制可以自然地处理“用户加入购物车但很久没下单”的情况不用专门写定时清理任务。第三是库存的预扣减。这个方案的细节比较多我放到第4章单独讲。Redis使用上我遇到过一个特别典型的坑——用StringRedisTemplate的increment方法没问题但用RedisTemplate的时候就报错“not integer or out of range”。原因是RedisTemplate默认的序列化器是JdkSerializationRedisSerializer往Redis存数字时会把Integer对象序列化成带类描述信息的二进制字节数组Redis执行INCR命令的时候看到不是整数字符串自然就报错了。解决办法很简单配置StringRedisSerializer或者直接用StringRedisTemplate操作计数字段。3. 前端Vue实现与界面交互设计3.1 Vue项目初始化与环境配置前端项目我用的Vue 3加Vite包管理工具用的npm。Node版本我本地用的是18.17.0这个版本对Vite 4和Vue 3的兼容性都非常好。初始化命令很简单npm create vuelatest这个命令会交互式地让你选择是否需要TypeScript、Vue Router、Pinia、ESLint等特性。像这类文章面向大多数基础项目我选择的是JavaScript版本勾上了Vue Router和PiniaESLint可以先不要不然配置规范也够折腾一阵。初始化完成之后记得先跑一下依赖安装npm install npm run dev如果启动之后浏览器访问3000端口或者5173端口能看到Vue的欢迎页说明项目骨架起来了。接下来我就会安装这些项目运行时所需要的依赖npm install axios element-plus element-plus/icons-vue pinia有一个和我一样入门时很容易栽跟头的问题Node版本和项目依赖不兼容。如果版本太高很多老包会报错比如OpenSSL的错误。解决办法有两个一种是用nvm切换Node版本另一种是在package.json的scripts命令里加上一行环境变量配置但最推荐的做法还是直接装一个LTS版本的Node省心非常多。3.2 前端项目目录结构与路由设计使用Vite生成的项目默认会有src目录我在它的下面按功能做了规划src/ api/ 存放所有接口请求方法 product.js order.js cart.js user.js shop.js views/ home/ 首页 product/ 商品详情 cart/ 购物车 order/ 订单列表、确认订单 user/ 个人中心、登录注册 seller/ 商家后台相关页面 router/ index.js 路由配置 store/ user.js 用户状态 cart.js 购物车状态 components/ 公共组件 ProductCard.vue HelloWorld.vue路由设计上我保留了两种不同的布局。一种是面向消费者的前台布局包含顶部导航、搜索框和底部信息另一类是商家后台布局包含左侧菜单和右侧内容区域。这两种布局分别用Vue Router的子路由承接。const routes [ { path: /, component: () import(/layouts/DefaultLayout.vue), children: [ { path: , name: Home, component: () import(/views/home/Home.vue) }, { path: product/:id, name: ProductDetail, component: () import(/views/product/ProductDetail.vue) } ] }, { path: /seller, component: () import(/layouts/SellerLayout.vue), meta: { requiresAuth: true, role: seller }, children: [ { path: , name: SellerDashboard, component: () import(/views/seller/Dashboard.vue) }, { path: products, name: SellerProducts, component: () import(/views/seller/ProductList.vue) }, { path: orders, name: SellerOrders, component: () import(/views/seller/OrderList.vue) } ] } ]商品详情页的路径我设计了带动态参数的形式比如/product/12这里的12就是商品ID。页面内部可以用useRoute().params.id直接拿到这个参数然后请求后端接口。对于查询条件类的参数比如列表页的筛选条件我一般优先用query方式传递。3.3 商品列表与商品详情的实现细节商品列表页是整个商城的门面。首页展示推荐商品和分类商品入口我设计成一个多TAB切换的交互头部分类导航内容区是商品卡片瀑布网格。商品卡片组件ProductCard是整个前台最常用的组件商品名称、价格、封面图、销量这几个要素一个都不能少。给这个组件传入一个product对象它自己负责任何时候被点击都跳转到商品详情页。const goDetail () { router.push({ path: /product/${props.product.id} }) }商品详情页的信息层级很关键。用户通常先看主图和商品标题再决定要不要看详情最后看价格和库存。我把商品主图放在顶部轮播下面紧跟标题和信息摘要包括价格、单位、销量、产地、发货地再往下是商品详情的图片集。对于特产这类商品发货地这个信息对决策影响很大比如买百合的用户会关心是不是兰州发货、能不能发冷链。这些信息我在详情页做了醒目的展示后端接口返回什么就展示什么。3.4 购物车与订单提交流程的推进购物车页面在前端的状态管理上花了一些功夫。我用Pinia创建了一个cart store里面的核心状态是购物车列表和几个计算属性比如总价、总数量、选中状态。export const useCartStore defineStore(cart, { state: () ({ items: [] }), getters: { totalPrice: (state) state.items.filter(item item.checked) .reduce((sum, item) sum item.price * item.count, 0) }, actions: { async fetchCart() { const { data } await getCartList() this.items data } } })登录状态下购物车数据从后端接口拉取。用户点击“去结算”时前端把选中的商品列表传给后端后端校验商品状态和库存然后按商家维度拆分订单返回给前端确认。这一步有一个经验之谈下单一定要做后端二次校验前端传来的价格和库存都不能直接信任后端要根据商品表里的最新价格重新计算订单金额。3.5 商家后台商品上架、订单处理和销售统计商家后台是这套系统的重头戏。商家登录后进入/seller路由看到的是独立的布局左侧菜单项有概览、商品管理、订单管理、销售统计。商品管理页面我用了一个表格每一行展示商品缩略图、名称、价格、库存、上下架状态、销量和操作列。操作列里最重要的是“上下架”切换。这个动作调用的后端接口非常直接const toggleShelf async (row) { await updateShelfStatus({ id: row.id, status: row.shelfStatus 1 ? 0 : 1 }) ElMessage.success(row.shelfStatus 1 ? 商品已下架 : 商品已上架) loadData() }商家添加商品用的是对话框表单。因为特产的属性比较多我分成了三个分组基础信息名称、分类、封面图、销售信息价格、库存、单位、产地物流信息产地、发货地、保质期、储藏方式。图片上传直接调后端封装好的upload接口接口返回图片URL后回填到表单字段里。订单管理页面向商家展示自己店铺的订单默认按待发货订单优先排序。商家看到已支付订单后点击“发货”按钮弹出填写物流公司和物流单号的对话框提交之后订单状态从1变成2。这个操作在真实业务中几乎每天都要做所以交互流程我尽量压缩到两次点击之内点击发货填单号提交完事。销售统计页我用了一个简单的柱状图和表格展示最近7天或者30天的订单量和销售额。后端从订单表里按日分组聚合前端用ECharts画图。对于商家来说“昨天卖了多少”“哪些商品走得好”是最直观的诉求这个页面不需要太复杂直接给结果。3.6 前端权限控制与路由守卫前端路由守卫我用的Vue Router全局前置守卫。每次页面跳转前统一做两件事检查路由是否需要登录检查登录用户的角色是否匹配。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.matched.some(record record.meta.requiresAuth)) { if (!token) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (to.meta.role !hasRole(to.meta.role)) { next({ path: / }) return } } next() })未登录用户访问购物车、订单、商家后台这些页面会被统一踢到登录页。登录成功后带着redirect参数跳回原目标页面这是所有系统都应该有的基本体验。4. 前后端联调、接口设计与部署上线4.1 接口文档规范与统一响应结构前后端联调最大的问题不是写接口而是接口格式不统一。我在后端的Controller层规定了一个统一的响应类ResultData public class ResultT { private Integer code; private String message; private T data; }code为200表示成功401表示未登录或登录过期500表示业务异常。前端封装的axios实例统一拦截这个结构service.interceptors.response.use( (response) { const res response.data if (res.code 200) { return res } ElMessage.error(res.message || 请求失败) if (res.code 401) { router.push(/login) } return Promise.reject(new Error(res.message)) }, (error) { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )接口命名我尽量走RESTful风格。商品相关接口有GET /api/product/page查询分页、GET /api/product/{id}查询详情、POST /api/product新增商品、PUT /api/product/{id}修改商品、PUT /api/product/shelf/{id}上下架商品。这样接口语义清晰前端调用的时候做什么操作、用什么方法一目了然。4.2 跨域问题开发环境与生产环境的两种解法前后端分离项目一定会遇到跨域问题。本地前端跑在5173端口后端跑在8080端口浏览器会把它们当成两个不同的源前端请求后端接口就会触发跨域拦截。开发环境我用Vite的代理解决跨域在vite.config.js里配置export default defineConfig({ server: { host: true, port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })生产环境我用Nginx做反向代理把域名下的/api路径转发到Java后端。这样最终对外只有一个源不存在跨域问题同时也把静态资源和后端服务统一到一个入口更安全也更好管理。4.3 接口鉴权JWT的接入解析用户登录成功后后端签发一个JWT令牌返回给前端。前端把令牌存到localStorage在axios请求拦截器里带上。service.interceptors.request.use( (config) { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }, (error) Promise.reject(error) )后端用一个拦截器统一解析Header里的令牌校验通过就把用户信息放到ThreadLocal里校验失败直接返回401错误码。要注意的是图片上传接口、商品列表这种公开接口要单独加到白名单里否则用户还没登录就看不到就算了连商品图片都加载不出来体验会很差。4.4 Vue项目打包与后端部署前端开发完成后执行npm run buildVite会生成一个dist目录里面是压缩优化后的静态文件。部署我推荐两种方案第一种直接把dist目录里的文件扔给Nginx由Nginx托管静态资源同时配置/api反向代理到Java服务。这是生产环境最标准的做法静态资源访问快后端负载也小。第二种把dist目录拷贝到Spring Boot项目的src/main/resources/static目录下然后重新打包成一个jar这样可以一包部署比较适合毕设演示或者小型项目。但缺点也很明显前端任何修改都要重新编译后端jar灵活性差不少。我这里推荐第一种方案并且给出一个Nginx的最小配置参考server { listen 80; server_name yourdomain.com; root /opt/shop-front/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有一个Vue Router配history模式的经典坑直接访问比如/seller/products这个地址Nginx会发现磁盘上根本没有这个文件返回404。所以location /里必须加try_files $uri $uri/ /index.html让所有请求都回退到入口文件剩下的路由交给前端处理。5. 常见问题与排查技巧实录5.1 后端必踩环境变量与连接配置问题Java开发第一步就是装JDK和配置环境变量。很多人卡在环境变量配置这一步我的经验就三条JAVA_HOME指向JDK安装目录PATH里加上%JAVA_HOME%\bin然后确定CLASSPATH不用配置不要画蛇添足。配置完之后在命令行输入java -version能出版本号就没问题。Spring Boot项目连接MySQL时数据库连接串建议按下面的模板写spring: datasource: url: jdbc:mysql://localhost:3306/shop_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 data: redis: host: localhost port: 6379 password:characterEncodingutf8控制中文不乱码serverTimezoneAsia/Shanghai解决时区差8小时的问题allowPublicKeyRetrievaltrue解决MySQL 8.x版本的公钥检索报错。这些都是我实际配过的少任何一个都会在运行中冒出奇怪的问题。5.2 前端必踩Element Plus按需导入与ElMessage未定义热词里反复出现的“为什么ElMessage还是提示未定义”这个问题我当初也被卡了半天。Vue 3项目用Element Plus做按需导入时经常配置unplugin-auto-import和unplugin-vue-components。组件类型的模块能自动导入但ElMessage、ElMessageBox这类函数式组件非常特殊它们不是模板里声明的组件而是在JavaScript里直接调用的因此自动导入插件经常识别不到运行时就报ElMessage is not defined。我总结下来最稳的处理方式在main.js里全量引入Element Plus的样式和JavaScript同时仍然使用自动导入组件来减小打包体积还是有点得不偿失不如直接在报错的文件顶部手动引入一次import { ElMessage } from element-plus这样虽然写起来多了一行但绝对稳定不会在任何诡异场景下失效。5.3 启动问题vue项目启动后Network不可用Vite启动项目之后默认只在localhost上监听。如果想让局域网里其他设备也能访问调试页面需要在vite.config.js里加host: trueserver: { host: true }加了之后启动日志会多出一行Network: http://192.168.x.x:5173用这个地址在手机或者另一台电脑上就能访问了。遇到启动后Network不可用还有一个高概率原因是公司或校园网络启用了AP隔离这个属于环境限制换一个网络环境就能解决。5.4 功能问题库存并发扣减处理库存扣减是商城系统的经典并发问题。直接写UPDATE语句在低并发场景下没问题但两个用户同时下单同一商品最后一件时可能出现超卖。我在这套系统里用数据库乐观锁和Redis预扣减两层配合来处理。Redis预扣减的流程是用户提交订单时先执行Redis的DECR操作如果减完结果不小于0说明库存充足允许下单否则恢复库存并拒绝。支付成功后再异步扣减MySQL里的真实库存。如果订单超时未支付或用户主动取消就执行INCR恢复Redis库存。public boolean reduceStockWithRedis(Long productId, Integer count) { Long remain redisTemplate.opsForValue() .decrement(STOCK_KEY_PREFIX productId, count.longValue()); if (remain 0) { redisTemplate.opsForValue().increment(STOCK_KEY_PREFIX productId, count.longValue()); return false; } return true; }MySQL的UPDATE语句则配合库存字段校验UPDATE biz_product SET stock stock - #{count} WHERE id #{productId} AND stock #{count}这种做法能应对绝大多数中小商城的并发量不用引入分布式锁这种重型方案。我把这次项目过程中踩过的坑整理成一个速查表给需要的朋友直接对照问题现象根本原因解决方案ElMessage is not defined按需导入识别不了函数式组件手动import { ElMessage }Network不可用Vite默认只监听localhost配置server.host: trueRedis的increment报错not integerRedisTemplate默认JDK序列化换StringRedisTemplate前端页面刷新404history模式缺少回退Nginx加try_files中文乱码连接串没有指定编码加characterEncodingutf8商家能操作别人的商品数据权限只卡了查询没卡更新Service层强制加shopId条件电商系统这类项目最大的特点就是链路长、细节多。从数据库表设计到前端Vue组件从Redis的运用法则到Nginx的配置方式每一个环节都会遇到各种问题。做完这套甘肃特产商城系统我最大的体会是选型定下来之后真正决定项目能不能顺利跑起来的往往都是那些不起眼的细节序列化方式、时区配置、路由回退、状态流转校验。希望这篇记录能帮你少走一些我已经走过的弯路至少在这几类经典问题上能直接把我验证过的方案抄过去用剩下的时间就专心去打磨你自己的业务逻辑和用户体验就好。