verify-dms-file-upload.mjs 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. /**
  2. * 验证「附件上传到 DMS 文件服务」这条链路(`POST {DMS}/file/uploadFile`)。
  3. *
  4. * 背景(用户 2026-09-23):「图片和文件调用接口存储…然后文件存放的相对路径放到对应的 URL 里」——
  5. * 即上传走 DMS,返回的**相对路径**随聊天请求体的 `pictures` / `files` 发给后端。
  6. * 此前附件直传厂商(metamaker)OSS,给的是绝对地址。
  7. *
  8. * **实测契约(2026-09-23,打真实服务)**:
  9. * - `multipart/form-data`,**五个字段都必填**:file / type / columnId / contentId / paraName
  10. * (少一个 → `{"code":-1,"message":"缺少必传字段"}`)
  11. * - `type` 决定存储目录:0 图 → `/static/photo/…`;1音/2视/3 文件 → `/static/file/…`
  12. * - 成功返回 `{"code":200,"content":"/static/photo/1889-c_question-<uuid>.png"}`
  13. *
  14. * 怎么跑(需要 dev server 起着,走同源代理;node 侧不校验 TLS):
  15. * 1) npm run dev
  16. * 2) npx esbuild harness/tools/_entry-dms.ts --bundle --format=esm \
  17. * --outfile=harness/tools/_dms.mjs --alias:@=./src --define:import.meta.env='{}'
  18. * 3) node harness/tools/verify-dms-file-upload.mjs
  19. *
  20. * ⚠️ 「真上传」那一段会往文件服务写一个**极小的测试文件**(名字带 verify_upload_ 前缀,
  21. * 体积几十字节);接口没有提供删除能力,所以它会留在那儿 —— 可接受但要知情。
  22. */
  23. process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
  24. const base = process.argv[2] || 'https://localhost:8083/dms-api';
  25. globalThis.VITE_DMS_API = base;
  26. const realFetch = globalThis.fetch;
  27. let captured = [];
  28. const stubFetch = (responder) => {
  29. globalThis.fetch = async (url, init = {}) => {
  30. captured.push({ url: String(url), body: init.body });
  31. return responder(String(url), init);
  32. };
  33. };
  34. const {
  35. uploadDmsAttachment,
  36. buildDmsFileDisplayUrl,
  37. DMS_ATTACHMENT_COLUMN_ID,
  38. DMS_ATTACHMENT_CONTENT_ID,
  39. DMS_ATTACHMENT_PARA_NAME,
  40. DMS_ATTACHMENT_TYPE_IMAGE,
  41. DMS_ATTACHMENT_TYPE_FILE,
  42. } = await import('./_dms.mjs');
  43. let pass = 0;
  44. let fail = 0;
  45. const check = (name, cond, extra = '') => {
  46. if (cond) {
  47. pass++;
  48. console.log(' ok ' + name);
  49. } else {
  50. fail++;
  51. console.log(' FAIL ' + name + ' ' + extra);
  52. }
  53. };
  54. /** node 里构造一个假的 File(Node 20+ 有全局 File) */
  55. const makeFile = (name, type, content = 'verify upload probe') =>
  56. new File([content], name, { type });
  57. console.log(`\nDMS 附件上传验证\n 代理地址: ${base}\n`);
  58. console.log('【1】请求形态:五个字段都得带上');
  59. {
  60. captured = [];
  61. stubFetch(() =>
  62. new Response(JSON.stringify({ code: 200, message: '上传成功!', content: '/static/photo/x.png' }), {
  63. status: 200,
  64. headers: { 'Content-Type': 'application/json' },
  65. })
  66. );
  67. const path = await uploadDmsAttachment(makeFile('a.png', 'image/png'), { isImage: true });
  68. globalThis.fetch = realFetch;
  69. const req = captured[0];
  70. check('只发一次请求', captured.length === 1, String(captured.length));
  71. check('打的是 /file/uploadFile', req?.url.endsWith('/file/uploadFile'), req?.url);
  72. check('返回相对路径', path === '/static/photo/x.png', path);
  73. const form = req?.body;
  74. check('body 是 FormData', form instanceof FormData, String(form?.constructor?.name));
  75. const keys = form ? [...form.keys()].sort() : [];
  76. check(
  77. '**五个字段齐全**(少一个后端就返回"缺少必传字段")',
  78. JSON.stringify(keys) === JSON.stringify(['columnId', 'contentId', 'file', 'paraName', 'type']),
  79. JSON.stringify(keys)
  80. );
  81. check('type:图片 = 0', form?.get('type') === DMS_ATTACHMENT_TYPE_IMAGE, String(form?.get('type')));
  82. check(`columnId = ${DMS_ATTACHMENT_COLUMN_ID}`, form?.get('columnId') === String(DMS_ATTACHMENT_COLUMN_ID));
  83. check(`contentId = ${DMS_ATTACHMENT_CONTENT_ID}`, form?.get('contentId') === String(DMS_ATTACHMENT_CONTENT_ID));
  84. check(`paraName = ${DMS_ATTACHMENT_PARA_NAME}`, form?.get('paraName') === DMS_ATTACHMENT_PARA_NAME);
  85. check('file 就是那个文件', form?.get('file')?.name === 'a.png', String(form?.get('file')?.name));
  86. }
  87. console.log('\n【2】非图片走 type=3');
  88. {
  89. captured = [];
  90. stubFetch(() => new Response(JSON.stringify({ code: 200, content: '/static/file/y.pdf' }), { status: 200 }));
  91. await uploadDmsAttachment(makeFile('b.pdf', 'application/pdf'), { isImage: false });
  92. globalThis.fetch = realFetch;
  93. check('type:非图片 = 3', captured[0]?.body?.get('type') === DMS_ATTACHMENT_TYPE_FILE, String(captured[0]?.body?.get('type')));
  94. }
  95. console.log('\n【3】失败要抛错(调用方据此把上传项标成 error)');
  96. {
  97. stubFetch(() => new Response(JSON.stringify({ code: -1, message: '缺少必传字段' }), { status: 200 }));
  98. let msg = '';
  99. try {
  100. await uploadDmsAttachment(makeFile('c.png', 'image/png'), { isImage: true });
  101. } catch (err) {
  102. msg = err.message;
  103. }
  104. globalThis.fetch = realFetch;
  105. check('code != 200 → 抛错并带上后端说法', msg === '缺少必传字段', msg);
  106. }
  107. {
  108. stubFetch(() => new Response('boom', { status: 500 }));
  109. let msg = '';
  110. try {
  111. await uploadDmsAttachment(makeFile('d.png', 'image/png'), { isImage: true });
  112. } catch (err) {
  113. msg = err.message;
  114. }
  115. globalThis.fetch = realFetch;
  116. check('HTTP 非 2xx → 抛错', msg.includes('500'), msg);
  117. }
  118. console.log('\n【4】真上传一次(走代理打真实文件服务)');
  119. {
  120. try {
  121. const imgPath = await uploadDmsAttachment(makeFile(`verify_upload_${Date.now()}.png`, 'image/png'), {
  122. isImage: true,
  123. });
  124. check('图片:返回 200 且拿到相对路径', /^\/static\/photo\/1889-c_question-[\w-]+\.png$/.test(imgPath), imgPath);
  125. const filePath = await uploadDmsAttachment(makeFile(`verify_upload_${Date.now()}.txt`, 'text/plain'), {
  126. isImage: false,
  127. });
  128. check('文件:落在 /static/file/', /^\/static\/file\/1889-c_question-[\w-]+\.txt$/.test(filePath), filePath);
  129. check(
  130. '路径里带上了 columnId 与 paraName(后端按这三个字段定位)',
  131. imgPath.includes('1889-c_question-') && filePath.includes('1889-c_question-'),
  132. `${imgPath} | ${filePath}`
  133. );
  134. console.log(' (测试文件已写入文件服务,名字前缀 verify_upload_,体积几十字节)');
  135. // 关键:相对路径**取不到**,但拼上代理前缀能取到 —— 缩略图预览靠的就是它
  136. const direct = await realFetch(`http://121.43.55.7:10081${imgPath}`).catch(() => null);
  137. check('相对路径直接取**不到**(所以不能当 img src)', !direct || direct.status === 404, `HTTP ${direct?.status}`);
  138. const displayUrl = buildDmsFileDisplayUrl(imgPath);
  139. check('展示地址 = 代理前缀 + 相对路径', displayUrl === `${base}${imgPath}`, displayUrl);
  140. const served = await realFetch(displayUrl).catch(() => null);
  141. check('**展示地址能取到**(缩略图刷新后仍在)', served?.status === 200, `HTTP ${served?.status}`);
  142. check('空路径返回空串(不拼出半个地址)', buildDmsFileDisplayUrl('') === '', buildDmsFileDisplayUrl(''));
  143. check('已是绝对地址则原样返回', buildDmsFileDisplayUrl('https://a.cn/x.png') === 'https://a.cn/x.png');
  144. } catch (err) {
  145. check('真上传成功', false, err?.message || String(err));
  146. console.log(' ⚠️ dev server 没起、或代理不通会走到这里');
  147. }
  148. }
  149. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  150. process.exit(fail ? 1 : 0);