Procházet zdrojové kódy

feat(dms): 附件路径落库到 c_image / c_file(用户要求)

用户要求:「现在图片和文件路径并没有存到 DMS 里面,我已经在 DMS 助手问答记录新建了
两个字段 c_image 和 c_file,后续也要将对应的路径存到对应的字段里。」

动手前先验前提(这一步不能省):DMS_API.md 记着一条实测陷阱 —— payload 里含模型里
不存在的列时,addContent 会忽略该列照写,而 updateContent 会**静默丢弃整个 patch**
(返回仍是 code 200「成功」)。所以先拉模型定义确认:
POST /dms/model/getModelById?modelId=2038 → fieldList 里 c_image(图片)与 c_file(文件)
都在,frontType: text ✓(模型字段数 13 → 15)。

存什么值:存**相对路径**(/static/photo/…),即发给后端 pictures/files 的同一个值 ——
库里记的和后端收到的是同一份,好对账;不存 /dms-api 展示地址(环境相关的拼接结果,
塞进库里等于把部署环境写死)。多张图/多个文件用逗号分隔(路径本身不含逗号)。

实现:
- chat-sessions-dms.ts:upsertDmsRecord 新增 images/files,经 joinDmsPathList
  (过滤空串/纯空白 → 逗号拼)写进 c_image / c_file;没有附件就不写这两列
  (与「没附件时请求体不多发字段」同口径);更新路径只在「有值且该列还空着」时才进 patch
  —— 万一这两列在 DMS 侧被删,最坏也只影响带附件的轮次,不会连累补 answer 这条主链路
- useBusinessAssistantChat.ts:saveTurnToDms 透传;sendMessage 直接用
  attachmentsForBackend.pictures/.files(不另算一套地址)

验证:verify-dms-chat-storage.mjs 65 → 78/78(打真实 DMS,新增【8】13 项:模型列存在性、
两张图+一个文件真写入并读回、补 answer 时附件列不被覆盖且 answer 本身写进去了、
无附件时两列不写、脏值过滤);变异测试阉掉 c_image 写入 → 3 项失败。
verify-attachment-question.mjs 51 → 57/57(新增【8】6 项 hook 接线的源码级断言 ——
这段是 Vue hook 里的事,node 跑不起来)。build、vue-tsc、verify-toast-channel 27/27、
validate-harness 均通过。
文档同步:DMS_COLUMNS.md 补两列、字段数 13 → 15,写清相对路径 + 逗号分隔 + 陷阱约束。

⚠️ 仍需用户在浏览器确认:带图提问一次,在 DMS 里看那条记录的 c_image 有没有值。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao před 1 měsícem
rodič
revize
a2b302fa69

+ 9 - 0
README.md

@@ -392,6 +392,15 @@
     `toChatAttachmentUrls` 改取 `fileUrl`(发给后端的地址与从前一致,不受影响)
   - 非图片附件没有展示地址(气泡里本来就是「文件条」,只显示名字/大小)
   - 回归脚本 39 → **51/51**(新增【7】12 项钉死这个契约,并用变异测试确认两个方向都会红)
