时间回到我刚做完这个项目的那个晚上,脑子里全是订单状态、库存超卖和前后端联调时那几百行报错日志。说实话,基于 SpringBoot+VUE 的综合电商网站设计与实现这个题目,在 Java 全栈类项目里几乎是被写烂了的“老三样”之一,但正因为烂大街,它反而成了一张试金石——同样的题目,有的人做出来就是玩具 Demo,答辩时讲两句就没词了;有的人做出来却是一套能演示、能压测、能讲清楚每一个设计决策的完整系统。
这篇文章我会把当初从零搭建到上线部署的完整过程拆开揉碎,重点放在那些文档里不会写的部分:SKU/SPU 到底怎么建模、下单扣库存为什么不能只靠 if 判断、JWT 令牌过期之后前端怎么续期、支付回调的幂等性怎么保证、以及最后部署到服务器上图片加载不出来的那些破事。不管你是做毕设、找工作写进简历,还是纯粹想练手全栈,这篇都值得你认真看一遍。
1. 这个题目到底在考察什么:先说清楚项目的骨架
“综合电商网站”听起来很宽泛,但落到技术上其实就两条主线:前台用户能逛能下单、后台管理员能管商品管订单。绝大多数人把它做成了“商品列表 + 购物车 + 下单”三步走,然后就去忙别的了。这不能说是错的,但如果把这个项目当作展示能力的作品,需要面对几个更深层的问题:商品数据怎么组织、库存扣减在高并发下会不会出问题、订单的状态流转怎样才能不乱、文件上传后存到哪里、以及前后端分离架构下用户身份怎么认证。
1.1 这类项目的标配功能清单与进阶思路
一个“能打”的综合电商网站,至少需要覆盖下面这些模块:
| 模块 | 前台用户侧 | 后台管理侧 |
|---|---|---|
| 用户系统 | 注册、登录、JWT 认证、个人资料 | 用户列表、状态管理、角色区分 |
| 商品系统 | 分类浏览、关键字搜索、商品详情 | 商品 CRUD、上下架、库存维护 |
| 购物车 | 加入购物车、修改数量、删除、结算 | 无需后台,但数据库需完整记录 |
| 订单系统 | 提交订单、支付、查看历史订单、取消 | 订单列表、发货操作、退款处理 |
| 支付模块 | 模拟支付(沙箱/伪支付) | 支付回调记录、对账 |
| 营销模块 | 优惠券领取/使用(可加分) | 优惠券创建、发放记录 |
| 物流与评价 | 物流轨迹(可模拟)、商品评价(可加分) | 发货填写单号、评价管理 |
基础功能每个教程都在做,真正的加分项在于后面的内容:库存扣减的并发安全方案、订单状态机的设计、支付回调幂等处理。这三个点哪怕只做到位一个,答辩时都能让你从“用过框架”变成“做过设计”的档位。
1.2 开发环境与版本选型,为什么这样组合
我当时的选型是这样的:
- 后端:SpringBoot 2.7.x(JDK 8 或 11),MyBatis-Plus 作为 ORM 框架,MySQL 5.7/8.0,Redis 用于缓存和库存预扣,Maven 做依赖管理。
- 前端:Vue 2.x + Vue Router + Vuex(如果不想引入太重的东西可以用 Pinia,但配套生态和文档量 Vue 2 更丰富),Element UI 做后台管理页面,Vue 3 + Element Plus 也行,看你熟悉哪个选哪个。
- 部署:后端打包 jar 直接扔服务器跑,前端 npm run build 之后把 dist 目录交给 Nginx 托管。
版本上有个很实际的建议——SpringBoot 不要追求最新,尤其是 3.x 系列。SpringBoot 3 基于 Jakarta EE 换了包名,网上能找到的资料和踩坑经验相对少,很多老教程直接跑不通。用 2.7.x 这种稳定版本做项目,遇到问题搜一下基本都有解决方案,把精力留给业务逻辑才是正事。
2. 项目工程结构设计与数据库建模:从根上决定上限
很多同学喜欢拿到题目就先写 Controller,而 Controller 里直接就写业务代码。这种开发方式在小 Demo 里看不出来问题,一旦订单流程牵扯到商品、库存、积分、物流、支付回调几个模块互相调用,代码马上变成一团乱麻。
个人推荐的分层是:Controller(接口层)→ Service(业务层)→ Mapper(数据访问层)。实体用entity包,DTO 和 VO 要分开建——入参不要直接用实体类去接,出参也不要直接把实体类返回给前端。刚接触项目的人会嫌麻烦,等到要加一个字段、又要防止用户传某个字段进来把数据改了的时候,就会明白这个设计多重要了。
2.1 一张典型电商库表的设计思路
以product商品表为例,最简单的建模通常长这样:
id、name、category_id、price、stock、image、description、status、create_time、update_time这样设计在只有一个商品级别的时候够用。可一旦涉及到“颜色尺码版本”,比如同一款手机有 8G+256G 和 12G+512G 两个规格,价格还可能不一样,单表平铺就完全没法处理了。这里就要引入电商系统里极其常见的一对概念:SPU(Standard Product Unit,标准化产品单元)和 SKU(Stock Keeping Unit,库存量单位)。
- SPU 描述的是“这是个什么商品”,比如“iPhone 15 Pro Max”,拥有统一的标题、描述、详情图。
- SKU 描述的是“具体哪个版本有货”,比如“iPhone 15 Pro Max / 黑色 / 256G”,库存、价格挂在 SKU 上。
对应的表结构调整为:spu表存公共信息,sku表存具体规格和库存,中间再挂一个sku_specification记录规格项的 JSON 数据。JSON 字段在某些场景下(如规格组合)是合理的妥协,不必为此单独建五六张表。
2.2 购物车和订单表,建表常见的坑
购物车这块,很多人想当然只存“商品 ID 和数量”,这样做在需求简单时确实没问题,但用户如果修改了商品价格、商品下架了,购物车里的价格和状态就全乱了。稳妥的做法是购物车表存user_id、sku_id、quantity、checked(是否选中结算),每次拉取购物车列表时实时去查 SKU 最新的价格和库存,而不是把价格直接冗余进去。这样用户下次进购物车时能立即看到价格变动,做促销活动也方便。
订单表则要明确区分“订单主表”和“订单明细表”。一个订单对应多条商品记录,主表存订单号、用户 ID、总金额、状态、收货人、手机号、地址;明细表存每一条商品的 SKU ID、快照名称、快照价格、购买数量。这里有个关键细节:订单里的商品名称和价格必须存快照,不能下单之后再去 join 商品表。道理很直白——商品以后改价、改名甚至删除,历史订单不能跟着变,否则对账和售后的数据全乱套了。
3. 商品模块与缓存策略:别把数据库扛在肩膀上
商品详情页是电商网站访问量最大的页面之一,如果不加缓存,每次都要查spu、sku、图片表,数据库压力会非常大。最简单的梯度方案是:热数据放 Redis,冷数据回源数据库。
3.1 商品列表缓存与缓存穿透的简单解法
商品列表页的数据结构可以缓存成一个 JSON 字符串,Redis Key 设计为product:list:{categoryId}:{page}:{size},超时时间设在 30 到 60 分钟。这样一来,同一页的请求全部打 Redis,数据库的查询频率大幅度下降。
缓存穿透的问题在于——有人故意查不存在的商品 ID,每次都绕过 Redis 打到数据库。解法是在查不到数据时,也往 Redis 里写一个短暂的“空缓存”,过期时间设短一些,比如 2 分钟,这样同一 ID 的恶意请求就只会第一次穿透到数据库。这个方法虽然老套,但确实简单高效。
缓存击穿是指一个热点 Key 刚好失效,大量请求瞬间打进数据库。最简单的处理是给热点 Key 设置不主动过期,靠后台定时任务刷新;或者直接加锁,在缓存失效时只放行一个线程去数据库查询,其他人等待缓存重建完成。项目里用到锁的情况下,分布式锁是个常用工具,这里就不展开细节了,但思路可以选择本地 JVM 锁或者 Redis 分布式锁,取决于你服务的节点数量。
3.2 商品上下架与缓存同步
比较重要的一条实操经验是:写了数据库,一定别忘了主动删缓存,而不是被动等过期。比如后台管理员上架商品、改价格,如果只更新 MySQL 而不同步删掉 Redis 里的旧 JSON,用户端刷新一小时还是老数据。常见做法是封装一个CacheService,在商品 update/delete 的方法里,把涉及该商品的列表缓存、详情缓存全部删除。下次请求到来时缓存未命中,自动回源数据库再重建缓存,这样就完成了数据同步。
我见过有人为了追求“绝对一致”搞实时双写、消息队列通知,说实话在单体电商项目里这是杀鸡用牛刀。删除缓存回源重建的延迟只有几十毫秒,对用户来说根本无感,但代码量少一个数量级。
4. 核心交易链路:购物车、下单、库存扣减与状态机
这是整个项目里最有含金量的部分,也是面试官或答辩老师最喜欢深挖的地方。
4.1 购物车到订单的完整流程
用户从购物车结算到订单生成,通常分这么几步:
- 前端把勾选的购物车记录 ID 列表传到后端。
- 后端根据 ID 查出购物车明细,校验 SKU 是否上架、库存是否充足。
- 按当前最新价格计算总金额(前端显示的价格只做展示,不能作为后端结算依据)。
- 生成订单主表和订单明细表,给订单生成唯一的业务订单号。
- 扣减库存(这一步是并发控制的核心)。
- 清空对应购物车记录。
- 返回订单号和待支付金额到前端。
这个流程最关键的点在于第 5 步的库存扣减。很多初学者写的是这样一段代码:
Sku stock = skuMapper.selectById(skuId); if (stock.getStock() >= quantity) { stock.setStock(stock.getStock() - quantity); skuMapper.updateById(stock); }这在单用户、低并发下演示没问题,但只要两个用户同时下单最后一件商品,程序读到的库存可能都是 1,两个请求都通过了 if 判断,最后都把库存减成 0,超卖就发生了。这是并发场景下的经典竞争条件。
4.2 乐观锁扣库存:最简单可靠的方案
数据库层面最直接的方案是乐观锁,给sku表加一个version字段,每次更新时带上版本条件:
UPDATE sku SET stock = stock - #{quantity}, version = version + 1 WHERE sku_id = #{skuId} AND stock >= #{quantity}注意这里的 SQL 直接用stock >= quantity作为条件,比“先查再改”安全得多。受影响行数如果是 0,就说明库存被扣光或者并发冲突了,服务端立刻返回“库存不足”,前端收到提示后刷新购物车即可。
乐观锁在高并发冲突时会导致部分请求失败,用户体验会受到影响。常见的更进一步优化是Redis 预扣库存——下单前先用DECR命令在 Redis 里扣,库存不足直接拒绝请求;等支付成功后异步更新数据库的最终库存。这样能扛住瞬时流量,但引入的消息补偿逻辑会复杂不少。对于单体毕设项目或个人作品,乐观锁已经够用了,如果你想让答辩老师眼前一亮,可以在方案说明里主动提到“Redis 预扣 + 异步对账”的进阶思路,并标出自己实现的乐观锁版本是它的简化形态。
4.3 订单状态机设计:为什么不能随便 if-else
订单状态是电商系统里最容易写乱的模块。常见状态有:待支付(PENDING_PAYMENT)、已支付(PAID)、已发货(SHIPPED)、已完成(COMPLETED)、已取消(CANCELLED)、退款中(REFUNDING)和已退款(REFUNDED)。
如果每个状态变化都在业务代码里判断“当前状态能不能转成目标状态”,后期加需求就会在各个方法里塞一层又一层 if,Bug 层出不穷。可靠的办法是建立一个状态机表或枚举映射,把合法流转明确写出来:
| 当前状态 | 合法目标状态 | 触发动作 |
|---|---|---|
| 待支付 | 已支付、已取消 | 支付回调、用户取消 |
| 已支付 | 已发货、退款中 | 商家发货、用户申请退款 |
| 已发货 | 已完成、退款中 | 用户确认收货、申请售后 |
| 已完成 | 无 | 终点状态 |
| 已取消 | 无 | 终点状态 |
| 退款中 | 已退款 | 商家同意退款 |
然后在更新状态时只执行一步操作:
if (orderStateMachine.canTransit(currentStatus, targetStatus)) { order.setStatus(targetStatus); orderMapper.updateById(order); } else { throw new BusinessException("非法的订单状态流转"); }这样无论前端传什么状态,后端都有统一的校验入口,订单状态就不会乱。它看起来只是多写了一张对照表,但真的能避免后期数据脏乱差和线上改代码的悲剧。
5. 前后端分离下的登录认证与联调:JWT 与路由守卫
SpringBoot 和 Vue 分属两个服务,Session 机制默认不适用,更通用的做法是 JWT(JSON Web Token)。用户登录成功后,后端生成一个包含用户 ID、用户名、过期时间的签名 Token 返回给前端,前端拿着它放到请求头Authorization里访问所有受保护接口。后端写一个拦截器统一校验。
5.1 后端拦截器与 Token 校验
拦截器要配置白名单,比如/api/user/login、/api/user/register、商品列表和详情查询、图片访问路径。其余接口全部过 Token 校验,校验通过后把用户信息放入ThreadLocal或请求上下文,方便后续业务代码获取当前登录用户。
SpringBoot 里推荐用HandlerInterceptor加WebMvcConfigurer注册,滤掉 OPTIONS 预检请求,这是前后端分离项目最容易被忽略的地方——浏览器跨域时会先发一个OPTIONS请求探测接口是否允许访问,如果你把所有非白名单路径都要求带 Token,预检请求拿不到 Token 就被拦了,前端看着莫名其妙的网络报错还以为接口坏了。
5.2 前端路由守卫与 Token 刷新
Vue 侧用户未登录却访问/cart、/order等页面时,需要统一重定向到登录页。在router/index.js中配置全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); } else { next(); } });登录后把 Token 存到localStorage,Axios 请求拦截器统一带上请求头。同时响应拦截器里判断401状态码,发现 Token 过期就跳转登录页,不要把后端抛出的错误信息直接裸露给用户。
Token 有效期设多长也是个值得考虑的问题。太短用户频繁掉线,太长有安全风险。合理设计是短 Token + 刷新 Token:访问 Token 2 小时过期,刷新 Token 7 天或 30 天,访问 Token 失效后用刷新 Token 换新。如果觉得双 Token 机制工作量比较大,至少要把 Token 时效设为 24 小时且记住密码自动登录,并能讲清楚这个取舍的利弊。
5.3 接口联调时的跨域配置
开发环境下后端要开启跨域允许,最简单的全局配置:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://localhost:8081"); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }这里有个坑:allowCredentials(true)时,AllowedOrigin不能写*,必须明确指定来源地址,否则浏览器依然会拦截掉请求。除非你完全不使用 Cookie 和 Credentials,但推荐的做法是严格配置前端实际地址。
6. 支付模块的模拟实现与回调幂等性设计
电商网站不可能真的对接支付宝微信支付完整流程,但这不代表“支付”这环应该砍掉。标准的替代方案是模拟支付沙箱:在项目中设计一个支付订单表,点击“立即支付”后生成支付单,跳转一个模拟收银台页面,用户确认支付后把支付状态改变,再由一个模拟回调接口通知订单系统支付成功。这既还原了真实支付的链路,又规避了个人签约第三方支付商户号的繁琐门槛。
6.1 支付回调的幂等性处理
真实支付回调和模拟回调本质都要求确保逻辑只执行一次。比如支付宝回调同一个通知可能发送多次,如果每次回调都直接把订单状态改成已支付,后续重复回调就可能导致订单状态被重复推进或关联的加积分操作重复执行。
最基本的解决思路是引入回调流水表:记录支付回调的payment_id或trade_no,处理前先去查这条记录是否已经处理过,如果处理过直接返回成功应答,不再走重复业务逻辑。同时再配合状态机流转判断——只有待支付状态下的订单才能被置为已支付,也能杜绝重复推进的问题。两者叠加使用,幂等才有保障。
回调接口必须对外部封锁,不能让用户自己构造一个请求来模拟支付成功。一个实用的做法是:在支付单表里预留一个notify_secret随机值,前端发起支付时后端把它返回给支付沙箱保存,回调时从回调消息的签名信息里取出来做比对,对不上直接拒绝。这个设计放在答辩里杀全场——你把“防伪造回调”这种真实支付的安全机制讲出来,明显比“我们调用一个接口改一下状态”有深度。
6.2 支付后逻辑的一致性:库存、积分、优惠券
支付成功后不止改订单状态一件事,还牵连着很多关联数据。通常是:订单状态变为已支付 → 积分流水新增 → 优惠券标记为已使用 → 如果之前用的是 Redis 预扣库存,还要把扣减结果同步到 MySQL。这里多个操作之间的“要么全成功,要么全不成功”约束,最直观的方案是使用 Spring 的@Transactional注解包住整个方法。要记住一个原则:事务只能覆盖同步调用链,异步操作(比如发消息、发邮件)出了异常回滚不掉,需要单独补偿。单体项目典型异步场景(订单超时自动取消)可以用延迟消费、定时任务扫描到期未支付订单来兜底,不要勉强在支付回调里实现定时任务等待。
7. 项目性能优化与上线部署:从能跑到能扛
一个电商网站如果只满足于本地能跑通,那它永远只是个练习项目。要拿得出手,至少得做到部署上线、访问顺畅、数据不崩这三条基本线。
7.1 数据库层面容易被忽略的索引设计
商品表按分类筛选,则category_id加索引;按关键词搜索,数据库层面至少对name字段加普通索引。订单表最核心的是按用户查订单:user_id加索引,create_time加索引,否则用户订单一多,全表扫描会越来越慢。订单号有唯一索引。“哪列参与查询,哪个列就加索引”,这个原则在初期够用。后续如果范围查询和排序字段复杂度上升,可以在性能调优阶段逐步优化组合索引。
7.2 图片与静态资源的存储方案
初学者最常见的做法是把图片上传到项目内部的resources/static/upload目录,问题在于:后端前后端分离之后,图片要么走后端端口,要么前端直接把请求打到后端占带宽。更麻烦的是重新部署时本地文件会被清掉。简单的改进是把图片存放目录配置到服务器独立路径(如/home/app/images),后端用映射方式把上传目录暴露为可访问的 URL。项目上线后,我推荐把图片迁移到对象存储服务,成本更低,访问速度也更快。
前端打包出来的dist目录本身是一堆纯静态文件,放在 Nginx 下面,网址就是http://服务器IP:80,用户在浏览器访问的是 Nginx 服务的页面。前端代码通过fetch('/api/xxx')请求时,再由 Nginx 反代到后端的localhost:8080,这样浏览器访问环境里没有跨域问题,比开发环境单独配跨域更顺滑。
7.3 一个简单但完整的 Nginx 配置参考
server { listen 80; server_name your-domain.com; root /home/app/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; } location /upload/ { alias /home/app/images/; } }里面最要紧的是try_files $uri $uri/ /index.html;这一行。Vue 是单页应用,路由由前端控制,如果用户直接访问http://域名/order/123,Nginx 在 dist 目录里找不到对应的物理文件会返回 404。加上这行后,所有未知路径都会回退到index.html,再由 Vue Router 接管页面展示,这就解决了前端 History 模式下刷新白屏的问题。
后端打包用:
mvn clean package -DskipTests nohup java -jar admin-web.jar --server.port=8080 > app.log 2>&1 &nohup保证关闭终端后进程继续跑。上线初期在app.log里实时追踪错误信息,是排查线上问题最快的路径。
8. 实测踩坑记录:这几个坑我替你踩平了
项目写过一遍,真正留在记忆里的往往是那些折磨过你的细节问题。下面几条是我个人认为最容易被卡住且搜半天才找到答案的点,趁现在连同解决思路一并写出来。
8.1 前端传时间字符串给后端,JSON 解析直接报错
前端new Date()出来的时间格式传到后端,Jackson 解析时会因为格式不匹配直接抛异常。解决办法是在 SpringBoot 里统一配置时间格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8后端的LocalDateTime配合 Jackson 的 JSR310 支持,就能做到自动转换。时区必须显式设为 GMT+8,否则服务器在 UTC 时区跑时所有时间都和本地对不上。当时因为时区问题导致订单支付后后台显示时间差 8 小时,查了一下午才发现。
8.2 金额计算用 double 等于给自己埋雷
金额永远不要用double或float,这是老生常谈但踩的人还是前赴后继。0.1 + 0.2 在二进制浮点里不精确,计算结果和数据库里的DECIMAL对不上,订单总额对账必然出问题。后端使用BigDecimal,数据库使用DECIMAL(10,2),前端展示由后端返回字符串。必要情况下统一封装金额运算工具类,加一个scale(2)的方法。
8.3 Vue 项目首次加载慢,白屏时间长
打包后的 JS 文件体积过大,是控制台里常见提示Chunk-Size警告的来源。最简单的优化是路由懒加载——把组件 import 改成动态导入:
const Cart = () => import('../views/Cart.vue');这样每个路由对应的 JS 文件会被拆成独立小块,首屏才加载需要的部分,整体体积瞬间下降很多。再把 Nginx 开启 gzip 压缩,首屏体验会有明显改善。
8.4 部署后图片看不到,权限、路径、大小三个问题一起查
如果用户评论区的图片全挂,通常是三个原因:图片目录没有读权限、Nginx 映射配置错误、上传时限制了大小。按顺序排查,先确认文件是否真的传到了服务器指定目录,再确认ls -l查看目录权限有没有www-data或其他 web 用户的权限,最后再用浏览器直接访问http://域名/upload/xxx.jpg看反馈——这种逐步缩小范围的排查方式,往往比盯着配置反复试更高效。
8.5 前端调用报 404,先分清“后端没起”还是“路径拼错”
前后端分离之后,接口 404 有 80% 的情况是前端请求路径api前缀和后端 Controller 的@RequestMapping对不上。排查时先打开浏览器开发者工具看 Network,看清楚请求的完整 URL 是什么,再去后端代码里找到对应的 controller@RequestMapping。如果两者完全一致且 Nginx 配置也没什么问题,再检查是否忘了加@CrossOrigin或全局跨域配置。
9. 写在最后的一些真心话
如果你是从零开始做这个项目,大概准备三到四周时间就能完整走一遍。第一周把后端接口写好能跑,第二周把前端页面联调完,第三周处理库存并发、支付模拟、缓存优化这些有深度的部分,第四周打磨部署和文档。整个过程里最有收益的,往往不是最后跑通的那一瞬间,而是那些反复排查 Bug、看日志定位问题的夜晚。
我个人实际经验是:在论文和答辩环节里,你要重点讲清楚的不是“我用了什么框架”,而是“某个核心问题我用什么方案解决了,为什么这样选,暴露什么问题,还有什么提升空间”。订单状态机设计、乐观锁扣库存、JWT 认证流程、支付回调幂等,随便挑一个讲到这种深度,都已经超过八成同题目的作品。
最后再分享一个收尾技巧:给自己的演示录一段 5 分钟以内的视频,按“前台逛店 → 下单 → 支付 → 后台发货 → 用户确认收货”这条线走一遍,再配合一两处源码讲解。这套演示放到简历作品链接里,比千言万语都有说服力。