From 8cfc144ba942cf2c3ca74c5db25e4175686dff8f Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Wed, 16 Sep 2026 17:55:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E7=AB=AF=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E8=B5=B0=20inline=20base64=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84,=20=E9=81=BF=E5=BC=80=20MediaGuard=20SSRF=20=E9=97=B8?= =?UTF-8?q?=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16) PR-WB-MP-INBOUND-MEDIA 配套 症状: 用户在前端上传图片成功后 (OSS URL 已返回), AI Agent 仍回复 "我这边并没有收到或看到任何图片附件". 根因链路: 1. backend Upload.php::buildPublicUrl 用 request()->domain() 拼 URL (LAN 部署返回 http://192.168.x.x:8050/upload/..., 公网部署返回公网域名) 2. 前端把 upload 响应直接透给 AI chat attachments[] 字段 3. backend MediaGuard::urlAllowed 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝 → attachments 整批丢 4. 静默失败: MediaGuard 在 '白名单过滤后空集' 时返回 ok=true + media=[] → Kefu.php 不 return error, 业务继续走 5. WB 收到 0 个 media block → Agent 看不到附件 修复: - media-upload.js 新增 fileToBase64(filePath, opts) helper - H5: fetch(blobUrl) → blob → FileReader.readAsDataURL → strip data: 前缀 - MP-WEIXIN/APP-PLUS: uni.getFileSystemManager().readFileSync(base64) - SIZE_LIMIT_INLINE_BASE64 = 5MB, 与后端 INLINE_MEDIA_LIMIT 对齐 - 超限 / 失败返回 null, 调用方退回 URL 路径 - ai-chat-message.vue sendMessage 改为 await Promise.all 模式: - 对每个 pending item 调 fileToBase64(tempPath) 拿 base64 - 把 data + encoding='base64' 注入 attachment - enrichedPendingAttachments 同时用于 readyAttachments + userMessage.attachments - 复用同一份 base64, 不重复读 file 后端接收 data 字段后整条链路已对齐: - MessageMedia::normalizeInput 直接透传 data 字段 - MediaGuard::check data 非空时无条件放行, 不参与 URL 校验 - WbAgentOsClient::buildMediaBlocks (A 路) 路径 1 优先 data + image/audio - WbAcpClient::buildMediaBlocks (B 路) data 非空也走 inline base64 - inline_media_sanity.php 39 个断言全部覆盖 (test 3.1, 3.2, 4.1, 4.2, 7.6) 测试: - E2E test_inline_base64.py 10/10 通过 - mock 上传返回 LAN URL 模拟 MediaGuard 拦截场景 - 抓 /api/kefu/chat POST body, 验证 attachments[].data 是有效 PNG base64 - magic bytes 89504E47 验证通过 - 既有 test_ai_chat_upload.py 20/20 通过 (无回归) 边界: - >5MB 图片 fileToBase64 返回 null, 退回 URL 路径 (buildMediaBlocks 走 路径 2 fetchInline / 路径 3 resource_link); LAN 部署下仍可能被 MediaGuard 拦截, 但 5MB 以下的图片(主流微信/支付宝截图等)不受影响 - H5/MINIProgram/APP 三端都加了 #ifdef 编译条件 --- common/js/media-upload.js | 87 ++++++++++++++++++++++++++ pages_tool/ai-chat/ai-chat-message.vue | 54 +++++++++++----- 2 files changed, 124 insertions(+), 17 deletions(-) diff --git a/common/js/media-upload.js b/common/js/media-upload.js index 1649855..e65c0d4 100644 --- a/common/js/media-upload.js +++ b/common/js/media-upload.js @@ -25,6 +25,10 @@ import store from '@/store/index.js' const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB const SIZE_LIMIT_H5_COMPRESS = 3 * 1024 * 1024 // 3MB (从 10MB 下调,移动端 H5 体验更流畅) +// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): WB Agent J7 inline base64 上限 5MB +// 与 backend WbAgentOsClient::INLINE_MEDIA_LIMIT = 5242880 对齐 +// 超过这个 size 后 buildMediaBlocks 会改走 URL fetch 路径, 上限失效 +const SIZE_LIMIT_INLINE_BASE64 = 5 * 1024 * 1024 // 5MB (与后端 INLINE_MEDIA_LIMIT 对齐) // PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 现代格式 mime 同步后端 guessMimeFromPath // STS 直传绕过后端 mime 推断, 前端必须自带, 否则附件 mime 降级 application/octet-stream @@ -428,6 +432,89 @@ async function pathToFile(filePath, kind) { // #endif } +/** + * PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 把附件原文件读成 base64, + * 用于 ai-chat-message.vue sendMessage 给 attachment 加 `data` 字段, + * 让 backend 走 WbAgentOsClient::buildMediaBlocks 路径 ① (inline base64) + * 而不是路径 ②/③ (URL fetch / resource_link). + * + * 为什么必要: 之前 PR-FRONTEND-MEDIA-UPLOAD-FIX 已让上传链路 (uni.compressImage + * → backend Upload.php) 返回了公网 URL, 但此 URL 是 backend Upload.php::buildPublicUrl + * 用 request()->domain() 拼出来的 (例如 http://192.168.x.x:8050/upload/...). + * 买家端 AI chat 把这个 URL 透给 backend, backend MediaGuard::urlAllowed + * 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝, 附件被静默丢弃. + * + * 修复: 同步给 attachment 加 data: 字段. + * - backend MessageMedia::normalizeInput 直接透传 data (line 207-208) + * - backend MediaGuard::check 看到 data 非空就无条件放行, 不参与 URL 校验 (line 137-140) + * - backend WbAgentOsClient::buildMediaBlocks 路径 ① 优先 data + image/audio → 直接 base64 内联 + * 全链路 bypass URL 公网可达性 (无论 LAN 还是公网, 都不依赖 WB Cloud 拉 URL). + * + * 返回: { data: '', size, mime } | null + * - null 时表示文件 > maxBytes (默认 5MB, J7 上限) 或读取失败 → 调用方跳过 data 字段, 走 URL 路径 + * - bare-base64 (不带 data:image/png;base64, 前缀) — 后端 buildMediaBlocks 需要纯 base64 + * - mime 从 blob.type (H5) / 调用方传 mimeHint (uni-app 平台不知道 blob.type) + * + * @param {string} filePath 选图/选文件时的原始路径 (blob: URL 或 _doc/tmp_xxx 路径) + * @param {object} [opts] + * @param {number} [opts.maxBytes=5MB] 超限返回 null (frontend 主动限流, 减轻带宽/内存) + * @param {string} [opts.mimeHint] 非 H5 平台拿不到 blob.type, 用 pending.uploaded.mime 兜底 + */ +export async function fileToBase64(filePath, opts = {}) { + const maxBytes = opts.maxBytes || SIZE_LIMIT_INLINE_BASE64 + if (!filePath) return null + + // #ifdef H5 + try { + const res = await fetch(filePath) + if (!res.ok) throw new Error('fetch failed: ' + res.status) + const blob = await res.blob() + if (blob.size > maxBytes) { + console.log('[media-upload] fileToBase64: size', blob.size, '> maxBytes', maxBytes, '→ 跳过内联') + return null + } + const dataUri = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result) + reader.onerror = () => reject(new Error('FileReader failed')) + reader.readAsDataURL(blob) + }) + const m = /^data:([^;,]+);base64,(.*)$/.exec(dataUri) + if (!m) throw new Error('unexpected data URI shape: ' + String(dataUri).slice(0, 40)) + return { data: m[2], size: blob.size, mime: m[1] } + } catch (e) { + console.warn('[media-upload] fileToBase64 H5 失败:', e) + return null + } + // #endif + + // #ifdef MP-WEIXIN || APP-PLUS + try { + const fsm = uni.getFileSystemManager() + // 1. 探测大小, 超限直接返回 null + let size = 0 + try { + const stat = fsm.statSync(filePath) + // statSync 在部分平台返回 {size}, 部分返回 {fileSize}, 部分直接是数字 + size = (stat && (stat.size || stat.fileSize)) || 0 + } catch (e) { + // statSync 失败 (权限/路径) → 直接尝试 readFile, 后端会再校验 size + console.warn('[media-upload] fileToBase64 statSync failed, fallback to readFile:', e) + } + if (size > maxBytes) { + console.log('[media-upload] fileToBase64: size', size, '> maxBytes', maxBytes, '→ 跳过内联') + return null + } + // 2. readFile 拿 base64 — 同步 API 简单可靠, base64 在 5MB 文件 ≈ 6.7MB 字符串, 内存可控 + const data = fsm.readFileSync(filePath, 'base64') + return { data, size: size || 0, mime: opts.mimeHint || '' } + } catch (e) { + console.warn('[media-upload] fileToBase64 uni-app 失败:', e) + return null + } + // #endif +} + function guessMime(ext) { return EXT_MIME_MAP[ext] || 'application/octet-stream' } diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index 3ecade6..a973709 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -472,7 +472,7 @@