From 2a7fec30a94527c1af9fd6b7defe2a3fb470d7d1 Mon Sep 17 00:00:00 2001 From: lzh Date: Thu, 23 Apr 2026 19:33:37 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20video=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E8=B5=B0=E8=8A=8B=E9=81=93=E7=BD=91=E5=85=B3=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E9=89=B4=E6=9D=83=EF=BC=8C=E7=A7=BB=E9=99=A4=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=20WVP=20JWT=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api/video/request.ts: 删除自实现的 WVP access-token 登录/缓存/401 续期逻辑, 直接 re-export requestClient(yudao Authorization: Bearer), wvpRequestClient 名字仅作过渡期别名,TODO 标记后续统一重命名。 - api/video/device/index.ts: 路径从 /video/device/{proxy,user,server,...} 迁移到 后端 @RequestMapping 对齐的 /video/{proxy,ai/*,server/media_server}; 删除 getAlertImageUrl(老 WVP 时代产物),删除截图 URL 里的 ?access-token。 - api/video/edge/index.ts: /video/device/device/* -> /video/ai/device/*(对齐 AiEdgeDeviceController)。 - views/video/device/camera: HEAD 探活 URL 同步,补注释说明后端需对 /video/ai/roi/snap/image permitAll 或依赖 session cookie。 - vite.config: 删除 /admin-api/video/device/* 按子路径 rewrite 到 WVP:18080 的规则, 统一走 /admin-api 到芋道网关 48080;去掉 VITE_WVP_USERNAME/PASSWORD_MD5 依赖 (安全改进:凭据不再打进客户端 bundle)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web-antd/src/api/video/device/index.ts | 106 +++++++-------- apps/web-antd/src/api/video/edge/index.ts | 11 +- apps/web-antd/src/api/video/request.ts | 122 ++---------------- .../src/views/video/device/camera/index.vue | 7 +- apps/web-antd/vite.config.mts | 38 +----- 5 files changed, 68 insertions(+), 216 deletions(-) diff --git a/apps/web-antd/src/api/video/device/index.ts b/apps/web-antd/src/api/video/device/index.ts index 7882adc45..336875415 100644 --- a/apps/web-antd/src/api/video/device/index.ts +++ b/apps/web-antd/src/api/video/device/index.ts @@ -1,16 +1,18 @@ /** * Video 模块 - 设备管理 API * - * 所有请求通过 wvpRequestClient 发送到 WVP 视频平台后端, - * 由 Vite 代理按路径分发并 rewrite: - * /video/device/proxy/* → WVP /api/proxy/* - * /video/device/user/* → WVP /api/user/* - * /video/device/server/* → WVP /api/server/media_server/* - * /video/device/* → WVP /api/ai/* + * 请求走统一的 requestClient(yudao 框架鉴权:Authorization: Bearer)。 + * video 微服务已接入 gateway,路径对齐后端 @RequestMapping: + * /video/proxy/* 拉流代理(StreamProxyController) + * /video/server/* 媒体服务器(ServerController) + * /video/ai/camera/* 摄像头 + * /video/ai/roi/* ROI 及算法绑定 + * /video/ai/algorithm/* 算法 + * /video/ai/config/* 配置推送 */ import { useAppConfig } from '@vben/hooks'; -import { getWvpToken, wvpRequestClient } from '#/api/video/request'; +import { wvpRequestClient } from '#/api/video/request'; const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); @@ -106,19 +108,19 @@ export function getCameraList(params: { pulling?: boolean; }) { return wvpRequestClient.get>( - '/video/device/proxy/list', + '/video/proxy/list', { params }, ); } /** 新增摄像头 */ export function addCamera(data: Partial) { - return wvpRequestClient.post('/video/device/proxy/add', data); + return wvpRequestClient.post('/video/proxy/add', data); } /** 编辑摄像头 */ export function updateCamera(data: Partial) { - return wvpRequestClient.post('/video/device/proxy/update', data); + return wvpRequestClient.post('/video/proxy/update', data); } /** 保存摄像头(新增/编辑,有 id 为编辑) */ @@ -128,25 +130,25 @@ export function saveCamera(data: Partial) { /** 删除摄像头 */ export function deleteCamera(id: number) { - return wvpRequestClient.delete('/video/device/proxy/delete', { + return wvpRequestClient.delete('/video/proxy/delete', { params: { id }, }); } /** 开始拉流 */ export function startCamera(id: number) { - return wvpRequestClient.get('/video/device/proxy/start', { params: { id } }); + return wvpRequestClient.get('/video/proxy/start', { params: { id } }); } /** 停止拉流 */ export function stopCamera(id: number) { - return wvpRequestClient.get('/video/device/proxy/stop', { params: { id } }); + return wvpRequestClient.get('/video/proxy/stop', { params: { id } }); } /** 在线媒体服务器列表 */ export function getMediaServerList() { return wvpRequestClient.get( - '/video/device/server/online/list', + '/video/server/media_server/online/list', ); } @@ -154,7 +156,7 @@ export function getMediaServerList() { export function getCameraOptions() { return wvpRequestClient.get< { cameraCode: string; cameraName: string }[] - >('/video/device/camera/options'); + >('/video/ai/camera/options'); } // ==================== ROI 区域管理 ==================== @@ -168,68 +170,71 @@ export function getRoiList(params: { query?: string; }) { return wvpRequestClient.get>( - '/video/device/roi/list', + '/video/ai/roi/list', { params }, ); } /** ROI 详情(含算法绑定) */ export function getRoiDetail(id: number) { - return wvpRequestClient.get(`/video/device/roi/${id}`); + return wvpRequestClient.get(`/video/ai/roi/${id}`); } /** 某摄像头的所有 ROI */ export function getRoiByCameraId(cameraId: string) { return wvpRequestClient.get( - '/video/device/roi/channel', + '/video/ai/roi/channel', { params: { cameraId } }, ); } /** 保存 ROI(新增/编辑) */ export function saveRoi(data: Partial) { - return wvpRequestClient.post('/video/device/roi/save', data); + return wvpRequestClient.post('/video/ai/roi/save', data); } /** 删除 ROI */ export function deleteRoi(roiId: string) { - return wvpRequestClient.delete(`/video/device/roi/delete/${roiId}`); + return wvpRequestClient.delete(`/video/ai/roi/delete/${roiId}`); } /** * 获取摄像头截图 URL * - * 非 force 模式:直接返回 /snap/image 代理 URL(无时间戳,浏览器自动缓存) - * force 模式:先触发边缘端截图,再返回带时间戳的代理 URL 破缓存 + * 非 force:返回代理 URL,浏览器按 URL 缓存。 + * force:先触发边缘端截图,再返回加时间戳的代理 URL 破缓存。 + * + * 图片鉴权假设(重要): + * - 老 WVP 的 ?access-token=xxx 已移除。 + * - 返回的 URL 会被 使用,浏览器不会带上 Authorization 头; + * 该端点必须在后端满足以下任一条件之一才能正常显示: + * (a) SecurityConfig 里对 /video/ai/roi/snap/image 放行(permitAll); + * (b) 依赖 yudao session cookie(非 JWT)做鉴权; + * (c) 后端返回预签名的 OSS URL 并由调用方改造成 302 重定向。 + * - 任何 401/403 不会在这里抛出,调用方需要处理 以显示占位图。 */ export async function getSnapUrl(cameraCode: string, force = false): Promise { - const token = await getWvpToken(); if (force) { // 强制刷新:先触发边缘端截图(等待完成) try { - await wvpRequestClient.get('/video/device/roi/snap', { + await wvpRequestClient.get('/video/ai/roi/snap', { params: { cameraCode, force: true }, }); } catch (err) { - // 截图请求可能超时,但 COS 上可能已有图片,继续返回代理 URL - // 留一条日志便于图片加载 401/404 时回溯根因 + // 截图请求可能超时,但 OSS 上可能已有图片,继续返回代理 URL if (import.meta.env.DEV) { - console.debug('[WVP] force snap 触发失败,继续返回已有代理 URL', err); + console.debug('[video] force snap 触发失败,继续返回已有代理 URL', err); } } - // 加时间戳破浏览器缓存 return ( - `${apiURL}/video/device/roi/snap/image` + + `${apiURL}/video/ai/roi/snap/image` + `?cameraCode=${encodeURIComponent(cameraCode)}` + - `&access-token=${encodeURIComponent(token)}` + `&t=${Date.now()}` ); } - // 非 force:使用代理端点,不加时间戳,浏览器自动缓存 return ( - `${apiURL}/video/device/roi/snap/image` + - `?cameraCode=${encodeURIComponent(cameraCode)}` + - `&access-token=${encodeURIComponent(token)}` + `${apiURL}/video/ai/roi/snap/image` + + `?cameraCode=${encodeURIComponent(cameraCode)}` ); } @@ -238,7 +243,7 @@ export async function getSnapUrl(cameraCode: string, force = false): Promise( - '/video/device/algorithm/list', + '/video/ai/algorithm/list', { params: deviceId ? { deviceId } : {} }, ); } @@ -246,7 +251,7 @@ export function getAlgorithmList(deviceId?: string) { /** 保存算法全局参数 */ export function saveAlgoGlobalParams(algoCode: string, globalParams: string) { return wvpRequestClient.post( - `/video/device/algorithm/global-params/${algoCode}`, + `/video/ai/algorithm/global-params/${algoCode}`, { globalParams }, ); } @@ -254,7 +259,7 @@ export function saveAlgoGlobalParams(algoCode: string, globalParams: string) { /** 批量更新设备算法参数 */ export function updateDeviceAlgoParams(deviceId: string, algoCode: string, params: string) { return wvpRequestClient.post( - `/video/device/algorithm/device-binds/${deviceId}/${algoCode}`, + `/video/ai/algorithm/device-binds/${deviceId}/${algoCode}`, { params }, ); } @@ -263,12 +268,12 @@ export function updateDeviceAlgoParams(deviceId: string, algoCode: string, param /** 绑定算法到 ROI */ export function bindAlgo(data: { roiId: string; algoCode: string }) { - return wvpRequestClient.post('/video/device/roi/bindAlgo', data); + return wvpRequestClient.post('/video/ai/roi/bindAlgo', data); } /** 解绑算法 */ export function unbindAlgo(bindId: string) { - return wvpRequestClient.delete('/video/device/roi/unbindAlgo', { + return wvpRequestClient.delete('/video/ai/roi/unbindAlgo', { params: { bindId }, }); } @@ -279,14 +284,14 @@ export function updateAlgoParams(data: { params?: string; enabled?: number; }) { - return wvpRequestClient.post('/video/device/roi/updateAlgoParams', data); + return wvpRequestClient.post('/video/ai/roi/updateAlgoParams', data); } // ==================== 配置推送 ==================== /** 推送配置到边缘端 */ export function pushConfig(cameraId: string) { - return wvpRequestClient.post('/video/device/config/push', null, { + return wvpRequestClient.post('/video/ai/config/push', null, { params: { cameraId }, }); } @@ -294,30 +299,15 @@ export function pushConfig(cameraId: string) { /** 一次性推送全部配置到本地Edge */ export function pushAllConfig() { return wvpRequestClient.post>( - '/video/device/config/push-all', + '/video/ai/config/push-all', ); } /** 导出摄像头配置 JSON */ export function exportConfig(cameraId: string) { return wvpRequestClient.get>( - '/video/device/config/export', + '/video/ai/config/export', { params: { cameraId } }, ); } -// ==================== 告警图片代理 ==================== - -/** - * 构造告警图片代理 URL(通过 WVP 下载 COS 图片后返回字节流) - * @param imagePath COS 对象路径或完整 URL - */ -export async function getAlertImageUrl(imagePath: string): Promise { - if (!imagePath) return ''; - const token = await getWvpToken(); - return ( - `${apiURL}/video/device/alert/image` + - `?imagePath=${encodeURIComponent(imagePath)}` + - `&access-token=${encodeURIComponent(token)}` - ); -} diff --git a/apps/web-antd/src/api/video/edge/index.ts b/apps/web-antd/src/api/video/edge/index.ts index 83b91602a..c0af08f70 100644 --- a/apps/web-antd/src/api/video/edge/index.ts +++ b/apps/web-antd/src/api/video/edge/index.ts @@ -31,27 +31,26 @@ export namespace VideoEdgeApi { } // ==================== 边缘设备 API ==================== -// 数据源:WVP 数据库 wvp_ai_edge_device 表 -// 路径经 vite proxy: /admin-api/video/device/* → rewrite → /api/ai/* → WVP:18080 +// 对应后端 AiEdgeDeviceController @RequestMapping("/video/ai/device") /** 获取全部边缘设备列表 */ export function getDeviceList() { return wvpRequestClient.get( - '/video/device/device/list', + '/video/ai/device/list', ); } /** 分页查询边缘设备列表 */ export function getDevicePage(params: PageParam) { return wvpRequestClient.get>( - '/video/device/device/page', + '/video/ai/device/page', { params }, ); } /** 获取设备详情 */ export function getDevice(deviceId: string) { - return wvpRequestClient.get('/video/device/device/get', { + return wvpRequestClient.get('/video/ai/device/get', { params: { deviceId }, }); } @@ -59,6 +58,6 @@ export function getDevice(deviceId: string) { /** 获取设备统计 */ export function getDeviceStatistics() { return wvpRequestClient.get( - '/video/device/device/statistics', + '/video/ai/device/statistics', ); } diff --git a/apps/web-antd/src/api/video/request.ts b/apps/web-antd/src/api/video/request.ts index c46fad5f5..8be9ef033 100644 --- a/apps/web-antd/src/api/video/request.ts +++ b/apps/web-antd/src/api/video/request.ts @@ -1,118 +1,12 @@ /** - * WVP 视频平台专用请求客户端 + * Video 模块请求客户端 * - * WVP 使用独立的 JWT 认证系统(access-token 头), - * 与芋道前端的 Authorization Bearer 认证不同。 - * 本模块实现自动登录 WVP、缓存 token、401 自动续期。 - */ -import { useAppConfig } from '@vben/hooks'; -import { preferences } from '@vben/preferences'; -import { RequestClient } from '@vben/request'; - -const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); - -// ==================== WVP Token 管理 ==================== - -/** - * WVP 默认账号 + * 历史:原 WVP 独立服务有自己的 JWT + access-token 认证; + * 已迁移到 viewsh-module-video 微服务下,走框架统一鉴权 + * (yudao Authorization: Bearer,由 #/api/request 的拦截器统一注入)。 * - * 注意:前端任何 WVP 账号/密码都会打进 bundle,对懂行的人等于明文。 - * 长期方案应由后端代理 WVP 鉴权,不要把账号分发到客户端。 - * 作为过渡,至少走环境变量让生产可覆盖。 + * TODO(video-rename): 这是过渡期的命名别名。后续把 `#/api/video/**` 下所有 + * `wvpRequestClient` 的调用点改为直接 import `requestClient`,然后删掉本文件。 + * 保留只是为了降低此次迁移的一次性改动量。 */ -const WVP_USERNAME = import.meta.env.VITE_WVP_USERNAME || 'admin'; -const WVP_PASSWORD_MD5 = - import.meta.env.VITE_WVP_PASSWORD_MD5 || - '21232f297a57a5a743894a0e4a801fc3'; // 'admin' 的 MD5,仅作兜底 - -let wvpAccessToken: null | string = null; -let tokenPromise: null | Promise = null; - -/** 登录 WVP 获取 access-token */ -async function loginToWvp(): Promise { - const url = - `${apiURL}/video/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 { - 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({ - fulfilled: async (config) => { - const token = await getWvpToken(); - config.headers['access-token'] = token; - config.headers['Accept-Language'] = preferences.app.locale; - 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; -} - -export const wvpRequestClient = createWvpRequestClient(apiURL); +export { requestClient as wvpRequestClient } from '#/api/request'; diff --git a/apps/web-antd/src/views/video/device/camera/index.vue b/apps/web-antd/src/views/video/device/camera/index.vue index a67eeaffd..4134ae4b4 100644 --- a/apps/web-antd/src/views/video/device/camera/index.vue +++ b/apps/web-antd/src/views/video/device/camera/index.vue @@ -146,9 +146,12 @@ async function loadCameraStatus() { for (const cam of cameraList.value) { const cameraCode = cam.cameraCode; if (!cameraCode) continue; - // 使用 fetch HEAD 请求检测截图是否可用(/snap/image 已免认证) + // 通过 HEAD 探测截图是否可用。 + // 前置条件:后端对 /video/ai/roi/snap/image 放行(SecurityConfig permitAll) + // 或依赖 session cookie —— fetch 默认不会带 Authorization 头。 + // 401/403 会被视作"离线",与 4xx/5xx 行为一致,对端上逻辑透明。 try { - const url = `${apiURL}/video/device/roi/snap/image?cameraCode=${encodeURIComponent(cameraCode)}`; + const url = `${apiURL}/video/ai/roi/snap/image?cameraCode=${encodeURIComponent(cameraCode)}`; const res = await fetch(url, { method: 'HEAD' }); cameraStatus.value = { ...cameraStatus.value, [cameraCode]: res.ok }; } catch { diff --git a/apps/web-antd/vite.config.mts b/apps/web-antd/vite.config.mts index 280e9fd21..92eff85c4 100644 --- a/apps/web-antd/vite.config.mts +++ b/apps/web-antd/vite.config.mts @@ -34,43 +34,9 @@ export default defineConfig(async () => { target: 'http://127.0.0.1:8000', }, - // video/device/* -> WVP :18080(按子路径分别 rewrite) - // 注意:更具体的路径必须写在通配路径前面 - - // 摄像头拉流代理: /admin-api/video/device/proxy -> /api/proxy - '/admin-api/video/device/proxy': { - changeOrigin: true, - target: 'http://127.0.0.1:18080', - rewrite: (path: string) => - path.replace('/admin-api/video/device/proxy', '/api/proxy'), - }, - // WVP 用户认证: /admin-api/video/device/user -> /api/user - '/admin-api/video/device/user': { - changeOrigin: true, - target: 'http://127.0.0.1:18080', - rewrite: (path: string) => - path.replace('/admin-api/video/device/user', '/api/user'), - }, - // 媒体服务器: /admin-api/video/device/server -> /api/server/media_server - '/admin-api/video/device/server': { - changeOrigin: true, - target: 'http://127.0.0.1:18080', - rewrite: (path: string) => - path.replace( - '/admin-api/video/device/server', - '/api/server/media_server', - ), - }, - // ROI/算法/配置等: /admin-api/video/device -> /api/ai(通配,放最后) - '/admin-api/video/device': { - changeOrigin: true, - target: 'http://127.0.0.1:18080', - rewrite: (path: string) => - path.replace('/admin-api/video/device', '/api/ai'), - }, - // ==================== 芋道主平台 ==================== - // 所有 system/*、infra/* 等基础接口 -> 芋道后端 48080 + // 所有 system/*、infra/*、video/device/* 等业务接口 -> 芋道网关 48080 + // video 微服务已挂到 gateway,不再直连 WVP :18080 '/admin-api': { changeOrigin: true, rewrite: (path) => path.replace(/^\/admin-api/, ''),