接手“基于SpringBoot的在线家具商城”这个项目的时候,说实话我的第一反应不是急着去搭工程、写接口,而是先问自己一个问题:家具商城和普通数码商城、服装商城到底差在哪。这个问题想不清楚,后面做的所有功能都是在给自己挖坑。很多人做这类系统,最后做出来的东西看起来很完整,商品管理、购物车、订单都有,但实际经不起细问——比如家具这种大件商品怎么处理规格和定制属性?下单的时候库存扣多了或者扣少了怎么办?订单状态乱跳怎么办?这些问题才是这个项目真正值钱的地方。
这篇文章我会按我从零开始做这个项目的完整思路来写,从功能边界划分、技术选型,到数据库设计、后端核心链路、并发库存处理、Redis缓存引入、管理后台权限,再到部署上线和踩坑记录,一次性讲透。无论你是拿它当毕业设计,还是想写进简历作为实战项目,又或者单纯想练手SpringBoot全家桶,这篇文章应该都能让你少走不少弯路。
1. 功能地图与技术选型:动手之前先把项目拆明白
1.1 用户端和管理端到底要做什么
在线家具商城这个题目,听起来就是一个标准电商系统,但如果你仔细拆一下,会发现它的功能边界其实比想象中要大。我在做功能拆分的时候,习惯先列用例,把角色和操作一对一对地列出来,而不是一上来就打开IDEA新建项目。
用户端这边,核心链路是:注册登录 → 浏览商品 → 查看详情 → 加入购物车 → 提交订单 → 支付 → 查看订单状态 → 确认收货。注意这里我把“支付”写成了模拟支付,因为真正的支付需要商户资质和第三方支付平台对接,对个人开发者和学生项目来说不现实,所以一般是用一个“模拟支付”按钮或者在后端直接标记支付成功。这块一定要在项目文档里写清楚,否则答辩或者面试时容易被追问。
管理端这边,核心链路是:管理员登录 → 商品管理(上架、下架、编辑、库存维护)→ 类目管理 → 订单管理(发货、查看详情)→ 用户管理 → 数据统计。管理端不用做花哨的图表,一张简单的数据看板就够,关键是订单状态的处理流程要闭环。
1.2 为什么是SpringBoot全家桶加Vue
这个项目的技术选型,我最终定的是:SpringBoot 2.7 + MyBatis-Plus + MySQL 8 + Redis + Vue 3 + Element Plus。这套组合大概是目前做个人项目和毕设的主流配置,好处是生态成熟、资料多、踩坑了也容易搜到解决方案。
先聊SpringBoot本身。它最大的价值不是“快”,而是“自动配置”这件事。你引入一个spring-boot-starter-web,它就自动帮你配好内嵌Tomcat和SpringMVC;引入spring-boot-starter-data-redis,它就自动帮你创建RedisTemplate的Bean。这种“约定优于配置”的思想,能让一个单人开发的项目从零到跑通,花费的时间压缩到一个很夸张的程度。理解这一点,比背一百道SpringBoot面试题都有用——因为那些题的核心往往就是自动配置和启动流程。如果你在答辩时把“SpringBoot启动的时候,spring.factories或AutoConfiguration.imports里的配置类会被加载,条件注解决定哪些Bean生效”这套讲清楚,老师基本不会再难为你。
再聊为什么选了MyBatis-Plus而不是纯MyBatis。纯MyBatis写单表CRUD其实是很痛苦的,每个表都要写Mapper接口加XML文件。MyBatis-Plus把单表的增删改查封装好了,你只需要继承一个BaseMapper接口,常用的方法全有了,分页也有现成的插件。这个选择尤其适合商城项目里面那些结构简单的表,比如用户表、购物车表、地址表,基本上不需要手写SQL。而复杂的多表关联查询,比如订单加订单明细加商品信息,再手写SQL也不迟。
前端选Vue 3加Element Plus,原因很简单:Element Plus的表格、表单、弹窗、分页组件做得非常完整,管理后台的前端界面基本是“拼组件”就能拼出来。至于为什么不选JSP加Thymeleaf模板渲染,而坚持前后端分离——一是为了让后端接口更干净,职责更单一;二是现在简历上写“前后端分离项目”是加分项;三是前端静态文件可以单独部署到Nginx,后端只专心提供JSON接口,排错和扩展都方便。
1.3 前后端分离的工程目录怎么规划
工程结构上,前端我用Vite创建了一个Vue3项目,后端用Spring Initializr创建了一个Maven工程。很多人在这里会纠结要不要用Gradle,其实对单模块的开发来说没什么区别,Maven的依赖管理对新手更友好。如果你是从网上clone了一个Gradle项目,也不用慌,本质都一样,就是构建工具不同。
后端这边,我按包结构划分功能模块,而不是按技术类型划分。也就是说,不要搞那种controller包下面放所有Controller、service包下面放所有Service的“大锅炖”结构。我用的方式是:
com.example.furniture ├── common // 通用类:统一返回结果、异常处理、工具类 ├── config // 配置类:跨域、Redis、MyBatis-Plus分页 ├── controller // 控制层 ├── entity // 数据库实体 ├── mapper // MyBatis-Plus的Mapper接口 ├── service // 业务逻辑层 ├── dto // 接收前端参数的封装对象 └── vo // 返回给前端的视图对象这里有个关键习惯要养成:接收前端的参数不要直接拿Entity接。比如注册时前端传过来的JSON,你别让它直接绑定到User实体上,因为你数据库表里的字段可能比前端传的参数多,直接用Entity接容易出安全问题。正确做法是定义一个RegisterDTO,只包含username、password、nickname这几个字段,这样职责清晰,也安全。
2. 数据库设计:家具商城和其他电商的差异点在哪
2.1 核心实体关系梳理
数据库设计是整个商城系统的地基。地基没打好,后面写代码会处处别扭。我画实体关系图的时候,核心实体是这几个:用户、地址、商品分类、商品、商品SKU、购物车、订单、订单明细。
先说家具商城的特殊之处。家具不是标品,一套沙发可能有不同的颜色、材质、尺寸组合,对应的价格和库存都不一样。这种“一个商品多个规格”的情况,在电商里叫SPU和SKU的概念。SPU是商品抽象层,比如“北欧风布艺沙发”,SKU是具体可下单的规格层,比如“北欧风布艺沙发 灰色 三人位 2.8米长”。一张商品表配一张SKU表,商品表放公共属性,SKU表放价格、库存、规格图片,这是电商数据库设计的基础,也是很多新手最容易缺失的一层。如果直接把商品和SKU混在一张表里,那一个商品有五个规格,你就得建五条记录,查询和展示变成一场灾难。
另外,家具还有一个特点,就是商品属性比较重。材质、风格、尺寸、安装方式、是否定制,这些字段在不同的类目下完全不一样。最合理的做法不是给商品表加一堆可能永远用不到的列,而是设计一个attributes字段,用JSON格式存储。MySQL 8原生支持JSON类型,查询时也能用JSON函数,对单机项目来说完全够用,还免去了拆成属性表的复杂度。
2.2 关键表结构的字段设计
我把核心表的字段设计列一下,你可以直接照着建表。
用户表t_user,自增主键或者雪花ID都行。我建议用MyBatis-Plus默认的雪花ID,因为后期如果要分库分表,自增ID会有冲突风险,雪花ID天生是分布式的。字段包括username、password(BCrypt加密后的密文)、nickname、phone、avatar、role(0表示普通用户,1表示管理员)、create_time、update_time。
地址表t_address,字段包括user_id、receiver_name、receiver_phone、province、city、district、detail_address、is_default。家具是大件商品,配送地址的准确性尤为重要,所以地址信息要完整到省市区。
分类表t_category,字段包括name、parent_id、sort_order。注意parent_id这个字段支持无限级分类,比如“客厅家具”下面挂“沙发”,“沙发”下面再挂“布艺沙发”。查询的时候用递归或者直接查全表在内存里组装都行,数据量不大,不用太担心性能。
商品表t_product,字段包括category_id、name、subtitle(卖点副标题)、main_image、detail(富文本详情)、brand、attributes(JSON)、status(1上架,0下架)、create_time、update_time。
SKU表t_product_sku,字段包括product_id、sku_name(比如“灰色 三人位”)、price(以分存储,避免浮点数精度问题)、stock、sku_image、sales。价格用分存储这一点,我强调一下,数据库里不要直接用DECIMAL存元,要么用DECIMAL(10,2)也行,但更规范的做法是直接用整型存分,Java里用BigDecimal处理,前端展示时再转成元。这个习惯能帮你省掉一堆浮点精度相关的玄学Bug。
购物车表t_cart,字段包括user_id、product_id、sku_id、quantity、checked(是否勾选,用于批量结算)。
订单主表t_order,字段包括order_no(订单编号)、user_id、order_status、total_amount、pay_amount、freight_amount、receiver_name、receiver_phone、receiver_address、pay_time、delivery_time、finish_time、cancel_time、create_time。
订单明细表t_order_item,字段包括order_id、product_id、sku_id、product_name、sku_name、product_image、price、quantity、total_price。注意明细表要冗余商品名称、SKU名称和图片,因为商品信息后续可能被修改甚至删除,但订单作为交易快照不能变,这是电商系统的一个经典设计原则。
2.3 为什么订单表必须单独抽一张订单明细表
很多新手做订单功能的时候,喜欢在一张订单表里放一个字段存商品列表,比如用逗号分隔的ID串,或者干脆塞一段JSON。这个设计第一个版本跑起来确实快,但后面做订单详情、退货退款、按商品维度统计销量的时候,全部卡壳。正确的做法永远是订单主表和订单明细表一对多拆分。主表管状态、金额、收货信息,明细表管每一个商品项。一张订单买三个商品,就是一条主表记录加三条明细记录。这样查询订单详情只需要一条主表查询加一个按order_id查明细的列表查询,逻辑清晰,扩展性也好。
顺便说一句,订单编号的生成也有讲究。不要用自增ID直接当订单号,一是容易被猜到业务量,二是多表合并订单信息时不好处理。我用的方案是:时间戳加用户ID后四位加随机数,最终拼成一条24位左右的字符串。网上有很多雪花算法生成的ID工具,但订单号用可读性更强的自定义规则会更方便后续排查问题。
3. 后端核心模块实现:从注册登录到订单闭环
3.1 JWT登录认证与拦截器设计
登录模块是整个系统的基础,它的设计影响到后面所有需要用户身份的操作。我不用Session,而是用JWT(JSON Web Token)。为什么要用JWT?Session依赖服务端存储状态,在前后端分离的架构下,你需要处理跨域携带Cookie的问题,还要考虑如果以后扩展多个服务实例,Session同步会很难处理。JWT把用户信息加密后放在客户端,服务端无状态,每次请求带Token就行,本质上更贴合前后端分离的模式。
登录流程是这样的:用户提交用户名和密码,后端用BCrypt校验密码。注意密码绝对不能明文存储,也尽量不要用MD5——MD5已经能被彩虹表批量破解,而且同一密码的MD5值固定,容易被撞库。BCrypt每次加密同一个密码得到的密文都不一样,因为它内置了随机盐,安全性完全够用。校验通过后,我用jjwt这个库生成一个Token,载荷里存放userId和role,设置过期时间为24小时。前端拿到Token后存在localStorage里,每次请求在HTTP Header的Authorization字段里带上。后端写一个拦截器或者Spring MVC的HandlerInterceptor,对所有需要登录的接口校验Token的有效性,解析出来后把userId放到ThreadLocal里,这样后面的业务代码里随时可以拿当前登录用户。
这里有个非常容易踩的坑:ThreadLocal用完忘记清理。在拦截器的afterCompletion方法里一定要remove,否则在高并发下Tomcat的线程池复用线程,线程中残留的userId会串到下一个请求里去,查出来的数据就会出现“灵魂附体”一样的错乱。
3.2 商品浏览与分类筛选查询
商品模块看起来是纯查询,其实也有一点设计空间。用户端浏览商品,最常见的场景是:进入首页 → 点击某个分类 → 看到商品列表 → 点击进入详情。所以接口层面我设计了三个:分页查询商品列表(支持分类ID和关键字筛选)、查询商品详情(包含SKU列表)、查询所有分类树。
分页查询用MyBatis-Plus的分页插件,配置一个PaginationInnerInterceptor就行。调用时传入current和size两个参数,插件自动帮你生成limit语句,返回IPage对象,里面包含总条数和当前页数据。前端用Element Plus的表格组件配pagination组件,把总条数和当前页接起来,就是一个完整的分页流程。
查询逻辑上要支持三个条件:分类ID、关键字、排序方式。分类ID这一层有一个细节,就是用户点击一级分类“客厅家具”时,要不要把二级分类“沙发”“茶几”下的商品也一起查出来。我这里的处理方式是:先查出该分类下所有子分类ID的集合,再用IN语句查询。如果数据量大了可以改成递归公共表表达式,但单体项目这几个表的数据量用IN就足够了,不要过度设计。
商品详情接口返回的数据结构,我用一个ProductDetailVO来封装,里面包括商品基本信息、属性JSON解析后的Map、以及该商品下所有SKU的列表。前端拿到这个VO,就可以渲染商品主图、详情参数、规格选择和价格库存展示。注意VO的字段命名要对齐前端的驼峰命名习惯,否则前端接数据的时候总是undefined,排查半天发现是字段名对不上,这种低级问题浪费的时间比写代码还多。
3.3 购物车模块的实现思路
购物车在电商系统里是一个很有争议的模块,因为做简单特别简单,做复杂特别复杂。复杂到什么程度?淘宝那种购物车,要处理失效商品、优惠折扣、库存实时校验、跨店结算,每一项都是一套独立系统。我这个项目定位是教学和毕设级别,所以购物车做成MySQL存储的基础版就好,不要一上来就上Redis。
购物车表的操作就五个:加购、改数量、勾选状态、删除、查询列表。加购的时传入product_id、sku_id、quantity,后端先判断该用户购物车里有没有同款SKU,有就把数量累加,没有就新插一条记录。这里有一个查询时要注意的点:购物车列表接口不能只查购物车表本身,还要连表查出商品名称、SKU规格名称、商品图片和最新价格,否则前端没法渲染。最简单的写法是查出购物车数据后,用SKU ID集合批量查SKU表,再按SKU ID映射回购物车数据里组装。
购物车要不要用Redis?我的答案是:在数据量大到一定程度之前,MySQL就够。购物车最核心的需求是数据不丢失,用户加进购物车的商品如果因为缓存失效没了,这个体验是致命的。Redis做购物车一般适合大促读多写少的场景,对单机项目来说引入它只会增加缓存和数据库数据一致性的维护成本。我会在后面的章节详细讲Redis到底用在哪些地方更划算。
3.4 订单状态机的设计与落地
订单是整个商城里最容易写乱的部分。我见过太多人用一个int类型的status字段,代码里到处写if(status == 1),改着改着就乱套了。正确的做法是用状态机思维来管理订单状态。
我定义的订单状态枚举是:待支付、已支付、已发货、已完成、已取消。对应的流转边界如下:
- 待支付 → 已支付(用户模拟支付)
- 待支付 → 已取消(用户超时未支付或主动取消)
- 已支付 → 已发货(管理员在后台点击发货)
- 已发货 → 已完成(用户确认收货)
下单时的完整事务逻辑是:校验购物车商品→校验库存→生成订单主表和明细→扣减库存→清空购物车对应商品→返回订单号。这一步必须在事务里执行,任何一个环节失败都要回滚,否则会出现订单建了但库存没扣,或者库存扣了但订单没建这种数据不一致。事务用Spring的@Transactional注解标注在方法上,默认遇到RuntimeException就回滚。
创建订单时还要注意防重复提交。用户手一抖点了两次“提交订单”,如果没做处理,就会生成两笔一模一样的订单。处理方案是在下单接口生成一个唯一的幂等键,前端提交前先从后端获取,提交时带着这个键,后端判断键是否已存在,存在就直接返回已创建的订单。简单一点的方案,也可以用时间戳加随机数,前端生成一个requestId放在请求里,后端用Redis的setnx命令判断这个requestId是否处理过。
订单创建完成后,用户点击模拟支付,其实就是把订单状态从待支付改成已支付并记录支付时间。之后管理员在后台看到已支付订单,点击发货,状态变成已发货。最后用户收到货,点击确认收货,状态变成已完成。这一套流程走完,订单的整个生命周期就闭环了。
4. 库存扣减与并发下单:一个必考的难点
4.1 超卖问题是怎么产生的
如果说这个项目哪个点最容易被面试官和答辩老师追问,那一定是库存扣减。原因很简单:普通的CRUD谁都会写,但并发场景下的数据一致性才是真正拉开差距的地方。
假设一个商品SKU的库存还剩最后1件,两个用户同时提交订单。如果代码逻辑是这样的:先查库存,发现库存大于0,然后执行扣减。那问题就来了——两个请求同时查到库存为1,两个都认为有货可以买,然后都执行扣减,数据库里库存变成-1,但两个订单都创建成功了,这就是超卖。
根本原因是什么呢?是“先查后改”这个非原子操作在并发环境下的时间差。查的时候库存是1,改的时候条件已经变了,但你的UPDATE语句没有把这个变化感知到,所以照样执行成功。
4.2 乐观锁方案与Redis预扣减方案的取舍
解决超卖,最常见也最容易被接受的方案是乐观锁。思路很简单:在SKU表加一个version版本号字段,或者直接用库存数本身作为版本号。更新库存的时候,SQL写成一个带条件的原子更新:
UPDATE t_product_sku SET stock = stock - 1 WHERE sku_id = #{skuId} AND stock - 1 >= 0Java代码里的逻辑就是:执行这条UPDATE,返回受影响的行数,如果行数大于0说明扣减成功,等于0说明库存不足或者已经被并发抢走了。这个方案的本质是把“判断库存够不够”和“扣库存”合并成一条SQL,由数据库的行锁保证同一时刻只有一个事务能改这条记录,从根源上消灭了时间差。我实测下来,用这个方案配合事务,单机项目哪怕压到几百并发也很稳。
还有一个方案是用Redis的incr/decr原子操作做预扣减。先扣Redis里存的库存,扣减成功再异步落库,最后通过消息队列或者定时任务同步数据库。这个方案的优势是性能极高,扛得住大促峰值,缺点是架构复杂度上去了,Redis和MySQL的数据一致性需要额外处理,一旦Redis挂了库存数据就不可靠。对单体商城项目来说,我建议还是老老实实用数据库乐观锁,把复杂度降到最低。但如果答辩时想展示自己的知识面,可以把两种方案拿出来对比,然后重点解释Redis方案的适用场景——峰值流量极大、允许引入消息中间件的团队,才会选它。
4.3 订单超时未支付的兜底策略
下了单但不支付,库存一直被占着,这是电商系统必须处理的问题。处理方案有两种主流做法:一种是用延迟消息队列,比如RabbitMQ的延迟消息插件、RocketMQ的定时消息,让订单创建后触发一个30分钟后的事件,事件里判断订单是否仍然待支付,是就取消并回补库存;另一种是在订单表加一个创建时间字段,用一个定时任务每分钟扫描一次待支付且超过30分钟的订单,批量取消并回补库存。
对小项目来说,第二种做法更实际,因为不需要额外引入消息中间件。我用Spring的@Scheduled注解写了一个定时任务,每60秒执行一次,查询条件就是:status = 待支付 AND create_time < NOW() - 30分钟。查出订单集合后,逐单执行取消操作——更新订单状态、回补SKU库存。这一套写下来代码量不大,但很好地补上了电商系统的重要拼图。
超时关单这里有一个细节容易漏,就是在定时任务里逐单回补库存,如果某一张订单的SKU已经被删除,SQL会更新0行,不影响其他订单。所以循环处理时,每个订单的库存回补要单独捕获异常,避免一个订单出问题导致整个批处理中断。
5. Redis引入:缓存加载与数据一致性
5.1 热门商品数据的缓存策略
Redis在这个项目里真正发挥价值的地方,是商品详情和首页推荐位的缓存。为什么要缓存商品?因为商品信息是典型的读多写少数据。用户访问商品详情的频率远高于管理员修改商品信息的频率,每次查询都打到MySQL上,不仅慢还给数据库增加无谓的压力。把热点商品的详情放到Redis里,查询路径就变成了:先查Redis,命中直接返回,没命中再查数据库并回填Redis。
我用Spring Cache或者手动操作RedisTemplate都行。手动控制的逻辑更直观:商品详情接口先根据productId生成一个缓存key,比如product:detail:1001,然后查询代码里先尝试从Redis取值,取到直接转成ProductDetailVO返回;取不到就走数据库逻辑,然后设置一个随机的过期时间写入Redis。这里为什么要加随机过期时间,我在后面章节展开讲。
商品缓存失效之后,第一次请求会打到数据库。这个行为本身没什么问题,但要注意缓存击穿的情况——也就是某个热点商品的缓存刚好过期,此时大量请求同时涌入,全部打到数据库,数据库瞬间压力大增。应对方案是加锁重建缓存:在回填数据库这段逻辑上加一把互斥锁,让一个请求去查数据库回填缓存,其他请求先等待,等缓存回填完成后直接从缓存读取。
5.2 缓存穿透、击穿、雪崩的实际应对
这三个词听起来吓人,但本质都是缓存失效时的边界问题。缓存穿透是指查询一个数据库中根本不存在的数据。比如有人恶意用不存在的商品ID频繁请求,每次都会穿过缓存打到数据库。解决办法有两个:一个是缓存空值,把不存在的key也缓存起来,设置一个较短的过期时间;另一个是接口层做参数校验,商品ID的格式不合法就直接拒绝。
缓存击穿就是我上面说的热点key过期瞬间大量请求打到数据库。解决办法是互斥锁重建缓存,或者采用逻辑过期时间——缓存里存一个字段标记逻辑过期时间,查询时发现逻辑过期了,返回旧数据同时异步去刷新缓存。这个方案更复杂,但体验最好。
缓存雪崩是指大量key在同一时刻集中过期,导致一波请求全部打到数据库。解决办法很简单,就是给每个key的过期时间加一个随机值,让过期时间均匀分散。比如基础过期时间是30分钟,那就随机加1到5分钟,这样即使同一批商品一起缓存进去,过期时间也各不相同,数据库不会在同一时刻承受全部流量。
5.3 缓存和数据库的一致性怎么做
Redis缓存MySQL数据,最头疼的就是修改商品时,缓存怎么办。最简单的方案是:更新数据库后,手动删除对应的缓存key,下次请求时发现缓存没命中,自然就把最新数据从数据库回填到缓存里了。这个方案叫Cache Aside,也叫旁路缓存,是业界使用最广泛的一致性方案。
为什么更新数据库后删缓存,而不是更新缓存?因为更新缓存这个操作本身有风险——如果两个并发请求同时更新一个商品的缓存,后更新的数据可能覆盖了先更新的数据,刚好把旧数据写进去了。而删除缓存则没有这个问题,因为删是幂等的,下次查询重新构建缓存,构建时读到的一定是数据库里的最新值。
极端情况下,删除缓存也可能失败。如果删缓存失败,旧缓存会继续存在,导致用户读到旧数据。解决办法是用延迟双删:先删除缓存,更新数据库,隔几百毫秒再删除一次缓存。这个方案能覆盖绝大多数不一致问题,但对单体项目来说,直接用最基础的删缓存方案就够了,把数据库更新和缓存删除放到同一个事务里失败回滚,已经能解决绝大多数场景。
6. 管理后台与权限控制:别做裸奔的admin接口
6.1 用户角色与权限模型怎么落地
很多自学的项目,管理后台接口是裸奔的——只要知道URL,任何人都能访问。比如你在前端页面把/admin/product/list这个请求看一遍,拿到接口地址,然后用Postman直接发一个修改商品价格的请求,这就能改掉商城里的商品价格了。这个安全隐患在答辩演示时一旦被老师点到,印象分会大打折扣。
我的处理方式是,在JWT的载荷里写入用户角色,普通用户的role是0,管理员的role是1。然后在后端定义一个角色校验的注解,比如@RequireAdmin,并实现一个拦截器,在拦截器里解析Token后判断当前用户角色是否为管理员,如果不是就返回无权限的提示。你可以用Spring MVC的拦截器对/admin/**路径统一做校验,最简单。
还有一种升级方案是引入Spring Security加JWT做认证授权,但这套组合的学习曲线比较陡,配置类写起来比拦截器复杂得多。考虑到项目体量,我最终选择了拦截器加注解的轻量方案,既能说明白权限控制的原理,又不至于把代码复杂度拉太高。如果简历上写“熟悉Spring Security”,那可以再往深了做,否则用轻量方案反而更容易在面试时自圆其说。
6.2 商品上下架、SKU维护与订单发货
管理后台的商品管理功能,核心是围绕SPU和SKU做的。新增商品时,前端填完商品基本信息后,继续维护SKU列表——每个SKU包含规格名称、价格、库存、图片。后端接收的时候用ProductDTO,里面嵌套一个List ,先插入商品主记录拿到productId,然后批量插入SKU记录,整个过程放在一个事务里。
商品上下架,说白了就是更新Product表的status字段。这里有一个连带逻辑要想清楚:下架一个商品之后,它下面的SKU也不能再被购买。所以在用户端的查询逻辑里,只查status为1的商品,而且下单创建订单时还要再校验一次商品和SKU的状态,不能只在前端隐藏就算完事,后端才是最后一道防线。
订单发货是管理员最常见的操作。后台订单列表按订单状态筛选,看到已支付的订单,点发货按钮,后端做两件事:更新订单状态为已发货,记录发货时间。这里可以加一个物流单号字段,用户端查询订单详情时能看到,但如果是教学项目,字段可以先不接真实的物流API,手动填写一个模拟单号即可。
6.3 数据看板背后的SQL怎么写
管理端的首页数据看板,不需要上重型BI工具,几张统计卡片加一个趋势图就够。统计口径一般包括:用户总数、商品总数、今日订单数、今日销售额、近七天订单趋势。
这些统计用简单的SQL聚合就能搞定。用户总数和商品总数,直接用COUNT查询;今日订单数,就是WHERE create_time大于今天的零点再COUNT;今日销售额,是SUM已支付和已完成订单的pay_amount。近七天趋势,用一条按日期分组的SQL:
SELECT DATE(create_time) AS d, COUNT(*) AS order_count, SUM(pay_amount) AS amount FROM t_order WHERE order_status IN (1, 2, 3) AND create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(create_time) ORDER BY d注意,查询的日期函数尽量在数据库里完成,不要把所有订单拉到Java内存里再按日期分组,那样既慢又浪费资源。数据量小时无所谓,但写SQL时养成“能用SQL就不要用Java算”的习惯,对大促数据的处理会有很大帮助。
7. 部署上线与踩坑清单
7.1 从本地到服务器:部署全流程
项目开发完成后,部署上线是检验整个系统能否真正运行的关键一步。环境我用的是一台最便宜的云服务器,操作系统选CentOS 7或Ubuntu 20.04都行。服务器上需要装的东西有:JDK(版本跟本地开发保持一致)、MySQL 8、Redis、Nginx。
后端打包流程很简单,在项目根目录执行:
mvn clean package -DskipTests打包完成后,target目录下会生成一个jar包。这里的关键点是,SpringBoot内置了Tomcat,所以这个jar包是可直接运行的web服务,不需要再单独装Tomcat解压war包放进去。启动命令是:
nohup java -jar furniture-admin.jar --spring.profiles.active=prod > app.log 2>&1 &注意nohup和&的组合,这是让jar包在后台持续运行的标准姿势。日志重定向到app.log文件,方便排查故障。
前端部分,在Vue项目根目录执行npm run build,生成dist目录,里面是一堆静态文件。把这堆文件传到服务器的Nginx站目录下,然后配置Nginx把前端页面和API请求做反向代理。核心配置思想是:所有/api/**路径的请求转发到后端jar包所在的端口,其他路径一律走前端静态文件,并加上try_files让刷新页面时不会404。
7.2 部署后必踩的几个坑
部署过程中最容易出问题的地方,我列成清单,每一条都是我在实际操作中撞过墙的:
JDK版本不匹配。如果你本机用的JDK 17或21,但服务器装的是JDK 8,大概率启动时报UnsupportedClassVersionError。SpringBoot 2.x支持JDK 8,SpringBoot 3.x强制要求JDK 17以上。如果你习惯用JDK 1.8,老老实实用SpringBoot 2.7。如果IDEA创建项目时选了SpringBoot 3.x,又想回退到JDK 1.8,大概率是建不起来或者编译不过,这个时候别硬刚,直接降低SpringBoot版本到2.7。
数据库时区问题。MySQL连接串如果不指定serverTimezone,在高版本MySQL驱动下有可能会报时区错误,就算不报错,存储的时间也和你本地时间差8个小时。连接串加上
serverTimezone=Asia/Shanghai是最稳妥的做法。跨域问题。前端部署在Nginx上,后端在8080端口,两个域名或者端口不同就涉及跨域。解决方式有两个:后端加一个全局CorsFilter允许指定来源,或者在Nginx里把前后端配成同一个域名、通过路径区分,这样浏览器就不会判定为跨域。我建议直接用Nginx反向代理处理,这样前端代码里连axios的baseURL都不用区分环境,全部写成相对路径/api就行了。
MyBatis-Plus雪花ID在JavaScript里的精度丢失。MyBatis-Plus默认用雪花算法生成19位的Long型ID,但JavaScript的Number类型最大安全整数只有9007199254740991,19位数字早就超了。前端拿到的商品ID末尾几位会变成0,导致商品详情查不出来。解决办法有两种:一个是在后端把Long转成String返回,用@JsonSerialize注解或者全局Jackson配置;另一个是用串行化的VO类,把ID字段改成String类型。
前端刷新404。Vue是单页应用,路由是前端控制的,默认只有index.html,用户在商品详情页刷新时,Nginx会去找对应的URI路径文件,找不到就404。解决办法是Nginx的location配置加上
try_files $uri $uri/ /index.html;。
7.3 这套架构后续还能怎么扩展
如果这个项目做完了还想继续提升,几个方向可以参考。一个是把用户端和管理端彻底拆成独立的SpringBoot服务,用Nacos做服务注册发现,也就是把单体架构升级成微服务架构,虽然业务不大,但架构设计的思路能练一遍。另一个是引入消息队列,用RabbitMQ或RocketMQ处理订单超时取消和订单创建后的异步通知,把定时任务轮询取消订单的方案替换成更实时的事件驱动方案。还有一个方向是引入全文检索,用Elasticsearch做商品搜索,替换掉目前的MySQL模糊查询,搜索体验会好很多。
在实际操作中我还有一个体会想多说一句:做这个项目,代码量本身不是最大的成本,真正花时间的是把数据关系理清楚、把状态流转想明白、把并发边界测试到位。如果你也是自己一个人在搞,千万别急着写代码,先把数据库表设计好,把订单状态图画出来,把接口清单列出来——磨刀不误砍柴工,这步省下来的时间,后面都会还给你。