前后端分离的在线家具商城,这类项目我这两年带不少人跑通过。先说结论:这是一个非常典型的Java全栈练手项目,麻雀虽小五脏俱全——用户注册登录、商品分类浏览、购物车、下单结算、后台商品和订单管理全都有,而且SpringBoot + Vue + MyBatis + MySQL这套组合,是现阶段国内中小型团队做Web系统最主流的选型之一,你把这个项目吃透,出去面试或者接手外包项目,底子已经比大多数简历写“熟悉SpringBoot”的人扎实了。
这篇博文我不想只丢一个源码包让你自己看,而是直接把整个项目从需求拆解、技术选型、核心实现到部署上线的完整链路给你捋一遍。下面所有截图级的细节、配置文件和踩坑点,都是我在实际部署和改造这个项目的过程中整理出来的,适合正在做毕业设计、想入门全栈开发、或者准备做一套基础商城系统二次开发的读者参考。
1. 项目整体拆解:在线家具商城到底在做什么
1.1 为什么选“家具”这个品类做前后端分离项目
很多人一开始看到“家具商城”会觉得奇怪:做商城系统,用“图书”、“数码”不是更常见吗?我当时选家具这个品类,恰恰是因为它比图书、数码更有代表性。
家具类商品有几个显著特点:SKU(库存量单位)不算太多但属性复杂——一张沙发有材质、颜色、尺寸、风格、品牌五个维度;主图要求高——需要多角度展示图、详情轮播图;客单价高、订单状态流转长——从下单到发货再到收货确认,中间还可能要经历定制工期。这些东西会倒逼你把数据库设计得足够规范,把订单模块做完整,而不是像图书商城那样简单“下单-发货”两步走完。
从教学和毕设角度来看,家具商城还天然带一个亮点:后端管理端可以做得很有内容,比如商品多图上传、分类层级管理、库存预警、订单多条件筛选。这些功能拆出来都是面试喜欢问的高频点,写在简历上比“实现了用户登录注册”好看得多。
1.2 需求范围与功能模块清单
这个系统的整体角色分成两端:
- 前台商城(用户端):面向C端消费者,核心使命是“让用户逛得爽、买得顺”。
- 后台管理(管理端):面向运营和管理员,核心使命是“让商品和订单管得清”。
我们对照着来看模块清单:
| 端 | 模块 | 功能说明 |
|---|---|---|
| 前台 | 用户模块 | 注册、登录(JWT鉴权)、个人信息维护、收货地址管理 |
| 前台 | 商品模块 | 分类导航、商品列表(价格/销量排序)、关键词搜索、商品详情 |
| 前台 | 购物车 | 加入购物车、修改数量、删除、选中结算 |
| 前台 | 订单模块 | 订单创建、订单列表、订单详情、取消订单 |
| 后台 | 商品管理 | 商品CRUD、多图上传(本地存储)、上下架、库存修改 |
| 后台 | 分类管理 | 一级/二级分类维护、排序 |
| 后台 | 订单管理 | 订单列表查询、发货状态更新、订单备注 |
| 后台 | 统计看板 | 商品总数、订单总数、近7日订单趋势(简单ECharts图表) |
这套功能范围我建议你拿到源码后第一件事就是对照着跑一遍,跑通之后你自然会对整个项目的数据流转有个整体的感知:用户在前台点下“提交订单”的那一刻,后端发生了什么,数据库里几张表的数据怎么联动。带着这个感知去读源码,效率会高很多。
2. 技术选型:SpringBoot + Vue + MyBatis + MySQL为什么能打
2.1 后端框架选型:SpringBoot的取舍
后端框架备选方案其实不少,SSH(Struts2 + Spring + Hibernate)、SSM(Spring + SpringMVC + MyBatis)、SpringBoot都有人用。但放在2024年这个时间节点,SpringBoot已经是事实上的Java后端标准,没有太多悬念。
选SpringBoot的理由很实在:
- 内置服务器:自带的Tomcat让开发期零配置启动,“写好接口直接Run”,不用像传统SSM那样部署到外部Tomcat再启动,迭代效率提升是肉眼可见的。
- 自动配置:数据库连接池、ORM框架、JSON序列化这些基础设施,SpringBoot用
spring-boot-starter-xxx一拉就全有了,省掉大量XML配置。 - 生态无缝衔接:SpringSecurity、Redis、RabbitMQ、文件存储这些后续扩展组件,全都是在SpringBoot上做starter集成,你现在用SpringBoot打底,后面不管加入什么功能都有现成方案。
从学习角度我也建议你跳过传统的SSM阶段直接上SpringBoot。XML配置那套是老一辈程序员的历史包袱,你花两周死磕applicationContext.xml的bean定义,对理解现代Web开发几乎没有增量收益,不如把这时间花在接口设计、数据库建模、权限方案上。
2.2 数据持久层:MyBatis凭什么替代JPA
持久层框架的选择是个争论不休的话题,我的态度很明确:国范围内你想找一套最快上手、SQL最可控的方案,MyBatis依然是首选。
对比一下你就懂了:
| 对比维度 | MyBatis | Spring Data JPA |
|---|---|---|
| SQL可控性 | 手写SQL,慢查询一眼就能定位 | 动态生成的SQL,复杂查询需要查日志 |
| 上手门槛 | 会SQL就会用,XML直接写 | 需要理解ORM思想、懒加载、持久化上下文 |
| 复杂查询 | 多表联查、动态条件都很好写 | 关联查询容易踩N+1坑 |
| 国内普及度 | 绝大多数公司Java岗都在用 | 外企和部分新项目用得多 |
家具商城这种系统有个很典型的场景:商品列表需要多条件动态查询(分类+价格区间+排序方式,条件可能有也可能没有)。在MyBatis里用<where>加<if>标签写动态SQL,逻辑清晰、执行SQL一眼看懂;在JPA里你得靠Specification或者@Query拼接,写出来又长又绕。所以我坚持用MyBatis,本质上是为了让“SQL”这件事回归直觉——你写出什么SQL,数据库就执行什么SQL,性能全部在掌控之中。
2.3 前端方案:Vue 2还是Vue 3
这个项目我用的是Vue 2 + Element UI,如果在2024年新开项目,我会建议你直接上Vue 3 + Element Plus。但这里有个现实考量:现在市面上大量存量项目、毕设源码、外包系统都是Vue 2,你想要快速跑通这套源码,Vue 2是绕不开的学习成本。而且Vue 2的核心思想(组件化、数据响应式、路由、状态管理)在Vue 3里基本一脉相承,你先把Vue 2的项目啃明白,迁移到Vue 3只需要学Composition API和新的生态组件即可。
Vue在前端分离这个架构里承担的角色很清晰:
- 组件化:首页的导航栏、商品卡片、轮播图、购物车列表,全部拆成独立组件,相互不干扰,这就是前后端分离里前端部分的自洽与模块化——后端按接口切分,前端按组件切分。组件化带来最大好处是可复用,比如商品卡片组件,首页推荐位、商品列表页、搜索结果页三处共用,改一个地方三处生效。
- 数据响应式:数据变更自动更新视图,你在购物车页面点“+1”,总价实时刷新,不需要手动操作DOM,这种开发体验比直接写jQuery舒服太多。
- 生态配套:VueRouter管页面跳转、Axios管HTTP请求、Vuex/Pinia管全局状态,三件套把前端工程化拼图补齐了。
2.4 前后端分离的架构优势与通信机制
聊聊“前后端分离”本身。早年的JSP开发模式是前端写死在Java里,一个页面既要写HTML又要嵌套Java代码,改个样式可能要重新编译整个项目。前后端分离之后,职责边界非常清晰:
- 前端只负责页面渲染、用户交互、调用接口渲染数据,跑在浏览器里。
- 后端只负责业务逻辑、数据持久化、权限校验,跑在服务器上。
- 通信载体是JSON格式的RESTful API,前端发HTTP请求,后端返回JSON,谁也不关心对面内部怎么实现。
开发模式上,只要接口文档定义好,前后端可以并行开发,前端用Mock数据先渲染页面,后端同步写接口,最后联调时把Mock换成真实接口就行。部署模式上,前端构建成静态文件扔Nginx,后端打包Jar或War独立运行,两边各部署各的,扩容时甚至可以分别做负载均衡。这套架构能成为今天Web开发的主流,靠的是实打实的工程效率提升,不是概念炒作。
通信的细节部分有个必须掌握的机制——跨域。你在开发环境访问localhost:8080的Vue页面,却要向localhost:9090的SpringBoot接口发请求,浏览器会因为协议、域名、端口不一致而阻止,这就是跨域问题。经典解法是在SpringBoot里加一个CORS全局配置类,允许指定来源访问。这个配置我在后面的实操部分会给出完整代码。
3. 核心实现细节与实操要点
3.1 数据库设计:9张核心表的设计思路
数据库是这套系统的地基,出问题全是连锁反应。我按业务模块把核心表拆开讲讲设计思路:
| 表名 | 核心字段 | 设计要点 |
|---|---|---|
user | id, username, password, nickname, avatar, phone | 密码必须加密存储,我用的是BCrypt加盐哈希,绝对不能明文 |
address | id, user_id, receiver, phone, province, city, detail | 一个用户对应多个地址,所以单拎出来做表 |
category | id, name, parent_id, sort | parent_id为0表示一级分类,实现两级分类结构 |
product | id, category_id, name, subtitle, main_image, price, stock, sale_count, status, detail | 用逻辑删除(status字段)而不是物理删除,便于订单历史数据追溯 |
product_image | id, product_id, url, sort | 一张商品多张图片,用子表存 |
cart_item | id, user_id, product_id, quantity, checked | 联合约束user_id + product_id唯一,同一商品复用一条记录 |
order | id, order_no, user_id, total_price, status, receiver_info | order_no全局唯一;收货人信息冗余存入订单表,防止地址变更后订单查不到旧地址 |
order_item | id, order_id, product_id, product_name, product_image, price, quantity | 商品快照字段(名称/图片/价格)必须冗余,否则商品改价后订单金额对不上 |
admin | id, username, password | 管理端单独账号体系,和用户端隔离 |
几个容易被人忽略的细节:
- 金额字段用十进制类型:
decimal(10,2)而不是float/double。浮点数的精度问题在金额计算上会直接导致对不上账,这是初学者常踩的坑。 - 订单号和雪花算法:不要用数据库自增ID当订单号,因为会暴露销量、而且多表合并时容易冲突。我项目里用UUID截取加时间戳生成了个32位的order_no,虽然没那么花哨,但足够满足唯一性要求。
- 逻辑删除的设计:商品表用
status字段(0下架、1上架、2删除)代替物理删除。因为订单详情里要展示历史商品信息,物理删除会导致外键悬空。同理用户也是逻辑删除,防止user_id在订单里查无此人。
3.2 后端分层与接口设计:Controller-Service-Mapper怎么组织
后端代码结构我建议你直接照搬经典三层架构,别玩花活:
com.example.mall ├── controller // 接收请求、参数校验、返回统一结果 ├── service // 业务逻辑编排、事务管理 ├── mapper // MyBatis数据访问接口 + XML文件 ├── entity // 数据库实体类 ├── dto // 数据传输对象(前端传入/返回) ├── config // 全局配置(跨域、拦截器、静态资源映射) ├── common // 统一返回结果、异常处理、常量 ├── util // 工具类(JWT工具、MD5工具) └── interceptor // JWT登录校验拦截器Controller层我强调两点:
- 统一返回结构。所有接口都返回
Result.success(data)或Result.error(msg),结构固定为{code, message, data}。这样前端拦截器可以统一判断code是否为200,不用每个接口单独写错误处理。三端联调(前端、后端、测试)只看code就能定位问题。 - RESTful风格。比如商品接口设计为
GET /product/{id}、GET /product?categoryId=1&pageNum=1&pageSize=10&sort=price_desc。路径上的名词代表资源,HTTP方法代表操作,语义清晰自解释,前端对接零沟通成本。
Service层是业务逻辑的重头戏,最核心的就是下单接口。它不是一个简单的INSERT,而是一个涉及多张表的事务操作:
@Transactional public Order createOrder(Long userId, List<CartItemDTO> items, Long addressId) { // 1. 校验购物车数据,计算总金额 // 2. 检查商品库存,不满足则抛出异常 // 3. 生成订单主记录(状态:待付款) // 4. 批量生成订单明细(商品名称/图片/价格快照) // 5. 扣减库存:UPDATE product SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity} // 6. 清空购物车中已下单的商品 return order; }注意第5步的写法:WHERE stock >= #{quantity}是防超卖的关键,通过UPDATE语句受影响行数是否为0来判断库存是否足够,这种“乐观锁扣减”在单体应用里已经够用,不需要引入Redis分布式锁那么重。方法上的@Transactional保证这6步要么全部成功、要么全部回滚——比如库存扣减成功但订单明细插入失败,整个事务回滚,库存不会白白扣掉。
MyBatis的XML部分,商品列表的动态查询是我最想让你看懂的案例:
<select id="selectProductList" resultType="com.example.mall.entity.Product"> SELECT * FROM product <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR subtitle LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY ${sort} -- 这里注意用${},排序字段属于白名单配置,不能走预编译 </select><where>标签会自动处理掉第一个条件的AND,<if>实现了条件动态拼接。这套写法就是MyBatis的看家本领,面试十有八九会问到,你源码里多找几个这种例子,自己能默写出来就算真掌握了。
3.3 前端页面与组件拆解:从登录页到购物车的完整链路
前端项目结构我建议这样组织:
src ├── api // 接口请求模块(按业务模块拆:product.js, order.js, user.js) ├── assets // 静态资源(图片、全局样式) ├── components // 公共组件(Header, Footer, ProductCard, Pagination) ├── router // 路由配置(含守卫) ├── store // Vuex状态管理(购物车角标、用户信息) ├── views // 页面组件 │ ├── home // 首页 │ ├── product // 商品列表、详情 │ ├── cart // 购物车 │ ├── order // 订单确认、订单列表 │ ├── user // 登录、注册、个人中心 │ └── admin // 后台管理页面 └── utils // 工具(axios封装、token存储)核心请求封装直接用Axios实例,统一注入token和响应拦截:
// utils/request.js import axios from 'axios' import router from '@/router' const service = axios.create({ baseURL: '/api', // 开发环境走Vue代理 timeout: 10000 }) // 请求拦截:带上token service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token // 后端从Header取值 } return config }) // 响应拦截:统一处理code service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { // 401跳登录页 if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )购物车角标这个功能最能体现Vuex的价值:用户在不同页面(首页加到购物车、商品详情页看购物车、下单页清空购物车)时,角标数字是全局共享的状态,放进Vuex维护,一个commit触发,所有相关组件自动更新。
前端几个容易忽略的细节:
- 路由守卫:
router.beforeEach((to, from, next) => {})里判断路由meta是否需要登录,没token就跳转/login?redirect=当前路径,登录成功后跳回原页面,这个交互细节很提升体验。 - 图片别名:Vue CLI默认的
@指向src目录,配置好了别瞎改,不然一堆import报错。 - 组件通信:父子组件用
props和$emit,跨页面用Vuex,全局事件(如购物车变更)可以用一个简单的$bus(new Vue())来做,但别在项目里到处乱用,容易把数据流搞乱。
3.4 鉴权与安全:JWT登录态方案
前后端分离项目不能靠Session维护登录态,因为浏览器和服务器跨域了、而且前端可能是多端(Web/H5/小程序)。我用的是JWT(JSON Web Token)方案,流程如下:
- 用户输入用户名密码,后端校验通过,签发一个token返回给前端。
- token由三部分组成:Header(加密算法)、Payload(用户ID、过期时间)、Signature(服务端密钥签名)。密钥在服务端私藏,用HS256算法签名,客户端无法伪造。
- 前端把token存到
localStorage,后续所有请求在Header里带上Authorization: token。 - 后端写一个拦截器,拦截除了登录、注册、商品列表、商品详情以外的所有接口,校验token、解析出用户ID放入
ThreadLocal(用户上下文),供Controller直接使用。 - 管理端单独用一套
AdminInterceptor做校验,防止普通用户token访问管理接口。
核心代码:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行OPTIONS预检请求 if ("OPTIONS".equals(request.getMethod())) return true; String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { throw new BizException(401, "未登录"); } try { Claims claims = Jwts.parser().setSigningKey(secretKey) .parseClaimsJws(token).getBody(); // 用户ID存入ThreadLocal UserContext.set(claims.get("userId", Long.class)); return true; } catch (ExpiredJwtException e) { throw new BizException(401, "登录已过期"); } catch (JwtException e) { throw new BizException(401, "非法token"); } } }这个方案有几个需要特别注意的坑:
- 登录过期处理:前端拦截器拿到401要跳登录页,但Ajax请求要避免无限循环跳转,我没用
window.location.href直接跳,而是用router.push并带上redirect参数。 - 敏感信息不进Payload:JWT的Payload部分只是Base64编码,不是加密,别把密码、手机号明文塞进去,放个用户ID就够了。
- 注销问题:JWT是无状态的,服务端不能主动使它失效。所以我的方案是前端删除本地token,配合短过期时间(2小时)。真要严格注销控制,得引入Redis黑名单,这个属于进阶扩展了。
4. 完整部署教程:从源码到上线
4.1 本地开发环境搭建
先把环境准备好,我用的是目前稳定且兼容性最好的版本组合:
| 工具 | 版本建议 | 备注 |
|---|---|---|
| JDK | 1.8 或 11 | SpringBoot 2.x最稳的是1.8,装3.x才需要17 |
| Maven | 3.6.3+ | 配置阿里云镜像加速依赖下载 |
| Node.js | 14.x 或 16.x | Vue2项目别装Node 18+,容易报OpenSSL错误 |
| MySQL | 5.7 或 8.0 | 8.0注意serverTimezone=Asia/Shanghai |
| IDE | IDEA 2023+ | 后端强烈建议IDEA,前端用VS Code即可 |
环境搭建三个高频报错先给你预防针:
- Maven依赖下载慢或者失败:在
~/.m2/settings.xml配阿里云镜像,别用默认中央仓库。 - Node版本太高导致的OpenSSL问题:Vue2老项目的webpack版本和Node18不兼容,启动报
error:0308010C:digital envelope routines::unsupported,三个解法任选:换Node16、或者用NODE_OPTIONS=--openssl-legacy-provider环境变量硬撑。 - MySQL8认证插件问题:JDBC连接报
Unable to load authentication plugin 'caching_sha2_password',把连接驱动换成mysql-connector-java最新版即可。
数据库初始化我直接给你完整流程:
# 1. 登录MySQL mysql -u root -p # 2. 创建数据库(utf8mb4比utf8更好,兼容emoji和生僻字) CREATE DATABASE furniture_mall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; # 3. 导入SQL脚本 use furniture_mall; source /path/to/furniture_mall.sql; # 4. 查看导入是否成功 SHOW TABLES;后端配置文件里的数据库连接和密钥,照着这样的格式改:
spring: datasource: url: jdbc:mysql://localhost:3306/furniture_mall?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver jwt: secret: 自定义一个至少32位的密钥字符串 expire: 7200000 # 过期时间毫秒数,2小时前端环境搭建两条命令搞定:
npm install npm run serve如果npm install卡在某个包上,先清缓存再换源:
npm config set registry https://registry.npmmirror.com rm -rf node_modules package-lock.json npm install4.2 后端打包:jar包运行与Tomcat war包两种方案
后端打包有两条路线,我逐一说明适用场景。
方案A:Jar包直接运行(推荐)
SpringBoot内置Tomcat,你最终交付的就是一个可执行的jar包,服务器上只要装了JDK就能跑。步骤:
# 在项目根目录执行 mvn clean package -DskipTests # 得到 target/furniture-mall-0.0.1-SNAPSHOT.jar java -jar target/furniture-mall-0.0.1-SNAPSHOT.jar生产环境用nohup启动,避免SSH断开导致进程退出:
nohup java -jar furniture-mall.jar --spring.profiles.active=prod > logs/app.log 2>&1 & echo $! > app.pid方案B:War包部署到外部Tomcat
有些学校或公司的运维环境只允许把应用放进Tomcat的webapps目录,这时候需要改造:
- 修改
pom.xml:<packaging>war</packaging>。 - 启动类继承
SpringBootServletInitializer并重写configure方法:
@SpringBootApplication public class MallApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(MallApplication.class); } }- 打包后把war放到Tomcat的
webapps目录,启动Tomcat自动解压部署。
两种方案怎么选:jar好比便携式炒锅,自带加热源随处可用;war好比电炉台面上的锅,必须依托于外部容器。我强烈建议你学会jar方式,因为这才是SpringBoot生态的默认姿势,war是历史兼容产物。
4.3 前端构建与Nginx部署
这套前端300行配置你直接照抄,核心就两个作用:托管静态文件 + 反向代理后端接口:
server { listen 80; server_name your-domain.com; # 改成你的域名或服务器IP # 前端静态资源 location / { root /usr/share/nginx/html/furniture-mall; index index.html; try_files $uri $uri/ /index.html; # Vue history模式必须加这句 } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:9090/; 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 /images/ { alias /data/images/; # 图片存在服务器的这个目录下 } }这里有两个必须理解的配置点:
try_files $uri $uri/ /index.html;:Vue Router开了history模式(URL不带#号)时,前端路由在服务端其实不存在对应文件。用户访问/product/1直接刷新,Nginx找不到这个文件就会404,这行配置把所有找不到的文件路径都回退到index.html,由前端路由接管。没有这行,刷新必挂。location /api/代理到后端:前端请求/api/product/list,Nginx把请求转发到127.0.0.1:9090/product/list(注意proxy_pass末尾的/会替换掉匹配的/api/前缀)。这样浏览器访问的是同一域名,不存在跨域问题,生产环境根本不需要CORS配置。
构建命令和执行过程:
# 前端项目目录 npm run build # 产物在 dist 目录 ls dist/ # dist/index.html dist/static/... # 部署到Nginx目录 sudo cp -r dist/* /usr/share/nginx/html/furniture-mall/ # 重新加载Nginx sudo nginx -s reload部署完成后访问http://服务器IP/,看到首页就说明前端起来了,点一个商品加入购物车,如果数据正常返回,说明前后端链路通了。
4.4 前后端联调与生产环境配置
联调阶段经常出现本地好好的、上线就坏的情况,排查顺序很重要。我先说一个最容易翻车的点:环境配置分环境。
你别把开发环境的数据库密码、JWT密钥写死在application.yml里传到服务器。正确做法是:
# application-dev.yml spring: datasource: url: jdbc:mysql://localhost:3306/furniture_mall_dev password: dev_password # application-prod.yml spring: datasource: url: jdbc:mysql://你的生产库地址:3306/furniture_mall password: prod_password启动时指定环境:
java -jar app.jar --spring.profiles.active=prod另一个生产环境必踩的坑是上传图片的路径。本地开发随便存到项目resources下就行,但jar包运行后,往jar内部写文件要么不生效要么重启丢失。正确做法是在配置里指定外部路径:
upload: path: /data/images然后写一个静态资源映射配置类,把/images/**映射到磁盘目录:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${upload.path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourceLocations("file:" + uploadPath + "/"); } }这样商品图片传到/data/images/xxx.jpg,前端访问/images/xxx.jpg,Nginx再把这个路径映射到磁盘,中间没有任何jar包内部写文件的坑。
5. 常见问题与排查技巧实录
5.1 跨域问题:前后端联调第一道坎
现象:前端localhost:8080访问后端localhost:9090接口,控制台报错Access to XMLHttpRequest at ... from origin ... has been blocked by CORS policy。
原因:浏览器同源策略拦截。不过有一个容易误解的点——跨域是浏览器的行为,不是服务器的行为,后端接口其实正常返回数据了,但浏览器不让JS读取。
我项目里给SpringBoot加一个CORS配置类解决:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意事项:如果allowedOriginPatterns配了*,前端请求带token时浏览器会先发一次OPTIONS预检请求,这里allowCredentials(true)和allowedOriginPatterns("*")同时使用,在浏览器中会被判定为非法配置(因为*太宽泛),需要把*换成具体的前端地址http://localhost:8080才不报错。这是很多新手照着教程配好之后依然报跨域的原因。
5.2 PageHelper分页插件的两大坑
分页用MyBatis最流行的插件PageHelper,使用本来很简单:
PageHelper.startPage(pageNum, pageSize); List<Product> list = productMapper.selectProductList(query); PageInfo<Product> pageInfo = new PageInfo<>(list); // pageInfo.getList() 就是分页数据,getTotal()是总数但有两个坑我踩过好几次:
坑一:startPage和查询必须紧邻。startPage开启一个线程本地变量,如果不马上执行查询,这个变量不会自动清除,可能导致下一次查询被意外分页。所以在PageHelper.startPage()和mapper查询之间绝对不能插入任何其他DB操作,否则分页条件就串了。
坑二:多表JOIN时分页不准。如果SQL里有LEFT JOIN,PageHelper会自动在SQL外面包一层SELECT COUNT(0) FROM (原SQL) tmp_count来统计总数,但商品表关联图片表时一对多会产生重复行,导致总数偏大。解决方法是:在select语句里用DISTINCT关键字,或者干脆子查询先聚合再关联,比如商品主查询里不直接JOIN product_image,而是把图片URL做成子查询。
5.3 MyBatis缓存导致的幽灵数据
现象:后台修改了商品价格,前台刷新还是旧价格;重启项目就好了,跑一段时间又复发。
原因:这是MyBatis二级缓存造成的。在单个Mapper的XML里配置了<cache/>开启二级缓存后,同一Mapper的查询结果会被缓存到内存。如果你在管理端直接执行update修改了商品数据,而查询缓存的key没有同步失效,就会返回脏数据。
排查方式:看MyBatis日志里开了Cache Hit Ratio(缓存命中率统计),命中率高且数据不对,基本就是缓存问题。
这个项目我的建议是:学习阶段直接去掉<cache/>配置,不需要做缓存优化。等到系统真的出现性能瓶颈,再去考虑Redis做热点数据缓存,那是一个完整独立的优化专题。MyBatis缓存是个看着简单实际上容易踩坑的东西,没必要在小项目里用它。
5.4 Vue Router history模式刷新404
现象:本地npm run serve一切正常,点击页面跳转也没事,但部署到Nginx后,在/product/1这种二级路径刷新,页面404。
原因:/product/1在Nginx里找不到对应的静态文件,Nginx直接返回404。
解决办法前面已经提到了,location /块里加try_files $uri $uri/ /index.html;。我再补充一个注意点:如果前端项目不是部署在根路径,而是部署在子路径(比如/mall/),那Nginx配置、Vue的base配置、路由的createWebHistory(process.env.BASE_URL)要三者保持统一,否则资源路径全部错乱。
5.5 数据库连接与中文乱码排查
先给个快速自查表:
| 症状 | 大概率原因 | 解决 |
|---|---|---|
| 页面表单提交中文乱码 | 数据库连接URL没有characterEncoding=utf8 | 在JDBC URL加参数 |
| 数据库表中查询乱码 | 建表时字符集不是utf8mb4 | ALTER TABLE xxx CONVERT TO CHARACTER SET utf8mb4 |
| JSON返回乱码 | SpringBoot没有配置Jackson编码 | 确保spring.http.encoding.force=true |
| 控制台中文乱码 | IDEA控制台编码问题 | IDEA设置里File Encodings全改成UTF-8 |
还有一个特别隐蔽的坑:MySQL的sql_mode里如果有ONLY_FULL_GROUP_BY,某些GROUP BY查询会报错,比如商品列表里想按分类聚合统计数量,但SELECT字段包含非聚合列。换个思路改成子查询,或者合理修改sql_mode,别硬刚。
5.6 前端启动常遇的Node版本和依赖安装问题
前端项目clone下来,npm install步骤经常出幺蛾子:
- 安装卡在
node-sass:node-sass是Vue2老项目的经典痛点,它需要下载二进制文件,在国内网络经常超时。我建议你把样式方案降级,把less/sass都换成纯CSS,或者使用dart-sass替代node-sass。 - 报错
Maximum call stack size exceeded:这是Node版本过高导致的,node-sass等老依赖与高版本Node不兼容。换Node 16即可。 .env环境变量没生效:Vue CLI的.env.development只在npm run serve时加载,.env.production只在npm run build时加载,别把开发变量写到production文件里。
6. 个人实操心得与拓展建议
6.1 从这个项目往后延伸的三种方向
先把基础版本跑通、能改、能部署,然后你可以根据自己的目标做三种方向的延伸。
方向一:面试突击型。把高并发相关基础补上:Redis缓存商品热点数据、秒杀场景用Redis预扣库存、消息队列削峰。核心不一定要真的实现,但至少要在简历和面试里能讲清楚:商城的哪个模块存在热点问题、你的方案思路是什么。这个项目就是你的“弹药库”,所有场景都能落地到一个真实模块上。
方向二:商业接单型。把它改造成一个可交付的多商户商城,把商品模型扩展成“店铺+商品”,订单增加售后流程,后台加权限管理(不同管理员不同权限),支付接入微信/支付宝。说实话不少外包商城的原始版本就是我描述的这种结构,你在上面做加法减轻了从零开始的巨大工作量。
方向三:技术深造型。把单体拆成微服务:SpringCloud Gateway做网关、Nacos做注册中心、用户/商品/订单三个独立服务、分布式事务用Seata、统一日志用ELK。这个项目源码足够清晰,拆起来会比较顺,因为表结构和业务边界本来就划分得干净。
6.2 最后几条实在的建议
根据我反复跑这个项目的实际体验,整理几条可能会帮你少走弯路的建议:
- 先跑通再读代码。别一开始就纠结某一行代码为什么这么写。先把环境搭起来、项目跑起来、走一遍“注册→登录→加购物车→下单→后台发货”的完整流程,你对系统的体感会完全不一样。带着问题读代码,效率翻倍。
- 改代码前先备份数据库。这个项目的SQL脚本虽然可以反复执行,但如果你已经手动改了表结构,重跑脚本可能报错。养成习惯:动手前
mysqldump导出一份备份,出问题随时回滚。 - 接口调试用Postman或Apifox。前端页面没调通之前,先用工具测后端接口。比如验证登录接口返回的token能不能通过拦截器、商品搜索接口的排序参数是否生效。工具先把后端验证完,再联调前端,出问题时定位范围小很多。
- 保留一个全量SQL初始化文件。每次修改表结构或者新加测试数据,都同步更新到SQL初始化脚本里。这样不管是换电脑、换服务器、给同学部署,一条命令还原整个环境,不用靠“经验”去手动补数据。
最后再分享一个小技巧:项目里所有的配置文件都尽量用url和密码分离的写法,application.yml里不要写死具体环境变量,统一通过--spring.profiles.active=xxx和@Value注入。这样代码在哪个环境部署都不用改源码,只需要改启动参数和环境变量,前后端分离项目的部署灵活性就是这么体现出来的。