
刚入行那会儿最头疼的往往不是某个框架用不熟而是明明教程看了不少真到自己搭一个前后端分离项目时却发现到处都是模棱两可的地方后端接口写好了前端不知道该怎么调前端页面写完了后端又没配跨域联调时一行 Access-Control-Allow-Origin 报错能卡一整个下午。身边不少同事都是从资料零散、到处踩坑的阶段熬过来的。我整理这套一小时纯享版实战路线目的很简单把 Spring Boot 3 Vue 3 MyBatis-Plus 这套前后端分离项目的搭建过程压缩成一条能完整跑通的闭环主线。你不需要在看哪篇教程、装哪个依赖上反复纠结只要按照顺序走一遍新手也能在一小时左右搭出带数据库读写和前后端联调的最小项目。本文以商品信息管理作为贯穿案例后端负责提供 RESTful 接口前端负责页面展示和表单提交最终实现新增商品、列表分页、按名称搜索三个核心功能。涉及的内容包括环境准备、后端项目初始化、数据库表设计、接口开发、前端工程创建、页面联调以及我在实际开发中遇到的高频问题和对应排查思路。还在纠结前后端之间到底怎么打通的读者这一篇值得完整过一遍。1. 技术选型与核心概念1.1 为什么要选这套技术栈前后端分离是目前 Web 开发的主流形态。后端只负责处理业务逻辑和读写数据库通过 JSON 格式返回数据前端负责渲染页面和收集用户操作通过 HTTP 请求调用后端接口。两者通过一组约定好的 API 契约协作互不干扰也方便以后各自独立扩展和部署。本文选择的技术组合是Spring Boot 3目前主流的 Java 后端框架内置 Tomcat简化了配置和依赖管理。Vue 3目前主流的 JavaScript 前端框架配合 Composition API 和script setup语法写起来简洁清晰。Vite前端构建工具开发环境下启动速度快热更新体验好。MyBatis-Plus基于 MyBatis 的增强工具提供通用 CRUD 方法和分页插件能减少大量重复的 SQL 编写。MySQL常用的关系型数据库用于存储业务数据。整套组合的特点是难度曲线相对平缓社区资料多出了问题容易搜索到解决方案而且能满足大多数中小型项目的需求。1.2 一小时纯享版是什么意思所谓纯享版指的是我在这篇文章里砍掉了大量与主线无关的旁支内容比如单元测试怎么写、Docker 镜像怎么打、鉴权框架怎么集成。这些内容当然重要但它们会打断把项目跑起来这个第一目标。在一小时内完成任务需要明确一条主线创建数据库表 → 搭建后端工程 → 写好增删改查接口 → 启动后端 → 创建前端工程 → 编写页面并调用接口 → 前后端联调成功只要这条链路通了后续再往里面加 Redis、加 Spring Security、加文件上传都是在这个骨架上做增量扩展难度会小很多。1.3 核心概念补充开发过程中你一定会反复听到下面几个词先在这里统一解释RESTful API一种接口设计风格用 HTTP 方法表示操作类型。GET 代表查询POST 代表新增PUT 代表修改DELETE 代表删除。接口地址用名词表示资源比如/api/products。JSON前后端数据交换的格式。后端返回{code:200,data:[...],msg:success}前端拿到后再渲染到页面上。跨域当前端项目的地址比如http://localhost:5173和后端接口的地址比如http://localhost:8080不在同一个域名、端口或协议下时浏览器出于安全策略会拦截请求响应导致前端拿不到数据。参数绑定后端把前端传来的 JSON 数据转换成 Java 对象的过程。Spring Boot 通过RequestBody等注解自动完成。把这些概念理解到位后面写代码时会顺畅很多。2. 环境准备与版本说明动手之前先把开发环境准备好。这里不写死具体的小版本号因为不同机器的已有环境可能不一样重点是确认大版本能满足框架要求。2.1 后端环境要求工具建议版本说明JDK17 及以上Spring Boot 3 要求 JDK 17如果你的本机是 JDK 8需要先升级Maven3.6负责依赖下载和项目构建IDEA任何较新版本建议用 IDEA 打开 Maven 工程社区版也够用MySQL5.7 或 8.x本文示例以 MySQL 8 为主5.7 也兼容验证 JDK 和 Maven 是否安装成功的命令java -version mvn -version如果运行java -version显示的是 1.8说明当前 JDK 版本较低需要先安装 JDK 17 并配置好JAVA_HOME环境变量。2.2 前端环境要求工具建议版本说明Node.js18 及以上Vite 5 以上版本要求 Node 18 起npm 或 pnpm与 Node 配套包管理器用于安装前端依赖验证命令node -v npm -v2.3 数据库准备在 MySQL 中创建一个专门的数据库用于本例的练习CREATE DATABASE IF NOT EXISTS demo_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;建议使用utf8mb4字符集否则中文数据写入时可能出现乱码。如果你的电脑还没安装 MySQL可以先用 Docker 快速启动一个临时实例docker run -d --name mysql-demo \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD123456 \ -e MYSQL_DATABASEdemo_db \ mysql:8.0需要注意MYSQL_ROOT_PASSWORD只是测试环境的密码真实项目中请使用强密码并遵循最小权限原则为业务单独创建账号。2.4 建表语句商品表设计得精简一些保留关键字段USE demo_db; CREATE TABLE product ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键ID, name varchar(100) NOT NULL COMMENT 商品名称, price decimal(10,2) NOT NULL COMMENT 商品单价, stock int NOT NULL DEFAULT 0 COMMENT 库存数量, create_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品信息表;插入两条测试数据方便后面验证列表接口INSERT INTO product (name, price, stock) VALUES (机械键盘, 599.00, 120), (无线鼠标, 129.00, 300);3. 后端项目初始化与核心代码3.1 创建 Spring Boot 工程推荐直接用 IDEA 创建选择 Spring Initializr。填写 Group如com.example和 Artifact如demo-backend。依赖选择 Spring Web。项目类型选择 MavenJava 版本选择 17。创建完成后手动在pom.xml中补充 MyBatis-Plus 和 MySQL 驱动依赖。3.2 pom.xml 依赖说明文件路径demo-backend/pom.xmldependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies如果你用的 Spring Boot 版本是 3.x注意 MyBatis-Plus 要选择带spring-boot3标识的 starter不要沿用旧的mybatis-plus-boot-starter否则会提示依赖不兼容。3.3 application.yml 配置文件路径demo-backend/src/main/resources/application.ymlserver: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto这里解释几个关键配置serverTimezoneAsia/Shanghai解决 MySQL 8 的时区问题避免日期时间字段相差 8 小时。map-underscore-to-camel-case数据库字段create_time自动映射为 Java 属性createTime。log-impl开启 SQL 日志输出开发环境调试时直接看到执行的 SQL 语句非常方便。id-type: auto主键使用数据库自增策略。3.4 编写实体类文件路径demo-backend/src/main/java/com/example/demo/entity/Product.javapackage com.example.demo.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; Data TableName(product) public class Product { TableId(type IdType.AUTO) private Long id; private String name; private BigDecimal price; private Integer stock; private LocalDateTime createTime; private LocalDateTime updateTime; }Lombok 的Data注解帮我们自动生成 getter、setter、toString 等方法代码看起来更干净。如果你不想引入 Lombok也可以手动写这些方法效果一样。实体类的属性命名遵循驼峰规则和数据库字段的下划线命名由 MyBatis-Plus 自动完成转换。3.5 编写 Mapper文件路径demo-backend/src/main/java/com/example/demo/mapper/ProductMapper.javapackage com.example.demo.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.demo.entity.Product; import org.apache.ibatis.annotations.Mapper; Mapper public interface ProductMapper extends BaseMapperProduct { }继承BaseMapperProduct之后就自动拥有了selectById、selectList、insert、updateById、deleteById等常用方法不需要再写 XML 映射文件。3.6 编写统一返回结果类为了让前后端接口约束清晰定义一个统一的返回格式。前端只需要判断code是否为 200就知道接口是否成功。文件路径demo-backend/src/main/java/com/example/demo/common/Result.javapackage com.example.demo.common; import lombok.Data; Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMsg(success); result.setData(data); return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.setCode(500); result.setMsg(msg); return result; } }3.7 编写 Service 与实现类文件路径demo-backend/src/main/java/com/example/demo/service/ProductService.javapackage com.example.demo.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.IService; import com.example.demo.entity.Product; public interface ProductService extends IServiceProduct { PageProduct pageProducts(long current, long size, String name); }文件路径demo-backend/src/main/java/com/example/demo/service/impl/ProductServiceImpl.javapackage com.example.demo.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.example.demo.entity.Product; import com.example.demo.mapper.ProductMapper; import com.example.demo.service.ProductService; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; Service public class ProductServiceImpl extends ServiceImplProductMapper, Product implements ProductService { Override public PageProduct pageProducts(long current, long size, String name) { PageProduct page new Page(current, size); LambdaQueryWrapperProduct wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(name), Product::getName, name) .orderByDesc(Product::getCreateTime); return this.page(page, wrapper); } }LambdaQueryWrapper是 MyBatis-Plus 提供的条件构造器。like的第一个参数是条件判断当name不为空时才拼接模糊查询这样搜索关键词可以传也可以不传。3.8 编写 Controller文件路径demo-backend/src/main/java/com/example/demo/controller/ProductController.javapackage com.example.demo.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.example.demo.common.Result; import com.example.demo.entity.Product; import com.example.demo.service.ProductService; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/products) public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService productService; } GetMapping public ResultPageProduct page(RequestParam(defaultValue 1) long current, RequestParam(defaultValue 10) long size, RequestParam(required false) String name) { return Result.success(productService.pageProducts(current, size, name)); } PostMapping public ResultVoid add(RequestBody Product product) { productService.save(product); return Result.success(null); } PutMapping public ResultVoid update(RequestBody Product product) { productService.updateById(product); return Result.success(null); } DeleteMapping(/{id}) public ResultVoid delete(PathVariable Long id) { productService.removeById(id); return Result.success(null); } }注意几点PostMapping对应新增PutMapping对应修改DeleteMapping对应删除接口语义一目了然。RequestBody负责把前端传来的 JSON 转成Product对象。这里使用构造器注入Spring 官方也更推荐这种写法方便单元测试和依赖明确。3.9 分页插件配置MyBatis-Plus 的分页功能需要单独配置插件。文件路径demo-backend/src/main/java/com/example/demo/config/MybatisPlusConfig.javapackage com.example.demo.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(500L)是给分页查询加一道保险防止前端传入过大的size导致一次查出太多数据。3.10 全局跨域配置前后端分离开发时前端运行在 5173 端口后端运行在 8080 端口浏览器会拦截跨域请求。最直接的做法是由后端配置跨域规则。文件路径demo-backend/src/main/java/com/example/demo/config/CorsConfig.javapackage com.example.demo.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 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配置成*而是写上具体的域名。这里为了方便本地联调暂时放开。3.11 启动类说明Spring Boot 启动类一般由初始化器自动生成内容如下文件路径demo-backend/src/main/java/com/example/demo/DemoBackendApplication.javapackage com.example.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class DemoBackendApplication { public static void main(String[] args) { SpringApplication.run(DemoBackendApplication.class, args); } }启动后端后可以在浏览器访问http://localhost:8080/api/products?current1size10如果能看到 JSON 数据后端部分就基本没问题了。4. 前端项目初始化与页面实现4.1 使用 Vite 创建 Vue 3 项目在命令行执行npm create vitelatest demo-frontend -- --template vue这条命令会生成一个名为demo-frontend的 Vue 3 Vite 项目。进入目录并安装依赖cd demo-frontend npm install4.2 安装必要依赖npm install axios element-plusaxios用于发送 HTTP 请求。element-plusVue 3 的 UI 组件库提供表格、表单、弹窗、分页等现成组件能省去大量样式编写工作。4.3 main.js 入口配置文件路径demo-frontend/src/main.jsimport { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue const app createApp(App) app.use(ElementPlus) app.mount(#app)4.4 封装 axios 请求工具直接在每个组件里写 axios 会非常重复建议把请求逻辑统一封装。文件路径demo-frontend/src/utils/request.jsimport axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.response.use( (response) { const res response.data if (res.code ! 200) { return Promise.reject(new Error(res.msg || 请求失败)) } return res }, (error) { return Promise.reject(error) } ) export default request这里把baseURL设置成/api配合下一小节的 Vite 代理配置前端请求/api/products时会被自动转发到后端的http://localhost:8080/api/products。4.5 Vite 代理配置文件路径demo-frontend/vite.config.jsimport { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })配置代理后开发环境的请求路径不需要写完整域名由 Vite 帮忙转发这样既能绕过跨域限制也让代码更整洁。4.6 商品管理页面组件文件路径demo-frontend/src/components/ProductManager.vue完整的商品管理页面包含三块功能搜索栏输入名称点击查询。商品表格展示名称、价格、库存、创建时间并提供编辑和删除按钮。新增/编辑弹窗通过表单提交数据。template div classproduct-manager el-card el-form :inlinetrue el-form-item label商品名称 el-input v-modelquery.name placeholder请输入商品名称 clearable keyup.enterhandleSearch / /el-form-item el-form-item el-button typeprimary clickhandleSearch查询/el-button el-button typesuccess clickopenAddDialog新增/el-button /el-form-item /el-form el-table :datatableData border stripe el-table-column propid labelID width80 / el-table-column propname label商品名称 / el-table-column propprice label单价 width120 / el-table-column propstock label库存 width100 / el-table-column propcreateTime label创建时间 width180 / el-table-column label操作 width180 fixedright template #defaultscope el-button typeprimary link clickopenEditDialog(scope.row)编辑/el-button el-button typedanger link clickhandleDelete(scope.row)删除/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequery.current v-model:page-sizequery.size :totaltotal :page-sizes[5, 10, 20] layouttotal, sizes, prev, pager, next changefetchData / /el-card el-dialog v-modeldialogVisible :titleform.id ? 编辑商品 : 新增商品 width480px el-form :modelform label-width80px el-form-item label商品名称 el-input v-modelform.name / /el-form-item el-form-item label单价 el-input-number v-modelform.price :min0 :precision2 / /el-form-item el-form-item label库存 el-input-number v-modelform.stock :min0 / /el-form-item /el-form template #footer el-button clickdialogVisible false取消/el-button el-button typeprimary clickhandleSubmit确定/el-button /template /el-dialog /div /template script setup import { onMounted, reactive, ref } from vue import { ElMessage, ElMessageBox } from element-plus import request from ../utils/request const query reactive({ current: 1, size: 10, name: }) const tableData ref([]) const total ref(0) const dialogVisible ref(false) const form reactive({ id: null, name: , price: 0, stock: 0 }) const fetchData async () { const res await request.get(/products, { params: { current: query.current, size: query.size, name: query.name } }) tableData.value res.data.records total.value res.data.total } const handleSearch () { query.current 1 fetchData() } const openAddDialog () { form.id null form.name form.price 0 form.stock 0 dialogVisible.value true } const openEditDialog (row) { form.id row.id form.name row.name form.price Number(row.price) form.stock row.stock dialogVisible.value true } const handleSubmit async () { if (form.id) { await request.put(/products, form) ElMessage.success(修改成功) } else { await request.post(/products, form) ElMessage.success(新增成功) } dialogVisible.value false fetchData() } const handleDelete async (row) { await ElMessageBox.confirm(确定删除商品「${row.name}」吗, 提示, { type: warning }) await request.delete(/products/${row.id}) ElMessage.success(删除成功) fetchData() } onMounted(fetchData) /script说明几个容易忽略的细节request.get(/products)配合baseURL: /api实际请求地址是/api/products再由 Vite 代理转发给后端。el-pagination的change事件在页码和每页条数变化时都会触发所以不需要额外监听current和size的单独变化。handleSubmit里根据form.id是否为空判断新增还是修改复用同一个弹窗。4.7 在 App.vue 中引入组件文件路径demo-frontend/src/App.vuetemplate ProductManager / /template script setup import ProductManager from ./components/ProductManager.vue /script4.8 启动前端npm run dev启动后浏览器访问http://localhost:5173正常的话能看到商品列表数据无论是新增、编辑还是删除操作后列表都会自动刷新。5. 运行与联调验证前后端都启动后按下面顺序验证一遍确认整条链路是通的访问http://localhost:5173确认页面正常渲染。查看商品表格如果显示出机械键盘和无线鼠标两条记录说明 GET 接口、分页、代理都正常。点击新增填写一个商品名称和价格点击确定表格立即多出一条记录。点击某行的编辑修改库存数量并保存观察该行数据是否变化。点击删除确认弹窗后该行从列表移除。验证过程中可以同时观察后端控制台。MyBatis-Plus 配置了 SQL 日志输出每次查询、插入、更新时控制台都会打印对应的 SQL 语句方便确认实际执行的逻辑。如果某一步失败不要急着改代码先确认是前端问题还是后端问题。最简单的方式是打开浏览器开发者工具切换到 Network 标签页查看对应请求的状态码和响应内容这是定位联调问题最有效的手段。6. 常见问题与排查思路6.1 问题汇总表问题现象常见原因解决思路浏览器控制台报 CORS 错误后端未配置跨域或前端未使用代理在后端添加 CorsConfig或在前端配置 Vite proxy页面请求返回 404请求路径和后端接口路径不一致核对 Controller 的RequestMapping和前端请求 URL请求返回 500日志显示表不存在实体类表名映射错误检查TableName(product)是否和数据库表名一致新增请求报字段不能为空前端提交的字段名和后端实体属性不一致检查 JSON 字段名是否为驼峰命名分页数据不正确分页插件未配置添加 MybatisPlusInterceptor注册 PaginationInnerInterceptor日期时间相差 8 小时JDBC 连接未指定时区URL 中添加serverTimezoneAsia/Shanghai前端请求一直 pending最后超时后端没启动或代理 target 地址写错确认后端启动成功确认http://localhost:8080可访问6.2 典型报错详解报错一Access to XMLHttpRequest at http://localhost:8080/api/products from origin http://localhost:5173 has been blocked by CORS policy这个报错几乎每个前后端分离项目都会遇到。原因是浏览器的同源策略拦截了跨端口请求。解决方式有两种在后端配置全局跨域也就是上文中的CorsConfig。在前端配置 Vite 代理让请求看起来是同源的。实际工作中我更推荐优先使用代理方式因为生产环境一般会通过 Nginx 把前后端收敛到同一个域名下与 Vite 代理的思路一致。报错二Invalid bound statement (not found)出现这个报错常见原因有两个。一是 Mapper 接口没有加Mapper注解导致 Spring 容器没有扫描到二是自定义 XML 文件路径编写错误。本文的示例只用了BaseMapper没有自定义 XML所以重点检查第一点。报错三Parameter name not found. Available parameters are [arg1, arg0, param1, param2]这种问题一般出现在自定义 SQL 中多个参数没有加Param注解。MyBatis 对参数的处理要求比较严格写自定义 Mapper 方法时给每个参数加上Param(xxx)就能避免。报错四前端提交价格后后端收到的是字符串类型转换失败Element Plus 的el-input-number绑定的值本身是数字但如果用了el-input手动输入价格会被当作字符串提交。解决方案是把表单字段绑定为Number类型或者在后端 DTO 中使用BigDecimal配合 Spring MVC 的类型转换机制处理。7. 最佳实践与工程建议7.1 接口设计规范示例中直接返回了Product实体对象这在小型项目中效率很高但项目变复杂后会带来问题。比如前端的表格不需要updateTime字段但接口把整个对象都返回了既多余又可能泄露不该暴露的信息。更稳妥的做法是定义独立的 DTOData Transfer Object类按需返回字段。接口路径建议遵循 REST 风格资源使用复数名词比如/api/products。查询、新增、修改、删除分别用 GET、POST、PUT、DELETE。不要为了省事把所有操作都写成 GET。统一返回结构也是非常重要的一环。示例中的Result类虽然简单但保证了接口返回格式一致前端封装 request 时只需要处理一次code判断。7.2 配置管理application.yml里直接写死了数据库账号密码这只是本地演示。真实项目中应当通过环境变量注入敏感配置避免提交到代码仓库。使用 Spring Boot 的多环境配置例如application-dev.yml、application-prod.yml通过启动参数--spring.profiles.activedev切换环境。数据库账户遵循最小权限原则为应用单独创建账号仅授予需要用到的库表权限。7.3 数据库操作安全凡是涉及删除、更新的接口都要谨慎。示例中的删除是物理删除也就是直接从数据库删掉记录。很多业务场景更适合逻辑删除增加一个deleted字段删除时只更新这个字段查询时自动过滤掉已删除数据。MyBatis-Plus 提供了TableLogic注解支持逻辑删除配置简单建议学习一下。批量操作的场景尤其要小心。不要在一次请求里循环调用单条删除接口而应提供批量删除接口并使用事务保证操作的原子性。7.4 前后端开发体验不少人习惯后端写完再写前端或者前端先把页面做完再找后端对接。效率更高的方式是先在前后端之间定义好接口文档明确每个接口的路径、请求参数和返回结构然后再并行开发。后端完成一个接口前端就可以立即联调一个接口不用等到全部完成才收网。前端请求统一走封装好的request.js避免在每个组件里重复设置baseURL和超时时间。后端日志保留 SQL 输出本地调试时能直接看到 MyBatis 实际执行的内容排查 SQL 问题会快很多。7.5 项目结构建议后端代码按功能分包而不是按技术层分包。示例中使用了controller、service、mapper、entity这种经典分层适合简单项目。当模块增多后可以考虑改成按业务模块分包比如product、order、user各为一个包每个包内部再继续分层。这样的结构在多人协作时更容易定位代码互不干扰。前端也一样组件不要全部堆在components目录下建议按页面和功能拆分目录例如views/product、components/common配合 Vue Router 做页面级别的组织。7.6 版本依赖管理Spring Boot 3 对依赖版本比较敏感。在pom.xml中引入第三方 starter 时要注意是否提供对应的spring-boot3版本不要混用。前端引入 npm 包时报错信息通常非常直白比如SyntaxError、Failed to resolve dependency遇到这些问题先检查 Node 版本是否符合要求再检查依赖是否安装成功。依赖锁定也很重要。前端项目会生成package-lock.json后端 Maven 会根据坐标固定版本。建议把这些锁文件提交到 Git避免不同开发人员的依赖版本不一致导致诡异的行为差异。8. 一小时之内你应该记住的事如果你按照本文从第一节看到这里实际上已经完成了一次完整的前后端项目搭建。即使中间某些细节没有完全记住也没关系真正动手敲一遍之后你会发现这些内容比自己想象的要简单。这套流程的核心价值不在于代码本身而在于它让你建立了一条清晰的认知链路前端页面通过 HTTP 请求访问后端接口后端接口通过 MyBatis-Plus 读写数据库数据以 JSON 格式在前后端之间流转。只要这条链路在你的大脑里是清晰的以后无论换成哪种框架、哪种数据库你都能以最快的速度迁移。接下来可以去尝试一些进阶的主题给前端加入 Vue Router 和登录页给后端集成 Spring Security 做权限控制把配置文件搬到 Nacos 实现动态刷新或者把项目打包然后用 Docker 部署到服务器上。每一次扩展都会让你更理解这套骨架在工程实践中的真实位置。一小时的纯享路线到这里就算闭环了。如果你是照着一步步搭到这里的下次再遇到类似项目相信你已经不需要再翻这篇文章了。