先说我做完这个项目最大的感受:AI 确实把编码门槛拉低了,但并没有把联调门槛拉低。你可以让 AI 几分钟内生成一整套 Spring Boot 后端和 Vue 页面,但真正让数据从数据库流到表格里、再从表单流回数据库,还得靠人把接口契约、异常处理、跨域策略、字段序列化这些散落的细节串起来。这篇文章我不会只给你贴代码,而是还原一条完整路线:从把需求翻译成接口草案开始,到 Maven 搭出 Spring Boot 工程,再按 RESTful 规范把接口写扎实,最后用 Vue3 页面把链路打通并解决联调中的典型问题。适合正在做课程设计、团队交接或者第一个全栈项目的开发者,尤其是想让 AI 帮忙但又被“代码能生成、系统跑不通”折磨过的人。
1. 让 AI 写前后端之前,先逼自己把接口草案写清楚
1.1 AI 编程时代的核心矛盾:产出速度变快,出错密度也变高
我在最初尝试用 AI 辅助做前后端项目时,习惯是直接说“帮我写一个任务管理系统”,然后看 AI 输出一长串 Controller、Service、Mapper 和页面代码。代码确实漂亮,但一跑起来就出问题:前端以为返回的是{ data: [...] },后端返回的却是{ code: 0, data: { records: [...] } };前端提交的字段叫createTime,后端实体里写的是createdAt。这种问题本质不是 AI 笨,而是因为需求在“人脑”里不够具体时,AI 只能在语言模型概率空间里猜一个最像样的实现。
所以我的建议是:在你输入第一句 AI 提示词之前,先逼自己把接口契约写成文字。这件事的时间成本通常只要 20 分钟,却能把后续联调时间压缩到原来的三分之一以下。
1.2 把一句模糊需求拆成资源、字段和操作三张清单
假设我们要做一个任务待办管理功能。大多数人的第一反应是:用户能新增任务、能看列表、能改状态。这个粒度对 AI 来说太粗了。我会先按“资源视图”拆开。
第一步是定义资源。这个系统里最核心的资源就是任务 Task,它拥有唯一标识、标题、描述、状态、创建时间和更新时间。
第二步是定义状态集合。任务状态不要用字符串随便存,建议先约定枚举:TODO(待处理)、IN_PROGRESS(进行中)、DONE(已完成)。这一步如果漏了,前端下拉框、后端枚举、数据库字段就各说各话。
第三步是写下所有操作,并给每个操作配上 HTTP 方法和路径。这块直接采用 RESTful 风格会非常清晰:
| 操作说明 | HTTP 方法 | 路径 | 请求体要点 | 响应数据 |
|---|---|---|---|---|
| 新增任务 | POST | /api/v1/tasks | title、description | 创建后的完整任务对象 |
| 分页查询 | GET | /api/v1/tasks?page=0&size=10&status=TODO | 无 | 分页结果,含记录列表和总数 |
| 查询详情 | GET | /api/v1/tasks/{id} | 无 | 单个任务对象 |
| 全量更新 | PUT | /api/v1/tasks/{id} | title、description、status | 更新后的任务对象 |
| 删除任务 | DELETE | /api/v1/tasks/{id} | 无 | 无内容,204 状态码 |
这张表就是“人机契约”的核心。我通常把它直接贴给 AI,再让 AI 生成后端代码和接口文档。从实践经验看,AI 在明确的 URL、方法和字段约束下,输出质量和可控度会有非常明显的提升。这里的道理很简单:你给 AI 的上下文不是“空泛需求”,而是接近最终代码结构的规格说明书。
2. 后端地基:用 Maven 构建 Spring Boot 工程并统一响应结构
2.1 工程初始化的两条路,我推荐你走哪条
Spring Boot 工程初始化通常有两条路。一条是去 Spring Initializr 网站勾选依赖再下载压缩包,另一条是直接用 IDEA 的 Spring Initializr 创建。两种方式本质一样,都是生成一个 Maven 工程目录。我个人的建议是:无论哪种方式,最终都要能看懂 pom.xml,因为 AI 生成的代码也依赖这份依赖清单。
以 Spring Boot 3.x 为例,最终 pom.xml 中至少需要这几组依赖:spring-boot-starter-web(提供 MVC 和 Tomcat 内嵌容器)、spring-boot-starter-data-jpa(做数据库持久化)、spring-boot-starter-validation(做参数校验)、h2(本地无外部依赖调试用)、mysql-connector-j(生产切 MySQL 用)。如果你希望接口文档自动生成,可以再加 springdoc-openapi-starter-webmvc-ui。实测下来 springdoc 的版本要和 Spring Boot 版本匹配,否则启动阶段会扫包报错。
2.2 统一响应体:为什么每写一个接口都要返回同样结构的 JSON
项目里许多人写接口时非常随意:成功时直接返回对象,失败时返回一个 Map 或自定义错误页。短时间看没什么问题,但一旦接口数量超过 10 个,前端就要在每个请求里做各种类型判断。正确的做法是从第一个接口开始就定义统一的 API 响应结构。我会让 AI 先生成这样一个泛型类:
public class ApiResponse<T> { private int code; private String message; private T data; public static <T> ApiResponse<T> success(T data) { ApiResponse<T> response = new ApiResponse<>(); response.setCode(0); response.setMessage("success"); response.setData(data); return response; } public static <T> ApiResponse<T> error(int code, String message) { ApiResponse<T> response = new ApiResponse<>(); response.setCode(code); response.setMessage(message); return response; } // getter / setter 省略 }注意,这里的 code 并不是 HTTP 状态码,而是业务状态码。我习惯把业务成功固定为 0,HTTP 状态码继续保持它本身的语义:200 代表请求成功,400 代表参数有误,401 代表未认证,500 代表服务端异常。前端拿到响应后优先判断code === 0,再取data,这个模式在团队协作中非常好用。
2.3 数据库配置:本地 H2 跑通逻辑,生产 MySQL 一键切换
为了让项目在没有安装 MySQL 的机器上也能立刻跑起来,我建议本地开发阶段使用 H2 内存数据库,同时保留 MySQL 的 profile 配置。这样 AI 生成的代码、脚本和页面不会因为数据库连不上而中断。
# application.yml spring: datasource: url: jdbc:h2:mem:taskdb driver-class-name: org.h2.Driver username: sa password: "" jpa: hibernate: ddl-auto: update show-sql: true如果切到 MySQL,只需要增加一个 application-mysql.yml:
spring: datasource: url: jdbc:mysql://localhost:3306/task_manager?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: root password: yourpassword jpa: hibernate: ddl-auto: update启动命令变成mvn spring-boot:run -Dspring-boot.run.profiles=mysql即可。这里有个容易踩的坑:Spring Boot 2.7 之后 MySQL 驱动坐标名称变化很大,如果引入的是mysql:mysql-connector-java旧坐标,在 Spring Boot 3.x 里很可能出现驱动类找不到的情况。统一使用新坐标com.mysql:mysql-connector-j就能避开。
3. 让 AI 按 RESTful 规范生成资源接口,再人工卡住细节
3.1 从 Controller 到 DTO:AI 生成的代码哪些能直接用
拿到接口草案后,我通常会让 AI 按“Controller → Service → Repository → Entity”的顺序逐层生成。不要一次让它生成全部代码,更好的办法是让它先按实体和字段约束生成 Entity:
@Entity @Table(name = "task") public class Task { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @Column(length = 1000) private String description; @Enumerated(EnumType.STRING) private TaskStatus status; private LocalDateTime createdAt; private LocalDateTime updatedAt; @PrePersist void onCreate() { this.createdAt = LocalDateTime.now(); this.updatedAt = this.createdAt; } @PreUpdate void onUpdate() { this.updatedAt = LocalDateTime.now(); } }Entity 直接暴露给前端是很多教程爱做的偷懒事,但实际联调你会发现它相当危险。Entity 里一旦包含审计字段、关联对象或内部逻辑,JSON 响应就会多出很多前端用不到也不该看到的字段。更合理的做法是引入 DTO,让 Controller 层只返回视图对象。虽然多写一层看起来麻烦,但它换来的是接口结构的稳定性。Entity 改了不影响前端,前端加了展示字段也不影响数据库表。
3.2 参数校验是 AI 最常忽略的地方,必须显式要求
我发现 AI 默认生成的接口能正常处理“理想输入”,但几乎不做参数校验。比如 POST 接口要求 title 必填,AI 生成的代码通常只在 Service 里判断空字符串,但缺少 Spring Validation 的注解。这对联调影响很大:前端哪天漏传了字段,后端返回的就不是规范 JSON,而是默认的 400 错误页,前端拿到后完全没法解析。
解决办法是在 DTO 上直接加校验注解,并让 Controller 使用@Valid:
public class TaskCreateRequest { @NotBlank(message = "任务标题不能为空") @Size(max = 50, message = "任务标题长度不能超过50") private String title; @Size(max = 1000, message = "任务描述长度不能超过1000") private String description; }Controller 中这样接收:
@PostMapping public ApiResponse<TaskVO> create(@Valid @RequestBody TaskCreateRequest request) { return ApiResponse.success(taskService.create(request)); }随后配置一个全局异常处理器,把MethodArgumentNotValidException统一转成符合 ApiResponse 结构的错误体。这样前端无论传错什么参数,都能从 response 里解析出明确的中文提示,这是联调体验提升最明显的一步。
3.3 接口写完后先用工具自测,不急着写前端
接口层代码完成后,第一件事不是去写前端页面,而是打开火狐或 Chrome 的 REST 客户端,或者用 Postman 把每个接口轮一遍。这里我最常做的是先创建一条任务,然后立刻分页查询,核对返回字段的命名是否和接口草案完全一致。如果此时发现字段拼写不一致,成本极低;等前端页面写好了再发现,就需要两边同时改。
一个很实用的细节是:把接口返回的 JSON 直接复制到前端项目的类型定义文件里,作为 TypeScript interface 或 JSDoc 注释。这一步能强制后端字段命名和前端引用完全对齐,让 AI 后续生成页面代码时也不容易引用错字段。
4. 把接口与数据库真正打通,避开“假接口”和“隐蔽坑”
4.1 Long 型 ID 的序列化陷阱:前端数字精度丢失
这是一个非常典型、但 AI 很少会主动告诉你的问题。Java 后端的Long类型最大可以到 19 位数字,而 JavaScript 的 Number 类型安全整数范围只有2^53 - 1,也就是大约 9007199254740991。一旦主键 ID 超过这个范围,前端拿到的 ID 就会不精确。如果后续你要用这个 ID 做详情查看、编辑或删除,请求就会打到错误的资源上。
解决办法有两个层级。第一层是全局配置 Jackson,让所有 Long 类型在序列化为 JSON 时转为字符串:
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder -> { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; } }第二层是在前端用 string 类型接收 ID。这个坑在真实项目中非常高频,我强烈建议在你的项目里优先处理。
4.2 Repository 分页查询:默认页码别搞错
Spring Data JPA 的Pageable默认页码从 0 开始,而很多前端分页组件页码从 1 开始。AI 生成的 Controller 可能直接接收page参数再丢给 Repository,导致前端传第 1 页,后端返回的是第 2 页内容。更隐蔽的是,当查询条件里带状态筛选时,写法稍有不对就会出现 N+1 查询或者内存分页。
正确的做法是直接在 Repository 层声明带条件的分页查询:
public interface TaskRepository extends JpaRepository<Task, Long> { Page<Task> findByStatus(TaskStatus status, Pageable pageable); }Service 层把前端的页码做一次转换:
public Page<TaskVO> list(int page, int size, TaskStatus status) { Pageable pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending()); Page<Task> taskPage = (status == null) ? taskRepository.findAll(pageable) : taskRepository.findByStatus(status, pageable); return taskPage.map(taskMapper::toVO); }我建议在前后端约定里写死:前端页码从 1 开始,后端接口接收的 page 也是从 1 开始,后端内部再减 1。这样比“前端传 0 表示第一页”要直观得多。
4.3 事务所、软删除与时间字段,AI 不会替你考虑的设计决策
AI 生成的基本 CRUD 往往不会处理逻辑删除,也不会考虑“列表接口默认只查未删除数据”。一旦你的业务需要保留操作记录,物理删除就很危险。所以我的习惯是在新增表的时候直接加一个deleted字段,所有查询默认带deleted = false条件。在 Repository 中可以用@Query或@Where注解来收敛,不要让每个 Service 方法都手写这个条件。
另外,创建时间和更新时间建议完全交给数据库或 JPA 的@PrePersist、@PreUpdate处理,而不是由前端传进来。如果前端提交的 DTO 里包含createTime这样的字段,最好的做法是直接从 DTO 中移除,避免被恶意覆盖。这样接口契约也会更干净。
5. 前端接管:用 Vue3 页面把请求真正发到后端
5.1 初始化 Vite 工程与代理配置
前端侧我建议使用 Vite 创建项目,命令是npm create vite@latest task-frontend -- --template vue。然后安装 axios。开发模式下前端页面跑在 5173 端口,后端接口跑在 8080 端口,直接跨域请求会被浏览器拦截。我不推荐在 Spring Boot 里配@CrossOrigin解决开发问题,因为生产环境大概率会用 Nginx 反代,跨域策略根本不需要后端代码参与。更干净的方式是在 Vite 里配置代理:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } });这样前端请求/api/v1/tasks时,Vite 开发服务器会把请求转发到http://localhost:8080/api/v1/tasks,浏览器层面不产生跨域,后端也无需额外配置。
5.2 axios 拦截器:把统一响应体转换成前端友好数据
为了避免每个页面都写一遍错误处理,我会在 axios 实例中统一封装响应拦截器。当后端返回的code !== 0时,直接弹出错误提示;当 HTTP 状态码是 401 或 500 时,统一走全局错误提示逻辑。这样页面组件里只需关心成功时的数据。
import axios from 'axios'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 0) { alert(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res.data; }, (error) => { if (error.response) { if (error.response.status === 400) { alert('参数有误,请检查输入'); } else if (error.response.status === 500) { alert('服务器内部错误'); } } return Promise.reject(error); } );注意这里的 return 值是res.data,也就是说调用request.get(...)时,组件拿到的直接就是业务数据,不再需要反复做response.data.data这种双层拆包。
5.3 一个列表页与一个新增表单:打通完整链路
在核心链路里,一个列表页需要三件事:初始化时加载数据;表格渲染展示;操作列响应删除或状态切换。用 Vue Composition API 写出来的最小实现大概长这样:
<script setup> import { ref, onMounted } from 'vue'; import request from './api'; const taskList = ref([]); const total = ref(0); const page = ref(1); const pageSize = ref(10); async function loadTasks() { const data = await request.get('/v1/tasks', { params: { page: page.value, size: pageSize.value } }); taskList.value = data.records; total.value = data.total; } onMounted(loadTasks); </script>新增任务的提交逻辑则通过表单收集 title 和 description,再调用request.post('/v1/tasks', form)。后端接收后返回创建好的任务对象,前端重新拉一次列表即可。到这里,“页面 → 代理 → Controller → Service → Repository → 数据库”这条完整链路就已经通了。
我在实测时最喜欢做的验证是:提交完新任务后不刷新页面,直接在 DevTools 的 Network 面板里看新增请求的响应体,确认数据库返回的主键 ID 是字符串,createdAt 是 ISO 时间格式。前端页面显示的时间如果需要本地化,再在展示时做一次dayjs格式化。
6. 联调实战:把「前端说没数据、后端说有数据」这类矛盾一次排清
6.1 问题边界三层定位法
即使接口文档、类型定义都做好了,联调阶段依然可能出问题。最常见的场景是前端说“请求成功了但表格是空的”,后端说“我直接调接口有数据”。这类矛盾的本质是两边看到的数据环境或代码分支不一致。
我把联调问题分成三层:第一层是接口层,检查请求是否真的到达后端,响应状态码是什么;第二层是数据层,检查后端返回的数据结构是否和前端解析逻辑一致;第三层是渲染层,检查前端拿到数据后是否用对了字段。排查时按这个顺序走,很少绕远路。
| 现象 | 优先怀疑方向 | 快速验证手段 |
|---|---|---|
| Network 里请求显示 404 | 路由或代理路径不对 | 直接复制完整 URL,在浏览器地址栏访问 |
| Network 里请求显示 500 | 后端代码异常 | 看后端控制台堆栈,通常能直接定位 |
| 请求 200 但表格无数据 | 数据解析路径不对 | 在响应拦截器里 console.log 原始数据 |
| 数据量不对 | 后端分页页码语义不一致 | 分别请求 page=0 和 page=1,比对两份结果 |
| 字段显示 undefined | 前后端命名不一致 | 展开 JSON 树,逐个字段对照类型定义 |
6.2 让接口文档自动化,而不是人肉维护
联调中最怕所谓“口头契约”,也就是前端问后端“列表返回啥”,后端口头说“records”,前端照着写了,但第二天后端把字段改成list,页面就静默白屏。你很难在编译阶段发现,因为 JS 里读取不存在的字段只是返回 undefined。
我强烈建议在 Spring Boot 项目里集成 springdoc-openapi,接口一启动就能在/swagger-ui.html看到所有接口的定义和示例。AI 生成的接口只要带上 OpenAPI 注解,文档就会自动更新。前端可以直接从页面复制 JSON 示例作为 mock 数据来源,双方对字段定义的偏差会在联调开始前就暴露。
6.3 最容易翻车的三类联调问题与修复清单
第一类是请求体格式不对。前端axios.post默认会发送 JSON,但有时候组件库表单封装会误把数据拼成 form-urlencoded 格式,后端用@RequestBody接收时就会报HttpMessageNotReadableException,响应直接变成 400。遇到这类问题,先在 Network 里看请求头Content-Type是不是application/json,再看请求体是不是合法 JSON 字符串。
第二类是日期时间格式不一致。Spring Boot 默认序列化LocalDateTime会带T分隔符,比如2025-01-01T12:00:00,前端如果直接展示就会很难看。我建议在 application.yml 中把全局格式配置清楚:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这里有个坑需要注意:date-format对LocalDateTime的作用并不总是有效,因为 Java 8 时间类型走的是JavaTimeModule。稳妥的做法是加一个@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),或者让前端直接用 dayjs 做格式化。实战中我通常选择前端格式化,后端保持 ISO 标准格式,避免所有接口都被人为改造。
第三类是分页返回结构不统一。JPA 的Page对象序列化后包含content、totalElements、totalPages、number等字段,但如果你在 Service 里手动封装成 VO 或 Map,前端看到的字段就是另一套。这里建议接口草案里直接规定分页结构:records放列表数据,total放总数,current放当前页码。后端用一个统一的PageResult<T>类来组装,所有分页接口保持一致,前端写一套分页组件就能通吃所有列表。
7. AI 没有替你做的部分:安全、监控与部署节奏
7.1 AI 能生成 CRUD,但生成不了权限模型
如果项目需要登录鉴权,AI 生成的代码往往会在每个接口上贴个@PreAuthorize("hasRole('ADMIN')"),但它不会帮你设计用户表、角色表、路由表和 token 刷新机制。我的经验是,权限模型必须在动手写代码前用文档定义清楚,然后再让 AI 基于这个模型生成代码。否则后补权限会非常痛苦,可能要改动所有前端路由和后端切面。
7.2 给接口加监控与健康检查:用 Micrometer 和 Actuator 说话
联调阶段如果前端报告请求很慢,你很难判断是网络问题、接口性能问题还是数据库查询慢。我建议在 Spring Boot 项目中开启 Actuator,并接入 Micrometer 指标。Spring Boot 3.x 项目加依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>然后访问/actuator/health可以看到服务健康状态,访问/actuator/metrics可以看到 JVM 内存、线程和 HTTP 请求耗时等指标。很多人在联调时忽略这层价值,其实它非常有用。前端说“保存任务很慢”,你可以直接通过指标看接口 P99 耗时,而不是靠双方反复试。
7.3 前后端分离部署:本地调通后如何放到服务器
项目开发本地跑通后,部署方式直接影响后续联调环境是否稳定。我比较推荐的方案是把后端打成 Docker 镜像,前端npm run build后把dist目录交给 Nginx 托管,然后再反向代理/api到后端容器。
后端 Dockerfile 可以非常简洁:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/task-backend-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=mysql"]前端的 Nginx 配置只需要保证两件事:location /指向静态文件;location /api/把请求转发到后端服务,并处理好proxy_set_header。这种结构下,前端页面和后端服务在浏览器看来是同源的,CORS 问题彻底消失,部署后的联调环境和本地开发环境保持高度一致。这也是我在试过多种部署方式后,最终稳定沿用的一条路。
最后再分享一点个人体验
这套流程我前后完整跑过五次以后,最大的体会是:AI 真正提升效率的部分是“按契约翻译成代码”,而真正决定项目质量的部分仍然是“人如何定义契约”。你在接口草案、统一响应、字段校验上多花的每一分钟,都会在联调时加倍还给你。也许下次做新功能时,你可以试试把一个模糊需求先拆成资源、字段和操作三张清单,再让 AI 进入工作流。这样产出的代码不仅能用,而且像一个团队长期维护过的代码,而不是一个一次性原型。