+- **附件路径随问答记录落 DMS(`c_image` / `c_file`)**(用户 2026-09-23:在「助手问答记录」里
+  新建了这两个字段,要求把对应的路径存进去):
+  - 存的是**相对路径**(`/static/photo/…`)—— 与请求体 `pictures`/`files` 里发给后端的是
+    **同一个值**,库里记的和后端收到的能对上账;不存 `/dms-api` 展示地址(那是环境相关的拼接结果)
+  - 多张图/多个文件用**逗号**分隔;**没有附件的那一轮不写这两列**
+  - 写入前先拉模型定义确认两列真的存在 —— DMS 的坑:`updateContent` 遇到模型里没有的列会
+    **静默丢弃整个 patch**(返回仍是 200「成功」),所以更新路径只在「真有附件值」时才捎上这两列,
+    最坏也只影响带附件的轮次,不会连累补 answer
+  - 验证:`verify-dms-chat-storage.mjs` 65 → **78/78**(打真实 DMS 的写入-读回-补 answer 全链路)
 - **🔴 待处理(登记进技术债 #9)**:`.env.production` / `.env.qingpu` / `.env.test`
   **都没有配 `VITE_DMS_API`** —— 这些环境打出来的包里附件**传不上去**(直接抛「未配置 DMS 接口地址」)、
   DMS 会话落库也**静默跳过**。接生产要先定:DMS 从部署环境是否可达、**token 由谁注入**

+ 15 - 2
harness/docs/reference/DMS_COLUMNS.md

@@ -42,11 +42,12 @@
 | 企业荣誉信息 | **1886** | `yszs_qcc_honor` | **2032** | **13** | `qcc_honor` | 7,118 |
 | 助手会话 | **1887** | `yszs_chat_session` | **2034** | **23** | `chat_session` | 3,133 |
 | 企业基础信息 | **1888** | `yszs_qcc_enterprise` | **2036** | **47** | `qcc_enterprise` | 81,980 |
-| 助手问答记录 | **1889** | `yszs_chat_record` | **2038** | **13** | `chat_record` | 15,542 |
+| 助手问答记录 | **1889** | `yszs_chat_record` | **2038** | **15** | `chat_record` | 15,542 |
 
 > ⚠️ 「字段数」一列原为建栏目时的值(11 / 7 / 41),**已被 DMS 侧扩过**:
 > 1886 → 13(`c_tag_name`、`c_honor`)、1887 → 23(运营分析字段,见 `chat-sessions-dms.ts` 的说明)、
-> 1888 → 47(六个 `c_tag_*`)。**改字段后请重新核对本表**(`getModelById` 拉 fieldList)。
+> 1888 → 47(六个 `c_tag_*`)、**1889 → 15(`c_image`、`c_file`,2026-09-23 用户新建)**。
+> **改字段后请重新核对本表**(`getModelById` 拉 fieldList)。
 
 **调 content 接口时用 `columnId`。** 权限用 `tag`(已同步 OAuth `permissionName`)。
 
@@ -232,6 +233,8 @@
 | `c_record_id` | 记录ID | text | varchar | |
 | `c_question` | 问题 | **text** | text | |
 | `c_answer` | 回答 | **text** | text | |
+| `c_image` | 图片 | **text** | text | |
+| `c_file` | 文件 | **text** | text | |
 | `c_reason` | 原因 | **text** | text | |
 | `c_source` | 来源 | text | varchar | |
 | `c_feedback_status` | 反馈状态 | integer | int_num | |
@@ -240,6 +243,16 @@
 | `c_created_at` | 创建时间 | **timestamp** | date_time | |
 | `c_feedback_at` | 反馈时间 | **timestamp** | date_time | |
 
+> 📎 **`c_image` / `c_file`(2026-09-23 用户新建,同日前端接上)**:存本轮附件的
+> **相对路径**(`/static/photo/…`、`/static/file/…`)—— 与请求体 `pictures` / `files`
+> 里发给后端的是**同一个值**,便于对账。多张图/多个文件用**逗号**分隔(路径本身不含逗号)。
+> 没有附件的那一轮**不写这两列**。写入见 `chat-sessions-dms.ts` 的 `upsertDmsRecord`
+> 与 `verify-dms-chat-storage.mjs` 的【8】。
+>
+> ⚠️ 这两列**必须先在 DMS 侧存在**,否则 `updateContent` 会把**整个 patch 静默丢弃**
+> (见 `DMS_API.md` 的写入陷阱)—— 所以写入端只在「真有附件值」时才把列捎进 patch,
+> 把最坏影响限制在带附件的轮次。
+
 ---
 
 ### 2.6 `company_info` → DMS 的字段覆盖现状(2026-09-20 审计)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 4 - 2
harness/feature_list.json


+ 40 - 0
harness/progress.md

@@ -3903,4 +3903,44 @@ AntD message 时,语音这几处按「不在要求范围内」留着没动,
 要接生产得先拍板两件事:DMS 从部署环境是否可达、**token 由谁注入**
 (开发环境是 vite 代理转发时加 `token` 头,生产得 nginx 做同样的事)。
 
+### 续 29:附件路径落 DMS 的 c_image / c_file(用户要求)
+
+**用户要求**:「现在图片和文件路径并没有存到 DMS 里面,我已经在 DMS 助手问答记录新建了两个字段
+c_image 和 c_file,后续也要将对应的路径存到对应的字段里。」
+
+**动手前先验前提(这一步不能省)**:`DMS_API.md` 记着一条实测陷阱 ——
+payload 里含模型里**不存在**的列时,`addContent` 会忽略该列照写,而 `updateContent` 会
+**静默丢弃整个 patch**(返回仍是 `code 200「成功」`)。所以先拉模型定义确认列真的在:
+`POST /dms/model/getModelById?modelId=2038` → fieldList 里 **`c_image`(别名「图片」)与
+`c_file`(别名「文件」)都在,`frontType: text`** ✓(模型字段数 13 → 15)。
+
+**存什么值**:存**相对路径**(`/static/photo/…`),也就是**发给后端 `pictures`/`files` 的同一个值**
+—— 库里记的和后端收到的是同一份,出问题好对账;不存 `/dms-api` 展示地址(那是环境相关的拼接结果,
+塞进库里就等于把部署环境写死)。多张图/多个文件用**逗号**分隔(路径本身不含逗号)。
+
+**实现**:
+- `chat-sessions-dms.ts`:`upsertDmsRecord` 新增 `images?: string[]` / `files?: string[]`,
+  经 `joinDmsPathList`(过滤空串/纯空白 → 逗号拼)写进 `c_image` / `c_file`;
+  **没有附件就不写这两列**(与「没附件时请求体不多发字段」同口径);
+  更新路径只在「有值且该列还空着」时才进 patch —— 万一哪天这两列在 DMS 侧被删,
+  最坏也只影响带附件的轮次,**不会连累补 answer 这条主链路**(那正是陷阱的杀伤方式)
+- `useBusinessAssistantChat.ts`:`saveTurnToDms` 透传两个参数;`sendMessage` 里
+  直接用 `attachmentsForBackend.pictures / .files`(不再另算一套地址)
+- 没做「读回来」:`fetchDmsSessionRecords` 仍只取 question/answer(历史消息渲染附件是另一件事,未要求)
+
+**运行过的验证**:
+- `verify-dms-chat-storage.mjs` 65 → **78/78**(打**真实 DMS**,新增【8】13 项):
+  ①先断言模型里有这两列(守住陷阱的前提)②两张图 + 一个文件真写入 → 读回是逗号拼的相对路径
+  ③**补 answer 时附件列不被覆盖、且 answer 本身写进去了**(证明没连累主链路)
+  ④无附件时两列不写 ⑤脏值(空串/纯空格)被过滤、`files` 不传时不写 `c_file`
+- 变异测试:阉掉 `if (images) content.c_image = images;` → **3 项失败**;还原后 78/78
+- `verify-attachment-question.mjs` 51 → **57/57**:新增【8】6 项**源码级**接线断言
+  (hook 有没有把路径递下去 —— 这段是 Vue hook 里的事,node 跑不起来)
+- `npm run build` 通过;`vue-tsc` 改动文件无类型错误;`verify-toast-channel.mjs` 27/27;
+  `validate-harness` 通过
+- 文档同步:`DMS_COLUMNS.md` 的 1889 字段表补两列、速查表字段数 13 → 15(该文件自己要求
+  「改字段后重新核对本表」),并写清存的是相对路径 + 逗号分隔 + 陷阱约束
+
+**⚠️ 仍需用户在浏览器确认**:带图提问一次,然后在 DMS 里看那条记录的 `c_image` 是否有值。
+
 

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

@@ -368,5 +368,30 @@ console.log('\n【7】消息附件:`url`(能显示)与 `fileUrl`(发给
   globalThis.fetch = restoreFetch;
 }
 
+console.log('\n【8】附件路径随请求一起落 DMS(源码级 —— hook 里的事,node 跑不起来)');
+{
+  // 落库那一端的读写行为由 verify-dms-chat-storage.mjs 的【8】打真实 DMS 验(13 项);
+  // 这里只钉「hook 有没有把路径递下去」这一段接线(用户 2026-09-23 要求:
+  // 图片进 c_image、文件进 c_file)。
+  const hook = readFileSync('src/components/business-assistant/useBusinessAssistantChat.ts', 'utf8');
+  const dms = readFileSync('src/network/api/dms/chat-sessions-dms.ts', 'utf8');
+
+  check('sendMessage 把附件路径一并交给 DMS 写入',
+    /images:\s*attachmentsForBackend\.pictures/.test(hook) && /files:\s*attachmentsForBackend\.files/.test(hook));
+  check('用的就是发给后端的那两份(同一口径,不是另算一套地址)',
+    hook.split('images: attachmentsForBackend.pictures').length - 1 === 1);
+  check('upsertDmsRecord 收 images / files 两个参数',
+    /images\?: string\[\]/.test(dms) && /files\?: string\[\]/.test(dms));
+  check('写入落到 c_image / c_file 两列',
+    /content\.c_image = images/.test(dms) && /content\.c_file = files/.test(dms));
+  check('没有附件时不写这两列(与请求体同口径)',
+    /if \(images\) content\.c_image = images;/.test(dms) && /if \(files\) content\.c_file = files;/.test(dms));
+  // ⚠️ updateContent 遇到「模型里不存在的列」会静默丢弃**整个** patch(DMS_API.md 的陷阱):
+  // 附件列必须只在真有值时才进 patch,否则补 answer 这条主链路会被连累
+  check('更新路径里附件列只在「有值且该列还空着」时才进 patch',
+    /if \(images && !existing\.c_image\) patch\.c_image = images;/.test(dms) &&
+      /if \(files && !existing\.c_file\) patch\.c_file = files;/.test(dms));
+}
+
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 87 - 0
harness/tools/verify-dms-chat-storage.mjs

@@ -55,6 +55,7 @@ const {
   deleteDmsContent,
   DMS_COLUMN_SESSION,
   DMS_COLUMN_RECORD,
+  DMS_MODEL_RECORD,
   enterpriseInfo,
   resolveVisitorLabel,
 } = await import('./_dms.mjs');
@@ -361,5 +362,91 @@ console.log('\n【7】title 字段');
   await deleteDmsSession(SID2);
 }
 
