Spring Boot + Vue 前后端联调踩坑记录

最近在搞那个 AI 内容创作平台 的前后端联调,踩了一堆坑,从 SSE 时序到参数类型,再到响应数据结构,一个接一个。

本文记录一下,大概有4个比较典型的。

SSE 连接时序竞争:Emitter 不存在

plain 复制代码
SSE Emitter 不存在, taskId=aef5eec7307b4532815a239c45cad95b
GET http://localhost:5173/api/article/progress/xxx 500 (Internal Server Error)

后端用 @Async 跑 AI 生成文章的异步任务,但频繁报 "SSE Emitter 不存在" 错误。

原因分析

这是一个典型的时序竞争问题。

流程如下:

plain 复制代码
1. POST /article/create → 返回 taskId,同时 @Async 启动异步任务
2. 异步线程立即开始执行 AI 生成,尝试通过 sseEmitterManager.send() 推送消息
3. 前端拿到 taskId 后才建立 SSE 连接 GET /article/progress/{taskId}

如果步骤 2 比步骤 3 先发生,emitterMap 中还没有该 taskId 对应的 Emitter,就会报错。

解决方案

在启动异步任务之前,先把 SSE Emitter 预创建好,放进 emitterMap 里,确保异步任务启动时,Emitter 已经存在了。

ArticleController.java 调整一下顺序:

java 复制代码
@PostMapping("/create")
public BaseResponse<String> createArticle(...) {
    String taskId = articleService.createArticleTask(request.getTopic(), loginUser);

    // 关键:先预创建 SSE Emitter,放进 map 里
    sseEmitterManager.createEmitter(taskId);

    // 之后再启动异步任务,此时 Emitter 已经存在,可以安全发送
    articleAsyncService.executeArticleGeneration(taskId, request.getTopic());

    return ResultUtils.success(taskId);
}

然后在SseEmitterManager.javacreateEmitter 方法加个幂等判断,防止重复创建:

java 复制代码
public SseEmitter createEmitter(String taskId) {
    // 如果已经存在,直接返回已有的(支持预创建场景)
    SseEmitter existingEmitter = emitterMap.get(taskId);
    if (existingEmitter != null) {
        return existingEmitter;
    }
    // ... 原来的创建新 Emitter 的逻辑
}

修复后时序变为:

plain 复制代码
1. 创建任务 + 预创建 Emitter(放入 emitterMap)
2. 启动异步任务(此时 Emitter 已存在,可以安全发送)
3. 前端建立 SSE 连接(返回已预创建的 Emitter)

文章详情页参数传递类型错误

plain 复制代码
BusinessException: 文章不存在
at ArticleController.getArticle(ArticleController.java:106)

文章是创建成功了,但是,点击"查看文章",前端页面却显示"文章不存在"。

原因分析

前端 API 函数 getArticle 期望接收一个对象 { taskId: string },但是,我在页面里直接传了个字符串:

typescript 复制代码
// articleController.ts --- 函数签名明确要对象
export async function getArticle(params: API.getArticleParams) {
  const { taskId: param0, ...queryParams } = params
  return request(`/article/${param0}`, { ... })
}

// ArticleDetailPage.vue --- 我直接传了字符串
const res = await getArticle(taskId)  // 传入字符串

对字符串做解构后 param0undefined,最终请求变成了 /article/undefined

解决方案

老老实实传对象就行:

typescript 复制代码
const res = await getArticle({ taskId })  // 传 { taskId: xxx }

我的文章列表显示 "No data"

文章生成完成了,数据库里也有数据,但查看【我的文章】列表页却显示 "No data"。

原因分析

Axios 的完整响应结构是这样的:

plain 复制代码
res = AxiosResponse 对象
res.data = { code: 200, data: { records: [...], totalRow: 10 }, message: "ok" }

也就是说,实际的业务数据在 res.data.data 里,但我在前端直接取了 res.data.records

typescript 复制代码
// 错误写法 --- res.data 是 { code, data, message },根本没有 records 属性
dataSource.value = res.data.records || []
pagination.value.total = res.data.totalRow || 0

当然是空的。

解决方案

老老实实多写一层 .data

typescript 复制代码
dataSource.value = res.data.data.records || []
pagination.value.total = res.data.data.totalRow || 0

删除文章请求体格式错误

plain 复制代码
MismatchedInputException: Cannot construct instance of `DeleteRequest`
(no int/Int-argument constructor/factory method to deserialize from Number value (5))

点击删除文章,后端直接报 Jackson 反序列化错误。

原因

后端接口用的是 @RequestBody DeleteRequest,期望接收一个 JSON 对象,但我在前端直接传了个数字:

typescript 复制代码
// 错误写法 --- 请求体是数字 5
await deleteArticleApi(record.id)

Jackson 收到数字 5,当然没法反序列化成 DeleteRequest 对象。

解决方案

老老实实传对象就行:

typescript 复制代码
// 正确写法 --- 请求体是 { "id": 5 }
await deleteArticleApi({ id: record.id })
相关推荐
程序员阿明2 小时前
spring boot在普通方法中获取HttpServletRequest及其使用的方式
java·spring boot·后端
花千树-0102 小时前
Spring Boot 启动慢排查与优化实战指南
java·spring boot·后端·spring
小江的记录本2 小时前
【Docker】《 Docker 高频常用命令速查表 》
java·前端·后端·http·docker·容器·eureka
Beginner x_u2 小时前
前端八股整理|Vue|虚拟 DOM、Diff 与 Patch 流程
前端·javascript·vue.js
一 乐2 小时前
智能农田管理|基于springboot + vue智能农田管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·智能农田管理系统
上天_去_做颗惺星 EVE_BLUE2 小时前
Go 语言入门实战指南
开发语言·后端·golang
CesareCheung2 小时前
Python+Vue +K6接口性能压测平台搭建
开发语言·vue.js·python
咚为2 小时前
深入浅出 Rust RefCell:打破静态检查的“紧箍咒”
开发语言·后端·rust
Stack Piston2 小时前
Spring实践@Cacheable坑
java·后端·spring