☰
SpringBoot+Vue3+MyBatis前后端分离图书商城系统实战解析
2026/10/22 9:35:50 网站建设 项目流程

自己在做图书商城一类的管理系统时,第一个绕不开的选型就是前后端分离还是服务端渲染。如果你正打算用 SpringBoot + Vue3 + MyBatis 做一套前后端分离的图书商城,或者你手里已经拿到了一套类似的源码,正在琢磨怎么把它跑起来、改明白,这篇文章应该能帮你省掉不少摸索时间。

我基于这个题目把整套系统的架构思路、表结构设计、关键代码实现、前后端联调细节和常见坑位都过了一遍。尤其是订单状态流转、库存扣减、登录鉴权、跨域处理这几块,都是实际开发中容易翻车的地方。下面这些内容不是我凭空编的,基本都是我在类似项目里实际踩过、改过、优化过的经验,你可以直接拿来参考或者照抄。

1. 项目整体设计与技术选型思路

1.1 前后端分离到底解决了什么问题

图书商城管理系统这种项目,业务上天然分为“用户逛书、下单、支付”和“管理员管书、管订单、管用户”两条线,页面形态差异很大。如果用传统服务端渲染,JSP或者Thymeleaf一套模板撑到底,前端逻辑和后端逻辑搅在一起,改个页面样式可能都要重启服务,更别说多端复用。

选前后端分离,核心就是为了让前端专注交互和展示,后端专注业务和数据处理,两边通过JSON接口通信。Vue3负责页面渲染和状态管理,SpringBoot只暴露RESTful API,MyBatis负责和MySQL打交道。这个组合的好处是:接口写好了,前端可以并行开发,后端也可以单独用Postman之类工具调试;以后要加小程序端或者移动端,后端接口基本可以原样复用。

前端工程和后端工程完全独立部署,前端打包成静态文件扔到Nginx或者直接扔到服务器上,后端打包成Jar包跑在独立端口。开发环境下,通过Vite代理解决跨域;生产环境下,用Nginx反向代理把/api开头的请求转发到后端服务,这样浏览器里只有同源请求,不存在跨域问题,也适合把静态资源和后端服务部署在同一台机器上,省事很多。

1.2 技术栈里的每个组件都在干什么

先把这个项目里用到的几个核心组件拆开看:

  • SpringBoot:后端基础框架,负责启动应用、装配Bean、提供嵌入式Tomcat、整合拦截器、处理全局异常等。SpringBoot的自动配置让项目不用写一堆XML配置,依赖引入后基本就能跑。
  • Vue3:前端框架,用组合式API(Composition API)组织业务代码,配合<script setup>语法,逻辑复用比Vue2的Options API舒服太多。图书列表、购物车、订单管理这些模块都可以拆成独立组件和组合式函数。
  • MyBatis:持久层框架,SQL由开发者自己写,灵活可控,适合做复杂的多表联查、动态条件筛选。图书列表页的按分类筛选、按价格区间筛选、按关键字搜索,用MyBatis动态SQL拼条件就非常顺手。
  • MySQL:数据存储层,图书信息、用户信息、订单信息、购物车记录都放这里。MySQL的InnoDB引擎支持事务,这对订单创建和库存扣减的一致性很关键。
  • Vue3生态配套:Vue Router做前端路由(页面跳转和权限控制)、Pinia做状态管理(登录状态、用户信息、购物车数据)、Element Plus做UI组件库(表格、表单、弹窗、分页这些现成的组件直接能用)、Axios做HTTP请求。

1.3 前后端工程目录结构怎么划分

实际开发中,我习惯把项目分成两个工程目录:

后端工程结构:

