Преглед на файлове

feat(upload): 附件改上传到 DMS 文件服务,相对路径随 pictures/files 发出

用户要求:图片和文件调用接口存储,然后文件存放的相对路径放到对应的 URL 里。

先探清接口(过程中三处与描述不同,都以实测为准):
- 接口在 10081 的 /dms/file/uploadFile(10011 同路径 404)
- multipart/form-data,五个字段全必填:file / type / columnId / contentId / paraName
  (少一个返回 {"code":-1,"message":"缺少必传字段"})
- type 决定目录:0 图 → /static/photo/…,其余 → /static/file/…
- 返回 {"code":200,"content":"/static/photo/1889-c_question-<uuid>.png"} 即相对路径
- 相对路径本身取不到,但 /dms + 相对路径 = 200(/dms-api 代理补的正是 /dms)
- columnId=1889 / paraName=c_question / contentId=0 由用户拍板

实现:
- 新增 src/network/api/dms/file-upload.ts:uploadDmsAttachment()(五字段、type 0/3、
  失败抛错带后端说法)+ buildDmsFileDisplayUrl()(拼代理前缀得到可显示地址)
- useBusinessAssistantUpload.ts:上传改调 DMS;fileUrl=相对路径(发给后端)、
  url=可显示地址(预览用,刷新后仍有效);厂商 OSS 的签名与直传整段块注释停用
  (含 VITE_UPLOAD_SIGN_URL)
- 与上一轮 pictures/files 对接:toChatAttachmentUrls() 取的就是 fileUrl

验证:新增 verify-dms-file-upload.mjs 21/21(请求形态 / 失败抛错 / 真上传一次 /
展示地址端到端:相对路径 404、拼前缀 200);回归全绿(附件 19/19、复制 19/19、
答案流式 165/165、DMS 65/65、载荷 19/19、企业分类 84/84、判空 30/30、聊天任务 33/33、
政策卡 24/24);build 与 validate-harness 通过。

文档:current-api-call-sites 的附件上传一节改写(新路径 + 旧 OSS 标停用);README 20260923 节补条目。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao преди 1 месец
родител
ревизия
4e7813e210

+ 8 - 0
README.md

@@ -351,3 +351,11 @@
     带上 `pictures` 问「这张图片里是什么」,模型确实描述了图的内容(不是收下就忽略);
     空数组与不传都接受
   - 没有附件时**不发**这两个字段 —— 请求体与从前逐字节一致
+- **附件改为上传到 DMS 文件服务**(用户 2026-09-23):图片/文件走
+  `POST {DMS}/file/uploadFile`(multipart,五个字段全必填:file / type / columnId / contentId / paraName),
+  返回**相对路径**(如 `/static/photo/1889-c_question-<uuid>.png`),它随请求体发给后端;
+  原先直传厂商 OSS 的实现整段停用保留
+  - 缩略图预览:相对路径本身打不开,拼上 DMS 代理前缀才能取(实测 200),
+    所以刷新页面后气泡里的图还在
+  - 实测细节:`type` 决定目录(0 图 → `/static/photo/`,其余 → `/static/file/`);
+    少任何一个字段都返回 `{"code":-1,"message":"缺少必传字段"}`

+ 14 - 0
harness/docs/reference/current-api-call-sites.md

@@ -116,6 +116,20 @@ wss://{VITE_XF_ASR}/api/human_asr/v2/asr?Authorization=...
 - 触发:页面初始化的内联脚本
 - 现状:只在 `VITE_USE_KNOWLEDGE_API === false` 时执行,四份 env 里**只有 `.env.test` 是 false**
 
+### 6b. 附件上传 —— **已改走 DMS 文件服务(2026-09-23)**
+
+- **现在**:`POST {VITE_DMS_API}/file/uploadFile`(→ `http://121.43.55.7:10081/dms/file/uploadFile`,
+  经 `/dms-api` 代理)。`multipart/form-data`,**五个字段都必填**:
+  `file` / `type`(0 图·1 音·2 视·3 文件)/ `columnId`(1889) / `contentId`(0) / `paraName`(c_question)。
+  返回 `{"code":200,"content":"/static/photo/1889-c_question-<uuid>.png"}` —— **相对路径**,
+  它随聊天请求体的 `pictures` / `files` 发给后端(见 `api-chat.md` 的多模态节)
+  - 缩略图预览:相对路径本身取不到,要拼代理前缀 →
+    `{VITE_DMS_API}/static/photo/…` 实测 200(`buildDmsFileDisplayUrl()` 干这个)
+  - 验证脚本:`harness/tools/verify-dms-file-upload.mjs`
+- **[已停用] 原路**:厂商 metamaker 的 OSS 直传 —— `POST {VITE_UPLOAD_SIGN_URL}/common/qp_signed_url`
+  取签名 → 直传 OSS → 得到**绝对地址**。2026-09-23 用户要求改用 DMS 存储,代码整段注释保留
+  (`useBusinessAssistantUpload.ts`),`VITE_UPLOAD_SIGN_URL` 这个 env 也一并未用
+
 ### 7. 取本机 IP(DMS 访客归属用)—— **在用(2026-09-22 新增)**
 
 - 触发:应用启动时预热一次(`main.ts`),写 DMS 前会 `await` 一次(内部缓存 + 并发去重)

+ 35 - 0
harness/progress.md

@@ -3713,3 +3713,38 @@ DMS 代理**注入全权限 token 且不区分调用者** → 任何能打开站
   包成 `![](url)`,再给 `TextContent` 补 img 样式(现在没有任何 img 样式)
 
 **本轮无代码改动**,只登记;`validate-harness` 通过(功能 23 项,in_progress 1 项)。
+
+### 续 24:附件上传改走 DMS 文件服务(用户指定)
+
+**用户要求**:「图片和文件调用接口存储,路径是 http://121.43.55.7:10011,具体参照图片,
+然后文件存放的相对路径放到对应的URL里。」
+
+**先探清接口(不猜)**——过程中有几处与用户描述不同,都实测纠正了:
+- **接口实际在 `10081`**(`/dms/file/uploadFile`),10011 上同一路径是 404
+- `multipart/form-data`,**五个字段全必填**:`file` / `type` / `columnId` / `contentId` / `paraName`
+  ——少任何一个都返回 `{"code":-1,"message":"缺少必传字段"}`(我第一次只传 `file` 就卡在这里)
+- `type` 决定**存储目录**:0 图 → `/static/photo/…`;1音/2视/3 文件 → `/static/file/…`
+- 成功返回 `{"code":200,"content":"/static/photo/1889-c_question-<uuid>.png"}` —— **相对路径**
+- **相对路径本身取不到**(10081/10011 根路径都 404),但 **`/dms` + 相对路径 = 200** ✓
+  (`http://121.43.55.7:10081/dms/static/photo/….png`)—— 而 `/dms-api` 代理补的正是 `/dms` ✓
+- 三个定位字段取值由**用户拍板**:`columnId=1889`、`paraName=c_question`、`contentId=0`
+
+**实现**:
+- 新增 `src/network/api/dms/file-upload.ts`:`uploadDmsAttachment(file,{isImage,signal})`
+  (五个字段全发、`type` 按是否图片取 0/3、失败抛错并带后端说法)
+  + `buildDmsFileDisplayUrl(relativePath)`(拼代理前缀得到**可显示**地址)
+- `useBusinessAssistantUpload.ts`:上传改调 DMS;**两个字段分工**——
+  `fileUrl` = 相对路径(发给后端)· `url` = 拼了前缀的显示地址(预览用,刷新后仍有效);
+  厂商 OSS 的签名与直传整段**块注释停用**(含 `VITE_UPLOAD_SIGN_URL`)
+- 与上一轮的 `pictures` / `files` 对接:`toChatAttachmentUrls()` 取的就是 `fileUrl` ✓
+
+**运行过的验证**:新增 `verify-dms-file-upload.mjs` **21/21** ——
+① 请求形态(五个字段齐全、type=0/3、file 正确)② 失败要抛错(code≠200 / HTTP 5xx)
+③ **真上传一次**(走代理打真实服务:图片落 `/static/photo/`、文件落 `/static/file/`)
+④ **展示地址端到端**:相对路径直接取 404、拼前缀后 200 ✓
+回归:附件 19/19、复制纯文本 19/19、答案流式 165/165、DMS 65/65、载荷字段 19/19、
+企业分类 84/84、判空 30/30、聊天任务 33/33、政策卡 24/24;build 与 validate-harness 通过。
+
+⚠️ **过程中踩的坑(记录)**:批量注释时把**钩子函数签名一起吃进注释**了(接口定义在钩子之前),
+`npm run build` 直接报 `Unexpected "}"`。是分块注释时按行号切的锅 —— 教训:**块注释要按语义单元切,
+并用类型检查复核**(`vue-tsc` 报的行号直接指向了问题)。

