fix(aiot): 修复 WVP 认证机制,实现自动登录和 token 管理
- WVP 使用独立 JWT 认证(access-token 头),与芋道 Authorization Bearer 不同
- 实现 WVP 自动登录:首次请求时自动调用 /api/user/login 获取 token
- 缓存 token 防止重复登录,401 时自动续期
- 响应拦截器自动解包 WVP {code:0, data:...} 格式
- Vite 代理新增 /aiot/device/user 和 /aiot/device/server 路由规则
- 移除已废弃的 aiot/video 代理规则
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,34 +1,109 @@
|
|||||||
/**
|
/**
|
||||||
* WVP 视频平台专用请求客户端
|
* WVP 视频平台专用请求客户端
|
||||||
*
|
*
|
||||||
* WVP 返回原始 JSON(非芋道的 {code:0, data:...} 格式),
|
* WVP 使用独立的 JWT 认证系统(access-token 头),
|
||||||
* 需要跳过芋道默认的响应拦截器。
|
* 与芋道前端的 Authorization Bearer 认证不同。
|
||||||
|
* 本模块实现自动登录 WVP、缓存 token、401 自动续期。
|
||||||
*/
|
*/
|
||||||
import { useAppConfig } from '@vben/hooks';
|
import { useAppConfig } from '@vben/hooks';
|
||||||
import { preferences } from '@vben/preferences';
|
import { preferences } from '@vben/preferences';
|
||||||
import { RequestClient } from '@vben/request';
|
import { RequestClient } from '@vben/request';
|
||||||
import { useAccessStore } from '@vben/stores';
|
|
||||||
|
|
||||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||||
|
|
||||||
function createWvpRequestClient(baseURL: string) {
|
// ==================== WVP Token 管理 ====================
|
||||||
const client = new RequestClient({
|
|
||||||
baseURL,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 请求头:携带 token 用于 Vite 代理鉴权透传
|
/** WVP 默认账号(开发环境使用,生产环境应通过环境变量配置) */
|
||||||
|
const WVP_USERNAME = 'admin';
|
||||||
|
const WVP_PASSWORD_MD5 = '21232f297a57a5a743894a0e4a801fc3'; // admin 的 MD5
|
||||||
|
|
||||||
|
let wvpAccessToken: null | string = null;
|
||||||
|
let tokenPromise: null | Promise<string> = null;
|
||||||
|
|
||||||
|
/** 登录 WVP 获取 access-token */
|
||||||
|
async function loginToWvp(): Promise<string> {
|
||||||
|
const url =
|
||||||
|
`${apiURL}/aiot/device/user/login` +
|
||||||
|
`?username=${encodeURIComponent(WVP_USERNAME)}` +
|
||||||
|
`&password=${encodeURIComponent(WVP_PASSWORD_MD5)}`;
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (json.code !== 0 || !json.data?.accessToken) {
|
||||||
|
throw new Error(json.msg || 'WVP 登录失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
wvpAccessToken = json.data.accessToken;
|
||||||
|
return wvpAccessToken!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取有效的 WVP token(自动登录,防止并发重复登录) */
|
||||||
|
export async function getWvpToken(): Promise<string> {
|
||||||
|
if (wvpAccessToken) {
|
||||||
|
return wvpAccessToken;
|
||||||
|
}
|
||||||
|
if (!tokenPromise) {
|
||||||
|
tokenPromise = loginToWvp().finally(() => {
|
||||||
|
tokenPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tokenPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除缓存的 token(用于 401 后重新登录) */
|
||||||
|
function clearWvpToken() {
|
||||||
|
wvpAccessToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 请求客户端 ====================
|
||||||
|
|
||||||
|
function createWvpRequestClient(baseURL: string) {
|
||||||
|
const client = new RequestClient({ baseURL });
|
||||||
|
|
||||||
|
// 请求拦截器:注入 WVP access-token
|
||||||
client.addRequestInterceptor({
|
client.addRequestInterceptor({
|
||||||
fulfilled: async (config) => {
|
fulfilled: async (config) => {
|
||||||
const accessStore = useAccessStore();
|
const token = await getWvpToken();
|
||||||
const token = accessStore.accessToken;
|
config.headers['access-token'] = token;
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
config.headers['Accept-Language'] = preferences.app.locale;
|
config.headers['Accept-Language'] = preferences.app.locale;
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 响应拦截器:处理 WVP 响应格式 + 401 自动续期
|
||||||
|
client.addResponseInterceptor({
|
||||||
|
fulfilled: (response) => {
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// WVP 标准格式 { code: 0, msg: "成功", data: ... }
|
||||||
|
if (data && typeof data === 'object' && 'code' in data) {
|
||||||
|
if (data.code === 0) {
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(data.msg || '请求失败'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非标准格式直接返回
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
rejected: async (error) => {
|
||||||
|
// 401 → 清除 token 并重试一次
|
||||||
|
if (error?.response?.status === 401 && error.config) {
|
||||||
|
clearWvpToken();
|
||||||
|
try {
|
||||||
|
const token = await getWvpToken();
|
||||||
|
error.config.headers['access-token'] = token;
|
||||||
|
const response = await client.instance.request(error.config);
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return Promise.reject(new Error('WVP 认证失败,请检查服务状态'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export default defineConfig(async () => {
|
|||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
// ==================== AIoT 统一路由 ====================
|
// ==================== AIoT 统一路由 ====================
|
||||||
|
|
||||||
// aiot/alarm, aiot/edge -> 告警服务 :8000(直通)
|
// aiot/alarm, aiot/edge -> 告警服务 :8000(直通)
|
||||||
'/admin-api/aiot/alarm': {
|
'/admin-api/aiot/alarm': {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
@@ -17,36 +18,44 @@ export default defineConfig(async () => {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
target: 'http://127.0.0.1:8000',
|
target: 'http://127.0.0.1:8000',
|
||||||
},
|
},
|
||||||
// aiot/device/proxy -> WVP :18080(rewrite: /admin-api/aiot/device/proxy -> /api/proxy)
|
|
||||||
// 摄像头拉流代理接口在 /api/proxy 下,需单独匹配
|
// aiot/device/* -> WVP :18080(按子路径分别 rewrite)
|
||||||
|
// 注意:更具体的路径必须写在通配路径前面
|
||||||
|
|
||||||
|
// 摄像头拉流代理: /admin-api/aiot/device/proxy -> /api/proxy
|
||||||
'/admin-api/aiot/device/proxy': {
|
'/admin-api/aiot/device/proxy': {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
target: 'http://127.0.0.1:18080',
|
target: 'http://127.0.0.1:18080',
|
||||||
rewrite: (path: string) =>
|
rewrite: (path: string) =>
|
||||||
path.replace('/admin-api/aiot/device/proxy', '/api/proxy'),
|
path.replace('/admin-api/aiot/device/proxy', '/api/proxy'),
|
||||||
},
|
},
|
||||||
// aiot/device/server -> WVP :18080(rewrite: -> /api/server/media_server)
|
// WVP 用户认证: /admin-api/aiot/device/user -> /api/user
|
||||||
|
'/admin-api/aiot/device/user': {
|
||||||
|
changeOrigin: true,
|
||||||
|
target: 'http://127.0.0.1:18080',
|
||||||
|
rewrite: (path: string) =>
|
||||||
|
path.replace('/admin-api/aiot/device/user', '/api/user'),
|
||||||
|
},
|
||||||
|
// 媒体服务器: /admin-api/aiot/device/server -> /api/server/media_server
|
||||||
'/admin-api/aiot/device/server': {
|
'/admin-api/aiot/device/server': {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
target: 'http://127.0.0.1:18080',
|
target: 'http://127.0.0.1:18080',
|
||||||
rewrite: (path: string) =>
|
rewrite: (path: string) =>
|
||||||
path.replace('/admin-api/aiot/device/server', '/api/server/media_server'),
|
path.replace(
|
||||||
|
'/admin-api/aiot/device/server',
|
||||||
|
'/api/server/media_server',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
// aiot/device -> WVP :18080(rewrite: /admin-api/aiot/device -> /api/ai)
|
// ROI/算法/配置等: /admin-api/aiot/device -> /api/ai(通配,放最后)
|
||||||
'/admin-api/aiot/device': {
|
'/admin-api/aiot/device': {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
target: 'http://127.0.0.1:18080',
|
target: 'http://127.0.0.1:18080',
|
||||||
rewrite: (path: string) =>
|
rewrite: (path: string) =>
|
||||||
path.replace('/admin-api/aiot/device', '/api/ai'),
|
path.replace('/admin-api/aiot/device', '/api/ai'),
|
||||||
},
|
},
|
||||||
// aiot/video -> WVP :18080(rewrite: /admin-api/aiot/video -> /api)
|
|
||||||
'/admin-api/aiot/video': {
|
|
||||||
changeOrigin: true,
|
|
||||||
target: 'http://127.0.0.1:18080',
|
|
||||||
rewrite: (path: string) =>
|
|
||||||
path.replace('/admin-api/aiot/video', '/api'),
|
|
||||||
},
|
|
||||||
// ==================== 系统基础路由 ====================
|
// ==================== 系统基础路由 ====================
|
||||||
|
|
||||||
// 认证相关接口 -> 告警平台(测试阶段提供模拟认证)
|
// 认证相关接口 -> 告警平台(测试阶段提供模拟认证)
|
||||||
'/admin-api/system/auth': {
|
'/admin-api/system/auth': {
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user