bookstore-backend/ ├── src/main/java/com/example/bookstore/ │ ├── controller/ # 控制器层,接收前端请求 │ ├── service/ # 业务逻辑层,处理具体业务 │ ├── mapper/ # MyBatis的Mapper接口 │ ├── entity/ # 实体类,对应数据库表 │ ├── dto/ # 数据传输对象,对前端请求/响应做包装 │ ├── config/ # 配置类(跨域、拦截器、认证等) │ ├── common/ # 公共类(统一返回结果、异常处理、工具类) │ └── BookstoreApplication.java ├── src/main/resources/ │ ├── mapper/ # MyBatis的XML映射文件 │ └── application.yml # 配置文件 └── pom.xml

前端工程结构:

bookstore-frontend/ ├── src/ │ ├── api/ # 接口请求封装 │ ├── assets/ # 静态资源 │ ├── components/ # 公共组件 │ ├── router/ # 路由配置 │ ├── store/ # Pinia状态管理 │ ├── views/ # 页面组件(前台页面 + 后台管理页面) │ ├── utils/ # 工具函数(Axios实例、日期格式化等) │ └── App.vue ├── vite.config.js # Vite配置(含代理) └── package.json

控制器层和业务层分开,不要都堆在Controller里写一堆SQL。Controller只做参数接收和结果返回,具体业务逻辑放到Service层,事务和异常处理也统一在Service层做。这样代码后期好维护,排查问题的时候定位也快。

2. 数据库设计与核心业务表

2.1 核心表结构和字段设计

图书商城系统,最核心的表我建议至少要有用户表、图书表、分类表、购物车表、订单表、订单明细表。如果再细致一点,还要加轮播图表、收货地址表,但骨架就是这六张。

用户表:

