/**
* 验证「多模态附件」这条链路(`/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);