+// ── 8. 附件路径落库:c_image / c_file(用户 2026-09-23:新建了这两列,要求把路径存进去)──
+console.log('\n【8】附件路径 → c_image / c_file');
+{
+  const SID10 = SESSION_ID + '_att';
+  const REC_A = 'recA_' + Date.now().toString(36);
+  const REC_B = 'recB_' + Date.now().toString(36);
+  const IMG_1 = '/static/photo/1889-c_question-aaaa.png';
+  const IMG_2 = '/static/photo/1889-c_question-bbbb.jpg';
+  const FILE_1 = '/static/file/1889-c_question-cccc.xlsx';
+
+  const rowOf = async (columnId, field, value) =>
+    (
+      await searchDmsContents(columnId, {
+        search: [{ field, searchType: 1, content: { value } }],
+        page: 0,
+        pageSize: 5,
+      })
+    )[0];
+
+  // 8.1 ⚠️ 先确认这两列**确实在模型里** —— 这是这次改动的硬前提。
+  // `updateContent` 遇到模型里不存在的列会**静默丢弃整个 patch**(返回仍 code 200),
+  // 见 docs/reference/DMS_API.md 的写入陷阱:真那样,补 answer 这条主链路会被连累。
+  {
+    const res = await fetch(`${base}/model/getModelById?modelId=${DMS_MODEL_RECORD}`, { method: 'POST' });
+    const body = await res.json().catch(() => ({}));
+    let fields = {};
+    try {
+      fields = JSON.parse(body?.content?.fieldList || '{}');
+    } catch {
+      /* 取不到就当空 —— 下面的断言会报出来 */
+    }
+    check('模型 2038 里 c_image 列存在(否则前端不该发它)', !!fields.c_image, Object.keys(fields).join(','));
+    check('模型 2038 里 c_file 列存在', !!fields.c_file);
+    check('两列都是文本列(多值用逗号拼是安全的)',
+      fields.c_image?.frontType === 'text' && fields.c_file?.frontType === 'text',
+      `${fields.c_image?.frontType} / ${fields.c_file?.frontType}`);
+  }
+
+  // 8.2 带图 + 带文件:两张图 + 一个文件
+  enterpriseInfo.value = { credit_code: TEST_CREDIT, name: '验证企业' };
+  await upsertDmsRecord({
+    recordId: REC_A,
+    sessionId: SID10,
+    question: '这两张图里是什么?',
+    images: [IMG_1, IMG_2],
+    files: [FILE_1],
+  });
+  let row = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC_A);
+  check('c_image 存的是两张图的相对路径(逗号分隔)', row?.c_image === `${IMG_1},${IMG_2}`, JSON.stringify(row?.c_image));
+  check('c_file 存的是文件的相对路径', row?.c_file === FILE_1, JSON.stringify(row?.c_file));
+  check('question 照旧', row?.c_question === '这两张图里是什么?', JSON.stringify(row?.c_question));
+
+  // 8.3 补 answer 时**不能**把附件路径冲掉、也不能把整条 patch 弄失败
+  await upsertDmsRecord({ recordId: REC_A, sessionId: SID10, answer: '第一轮回答。' });
+  row = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC_A);
+  check('补 answer 后 c_image 还在(没被覆盖)', row?.c_image === `${IMG_1},${IMG_2}`, JSON.stringify(row?.c_image));
+  check('补 answer 后 c_file 还在', row?.c_file === FILE_1, JSON.stringify(row?.c_file));
+  check('**补 answer 本身写进去了**(附件列没连累主链路)', row?.c_answer === '第一轮回答。', JSON.stringify(row?.c_answer));
+
+  // 8.4 没有附件的一轮:这两列保持不写(别写空串、别写 null)
+  await upsertDmsRecord({ recordId: REC_B, sessionId: SID10, question: '只发文字', images: [], files: [] });
+  const rowB = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC_B);
+  check('无附件时 c_image 不写', !rowB?.c_image, JSON.stringify(rowB?.c_image));
+  check('无附件时 c_file 不写', !rowB?.c_file, JSON.stringify(rowB?.c_file));
+
+  // 8.5 脏值过滤:空串 / 纯空格 / 夹带的空值都不能拼进去
+  const REC_C = 'recC_' + Date.now().toString(36);
+  await upsertDmsRecord({
+    recordId: REC_C,
+    sessionId: SID10,
+    question: '脏值',
+    images: ['  ', IMG_1, ''],
+    files: undefined,
+  });
+  const rowC = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC_C);
+  check('脏值被过滤(只剩真路径,没有多余逗号)', rowC?.c_image === IMG_1, JSON.stringify(rowC?.c_image));
+  check('没传 files 时不写 c_file', !rowC?.c_file, JSON.stringify(rowC?.c_file));
+
+  enterpriseInfo.value = null;
+  for (const rec of [REC_A, REC_B, REC_C]) {
+    const r = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', rec);
+    if (r?.id) await deleteDmsContent(DMS_COLUMN_RECORD, r.id);
+  }
+  console.log('  (本节测试数据已清理)');
+}
+
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 16 - 2
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -106,13 +106,19 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
   /**
    * 把一轮问答写进 DMS(一问一答一条,幂等键 = 该轮 AI 消息 id)。
-   * 提交时只带 question,回答完成/中断时再带 answer 补一次。
+   * 提交时只带 question(+ 附件路径),回答完成/中断时再带 answer 补一次。
+   *
+   * `images` / `files` 存的是附件**相对路径**(用户 2026-09-23 要求落到
+   * `c_image` / `c_file` 两列),值与请求体 `pictures` / `files` 完全一致 ——
+   * 这样库里记的和后端收到的是同一份,出问题好对账。
    */
   const saveTurnToDms = (params: {
     sessionId: string;
     recordId: string;
     question?: string;
     answer?: string;
+    images?: string[];
+    files?: string[];
   }) => {
     void upsertDmsRecord(params);
   };