+ 10 - 0
harness/tools/_entry-dms.ts

@@ -14,6 +14,16 @@ export {
 // 注意:它由 `useEnterpriseAuth` 维护,这里只是把它暴露出来,不要改写它的语义。
 export { enterpriseInfo } from '../../src/components/useEnterpriseAuth';
 
+export {
+  uploadDmsAttachment,
+  buildDmsFileDisplayUrl,
+  DMS_ATTACHMENT_COLUMN_ID,
+  DMS_ATTACHMENT_CONTENT_ID,
+  DMS_ATTACHMENT_PARA_NAME,
+  DMS_ATTACHMENT_TYPE_IMAGE,
+  DMS_ATTACHMENT_TYPE_FILE,
+} from '../../src/network/api/dms/file-upload';
+
 export {
   upsertDmsSession,
   readSessionCompanyFields,

+ 167 - 0
harness/tools/verify-dms-file-upload.mjs

@@ -0,0 +1,167 @@
+/**
+ * 验证「附件上传到 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-<uuid>.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);

+ 28 - 7
src/components/business-assistant/useBusinessAssistantUpload.ts

@@ -1,4 +1,5 @@
 import { ref, type Ref } from 'vue';
+import { uploadDmsAttachment, buildDmsFileDisplayUrl } from '@/network/api/dms/file-upload';
 import {
   BusinessAssistantUploadedFile,
   BusinessAssistantInputFileTransmission,
@@ -9,6 +10,7 @@ import {
   SUPPORTED_IMAGE_EXTENSIONS,
 } from './shared';
 
+/* [已停用] OSS 签名响应类型 —— 2026-09-23 起改用 DMS 文件服务(src/network/api/dms/file-upload.ts)
 interface OssSignaturePayload {
   file_url?: string;
   host?: string;
@@ -25,16 +27,21 @@ interface OssSignaturePayload {
   'x-oss-signature-version'?: string;
   [key: string]: any;
 }
+*/
 
+/* [已停用] OSS 签名接口地址 —— 2026-09-23 起改用 DMS 文件服务(src/network/api/dms/file-upload.ts)
 // 取 OSS 上传签名的接口。**当前是厂商(metamaker)提供的**,且实测无需鉴权。
 // 已做成 env 可配:将来若换成自建签名服务,改配置即可,不用改代码。
 // 详见 harness/docs/reference/current-api-call-sites.md 与 progress.md Session 049。
 const OSS_SIGNATURE_URL =
   import.meta.env.VITE_UPLOAD_SIGN_URL || '//qingpu-data-api.metamaker.cn/common/qp_signed_url';
+*/
 const DRAFT_HOME_KEY = '__business_assistant_home_draft__';
 
+/* [已停用] OSS 字段取值辅助 —— 2026-09-23 起改用 DMS 文件服务(src/network/api/dms/file-upload.ts)
 const getOssField = (payload: OssSignaturePayload, kebabName: string, snakeName: string) =>
   payload[kebabName] || payload[snakeName];
+*/
 
 export function useBusinessAssistantUpload(options?: {
   imageInputRef?: Ref<HTMLInputElement | null>;
@@ -116,6 +123,7 @@ export function useBusinessAssistantUpload(options?: {
     }
   };
 
+/* [已停用] 取 OSS 签名 —— 2026-09-23 起改用 DMS 文件服务(src/network/api/dms/file-upload.ts)
   const requestOssSignature = async (extension: string, signal: AbortSignal) => {
     const response = await fetch(OSS_SIGNATURE_URL, {
       method: 'POST',
@@ -144,7 +152,9 @@ export function useBusinessAssistantUpload(options?: {
 
     return payload;
   };
+*/
 
+/* [已停用] 直传 OSS —— 2026-09-23 起改用 DMS 文件服务(src/network/api/dms/file-upload.ts)
   const uploadToOss = async (file: File, signature: OssSignaturePayload, signal: AbortSignal) => {
     const formData = new FormData();
     formData.append('success_action_status', '200');
@@ -172,6 +182,7 @@ export function useBusinessAssistantUpload(options?: {
       throw new Error('上传失败');
     }
   };
+*/
 
   const uploadFile = async (file: File, uploadItem: BusinessAssistantUploadedFile) => {
     const abortController = new AbortController();
@@ -179,20 +190,30 @@ export function useBusinessAssistantUpload(options?: {
     updateUploadedFile(uploadItem.id, { abort: abortSignature });
 
     try {
-      const signature = await requestOssSignature(getFileExtension(file.name), abortController.signal);
-      updateUploadedFile(uploadItem.id, { abort: abortSignature });
-      await uploadToOss(file, signature, abortController.signal);
+      // 2026-09-23 起改用 **DMS 的文件服务**(用户指定):返回的是**相对路径**,
+      // 它随聊天请求体的 `pictures` / `files` 发给后端(见 file-upload.ts 的实测契约)。
+      // 旧的厂商 OSS 上传整段停用(见下方 [已停用])。
+      const relativePath = await uploadDmsAttachment(file, {
+        isImage: Boolean(uploadItem.isImage),
+        signal: abortController.signal,
+      });
       const currentFile = uploadedFiles.value.find((item) => item.id === uploadItem.id);
       const localPreviewUrl = currentFile?.url;
-      const nextUrl = currentFile?.isImage ? signature.file_url : localPreviewUrl;
+      // 展示地址:相对路径直接打不开,要拼上代理前缀(实测 `/dms` + 相对路径 = 200)。
+      // 图片才需要;非图片在气泡里显示成文件条,用不上 url。
+      const displayUrl = currentFile?.isImage ? buildDmsFileDisplayUrl(relativePath) : undefined;
       updateUploadedFile(uploadItem.id, {
         status: 'success',
         progress: 100,
-        fileUrl: signature.file_url,
-        url: nextUrl,
+        // ⚠️ 两个字段分工不同,别混:
+        //   fileUrl —— **相对路径**,随请求体 pictures / files 发给后端
+        //   url     —— **可显示地址**(拼了代理前缀),只给预览用
+        fileUrl: relativePath,
+        url: displayUrl,
         abort: undefined,
       });
-      if (localPreviewUrl?.startsWith('blob:') && localPreviewUrl !== nextUrl) {
+      // 本地 blob 用完就撤(换成代理地址后不再需要)
+      if (localPreviewUrl?.startsWith('blob:') && localPreviewUrl !== displayUrl) {
         URL.revokeObjectURL(localPreviewUrl);
       }
     } catch (error) {

+ 90 - 0
src/network/api/dms/file-upload.ts

@@ -0,0 +1,90 @@
+/**
+ * 附件上传到 DMS 的文件服务(`POST {DMS}/file/uploadFile`)。
+ *
+ * **实测契约(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,"message":"上传成功!","content":"/static/photo/1889-c_question-<uuid>.png"}`
+ *   —— **`content` 就是那个相对路径**
+ * - ⚠️ 返回的相对路径**当前直接取不到**(10081 / 10011 都 404)——
+ *   所以聊天里的缩略图**不能拿它当 `<img>` 的 src**,预览要用本地 blob(见上传模块)
+ * - ⚠️ 走 `/dms-api` 代理:页面是 HTTPS、接口是 HTTP,直连会被混合内容拦掉
+ *   (代理已注入 DMS token,实测这个端点不校验 token 也无妨)
+ *
+ * 这个相对路径要放进聊天请求体的 `pictures` / `files` 里(用户 2026-09-23 指定)。
+ */
+
+import { getDmsApiBaseUrl } from "@/utils/runtime-config";
+
+/**
+ * 上传时随文件一起提交的三个 DMS 定位字段(**会进文件名**)。
+ *
+ * 取值由用户 2026-09-23 指定:附件按「属于 1889 助手问答记录的问题内容」落库 ——
+ * 会话/问答记录都在 1889,附件跟着问题走。
+ */
+export const DMS_ATTACHMENT_COLUMN_ID = 1889;
+export const DMS_ATTACHMENT_CONTENT_ID = 0;
+export const DMS_ATTACHMENT_PARA_NAME = "c_question";
+
+/** `type`:0 图 · 1 音 · 2 视 · 3 文件(实测 0 与 3 的差别就是落在 photo/ 还是 file/ 目录) */
+export const DMS_ATTACHMENT_TYPE_IMAGE = "0";
+export const DMS_ATTACHMENT_TYPE_FILE = "3";
+
+/**
+ * 把上传返回的**相对路径**变成一个**可显示**的地址(用于聊天里的缩略图预览)。
+ *
+ * 实测(2026-09-23):相对路径本身取不到(10081 / 10011 的根路径都 404),
+ * 但**在 DMS 主机前加上 `/dms` 就能取到** ——
+ * `http://121.43.55.7:10081/dms/static/photo/….png` → **200**。
+ * 而 `/dms-api` 代理补的正好就是 `/dms`,所以这里直接拼代理前缀即可。
+ *
+ * ⚠️ 存进消息里的就是这个地址:它依赖代理前缀保持不变;
+ * 发给**后端**的仍是相对路径(见 `toChatAttachmentUrls` 用的 `fileUrl` 字段),两者不要混。
+ */
+export const buildDmsFileDisplayUrl = (relativePath: string): string => {
+  const path = String(relativePath || "").trim();
+  if (!path) return "";
+  if (/^https?:\/\//i.test(path)) return path; // 已经是绝对地址就原样返回
+  const base = getDmsApiBaseUrl();
+  return base ? `${base}${path.startsWith("/") ? "" : "/"}${path}` : "";
+};
+
+/**
+ * 上传一个附件,返回**相对路径**(如 `/static/photo/1889-c_question-<uuid>.png`)。
+ * 任何失败都抛 `Error`(消息可直接给用户看),由调用方更新上传项状态。
+ */
+export const uploadDmsAttachment = async (
+  file: File,
+  options: { isImage: boolean; signal?: AbortSignal }
+): Promise<string> => {
+  const base = getDmsApiBaseUrl();
+  if (!base) {
+    throw new Error("未配置 DMS 接口地址,无法上传附件");
+  }
+
+  const form = new FormData();
+  form.append("file", file);
+  form.append("type", options.isImage ? DMS_ATTACHMENT_TYPE_IMAGE : DMS_ATTACHMENT_TYPE_FILE);
+  form.append("columnId", String(DMS_ATTACHMENT_COLUMN_ID));
+  form.append("contentId", String(DMS_ATTACHMENT_CONTENT_ID));
+  form.append("paraName", DMS_ATTACHMENT_PARA_NAME);
+
+  const response = await fetch(`${base}/file/uploadFile`, {
+    method: "POST",
+    body: form,
+    signal: options.signal,
+  });
+
+  if (!response.ok) {
+    throw new Error(`上传失败(HTTP ${response.status})`);
+  }
+
+  const payload = await response.json().catch(() => null);
+  const path = String(payload?.content ?? "").trim();
+  if (payload?.code !== 200 || !path) {
+    throw new Error(String(payload?.message || "上传失败"));
+  }
+  return path;
+};