Browse Source

feat(upload): 恢复上传图片/附件(方案②:OSS 地址拼进 question)

新接口 /api/chat 只收 {thread_id, question},旧协议的 transmission.files 发不出去,
上传功能一直挂起。按用户拍板走方案②——把 OSS 地址拼进 question,不需要后端配合。

实现:
- 新增纯函数 buildQuestionWithAttachments(question, fileUrls)(shared.ts):
  原文 + 换行 + 每条 URL 一行;不加「附件:」之类自定义措辞;无有效 URL 时原样返回
- sendMessage 拆开两个变量(关键):
  · questionForBackend(含附件地址)→ 只发给后端
  · text(用户原文)→ 本地气泡、会话标题、DMS 记录
  这样界面与 DMS 记录里都不会出现一长串 URL
- 恢复「上传图片 / 上传附件」两个按钮(原先整块注释);拖拽/粘贴本来就是通的
- 取签名地址做成 env VITE_UPLOAD_SIGN_URL(四份 env 均写,值仍为厂商地址),
  换自建签名服务只改配置不改代码

实测与验证:
- 签名接口 POST //qingpu-data-api.metamaker.cn/common/qp_signed_url body {ext}
  → err_code 0,返回 file_url/host/key/policy,**无需鉴权**
  ⚠️ 文件实际存在厂商 OSS bucket heijing-products(杭州),外链 prod.heijingai.com
- 新增 verify-attachment-question.mjs 10/10 通过(无附件原样返回、有附件原文在前、
  不添加自定义措辞、脏数据过滤、URL 去空格)
- npm run build 通过

⚠️ 未验证:浏览器端到端;后端能否识别拼接的 URL(方案②的固有不确定性)。
feature_list 的 file-upload 从 not_started 转为 in_progress。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 2 ngày trước cách đây
mục cha
commit
b8f31a9cec

+ 4 - 0
.env.development

@@ -47,3 +47,7 @@ VITE_CHAT_TARGET="http://192.168.2.23:8000"
 # 且 token 由代理侧注入(见 vite.config.ts),不进浏览器产物
 VITE_DMS_API="/dms-api"
 VITE_DMS_TARGET="http://121.43.55.7:10081"
+
+# 取 OSS 上传签名的接口(当前为厂商 metamaker 提供,实测无需鉴权)。
+# 换自建签名服务时只改这里(见 harness/docs/reference/current-api-call-sites.md)
+VITE_UPLOAD_SIGN_URL="//qingpu-data-api.metamaker.cn/common/qp_signed_url"

+ 4 - 0
.env.production

@@ -15,3 +15,7 @@ VITE_USE_KNOWLEDGE_API=true
 # 生产部署时请改为实际后端地址(当前为内网测试地址)
 VITE_CHAT_API="http://192.168.2.23:8000"
 
+
+# 取 OSS 上传签名的接口(当前为厂商 metamaker 提供,实测无需鉴权)。
+# 换自建签名服务时只改这里(见 harness/docs/reference/current-api-call-sites.md)
+VITE_UPLOAD_SIGN_URL="//qingpu-data-api.metamaker.cn/common/qp_signed_url"

+ 4 - 0
.env.qingpu

@@ -15,3 +15,7 @@ VITE_USE_KNOWLEDGE_API=true
 VITE_CHAT_API="http://192.168.2.23:8000"
 
 VITE_REDIRECT_MAP={"demo":"https://demo.heijingai.com/FTA-c2p/%23/overview","zhaoshang":"https://demo.heijingai.com/policy-application/%23/"}
+
+# 取 OSS 上传签名的接口(当前为厂商 metamaker 提供,实测无需鉴权)。
+# 换自建签名服务时只改这里(见 harness/docs/reference/current-api-call-sites.md)
+VITE_UPLOAD_SIGN_URL="//qingpu-data-api.metamaker.cn/common/qp_signed_url"

+ 4 - 0
.env.test

@@ -12,3 +12,7 @@ VITE_POLICY_LOCAL_FIRST="false"
 # 新版聊天协议后端(POST /api/chat,SSE),直连模式
 VITE_CHAT_API="http://192.168.2.23:8000"
 
+
+# 取 OSS 上传签名的接口(当前为厂商 metamaker 提供,实测无需鉴权)。
+# 换自建签名服务时只改这里(见 harness/docs/reference/current-api-call-sites.md)
+VITE_UPLOAD_SIGN_URL="//qingpu-data-api.metamaker.cn/common/qp_signed_url"

