在当下竞争激烈的金融科技领域,你们公司正在全力打造一款创新理财 APP,后端基于 Ruoyi 框架,前端采用 Vue3。
开发团队各司其职,后端像精密仪器的锻造师,用 Ruoyi 构建接口;前端似界面设计师,以 Vue3 雕琢交互。
然而,如同任何宏大工程都会遭遇波折,当后端与前端的工作进入联调阶段,一系列棘手的问题如暗礁般浮出水面。譬如:
接口路径的拼接,仿佛陷入了复杂的迷宫,总在不经意间偏离正轨;
跨域问题恰似一道无形的高墙,横亘在前后端之间,阻碍着数据的顺畅流通;
Token 机制原本是守护系统安全的卫士,此刻却似偶尔闹情绪的哨兵,无端宣告过期,阻断用户的正常访问;
参数传递环节更是状况百出,参数不匹配的状况就像打乱的密码锁,让前后端信息交互陷入混乱。
这些问题犹如悬在团队头顶的达摩克利斯之剑,若不能妥善解决,不仅会延误项目进度,更可能影响产品上线后的用户体验,给公司带来不可估量的损失。接下来,让我们一同深入这个生产实际场景,看看开发团队如何凭借深厚的技术功底和敏锐的洞察力,突破重重困境,成功完成前后端联调,为这款金融 APP 的顺利问世保驾护航。
1. 接口路径相关
基础小白可能会问的一个问题:当看到
js
// 1. 查询用户列表(对应后端 /system/user/list 接口)
export function listUser(query) {
return request({
url: '/system/user/list', // 接口路径(会拼接基础地址)
method: 'get', // 请求方法
params: query // GET 请求参数(自动拼在 URL 后)
});
}
注释中的url: '/system/user/list', // 接口路径(会拼接基础地址),在ruoyi框架中url是如何拼接的呢?
在 RuoYi-Vue3 框架中,接口 URL 的拼接逻辑由封装的 request
工具统一处理,核心是基础地址(baseURL)+ 接口路径(url) 的组合,具体流程如下:
1. 基础地址(baseURL)的来源
基础地址是接口的统一前缀(如 http://localhost:8080/dev-api
),由环境配置文件定义,位置在项目根目录的 .env
系列文件中:
- 开发环境:
.env.development
- 生产环境:
.env.production
- 测试环境:
.env.test
以开发环境为例,配置内容如下:
ini
# 开发环境接口基础地址
VITE_APP_BASE_API = '/dev-api'
注意:
VITE_
前缀是 Vite 构建工具的要求,用于在前端代码中访问环境变量。
2. 接口路径(url)的拼接规则
在 src/utils/request.js
中,axios
实例会读取环境变量中的基础地址,并与接口定义的 url
拼接:
核心代码(request.js):
javascript
import axios from 'axios';
// 创建 axios 实例
const service = axios.create({
// 从环境变量中读取基础地址
baseURL: import.meta.env.VITE_APP_BASE_API,
// 超时时间
timeout: 5000
});
export default service;
拼接示例:
以 listUser
接口为例:
- 接口定义的
url
:'/system/user/list'
- 环境变量
VITE_APP_BASE_API
:'/dev-api'
最终拼接后的完整请求地址为:
baseURL + url = '/dev-api/system/user/list'
3. 实际请求地址的转换(解决跨域)
前端开发时,/dev-api
通常是一个代理标识 ,并非真实后端地址。RuoYi 会通过 Vite 配置代理,将包含 /dev-api
的请求转发到真实后端服务器,避免跨域问题。
代理配置(vite.config.js):
javascript
export default defineConfig({
server: {
// 开发环境代理配置
proxy: {
// 匹配以 '/dev-api' 开头的请求
'/dev-api': {
// 真实后端接口地址(例如 Spring Boot 后端服务)
target: 'http://localhost:8080',
// 允许跨域
changeOrigin: true,
// 替换路径:去掉 '/dev-api' 前缀
rewrite: (path) => path.replace(/^\/dev-api/, '')
}
}
}
});
最终真实请求地址:
经过代理转发后,实际向后端发送的请求地址为:
http://localhost:8080/system/user/list
总结:URL 拼接完整流程
- 定义接口路径 :在
api
模块中定义相对路径(如/system/user/list
); - 读取基础地址 :
request
工具从环境变量获取VITE_APP_BASE_API
(如/dev-api
); - 拼接临时地址 :
baseURL + url
得到临时地址(如/dev-api/system/user/list
); - 代理转发 :通过 Vite 代理配置,将临时地址转发到真实后端地址(如
http://localhost:8080/system/user/list
)。
这种设计的优势是:
- 环境隔离:开发/生产环境的基础地址可通过配置文件灵活切换;
- 跨域解决:通过代理避免前端直接请求后端的跨域问题;
- 路径统一管理:接口路径无需硬编码完整地址,便于维护。
实战环节
2.Vue3 + Ruoyi 前后端联调实战指南:从功能拆解到问题解决
在 Ruoyi-Vue3 项目开发中,"前后端联调"是衔接前端页面与后端接口的核心环节,尤其在用户管理、角色管理等核心模块中,需明确联调流程、对齐参数规范,并高效解决跨域、Token 过期等常见问题。本文以 用户管理模块 和 角色管理模块 为例,拆解完整联调流程,并针对性解决联调痛点。
一、联调前置准备:明确"3个约定"
联调前需与后端开发者对齐基础规范,避免后期因"约定不一致"反复修改:
约定类型 | 核心内容 | Ruoyi 适配建议 |
---|---|---|
接口地址约定 | 后端接口前缀(如 /system )、请求方法(GET/POST/DELETE) |
前端统一在 src/api/system/ 目录下按模块封装(如 user.js 对应用户接口,role.js 对应角色接口) |
参数格式约定 | - GET 请求:参数拼在 URL 后(?pageNum=1&pageSize=10 ) - POST 请求:参数放在请求体(JSON 格式) - 分页参数:统一用 pageNum (当前页)、pageSize (每页条数) |
前端用 reactive 定义 queryParams 存储分页+筛选参数,调用接口时直接传递 |
响应格式约定 | 后端统一返回 JSON 格式,包含 code (状态码)、msg (提示信息)、data (业务数据): json{ "code": 200, "msg": "success", "data": { "rows": [], "total": 100 } } |
前端 request.js 已封装响应拦截器,自动处理 code 判定(如 code!==200 则抛错) |
二、实战1:用户管理模块联调(核心流程拆解)
用户管理模块是 Ruoyi 最基础的模块,包含"查询用户列表、新增用户、编辑用户、删除用户"4个核心接口,联调流程具有通用性。
步骤1:前端封装接口(API 模块)
在 src/api/system/user.js
中,按后端接口定义封装函数(需与后端对齐 URL、请求方法、参数名):
javascript
import request from '@/utils/request';
// 1. 查询用户列表(GET 请求,参数:分页+筛选条件)
export function listUser(query) {
return request({
url: '/system/user/list', // 后端接口路径
method: 'get',
params: query // GET 参数(自动拼 URL)
});
}
// 2. 新增用户(POST 请求,参数:用户信息)
export function addUser(data) {
return request({
url: '/system/user',
method: 'post',
data: data // POST 参数(放请求体)
});
}
// 3. 编辑用户(PUT 请求,参数:用户ID+更新信息)
export function updateUser(data) {
return request({
url: '/system/user',
method: 'put',
data: data
});
}
// 4. 删除用户(DELETE 请求,参数:用户ID)
export function delUser(userId) {
return request({
url: '/system/user/' + userId, // 路径传参
method: 'delete'
});
}
步骤2:前端页面调用接口(组合式 API)
在用户列表页面 src/views/system/user/index.vue
中,用组合式 API 调用接口,实现"数据渲染+交互逻辑":
vue
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { listUser, delUser } from '@/api/system/user'; // 导入接口
// 1. 响应式数据定义(与后端参数对齐)
const loading = ref(false); // 加载状态
const total = ref(0); // 总条数
const queryParams = reactive({ // 分页+筛选参数
pageNum: 1,
pageSize: 10,
username: '', // 筛选条件:用户名
status: '' // 筛选条件:用户状态
});
const tableData = reactive([]); // 表格数据
// 2. 页面初始化加载列表(onMounted 钩子)
onMounted(() => {
getList();
});
// 3. 查询用户列表(核心方法)
const getList = () => {
loading.value = true;
listUser(queryParams).then(res => {
tableData.length = 0; // 清空旧数据
tableData.push(...res.data.rows); // 赋值新数据(res.data 对应后端 data 字段)
total.value = res.data.total; // 赋值总条数
}).catch(err => {
ElMessage.error('查询失败:' + err.message); // 错误提示
}).finally(() => {
loading.value = false; // 关闭加载
});
};
// 4. 删除用户(调用删除接口)
const handleDelete = (row) => {
ElMessageBox.confirm('确定要删除该用户吗?', '提示', {
type: 'warning'
}).then(() => {
delUser(row.userId).then(() => {
ElMessage.success('删除成功');
getList(); // 重新查询列表
});
});
};
</script>
<template>
<!-- 筛选表单 -->
<el-form :model="queryParams" inline @submit.prevent="getList">
<el-form-item label="用户名">
<el-input v-model="queryParams.username" placeholder="请输入" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getList">查询</el-button>
</el-form-item>
</el-form>
<!-- 用户表格 -->
<el-table :data="tableData" v-loading="loading">
<el-table-column label="用户名" prop="username" />
<el-table-column label="状态" prop="status">
<template #default="scope">{{ scope.row.status === '0' ? '启用' : '禁用' }}</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button type="text" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页组件 -->
<el-pagination
:current-page="queryParams.pageNum"
:page-size="queryParams.pageSize"
:total="total"
@current-change="(val) => { queryParams.pageNum = val; getList(); }"
/>
</template>
步骤3:后端接口适配(联调对齐)
后端需基于 Ruoyi 后端框架(Spring Boot)实现对应接口,确保与前端约定一致:
- 查询用户列表 :接收
pageNum
、pageSize
、username
等参数,返回rows
(列表数据)和total
(总条数); - 删除用户 :接收路径参数
userId
,执行删除逻辑后返回code:200
; - 关键注解:用
@GetMapping
/@PostMapping
/@DeleteMapping
标注请求方法,用@RequestParam
接收 GET 参数,@RequestBody
接收 POST 请求体。
步骤4:联调测试(验证功能)
- 启动服务 :前端
npm run dev
,后端启动 Spring Boot 服务; - 抓包验证 :打开浏览器 F12 → Network 面板,查看接口请求:
- 检查 请求地址 :是否拼接正确(如
http://localhost:8080/dev-api/system/user/list
); - 检查 请求参数:GET 参数是否在 URL 后,POST 参数是否在 Request Body 中;
- 检查 响应数据 :是否符合
code+msg+data
格式,data.rows
是否为数组;
- 检查 请求地址 :是否拼接正确(如
- 功能验证:测试"查询、新增、删除"是否正常(如删除后表格数据刷新,提示"删除成功")。
三、实战2:角色管理模块联调(重点:参数嵌套场景)
角色管理模块涉及"角色绑定权限"的嵌套参数(如 roleId
+ menuIds
数组),需处理"前端参数格式"与"后端接收方式"的对齐问题。
核心场景:角色绑定权限(前端传数组参数)
- 前端接口封装 (
src/api/system/role.js
):
javascript
// 角色绑定权限(POST 请求,参数:roleId + menuIds 数组)
export function assignRoleMenu(data) {
return request({
url: '/system/role/assignMenu',
method: 'post',
data: data // data 格式:{ roleId: 1, menuIds: [1,2,3] }
});
}
- 前端页面调用:
vue
<script setup>
import { reactive } from 'vue';
import { assignRoleMenu } from '@/api/system/role';
// 嵌套参数:roleId + menuIds 数组
const form = reactive({
roleId: 1, // 当前角色ID
menuIds: [] // 选中的菜单ID数组(如 [1,2,3])
});
// 提交绑定权限
const handleSubmit = () => {
assignRoleMenu(form).then(() => {
ElMessage.success('权限绑定成功');
});
};
</script>
- 后端接口接收 (关键:用
@RequestBody
接收嵌套对象):
java
@RestController
@RequestMapping("/system/role")
public class SysRoleController {
// 角色绑定权限接口
@PostMapping("/assignMenu")
public AjaxResult assignRoleMenu(@RequestBody SysRoleMenuDTO roleMenuDTO) {
// roleMenuDTO 包含 roleId(Long 类型)和 menuIds(Long[] 数组)
return AjaxResult.success(roleService.assignRoleMenu(roleMenuDTO));
}
}
// 数据传输对象(DTO):与前端参数名完全对齐
@Data
public class SysRoleMenuDTO {
private Long roleId;
private Long[] menuIds; // 接收前端 menuIds 数组
}
四、联调常见问题与解决方案(实战痛点)
联调中最容易遇到 跨域、Token 过期、参数不匹配、接口报错 四类问题,以下是针对性解决方案:
问题1:跨域错误(Access to XMLHttpRequest at ... from origin ... has been blocked)
原因:
浏览器的"同源策略"限制------前端服务(如 http://localhost:80
)与后端服务(如 http://localhost:8080
)的"协议、域名、端口"不同,直接请求会被拦截。
解决方案(2种方式,优先选方式1):
-
前端 Vite 代理(推荐,开发环境用) :
在
vite.config.js
中配置代理,将前端dev-api
前缀的请求转发到后端地址:javascriptexport default defineConfig({ server: { proxy: { '/dev-api': { // 前端请求前缀 target: 'http://localhost:8080', // 后端真实地址 changeOrigin: true, // 允许跨域 rewrite: (path) => path.replace(/^\/dev-api/, '') // 去掉前缀(关键) } } } });
配置后,前端请求
http://localhost:80/dev-api/system/user/list
会被转发为http://localhost:8080/system/user/list
,避免跨域。 -
后端 CORS 配置(生产环境用) :
后端通过 Spring 配置允许跨域,在 Ruoyi 后端
config/CorsConfig.java
中添加:java@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") // 所有接口 .allowedOrigins("*") // 允许所有前端地址(生产环境需指定具体域名) .allowedMethods("GET", "POST", "PUT", "DELETE") // 允许的请求方法 .allowedHeaders("*") // 允许的请求头 .maxAge(3600); // 预检请求有效期 } }
问题2:Token 过期/未登录(响应 code:401
,msg:"未登录或登录已过期")
原因:
- 前端未传 Token(登录后未存储 Token 到 Pinia);
- Token 已过期(后端 JWT 过期时间通常为 2 小时)。
解决方案:
-
确保登录后存储 Token :
Ruoyi 登录页面(
src/views/login/index.vue
)已封装逻辑,登录成功后会将 Token 存储到 Pinia 的userStore
:javascript// 登录成功后存储 Token const userStore = useUserStore(); userStore.setToken(res.data.token); // 调用 Pinia 方法存储
-
确保请求自动携带 Token :
Ruoyi
request.js
已封装请求拦截器,自动从 Pinia 取 Token 并添加到请求头:javascript// 请求拦截器 service.interceptors.request.use(config => { const userStore = useUserStore(); if (userStore.token) { // 添加 Token 到 Authorization 头(后端需按此头解析) config.headers['Authorization'] = 'Bearer ' + userStore.token; } return config; });
-
Token 过期处理 :
在
request.js
响应拦截器中添加"Token 过期跳转登录页"逻辑:javascript// 响应拦截器 service.interceptors.response.use( response => { ... }, error => { // 若响应码为 401,说明 Token 过期 if (error.response && error.response.status === 401) { const userStore = useUserStore(); userStore.logout(); // 清空 Token window.location.href = '/login'; // 跳转到登录页 } return Promise.reject(error); } );
问题3:参数不匹配(后端报"参数缺失"或"类型转换错误")
常见场景与解决:
场景 | 原因 | 解决方案 |
---|---|---|
后端报"Required Integer parameter 'pageNum' is not present" | 前端 GET 请求参数名与后端 @RequestParam 不一致(如前端写 pageNum ,后端写 pageNo ) |
1. 前端修改 queryParams 中的参数名,与后端对齐; 2. 后端在 @RequestParam 中指定别名,如 @RequestParam("pageNum") Integer pageNo |
后端报"JSON parse error: Cannot deserialize value of type java.lang.Long from String" |
前端传的参数类型与后端不一致(如前端传字符串 userId: "1" ,后端接收 Long userId ) |
1. 前端确保参数类型正确(如 userId: 1 ,不用引号); 2. 后端用 @JsonFormat 兼容类型,如 @JsonFormat(shape = JsonFormat.Shape.STRING) private Long userId |
后端接收数组参数为 null (如 menuIds 数组) |
前端传参格式错误(如传 menuIds: "1,2,3" 字符串,后端接收 Long[] menuIds ) |
前端确保传数组格式:menuIds: [1,2,3] ,且后端用 @RequestBody 接收 |
问题4:接口报错(后端报 code:500
,前端提示"服务器内部错误")
排查步骤:
- 查看后端日志:优先看后端控制台输出,定位错误原因(如 SQL 语法错误、空指针异常);
- 抓包看请求参数:在浏览器 Network 面板查看"Request Payload",确认参数是否正确传递;
- 用 Postman 测试 :直接用 Postman 调用后端接口(如
http://localhost:8080/system/user/list
),若 Postman 也报错,说明是后端接口问题;若 Postman 正常,说明是前端传参或请求方式问题。
五、联调总结:高效协作的"3个技巧"
- 优先用接口文档对齐 :提前用 Swagger(Ruoyi 后端默认集成)生成接口文档(访问
http://localhost:8080/doc.html
),前端按文档封装接口,避免口头约定; - 善用抓包工具:浏览器 F12 Network 面板是联调"神器",能快速定位"请求地址、参数、响应"是否正常;
- 先定位问题归属:遇到错误时,先判断是"前端问题"(如参数没传对、请求没发出去)还是"后端问题",甚至可以详细展开。