/** * 验证「多模态附件」这条链路(`/api/chat` 的 `pictures` / `files` 两个字段)。 * * 历史:2026-09-18 时新接口只收 `{thread_id, question}`,多一个字段 422, * 附件只能**把 OSS 地址拼进 question 文本**(方案②)。 * 2026-09-23 后端支持了 `pictures` / `files`(方案①),改为走请求体字段。 * * **实测到的契约**(2026-09-23,打真实后端): * - 两者都是 **URL 字符串数组**;传对象数组(`[{url,name}]`)会被 `422 invalid_request` 拒掉 * - `pictures` 里的图**由后端自己去取**(必须公网可达)—— * 实测带上 `pictures:[图URL]` 问「这张图片里是什么」,模型确实描述了图的内容 * - 空数组与不传都接受 * * 不变量: * ① **没有附件时请求体逐字节与从前一致**(只有 thread_id + question)—— * 既不多传无意义字段,也让「后端若回滚」只影响带附件的轮次 * ② 有附件时:`question` 仍是**用户原文**(地址不拼进去),附件在 `pictures` / `files` 里 * ③ 图片按 `isImage` 进 `pictures`,其余进 `files`;空 URL 被过滤掉 * * 怎么跑(在项目根目录): * npx esbuild harness/tools/_entry-attachments.ts --bundle --format=esm \ * --outfile=harness/tools/_attachments.mjs --alias:@=./src \ * --define:import.meta.env='{}' --loader:.png=dataurl * # ⚠️ 必须带 --loader:.png=dataurl:协调器 import 了企业微信二维码 * node harness/tools/verify-attachment-question.mjs */ import { readFileSync, readdirSync } from 'node:fs'; const { toChatAttachmentUrls, ApiChatCoordinator, useBusinessAssistantUpload, SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_ATTACHMENT_EXTENSIONS, SUPPORTED_IMAGE_LABEL, SUPPORTED_ATTACHMENT_LABEL, } = await import('./_attachments.mjs'); let pass = 0; let fail = 0; const check = (name, cond, extra = '') => { if (cond) { pass++; console.log(' ok ' + name); } else { fail++; console.log(' FAIL ' + name + ' ' + extra); } }; const IMG1 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/a.png'; const IMG2 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/b.jpg'; const PDF = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/c.pdf'; const DOC = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/d.docx'; const att = (url, isImage) => ({ id: url, name: url.split('/').pop(), size: 1, isImage, url }); const tx = (attachments, files) => ({ files: files ?? attachments.map((a) => a.url), file_pos: attachments.map(() => 0), attachments, }); console.log('\n【1】按类型拆分(纯逻辑)'); { const mixed = toChatAttachmentUrls(tx([att(IMG1, true), att(PDF, false), att(IMG2, true)])); check('图片进 pictures', JSON.stringify(mixed.pictures) === JSON.stringify([IMG1, IMG2]), JSON.stringify(mixed.pictures)); check('非图片进 files', JSON.stringify(mixed.files) === JSON.stringify([PDF]), JSON.stringify(mixed.files)); const onlyImages = toChatAttachmentUrls(tx([att(IMG1, true)])); check('只有图片时 files 为空数组', onlyImages.files.length === 0 && onlyImages.pictures.length === 1); const none = toChatAttachmentUrls(undefined); check('没有附件 → 两个空数组', none.pictures.length === 0 && none.files.length === 0); check('null 也安全', JSON.stringify(toChatAttachmentUrls(null)) === JSON.stringify({ pictures: [], files: [] })); check('空 attachments 数组 → 两个空数组', JSON.stringify(toChatAttachmentUrls(tx([]))) === JSON.stringify({ pictures: [], files: [] })); const dirty = toChatAttachmentUrls(tx([att(' ' + IMG1 + ' ', true), att('', false)])); check('URL 前后空格去掉、空 URL 过滤掉', JSON.stringify(dirty.pictures) === JSON.stringify([IMG1]) && dirty.files.length === 0, JSON.stringify(dirty)); const noAttachments = toChatAttachmentUrls({ files: [PDF, DOC], file_pos: [0, 0] }); check('只有 files(没 attachments)→ 全落 files,地址不丢', JSON.stringify(noAttachments.files) === JSON.stringify([PDF, DOC]), JSON.stringify(noAttachments)); } /* ------------------------------------------------------------------ * * 协调器:真正发出的请求体 * ------------------------------------------------------------------ */ const sse = (events) => events .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`) .join(''); let captured = []; const stubFetch = () => { globalThis.fetch = async (url, init = {}) => { captured.push(init.body ? JSON.parse(String(init.body)) : null); return new Response( new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(sse([['done', { status: 'completed' }]]))); controller.close(); }, }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } } ); }; }; const runTurn = async (question, attachments) => { captured = []; stubFetch(); const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' }); await coordinator.generateAnswer(question, attachments); await new Promise((r) => setTimeout(r, 60)); return captured[0]; }; console.log('\n【2】请求体:没有附件时与从前逐字节一致'); { const body = await runTurn('帮我看看这份材料符合条件吗'); check( '只有 thread_id + question', JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['question', 'thread_id']), JSON.stringify(body) ); check('不该出现空数组字段', !('pictures' in body) && !('files' in body), JSON.stringify(body)); const empty = await runTurn('没有附件', { pictures: [], files: [] }); check('显式传空数组也不发字段', !('pictures' in empty) && !('files' in empty), JSON.stringify(empty)); } console.log('\n【3】请求体:带附件时'); { const Q = '帮我看看这份材料符合条件吗'; const body = await runTurn(Q, { pictures: [IMG1], files: [PDF] }); check('**question 是用户原文**(地址不拼进去)', body.question === Q, JSON.stringify(body.question)); check('pictures 是 URL 字符串数组', JSON.stringify(body.pictures) === JSON.stringify([IMG1]), JSON.stringify(body.pictures)); check('files 是 URL 字符串数组', JSON.stringify(body.files) === JSON.stringify([PDF]), JSON.stringify(body.files)); check('没有多余字段', JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['files', 'pictures', 'question', 'thread_id']), JSON.stringify(Object.keys(body))); check( 'pictures 里**不带**对象结构(后端只认字符串数组,传对象 422)', body.pictures.every((item) => typeof item === 'string'), JSON.stringify(body.pictures) ); const multi = await runTurn(Q, { pictures: [IMG1, IMG2], files: [PDF, DOC] }); check('支持多个图片与文件', multi.pictures.length === 2 && multi.files.length === 2, JSON.stringify(multi)); const dirty = await runTurn(Q, { pictures: ['', ' '], files: [PDF] }); check('空 URL 被清掉(不会发出 [""])', !('pictures' in dirty) && JSON.stringify(dirty.files) === JSON.stringify([PDF]), JSON.stringify(dirty)); } console.log('\n【4】旧方案已停用(源码级)'); { const files = []; const walk = (dir) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = `${dir}/${entry.name}`; if (entry.isDirectory()) walk(full); else if (/\.(ts|vue)$/.test(entry.name)) files.push(full); } }; walk('src'); const callers = files.filter((file) => { const text = readFileSync(file, 'utf8'); // 跳过它自己的定义(已在 shared.ts 里注释掉) return text.split('\n').some((line) => !line.trim().startsWith('//') && line.includes('buildQuestionWithAttachments')); }); check('没有活跃调用点(地址不再拼进 question)', callers.length === 0, callers.join(', ')); } console.log('\n【5】支持格式的白名单(与产品提示同一口径)'); { check( '图片:JPEG / PNG / GIF / WebP', JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS) === JSON.stringify(['jpg', 'jpeg', 'png', 'gif', 'webp']), JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS) ); check( '文件:Excel / DOCX / PDF', JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS) === JSON.stringify(['xls', 'xlsx', 'docx', 'pdf']), JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS) ); // 用户 2026-09-23 指出的缺口:提示里写了 GIF,白名单里原来没有 check('**GIF 确实在白名单里**(提示说支持就得真支持)', SUPPORTED_IMAGE_EXTENSIONS.includes('gif')); check('**Excel 确实在白名单里**', SUPPORTED_ATTACHMENT_EXTENSIONS.includes('xlsx') && SUPPORTED_ATTACHMENT_EXTENSIONS.includes('xls')); // 提示文案与白名单必须描述同一批格式:文案里出现的每个格式名都要能在白名单里找到 const labelCovers = (label, list, aliases = {}) => { const named = String(label).split(/[、,,/]/).map((x) => x.trim()).filter(Boolean); return named.every((name) => { const key = aliases[name] || name.toLowerCase(); // 文案里可能写成「文字PDF」这类带修饰的写法 → 用包含匹配(PDF 在白名单里即可) return list.includes(key) || list.some((ext) => name.toLowerCase().includes(ext)); }); }; check( '提示文案里的图片格式都在白名单里', labelCovers(SUPPORTED_IMAGE_LABEL, SUPPORTED_IMAGE_EXTENSIONS), `${SUPPORTED_IMAGE_LABEL} ↔ ${JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)}` ); check( '提示文案里的文件格式都在白名单里', labelCovers(SUPPORTED_ATTACHMENT_LABEL, SUPPORTED_ATTACHMENT_EXTENSIONS, { Excel: 'xlsx', PDF: 'pdf', DOCX: 'docx' }), `${SUPPORTED_ATTACHMENT_LABEL} ↔ ${JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)}` ); // ⚠️ 另一处口径:PC / Mobile 的 若与白名单不一致,就会出现 // 「文件选择框里选不了某些格式」或「选得进但被校验拒掉」(用户这次报的就是这类) for (const file of ['src/components/BusinessAssistantPC.vue', 'src/components/BusinessAssistantMobile.vue']) { const src = readFileSync(file, 'utf8'); const imageAccept = (src.match(/accept="(image\/[^"]+)"/) || [])[1] || ''; const fileAccept = (src.match(/accept="((?:\.[a-z0-9]+)(?:,\.[a-z0-9]+)*)"/) || [])[1] || ''; // ⚠️ jpg 与 jpeg 是**同一个 MIME**(image/jpeg)—— 两边都要归一后再比, // 否则「accept 写 image/jpeg、白名单同时列了 jpg 与 jpeg」会被误报成不一致 const norm = (exts) => [...new Set(exts.map((x) => (x === 'jpeg' ? 'jpg' : x)))].sort(); const acceptExts = imageAccept .split(',') .map((x) => x.trim().replace(/^image\//, '')) .filter(Boolean); check( `${file.split('/').pop()} 的图片 accept 与白名单一致`, JSON.stringify(norm(acceptExts)) === JSON.stringify(norm(SUPPORTED_IMAGE_EXTENSIONS)), `${imageAccept} ↔ ${JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)}` ); check( `${file.split('/').pop()} 的文件 accept 与白名单一致`, JSON.stringify(fileAccept.split(',').map((x) => x.trim().replace(/^\./, '')).sort()) === JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS.slice().sort()), `${fileAccept} ↔ ${JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)}` ); } } console.log('\n【6】不支持的格式:toast 提示 + **不放进输入框**(调真实上传模块)'); { // node 里没有这几样,补上桩(只在「支持的格式」那条路径会用到) if (!globalThis.URL.createObjectURL) { globalThis.URL.createObjectURL = () => 'blob:stub'; globalThis.URL.revokeObjectURL = () => {}; } const stubFetchOk = () => new Response(JSON.stringify({ code: 200, content: '/static/photo/1889-c_question-x.png' }), { status: 200 }); const restoreFetch = globalThis.fetch; const toasts = []; const { uploadedFiles, handleFiles } = useBusinessAssistantUpload({ notify: (m) => toasts.push(m) }); const f = (name, type = '') => new File(['x'], name, { type }); globalThis.fetch = async () => stubFetchOk(); // 6.1 全是不支持的格式 → 每个都提示一次,且一个都不加入 handleFiles([f('a.svg', 'image/svg+xml'), f('b.zip'), f('c.doc'), f('d.txt')], false); await new Promise((r) => setTimeout(r, 20)); check('**四个不支持的文件各弹一次提示**', toasts.length === 4, JSON.stringify(toasts)); check('提示里带文件名与格式要求', toasts[0].includes('a.svg') && toasts[0].includes('Excel'), toasts[0]); check('**一个都没放进输入框**', uploadedFiles.value.length === 0, JSON.stringify(uploadedFiles.value.map((x) => x.name))); // 6.2 图片通道同样校验 —— **MIME 是 image/* 但扩展名不在白名单的(SVG/BMP/HEIC)也必须拦** // (原来这里只看 `type.startsWith('image/')`,SVG 会被当图片收下 —— 已修) toasts.length = 0; handleFiles([f('e.svg', 'image/svg+xml'), f('f.bmp', 'image/bmp'), f('g.heic', 'image/heic')], true); await new Promise((r) => setTimeout(r, 20)); check('伪图片(image/* 但扩展名不在白名单)**也要拦**', toasts.length === 3, JSON.stringify(toasts)); check('图片通道的提示给的是图片格式', toasts[0]?.includes('JPEG'), JSON.stringify(toasts[0])); check('伪图片没放进去', uploadedFiles.value.length === 0, JSON.stringify(uploadedFiles.value.map((x) => x.name))); // 6.3 支持的格式照旧放进去(证明上面的拦截没误伤)—— 注意图片走图片通道、文件走文件通道 toasts.length = 0; handleFiles([f('ok.xlsx')], false); handleFiles([f('ok2.png', 'image/png'), f('ok3.gif', 'image/gif'), f('ok4.jpeg', 'image/jpeg')], true); await new Promise((r) => setTimeout(r, 30)); check('支持的格式**没有**被拦', toasts.length === 0, JSON.stringify(toasts)); check('支持的格式进了输入框(xlsx + png + gif + jpeg)', uploadedFiles.value.length === 4, JSON.stringify(uploadedFiles.value.map((x) => x.name))); // 6.4 超过大小限制也走同一条提示(此前同样是 window.alert) toasts.length = 0; const tooBig = f('big.pdf'); Object.defineProperty(tooBig, 'size', { value: 51 * 1024 * 1024 }); // 只改 size,不真分配 51MB handleFiles([tooBig], false); await new Promise((r) => setTimeout(r, 20)); check('超限时提示(且不是 window.alert)', toasts.length === 1 && toasts[0].includes('大小限制'), JSON.stringify(toasts)); check('超限的文件没进输入框', uploadedFiles.value.length === 4, String(uploadedFiles.value.length)); globalThis.fetch = restoreFetch; } console.log('\n【7】消息附件:`url`(能显示)与 `fileUrl`(发给后端)不能混 —— 回归:气泡里的图不显示'); { // 用户 2026-09-23 报「提交之后看不到图片了」: // 换 DMS 后 `fileUrl` 从 OSS 绝对地址变成了 `/static/photo/…` 这种**相对路径**, // 而 `consumeSuccessfulFileTransmission` 当时仍然只填 `url: file.fileUrl` —— // 于是气泡里的 `` 经 dev server 拿到的是 index.html(200 但非图片), // 图**解不出来**。实测:相对路径 → text/html;`/dms-api` + 相对路径 → image/png。 const stubFetchOk = () => new Response(JSON.stringify({ code: 200, content: '/static/photo/1889-c_question-x.png' }), { status: 200 }); const restoreFetch = globalThis.fetch; globalThis.fetch = async () => stubFetchOk(); const RELATIVE = '/static/photo/1889-c_question-x.png'; const DISPLAY = '/dms-api' + RELATIVE; // 7.1 配好 DMS 前缀(开发环境的取值,见 .env.development 的 VITE_DMS_API) globalThis.VITE_DMS_API = '/dms-api'; { const { uploadedFiles, handleFiles, consumeSuccessfulFileTransmission } = useBusinessAssistantUpload({ notify: () => {}, }); handleFiles([new File(['x'], 'ok.png', { type: 'image/png' })], true); await new Promise((r) => setTimeout(r, 30)); check('图片上传成功(DMS 前缀配好时)', uploadedFiles.value[0]?.status === 'success', JSON.stringify(uploadedFiles.value.map((f) => f.status))); const transmission = consumeSuccessfulFileTransmission(0); const attachment = (transmission.attachments || [])[0] || {}; check('attachment.fileUrl = 相对路径(发给后端用)', attachment.fileUrl === RELATIVE, String(attachment.fileUrl)); check('**attachment.url = 可显示地址(拼了代理前缀)**', attachment.url === DISPLAY, String(attachment.url)); check('**两个字段不相等**(曾经的 bug 就是 url 直接用了相对路径)', attachment.url !== attachment.fileUrl, `${attachment.url} vs ${attachment.fileUrl}`); // 后端拿到的必须仍是相对路径 —— 换 DMS 后这是请求体契约 const payload = toChatAttachmentUrls(transmission); check('请求体 pictures 里是相对路径(不是展示地址)', payload.pictures.length === 1 && payload.pictures[0] === RELATIVE, JSON.stringify(payload)); check('展示地址**没有**混进请求体', !JSON.stringify(payload).includes('/dms-api'), JSON.stringify(payload)); check('非图片附件不占 pictures', payload.files.length === 0, JSON.stringify(payload)); } // 7.2 非图片文件:气泡里是个「文件条」(只显示名字/大小),不需要展示地址 { const { uploadedFiles, handleFiles, consumeSuccessfulFileTransmission } = useBusinessAssistantUpload({ notify: () => {}, }); globalThis.fetch = async () => new Response(JSON.stringify({ code: 200, content: '/static/file/1889-c_question-y.xlsx' }), { status: 200 }); handleFiles([new File(['x'], 'ok.xlsx')], false); await new Promise((r) => setTimeout(r, 30)); const transmission = consumeSuccessfulFileTransmission(0); const attachment = (transmission.attachments || [])[0] || {}; check('文件附件的 fileUrl 是相对路径', attachment.fileUrl === '/static/file/1889-c_question-y.xlsx', String(attachment.fileUrl)); check('文件附件没有可显示地址(气泡走文件条,不需要)', attachment.url === undefined, String(attachment.url)); check('文件附件仍进请求体 files', toChatAttachmentUrls(transmission).files.length === 1, JSON.stringify(toChatAttachmentUrls(transmission))); } // 7.3 没配 DMS 前缀时(生产环境当前就是没配,见 .env.production)—— // 宁可**不给展示地址**,也不能塞一个取不到的地址让气泡显示成坏图 { globalThis.VITE_DMS_API = ''; const { uploadedFiles, handleFiles, consumeSuccessfulFileTransmission } = useBusinessAssistantUpload({ notify: () => {}, }); globalThis.fetch = async () => stubFetchOk(); handleFiles([new File(['x'], 'ok.png', { type: 'image/png' })], true); await new Promise((r) => setTimeout(r, 30)); // 上传本身会失败(未配置 DMS 接口地址)→ 附件不会进消息,这是「拦在源头」 check('未配 DMS 前缀时上传直接失败(不会产生一个坏地址的附件)', uploadedFiles.value.length === 0 || uploadedFiles.value[0]?.status === 'error', JSON.stringify(uploadedFiles.value.map((f) => f.status))); const transmission = consumeSuccessfulFileTransmission(0); check('失败的上传不会混进消息附件', (transmission.attachments || []).length === 0, JSON.stringify(transmission.attachments)); } globalThis.VITE_DMS_API = ''; globalThis.fetch = restoreFetch; } console.log('\n【8】附件路径随请求一起落 DMS(源码级 —— hook 里的事,node 跑不起来)'); { // 落库那一端的读写行为由 verify-dms-chat-storage.mjs 的【8】打真实 DMS 验(13 项); // 这里只钉「hook 有没有把路径递下去」这一段接线(用户 2026-09-23 要求: // 图片进 c_image、文件进 c_file)。 const hook = readFileSync('src/components/business-assistant/useBusinessAssistantChat.ts', 'utf8'); const dms = readFileSync('src/network/api/dms/chat-sessions-dms.ts', 'utf8'); check('sendMessage 把附件路径一并交给 DMS 写入', /images:\s*attachmentsForBackend\.pictures/.test(hook) && /files:\s*attachmentsForBackend\.files/.test(hook)); check('用的就是发给后端的那两份(同一口径,不是另算一套地址)', hook.split('images: attachmentsForBackend.pictures').length - 1 === 1); check('upsertDmsRecord 收 images / files 两个参数', /images\?: string\[\]/.test(dms) && /files\?: string\[\]/.test(dms)); check('写入落到 c_image / c_file 两列', /content\.c_image = images/.test(dms) && /content\.c_file = files/.test(dms)); check('没有附件时不写这两列(与请求体同口径)', /if \(images\) content\.c_image = images;/.test(dms) && /if \(files\) content\.c_file = files;/.test(dms)); // ⚠️ updateContent 遇到「模型里不存在的列」会静默丢弃**整个** patch(DMS_API.md 的陷阱): // 附件列必须只在真有值时才进 patch,否则补 answer 这条主链路会被连累 check('更新路径里附件列只在「有值且该列还空着」时才进 patch', /if \(images && !existing\.c_image\) patch\.c_image = images;/.test(dms) && /if \(files && !existing\.c_file\) patch\.c_file = files;/.test(dms)); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);