+ 11 - 0
README.md

@@ -169,6 +169,17 @@
   - 新增回归脚本:`verify-chat-task-utils.mjs`(33 项,含把「响应式容器破坏对象同一性」
     这个陷阱钉死的断言)、`verify-coordinator-multi-instance.mjs`
     (11 项,验证多实例事件隔离——并行化的地基)
+- **恢复上传图片/附件功能**(此前因新协议只收两个字段而挂起):
+  - 采用「把 OSS 地址拼进 `question` 文本」的方案(用户拍板,**不需要后端配合**)
+  - 关键:`sendMessage` 拆成两个变量——发给后端的 `questionForBackend`(含附件地址)与
+    本地用的原文 `text`(气泡、会话标题、DMS 记录都不显示 URL)
+  - 恢复「上传图片 / 上传附件」两个按钮(原先整块注释);拖拽与粘贴本来就是通的
+  - 取签名的地址做成 env `VITE_UPLOAD_SIGN_URL`(四份 env 均写,值仍为厂商地址),
+    将来换自建签名服务只改配置
+  - 实测:厂商签名接口 `//qingpu-data-api.metamaker.cn/common/qp_signed_url` **无需鉴权**即可取签名;
+    文件实际存放在**厂商的 OSS bucket `heijing-products`(杭州)**,
+    外链为 `https://prod.heijingai.com/qingpu/<uuid>.<ext>`
+  - 新增回归脚本 `harness/tools/verify-attachment-question.mjs`(10 项断言)
 - 删除统计埋点的老接口(用户要求):
   - 删除调用点(`BusinessAssistant.vue` 的页面浏览、`BusinessRecord.vue` 的反馈统计)
     与整个 `src/network/api/assistant-statistics.ts`,以及已无人使用的

+ 10 - 5
harness/feature_list.json

@@ -280,11 +280,16 @@
       "priority": 70,
       "area": "upload",
       "title": "文件与图片上传",
