verify-attachment-question.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. /**
  2. * 验证「多模态附件」这条链路(`/api/chat` 的 `pictures` / `files` 两个字段)。
  3. *
  4. * 历史:2026-09-18 时新接口只收 `{thread_id, question}`,多一个字段 422,
  5. * 附件只能**把 OSS 地址拼进 question 文本**(方案②)。
  6. * 2026-09-23 后端支持了 `pictures` / `files`(方案①),改为走请求体字段。
  7. *
  8. * **实测到的契约**(2026-09-23,打真实后端):
  9. * - 两者都是 **URL 字符串数组**;传对象数组(`[{url,name}]`)会被 `422 invalid_request` 拒掉
  10. * - `pictures` 里的图**由后端自己去取**(必须公网可达)——
  11. * 实测带上 `pictures:[图URL]` 问「这张图片里是什么」,模型确实描述了图的内容
  12. * - 空数组与不传都接受
  13. *
  14. * 不变量:
  15. * ① **没有附件时请求体逐字节与从前一致**(只有 thread_id + question)——
  16. * 既不多传无意义字段,也让「后端若回滚」只影响带附件的轮次
  17. * ② 有附件时:`question` 仍是**用户原文**(地址不拼进去),附件在 `pictures` / `files` 里
  18. * ③ 图片按 `isImage` 进 `pictures`,其余进 `files`;空 URL 被过滤掉
  19. *
  20. * 怎么跑(在项目根目录):
  21. * npx esbuild harness/tools/_entry-attachments.ts --bundle --format=esm \
  22. * --outfile=harness/tools/_attachments.mjs --alias:@=./src \
  23. * --define:import.meta.env='{}' --loader:.png=dataurl
  24. * # ⚠️ 必须带 --loader:.png=dataurl:协调器 import 了企业微信二维码
  25. * node harness/tools/verify-attachment-question.mjs
  26. */
  27. import { readFileSync, readdirSync } from 'node:fs';
  28. const {
  29. toChatAttachmentUrls,
  30. ApiChatCoordinator,
  31. useBusinessAssistantUpload,
  32. SUPPORTED_IMAGE_EXTENSIONS,
  33. SUPPORTED_ATTACHMENT_EXTENSIONS,
  34. SUPPORTED_IMAGE_LABEL,
  35. SUPPORTED_ATTACHMENT_LABEL,
  36. } = await import('./_attachments.mjs');
  37. let pass = 0;
  38. let fail = 0;
  39. const check = (name, cond, extra = '') => {
  40. if (cond) {
  41. pass++;
  42. console.log(' ok ' + name);
  43. } else {
  44. fail++;
  45. console.log(' FAIL ' + name + ' ' + extra);
  46. }
  47. };
  48. const IMG1 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/a.png';
  49. const IMG2 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/b.jpg';
  50. const PDF = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/c.pdf';
  51. const DOC = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/d.docx';
  52. const att = (url, isImage) => ({ id: url, name: url.split('/').pop(), size: 1, isImage, url });
  53. const tx = (attachments, files) => ({
  54. files: files ?? attachments.map((a) => a.url),
  55. file_pos: attachments.map(() => 0),
  56. attachments,
  57. });
  58. console.log('\n【1】按类型拆分(纯逻辑)');
  59. {
  60. const mixed = toChatAttachmentUrls(tx([att(IMG1, true), att(PDF, false), att(IMG2, true)]));
  61. check('图片进 pictures', JSON.stringify(mixed.pictures) === JSON.stringify([IMG1, IMG2]), JSON.stringify(mixed.pictures));
  62. check('非图片进 files', JSON.stringify(mixed.files) === JSON.stringify([PDF]), JSON.stringify(mixed.files));
  63. const onlyImages = toChatAttachmentUrls(tx([att(IMG1, true)]));
  64. check('只有图片时 files 为空数组', onlyImages.files.length === 0 && onlyImages.pictures.length === 1);
  65. const none = toChatAttachmentUrls(undefined);
  66. check('没有附件 → 两个空数组', none.pictures.length === 0 && none.files.length === 0);
  67. check('null 也安全', JSON.stringify(toChatAttachmentUrls(null)) === JSON.stringify({ pictures: [], files: [] }));
  68. check('空 attachments 数组 → 两个空数组', JSON.stringify(toChatAttachmentUrls(tx([]))) === JSON.stringify({ pictures: [], files: [] }));
  69. const dirty = toChatAttachmentUrls(tx([att(' ' + IMG1 + ' ', true), att('', false)]));
  70. check('URL 前后空格去掉、空 URL 过滤掉', JSON.stringify(dirty.pictures) === JSON.stringify([IMG1]) && dirty.files.length === 0, JSON.stringify(dirty));
  71. const noAttachments = toChatAttachmentUrls({ files: [PDF, DOC], file_pos: [0, 0] });
  72. check('只有 files(没 attachments)→ 全落 files,地址不丢', JSON.stringify(noAttachments.files) === JSON.stringify([PDF, DOC]), JSON.stringify(noAttachments));
  73. }
  74. /* ------------------------------------------------------------------ *
  75. * 协调器:真正发出的请求体
  76. * ------------------------------------------------------------------ */
  77. const sse = (events) =>
  78. events
  79. .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`)
  80. .join('');
  81. let captured = [];
  82. const stubFetch = () => {
  83. globalThis.fetch = async (url, init = {}) => {
  84. captured.push(init.body ? JSON.parse(String(init.body)) : null);
  85. return new Response(
  86. new ReadableStream({
  87. start(controller) {
  88. controller.enqueue(new TextEncoder().encode(sse([['done', { status: 'completed' }]])));
  89. controller.close();
  90. },
  91. }),
  92. { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
  93. );
  94. };
  95. };
  96. const runTurn = async (question, attachments) => {
  97. captured = [];
  98. stubFetch();
  99. const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
  100. await coordinator.generateAnswer(question, attachments);
  101. await new Promise((r) => setTimeout(r, 60));
  102. return captured[0];
  103. };
  104. console.log('\n【2】请求体:没有附件时与从前逐字节一致');
  105. {
  106. const body = await runTurn('帮我看看这份材料符合条件吗');
  107. check(
  108. '只有 thread_id + question',
  109. JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['question', 'thread_id']),
  110. JSON.stringify(body)
  111. );
  112. check('不该出现空数组字段', !('pictures' in body) && !('files' in body), JSON.stringify(body));
  113. const empty = await runTurn('没有附件', { pictures: [], files: [] });
  114. check('显式传空数组也不发字段', !('pictures' in empty) && !('files' in empty), JSON.stringify(empty));
  115. }
  116. console.log('\n【3】请求体:带附件时');
  117. {
  118. const Q = '帮我看看这份材料符合条件吗';
  119. const body = await runTurn(Q, { pictures: [IMG1], files: [PDF] });
  120. check('**question 是用户原文**(地址不拼进去)', body.question === Q, JSON.stringify(body.question));
  121. check('pictures 是 URL 字符串数组', JSON.stringify(body.pictures) === JSON.stringify([IMG1]), JSON.stringify(body.pictures));
  122. check('files 是 URL 字符串数组', JSON.stringify(body.files) === JSON.stringify([PDF]), JSON.stringify(body.files));
  123. check('没有多余字段', JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['files', 'pictures', 'question', 'thread_id']), JSON.stringify(Object.keys(body)));
  124. check(
  125. 'pictures 里**不带**对象结构(后端只认字符串数组,传对象 422)',
  126. body.pictures.every((item) => typeof item === 'string'),
  127. JSON.stringify(body.pictures)
  128. );
  129. const multi = await runTurn(Q, { pictures: [IMG1, IMG2], files: [PDF, DOC] });
  130. check('支持多个图片与文件', multi.pictures.length === 2 && multi.files.length === 2, JSON.stringify(multi));
  131. const dirty = await runTurn(Q, { pictures: ['', ' '], files: [PDF] });
  132. check('空 URL 被清掉(不会发出 [""])', !('pictures' in dirty) && JSON.stringify(dirty.files) === JSON.stringify([PDF]), JSON.stringify(dirty));
  133. }
  134. console.log('\n【4】旧方案已停用(源码级)');
  135. {
  136. const files = [];
  137. const walk = (dir) => {
  138. for (const entry of readdirSync(dir, { withFileTypes: true })) {
  139. const full = `${dir}/${entry.name}`;
  140. if (entry.isDirectory()) walk(full);
  141. else if (/\.(ts|vue)$/.test(entry.name)) files.push(full);
  142. }
  143. };
  144. walk('src');
  145. const callers = files.filter((file) => {
  146. const text = readFileSync(file, 'utf8');
  147. // 跳过它自己的定义(已在 shared.ts 里注释掉)
  148. return text.split('\n').some((line) => !line.trim().startsWith('//') && line.includes('buildQuestionWithAttachments'));
  149. });
  150. check('没有活跃调用点(地址不再拼进 question)', callers.length === 0, callers.join(', '));
  151. }
  152. console.log('\n【5】支持格式的白名单(与产品提示同一口径)');
  153. {
  154. check(
  155. '图片:JPEG / PNG / GIF / WebP',
  156. JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS) === JSON.stringify(['jpg', 'jpeg', 'png', 'gif', 'webp']),
  157. JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)
  158. );
  159. check(
  160. '文件:Excel / DOCX / PDF',
  161. JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS) === JSON.stringify(['xls', 'xlsx', 'docx', 'pdf']),
  162. JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)
  163. );
  164. // 用户 2026-09-23 指出的缺口:提示里写了 GIF,白名单里原来没有
  165. check('**GIF 确实在白名单里**(提示说支持就得真支持)', SUPPORTED_IMAGE_EXTENSIONS.includes('gif'));
  166. check('**Excel 确实在白名单里**', SUPPORTED_ATTACHMENT_EXTENSIONS.includes('xlsx') && SUPPORTED_ATTACHMENT_EXTENSIONS.includes('xls'));
  167. // 提示文案与白名单必须描述同一批格式:文案里出现的每个格式名都要能在白名单里找到
  168. const labelCovers = (label, list, aliases = {}) => {
  169. const named = String(label).split(/[、,,/]/).map((x) => x.trim()).filter(Boolean);
  170. return named.every((name) => {
  171. const key = aliases[name] || name.toLowerCase();
  172. // 文案里可能写成「文字PDF」这类带修饰的写法 → 用包含匹配(PDF 在白名单里即可)
  173. return list.includes(key) || list.some((ext) => name.toLowerCase().includes(ext));
  174. });
  175. };
  176. check(
  177. '提示文案里的图片格式都在白名单里',
  178. labelCovers(SUPPORTED_IMAGE_LABEL, SUPPORTED_IMAGE_EXTENSIONS),
  179. `${SUPPORTED_IMAGE_LABEL} ↔ ${JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)}`
  180. );
  181. check(
  182. '提示文案里的文件格式都在白名单里',
  183. labelCovers(SUPPORTED_ATTACHMENT_LABEL, SUPPORTED_ATTACHMENT_EXTENSIONS, { Excel: 'xlsx', PDF: 'pdf', DOCX: 'docx' }),
  184. `${SUPPORTED_ATTACHMENT_LABEL} ↔ ${JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)}`
  185. );
  186. // ⚠️ 另一处口径:PC / Mobile 的 <input accept="…"> 若与白名单不一致,就会出现
  187. // 「文件选择框里选不了某些格式」或「选得进但被校验拒掉」(用户这次报的就是这类)
  188. for (const file of ['src/components/BusinessAssistantPC.vue', 'src/components/BusinessAssistantMobile.vue']) {
  189. const src = readFileSync(file, 'utf8');
  190. const imageAccept = (src.match(/accept="(image\/[^"]+)"/) || [])[1] || '';
  191. const fileAccept = (src.match(/accept="((?:\.[a-z0-9]+)(?:,\.[a-z0-9]+)*)"/) || [])[1] || '';
  192. // ⚠️ jpg 与 jpeg 是**同一个 MIME**(image/jpeg)—— 两边都要归一后再比,
  193. // 否则「accept 写 image/jpeg、白名单同时列了 jpg 与 jpeg」会被误报成不一致
  194. const norm = (exts) => [...new Set(exts.map((x) => (x === 'jpeg' ? 'jpg' : x)))].sort();
  195. const acceptExts = imageAccept
  196. .split(',')
  197. .map((x) => x.trim().replace(/^image\//, ''))
  198. .filter(Boolean);
  199. check(
  200. `${file.split('/').pop()} 的图片 accept 与白名单一致`,
  201. JSON.stringify(norm(acceptExts)) === JSON.stringify(norm(SUPPORTED_IMAGE_EXTENSIONS)),
  202. `${imageAccept} ↔ ${JSON.stringify(SUPPORTED_IMAGE_EXTENSIONS)}`
  203. );
  204. check(
  205. `${file.split('/').pop()} 的文件 accept 与白名单一致`,
  206. JSON.stringify(fileAccept.split(',').map((x) => x.trim().replace(/^\./, '')).sort()) ===
  207. JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS.slice().sort()),
  208. `${fileAccept} ↔ ${JSON.stringify(SUPPORTED_ATTACHMENT_EXTENSIONS)}`
  209. );
  210. }
  211. }
  212. console.log('\n【6】不支持的格式:toast 提示 + **不放进输入框**(调真实上传模块)');
  213. {
  214. // node 里没有这几样,补上桩(只在「支持的格式」那条路径会用到)
  215. if (!globalThis.URL.createObjectURL) {
  216. globalThis.URL.createObjectURL = () => 'blob:stub';
  217. globalThis.URL.revokeObjectURL = () => {};
  218. }
  219. const stubFetchOk = () =>
  220. new Response(JSON.stringify({ code: 200, content: '/static/photo/1889-c_question-x.png' }), { status: 200 });
  221. const restoreFetch = globalThis.fetch;
  222. const toasts = [];
  223. const { uploadedFiles, handleFiles } = useBusinessAssistantUpload({ notify: (m) => toasts.push(m) });
  224. const f = (name, type = '') => new File(['x'], name, { type });
  225. globalThis.fetch = async () => stubFetchOk();
  226. // 6.1 全是不支持的格式 → 每个都提示一次,且一个都不加入
  227. handleFiles([f('a.svg', 'image/svg+xml'), f('b.zip'), f('c.doc'), f('d.txt')], false);
  228. await new Promise((r) => setTimeout(r, 20));
  229. check('**四个不支持的文件各弹一次提示**', toasts.length === 4, JSON.stringify(toasts));
  230. check('提示里带文件名与格式要求', toasts[0].includes('a.svg') && toasts[0].includes('Excel'), toasts[0]);
  231. check('**一个都没放进输入框**', uploadedFiles.value.length === 0, JSON.stringify(uploadedFiles.value.map((x) => x.name)));
  232. // 6.2 图片通道同样校验 —— **MIME 是 image/* 但扩展名不在白名单的(SVG/BMP/HEIC)也必须拦**
  233. // (原来这里只看 `type.startsWith('image/')`,SVG 会被当图片收下 —— 已修)
  234. toasts.length = 0;
  235. handleFiles([f('e.svg', 'image/svg+xml'), f('f.bmp', 'image/bmp'), f('g.heic', 'image/heic')], true);
  236. await new Promise((r) => setTimeout(r, 20));
  237. check('伪图片(image/* 但扩展名不在白名单)**也要拦**', toasts.length === 3, JSON.stringify(toasts));
  238. check('图片通道的提示给的是图片格式', toasts[0]?.includes('JPEG'), JSON.stringify(toasts[0]));
  239. check('伪图片没放进去', uploadedFiles.value.length === 0, JSON.stringify(uploadedFiles.value.map((x) => x.name)));
  240. // 6.3 支持的格式照旧放进去(证明上面的拦截没误伤)—— 注意图片走图片通道、文件走文件通道
  241. toasts.length = 0;
  242. handleFiles([f('ok.xlsx')], false);
  243. handleFiles([f('ok2.png', 'image/png'), f('ok3.gif', 'image/gif'), f('ok4.jpeg', 'image/jpeg')], true);
  244. await new Promise((r) => setTimeout(r, 30));
  245. check('支持的格式**没有**被拦', toasts.length === 0, JSON.stringify(toasts));
  246. check('支持的格式进了输入框(xlsx + png + gif + jpeg)', uploadedFiles.value.length === 4, JSON.stringify(uploadedFiles.value.map((x) => x.name)));
  247. // 6.4 超过大小限制也走同一条提示(此前同样是 window.alert)
  248. toasts.length = 0;
  249. const tooBig = f('big.pdf');
  250. Object.defineProperty(tooBig, 'size', { value: 51 * 1024 * 1024 }); // 只改 size,不真分配 51MB
  251. handleFiles([tooBig], false);
  252. await new Promise((r) => setTimeout(r, 20));
  253. check('超限时提示(且不是 window.alert)', toasts.length === 1 && toasts[0].includes('大小限制'), JSON.stringify(toasts));
  254. check('超限的文件没进输入框', uploadedFiles.value.length === 4, String(uploadedFiles.value.length));
  255. globalThis.fetch = restoreFetch;
  256. }
  257. console.log('\n【7】消息附件:`url`(能显示)与 `fileUrl`(发给后端)不能混 —— 回归:气泡里的图不显示');
  258. {
  259. // 用户 2026-09-23 报「提交之后看不到图片了」:
  260. // 换 DMS 后 `fileUrl` 从 OSS 绝对地址变成了 `/static/photo/…` 这种**相对路径**,
  261. // 而 `consumeSuccessfulFileTransmission` 当时仍然只填 `url: file.fileUrl` ——
  262. // 于是气泡里的 `<img src="/static/photo/…">` 经 dev server 拿到的是 index.html(200 但非图片),
  263. // 图**解不出来**。实测:相对路径 → text/html;`/dms` + 相对路径 → image/png。
  264. const stubFetchOk = () =>
  265. new Response(JSON.stringify({ code: 200, content: '/static/photo/1889-c_question-x.png' }), { status: 200 });
  266. const restoreFetch = globalThis.fetch;
  267. globalThis.fetch = async () => stubFetchOk();
  268. const RELATIVE = '/static/photo/1889-c_question-x.png';
  269. const DISPLAY = '/dms' + RELATIVE;
  270. // 7.1 配好 DMS 前缀(开发环境的取值,见 .env.development 的 VITE_DMS_API)
  271. globalThis.VITE_DMS_API = '/dms';
  272. {
  273. const { uploadedFiles, handleFiles, consumeSuccessfulFileTransmission } = useBusinessAssistantUpload({
  274. notify: () => {},
  275. });
  276. handleFiles([new File(['x'], 'ok.png', { type: 'image/png' })], true);
  277. await new Promise((r) => setTimeout(r, 30));
  278. check('图片上传成功(DMS 前缀配好时)', uploadedFiles.value[0]?.status === 'success',
  279. JSON.stringify(uploadedFiles.value.map((f) => f.status)));
  280. const transmission = consumeSuccessfulFileTransmission(0);
  281. const attachment = (transmission.attachments || [])[0] || {};
  282. check('attachment.fileUrl = 相对路径(发给后端用)', attachment.fileUrl === RELATIVE, String(attachment.fileUrl));
  283. check('**attachment.url = 可显示地址(拼了代理前缀)**', attachment.url === DISPLAY, String(attachment.url));
  284. check('**两个字段不相等**(曾经的 bug 就是 url 直接用了相对路径)',
  285. attachment.url !== attachment.fileUrl, `${attachment.url} vs ${attachment.fileUrl}`);
  286. // 后端拿到的必须仍是相对路径 —— 换 DMS 后这是请求体契约
  287. const payload = toChatAttachmentUrls(transmission);
  288. check('请求体 pictures 里是相对路径(不是展示地址)',
  289. payload.pictures.length === 1 && payload.pictures[0] === RELATIVE, JSON.stringify(payload));
  290. check('展示地址**没有**混进请求体', !JSON.stringify(payload).includes('/dms'), JSON.stringify(payload));
  291. check('非图片附件不占 pictures', payload.files.length === 0, JSON.stringify(payload));
  292. }
  293. // 7.2 非图片文件:气泡里是个「文件条」(只显示名字/大小),不需要展示地址
  294. {
  295. const { uploadedFiles, handleFiles, consumeSuccessfulFileTransmission } = useBusinessAssistantUpload({
  296. notify: () => {},
  297. });
  298. globalThis.fetch = async () =>
  299. new Response(JSON.stringify({ code: 200, content: '/static/file/1889-c_question-y.xlsx' }), { status: 200 });
  300. handleFiles([new File(['x'], 'ok.xlsx')], false);
  301. await new Promise((r) => setTimeout(r, 30));
  302. const transmission = consumeSuccessfulFileTransmission(0);
  303. const attachment = (transmission.attachments || [])[0] || {};
  304. check('文件附件的 fileUrl 是相对路径', attachment.fileUrl === '/static/file/1889-c_question-y.xlsx',
  305. String(attachment.fileUrl));
  306. check('文件附件没有可显示地址(气泡走文件条,不需要)', attachment.url === undefined, String(attachment.url));
  307. check('文件附件仍进请求体 files', toChatAttachmentUrls(transmission).files.length === 1,
  308. JSON.stringify(toChatAttachmentUrls(transmission)));
  309. }
  310. // 7.3 没配 DMS 前缀时(生产环境当前就是没配,见 .env.production)——
  311. // 宁可**不给展示地址**,也不能塞一个取不到的地址让气泡显示成坏图
  312. {
  313. globalThis.VITE_DMS_API = '';
  314. const { uploadedFiles, handleFiles, consumeSuccessfulFileTransmission } = useBusinessAssistantUpload({
  315. notify: () => {},
  316. });
  317. globalThis.fetch = async () => stubFetchOk();
  318. handleFiles([new File(['x'], 'ok.png', { type: 'image/png' })], true);
  319. await new Promise((r) => setTimeout(r, 30));
  320. // 上传本身会失败(未配置 DMS 接口地址)→ 附件不会进消息,这是「拦在源头」
  321. check('未配 DMS 前缀时上传直接失败(不会产生一个坏地址的附件)',
  322. uploadedFiles.value.length === 0 || uploadedFiles.value[0]?.status === 'error',
  323. JSON.stringify(uploadedFiles.value.map((f) => f.status)));
  324. const transmission = consumeSuccessfulFileTransmission(0);
  325. check('失败的上传不会混进消息附件', (transmission.attachments || []).length === 0,
  326. JSON.stringify(transmission.attachments));
  327. }
  328. globalThis.VITE_DMS_API = '';
  329. globalThis.fetch = restoreFetch;
  330. }
  331. console.log('\n【8】附件路径随请求一起落 DMS(源码级 —— hook 里的事,node 跑不起来)');
  332. {
  333. // 落库那一端的读写行为由 verify-dms-chat-storage.mjs 的【8】打真实 DMS 验(13 项);
  334. // 这里只钉「hook 有没有把路径递下去」这一段接线(用户 2026-09-23 要求:
  335. // 图片进 c_image、文件进 c_file)。
  336. const hook = readFileSync('src/components/business-assistant/useBusinessAssistantChat.ts', 'utf8');
  337. const dms = readFileSync('src/network/api/dms/chat-sessions-dms.ts', 'utf8');
  338. check('sendMessage 把附件路径一并交给 DMS 写入',
  339. /images:\s*attachmentsForBackend\.pictures/.test(hook) && /files:\s*attachmentsForBackend\.files/.test(hook));
  340. check('用的就是发给后端的那两份(同一口径,不是另算一套地址)',
  341. hook.split('images: attachmentsForBackend.pictures').length - 1 === 1);
  342. check('upsertDmsRecord 收 images / files 两个参数',
  343. /images\?: string\[\]/.test(dms) && /files\?: string\[\]/.test(dms));
  344. check('写入落到 c_image / c_file 两列',
  345. /content\.c_image = images/.test(dms) && /content\.c_file = files/.test(dms));
  346. check('没有附件时不写这两列(与请求体同口径)',
  347. /if \(images\) content\.c_image = images;/.test(dms) && /if \(files\) content\.c_file = files;/.test(dms));
  348. // ⚠️ updateContent 遇到「模型里不存在的列」会静默丢弃**整个** patch(DMS_API.md 的陷阱):
  349. // 附件列必须只在真有值时才进 patch,否则补 answer 这条主链路会被连累
  350. check('更新路径里附件列只在「有值且该列还空着」时才进 patch',
  351. /if \(images && !existing\.c_image\) patch\.c_image = images;/.test(dms) &&
  352. /if \(files && !existing\.c_file\) patch\.c_file = files;/.test(dms));
  353. }
  354. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  355. process.exit(fail ? 1 : 0);