Spring Boot+Vue 3家具商城实战:数据库设计、库存扣减与Docker部署
1. 家具商城这个选题难的不是商城而是家具先说个有意思的现象。每年到毕设季或者想自己做点东西充实简历的时候商城系统永远是出现频率最高的题目。书城、电商城、服装城、二手交易平台改个名字就是一套新的。但如果你真打算做一个能拿得出手的家具商城Spring Boot Vue 3项目我的建议是别急着写代码先把家具这两个字琢磨透。为什么这么说因为普通电商和家具电商的系统设计差距远比你想象的大。家具商品有几个非常典型的特征是图书、数码产品甚至服装都不太会遇到的。第一体积和重量是核心交易要素。一张实木床和一个蓝牙耳机在下单流程里涉及的信息密度完全不同。耳机你只需要管SKU和库存床你得管尺寸、重量、是否包安装、有没有电梯、能不能进楼道。这些信息如果不在商品详情和下单页面里体现用户买回去发现进不了门售后纠纷直接拉满。第二非标属性复杂。同一款沙发可能有三人位布艺款三人位真皮款四人位L型款颜色、材质、尺寸排列组合起来就是一堆SKU。如果数据库设计阶段没把SKU的维度和库存关系理清后面加属性就是牵一发动全身。第三订单履约链路长。普通快递物流和家具的大件物流、上楼安装、旧家具处理完全是两套体系订单状态机如果只设计成待付款-已付款-已发货-已完成这种四态模型基本撑不住真实业务。说白了家具商城是一个商品模型复杂 订单模型复杂 售后模型复杂的综合体。把这三个复杂度吃下来你收获的绝不是一个花架子demo而是一套能迁移到任何中大型业务系统的建模思路。这也就是为什么我建议你选家具这个垂直品类练手而不是又做一个消费电子商城。这篇文章我会按自己做这个项目时的真实推进顺序来讲从技术选型到数据库设计到前后端核心代码再到部署上线最后是几个容易翻车的坑。文章里出现的所有代码思路都基于Spring Boot 3.x Vue 3 MySQL Redis这套经典组合你可以直接照着搭。2. 技术选型不是跟风Spring Boot 和 Vue 3 各自解决什么问题做技术选型的时候我见过太多人纠结Spring Boot到底比Spring MVC强在哪Vue 3比Vue 2难用这类问题。其实你换个角度想就通了选择框架的本质是选择一套解决协作效率和工程化问题的规范。2.1 Spring Boot 在后端承担的角色如果你用过早期的Spring项目你一定记得那些繁琐的XML配置还要自己维护Tomcat、手动处理依赖版本冲突。Spring Boot最核心的价值就是把配置这件最没有技术含量又最耗时间的事情从你身上拿走了。它通过自动装配机制根据你在pom.xml里引入的依赖自动帮你配置好数据源、事务管理器、Web容器、JSON序列化组件等等。这就是为什么你写一个spring-boot-starter-web起步依赖什么都不用配直接启动就能访问到Controller返回的接口。自动装配的原理其实不复杂SpringBootApplication注解里内置了EnableAutoConfigurationSpring Boot启动时会去读取META-INF/spring.factories或AutoConfiguration.imports文件里声明的自动配置类然后通过ConditionalOnClass、ConditionalOnMissingBean这类条件注解决定这个组件在什么情况下才帮你配好。以用户登录为例你会引入spring-boot-starter-security或者自己写JWT拦截器。如果你用Spring Security的自动配置它会默认帮你生成一个登录页面和密码校验规则但你实际的登录逻辑肯定要自定义于是你实现UserDetailsService接口覆盖它的loadUserByUsername方法Spring Boot检测到你自己的实现后就会放弃默认的直接使用你提供的。这就是ConditionalOnMissingBean的作用你只要提供更具体的实现框架就不会再自作主张。2.2 Vue 3 在前端承担的工程化能力再说Vue 3。不夸张地说组合式APIComposition API是把Vue项目的组织方式从散装零件升级成了模块化组装。Vue 2时代我们习惯了在data里放状态、在methods里放方法、在watch里监听变化一个功能的变化逻辑被拆散到三个地方。写过稍微复杂点的后台管理系统的人一定体会过为了改一个搜索功能要在模板、数据、方法、监听器之间来回跳的痛苦。Vue 3的组合式API让你可以按功能维度组织代码把某个业务模块涉及的状态、方法、计算属性放在一个setup区域里这种组织方式对后台管理系统这种表格多、表单多、弹窗多的场景简直太友好了。同时Vue 3的响应式系统也换了底层实现用Proxy替换了Object.defineProperty所以对象新增属性、删除属性、数组索引修改都能被响应式追踪到不再像Vue 2那样动不动就改了数据但视图不更新。这个特性在做购物车修改数量、商品筛选多条件联动时特别实用少踩很多手动this.$set才能刷新的坑。2.3 前后端分离的协作边界单说框架还不够得把前后端分离这个工程模型的边界理清楚。这套系统里后端的职责是提供稳定的数据接口、处理事务和并发、保证数据安全。前端的职责是管好页面路由、交互状态、用户体验。两者通过HTTP接口用JSON通信不共享模板不共享Session。实际开发中我会遵守三条约束。第一后端接口必须幂等。特别是下单和支付回调这类接口网络重试可能导致重复提交后端要做防重处理。第二前端所有请求统一走封装的axios实例baseURL、请求拦截器自动携带token、响应拦截器统一处理401跳转、错误提示都集中管理不要在页面里到处写axios.get。第三接口文档先行。就算只有两个人开发也要先定义好/api/goods/page、/api/goods/detail/{id}、/api/cart/add、/api/order/submit这些接口的路径、参数、返回值再各自开发。前后端联调大部分吵架的场景本质都是接口约定没对齐。3. 数据库设计家具 SKU、库存与订单状态机的串联逻辑如果你去网上搜商城系统数据库设计会看到很多标准答案用户表、商品表、订单表、订单项表各表主键外键一连看似完整。但放到家具场景里这套模型会在商品规格和物流履约上直接漏风。3.1 商品模型SPU、SKU与家具属性表先说结论我的设计是四张核心表product_spu商品标准单元、product_sku库存量单元、product_attribute属性定义、product_attribute_option属性选项。SPU对应你看到的北欧简约实木双人床SKU对应原木色-1.8m-裸床款原木色-1.8m-床床头柜套餐胡桃色-1.5m-裸床款这种具体可下单的规格。属性表存的是颜色尺寸材质是否含床头柜这种属性名属性选项表存的是每个属性下的具体值。SKU通过sku_attrs字段可以存JSON也可以拆成中间表取决于你属性筛选的复杂程度关联到具体的属性值组合。家具商品还有两个普通商品不需要的字段volume_weight体积重和install_fee安装费。体积重是物流计费的依据大件家具走的是按体积重计费的物流渠道不是按实际重量。安装费则要区分免费安装付费安装不提供安装三档。这两个字段如果在商品表上不单独建模后面算运费、算订单金额的时候会非常痛苦。3.2 库存模型总库存与可售库存分开家具商城的库存有个特点同一个SKU可能在多个仓库有货而每个仓库的现货数量是独立变动的。所以我的库存设计走的是warehouse仓库表sku_stockSKU库存表模式sku_stock里存total_quantity总库存和locked_quantity锁定库存。用户下单时不是直接扣减total_quantity而是先增加locked_quantity等支付成功后才真正扣减total_quantity并减少locked_quantity。这个先锁后扣的模式很关键它能避免用户在支付期间库存被其他订单抢走也能支撑下单未支付保留库存15分钟这种业务规则。库存表里的available_quantity可售库存我建议用total_quantity - locked_quantity这样的计算逻辑实时算不要冗余一个字段。因为冗余字段就要处理同步问题Redis缓存失效、数据库更新失败容易数据错乱。3.3 订单状态机状态与操作分离订单表是最能体现系统成熟度的。我的order_info表里把订单状态设计成了这样几档PENDING_PAYMENT待付款、PAID已付款、PENDING_DELIVERY待发货、DELIVERING配送中、COMPLETED已完成、CANCELLED已取消、REFUNDING退款中、REFUNDED已退款。这里要提醒一个容易踩坑的点状态字段存的是状态码但引起状态变化的动作事件要单独记录。比如已付款状态可能是用户主动支付触发的也可能是客服在后台手动标记已收款触发的不同的触发方式对应不同的操作人、操作时间和资金来源记录。所以我会额外建一张order_status_log表每次订单状态变更都插入一条日志。这不仅是为了后面对账查证也是售后纠纷时保护自己的凭证。订单状态流转里最绕的是退款流程。家具商品的退款经常不是全额退款而是按比例退款。比如用户收到货发现有磕碰客服协商补偿200元这时候订单金额、实际支付金额、退款金额就出现三个不同数值了。我的建议是订单表里冗余total_amount订单总额、pay_amount实付金额、refund_amount累计退款金额三个字段退款单单独建refund_order表每次退款都走独立的退款单不和主订单状态耦合死。如果直接把退款金额扣到主订单状态上你后面做数据统计时会崩溃。4. 后端核心落地从商品检索到下单扣库存的链路实现数据库设计完就到了最考验功底的部分。我挑三个最核心的接口链路来讲商品列表、购物车、下单扣库存。这三个链路把Spring Boot里的Controller、Service、事务管理、Redis缓存、并发控制全串起来了。4.1 商品分页查询与多条件筛选家具商城的商品筛选维度通常是分类、材质、价格区间、尺寸范围、颜色。多条件筛选如果直接在SQL里写where拼接条件一多久会变成重灾区。我用的方案是MyBatis-Plus的LambdaQueryWrapper动态拼接加上ES或MySQL的全文索引二选一。对大部分个人项目来说直接用MySQL的LIKE加组合索引就够了没必要为了检索单独引入Elasticsearch除非你的商品数据量到百万量级。Controller层我用的是一个很常规的分页接口RestController RequestMapping(/api/goods) public class GoodsController { GetMapping(/page) public ResultPageSpuVO page(RequestBody GoodsQuery query) { return Result.success(goodsService.pageQuery(query)); } }Service层注意几个细节。-分类筛选如果分类表有父子层级查询时要包含子分类ID不能只查当前分类。家具分类通常不超过三级家具-卧室家具-床用一个递归查询把子分类全部取出来。价格区间前端传的是minPrice和maxPrice后端要做参数校验防止minPrice大于maxPrice。排序我提供了sortField和sortOrder两个参数字段白名单校验不能允许前端传任意字段进去拼SQL否则就是SQL注入点。分页查询的缓存策略也要讲清楚。商品列表这种高频读接口我会做Redis缓存缓存Key设计为goods:page:{queryHash}其中queryHash是查询条件的MD5值。缓存更新策略是创建缓存 定时过期商品修改时主动删除相关缓存而不是直接更新等下次查询再回填。这个方案比更新缓存简单也更不容易出现并发写缓存导致的数据不一致。4.2 购物车用Redis还是数据库购物车这个模块很多人会纠结用Redis还是存数据库。我的经验是未登录购物车存Redis登录后购物车合并进MySQL。未登录用户的购物车数据用Redis的Hash结构存储Key为cart:token:{sessionId}Field为SKU IDValue为数量。这样做的原因是未登录状态下没有用户主键Redis按Token维度存储最适合。用户登录后前端把本地购物车数据传给后端后端做两件事一是把这些数据合并进MySQL的cart_item表二是清空Redis里的临时购物车。cart_item表的设计不复杂id、user_id、sku_id、quantity、checked是否选中、create_time、update_time。唯一要注意的是user_id和sku_id要建联合唯一索引防止同一个用户同一个SKU出现多条记录。每次加购操作都使用ON DUPLICATE KEY UPDATE这类存在则更新不存在则插入的写法避免先查再插入导致的条件竞争。加购接口的伪代码逻辑是Transactional public void addCartItem(Long userId, Long skuId, Integer quantity) { CartItem item cartItemMapper.selectByUserIdAndSkuId(userId, skuId); if (item null) { CartItem newItem new CartItem(); // setUserId, setSkuId, setQuantity cartItemMapper.insert(newItem); } else { cartItemMapper.incrementQuantity(userId, skuId, quantity); } }这里用Transactional的意义在于如果插入或更新过程中抛异常整个操作回滚购物车数据不会出现半截状态。你可能觉得一个加购操作没那么大风险但别忘了加购时通常还会同步校验SKU状态是否下架、库存数量是否还有货这些校验和写操作必须保证原子性。4.3 下单链路库存锁定与事务边界下单是整个系统最核心的链路。我的设计顺序是参数校验 - 查询SKU信息 - 锁定库存 - 创建订单 - 创建订单项 - 清空购物车对应商品 - 返回订单ID。每一步失败都要抛异常回滚。先看下单时锁库存的SQL这地方稍不注意就出超卖事故。第一种写法也是很多教程里的写法Stock stock stockMapper.selectBySkuId(skuId); if (stock.getAvailable() quantity) { stock.setAvailable(stock.getAvailable() - quantity); stockMapper.updateById(stock); }这种先查再改的写法在并发下必出问题。两个请求同时查到库存都是10都认为可以扣减都走完更新最后数据库里库存变成8但实际卖出了20件。这就是经典的超卖。正确做法是用一条SQL完成条件更新利用数据库行锁保证原子性Update(UPDATE sku_stock SET available_quantity available_quantity - #{quantity}, locked_quantity locked_quantity #{quantity} WHERE sku_id #{skuId} AND available_quantity #{quantity}) int lockStock(Param(skuId) Long skuId, Param(quantity) Integer quantity);这个SQL的关键在于AND available_quantity #{quantity}如果库存不够更新影响行数为0直接抛异常提示库存不足。用受影响行数判断是否成功而不是先查库存再判断这就是数据库层面的乐观锁思想——用条件约束代替独立校验。高并发场景下你可能还想加Redis分布式锁但我负责任地说对个人项目和中小型商城系统上述SQL方案已经足够。分布式锁是用来解决跨服务、跨实例的并发问题单体应用里把它用上属于过度设计还会引入Redis宕机后锁失效的新风险。订单创建和库存锁定必须放在同一个事务里否则会出现库存扣了但订单没生成或者订单生成了但库存没扣的数据不一致。Spring Boot里用Transactional在Service方法上配合默认的REQUIRED传播级别即可调用链中所有数据库操作都会在同一个事务里执行。需要注意的是如果锁库存方法在另一个类里要确保它不被同类调用绕过代理否则Transactional不会生效——这是Spring AOP的经典坑自查一下自己的代码。4.4 支付回调与订单状态翻转接入支付微信支付/支付宝时最核心的逻辑不在发起支付而在异步通知处理。支付平台会异步通知你的回调接口告诉你这笔订单支付成功了。这个回调接口有几个铁律幂等性同一个支付通知可能被平台推送多次回调接口必须做到重复通知不产生副作用。验签必须用平台公钥对通知内容做签名校验防止伪造回调。金额校验回调里的支付金额必须和订单表里的pay_amount一致否则拒绝更新订单状态。伪代码如下PostMapping(/api/pay/notify) public String notify(RequestBody String notifyData) { // 1. 验签 if (!payService.verifySign(notifyData)) { return failure; } // 2. 解析并查询订单 PayNotifyVO vo payService.parseNotify(notifyData); OrderInfo order orderService.getByOrderNo(vo.getOrderNo()); if (order null || !order.getPayAmount().equals(vo.getPayAmount())) { return failure; } // 3. 如果订单未支付则更新状态 if (OrderStatus.PENDING_PAYMENT.equals(order.getStatus())) { orderService.markPaid(order.getId(), vo.getTransactionId()); } return success; }注意第3步的判断OrderStatus.PENDING_PAYMENT这是幂等的基础——只有待付款状态的订单才能翻转为已付款如果订单已经是已付款状态说明是重复通知直接返回成功即可。5. Vue 3 前端骨架路由守卫、状态管理与家具商品展示的实现经验后端接口准备得差不多了前端这块我按Vite Vue 3 Pinia Vue Router Element Plus这套组合来讲。家具商城的C端页面一般包含首页、商品列表页、商品详情页、购物车页、结算页、订单列表页后台管理端则包含商品管理、订单管理、用户管理、数据统计。两边共用的基础设施是请求封装、路由守卫和全局状态。5.1 工程结构与请求封装前端项目我用Vite搭建目录结构上有一件事一定要尽早做把页面组件和业务逻辑分开。src/api/下按模块建文件src/api/goods.js封装所有商品接口的调用src/api/order.js封装订单接口。组件里不直接写fetch或axios全部通过api模块调用。这样做的直接好处是后端接口路径变了你只需要改一个文件而不是在几十个组件里做文本替换。axios封装是我每次必强调的部分。核心是拦截器const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code 401) { router.push(/login) return Promise.reject(new Error(未登录)) } if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )注意401的跳转必须放在拦截器里做而不是每个页面自己try-catch。如果你在每个请求里都写一遍如果状态码是401就跳登录页代码维护成本会直线上升。统一处理后页面里只关心业务成功或失败。5.2 路由守卫与权限控制家具商城的前台和后台权限是分开的。前台用户和后台管理员都走同一个后端但权限边界不同。我的设计是/admin开头的路由需要管理员权限其他路由需要用户登录下单、购物车、订单页。Vue Router的全局前置守卫写法router.beforeEach((to, from, next) { const token localStorage.getItem(token) const roles JSON.parse(localStorage.getItem(roles) || []) if (to.path.startsWith(/admin)) { if (!token) { next(/login) } else if (!roles.includes(ADMIN)) { next(/403) } else { next() } } else if (to.meta.requiresAuth) { if (!token) { next(/login?redirect to.fullPath) } else { next() } } else { next() } })有个细节容易被忽略登录跳转时要带redirect参数。用户访问购物车页时被踢到登录页登录成功后应该回到购物车页而不是永远停在首页。很多教程里没写这个我第一版也没写被自己测试时踢了好几次才补上。登录态保存我用的是localStorage存储token后端每次请求通过Header里的Authorization校验。你可能会问为什么不放pinia里因为Pinia的状态在刷新页面后会丢失而localStorage是持久化的。通常的做法是Pinia负责运行时的用户信息缓存localStorage负责持久化token和用户信息。两者结合避免刷新页面后用户信息丢失导致页面渲染异常。5.3 家具商品详情的展示交互家具商品详情页比普通商品复杂的地方在于需要直观展示尺寸信息、材质信息、安装信息、不同SKU组合下的价格变化。SKU选择这块我遇到的坑是当属性维度超过两个时选中一个属性后被禁用的其他属性如何计算。我用的是矩阵思路。先在前端把当前商品所有SKU组合加载出来存成一个数组数组里每个元素包含attrs对象和对应的价格、库存。当用户点击某个属性值时遍历剩下的属性组合看是否存在同时满足已选属性和当前属性的SKU如果没有就置灰禁用。这块逻辑并不难难的是数据量大的时候性能优化。家具商品的SKU组合通常不会超过几十个前端全量计算完全够用。购物车和订单提交时前端需要传递的字段要清晰skuId、quantity、checked。结算页还要展示商品的可选配送方式和安装服务。这些信息如果后端接口没返回前端就只能硬编码所以商品详情接口的返回结构设计很重要。我的/api/goods/detail/{id}返回结构大致是{ spuInfo: { id: 1, name: 北欧实木双人床, mainImage: https://..., detailImageList: [], serviceTags: [免费安装, 三年质保] }, skuList: [ { id: 101, specs: { 颜色: 原木色, 尺寸: 1.8m }, price: 3299.00, stock: 15, image: https://... } ], freightInfo: { freightType: VOLUME, volumeWeight: 0.8, defaultFreight: 120 }, installInfo: { installType: FREE, installFee: 0 } }这个结构保证前端拿一次接口就能渲染完整个详情页和SKU选择器不用按用户点击再逐个请求。6. 本地跑通到上线部署Spring Boot Jar 包与 Vue 构建物的 Docker 部署实践开发环境跑得再好不部署到服务器上总感觉项目没做完。我建议这个项目从一开始就按Docker部署来设计省掉后面在服务器上手动装JDK、MySQL、Redis、Nginx的繁琐过程。6.1 后端镜像制作Spring Boot项目执行mvn clean package后生成可执行的jar包。Dockerfile非常简单FROM openjdk:17-jdk-slim COPY target/furniture-mall.jar /app/furniture-mall.jar WORKDIR /app EXPOSE 8080 ENTRYPOINT [java, -jar, furniture-mall.jar, --spring.profiles.activeprod]这里选openjdk:17-jdk-slim是因为Spring Boot 3.x要求Java 17以上。如果你的项目用的Spring Boot 2.x需要把JDK版本降到openjdk:8或openjdk:11否则启动会直接报错。这是很多人在部署环节翻车的第一大原因本地用的JDK版本和基础镜像的JDK版本不一致。6.2 前端构建与Nginx配置Vue 3项目执行npm run build后生成dist目录里面是纯静态文件。Docker部署前端的思路是用Nginx镜像托管这些静态文件同时把/api路径的请求反向代理到后端服务。FROM nginx:1.25-alpine COPY dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80nginx.conf里的关键配置server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend: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; } }try_files $uri $uri/ /index.html;这一行非常重要。Vue Router默认是history模式路由路径是/goods/detail/1这种刷新页面时Nginx会去磁盘找/goods/detail/1这个文件找不到就404。加上这行配置后找不到文件就回退到index.html由前端路由接管。proxy_pass http://backend:8080;里的backend是Docker Compose里的服务名容器之间通过服务名通信。如果你不用Docker Compose这里就要填后端所在服务器的实际IP不能填localhost——因为在Nginx容器里localhost指的是Nginx容器自己不是宿主机。6.3 Docker Compose 编排我用一个docker-compose.yml把MySQL、Redis、后端、前端四个服务串起来version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: furniture_mall ports: - 3306:3306 volumes: - mysql_data:/var/lib/mysql restart: always redis: image: redis:7.0 ports: - 6379:6379 restart: always backend: build: ./backend depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql DB_PORT: 3306 DB_USERNAME: root DB_PASSWORD: root123 REDIS_HOST: redis ports: - 8080:8080 restart: always frontend: build: ./frontend ports: - 80:80 depends_on: - backend restart: always volumes: mysql_data:这个编排文件把环境变量传给容器后端配置里通过${DB_HOST}这类占位符读取。用过Docker的朋友应该知道容器里的环境变量比硬编码在application.yml里灵活得多——你换环境的时候不用重新打包只要改环境变量就行。6.4 部署后的常见问题部署完系统后我在测试阶段踩过几个比较有价值的坑你可以提前规避。跨域问题前端通过Nginx的/api代理访问后端浏览器里看到的全是同源请求所以不需要在后端配置CORS。但如果是本地开发环境前端在localhost:5173后端在localhost:8080就需要后端配置CorsFilter允许跨域或者在Vite的server.proxy配置代理。我的建议是开发用Vite代理生产用Nginx代理后端基本不碰CORS配置。数据库时区问题连接MySQL时一定要在JDBC URL里配置serverTimezoneAsia/Shanghai否则时间字段会差8小时。这是国内开发者几乎人人踩过的坑。前端白屏如果Vue页面部署后打开是白屏打开控制台看报错多半是静态资源路径问题。Vite默认base是/如果部署在域名子路径下需要在vite.config.js里设置base: /子路径/。7. 复盘家具商城项目里最值得警惕的六个翻车点最后把这套项目做完后我总结的几个经验写出来。这些话你在官方文档里看不到但非常管用。7.1 事务注解失效Transactional只在通过Spring代理调用时生效。如果一个类的方法A调用了同类的方法BB上的Transactional是不生效的因为调用发生在对象内部没有经过代理。解决方法是把事务方法拆到另一个Service类里或者自己注入自己不推荐。排查方法很简单在事务方法里故意抛个异常看数据是否回滚不回滚就是注解没生效。7.2 超卖问题不止一种表现超卖不仅出现在库存扣减也出现在订单号生成。如果你用SimpleDateFormat格式化时间戳拼订单号在高并发下可能生成重复订单号。我用的方案是Redis的INCR命令生成自增序号配合日期前缀比如20250101120000000001。Redis的INCR是原子操作不会出现重复。7.3 JSON 序列化循环引用如果商品对象里关联了分类对象分类对象里又关联了商品集合直接用Jackson序列化就会StackOverflow。解决方式有几种在关联字段加JsonIgnore或用JsonManagedReference和JsonBackReference配对或改用DTO直接控制返回字段。我的建议是能用DTO尽量用DTO把返回结构牢牢掌握在自己手里而不是让实体类直接暴露给前端。实体类里可能存在的密码、手机号等敏感字段一旦直接返回就成数据泄露了。7.4 前端防重复提交下单按钮如果用户手抖点两次就会生成两个订单。前端有几种处理方式按钮加loading状态置灰、提交前做节流、或者后端做防重令牌。我前后端都做了前端点击后立即置灰后端在创建订单前校验Redis里是否存在相同token的提交记录。最强的防重逻辑永远在后端前端只是个体验层面的保险。7.5 日志记录要尽早加项目中要尽早引入AOP统一日志切面记录每个接口的入参、出参、耗时和异常信息。不要等出了问题再去补。我用的是Spring Boot的HandlerInterceptor加Around环绕通知把日志输出到控制台和文件。排查线上问题时日志就是唯一的线索。没有日志的线上系统出了问题只能靠猜猜的代价往往是几天起步。7.6 分页查询的深坑分页查询最常见的坑是前端传page1但后端接收参数名是current导致前端第一页永远和后端第一页对不上。解决方法是统一分页参数命名用pageNum和pageSize前端封装分页组件时严格按这个命名传参。另一个坑是排序字段直接拼到SQL里导致注入要加字段白名单校验。家具商城这个项目我从画原型到上线大概用了三周时间。如果只追求跑通流程一周也够但如果想真正理解商城系统的业务复杂度和工程化细节我建议你把前面的数据库设计和下单链路多花点时间琢磨。尤其是库存锁定和状态机翻转这两个点搞明白你不仅能写好家具商城任何带交易属性的系统都能摸到门道。后面如果你们在做类似项目时遇到具体报错欢迎在评论区留言我看到了会尽量回复。