-      "user_visible_behavior": "用户能通过按钮 / 拖拽 / 粘贴上传图片与附件,上传后随提问一起发给模型。",
-      "status": "not_started",
-      "verification": [],
-      "evidence": "",
-      "notes": "阻塞原因(接口契约未定):新接口 /api/chat 请求体只接受 {thread_id, question},多字段返回 422,因此旧协议的 transmission.files / file_pos 发不出去。备选方案:①后端扩展请求体接受 files/file_pos;②前端把 OSS 地址拼进 question 文本;③后端提供文件登记接口返回 file_id。需用户先确认后端方案。原实现与两个接口的完整参数见 git 历史与 useBusinessAssistantUpload.ts(原代码已保留,入口按钮被注释)。"
+      "user_visible_behavior": "用户能通过按钮 / 拖拽 / 粘贴上传图片与附件;上传成功后提问时,附件的 OSS 地址会**拼进 question 文本**发给后端(方案②,用户 2026-09-18 拍板);本地消息气泡与 DMS 记录只显示用户原文,不显示 URL。",
+      "status": "in_progress",
+      "verification": [
+        "npm run build 通过",
+        "harness/tools/verify-attachment-question.mjs —— 10 项断言:无附件原样返回(undefined/null/空数组/全空串)、有附件时原文在前且 URL 各一行、不添加自定义措辞、脏数据过滤、URL 去空格",
+        "签名接口实测:POST //qingpu-data-api.metamaker.cn/common/qp_signed_url body {ext} → err_code 0,返回 file_url/host/key/policy(**无需鉴权**)",
+        "❌ **未做**:浏览器端到端(选文件 → 上传 → 提问 → 后端收到带 URL 的 question)"
+      ],
+      "evidence": "纯函数断言 10/10 通过;构建通过;签名接口 curl 实测可用。⚠️ 浏览器端到端未测;**后端能否识别拼接的 URL 未验证**。",
+      "notes": "方案②(用户 2026-09-18 拍板):把 OSS 地址拼进 question 文本 —— **不需要后端配合**。\n关键实现点:sendMessage 里拆成两个变量 —— `questionForBackend`(含附件地址,只发给后端)与 `text`(用户原文,用于本地气泡、会话标题、**DMS 记录**),避免 URL 出现在界面上。\n拼接格式:原文 + 换行 + 每条 URL 一行,**不加自定义措辞**;无有效 URL 时原样返回。\n入口:上传图片/附件两个按钮(原先注释,本轮恢复)+ **拖拽/粘贴(本来就是通的)**。\n限制:图片 20MB(jpg/jpeg/png/webp)、附件 50MB(pdf/doc/docx/txt/md)。\n\n⚠️ **存储与依赖**:上传走厂商的签名接口 `//qingpu-data-api.metamaker.cn/common/qp_signed_url`(实测**无需鉴权**),文件实际存在**厂商的 OSS bucket `heijing-products`(杭州)**,外链为 `https://prod.heijingai.com/qingpu/<uuid>.<ext>`。签名有效期约 15 分钟。\n签名地址已做成 env `VITE_UPLOAD_SIGN_URL`(四份 env 均写),换自建服务只改配置。\n\n⚠️ **未验证**:后端如何处理这段拼接的 URL(认不认、会不会抓取)未确认 —— 这是方案②的固有不确定性。\n\n备选方案(未采用):①后端扩展请求体接受 files/file_pos(最干净);③后端提供文件登记接口返回 file_id。"
     },
     {
       "id": "chat-markdown-format",

+ 47 - 0
harness/progress.md

@@ -1986,3 +1986,50 @@
 - **已知风险或未解决问题**:见技术债第 6 条(厂商依赖 + 硬编码凭据)
 - **下一步最佳动作**:无(本轮为记录决定)
 
+## Session 049
+
+- **日期**:2026-09-18
+- **本轮目标**:完善上传图片/附件功能(用户选**方案②**:把 OSS 地址拼进 question),
+  并说明「若要替换厂商接口需要提供什么」
+- **已完成**:
+  1. **新增纯函数 `buildQuestionWithAttachments(question, fileUrls)`**(`shared.ts`):
+     原文 + 换行 + 每条 URL 一行;**不加「附件:」之类自定义措辞**(不凭空发明后端可能不认的写法);
+     无有效 URL 时**原样返回**(不能凭空多换行,否则影响所有普通提问)
+  2. **`sendMessage` 拆开两个变量**(关键):
+     - `questionForBackend` = 拼了附件地址 → **只发给后端**
+     - `text` = 用户原文 → 本地消息内容、会话标题、**DMS 记录**
+     这样用户气泡里不会出现一长串 URL,DMS 记录也保持可读
+  3. **恢复两个上传入口按钮**(`BusinessAssistantMobileInputShell.vue` 里原先整块注释):
+     上传图片 / 上传附件。拖拽与粘贴**本来就是通的**,不是本轮才恢复
+  4. `OSS_SIGNATURE_URL` 改为**可配置**:新增 env `VITE_UPLOAD_SIGN_URL`(四份 env 都写,
+     值仍是厂商地址)→ 将来换自建签名服务**只改配置、不改代码**
+- **实测确认(重要)**:
+  - **厂商的签名接口今天可用,且完全不需要鉴权**:
+    `POST //qingpu-data-api.metamaker.cn/common/qp_signed_url` body `{ext}` → 返回
+    `file_url`(`https://prod.heijingai.com/qingpu/<uuid>.<ext>`)、`host`
+    (`https://heijing-products.oss-cn-hangzhou.aliyuncs.com`)、`key`、`policy` 等
+  - ⚠️ **上传的文件实际存在厂商的 OSS bucket(`heijing-products`)里,不是我们的** ——
+    政务场景下这点值得评估(数据在第三方)
+  - 签名有效期约 15 分钟(policy 的 expiration)
+- **运行过的验证**:
+  - **新增 `harness/tools/verify-attachment-question.mjs` —— 10/10 通过**:
+    无附件原样返回(undefined/null/空数组/全空串)、有附件时原文在前且 URL 各一行、
+    **不添加自定义措辞**、脏数据过滤、URL 去空格
+  - `npm run build` 通过
+  - 签名接口 curl 实测(见上)
+- **已记录证据**:本文件 Session 049;`verify-attachment-question.mjs`;签名接口实测响应
+- **更新过的文件或工件**:`src/components/business-assistant/shared.ts`、
+  `src/components/business-assistant/useBusinessAssistantChat.ts`、
+  `src/components/business-assistant/useBusinessAssistantUpload.ts`、
+  `src/components/BusinessAssistantMobileInputShell.vue`、四份 `.env.*`、
+  `harness/tools/{_entry-attachments.ts,verify-attachment-question.mjs}`(新增)、
+  根 `README.md`、`harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **未在浏览器实测**:要确认「选文件 → 上传成功 → 提问 → 后端确实收到带 URL 的 question」
+  - ⚠️ **后端如何解析这段 URL 未知**:现在只是把 URL 附在问题后面,**后端认不认、会不会去抓取,
+    没有验证过**(这是方案②的固有不确定性)
+  - ⚠️ 附件地址会随 question 进入后端;**DMS 记录存的是原文**(本轮特意拆开),
+    若要改成一并入库,改一行
+  - ⚠️ 上传的图片/附件存放在**厂商的 OSS bucket**(数据在第三方)
+- **下一步最佳动作**:浏览器实测整条链路;并确认后端能否识别拼接的 URL
+

+ 2 - 0
harness/tools/_entry-attachments.ts

@@ -0,0 +1,2 @@
+/** esbuild 入口:附件地址拼进 question 的纯逻辑 */
+export { buildQuestionWithAttachments } from "../../src/components/business-assistant/shared";

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

@@ -0,0 +1,53 @@
+/**
+ * 验证「把附件地址拼进 question 文本」这条链路(方案②)。
+ *
+ * 背景(2026-09-18):新接口 /api/chat 只收 {thread_id, question},多字段 422,
+ * 旧协议的 transmission.files 发不出去;改为把 OSS 地址拼进 question。
+ *
+ * 不变量:
+ *   ① 没有附件时**必须原样返回**问题(不能凭空多个换行 —— 那会改变所有普通提问)
+ *   ② 有附件时:原文在前、每条 URL 各占一行、**不加**任何自定义措辞
+ *   ③ 空/无效 URL 被过滤(上传失败的文件不该混进问题里)
+ *
+ * 怎么跑(在项目根目录):
+ *   npx esbuild harness/tools/_entry-attachments.ts --bundle --format=esm \
+ *     --outfile=harness/tools/_attachments.mjs
+ *   node harness/tools/verify-attachment-question.mjs
+ */
+
+import { buildQuestionWithAttachments } from './_attachments.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);
+  }
+};
+
+const Q = '帮我看看这份材料符合条件吗';
+const U1 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/a.png';
+const U2 = 'https://bucket.oss-cn-shanghai.aliyuncs.com/upload/b.pdf';
+
+console.log('【1】没有附件时:必须原样返回');
+check('undefined → 原文', buildQuestionWithAttachments(Q, undefined) === Q);
+check('null → 原文', buildQuestionWithAttachments(Q, null) === Q);
+check('空数组 → 原文', buildQuestionWithAttachments(Q, []) === Q);
+check('全是空串 → 原文(不要多个换行)', buildQuestionWithAttachments(Q, ['', '   ', null]) === Q, JSON.stringify(buildQuestionWithAttachments(Q, ['', '   ', null])));
+
+console.log('\n【2】有附件时:原文在前、URL 各一行');
+check('单个附件', buildQuestionWithAttachments(Q, [U1]) === `${Q}\n${U1}`, JSON.stringify(buildQuestionWithAttachments(Q, [U1])));
+check('多个附件按顺序', buildQuestionWithAttachments(Q, [U1, U2]) === `${Q}\n${U1}\n${U2}`);
+check('**原文完整保留在前面**', buildQuestionWithAttachments(Q, [U1]).startsWith(Q + '\n'));
+check('**不添加自定义措辞**(只有原文与 URL)', !/附件|文件|attachment/i.test(buildQuestionWithAttachments(Q, [U1]).slice(Q.length)));
+
+console.log('\n【3】脏数据过滤');
+check('混入空值只保留有效 URL', buildQuestionWithAttachments(Q, [U1, '', null, U2]) === `${Q}\n${U1}\n${U2}`);
+check('URL 前后空格被去掉', buildQuestionWithAttachments(Q, [`  ${U1}  `]) === `${Q}\n${U1}`);
+
+console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
+process.exit(fail ? 1 : 0);

+ 1 - 5
src/components/BusinessAssistantMobileInputShell.vue

@@ -33,8 +33,7 @@
 
       <div class="input-actions-bar">
         <div class="actions-left">
-          <!-- [旧协议] 新接口 /api/chat 只接受 {thread_id, question},不支持文件上传,入口隐藏 -->
-          <!--
+          <!-- 上传入口:附件地址会拼进 question 文本发给后端(见 useBusinessAssistantChat 的 sendMessage) -->
           <button type="button" class="action-btn" aria-label="上传图片" @click="emit('imageUpload')">
             <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
               <path
@@ -42,8 +41,6 @@
                 fill="currentColor" />
             </svg>
           </button>
-          -->
-          <!--
           <button type="button" class="action-btn" aria-label="上传附件" @click="emit('fileUpload')">
             <svg width="13" height="20" viewBox="0 0 13 20" fill="none">
               <path
@@ -51,7 +48,6 @@
                 fill="currentColor" />
             </svg>
           </button>
-          -->
           <button type="button" class="action-btn" :class="{ 'voice-active': isVoiceInputMode }"
             :aria-pressed="isVoiceInputMode" :disabled="isVoiceInputStarting" aria-label="语音输入"
             @click="emit('voiceToggle')">

+ 29 - 0
src/components/business-assistant/shared.ts

@@ -321,3 +321,32 @@ export const normalizeSessionHistory = (session: BusinessAssistantSession) => {
 
   return changed;
 };
+
+/**
+ * 组装**发给后端的 question**:把已上传附件的地址拼在问题后面。
+ *
+ * 背景(2026-09-18):新接口 `POST /api/chat` 的请求体**只接受 `{thread_id, question}`
+ * 两个字段**,多一个就 422,所以旧协议的 `transmission.files / file_pos` 发不出去。
+ * 经用户拍板走「把 OSS 地址拼进 question 文本」这条**不需要后端配合**的路子。
+ *
+ * ⚠️ **只用于发给后端**,不要拿它当本地消息内容 —— 否则用户气泡里会显示一长串 URL。
+ * 本地展示仍用用户输入的原文。
+ *
+ * 格式:原文 + 换行 + 每条 URL 一行(**不加「附件:」之类的字眼**,
+ * 免得凭空发明一套后端可能不认识的措辞;URL 本身就说明了一切)。
+ * 若后端要求特定标记,改这一个函数即可。
+ */
+export const buildQuestionWithAttachments = (
+  question: string,
+  fileUrls?: Array<string | null | undefined> | null,
+): string => {
+  const urls = (Array.isArray(fileUrls) ? fileUrls : [])
+    .map((url) => String(url ?? '').trim())
+    .filter(Boolean);
+
+  if (!urls.length) {
+    return question;
+  }
+
+  return `${question}\n${urls.join('\n')}`;
+};

+ 8 - 3
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -19,6 +19,7 @@ import {
   BusinessAssistantSuggestion,
   createBusinessAssistantId,
   appendAiMessageChunk,
+  buildQuestionWithAttachments,
   businessAssistantSuggestions,
   createAiMessageChunkBuffer,
   createUserMessage,
@@ -444,10 +445,14 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
     const userMessagePos = session.messages.filter((message) => message.role === 'user').length;
     const fileTransmission = options.getInputFileTransmission?.(userMessagePos);
-    // [旧协议] 新接口 /api/chat 只接受 {thread_id, question},不再支持文件上传;
-    // 上传入口已隐藏,这里不再写入 transmission.files/file_pos
+    // [旧协议] 新接口 /api/chat 只接受 {thread_id, question},多一个字段 422,
+    // 因此 transmission.files / file_pos 发不出去(下面两行停用)。
+    // 现改为把附件的 OSS 地址**拼进 question 文本**(用户 2026-09-18 拍板的方案②):
+    //   · questionForBackend —— 只发给后端,含附件地址
+    //   · text               —— 本地消息内容、会话标题、DMS 记录,保持原文(不显示 URL)
     // globalThis.transmission.files = fileTransmission?.files || [];
     // globalThis.transmission.file_pos = fileTransmission?.file_pos || [];
+    const questionForBackend = buildQuestionWithAttachments(text, fileTransmission?.files);
 
     session.messages.push(createUserMessage(text, fileTransmission?.attachments || []));
     // 预置一条空的 AI 消息:它的 id 既是本轮的 DMS 记录标识(c_record_id),
@@ -470,7 +475,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     // fire-and-forget,失败只 warn,不影响聊天
     saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: text });
 
-    void coordinator.generateAnswer(text);
+    void coordinator.generateAnswer(questionForBackend);
   };
 
   const playMockStreamMessage = async ({

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

@@ -26,7 +26,11 @@ interface OssSignaturePayload {
   [key: string]: any;
 }
 
-const OSS_SIGNATURE_URL = '//qingpu-data-api.metamaker.cn/common/qp_signed_url';
+// 取 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__';
 
 const getOssField = (payload: OssSignaturePayload, kebabName: string, snakeName: string) =>