前后端分离的 SpringBoot+Vue 图书电商系统,是我这段时间完整整理并跑通的一个全栈实战项目。整套源码覆盖了用户注册登录、图书检索、购物车、订单提交到后台管理的完整业务链路,后端用 SpringBoot+MyBatis 操作 MySQL,前端走 Vue+Element UI,接口全部是 RESTful 风格,部署在标准的 Tomcat 环境里。适合正在学前后端分离开发的人拿来练手,也适合需要快速搭建电商系统原型的同学,按文档一步步操作,从零到上线差不多一个周末就能搞定。
写这套系统的时候,我刻意没有用那些花里胡哨的微服务、中间件,原因是图书电商这个体量,单体架构加前后端分离已经非常够用。把核心链路跑通、把 CRUD 写规范、把部署走顺,比堆技术栈更能锻炼人。下面我会从设计思路、数据库建模、后端实现、前端联调、最后部署这几个维度完整拆解,末尾还有我在实际踩坑中整理的常见问题速查表,建议收藏。
1. 项目定位与技术选型思路
1.1 图书电商系统的核心业务闭环
在动手写代码之前,先要把业务逻辑盘清楚。图书电商网站不是简单的图书列表展示,它有一条完整的闭环:用户注册登录后浏览图书,搜索或按分类筛选,进入详情页查看内容,加入购物车,确认订单后填写收货地址提交订单,后台管理员对图书和订单进行管理。这条链路涉及 6 张核心表、20 多个接口、10 多个前端页面。
我在设计时把系统拆成了前台和后台两个部分。前台是普通用户能看到的全部页面,包括首页、图书分类页、搜索页、详情页、购物车、提交订单页、个人中心订单列表、登录注册。后台则面向管理员,包含图书管理(增删改查、上下架)、订单管理(查看、发货)、分类管理,以及一个简单的销售统计面板。前后台共用同一套后端接口,只是通过登录用户的角色字段来控制访问权限。
这个业务闭环看起来简单,但每一个环节都有值得深入的细节。比如购物车到底存 session 还是存数据库,订单状态怎么流转,图书库存怎么扣减,这些都是在真正开发中必须决策的点。我在这个项目里选择了购物车落库的方案,这样用户换设备登录购物车还在,体验更接近真实电商。订单状态则设计了待支付、已支付、已发货、已完成、已取消五种,用数字枚举存储,前端根据数字映射成中文标签。
1.2 技术栈选择:为什么是 SpringBoot+Vue+MyBatis+MySQL
很多新手纠结技术栈,总觉得要上个 Spring Cloud 才够高级。我的观点是:项目的技术选型要匹配业务复杂度。图书电商这个项目,并发量不大、业务逻辑清晰,SpringBoot+MyBatis+MySQL 是最稳妥的组合,Vue 负责前端交互,前后端通过 JSON 交互,开发效率和维护成本都最优。
选择 SpringBoot 是因为它极大地简化了配置。传统 SSM 项目要写一堆 XML 配置、配置数据源、配置事务管理器,SpringBoot 通过自动配置把这些全包了,一个application.yml文件就能搞定大部分配置。项目里我用的 SpringBoot 2.7.x 版本,稳定且兼容性好,网上资料也最全。
选择 MyBatis 是因为它对 SQL 的控制粒度最细。电商系统里有大量的动态查询场景——图书列表要根据分类、价格区间、书名关键字组合筛选,这种 SQL 用 MyBatis 的动态 SQL 写起来非常直观。相比 JPA 那种自动生成的 SQL,MyBatis 写复杂查询时心里更有底,执行效率也可以自己把控。面试的时候 MyBatis 的缓存机制、#{}和${}的区别也都是高频考点,用这个项目练一遍理解会深很多。
选择 Vue 是因为它的生态最成熟。Element UI 组件库让后台管理界面开发效率拉满,Vue Router 处理前端路由,Vuex 管理全局状态,axios 统一请求后端接口,这套组合我用了很多年,稳定可靠。Vue 的响应式原理让购物车数量、订单状态这类数据的更新是自动驱动的,不需要手动操作 DOM,开发体验很舒服。
1.3 模块划分与页面规划
前后端分离项目的模块划分,我习惯画一张功能脑图再动手。后端按业务域分成四个模块:用户模块(注册、登录、个人信息)、图书模块(列表、详情、搜索、分类)、购物车模块(增删改查、勾选)、订单模块(创建、列表、状态更新)。每个模块内部再按 Controller、Service、Mapper 三层组织,不搞过度设计。
前端页面规划相对清晰。前台部分我规划了 9 个页面视图:
- 首页(图书推荐轮播 + 图书网格)
- 分类列表页(侧边栏分类 + 图书筛选)
- 图书详情页(封面、价格、库存、加入购物车)
- 购物车页(勾选、数量增减、结算)
- 订单提交页(收货地址 + 商品清单)
- 订单列表页(个人中心)
- 登录页 / 注册页
后台管理部分规划了 4 个页面:图书管理、分类管理、订单管理、数据概览。这样划分之后,每个页面对应的接口非常清晰,开发的时候可以实现一个页面、联调一个接口,推进节奏很顺手。
2. 数据库设计与后端核心实现
2.1 库表结构与订单状态设计
数据库是电商系统的地基,表结构设计不合理后面写什么都别扭。这个项目我设计了 6 张表,我把核心字段和设计原因说一下。
用户表t_user:id主键自增,username唯一索引,password存的是 BCrypt 加密后的密文,绝不存明文。nickname、phone、email是基本资料字段,avatar存头像路径,role字段区分普通用户和管理员,默认 0 表示用户,1 表示管理员。
图书表t_book:这是核心表,字段比较多。title书名,author作者,isbn国际标准书号,price售价(我用DECIMAL(10,2)类型,避免浮点误差),original_price原价,stock库存整数,sales销量,category_id关联分类表,cover_url封面图路径,description图书简介,status上下架状态,1 上架 0 下架。index查询最多的是category_id和title,我给这两个字段加了普通索引。
分类表t_category:id、name、parent_id、sort排序字段。做二级分类的时候parent_id派上用场,前台侧边栏先查父分类,再根据父分类 id 查子分类。
购物车表t_cart:id、user_id、book_id、quantity、checked是否勾选、create_time、update_time。这里checked字段是我觉得很多新手容易忽略的——购物车结算时只算勾选的商品,所以这个字段必须有,否则结算逻辑没法写。
订单表t_order:id、order_no订单编号(我用时间戳+用户ID+随机数生成),user_id、total_amount总金额、status订单状态(0 待支付,1 已支付,2 已发货,3 已完成,4 已取消)、receiver_name、receiver_phone、receiver_address、create_time、pay_time。收货信息冗余在订单表里,这个设计很关键——因为用户可能改了地址,但订单必须保留下单时的地址快照。
订单明细表t_order_item:id、order_id、book_id、book_title、book_cover、price、quantity。这里我把书名和封面冗余存储,是因为订单生成后图书信息可能被修改,但订单明细里的商品快照不能变,这是电商设计的常规做法。
建表时统一用 InnoDB 引擎,字符集 utf8mb4,排序规则 utf8mb4_general_ci。为什么用 utf8mb4?因为要兼容表情符号和生僻字,utf8 3 字节存不下这些,utf8mb4 是 4 字节,兼容性最好。这点很多人踩过坑,表格列里插入一个表情直接报错,查了半天发现是字符集问题。
2.2 SpringBoot 工程组织与统一返回格式
后端工程我用 Maven 管理,包结构如下:
com.bookstore ├── controller # 接口层 ├── service # 业务逻辑层 ├── mapper # MyBatis 数据访问层 ├── entity # 实体类 ├── vo # 视图对象 ├── dto # 请求参数封装 ├── config # 配置类(跨域、拦截器) ├── common # 工具类与通用返回 ├── exception # 全局异常处理实体类对应数据库表,字段类型和表结构一一对应。VO 是返回给前端的视图对象,比如图书详情需要额外返回分类名称,这时候把t_book的数据和category_name组合成一个 BookVO,就不需要在前端自己拼接了。DTO 是接收前端参数的,比如登录时只需要用户名和密码,用 LoginDTO 接收比直接用实体类接收更干净。
统一返回格式是前后端分离项目必须做的一件事。我定义了Result<T>类,包含code(200 成功,500 失败,401 未登录)、message、data三个字段。所有接口都返回这个结构,前端 axios 拿到响应后先判断 code,再取 data。这样做的好处是前端处理逻辑统一,后端异常也统一走全局异常处理器转成Result返回,不会出现接口一层返回对象一层返回字符串的情况。
SpringBoot 里做全局异常处理,用@RestControllerAdvice注解加@ExceptionHandler方法就行。业务异常我自定义了BusinessException,在 Service 层判断逻辑不满足时抛出,比如库存不足时throw new BusinessException("库存不足"),全局异常处理器捕获后返回 code 500。这样 Controller 层不会堆一大堆 try-catch,代码干净很多,这也是我在实际工作中很看重的一点。
2.3 MyBatis 动态 SQL、缓存机制与初始化流程
MyBatis 是这一整套代码的持久层核心。项目里的图书列表查询就是典型的动态 SQL 场景,用户可能按分类筛选,可能按价格区间筛选,可能按关键字搜索,这三个条件是可选的,用if标签拼条件最合适。我在 Mapper XML 里这样写:
<select id="listBooks" resultType="com.bookstore.vo.BookVO"> SELECT b.*, c.name AS category_name FROM t_book b LEFT JOIN t_category c ON b.category_id = c.id <where> <if test="categoryId != null"> AND b.category_id = #{categoryId} </if> <if test="minPrice != null"> AND b.price >= #{minPrice} </if> <if test="maxPrice != null"> AND b.price <= #{maxPrice} </if> <if test="keyword != null and keyword != ''"> AND b.title LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY b.sales DESC </select>这里必须注意两个细节。第一,<和>在 XML 里要转义成<和>,否则 XML 解析直接报错。第二,LIKE 拼接用CONCAT函数而不用字符串拼,是为了防止 SQL 注入。MyBatis 里#{}会预编译成占位符?,安全;${}是直接拼接字符串,虽然有动态拼接表名的场景,但在值的地方用$就是给自己埋雷。面试问到这个区别,结合项目里的实际写法去答,比背概念强得多。
MyBatis 的一级缓存是 SqlSession 级别的,同一个 SqlSession 里执行相同 SQL 会直接走缓存。二级缓存是 namespace 级别的,也就是 Mapper 级别,默认不开启,需要cache标签手动开启。但电商项目里图书库存、订单这些数据实时性很强,开二级缓存反而容易读到脏数据,所以这个项目里我只在分类数据上开了二级缓存,图书和订单都不开。设计缓存的时候一定要结合业务,不是所有数据都适合缓存,这个判断能力面试官也喜欢听。
MyBatis 的初始化流程也是面试常考点:SqlSessionFactoryBuilder读取配置文件构建Configuration对象,其中XMLConfigBuilder(就是热搜里提到的 XmlConfigBuilder)负责解析 mybatis-config.xml,XMLMapperBuilder负责解析 Mapper XML 映射文件,把每一条增删改查语句解析成MappedStatement存进Configuration,最终通过MapperRegistry生成 Mapper 接口的动态代理,注入 Spring 容器。理解了这条链路,自定义 MyBatis 拦截器或者扩展 TypeHandler 就顺理成章了。
2.4 登录鉴权与密码加密
登录鉴权是前后端分离项目绕不开的话题。传统 Session 方案在前后端分离下有个问题:跨域情况下 Cookie 不好维护,所以我这个项目用的是 JWT(JSON Web Token)方案。用户登录成功后,后端用用户的 id、用户名、角色生成一个 token,设置过期时间(我设置为 2 小时),返回给前端。前端把 token 存在 localStorage,每次发请求时在请求头里带上Authorization: Bearer <token>。
后端用一个拦截器统一校验 token。拦截器里先拿到请求头,解析 token,如果合法就把用户信息放到ThreadLocal里,后续 Controller 和 Service 可以直接取当前登录用户的 id;如果 token 不存在或过期,直接返回 401,提示前端跳登录页。这个方案在实际项目里非常普遍,理解了它,前后端分离的登录问题就通了。
密码加密我用的是 Spring Security 里的 BCryptPasswordEncoder,不引入完整 Spring Security 框架,只用它这一个工具类。BCrypt 的特点是每次加密结果都不一样,但校验时能验证是否匹配,而且自带盐值,即使两个用户密码相同,密文也不同。千万别用 MD5 或 SHA 存储用户密码,这两个算法没有加盐机制,用彩虹表一撞就出来了,读者朋友如果自己写项目一定要避开这个坑。
3. Vue 前端工程与前后端联调
3.1 前端环境准备与工程化脚手架
前端部分我用的 Vue 2.6 + Vue CLI 4 + Element UI 的组合,主要考虑是稳定、资料全、协作团队上手快。如果你要新建项目,Vue CLI 官方脚手架是标准做法:
npm install -g @vue/cli vue create bookstore-web这里要提醒一个高频坑:Node 版本和 Vue CLI 版本的兼容性。Vue CLI 4 需要 Node 8.9 以上,但如果 Node 版本太高(比如 18+),老项目跑npm run dev时候可能报digital envelope routines::unsupported错误,这是 OpenSSL 版本变化导致的。解决方法是把 Node 降到 16 左右的 LTS 版本,或者用NODE_OPTIONS=--openssl-legacy-provider环境变量绕过,但后者只适合临时应急,长期还是建议锁定 Node 版本。
项目里我创建了vue.config.js配置文件,核心做了两件事。第一是开发服务器代理:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }代理配置让前端开发时请求/api开头路径自动转发到后端 8080 端口,解决了开发环境的跨域问题。生产部署时再通过 Nginx 或 Tomcat 配置同源,就不用 CORS 了。第二是配置了 Element UI 的按需引入,用官方提供的babel-plugin-import插件,只打包用到的组件,减小构建体积。
3.2 路由、状态管理与页面组件拆分
前端路由用 Vue Router。我设计了两种路由:普通页面路由和需要登录才能访问的路由。购物车、订单确认、个人中心这些页面都配置了meta: { requiresAuth: true },路由守卫里检查 localStorage 有没有 token,没有就跳登录页并带上redirect参数,登录成功后跳回原页面。这个体验细节非常重要,否则用户逛着逛着点加入购物车直接被踢到登录页,回不到原来的页面,体验非常割裂。
状态管理我用了 Vuex。这个项目里购物车数量、用户信息、图书分类数据属于全局共享状态,放进 Vuex 合理。用户登录后把用户信息存到state,同时同步到 localStorage,页面刷新后main.js里从 localStorage 重新加载,避免刷新就丢登录态。购物车数量在header组件里显示,加入购物车成功后调用 Vuex 的 action 重新获取购物车数量,页面上就自动更新了,这就是 Vue 响应式的好处。
组件拆分上,我的原则是复用优先。图书卡片(封面图、书名、价格、加入购物车按钮)在首页和搜索页是同一个组件,只是传入的图书数据不同。分页组件也是复用的,图书列表和订单列表都用同一个。Element UI 的el-table在后端管理中大量使用,只要把columns配置好,表格的渲染、排序、操作列都能统一处理,后台页面的开发效率非常高。
3.3 axios 封装与跨域配置
axios 封装是前端项目的基础工程,我在utils/request.js里统一创建了 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) { localStorage.removeItem('token') router.push('/login') return Promise.reject('未登录') } if (res.code !== 200) { Message.error(res.message || '请求失败') return Promise.reject(res) } return res })这里有几个设计意图值得说。请求拦截器统一添加 token,不需要每个请求单独写;响应拦截器统一处理 code,业务代码里拿到res.data直接就是后端返回的业务数据,不需要每个页面都判断 code 是不是 200;401 统一清除 token 跳登录页,避免用户明明已经失效还在页面里乱点。
axios 封装好了以后,API 层单独建一个目录,每个模块一个文件。比如api/user.js里导出login、register、getUserInfo方法,api/order.js里导出createOrder、getOrderList方法。页面组件里只调用 API 方法,不直接写 URL,这样接口路径有变动时只改 API 层一个文件。这个习惯我强烈建议读者养成,项目大了以后,散落在各页面的 URL 改起来是灾难。
3.4 路由传参与实用性功能细节
开发中常见的两个小问题,顺带在这里说一下。
一个是 Vue 路由传参。图书详情页需要知道是哪个图书,我用的方式是路径参数:/book/:id,跳转时this.$router.push({ path: '/book/' + id })。还有一种传参方式是用query:this.$router.push({ path: '/book', query: { id } }),区别是路径参数在 URL 里更干净,query 方式刷新后参数不会丢。用params配合name跳转的话,页面刷新后参数会丢失,这个坑我踩过,特此提醒。
另一个是用户在热搜词里问到的vue播放m3u8播放器。这个需求通常来源于要做电子书在线阅读或视频课程。实测下来,播放 m3u8 视频用vue-video-player配合videojs-contrib-hls插件是可以的,要注意hls.js方式的兼容性更好,移动端也能播。图书电商项目如果要扩展在线试读功能,加一个视频介绍页或者音频试听,这两个组件的选择可以直接参考。
4. 打包部署全流程实录
4.1 后端打包与 Tomcat 两种部署姿势
后端部署我实测了两种方式,都记录一下。
第一种是 jar 包方式(SpringBoot 推荐方式)。在pom.xml里确保打包插件是spring-boot-maven-plugin,然后执行:
mvn clean package -DskipTests生成的bookstore.jar在 target 目录下。部署到服务器时:
nohup java -jar bookstore.jar --spring.profiles.active=prod > app.log 2>&1 &jar 包方式启动后,SpringBoot 内置的 Tomcat 直接监听 8080 端口。好处是部署简单、进程管理方便,缺点是如果想用服务器的 Tomcat,这两种方式就要区分开。
第二种是 war 包方式,适合要让 SpringBoot 项目跑在外部 Tomcat 里的场景。需要把启动类改成继承SpringBootServletInitializer并重写configure方法,同时pom.xml里把打包方式改成 war:
<packaging>war</packaging>打包后得到bookstore.war,直接丢进 Tomcat 的webapps目录,启动 Tomcat 后自动解压部署。访问路径是http://ip:8080/bookstore/,注意会带一个应用上下文路径,前端的所有接口请求路径都要对应调整,或者把 war 包改名为ROOT.war部署,这样就能直接用http://ip:8080/访问了。
这两种方式我在项目文档里都写了,实际生产环境我更推荐 jar 包 + Nginx 的方式,因为外部 Tomcat 本身也是要配置线程池、虚拟主机等一堆东西,SpringBoot 内置的 Tomcat 完全够用,少一层就少一个出问题的点。但如果你公司要求统一用 Tomcat 管理,war 包方式也是标准方案,理解它的打包配置即可。
4.2 前端构建与静态资源托管
前端部署的核心是把源码构建成纯静态文件。执行:
npm run build构建完成后dist目录下就是最终的静态资源,包含index.html、js、css、static等文件。这个目录不需要 Node 环境,任何能提供静态文件的服务器都能托管。
最推荐的生产部署组合是 Nginx + jar 包。Nginx 配置里,前端静态资源和后端接口统一走 80 端口,用路径区分:
server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /opt/bookstore-web/dist; index index.html; 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; } }try_files $uri $uri/ /index.html这行很关键,它把前端路由请求全部指向index.html,让 Vue Router 自己根据路径渲染组件。如果少了这行,用户直接访问http://域名/book/1刷新页面就会 404,因为这个路径在静态目录里根本不存在。这个问题我在部署时遇到过,当时项目上线第二天就有用户反馈商品详情页刷新就白屏,排查了半天才发现是 Nginx 的try_files没配。
前端请求接口统一走/api,后端接口本身没有/api前缀的话,Nginx 的proxy_pass http://127.0.0.1:8080/末尾带/会去掉前缀后转发,这样前后端路径就对应上了。
4.3 数据库初始化与常见连接问题
数据库初始化我用了一个 SQL 脚本,包含建库、建表、插入预置分类数据和测试图书数据。拿到项目源码后,只需要在 MySQL 里执行一遍bookstore.sql就行:
mysql -u root -p < bookstore.sql如果你的 MySQL 是 Linux 上 rpm 或源码方式安装的,先把服务启动起来再执行导入。常见的几个连接问题,我在实际配置数据源时都踩过,直接列出来:
第一个是 SSL 连接报错。现象是启动 SpringBoot 后日志提示SSL connection error或者Establishing SSL connection without server's identity verification is not recommended。原因是 MySQL 8 默认开启了 SSL,而 JDBC 驱动默认会去验证。解决方案是 JDBC URL 加参数:
jdbc:mysql://localhost:3306/bookstore_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8useSSL=false关闭验证,serverTimezone指定时区,characterEncoding指定编码。这三个参数是 SpringBoot + MySQL 8 连接的标准配置,缺一不可。尤其是serverTimezone不配的话,启动时大概率报时区错误。
第二个是Can't connect to local MySQL server through socket '/tmp/mysql.sock'。这个错误本身不是项目代码问题,是 MySQL 服务没启动。Linux 上一般systemctl start mysqld或service mysql start就能解决。如果启了还连不上,检查是不是只监听了bind-address=127.0.0.1,而项目连接的是远程地址。
第三个是密码包含特殊字符导致连接失败。比如密码是abc#123,#在 URL 里会被当成 anchor 分割符,导致连接 URL 被截断。解决办法是把密码在 URL 里做编码,#编码成%23。也可以直接在application.yml里配数据源时用非 URL 形式的配置,SpringBoot 2.x 支持直接在配置文件里写 password 字段,不拼在 URL 里就不会有这个问题。
5. 常见问题排查与避坑实录
5.1 启动失败类问题速查表
项目跑不起来的原因集中在环境配置和依赖版本上,我整理了一个速查表,按出现的频率排序:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| SpringBoot 启动后自动退出 | 端口被占用 | lsof -i:8080查看占用进程,杀掉或改端口 |
Mapper 接口报BindingException | XML 文件没在resources/mapper目录下,或namespace写错 | 检查application.yml中mapper-locations配置,核对namespace与接口全限定名一致 |
| 页面中文乱码 | 数据库字符集不是 utf8mb4,或 JDBC URL 没加characterEncoding=utf8 | 重建库为 utf8mb4,URL 加编码参数 |
前端npm run dev报port 8081 already in use | 端口被占 | vue.config.js改端口,或用npx kill-port 8081 |
| 后端返回 404 但接口路径看着正确 | Controller 类没加@RestController或@RequestMapping路径不对 | 检查类上注解和类上路径 + 方法上路径拼接后和前端请求一致 |
连接 MySQL 报Access denied for user | 用户名或密码不对,或该用户没有远程访问权限 | 确认账号权限,开发环境可给%主机授权 |
这些坑都是真实开发中遇到过的,很多问题不是逻辑复杂,而是环境细节。排查的时候养成一条习惯:先看启动日志最后几行,异常栈的第一行往往就指明了问题方向。很多人一报错就到处问,其实先看看日志里Caused by那几行,大部分问题自己能定位。
5.2 前端联调类问题处理
前后端联调阶段的问题和启动阶段完全不同,都是业务层面的。我挑三个典型的说。
跨域报错是最常见的。浏览器控制台报blocked by CORS policy,是因为前端http://localhost:8081请求后端http://localhost:8080,跨域了。开发环境我在vue.config.js配置了代理,所以前端所有请求都用相对路径/api开头,不要直接写http://localhost:8080。如果你在代码里写死了全地址,代理就失效了,一定会跨域。生产环境我用 Nginx 反向代理同源,也避免了跨域。记住一个原则:前端代码里不要出现具体域名和端口,全部用相对路径,剩下的交给代理或网关。
登录后刷新页面就提示登录过期。这个问题的原因多半是 Vuex 的 state 在页面刷新后清空了,而某些组件在渲染时直接读 state 里的用户信息,读不到就当作未登录。解决方法是把 token 和用户信息持久化到 localStorage,main.js启动时初始化 Vuex 的 state 从 localStorage 读取。在响应拦截器里遇到 401 时清除 localStorage 并跳登录页。
图书封面图不显示。很多图书的封面 URL 是后端数据库里存的路径,如果路径是http://localhost:8080/upload/cover.png,前端本地开发访问不到,因为后端服务不在同源。我从数据库直接存相对路径/upload/cover.png,由后端资源映射或 Nginx 静态目录统一处理,这样开发和生产都能正确访问。
5.3 面试考点:MyBatis 与 Vue 必问点
整理这套源码时候,我把相关的高频面试考点也梳理了一遍。这部分对正在找工作的读者应该很实用。
MyBatis 部分最常问的是#{}和${}的区别。#{}是预编译占位符,能防 SQL 注入;${}是字符串拼接,看起来省事但安全风险高。项目里只有动态排序字段(如ORDER BY ${sortField})这种无法预编译的场景才用$,使用前必须白名单校验字段名。
第二个常问的是 MyBatis 缓存。一级缓存默认开启,范围是 SqlSession,查同样的 SQL 直接走内存。二级缓存默认关闭,范围是 namespace,要在 Mapper XML 加<cache/>开启。面试时最好能说出为什么我的项目里图书和订单不开二级缓存——因为数据实时性强,缓存命中率不高还会引入脏读风险。这就能展现你的实际项目经验。
第三个是 MyBatis 初始化流程。很多人面试答不上来,其实核心就是XMLConfigBuilder解析 mybatis-config.xml 构建Configuration对象,XMLMapperBuilder解析 Mapper 文件生成MappedStatement,最终通过MapperProxyFactory为 Mapper 接口生成动态代理。Spring Boot 里这些流程被MybatisAutoConfiguration自动完成了,所以你写代码时感知不到,但原理始终是这些。
Vue 部分的高频考点首先是响应式原理。Vue 2 用Object.defineProperty拦截数据读写,Vue 3 用 Proxy 代理。项目里购物车数量更新时页面自动刷新,就是响应式驱动视图更新,把这个现象和原理结合起来讲,很有说服力。其次是路由传参,对应 3.4 节的内容。然后是 Vuex 的 state、mutation、action 的协作关系,用项目里的用户登录状态管理来举例子最自然。
5.4 扩展方向与源码改造思路
这个图书电商项目是一个很标准的单体应用骨架,往上扩展的方向非常多。如果读者想把它当作毕业设计或者作品集项目,有几个方向我觉得性价比很高。
第一个是引入 Redis 缓存热点数据。首页图书推荐、分类列表这些都是访问频率高、变更频率低的数据,缓存到 Redis 能显著降低数据库压力。同时把购物车临时数据也放 Redis,设置过期时间,能减少数据库垃圾数据。SpringBoot 整合 Redis 的配置非常成熟,引入 starter、配置一下连接信息就行。
第二个是接入 MinIO 做对象存储。图书封面、管理员上传的图片目前存储在本地磁盘,生产环境扩容困难。MinIO 的 S3 协议接口可以用很简便的 SDK 接入,图片上传改成先传 MinIO,拿到访问 URL 再存数据库。对应热搜词里也有minio加入到springboot,说明很多人都在研究这个方向。
第三个是引入定时任务做订单超时处理。现在待支付订单是手动取消,真实电商都是 30 分钟未支付自动关闭。可以用 SpringBoot 的@Scheduled定时扫描,也可以用消息队列延迟消息实现。后者引入的复杂性比较高,建议先从@Scheduled做起。
第四个是后台数据统计可视化。目前订单管理页面只是简单的表格,可以加一个统计模块,按天统计订单销售额和图书销量,前端用 ECharts 画折线图和柱状图。这个功能对电商系统来说是很实用的一块,也是简历上能拿出来说的亮点。
再有就是项目源码的解读方式。读者如果拿到了源码,不要急着打开就跑,先看数据库脚本,把表结构搞清楚;再跑起来,用浏览器把每个页面点一遍,对着后台日志看每个操作调了什么接口;最后再打开代码,按 controller → service → mapper 的顺序读,理解每个接口的实现逻辑。我之前做项目就是这样一步步把别人的代码消化成自己的,这也是源码类项目最有价值的用法。
最后分享两个我个人实操中的小技巧
这套项目里有两个地方我觉得值得单独拿出来说说,都不是什么高深技术,但确实实际体验差异很大。
第一个是 SpringBoot 的 banner 自定义。默认启动时那个 Spring 的 ASCII 图案看久了真的腻,项目里我放了一个banner.txt,用文本生成工具做了个定制化的图案,启动的时候看一眼就知道服务起来了。很多开源项目都会这样做,属于团队辨识度的小细节,给人感觉代码是活的。
第二个是 jar 包反编译。有次线上环境出了问题,但我本地没保留对应版本的源码,就用了 JD-GUI 直接把 jar 包拖进去反编译,虽然只能看不能改,但用来定位问题完全够用。这件事也提醒了我,Git 提交打 tag 非常重要,否则版本和源码对应不上,排查问题的成本会高很多。这个项目我从一开始就给每次正式版本打了 tag,后续维护轻松不少。
做这种全栈项目,最大的体会是别怕踩坑,坑踩得多了就是经验。这套代码前后我自己跑了三遍,从环境搭建到部署线上,每一遍都还是有新的收获。希望这篇拆解能帮到正在学前后端分离和后端开发的朋友,如果按着文档一步步操作遇到卡住的地方,对照最后一节的排查表基本都能找到答案。