/** * 验证「附件上传到 DMS 文件服务」这条链路(`POST {DMS}/file/uploadFile`)。 * * 背景(用户 2026-09-23):「图片和文件调用接口存储…然后文件存放的相对路径放到对应的 URL 里」—— * 即上传走 DMS,返回的**相对路径**随聊天请求体的 `pictures` / `files` 发给后端。 * 此前附件直传厂商(metamaker)OSS,给的是绝对地址。 * * **实测契约(2026-09-23,打真实服务)**: * - `multipart/form-data`,**五个字段都必填**:file / type / columnId / contentId / paraName * (少一个 → `{"code":-1,"message":"缺少必传字段"}`) * - `type` 决定存储目录:0 图 → `/static/photo/…`;1音/2视/3 文件 → `/static/file/…` * - 成功返回 `{"code":200,"content":"/static/photo/1889-c_question-.png"}` * * 怎么跑(需要 dev server 起着,走同源代理;node 侧不校验 TLS): * 1) npm run dev * 2) npx esbuild harness/tools/_entry-dms.ts --bundle --format=esm \ * --outfile=harness/tools/_dms.mjs --alias:@=./src --define:import.meta.env='{}' * 3) node harness/tools/verify-dms-file-upload.mjs * * ⚠️ 「真上传」那一段会往文件服务写一个**极小的测试文件**(名字带 verify_upload_ 前缀, * 体积几十字节);接口没有提供删除能力,所以它会留在那儿 —— 可接受但要知情。 */ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; const base = process.argv[2] || 'https://localhost:8083/dms-api'; globalThis.VITE_DMS_API = base; const realFetch = globalThis.fetch; let captured = []; const stubFetch = (responder) => { globalThis.fetch = async (url, init = {}) => { captured.push({ url: String(url), body: init.body }); return responder(String(url), init); }; }; const { uploadDmsAttachment, buildDmsFileDisplayUrl, DMS_ATTACHMENT_COLUMN_ID, DMS_ATTACHMENT_CONTENT_ID, DMS_ATTACHMENT_PARA_NAME, DMS_ATTACHMENT_TYPE_IMAGE, DMS_ATTACHMENT_TYPE_FILE, } = await import('./_dms.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); } }; /** node 里构造一个假的 File(Node 20+ 有全局 File) */ const makeFile = (name, type, content = 'verify upload probe') => new File([content], name, { type }); console.log(`\nDMS 附件上传验证\n 代理地址: ${base}\n`); console.log('【1】请求形态:五个字段都得带上'); { captured = []; stubFetch(() => new Response(JSON.stringify({ code: 200, message: '上传成功!', content: '/static/photo/x.png' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }) ); const path = await uploadDmsAttachment(makeFile('a.png', 'image/png'), { isImage: true }); globalThis.fetch = realFetch; const req = captured[0]; check('只发一次请求', captured.length === 1, String(captured.length)); check('打的是 /file/uploadFile', req?.url.endsWith('/file/uploadFile'), req?.url); check('返回相对路径', path === '/static/photo/x.png', path); const form = req?.body; check('body 是 FormData', form instanceof FormData, String(form?.constructor?.name)); const keys = form ? [...form.keys()].sort() : []; check( '**五个字段齐全**(少一个后端就返回"缺少必传字段")', JSON.stringify(keys) === JSON.stringify(['columnId', 'contentId', 'file', 'paraName', 'type']), JSON.stringify(keys) ); check('type:图片 = 0', form?.get('type') === DMS_ATTACHMENT_TYPE_IMAGE, String(form?.get('type'))); check(`columnId = ${DMS_ATTACHMENT_COLUMN_ID}`, form?.get('columnId') === String(DMS_ATTACHMENT_COLUMN_ID)); check(`contentId = ${DMS_ATTACHMENT_CONTENT_ID}`, form?.get('contentId') === String(DMS_ATTACHMENT_CONTENT_ID)); check(`paraName = ${DMS_ATTACHMENT_PARA_NAME}`, form?.get('paraName') === DMS_ATTACHMENT_PARA_NAME); check('file 就是那个文件', form?.get('file')?.name === 'a.png', String(form?.get('file')?.name)); } console.log('\n【2】非图片走 type=3'); { captured = []; stubFetch(() => new Response(JSON.stringify({ code: 200, content: '/static/file/y.pdf' }), { status: 200 })); await uploadDmsAttachment(makeFile('b.pdf', 'application/pdf'), { isImage: false }); globalThis.fetch = realFetch; check('type:非图片 = 3', captured[0]?.body?.get('type') === DMS_ATTACHMENT_TYPE_FILE, String(captured[0]?.body?.get('type'))); } console.log('\n【3】失败要抛错(调用方据此把上传项标成 error)'); { stubFetch(() => new Response(JSON.stringify({ code: -1, message: '缺少必传字段' }), { status: 200 })); let msg = ''; try { await uploadDmsAttachment(makeFile('c.png', 'image/png'), { isImage: true }); } catch (err) { msg = err.message; } globalThis.fetch = realFetch; check('code != 200 → 抛错并带上后端说法', msg === '缺少必传字段', msg); } { stubFetch(() => new Response('boom', { status: 500 })); let msg = ''; try { await uploadDmsAttachment(makeFile('d.png', 'image/png'), { isImage: true }); } catch (err) { msg = err.message; } globalThis.fetch = realFetch; check('HTTP 非 2xx → 抛错', msg.includes('500'), msg); } console.log('\n【4】真上传一次(走代理打真实文件服务)'); { try { const imgPath = await uploadDmsAttachment(makeFile(`verify_upload_${Date.now()}.png`, 'image/png'), { isImage: true, }); check('图片:返回 200 且拿到相对路径', /^\/static\/photo\/1889-c_question-[\w-]+\.png$/.test(imgPath), imgPath); const filePath = await uploadDmsAttachment(makeFile(`verify_upload_${Date.now()}.txt`, 'text/plain'), { isImage: false, }); check('文件:落在 /static/file/', /^\/static\/file\/1889-c_question-[\w-]+\.txt$/.test(filePath), filePath); check( '路径里带上了 columnId 与 paraName(后端按这三个字段定位)', imgPath.includes('1889-c_question-') && filePath.includes('1889-c_question-'), `${imgPath} | ${filePath}` ); console.log(' (测试文件已写入文件服务,名字前缀 verify_upload_,体积几十字节)'); // 关键:相对路径**取不到**,但拼上代理前缀能取到 —— 缩略图预览靠的就是它 const direct = await realFetch(`http://121.43.55.7:10081${imgPath}`).catch(() => null); check('相对路径直接取**不到**(所以不能当 img src)', !direct || direct.status === 404, `HTTP ${direct?.status}`); const displayUrl = buildDmsFileDisplayUrl(imgPath); check('展示地址 = 代理前缀 + 相对路径', displayUrl === `${base}${imgPath}`, displayUrl); const served = await realFetch(displayUrl).catch(() => null); check('**展示地址能取到**(缩略图刷新后仍在)', served?.status === 200, `HTTP ${served?.status}`); check('空路径返回空串(不拼出半个地址)', buildDmsFileDisplayUrl('') === '', buildDmsFileDisplayUrl('')); check('已是绝对地址则原样返回', buildDmsFileDisplayUrl('https://a.cn/x.png') === 'https://a.cn/x.png'); } catch (err) { check('真上传成功', false, err?.message || String(err)); console.log(' ⚠️ dev server 没起、或代理不通会走到这里'); } } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);