@@ -615,7 +621,15 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     // (那时才知道本轮有没有 company_info,见 upsertDmsSession 的注释)。
     // 别在这里补一行,否则同一次会话会写两次。
     // fire-and-forget,失败只 warn,不影响聊天
-    saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: text });
+    saveTurnToDms({
+      sessionId: session.id,
+      recordId: pendingAiMessage.id,
+      question: text,
+      // 附件路径一并落库(用户 2026-09-23 要求):直接复用给后端的那两份,
+      // 保证 **DMS 里存的就是后端收到的**(相对路径),不会各存一份口径不同的地址
+      images: attachmentsForBackend.pictures,
+      files: attachmentsForBackend.files,
+    });
 
     void coordinator.generateAnswer(text, attachmentsForBackend);
   };

+ 37 - 0
src/network/api/dms/chat-sessions-dms.ts

@@ -317,6 +317,20 @@ export const upsertDmsSession = (params: {
     return updateDmsContent(DMS_COLUMN_SESSION, DMS_MODEL_SESSION, id, patch);
   });
 
+/**
+ * 附件路径列表 → DMS 文本列的值(`c_image` / `c_file`)。
+ *
+ * 这两列是 2026-09-23 用户新建的(模型 2038,`frontType: text`),存**相对路径**
+ * (与请求体 `pictures` / `files` 里发给后端的是同一个值,便于对账)。
+ * 多个用**逗号**分隔 —— 路径里不含逗号(DMS 生成的文件名是 uuid),所以是安全的。
+ * 空值/空串/纯空白一律过滤,全空时返回空串(调用方据此决定写不写这一列)。
+ */
+const joinDmsPathList = (list?: string[]): string =>
+  (Array.isArray(list) ? list : [])
+    .map((item) => String(item ?? "").trim())
+    .filter(Boolean)
+    .join(",");
+
 /**
  * 问答记录 upsert(栏目 1889,幂等键 `c_record_id`)—— **一问一答一条**。
  *
@@ -335,6 +349,13 @@ export const upsertDmsRecord = (params: {
   creditCode?: string;
   question?: string;
   answer?: string;
+  /**
+   * 本轮附件路径(**相对路径**,与请求体 `pictures` 里是同一个值)。
+   * 多张用逗号分隔后写进 `c_image`(见 `joinDmsPathList`)。
+   */
+  images?: string[];
+  /** 本轮非图片附件路径,写进 `c_file`。口径同上。 */
+  files?: string[];
 }): Promise<boolean> =>
   enqueueDmsWrite(`record:${params.recordId}`, async () => {
     // 同样先确保拿到 IP:1889 没有 c_ip 列,访客归属只能写进 c_credit_code
@@ -356,6 +377,13 @@ export const upsertDmsRecord = (params: {
         content.title = params.question;
       }
       if (params.answer !== undefined) content.c_answer = params.answer;
+      // 附件路径(用户 2026-09-23 要求:图片进 c_image、文件进 c_file)。
+      // 没有附件就**不写这两列** —— 与「没附件时请求体不多发字段」同一口径,
+      // 也让「后端/库表若回滚」只影响带附件的那几轮。
+      const images = joinDmsPathList(params.images);
+      const files = joinDmsPathList(params.files);
+      if (images) content.c_image = images;
+      if (files) content.c_file = files;
       content.c_created_at = nowDmsTimestamp();
       const id = await addDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, content);
       if (!id) console.warn("[dms] 问答记录写入失败", params.recordId);
@@ -371,6 +399,15 @@ export const upsertDmsRecord = (params: {
     // 不覆盖已有内容:补 answer 时别把 question 冲掉
     if (params.question !== undefined && !existing.c_question) patch.c_question = params.question;
     if (params.answer !== undefined) patch.c_answer = params.answer;
+    // 附件路径同理:只在**有值且这一列还空着**时才进 patch。
+    // ⚠️ 这里必须克制 —— `updateContent` 遇到「模型里不存在的列」会**静默丢弃整个 patch**
+    // (返回仍是 code 200「成功」,见 docs/reference/DMS_API.md 的写入陷阱)。
+    // 把 c_image / c_file 只在真有附件时才捎上,最坏情况也只影响带附件的轮次,
+    // 不会连累补 answer 这条主链路。
+    const images = joinDmsPathList(params.images);
+    const files = joinDmsPathList(params.files);
+    if (images && !existing.c_image) patch.c_image = images;
+    if (files && !existing.c_file) patch.c_file = files;
     if (!Object.keys(patch).length) return true;
     return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, id, patch);
   });

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů