Преглед изворни кода

fix(upload): 修「提交之后看不到图片」—— 消息附件把相对路径当成了可显示地址

用户反馈(附截图:气泡里只有文字,没有图):「提交之后看不到图片了」。

先复现(dev server 8083 正开着):经 dev 代理真上传一张 png,把浏览器会用到的两个地址
各取一次,机理就清楚了 ——
  · https://localhost:8083/static/photo/…      → 200 但 content-type 是 text/html
    (Vite 的 SPA 兜底吐了 index.html)→ <img> 解不出来 → 图不显示
  · https://localhost:8083/dms-api/static/photo/… → 200 image/png ✓

根因(git log -S 确认):consumeSuccessfulFileTransmission 一直写 url: file.fileUrl ——
OSS 时代没问题(fileUrl 是 https://prod.heijingai.com/… 绝对地址,能直接当 src)。
本轮换成 DMS 后 fileUrl 变成相对路径,上传模块新增了 url(展示地址)/ fileUrl(发后端)
的分工,这里没跟着改。

修法(把 file-upload.ts 注释里早就写好、但从未实现的那套契约实现掉):
- shared.ts:BusinessAssistantMessageAttachment 补 fileUrl?(相对路径,发后端)、
  url? 改可选并注明是可显示地址;toChatAttachmentUrls 的 pick 改取 fileUrl ?? url
  —— **发给后端的地址与从前完全一致**,只有渲染侧变了
- useBusinessAssistantUpload.ts:消息附件按上传模块同款填两个字段;url 按相对路径重新拼
  (不沿用 file.url:那条可能还是已被 revoke 的 blob);非图片 url 为 undefined
  (气泡本来就是「文件条」,只显示名字/大小)

验证:verify-attachment-question.mjs 39 → 51/51,新增【7】12 项钉死两个字段的分工;
变异测试两个方向都做了 —— ①两处一起改回旧写法 → 3 项失败(显示方向);
②只改回 pick(= 修了显示忘请求体)→ 3 项失败(请求体方向)。还原后 51/51。
另:真实上传端到端(调真实上传模块 + dev 代理)实测旧地址 text/html、新地址 image/png;
npm run build 通过、vue-tsc 改动文件无类型错误、verify-toast-channel 27/27、validate-harness 通过。

⚠️ 外观仍需用户在浏览器确认一次(再传一张图,气泡里应能看到图)。

🔴 同轮发现并登记为技术债 #9(未处理):.env.production / .env.qingpu / .env.test
都没有配 VITE_DMS_API —— 这些环境打出来的包里附件传不上去(直接抛「未配置 DMS 接口地址」)、
DMS 落库也静默跳过。接生产要先定 DMS 可达性与 token 由谁注入(dev 是 vite 代理加的)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao пре 1 месец
родитељ
комит
406238bbc5

+ 14 - 0
README.md

@@ -382,3 +382,17 @@
   - 保留项:清空输入框那句 `window.confirm("可能会清空之前的文字…")` 不换 ——
     它是要用户做决定的**模态确认**,不是轻提示
 - **轻提示时长 3s → 2s**(工作区改动,随本次一并提交;改 `APP_MESSAGE_DURATION` 一处即可)
+- **修:提交之后气泡里看不到图片**(用户 2026-09-23 报):
+  - 根因:附件换成 DMS 之后,上传返回的是**相对路径**(`/static/photo/…`),
+    而消息附件仍把**相对路径**当展示地址用 —— `<img src="/static/photo/…">` 经 dev server
+    取回的是 index.html(200 但 `text/html`),图解不出来。
+    实测对照:相对路径 → `text/html`;`/dms-api` + 相对路径 → `image/png` ✓
+  - 修法:消息附件按上传模块同款分成两个字段 —— `url`(**可显示地址**,气泡里 `<img>` 用)与
+    `fileUrl`(**相对路径**,随请求体 `pictures`/`files` 发给后端),
+    `toChatAttachmentUrls` 改取 `fileUrl`(发给后端的地址与从前一致,不受影响)
+  - 非图片附件没有展示地址(气泡里本来就是「文件条」,只显示名字/大小)
+  - 回归脚本 39 → **51/51**(新增【7】12 项钉死这个契约,并用变异测试确认两个方向都会红)
+- **🔴 待处理(登记进技术债 #9)**:`.env.production` / `.env.qingpu` / `.env.test`
+  **都没有配 `VITE_DMS_API`** —— 这些环境打出来的包里附件**传不上去**(直接抛「未配置 DMS 接口地址」)、
+  DMS 会话落库也**静默跳过**。接生产要先定:DMS 从部署环境是否可达、**token 由谁注入**
+  (开发环境是 vite 代理加的,生产得 nginx 做同样的事)

+ 21 - 0
harness/docs/exec-plans/tech-debt-tracker.md

@@ -110,6 +110,27 @@
      实践上很难做对,不建议。
 - **现状**:**未处理**,生产接入前必须解决。
 
+### 9. 生产 / 青浦构建没配 DMS:附件传不上去、DMS 落库静默跳过
+
+- **是什么**:`.env.development` 里有 `VITE_DMS_API="/dms-api"` + `VITE_DMS_TARGET`,
+  而 `.env.production` / `.env.qingpu` / `.env.test` **三份都没有**。
+  这些环境构建出的包里 `getDmsApiBaseUrl()` 返回空串,后果两条:
+  ①`uploadDmsAttachment` 直接抛「未配置 DMS 接口地址,无法上传附件」→ **附件功能在这些环境整体不可用**;
+  ②DMS 会话/问答/反馈请求走 `client.ts` 的「未配置就跳过」,**静默不落库**。
+  (构建产物可自证:`dist/index.html` 里 `globalThis.VITE_DMS_API = "%VITE_DMS_API%"` 原样留着。)
+- **为什么先欠着**:这两处是 2026-09-23 才接进来的(附件换 DMS、气泡图片修复),
+  当时只在开发环境验证过;生产怎么接需要**部署侧的决定**,不是改一行 env 就完。
+- **欠着会怎样**:部署到青浦后,用户传图片会弹「未配置 DMS 接口地址」,
+  且所有会话/问答记录不落 DMS(看起来像"功能没了")。
+- **该还的条件 / 要先拍板的两件事**:
+  ①DMS 地址(`http://121.43.55.7:10081`)从部署环境是否可达;
+  ②**token 谁注入** —— 开发环境是 **vite 代理在转发时加 `token` 头**(浏览器不持有 token,
+  见 `vite.config.ts` 的 `/dms-api/`),生产要由 nginx(或后端网关)做同样的事,
+  否则 DMS 会因鉴权失败而拒;顺带 `/dms-api` 还要把 `/dms` 前缀补回去。
+  ③`index.html` 的 `%VITE_DMS_API%` 支持运行时注入(`globalThis.VITE_DMS_API` 优先),
+  所以也可以**不改构建、只改部署产物里的这一行**——但同样绕不开 ②。
+- **当前状态**:仅登记,未处理。**需要用户决定生产怎么接。**
+
 ## 已还
 
 ### 访客行会被企业看到(2026-09-22 已还)

Разлика између датотеке није приказан због своје велике величине
+ 4 - 3
harness/feature_list.json


+ 45 - 0
harness/progress.md

@@ -3859,3 +3859,48 @@ AntD message 时,语音这几处按「不在要求范围内」留着没动,
 
 **未提交的既有改动一并带上**:`APP_MESSAGE_DURATION` 3 → 2(工作区里已有的手改)。
 
+### 续 28:修「提交之后看不到图片了」—— 气泡里的图用的地址是**相对路径**
+
+**用户反馈**(附截图:气泡里只有「这是什么」,没有图):「提交之后看不到图片了」。
+
+**先复现,不猜**(dev server 8083 正开着,用户就是在开发环境测的):
+- 经 dev 代理真上传一张 png → 拿到相对路径 `/static/photo/1889-c_question-<uuid>.png`
+- 浏览器会用的两个地址分别取一次,**这就是机理**:
+  - `https://localhost:8083/static/photo/…`(相对路径)→ **200 但是 `text/html`**(Vite 的 SPA 兜底吐了
+    index.html)→ `<img>` 解不出来 → **图不显示**
+  - `https://localhost:8083/dms-api/static/photo/…`(拼了代理前缀)→ **200 `image/png`** ✓
+
+**根因**(`git log -S` 确认):`consumeSuccessfulFileTransmission` 里一直写的是
+`url: file.fileUrl` —— **在 OSS 时代没问题**(那时 `fileUrl` 是 `https://prod.heijingai.com/...` 绝对地址,
+能直接当 `src`)。续 24 改成 DMS 后 `fileUrl` 变成**相对路径**,上传模块自己新增了 `url`(展示地址)
+与 `fileUrl`(发后端)的分工,但**这里没跟着改**,于是消息附件的 `url` 成了相对路径 → 气泡不显示。
+> 📌 `file-upload.ts` 的注释当时甚至已经写好了"存进消息里的就是这个地址……发给后端的仍是相对路径
+> (见 `toChatAttachmentUrls` 用的 `fileUrl` 字段),两者不要混"——**文档写了、代码没实现**。
+> 教训:注释里描述的设计不会自己成立,要么写进代码要么写进断言。
+
+**修法**(把注释里那套契约真正实现):
+- `shared.ts`:`BusinessAssistantMessageAttachment` 补 `fileUrl?`(=相对路径,发后端)、
+  `url?` 改为可选并注明它是**可显示地址**;`toChatAttachmentUrls` 的 `pick` 改取
+  `item.fileUrl ?? item.url`(**后端拿到的仍是相对路径**,没变)
+- `useBusinessAssistantUpload.ts`:消息附件按上传模块同款分工填两个字段;
+  `url` **按相对路径重新拼**(不沿用 `file.url`:那条路径可能还是已被 revoke 的 blob)
+- 非图片:`url` = undefined(气泡走「文件条」分支,只显示名字/大小),`fileUrl` 照旧
+
+**运行过的验证**:`verify-attachment-question.mjs` 由 39 → **51/51**,新增【7】12 项钉死这个契约:
+两个字段各自的值、**两者不相等**、请求体里是相对路径且**展示地址没混进去**、非图片没有展示地址、
+未配 DMS 前缀时上传直接失败且不产生坏附件。
+**变异测试两个方向都做了**:①把上传模块 + pick 一起改回旧写法 → 3 项失败(显示方向);
+②只改回 `pick`(模拟"修了显示、忘了请求体")→ 3 项失败(请求体方向)。还原后 51/51。
+另:`npm run build` 通过、`vue-tsc` 改动文件无类型错误、`verify-toast-channel.mjs` 27/27、
+`validate-harness` 通过。
+
+**⚠️ 外观仍需用户在浏览器确认**:再传一张图提问,气泡里应能看到图。
+
+**🔴 同轮发现的部署缺口(已登记为技术债 #9,未处理)**:`.env.production` / `.env.qingpu` /
+`.env.test` **都没有 `VITE_DMS_API`**(只有 `.env.development` 有)。构建产物可自证:
+`dist/index.html` 里 `globalThis.VITE_DMS_API = "%VITE_DMS_API%"` 原样留着。
+后果是这两个环境里**附件根本传不上去**(直接抛「未配置 DMS 接口地址」)+ **DMS 落库静默跳过**。
+要接生产得先拍板两件事:DMS 从部署环境是否可达、**token 由谁注入**
+(开发环境是 vite 代理转发时加 `token` 头,生产得 nginx 做同样的事)。
+
+

+ 82 - 0
harness/tools/verify-attachment-question.mjs

@@ -286,5 +286,87 @@ console.log('\n【6】不支持的格式:toast 提示 + **不放进输入框**
   globalThis.fetch = restoreFetch;
 }
 
+console.log('\n【7】消息附件:`url`(能显示)与 `fileUrl`(发给后端)不能混 —— 回归:气泡里的图不显示');
+{
+  // 用户 2026-09-23 报「提交之后看不到图片了」:
+  // 换 DMS 后 `fileUrl` 从 OSS 绝对地址变成了 `/static/photo/…` 这种**相对路径**,
+  // 而 `consumeSuccessfulFileTransmission` 当时仍然只填 `url: file.fileUrl` ——
+  // 于是气泡里的 `<img src="/static/photo/…">` 经 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===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 21 - 2
src/components/business-assistant/shared.ts

@@ -80,7 +80,18 @@ export interface BusinessAssistantMessageAttachment {
   name: string;
   size: number;
   isImage: boolean;
-  url: string;
+  /**
+   * **可显示地址**(气泡里 `<img src>` 用的那个)—— 图片才有。
+   * 2026-09-23 换 DMS 后它可能是 `/dms-api/static/photo/…` 这样的**代理地址**,
+   * 只有拼接前缀才能取到图,`/static/photo/…` 这种相对路径直接当 src 是取不到的
+   * (实测:相对路径经 dev server 返回的是 index.html,`<img>` 解不出来 → 图不显示)。
+   */
+  url?: string;
+  /**
+   * **相对路径**(`/static/photo/…`)—— 这个才是随请求体 `pictures` / `files`
+   * 发给**后端**的值,别跟 `url` 混(见 `toChatAttachmentUrls`)。
+   */
+  fileUrl?: string;
 }
 
 export interface BusinessAssistantSession {
@@ -377,13 +388,21 @@ export interface ChatAttachmentUrls {
  * 按 `attachments[].isImage` 分(上传时已经判好),**两个列表都只保留非空 URL**。
  * 拿不到 `attachments`(只有 `files`)时,只能全部当普通文件 —— 分不出图片,
  * 但至少地址不丢。
+ *
+ * ⚠️ **取的是 `fileUrl`(相对路径),不是 `url`(可显示地址)**:
+ * 换 DMS 之后两个字段分工不同 —— 后端要的是 `/static/photo/…`,
+ * 而 `url` 是拼了 `/dms-api` 代理前缀给 `<img>` 用的。
+ * 2026-09-23 的 bug 正是这里取错:气泡里的图不显示(相对路径解不出来),
+ * 详见 progress.md 续 28 与 verify-attachment-question.mjs 的【7】。
  */
 export const toChatAttachmentUrls = (
   transmission?: BusinessAssistantInputFileTransmission | null,
 ): ChatAttachmentUrls => {
   const attachments = Array.isArray(transmission?.attachments) ? transmission?.attachments : [];
   const pick = (list: BusinessAssistantMessageAttachment[] | undefined) =>
-    (list || []).map((item) => String(item?.url ?? '').trim()).filter(Boolean);
+    (list || [])
+      .map((item) => String(item?.fileUrl ?? item?.url ?? '').trim())
+      .filter(Boolean);
 
   if (attachments && attachments.length) {
     return {

+ 10 - 1
src/components/business-assistant/useBusinessAssistantUpload.ts

@@ -332,12 +332,21 @@ export function useBusinessAssistantUpload(options?: {
     const successfulFiles = uploadedFiles.value.filter((file) => file.status === 'success' && file.fileUrl);
     const successfulIds = new Set(successfulFiles.map((file) => file.id));
     const files = successfulFiles.map((file) => file.fileUrl as string);
+    // ⚠️ 两个字段分工不同(2026-09-23 换 DMS 后尤其明显),**别混**:
+    //   fileUrl —— 相对路径,随请求体 `pictures` / `files` 发给后端(toChatAttachmentUrls 取它)
+    //   url     —— 可显示地址,气泡里的 <img src> 取它
+    // 这里曾经只填 `url: file.fileUrl`(那时 fileUrl 还是 OSS 绝对地址,能直接当 src)。
+    // 换成 DMS 相对路径后那一行就成了 bug:气泡里的图**不显示**(相对路径经 dev server
+    // 返回的是 index.html,<img> 解不出来)。用户 2026-09-23 报「提交之后看不到图片了」。
+    // 显示地址在这里**按相对路径重新拼**(不沿用上传时的 file.url):那条路径可能还是
+    // 本地 blob(已被 revoke)或压根没来得及写,重拼一次才是确定的。
     const attachments = successfulFiles.map((file) => ({
       id: file.id,
       name: file.name,
       size: file.size,
       isImage: file.isImage,
-      url: file.fileUrl as string,
+      fileUrl: file.fileUrl as string,
+      url: file.isImage ? buildDmsFileDisplayUrl(file.fileUrl as string) || undefined : undefined,
     }));
 
     uploadedFiles.value = uploadedFiles.value.filter((file) => !successfulIds.has(file.id));

Неке датотеке нису приказане због велике количине промена