CREATE TABLE `user` ( `id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密后)', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL', `phone` varchar(20) DEFAULT NULL, `email` varchar(100) DEFAULT NULL, `role` tinyint NOT NULL DEFAULT '0' COMMENT '0-普通用户 1-管理员', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1-正常 0-禁用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

密码字段不要直接存明文,用BCrypt做哈希加密。MyBatis查询用户时直接查出加密后的密码,登录校验时用BCryptPasswordEncoder的matches方法比对。这样即使数据库泄露,用户明文密码也不会直接暴露。

图书表:

CREATE TABLE `book` ( `id` int NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '书名', `author` varchar(50) DEFAULT NULL COMMENT '作者', `publisher` varchar(100) DEFAULT NULL COMMENT '出版社', `category_id` int DEFAULT NULL COMMENT '分类ID', `price` decimal(10,2) NOT NULL COMMENT '售价', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价', `cover` varchar(255) DEFAULT NULL COMMENT '封面图URL', `description` text COMMENT '图书简介', `stock` int NOT NULL DEFAULT '0' COMMENT '库存', `sales` int NOT NULL DEFAULT '0' COMMENT '销量', `status` tinyint DEFAULT '1' COMMENT '1-上架 0-下架', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表';

这里有个细节,category_id建议加普通索引idx_category,因为前端列表页非常依赖按分类筛选,不加索引的话数据量大了之后全表扫描,MySQL会很吃力。

订单表和订单明细表是典型的一对多关系。订单表存一次下单的整体信息(订单号、用户ID、总价、状态、收货信息),订单明细表存这个订单里包含的每一本书、每本书的数量和当时的快照价格。

CREATE TABLE `orders` ( `id` int NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` int NOT NULL COMMENT '用户ID', `total_price` decimal(10,2) NOT NULL COMMENT '订单总价', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-待付款 1-已付款 2-已发货 3-已收货 4-已取消', `receiver_name` varchar(50) DEFAULT NULL COMMENT '收货人', `receiver_phone` varchar(20) DEFAULT NULL, `receiver_address` varchar(255) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; CREATE TABLE `order_item` ( `id` int NOT NULL AUTO_INCREMENT, `order_id` int NOT NULL COMMENT '订单ID', `book_id` int NOT NULL COMMENT '图书ID', `book_title` varchar(100) NOT NULL COMMENT '图书名称快照', `book_cover` varchar(255) DEFAULT NULL COMMENT '图书封面快照', `price` decimal(10,2) NOT NULL COMMENT '下单时单价快照', `quantity` int NOT NULL COMMENT '购买数量', PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';

订单明细为什么要做“快照”?因为图书价格是可能调整的,用户下单时是50块,过几天图书涨到60块,订单明细里必须保留下单时的50块单价,否则订单页面显示的历史信息就会对不上。这叫商品快照,是电商系统的常见设计。

2.2 状态字段用数字还是字符串

订单状态我建议用tinyint数字枚举,然后在前端做映射。数据库存数字,查询和索引效率都更高,而且占空间小。状态定义如下:

  • 0:待付款
  • 1:已付款 / 待发货
  • 2:已发货 / 待收货
  • 3:已收货 / 已完成
  • 4:已取消

前端展示时,根据状态值映射到对应的Tag组件颜色和文案。状态流转要写清楚:待付款可以取消或去付款,付款后变成待发货;发货后变成待收货;用户确认收货后变成已完成。这些流转规则要前后端同时遵守,后端Service层也要做状态校验,防止用户直接调接口把状态改成任意值。

MyBatis查询时,状态字段直接用Integer类型映射就行。但是每个状态码对应的含义散落在代码里会很乱,建议建一个枚举类统一管理:

public enum OrderStatus { PENDING_PAYMENT(0, "待付款"), PAID(1, "待发货"), SHIPPED(2, "待收货"), COMPLETED(3, "已完成"), CANCELLED(4, "已取消"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }

2.3 MyBatis映射的坑位提醒

MyBatis里经常遇到的一个问题是数据库字段命名和下划线转驼峰。比如数据库里是create_time,实体类属性是createTime,需要在application.yml里开启驼峰映射:

mybatis: configuration: map-underscore-to-camel-case: true

不开启的话,查询结果里create_time就映射不到createTime属性上,返回给前端的就是null,这是个很容易被忽略的问题。另外查询图书列表这种多表关联的情况,建议在XML里写好结果映射resultMap,明确每个字段的映射关系,不要依赖自动映射处理复杂嵌套查询。

3. 后端核心实现细节

3.1 登录认证与JWT鉴权

图书商城系统至少有两类角色:普通用户和管理员。用户登录后需要保持登录状态,最简单的方式是使用JWT(JSON Web Token)做无状态认证。

流程是这样的:

  1. 用户提交用户名密码;
  2. 后端校验用户名密码,成功后生成一个JWT Token;
  3. Token返回给前端,前端存到localStorage或者Pinia里;
  4. 前端每次请求在请求头带上Authorization: Bearer <token>;
  5. 后端拦截器校验Token,解析出用户身份,放行或者拒绝。

JWT生成代码:

String token = Jwts.builder() .setSubject(user.getUsername()) .claim("userId", user.getId()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();

Token有效期我一般设置7天,图书商城这种低频系统7天比较合适。过期时间太短用户频繁要重新登录,体验很差;太长又有安全风险,看需求取舍。

后端拦截器校验Token的逻辑,大概是这样:

@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或登录已过期"); } String token = authHeader.substring(7); // 解析Token,如果解析失败抛异常 Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody(); // 将用户ID和角色放入request属性,后面的业务代码可以直接取 request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } }

注意:后台管理接口需要额外校验管理员角色。比如/api/admin/**开头的接口,拦截器不仅要验证Token,还要验证role == 1,防止普通用户直接调管理接口。

配置拦截器注册的时候要注意,登录接口、注册接口、图书列表查询、图书详情这些公开接口不要拦截,否则用户没登录连书都看不了。

3.2 防止密码明文存储与暴力破解

密码加密用Spring Security自带的BCryptPasswordEncoder就够了,不需要额外引入整个Spring Security框架(那个框架太重,配置起来也麻烦)。注册时加密存储,登录时matches比对。

@Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 注册时 user.setPassword(passwordEncoder.encode(user.getPassword())); // 登录时 if (!passwordEncoder.matches(rawPassword, dbUser.getPassword())) { throw new BusinessException("用户名或密码错误"); }

登录接口还可以做一个简单的失败次数限制,比如同一个用户名连续输错5次密码,锁定15分钟,防止暴力破解。这个记录可以存到Redis里,如果项目里没接Redis,就先用一个ConcurrentHashMap简单实现也能防一下。

3.3 购物车和下单流程的并发问题

购物车表很直接,记录用户、图书、数量即可:

CREATE TABLE `cart_item` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `book_id` int NOT NULL, `quantity` int NOT NULL DEFAULT '1', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_book` (`user_id`, `book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车表';

这里加了一个联合唯一索引uk_user_book,防止同一个用户往购物车重复添加同一本书。添加时如果存在就更新数量,不存在就插入。可以用INSERT ... ON DUPLICATE KEY UPDATE一条SQL搞定,也可以先查后插靠事务兜底。

真正的重点在下单流程。提交订单这个操作牵扯到两步写操作:插入订单表和减少图书库存。这两步必须在一个数据库事务里,否则会出现订单创建成功但库存没减,或者库存减了但订单失败的数据不一致问题。

Service层代码:

@Transactional(rollbackFor = Exception.class) public Order createOrder(Long userId, List<CartItemDTO> items, AddressDTO address) { // 1. 计算订单总价 // 2. 扣减库存(注意这里要用乐观锁) // 3. 创建订单头和订单明细 // 4. 清空购物车中已购买的商品 // 5. 返回订单对象 }

库存扣减的SQL要特别注意并发问题:

UPDATE book SET stock = stock - #{quantity}, sales = sales + #{quantity} WHERE id = #{bookId} AND stock >= #{quantity}

这个WHERE条件里带stock >= #{quantity}就是乐观锁的核心,同一时间只有一个请求能把stock减到非负,MySQL的行锁会保证这个更新操作的原子性。如果更新影响行数为0,说明库存不足,直接抛异常回滚整个事务。注意不要先SELECT stock再去Java代码里判断库存,因为两个请求同时查出库存都够的话,后面就会超卖。

我见过不少项目把库存扣减写成了:

Book book = bookMapper.selectById(bookId); if (book.getStock() < quantity) { throw new Exception("库存不足"); } book.setStock(book.getStock() - quantity); bookMapper.updateById(book);

这种写法高并发下必超卖。因为两个线程可能同时读到同一份库存数据,都判断库存够,然后都执行扣减,库存就被扣成负数了。改成上面的条件更新SQL,才能真正利用数据库锁做原子操作。

3.4 统一返回结果和全局异常处理

前后端分离项目,前后端必须约定一个统一的JSON返回格式。我习惯用这样的结构:

{ "code": 200, "message": "success", "data": {} }

后端定义一个Result类:

public class Result { private Integer code; private String message; private Object data; public static Result success(Object data) { Result r = new Result(); r.code = 200; r.message = "success"; r.data = data; return r; } public static Result error(Integer code, String message) { Result r = new Result(); r.code = code; r.message = message; return r; } }

前端Axios封装里统一拦截这个结构体,code === 200才走成功回调,否则弹出错误消息。

全局异常处理用@RestControllerAdvice:

@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result handleBusinessException(BusinessException e) { return Result.error(e.getCode(), e.getMessage()); } @ExceptionHandler(Exception.class) public Result handleException(Exception e) { log.error("系统异常", e); return Result.error(500, "系统繁忙,请稍后重试"); } }

业务代码里碰到业务不满足条件的情况,直接throw new BusinessException("库存不足"),异常处理器会统一转成JSON返回,不用在每个Controller方法里写try-catch。这个小设计能省大量重复代码。

4. 前端Vue3核心实现与联调细节

4.1 Vue3组合式API是怎么组织的

Vue3里最爽的改动就是组合式API,一个功能点相关的响应式数据、计算属性、方法可以聚在一起,不再像Vue2 Options API那样被迫分散到data、methods、computed里。

以图书列表页为例,核心逻辑是这样的:

import { ref, reactive, onMounted, watch } from 'vue' import { getBookList } from '@/api/book' const bookList = ref([]) const total = ref(0) const queryParams = reactive({ page: 1, pageSize: 12, categoryId: null, keyword: '', sort: 'default' }) async function fetchBooks() { const res = await getBookList(queryParams) bookList.value = res.data.records total.value = res.data.total } onMounted(fetchBooks) watch(() => queryParams.page, fetchBooks)

ref用来包裹基本类型的响应式数据,reactive用来包裹对象类型的响应式数据,onMounted页面加载时请求数据,watch监听分页参数变化自动重新请求。页面代码非常直观,逻辑都串联得住。

4.2 Pinia管理全局用户状态

用户登录后的Token、用户信息、购物车商品数量,这些数据在多个页面之间共享。用Pinia存起来最合适。

// store/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: null }), actions: { setToken(token) { this.token = token localStorage.setItem('token', token) }, setUserInfo(info) { this.userInfo = info }, logout() { this.token = '' this.userInfo = null localStorage.removeItem('token') } } })

注意Token在Pinia里存一份的同时,也要同步写到localStorage,因为Pinia的数据是内存态的,刷新页面就没了。从localStorage恢复Token,刷新页面之后重新请求用户信息,登录状态才能保持住。

4.3 Axios请求封装与拦截器

Axios实例要统一封装,不要每个页面裸调axios.get,不然请求头加Token、错误提示、401跳转这些逻辑要在每个页面重复写。封装一个模块:

// utils/request.js import axios from 'axios' import { message } from 'ant-design-vue' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:自动带Token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一处理返回结果 request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { message.error(res.message) return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { // Token过期,跳转登录页 router.push('/login') } else { message.error('网络异常,请稍后重试') } return Promise.reject(error) } ) export default request

这样一个封装做完,后面写业务接口的时候只需要:

// api/book.js import request from '@/utils/request' export function getBookList(params) { return request.get('/book/list', { params }) } export function getBookDetail(id) { return request.get(`/book/${id}`) }

一个项目几十个接口,基本都是两行一个,清爽得多。

4.4 前端路由守卫和页面权限

前台页面(图书列表、图书详情、购物车、个人中心)中,购物车、提交订单、个人中心这些页面必须登录后才能访问,用路由守卫统一处理:

// router/index.js router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })

后台管理页面的路由要在meta里加一个requiresAdmin: true,然后判断用户角色。这些配置看起来琐碎,但真的能挡住用户直接改URL进入后台页面的问题。

4.5 开发环境的跨域代理设置

前后端分离开发时,前端跑在5173端口,后端跑在8080端口,浏览器直接请求后端接口一定跨域。这个问题有两种解法,推荐用Vite代理:

// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true // 路径重写:把 /api 前缀去掉,因为后端接口路径没有 /api 前缀 // rewrite: path => path.replace(/^\/api/, '') } } } })

关键点是changeOrigin: true,不加的话后端拿到请求的Host还是前端地址,某些安全校验会出问题。另外如果后端接口路径本身带/api前缀,那就不用rewrite;如果不带,就要做好路径重写。前端请求写/api/book/list,代理到后端后变成/book/list。

后端同时也要配置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); } }

生产环境用了Nginx反向代理之后,前端页面和后端接口同域,跨域问题自然消失,但本地开发环境这个配置还是必须的。

5. 订单、支付、与数据一致性

5.1 订单编号生成规则

订单编号别用数据库自增ID直接当订单号,太容易暴露真实订单量,而且多个表都可能有自增ID,容易混乱。更常见的做法是生成一个全局唯一的业务订单号,规则可以设计为:yyyyMMddHHmmss + 随机数,或者更稳妥的雪花算法。

这里给一个基于时间+随机数的简单方案:

public static String generateOrderNo() { DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); String timePart = LocalDateTime.now().format(fmt); String randomPart = String.format("%04d", ThreadLocalRandom.current().nextInt(10000)); return timePart + randomPart; }

两条订单在同一秒内生成,随机数重复的概率不高,但如果要做到严格唯一,还是在订单表给order_no建唯一索引,插入时如果冲突就重新生成。订单号里加时间有一个额外的好处:开发者一看订单号就能大概知道用户是什么时候下的单,排查问题很直观。

5.2 超时未付款自动取消

图书商城会自动取消超时未付款的订单,经典方案有两个:定时任务扫描或延迟消息队列。从零做的系统中,最简单的方案是Spring的@Scheduled定时任务,每1分钟扫描一次待付款订单,把超过30分钟未付款的订单状态改成已取消,同时加回库存。

@Scheduled(cron = "0 * * * * ?") @Transactional(rollbackFor = Exception.class) public void cancelExpiredOrders() { List<Order> expiredOrders = orderMapper.selectExpiredOrders(30); for (Order order : expiredOrders) { // 1. 更新订单状态为已取消 // 2. 释放库存 orderMapper.cancelOrder(order.getId()); orderItemMapper.restoreStock(order.getId()); } }

注意这里“取消订单同时恢复库存”的操作仍然要放在事务里。如果先把订单改成取消,然后恢复库存时失败,那库存数据就会出问题。

5.3 支付模块怎么实现

图书商城系统,很多场景下支付模块不用真接第三方支付,而是用模拟支付:用户在前端点“立即支付”,弹出支付弹窗,点确认就模拟支付成功,后端直接把订单状态改成已付款。

模拟支付的后端逻辑:

@Transactional(rollbackFor = Exception.class) public void payOrder(Long userId, Long orderId) { Order order = orderMapper.selectById(orderId); // 校验订单是否属于当前用户 if (!order.getUserId().equals(userId)) { throw new BusinessException("无权操作该订单"); } // 校验订单状态是待付款 if (order.getStatus() != OrderStatus.PENDING_PAYMENT.getCode()) { throw new BusinessException("订单状态异常,无法支付"); } // 更新状态为已付款,记录支付时间 orderMapper.updateStatus(orderId, OrderStatus.PAID.getCode()); }

如果真接了支付宝或微信支付,流程会复杂很多:前端调起支付、后端接收异步回调、回调里验证签名、更新订单状态。回调通知必须是后端接口而不是前端页面,因为支付结果的确认要以后端收到第三方服务器的异步通知为准,不能信前端传过来的结果。

图书商城这种毕设或者练手项目,用模拟支付是完全够用的,重点把订单状态流转这个逻辑做严谨。

5.4 数据一致性最终检查

我在一个实际项目里碰到过这样的Bug:用户在购物车提交了一个订单,订单创建成功,但购物车里的商品没有被清空。第二次提交时发现库存不足,用户一脸懵。后来排查发现是Service层方法漏写了清空购物车逻辑,并且这个方法上没有加@Transactional,订单插入成功但购物车清除失败时,数据就处于一个“半成功”状态。

这个教训说明:涉及多个写操作的方法,必须整体加事务,同时异常一定要做到事务回滚。Spring里默认情况下只有RuntimeException才回滚,如果抛的是受检异常(Exception),事务不会自动回滚,所以@Transactional要显式写rollbackFor = Exception.class。

6. 常见问题与排查技巧实录

6.1 前端请求接口报跨域错误

前端开发环境请求接口报Access-Control-Allow-Origin错误,90%是Vite代理没生效或者后端CORS配置不对。先检查Vite控制台有没有代理日志,再用浏览器F12看Network面板,如果请求路径还是http://localhost:5173/api/xxx且没有代理转发成功,那就是代理配置没写对。

另外注意,Vite代理只对开发服务器生效,打包后的静态文件如果直接双击index.html打开,代理是无效的,必须通过Nginx或者部署到服务器上访问。

6.2 用户登录后拿不到用户信息

排查顺序:先确认登录接口是否返回了用户信息,再看前端是否把信息存到了Pinia,再看请求用户信息的接口是否带了Token,再看后端拦截器是否解析了Token并放行。一套检查下来,基本能定位到是哪一层断了。

最常见的问题是:用户登录成功跳转到首页后,首页发起请求时Authorization头没带上Token,或者后端拦截器没有读取Authorization头。Token这种状态数据,跨页面传递必须走Pinia或localStorage,不能指望组件里面私有的变量。

6.3 MyBatis查出来某些字段为null

数据库表字段是create_time,查出来对应实体类属性createTime为null,大概率是map-underscore-to-camel-case没开启。另外,如果XML里写的是手动的resultMap,要检查里面有没有把create_time映射到createTime。

一个小技巧:在application.yml里开启mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,能看到MyBatis实际执行的SQL和参数,排查字段对应关系非常方便。

6.4 订单库存出现超卖

超卖问题的根源基本都是检查库存和扣减库存不是原子操作。修复思路:

  • 把UPDATE book SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}作为扣减库存的唯一入口;
  • 任何需要扣库存的地方都走这个SQL判断行数,如果影响行数为0就抛异常回滚;
  • 不要写“先查再改”的代码,尤其在并发场景。

6.5 后端启动失败端口占用

SpringBoot启动时提示Port 8080 was already in use,用命令查一下谁占了端口:

netstat -ano | findstr 8080

查到PID之后在任务管理器结束对应进程,或者改application.yml里的server.port换一个端口(比如8081)。前端Vite代理的target也要同步换。

6.6 数据库SQL乱码

创建数据库时如果字符集没设置好,中文写入后变成???,多半是数据库连接串缺了字符集参数。JDBC连接串要带:

jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai

另外建表语句统一用DEFAULT CHARSET=utf8mb4,utf8mb4比utf8支持更全的字符编码。MySQL的utf8实际是utf8mb3,只支持基本多语言平面,存emoji和冷僻字会出错。

7. 部署上线需要注意的事

7.1 打包配置

后端打包:

mvn clean package -DskipTests

打包后生成target/bookstore-0.0.1-SNAPSHOT.jar,直接Java运行:

java -jar bookstore-0.0.1-SNAPSHOT.jar

前端打包:

npm run build

打包后在dist目录生成静态文件,上传到服务器,用Nginx托管。

7.2 Nginx部署配置示例

把前端静态文件放到Nginx的html/bookstore目录下,配置一个server块:

server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html/bookstore; 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这一行很关键:Vue Router用了history模式,刷新某个子路由(比如/book/detail/1)时,Nginx要能找到对应的前端资源,找不到就回退到index.html,让前端路由接管页面。

7.3 上线之前的安全清单

上线前我建议过一遍下面这个清单:

  • 修改MySQL数据库密码,不要用默认的root弱口令;
  • 后端接口不能全裸奔,公开接口和登录接口要梳理清楚;
  • 管理后台的接口必须校验管理员角色;
  • 文件上传要限制类型和大小,防止恶意文件;
  • 生产环境关闭SpringBoot的内置错误详情;设置server.error.include-message: never;
  • 数据库定期备份,至少做一次全量备份脚本。

图书商城这类系统,做完不是重点,重点是做完还能把它讲清楚、改得动、跑得稳。SpringBoot + Vue3 + MyBatis这套组合,胜在每条链路都有大量现成资料可以查,遇到问题不至于卡死在某个冷门知识上。你能把这套架构从头到尾手工敲一遍,前后端联调走通,再想明白订单状态流转和库存扣减这两个核心难点,SpringBoot和Vue3的基础技能基本就到了能独立做项目的水平。

帮人改过好多次这种商城系统之后,我个人最深的体会是:技术选型其实不是这个项目最重要的东西,真正拉开差距的是对业务逻辑的理解。同样的登录、购物车、下订单,代码写一个月和写三天,区别不在语法,而在有没有把并发扣库存、订单状态机、快照这些隐性需求想清楚。把这些细节啃下来,换任何一套技术栈,做任何一套管理系统,思路都是通的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询