手办交易平台这个项目,我前后折腾了两三个月,说实话一开始以为就是个普通的电商项目,无非是商品、购物车、订单那套东西。真做起来才发现,多商家模式才是最大的坑——商品归属于不同商家、订单要拆开结算、数据得做隔离,这些事在单商家系统里根本不会遇到。如果你也准备做这类带商家入驻的交易平台,或者正在纠结 SpringBoot + Vue 前后端分离的项目怎么落地,这篇东西应该能帮你少走不少弯路。
我做的这个平台叫“手办集市”,核心就是让多个商家入驻开店,各自上架手办商品,买家在一个平台里逛店、加购、下单。技术栈选的是 SpringBoot 2.7 + Vue 3,数据库用 MySQL,缓存用了 Redis,权限认证走 JWT。这组合不算新,但胜在稳,社区资料多,真遇到问题搜一下到处都是答案。
下面我从需求拆解、数据库设计、后端实现、前端实现、部署联调五个维度展开讲,全程附带我实际踩过的坑和排查思路。内容偏实战,代码片段都是可直接抄的级别,但更重要的是一些设计取舍背后的原因,这部分往往会决定你项目做到一半是推倒重来还是继续走下去。
1. 项目是怎么搭起来的:从需求到技术选型
1.1 多商家手办交易平台,核心要解决什么问题
先别急着写代码,把需求盘明白比什么都重要。这个平台表面看是“买卖手办”,但本质上是一个多租户电商系统。和单商家商城比,多了一些非常麻烦的约束:
- 商品的归属权必须明确,每个商品都属于某个商家,买家下单时得知道钱是付给哪个商家的。
- 购物车里的商品可能来自不同商家,结算时要么拆单、要么合并支付后按商家分账。
- 同一个平台里,商家之间的数据必须隔离,A 商家不能看到或改到 B 商家的商品和订单。
- 商家端和买家端的功能边界不一样,商家要管商品、订单、发货,买家要逛、要买、要退。
我这个“手办集市”最终确定的核心功能模块是:用户认证、商家入驻、商品管理、购物车、订单交易。听起来不多,但每个模块后面都挂着一堆细节。比如商品管理要有图片上传、库存管理、上下架状态;订单交易要处理超时未支付、发货、确认收货;商家入驻要有审核流程。你没看错,光是“商家入驻”这一个点,就涉及到一个独立的资质审核后台。
所以第一件事就是把角色捋清楚:平台管理员、商家、买家。三套权限,三套界面,后端接口也要做对应的权限控制。很多新手项目翻车就是因为角色权限一开始没设计好,导致后面所有接口都要返工加权限判断。
1.2 为什么选 SpringBoot + Vue,而不是其他组合
选型这事我不爱整那些花里胡哨的对比,直接说结论。后端用 SpringBoot,是因为它靠“约定大于配置”把项目的初始复杂度压到了极低。你新建一个项目,引入 web 和 mysql 依赖,一个注解就能跑起来,这对个人开发者或者小团队来说非常重要。而且市面上绝大部分电商开源项目都是 SpringBoot 写的,遇到问题搜资料成本很低。
前端用 Vue 3 而不是 Vue 2,主要考虑到 Composition API 写业务逻辑确实更清爽,尤其是购物车、订单这种状态比较复杂的页面,按功能去组织代码比按选项去堆代码好维护得多。配合 Vite 构建,开发热更新快,体验很好。
那为什么不选微服务?问得好。手办交易平台这种体量,单体应用完全够用。微服务带来的服务拆分、分布式事务、服务治理,都是实打实的复杂度,不是业务需要就别硬上。可能有人会提“多商家”是不是天然适合微服务,比如商家服务独立部署——道理是这么个道理,但对于一个中小型项目,单体内聚开发效率更高,等商家量和订单量真的上来再拆也不迟。最怕的是项目还没上线,先被自己引入的中间件搞垮了。
1.3 前后端分离的工程结构:一个聚合工程拆出来的清晰边界
工程结构上,我采用的是前端完全独立、后端用 Maven 聚合工程的管理方式。目录结构大概长这样:
text复制handong-market/
├── frontend/ # Vue 3 前端工程
│ ├── src/
│ │ ├── api/ # 接口请求封装
│ │ ├── router/ # 路由配置
│ │ ├── stores/ # Pinia 状态管理
│ │ ├── views/ # 页面组件
│ │ ├── layout/ # 布局组件
│ │ └── utils/ # 工具函数
│ ├── package.json
│ └── vite.config.js
└── backend/ # SpringBoot 后端聚合工程
├── framework/ # 公共模块:实体、工具、统一响应
├── system/ # 系统模块:用户、角色、权限
├── trade/ # 交易模块:商品、购物车、订单
└── admin/ # 管理后台模块:商家审核、运营管理
这种拆法有一个很直接的好处:前端和后端可以完全独立开发。我自己是后端出身,写前端的时候边看文档边写,如果不是工程分离,光是在一个项目里切换上下文就够浪费时间的。后端每个子模块之间也依赖清晰——trade 依赖 framework 和 system,但反向不能依赖,谁违反这个规则,代码审查的时候就得被提出来。
前端那边的重点是做了统一的请求封装,axios 实例统一设置 baseURL、请求头携带 token、响应拦截器统一处理错误码。这样后面加一个接口,就是写一个函数的事,几行代码搞定,不需要每写一个接口就复制一遍配置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库和权限:多商家系统最容易翻车的地方
2.1 核心表结构:先把交易链路里的角色理清楚
数据库设计我重做了三次,前两次都是做到一半发现缺表或者字段不够用。最后沉淀下来的核心表也不算多,但每张表都经过业务验证。先看整体:
user:用户表,包含买家账号和商家账号,通过user_type字段区分。seller_info:商家信息表,存店铺名称、店铺头像、入驻状态、审核备注等。category:商品分类表,手办这东西分类还挺细的,GK 雕像、拼装模型、成品手办、景品,分类设计成树形结构。product:商品表,归属某个商家,有标题、描述、价格、库存、封面图、详情图、状态等字段。cart_item:购物车表,记录用户加购的商品和数量。order:订单表,一个订单对应一个买家和一个商家,包含订单号、总金额、状态、收货信息等。order_item:订单明细表,记录订单里每个商品的快照信息(商品名、价格、数量、图片)。shipping_address:收货地址表,买家维护的地址簿。
这里有两个很多人容易忽略的设计点。
第一个是 order_item 为什么要存商品快照,而不是直接关联 product 表。因为商品的价格、名称、图片是会发生变化的,商家可能改价、改标题、甚至下架商品。如果订单明细直接关联商品表,那买家查看历史订单时看到的可能已经不是下单时的商品信息了。快照的本质是把下单那一刻的商品信息固化下来,这是电商系统的基本素养。
第二个是订单的归属问题。手办交易平台里,一个订单必须归属于一个商家,但买家可能在购物车里选了 A 店和 B 店的商品一起结算。这里有两个方案:一是购物车结算时自动按商家拆分成多个订单;二是合并成一个订单但订单里增加 seller_id 字段。我采用的是后者——一个订单里可以有多条不同商家的明细,但每一条明细都标记了 seller_id,前端展示时按商家分组。这样支付流程简单,后面对账也方便,商家在自己的管理后台里只能看到自己名下的明细。
2.2 多商家数据隔离:商品和订单怎么保证商家之间不串
多商家系统最核心的一件事就是数据隔离。我这里的做法很简单但很有效:所有涉及商家数据的表都带上 seller_id 字段,所有查询商家数据的接口必须强制传入 seller_id,而且这个 seller_id 是从当前登录用户的上下文里获取的,前端传什么一律不信。
有人在网上说用 MyBatis 拦截器做数据权限,所有 SQL 自动拼接 seller_id 条件。我试过,确实省事,但排查问题时特别痛苦——你根本不知道 SQL 执行的时候到底有没有拼接上条件。所以我最后选择最土的办法:在 Service 层显式控制。ProductService 里有一个 updateProduct 方法,方法内部会从 SecurityUtils.getCurrentSellerId() 拿当前商家 ID,然后拼接 WHERE seller_id = ? 条件。如果当前商家不是这个商品的拥有者,更新影响行数为 0,业务层直接报“无权操作”。
这个方案的好处是逻辑肉眼可见,每个方法都能看到数据权限控制在哪里;坏处是重复代码多一点,但可以通过一个 BaseService 把常用的 checkPermission 逻辑抽出来。下面这段是商品更新的核心逻辑,包含了权限校验:
java复制@Transactional(rollbackFor = Exception.class)
public void updateProduct(ProductUpdateRequest req) {
Long sellerId = SecurityUtils.getCurrentSellerId();
Product product = productMapper.selectById(req.getId());
if (product == null || !product.getSellerId().equals(sellerId)) {
throw new BizException("商品不存在或无权操作");
}
// 更新时再套一层保护,防止并发修改
int rows = productMapper.updateProduct(req, sellerId);
if (rows == 0) {
throw new BizException("商品信息已变更,请刷新后重试");
}
}
订单那边也一样。商家后台要查订单列表,SQL 里必须带上 seller_id。我见过有人把订单列表接口设计成传 sellerId 参数,结果平台管理员调这个接口硬编码 sellerId=1 就能查到所有商家的订单,这种“越权”漏洞在电商项目里属于致命问题。所以接口参数永远不要传身份相关的字段,身份一律从 token 里解析。
2.3 登录认证与权限控制:JWT + 拦截器的一个精简实现
认证方案我选了 JWT,而不是 Session。原因很简单:前后端分离的场景下,前端部署在 Nginx,后端是独立服务,Session 要处理跨域和 Cookie 携带问题,麻烦。JWT 无状态,后端只管签发和验签,水平扩容也友好。
JWT 的实现在 SpringBoot 里不算复杂,核心就三步。登录成功生成 token,token 里带上用户 ID、用户名、角色类型;写一个拦截器拦截需要认证的接口,从请求头取 token,验签通过后把用户信息放到 ThreadLocal;在 WebMvcConfigurer 里注册拦截器,并配置放行路径(比如登录接口和商品浏览接口)。
有一点要注意:JWT 是无状态的,一旦签发无法主动失效。如果需要强制下线(比如用户修改密码、管理员封禁商家),就得引入 Redis 做 token 黑名单机制,或者直接校验 Redis 里存的最新 token 版本。我自己的实现是登录时把 userId 作为 key、token 作为 value 存入 Redis,拦截器里每次请求都校验 Redis 中的 token 是否与请求头的一致。这样改密码或封号时删掉 Redis 里的 token 就能强制下线,安全性高很多,代价就是每次请求多一次 Redis 查询——但这对中小项目来说完全可接受。
角色权限我这里没引入 Spring Security 那套复杂的 @PreAuthorize 注解体系,因为角色类型就三种(管理员、商家、买家),一个自定义注解加一个拦截器足够。后端定义了一个 @RequireRole 注解,标注在 Controller 方法上,拦截器里从 token 解析出角色后和注解要求的角色比对,不匹配直接返回 403。代码量和 Spring Security 的配置相比少得多,而且自己写的逻辑出了问题好排查。
3. 后端核心模块:写代码前必须想清楚的几个问题
3.1 SpringBoot 配置:别在版本上白折腾
SpringBoot 版本这件事我多说两句,因为真的有人卡在这里卡了一整天。我在项目初期用的是 SpringBoot 3.0,Java 17,结果发现很多第三方依赖还没完全适配,尤其是 MyBatis 的 starter 和代码生成器,要么报错要么行为怪异。后来我果断降级到 SpringBoot 2.7 + Java 8,整个世界清净了。
所以我的建议是:如果项目依赖的中间件多,就别追求最新版本。SpringBoot 2.7 是 2.x 的最后一个稳定版本,社区资料最多、踩坑文章最丰富,用它最稳。等 SpringBoot 3.x 生态彻底成熟了再迁移也不迟。
核心配置文件里,我把数据源、Redis、MyBatis、文件上传、自定义 JWT 配置拆成了多环境配置。application.yml 是公共配置,application-dev.yml 是本地开发配置,application-prod.yml 是线上配置。切换环境只需要改 spring.profiles.active,不用改代码。
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/handong_market?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: ${DB_PASSWORD}
redis:
host: localhost
port: 6379
servlet:
multipart:
max-file-size: 10MB
max-request-size: 50MB
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
jwt:
secret: ${JWT_SECRET}
expire-days: 7
特别注意 map-underscore-to-camel-case: true 这个配置,数据库字段 seller_id 就能自动映射到 Java 实体类的 sellerId 属性,省掉一堆 ResultMap 的烦恼。
数据库密码和 JWT 密钥这种敏感信息,我用了环境变量的方式注入,而不是直接写在配置文件里。配置文件里写死密钥有个隐患——代码一旦泄露到 Git 仓库,等于把线上系统的钥匙交出去了。用环境变量可以做到同一个配置文件在开发、测试、生产环境都通用,只是环境变量值不同罢了。
3.2 商品与图片上传:静态资源映射与文件存储
手办这种商品,用户买不买很大程度上取决于图片好不好看,所以图片上传是商品管理的核心环节。图片存储我选择了本地文件存储,而不是直接上 OSS。原因是这个项目体量还没到需要 CDN 的程度,本地存储省钱省事,而且实现起来非常简单。
后端实现思路是:上传接口接收 MultipartFile,保存到服务器指定目录,文件名用 UUID 重命名,防止重名和路径穿越。最终把可访问的 URL 返回给前端。重点在于 SpringBoot 要配置静态资源映射,把 /upload/** 路径映射到实际存储目录,否则图片上传成功后浏览器访问会 404。
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String uploadPath = System.getProperty("user.dir") + "/upload/";
registry.addResourceHandler("/upload/**")
.addResourceLocations("file:" + uploadPath);
}
}
这里有一个坑必须提醒:很多人会把图片存在项目资源目录 src/main/resources/static 下面,开发时没问题,但打成 JAR 包部署后会发现上传的图片在重启后消失了,或者根本写不进去。因为 JAR 包内部的目录是只读的,不适合作为动态文件的存储位置。正确的做法是存储在服务器的一个固定目录,比如 /opt/handong-market/upload,然后通过 Nginx 或者 SpringBoot 的静态资源映射来提供访问。
我知道热词里还出现了“vue播放m3u8”,这其实和手办交易平台有点关系——有些商家会上传手办开箱视频或者展示视频,视频转码后生成 m3u8 索引文件。如果你的项目也要做视频展示,前端可以用 video.js + videojs-contrib-hls 插件播放 m3u8。后端只需要提供视频文件的 HTTP 访问能力即可,视频转码建议用 FFmpeg 做离线任务,不要在请求链路里同步转码,否则接口超时大概率跑不掉。
3.3 订单与库存:事务、乐观锁和超时处理
订单模块是整个系统里最容易出大问题的部分,没有之一。我拆成几个关键点来讲。
第一个是库存扣减。手办的限量款、会场限定款特别容易发生超卖问题。如果代码写的是“先查库存,够就更新”,在高并发下必然出事——两个请求同时查到库存是 1,都执行更新,库存变成 0,但两个订单都创建成功了。我采用的方案是乐观锁,SQL 更新时带上库存条件,影响行数为 0 就说明库存已被扣完:
java复制@Update("UPDATE product SET stock = stock - #{count} " +
"WHERE id = #{productId} AND stock >= #{count}")
int deductStock(@Param("productId") Long productId, @Param("count") Integer count);
这个方法返回受影响行数,如果为 0 就抛异常提示“库存不足”,同时因为是在事务里执行,订单创建就会回滚。这套方案已经能解决 99% 的场景,没必要再上 Redis 分布式锁——除非你预估并发真的爆炸到需要用队列削峰。
第二个是事务边界。创建订单涉及多个操作:校验商品、扣库存、生成订单主表、生成订单明细、清空购物车对应的商品。这些操作必须在同一个事务里,任何一个失败全部回滚。我在 Service 方法上标注 @Transactional(rollbackFor = Exception.class),注意一定要指定 rollbackFor,因为 Spring 默认只对 RuntimeException 回滚,如果抛的是自定义的 BizException 继承自 RuntimeException 就没问题,但如果是受检异常会不回滚,数据就脏了。
第三个是超时未支付。手办订单我设置的是 30 分钟未支付自动取消,同时释放库存。实现方案用的是 SpringBoot 自带的 Quartz 定时任务,每分钟扫一次订单表,把超时未支付且状态为“待支付”的订单批量取消并恢复库存。这里有个细节:批量扫描时不能用“当前时间减去 30 分钟”作为条件一次性扫,因为订单量大时会出现长事务。我的实现是用户查询订单时实时判断,如果发现订单已超时未支付,先执行取消逻辑再返回结果,这样既保证用户体验,也把定时任务的负担降低。
其实我在实际项目里更推荐一个异步延时消息的方案,比如 RocketMQ 的延迟消息,或者 Redis 过期 Key 监听,但这两个方案都得引入额外的中间件,对新手不友好。定时任务最简单、最不容易出问题,即使延迟一两分钟用户也感知不到。
4. 前端 Vue 实现:从路由到页面的完整链路
4.1 Vue 项目初始化与路由设计
前端这边我用 Vite 创建 Vue 3 项目,命令是 npm create vite@latest frontend -- --template vue。组件库选的 Element Plus,毕竟电商后台管理类的项目用它最顺手,表格、表单、分页都有现成的。
路由设计是这个项目的核心之一。用户端和商家端的页面完全不同,需要用权限控制路由访问。我的实现思路是:路由表分成两部分,基础路由(首页、商品详情、登录注册)所有人可访问,业务路由(购物车、订单、商家后台)使用路由守卫做登录校验和角色校验。
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) {
next({ path: '/login', query: { redirect: to.fullPath } });
return;
}
if (to.meta.role && token) {
const user = JSON.parse(localStorage.getItem('userInfo') || '{}');
if (user.userType !== to.meta.role) {
next({ path: '/403' });
return;
}
}
next();
});
还有一个坑是路由模式。开发模式下用 createWebHashHistory 或者 createWebHistory 都行,但部署到 Nginx 后如果用的是 history 模式,刷新页面会 404。解决办法是 Nginx 配置 try_files $uri $uri/ /index.html;,把所有路径都回退到 index.html。我建议新手直接用 hash 模式,省掉这个配置项,代价是 URL 里多一个 #,搜索引擎收录略差,但电商平台本身不依赖 SEO 流量,问题不大。
Vite 开发环境的代理配置也很关键。前后端联调时,前端请求 /api 开头,Vite 把它代理到后端服务,这样就不会有跨域问题:
javascript复制server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
4.2 接口封装与状态管理:登录态和购物车怎么全局处理
Vue 3 的状态管理我用的 Pinia。相比 Vuex,Pinia 的 API 更简洁,对 TypeScript 支持也更好。项目里两个核心 store 分别是 userStore 和 cartStore。
userStore 管理登录态:登录成功后把 token 和用户信息存入 localStorage,并同步到 store;退出登录时清空。这里有个细节,token 过期后接口会返回 401,我在 axios 的响应拦截器里统一处理,一旦收到 401 就清除本地登录态并跳转到登录页。这个逻辑不能写在每个页面里,否则每个接口调用都要判断,太蠢。
cartStore 管理购物车数据。购物车数据有两个来源:未登录时是从 localStorage 读的临时数据;登录后是从后端接口拉取的真实数据。我处理的方式是登录成功后把本地临时的购物车数据合并到服务端,再拉取最新购物车列表。这个“合并”逻辑不复杂,但很关键——用户没登录时加了几个商品,一登录反而购物车空了,这体验是很差的。
axios 请求封装这块,我推荐每个接口都走一个统一函数:
javascript复制import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
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 !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res.data
},
error => {
if (error.response?.status === 401) {
localStorage.clear()
router.push('/login')
}
ElMessage.error(error.message || '网络异常')
return Promise.reject(error)
}
)
后端统一返回格式是 { code: 200, message: "success", data: ... },前端拦截器统一解包,业务代码里拿到的直接是 data 部分,省掉了一堆重复判断。
4.3 核心页面实现:商品列表、购物车、订单的踩坑点
商品列表页面,最核心的是一个“筛选 + 分页”的组合。手办用户非常看重品牌、比例、材质这些属性,所以我做了一套筛选条件:分类、价格区间、品牌、比例。这个页面我用的是 Element Plus 的 el-form + el-pagination 组合,筛选条件变化时重新请求接口。这里有一个性能优化点:筛选条件变化时,分页页码要重置为 1,否则用户在第 5 页筛选“价格 500 以下”,接口会返回空数据,体验非常差。
商品详情页的图片展示用了 el-carousel 轮播图。手办商品的图片通常很多,商家上传的详情图可能有 6 到 8 张,轮播图是标配。这里有个细节,图片加载最好用懒加载,不然页面有 8 张高清大图,首屏加载速度会很难看。
购物车页面的逻辑比想象中复杂,主要是一个“勾选结算”的需求。购物车里每个条目都有复选框,用户勾选几个就结算几个,需要维护一个 selectedItems 的数组,存的是购物车条目的 ID。结算按钮的计算逻辑是获取所有勾选项,累加价格,然后把这些 ID 传给后端创建订单接口。这里有一个前后端要协调的约定:创建订单时传的是购物车条目的 ID 列表,后端通过 ID 查询对应的商品和数量,生成订单。不能由前端把价格传给后端,否则用户改一下前端代码价格就变了,这属于最低级的漏洞。
订单页面我觉得最值得做的是“倒计时显示剩余支付时间”。每个待支付订单都有 30 分钟的支付窗口,前端进入订单详情时,用当前时间减去下单时间,算出剩余毫秒数,再用定时器每秒刷新一次提示文字。超过时间后端会自动取消,前端在倒计时归零时把订单状态刷新为“已取消”即可。这个功能让用户明确知道自己的支付时限,能显著减少“为什么订单被取消了”这种客服问题。
5. 前后端联调、打包部署与常见问题排查
5.1 联调阶段的跨域与代理配置
前后端分离开发时最烦的就是跨域问题。开发阶段我已经说了,Vite 配置代理解决。但要注意:联调阶段如果前端和后端不在同一台机器上,代理配置里的 target 要改成后端的真实 IP 地址,不能写 localhost。很多人在这里栽过跟头——在自己电脑上开发好好的,一联调就请求失败,一看是代理指向了本机。
生产环境跨域问题其实不存在了,因为前端静态资源由 Nginx 提供,Nginx 把 /api 开头的请求反向代理到后端服务,所有请求都在同一个域名下,浏览器的同源策略自然不会拦截。所以生产环境压根不需要在后端配 CORS 过滤器——配置了反而多一层风险,比如调试时可能会错误地放行了一些不该放行的来源。
我本地开发还是加了 CORS 配置,方便直接用 Postman 调试,但我限制 allowedOrigin 只允许 localhost 的 5173 端口,不允许 *。你要知道 CORS 它不是安全机制,它只是一种浏览器限制的绕过手段,不能替代后端真正的鉴权。
5.2 打包部署:SpringBoot JAR 和 Vue 静态资源的组合
部署这一块,我按照“最简洁实用的方案”来做,没有上 Docker Compose,也没有用 K8s,两台云服务器就够了。
后端打包命令是 mvn clean package -DskipTests(我这里用的是 mvn,实际可能是 ./mvnw,取决于项目是 Maven 还是 Maven Wrapper),生成 JAR 包后拷贝到服务器,用 java -jar handong-market.jar --spring.profiles.active=prod 启动即可。
这里有一个坑:SpringBoot 项目打成 JAR 包后,如果里面有静态资源(比如前端页面的静态文件),缺省情况下 JAR 包内静态资源可以被访问,但如果你把前端文件直接塞进 JAR 包的 static 目录,后续每次改前端都要重新打一次后端包,非常痛苦。我推荐的做法是前后端完全分开部署:前端 npm run build 生成 dist 目录,由 Nginx 托管;后端 JAR 包独立运行。Nginx 配置大致如下:
nginx复制server {
listen 80;
server_name your-domain.com;
root /opt/handong-market/dist;
index 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 / {
try_files $uri $uri/ /index.html;
}
location /upload/ {
alias /opt/handong-market/upload/;
}
}
热词里提到的“springboot jdk1.8打包到docker desktop”,说明你大概率会用 Docker 部署。那我多说一句:如果你的服务器上装了 Docker,最省心的部署方式是写一个 Dockerfile,把 JAR 包打成镜像,用 docker run 启动。但注意 JDK 版本要和打包时的版本一致,否则很可能出现“UnsupportedClassVersionError”。我自己项目用的 Java 8,Dockerfile 里就是 FROM openjdk:8-jdk-alpine。但要注意,Java 8 官方镜像里没有 tzdata,你需要手动安装时区数据,否则容器时间差 8 小时,订单超时逻辑全乱套。
5.3 常见问题速查表:我踩过的坑都在这了
我把实际开发中遇到并排查过的问题整理成一个表格,每个问题都是真实发生过的,对应的解决思路也验证过。如果你也遇到了相似问题,直接对着看:
| 问题现象 | 根因分析 | 解决方案 |
|---|---|---|
| 前端请求后端接口报 CORS 错误 | 后端没配 CORS 或 Nginx 代理配置不对 | 开发环境走 Vite 代理,生产环境 Nginx 反代,避免跨域 |
| Vue 项目刷新 404 | 前端用了 history 路由模式但没做回退 | Nginx 加 try_files $uri $uri/ /index.html;,或改用 hash 模式 |
| 上传图片后访问 404 | SpringBoot 还没配置静态资源映射 | 自定义 WebMvcConfig,把 /upload/** 映射到本地目录 |
SpringBoot 3.x 项目启动报 ClassNotFoundException: javax.* |
SpringBoot 3 把 javax 换成 jakarta,老代码没适配 |
降级到 SpringBoot 2.7 + Java 8,或者全面升级依赖到 jakarta |
| 库存扣完后还创建了订单 | 扣库存和创建订单不在同一个事务里 | 把扣库存、建订单、清购物车放在同一个事务方法中,用乐观锁更新库存 |
| 商家后台能看到其他商家的商品 | 查询 SQL 没带上 seller_id 条件 |
Service 层从当前登录用户上下文取 sellerId,SQL 显式拼接条件 |
| 订单被自动取消但库存没恢复 | 定时任务里没有回滚库存 | 取消订单的 update 放在事务里,同时调用库存恢复方法 |
| token 明明还在,但请求一直返回 401 | JWT 密钥不对,或者服务器时间不一致 | 检查配置的 JWT 密钥是否一致,检查 Redis 里的 token 是否被删除 |
| 大图片上传报 413 错误 | Nginx 默认只允许 1MB 请求体 | Nginx 配置 client_max_body_size 20m;,同时 SpringBoot 里调大 max-file-size |
| 本地开发响应正常,线上接口偶发超时 | 数据库连接池耗尽或慢 SQL | 慢 SQL 打开日志分析,给常用查询字段加索引,调大连接池配置 |
这里面的“JWT 密钥不一致”是我栽过最冤的一次,因为我在本地测试时用的配置文件和线上配置文件的密钥不一样,导致本地生成的 token 在线上验签永远不过。排查了很久才发现是环境变量没同步。后来我写了一个登录后打印 token 里 payload 的日志,方便开发时快速确认 token 内容。
还有一个关于“SpringBoot 版本太高”的提醒。我面试别人的时候经常会问 SpringBoot 自动装配原理,但其实工作里真正重要的是你能不能用好给定版本下的生态。如果你在项目里看到 spring-boot-starter-parent 版本号是 3.x,那么所有 starter 的命名空间和部分配置项都是新规范,网上搜到的 2.x 资料可能直接套不上。出现这种情况,最快的办法不是硬啃源码,而是去看官方升级文档,或者干脆降到 2.7。
最后再分享一个小技巧。开发阶段,我会在 application-dev.yml 里开启 MyBatis 的 SQL 日志,这样每次操作数据库都能在控制台看到执行的 SQL 和参数值。很多人写 SQL 写不对,看着报错信息一头雾水,打开日志一看,原来是参数传错了。等排查完,上线前把日志级别调回 INFO 就行。这个习惯帮我至少省下了十倍查 Bug 的时间。
