Sfoglia il codice sorgente

feat(dms): 1887 助手会话写企业字段(未登录落值 / 登录态优先覆盖)

用户需求:未登录时若对话带回 company_info 非空,把对应的值写进 DMS 1887 助手会话;
登录后也写且登录态的值优先级最高(已有值也改);未登录则不修改已有值。
主要针对企业名称、信用代码、登录状态。用户另选定:加企业图片;恢复会话时间;
c_title 不写;c_session_id 与 1889 一致;「对话带出的公司代码」单开一列(需后端加)。

实现(chat-sessions-dms.ts 的 upsertDmsSession 重写,规则写在函数注释里):
- 登录态的值(c_ent_name / c_credit_code / c_is_login="1")→ 无条件写(覆盖)
- 对话带出的值(c_ent_name / 新列 / c_ent_image_url)→ 只填空(未登录不修改)
- 归属键 c_credit_code:登录=企业代码,未登录=访客_<id>(未登录只填空)
- c_title 不写;系统字段 title 只在创建那一刻带会话标题(不写会落成字符串 "null")
- 必填运营位 summary/qpyszx/qyzt/rzqpyx 只在创建时带空值 —— 库里实测那些是运营分析
  系统回填的 JSON,更新时绝不能碰
- 访客且本轮无企业资料 → 不建行(尊重 2026-09-18「只传 1889」的决定)
- 触发点只有一处:回答完成的 totalResponse;另两处旧调用保留注释并注明别在此补写

🚨 过程中撞到 DMS 协议坑(已写进 DMS_API.md 告警框):payload 含模型里不存在的列时,
addContent 会忽略该列继续写,而 updateContent 会**静默丢弃整个 patch**(返回仍是
{"code":200,"message":"成功"})。实测 {c_ent_name:'A'} 生效,{c_ent_name:'B', c_不存在:'x'}
读回仍是 A。后果:新列未加好就发它,会把企业名称/归属/是否登录一起搞失效且看不出来。
→ 加开关 DMS_SESSION_CHAT_CREDIT_CODE_ENABLED = false 挡住,DMS 加列后改 true。

验证:verify-dms-chat-storage(打真实 DMS)55/55,新增【8】节 11 项覆盖上述规则;
npm run build 与 validate-harness 通过。给后端的需求见 tech-debt-tracker 条目 8。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 mese fa
parent
commit
1cd4cc6f20

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

@@ -101,6 +101,25 @@
   仍无 `text-size-adjust`、思考卡片仍靠 JS 测量切样式,`padding-bottom: 180px` 也仍是写死的)。
   **换台手机、或用户调大系统字号,仍可能变样**;方案 A/B/C 依然有效,等用户决定要不要做。
 
+### 8. 1887「助手会话」缺一列:对话带出的公司代码无处安放
+
+- **是什么**:未登录时若对话带回了 `company_info`(企查查),其中的**公司统一社会信用代码**
+  需要一个落脚处。1887 现有列不合适 —— `c_credit_code` 是这一行的**归属键**
+  (登录=企业代码,访客=`访客_<id>`,`fetchDmsSessions` 按它查「我的会话」),
+  写进去会让访客查不到自己的会话、而那家公司登录后会看到访客的会话。
+- **需要 DMS 侧做的事**:在**模型 2034(助手会话 / 栏目 1887)** 加一列:
+  - 字段名:`c_chat_credit_code`
+  - 别名建议:「对话企业信用代码」
+  - 类型:`varchar`(与 `c_credit_code` 一致)
+- **前端已就绪**:列名与取值逻辑都写好了,只等列存在 ——
+  然后把 `chat-sessions-dms.ts` 的 `DMS_SESSION_CHAT_CREDIT_CODE_ENABLED` 改成 `true`。
+- **⚠️ 为什么不先发着**:实测 `updateContent` 只要 payload 里含**一个模型里不存在的列**,
+  **整个 patch 会被静默丢弃**(返回仍是 200 成功)——先发它会连带把
+  「企业名称 / 归属 / 是否登录」一起搞失效。详细实测见 `docs/reference/DMS_API.md` 的告警框。
+- **欠着会怎样**:未登录时「这次对话讲的是哪家公司」只能靠 `c_ent_name` 间接看出,
+  没有代码可查(按代码去重/关联做不到)。
+- **该还的条件**:DMS 侧加列后改一个布尔值即可(脚本里已有对应断言,会自动从「跳过」转为「校验」)。
+
 ## 已还
 
 (还清后从上面移到此处,保留一行说明怎么还的,便于追溯)

+ 18 - 0
harness/docs/reference/DMS_API.md

@@ -43,6 +43,24 @@ token 通过环境变量传递,**不要写进任何文件**:
 export DMS_TOKEN='...'
 ```
 
+> 🚨 **写入前必看:未知列在 add 与 update 上的行为完全不同**(2026-09-22 协议层实测)
+>
+> | 端点 | payload 里有模型里**不存在**的列 | 返回 |
+> |---|---|---|
+> | `addContent` | **忽略该列,其余字段照写** ✅ | `{"code":200,"message":"成功"}` |
+> | `updateContent` | **整个 patch 被静默丢弃**(一个字段都不写)🚨 | `{"code":200,"message":"成功"}`(看不出异常) |
+>
+> 实测过程:同一行先 `updateContent {c_ent_name:'A'}` → 读回 `A` ✅;
+> 再 `updateContent {c_ent_name:'B', c_不存在的列:'x'}` → 返回 200「成功」,**读回仍是 `A`** ✗。
+>
+> **踩过的后果**:给 1887 加新列时,前端把新列名一起放进更新 patch,
+> 于是「企业名称 / 归属 / 是否登录」这些本来能写的字段**一起失效**,且返回码完全正常。
+> 现在用一个开关挡住(见 `chat-sessions-dms.ts` 的 `DMS_SESSION_CHAT_CREDIT_CODE_ENABLED`),
+> **列没加好之前不发它**。
+>
+> 结论:**新增列时,先确认 DMS 侧已加,再让前端发它**;排查「写入没生效」时,
+> 第一个要怀疑的就是 payload 里混进了模型里没有的列。
+
 > 📌 **本仓库开发环境的口径(2026-09-21 起)**:token 放在仓库根的**单行文件 `.dms-token`**
 > (按 `.gitignore` 不入库),由 `npm run dms:token` 写入(无参数时从**剪贴板**读)。
 > vite 代理**每个请求现读**它,所以换 token **不用重启 dev server**;

+ 40 - 0
harness/progress.md

@@ -3477,3 +3477,43 @@ F073 之后正文是**即时渲染**的(流式期间打字机关着),但**
 一节(A1),写清了现象、契约依据、实测数据与「若要根治得后端改」,避免以后重复排查。
 
 **本轮无代码改动**,只更新 harness 记录。
+
+### 续 17:1887 写企业字段(用户 2026-09-22 定的新功能)
+
+**需求**(用户原话拆解):
+- 未登录:对话带回 `company_info` 非空时,把对应的值写进 DMS **1887 助手会话**栏目
+- 登录后也写,且**登录态的值优先级最高**(库里已有值也要改);未登录则**不修改**已有值
+- 主要针对**企业名称、信用代码、登录状态**
+- 用户选定:另加 `c_ent_image_url`(企业图片);**恢复会话时间**;**`c_title` 不写**;
+  `c_session_id` 要写且**与 1889 保持一致**;「对话带出的公司代码」**单开一列**(需后端加)
+
+**实现**:
+- `chat-sessions-dms.ts` 重写 `upsertDmsSession`,规则写死在函数注释里:
+  - **登录态的值**(`c_ent_name` / `c_credit_code` / `c_is_login="1"`)→ **无条件写**(覆盖)
+  - **对话带出的值**(`c_ent_name` / 新列 / `c_ent_image_url`)→ **只填空**(未登录不修改)
+  - 归属键 `c_credit_code`:登录=企业代码,未登录=`访客_<id>`(未登录只填空,不顶掉已有)
+  - **`c_title` 不写**(标题由运营侧生成);DMS 系统字段 `title` 只在**创建那一刻**带会话标题
+    (不写会落成字符串 `"null"`,1889 踩过)
+  - 必填运营位(`summary`/`qpyszx`/`qyzt`/`rzqpyx`)**只在创建时带空值**:库里实测那些是
+    运营分析系统回填的 JSON,更新时**绝不能碰**
+  - 访客且本轮没带回企业资料 → **不建行**(尊重 2026-09-18「只传 1889」的决定)
+  - 触发点**只有一处**:回答完成(`totalResponse`),那时才知道本轮有没有 company_info。
+    代码里另两处 [已停用] 的旧调用保留了注释并写明「别在这里补一行,否则同一次会话写两次」
+- `_entry-dms.ts` 导出 `enterpriseInfo`(验证脚本要模拟登录/未登录)
+
+**🚨 过程中撞到一个 DMS 协议坑(已写进 DMS_API.md 的告警框)**:
+| 端点 | payload 含模型里**不存在**的列 |
+|---|---|
+| `addContent` | 忽略该列、其余照写 ✅ |
+| `updateContent` | **整个 patch 静默丢弃**,返回仍是 `{"code":200,"message":"成功"}` 🚨 |
+实测:`{c_ent_name:'A'}` → 读回 A ✅;再 `{c_ent_name:'B', c_不存在:'x'}` → 返回 200 成功、
+**读回仍是 A** ✗。后果:新列没加好就发它,会把「企业名称/归属/是否登录」**一起搞失效**且看不出来。
+→ 加开关 `DMS_SESSION_CHAT_CREDIT_CODE_ENABLED = false` 挡住,**DMS 侧加列后改 true 即可**。
+
+**运行过的验证**:`verify-dms-chat-storage.mjs`(打真实 DMS)**55/55 通过**,新增【8】节 11 项:
+未登录落值 / 未登录第二轮不改 / 登录覆盖名称与归属 / 登录时对话值不顶掉登录值 / 退出后不被改回 /
+`c_title` 从不写 / 系统 title 只在创建时初始化。
+`npm run build` 与 `validate-harness` 通过。
+
+**给后端的需求**:见 `docs/exec-plans/tech-debt-tracker.md` 条目 8 ——
+在模型 2034(栏目 1887)加 `c_chat_credit_code`(varchar,别名建议「对话企业信用代码」)。

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

@@ -10,8 +10,14 @@ export {
   DMS_MODEL_RECORD,
 } from '../../src/network/api/dms/client';
 
+// 登录态(企业信息 ref)——验证脚本要能模拟「登录 / 未登录」两种状态。
+// 注意:它由 `useEnterpriseAuth` 维护,这里只是把它暴露出来,不要改写它的语义。
+export { enterpriseInfo } from '../../src/components/useEnterpriseAuth';
+
 export {
   upsertDmsSession,
+  readSessionCompanyFields,
+  DMS_SESSION_CHAT_CREDIT_CODE_FIELD,
   upsertDmsRecord,
   fetchDmsSessions,
   fetchDmsSessionRecords,

+ 109 - 9
harness/tools/verify-dms-chat-storage.mjs

@@ -41,6 +41,8 @@ const {
   deleteDmsContent,
   DMS_COLUMN_SESSION,
   DMS_COLUMN_RECORD,
+  enterpriseInfo,
+  DMS_SESSION_CHAT_CREDIT_CODE_FIELD,
 } = await import('./_dms.mjs');
 
 let pass = 0;
@@ -90,16 +92,31 @@ const cleanup = async () => {
 try {
   // ── 1. 会话行 ──────────────────────────────────────────────────────
   console.log('【1】会话(1887)');
-  check('upsertDmsSession 新增', (await upsertDmsSession({ sessionId: SESSION_ID, title: TITLE_A, creditCode: TEST_CREDIT })) === true);
+  check('upsertDmsSession 新增', (await upsertDmsSession({ sessionId: SESSION_ID, sessionTitle: TITLE_A, creditCode: TEST_CREDIT })) === true);
   let sessions = await fetchDmsSessions(TEST_CREDIT);
   check('列表能查到', sessions.some((s) => s.session_id === SESSION_ID));
-  check('标题正确', sessions.find((s) => s.session_id === SESSION_ID)?.title === TITLE_A);
   check('updated_at 合理', Math.abs((sessions.find((s) => s.session_id === SESSION_ID)?.updated_at || 0) - Date.now()) < 10 * 60 * 1000);
 
-  await upsertDmsSession({ sessionId: SESSION_ID, title: TITLE_B, creditCode: TEST_CREDIT });
+  // 标题规则(用户 2026-09-22):**c_title 不写**(标题由运营侧生成);
+  // DMS 系统字段 `title` 只在创建那一刻带上会话标题(不写会落成字符串 "null",1889 踩过)。
+  let rowSession = (await searchDmsContents(DMS_COLUMN_SESSION, {
+    search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }],
+    page: 0,
+    pageSize: 1,
+  }))[0];
+  check('创建时带上了系统 title', rowSession?.title === TITLE_A, JSON.stringify(rowSession?.title));
+  check('**c_title 不写**(前端不碰标题)', !rowSession?.c_title, JSON.stringify(rowSession?.c_title));
+
+  await upsertDmsSession({ sessionId: SESSION_ID, sessionTitle: TITLE_B, creditCode: TEST_CREDIT });
   sessions = await fetchDmsSessions(TEST_CREDIT);
-  check('改名后只有一行', sessions.filter((s) => s.session_id === SESSION_ID).length === 1);
-  check('标题已更新', sessions.find((s) => s.session_id === SESSION_ID)?.title === TITLE_B);
+  check('再次 upsert 只有一行', sessions.filter((s) => s.session_id === SESSION_ID).length === 1);
+  rowSession = (await searchDmsContents(DMS_COLUMN_SESSION, {
+    search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }],
+    page: 0,
+    pageSize: 1,
+  }))[0];
+  check('**再 upsert 也不写 c_title**', !rowSession?.c_title, JSON.stringify(rowSession?.c_title));
+  check('系统 title 保持创建时的值(只在创建那一刻初始化)', rowSession?.title === TITLE_A, JSON.stringify(rowSession?.title));
 
   // ── 2. 粒度:一问一答一条 ──────────────────────────────────────────
   console.log('\n【2】问答记录(1889)—— 粒度必须是「一问一答一条」');
@@ -167,6 +184,89 @@ try {
   check('parseDmsTimestamp 秒', parseDmsTimestamp(1789651800) === 1789651800000);
   check('parseDmsTimestamp 字符串', parseDmsTimestamp('2026-09-17 21:30:00') === 1789651800000, String(parseDmsTimestamp('2026-09-17 21:30:00')));
   check('访客归属形态 访客_<id>', /^访客_.+/.test(resolveDmsCreditCode()), resolveDmsCreditCode());
+
+  // ── 8. 1887 的企业字段(2026-09-22 用户要求)──────────────────────
+  // 规则:登录态的值优先级最高(覆盖);对话带出的 company_info **只填空**(未登录不修改);
+  //       归属键 c_credit_code 不变(公司代码另存 c_chat_credit_code 一列)。
+  console.log('\n【8】1887 企业字段(名称 / 公司代码 / 图片 / 是否登录)');
+  const SID8 = `verify_ent_${Date.now().toString(36)}`;
+  const sessionRowOf = async (sid) =>
+    (
+      await searchDmsContents(DMS_COLUMN_SESSION, {
+        search: [{ field: 'c_session_id', searchType: 1, content: { value: sid } }],
+        page: 0,
+        pageSize: 1,
+      })
+    )[0];
+  const COMPANY_A = {
+    profile: { data: { Name: '甲测试公司', CreditCode: '91310000AAAAAAAAAA', ImageUrl: 'https://example.com/a.png' } },
+  };
+  const COMPANY_B = {
+    profile: { data: { Name: '乙测试公司', CreditCode: '91310000BBBBBBBBBB', ImageUrl: 'https://example.com/b.png' } },
+  };
+
+  // 8.1 未登录 + company_info → 填进 1887
+  enterpriseInfo.value = null;
+  await upsertDmsSession({ sessionId: SID8, sessionTitle: '企业字段验证', companyInfo: COMPANY_A });
+  let entRow = await sessionRowOf(SID8);
+  check('未登录:企业名称已填', entRow?.c_ent_name === '甲测试公司', JSON.stringify(entRow?.c_ent_name));
+  // ⚠️ `c_chat_credit_code`(公司代码另存的那一列)需要 **DMS 侧先加列**(模型 2034)。
+  // 加好之前写它会被 DMS 静默忽略(未知字段不报错),所以这里按「列存不存在」分别断言:
+  // 不存在就打印 ⏭ 跳过,不当作失败 —— 前端已经照发,加列后自然生效。
+  const hasChatCreditColumn = entRow
+    ? Object.prototype.hasOwnProperty.call(entRow, DMS_SESSION_CHAT_CREDIT_CODE_FIELD)
+    : false;
+  if (!hasChatCreditColumn) {
+    console.log(`  ⏭  列 ${DMS_SESSION_CHAT_CREDIT_CODE_FIELD} 在 DMS 模型里还不存在 →`);
+    console.log('     前端开关 DMS_SESSION_CHAT_CREDIT_CODE_ENABLED 仍为 false(**故意**):');
+    console.log('     实测 updateContent 只要含一个不存在的列,整个 patch 会被静默丢弃,');
+    console.log('     连企业名称/登录态都写不进去 —— 所以先不发它。加列后把开关改 true 即可。');
+  }
+  if (hasChatCreditColumn) {
+    check(
+      `未登录:公司代码写进 ${DMS_SESSION_CHAT_CREDIT_CODE_FIELD}`,
+      entRow[DMS_SESSION_CHAT_CREDIT_CODE_FIELD] === '91310000AAAAAAAAAA',
+      JSON.stringify(entRow[DMS_SESSION_CHAT_CREDIT_CODE_FIELD])
+    );
+  }
+  check('未登录:企业图片已填', entRow?.c_ent_image_url === 'https://example.com/a.png', JSON.stringify(entRow?.c_ent_image_url));
+  check('未登录:是否登录 = "0"', entRow?.c_is_login === '0', JSON.stringify(entRow?.c_is_login));
+  check('归属键仍是 访客_<id>(没被公司代码顶掉)', /^访客_/.test(String(entRow?.c_credit_code)), JSON.stringify(entRow?.c_credit_code));
+
+  // 8.2 未登录再来一轮、带的是**另一家**公司 → 已有值不动(「未登录则不修改」)
+  await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B });
+  entRow = await sessionRowOf(SID8);
+  check('未登录:第二轮**不改**已填的名称', entRow?.c_ent_name === '甲测试公司', JSON.stringify(entRow?.c_ent_name));
+  if (hasChatCreditColumn) {
+    check(
+      '未登录:第二轮也不改公司代码',
+      entRow?.[DMS_SESSION_CHAT_CREDIT_CODE_FIELD] === '91310000AAAAAAAAAA',
+      JSON.stringify(entRow?.[DMS_SESSION_CHAT_CREDIT_CODE_FIELD])
+    );
+  }
+
+  // 8.3 登录 → 登录态的值优先级最高,**覆盖**库里已有的
+  enterpriseInfo.value = { credit_code: '91310000LOGIN000001', name: '登录态公司' };
+  await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B });
+  entRow = await sessionRowOf(SID8);
+  check('登录:企业名称被登录值**覆盖**', entRow?.c_ent_name === '登录态公司', JSON.stringify(entRow?.c_ent_name));
+  check('登录:归属键被登录值**覆盖**', entRow?.c_credit_code === '91310000LOGIN000001', JSON.stringify(entRow?.c_credit_code));
+  check('登录:是否登录 = "1"', entRow?.c_is_login === '1', JSON.stringify(entRow?.c_is_login));
+  if (hasChatCreditColumn) {
+    check(
+      '登录:对话带出的公司代码**不会**顶掉登录归属(另存一列)',
+      entRow?.[DMS_SESSION_CHAT_CREDIT_CODE_FIELD] === '91310000AAAAAAAAAA',
+      JSON.stringify(entRow?.[DMS_SESSION_CHAT_CREDIT_CODE_FIELD])
+    );
+  }
+
+  // 8.4 退出登录后再写 → 登录值**不被**访客值改回去
+  enterpriseInfo.value = null;
+  await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B });
+  entRow = await sessionRowOf(SID8);
+  check('退出后:企业名称保持登录值', entRow?.c_ent_name === '登录态公司', JSON.stringify(entRow?.c_ent_name));
+  check('退出后:是否登录不被改回 "0"', entRow?.c_is_login === '1', JSON.stringify(entRow?.c_is_login));
+  await deleteDmsSession(SID8);
 } finally {
   console.log('\n【清理】');
   await cleanup();
@@ -189,7 +289,7 @@ console.log('\n【7】title 字段');
       pageSize: 1,
     }))[0];
 
-  await upsertDmsSession({ sessionId: SID2, title: '标题甲', creditCode: TEST_CREDIT });
+  await upsertDmsSession({ sessionId: SID2, sessionTitle: '标题甲', creditCode: TEST_CREDIT });
   let row = await rowOf(DMS_COLUMN_SESSION, 'c_session_id', SID2);
   check('新建会话带上了系统 title', row?.title === '标题甲', JSON.stringify(row?.title));
   // 四个必填运营字段(summary/qpyszx/qyzt/rzqpyx)**故意传空值/默认值**:
@@ -199,10 +299,10 @@ console.log('\n【7】title 字段');
   check('三个布尔运营字段为默认 false', row?.qpyszx === false && row?.qyzt === false && row?.rzqpyx === false,
     JSON.stringify([row?.qpyszx, row?.qyzt, row?.rzqpyx]));
 
-  await upsertDmsSession({ sessionId: SID2, title: '标题乙', creditCode: TEST_CREDIT });
+  await upsertDmsSession({ sessionId: SID2, sessionTitle: '标题乙', creditCode: TEST_CREDIT });
   row = await rowOf(DMS_COLUMN_SESSION, 'c_session_id', SID2);
-  check('改名后 c_title 已更新', row?.c_title === '标题乙', JSON.stringify(row?.c_title));
-  check('**改名后系统 title 也跟着变**(原先不跟随)', row?.title === '标题乙', JSON.stringify(row?.title));
+  check('再 upsert 仍不写 c_title', !row?.c_title, JSON.stringify(row?.c_title));
+  check('系统 title 保持创建时的值', row?.title === '标题甲', JSON.stringify(row?.title));
 
   await upsertDmsRecord({ recordId: REC3, sessionId: SID2, creditCode: TEST_CREDIT, question: '记录的问题文本' });
   const recRow = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC3);

+ 17 - 4
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -34,7 +34,7 @@ import { buildTestDocumentLineStreamChunks } from './test-document-line-stream';
 import { getChatHistoryStorageKey, enterpriseInfo } from '@/components/useEnterpriseAuth';
 import { fetchRemoteSessionRecords, deleteSessionInfo, type RemoteSession } from '@/network/api/chat-sessions';
 // [已停用] updateSessionInfo 随 1887 会话写入一并停用
-import { upsertDmsRecord } from '@/network/api/dms/chat-sessions-dms';
+import { upsertDmsRecord, upsertDmsSession } from '@/network/api/dms/chat-sessions-dms';
 import { syncCompanyInfoFromChat } from '@/network/api/dms/company-info-sync';
 import { hasVisibleMessageContent } from '@/utils/interrupted-message';
 import {
@@ -43,7 +43,7 @@ import {
   composeAroundStreamBody,
   resolveStreamedBodyAfterEnd,
 } from '../answer-stream';
-// [已停用] upsertDmsSession 随 1887 会话写入一并停用
+// 1887 助手会话:写企业相关的值(名称/归属/是否登录/图片),见 upsertDmsSession 的注释
 
 interface UseBusinessAssistantChatOptions {
   apiBaseUrl?: string;
@@ -466,6 +466,15 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       // fire-and-forget:内部全程 try/catch + 只 warn,不阻塞也不影响聊天
       if (payload?.company_info) syncCompanyInfoFromChat(payload.company_info);
 
+      // 顺带把企业相关的值写进 1887「助手会话」(用户 2026-09-22 要求):
+      // 未登录时只在对话确实带回了 company_info 时写;登录后用登录态的值覆盖(优先级最高)。
+      // 写哪些、谁覆盖谁见 upsertDmsSession 的注释。fire-and-forget,失败只 warn。
+      void upsertDmsSession({
+        sessionId,
+        sessionTitle: session?.title || '',
+        ...(payload?.company_info ? { companyInfo: payload.company_info } : {}),
+      });
+
       finishTask(task);
     });
 
@@ -603,7 +612,9 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     await options.afterSend?.();
 
     // 点击提交即写 DMS:**只写本轮问答记录(1889)**,不再写会话行(1887)
-    // [已停用] void upsertDmsSession({ sessionId: session.id, title: session.title });
+    // 1887 助手会话的写入**不在这里**:统一放在回答完成的 totalResponse 里
+    // (那时才知道本轮有没有 company_info,见 upsertDmsSession 的注释)。
+    // 别在这里补一行,否则同一次会话会写两次。
     // fire-and-forget,失败只 warn,不影响聊天
     saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: text });
 
@@ -1806,7 +1817,9 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
     // 补问也是一轮问答:记进 DMS 的问答记录(1889)。问题存**真实提交文本**(answerText),
     // 不存界面上那句「已提交企业信息补充」——否则从 DMS 读回的历史会失真
-    // [已停用] void upsertDmsSession({ sessionId: session.id, title: session.title });
+    // 1887 助手会话的写入**不在这里**:统一放在回答完成的 totalResponse 里
+    // (那时才知道本轮有没有 company_info,见 upsertDmsSession 的注释)。
+    // 别在这里补一行,否则同一次会话会写两次。
     saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: answerText });
 
     void coordinator.generateAnswer(answerText);

+ 119 - 13
src/network/api/dms/chat-sessions-dms.ts

@@ -133,33 +133,133 @@ export const parseDmsTimestamp = (value: unknown): number => {
 // 2026-09-18 企业信息同步也要用(且「不要加 orderBy」那条坑不想再踩一遍),
 // 已上移到 ./client 共用,名字加 Dms 前缀:`enqueueDmsWrite` / `findDmsRowBy`。
 
+/**
+ * 1887 里「**对话带出的**公司统一社会信用代码」列。
+ *
+ * 为什么要单开一列(用户 2026-09-22 拍板):`c_credit_code` 是这一行的**归属键**
+ * (登录 → 企业代码;访客 → `访客_<id>`;`fetchDmsSessions` 就是按它查「我的会话」)。
+ * 未登录时如果把公司代码写进归属键,那个访客**查不到自己的会话**,
+ * 而那家公司登录后**会看到这个访客的会话**。所以归属键保持原样,公司代码另存一列。
+ *
+ * ⚠️ **本列需要 DMS 侧先加**(模型 2034)。加好之前写它会被 DMS 静默忽略
+ * (未知字段不报错,见 DMS_API.md),不影响其它字段落库。
+ */
+export const DMS_SESSION_CHAT_CREDIT_CODE_FIELD = "c_chat_credit_code";
+
+/**
+ * 是否**真的往 DMS 发**这一列 —— **DMS 侧加好列之后再把这里改成 `true`**。
+ *
+ * ⚠️ 为什么先关着(2026-09-22 实测踩到,协议层确认):
+ * - `addContent` 遇到模型里**不存在**的列 → **忽略该列、其余字段照写** ✓
+ * - `updateContent` 只要 payload 里有**任何一个**模型里不存在的列 →
+ *   **整个 patch 被静默丢弃**,返回却仍是 `{"code":200,"message":"成功"}` ✗
+ *
+ * 所以在列加好之前把它放进更新 patch,会让「企业名称 / 归属 / 是否登录」这些
+ * **本来能写的字段一起失效**,而且从返回码上完全看不出来。
+ */
+export const DMS_SESSION_CHAT_CREDIT_CODE_ENABLED = false;
+
+const asRecord = (value: unknown): Record<string, unknown> | null =>
+  value && typeof value === "object" && !Array.isArray(value)
+    ? (value as Record<string, unknown>)
+    : null;
+
+const pickText = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
+
+/**
+ * 从对话带回来的 `company_info` 里取出 1887 用得上的三项。
+ *
+ * 取值路径与 1888 的同步一致:优先 `profile.data.*`(企查查),回退 `company.*`。
+ * 取不到就是空串(调用方据此决定不写该字段)。
+ */
+export const readSessionCompanyFields = (
+  companyInfo: unknown
+): { name: string; creditCode: string; imageUrl: string } => {
+  const info = asRecord(companyInfo);
+  const data = asRecord(asRecord(info?.profile)?.data) ?? asRecord(info?.company) ?? {};
+  return {
+    name: pickText(data.Name),
+    creditCode: pickText(data.CreditCode),
+    imageUrl: pickText(data.ImageUrl),
+  };
+};
+
+/** DMS 行里这个字段算不算「空」(空串 / null / 空数组都算) */
+const isEmptyRowValue = (value: unknown): boolean =>
+  value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
+
 /**
  * 会话 upsert(栏目 1887,幂等键 `c_session_id`)。
- * 首次提问时创建,改名/回答完成时更新标题与更新时间。
+ *
+ * 写哪些、谁能覆盖谁(用户 2026-09-22 定):
+ * - **登录态的值优先级最高**:`c_ent_name` / `c_credit_code` / `c_is_login="1"`
+ *   登录时**无条件写**(库里已有值也改掉)。
+ * - **对话带出的 `company_info`**:`c_ent_name` / `c_chat_credit_code` / `c_ent_image_url`,
+ *   **只在字段为空时才写**(「未登录则不修改」);登录用户已由上一条写死,不会被它改回去。
+ * - **归属键 `c_credit_code`**:登录 → 企业代码;未登录 → `访客_<id>`,且未登录时只填空。
+ * - **`c_title` 不写**(用户要求:标题由运营侧生成);DMS 的系统字段 `title` 只在**创建那一刻**
+ *   带上会话标题 —— 不写会落成字符串 `"null"`(1889 踩过)。
+ * - 必填运营位(`summary` / `qpyszx` / `qyzt` / `rzqpyx`)**只在创建时带空值**:
+ *   它们是运营分析系统回填的(实测库里那几行是分析产出的 JSON),更新时绝不能碰。
  */
 export const upsertDmsSession = (params: {
   sessionId: string;
-  title: string;
+  /** 仅用于创建那一刻的 DMS 系统字段 `title`;`c_title` 不写 */
+  sessionTitle?: string;
+  /** 显式指定归属(验证脚本用);不传则按登录态推导 */
   creditCode?: string;
+  /** 本轮对话带回的 `company_info`(原样透传,内部自己取字段);没有就不传 */
+  companyInfo?: unknown;
 }): Promise<boolean> =>
   enqueueDmsWrite(`session:${params.sessionId}`, async () => {
-    const content: DmsRow = {
-      c_credit_code: params.creditCode || resolveDmsCreditCode(),
+    const logged = enterpriseInfo.value;
+    const fromChat = readSessionCompanyFields(params.companyInfo);
+
+    /** 登录态的值:优先级最高,登录时无条件写 */
+    const authoritative: DmsRow = {};
+    if (logged) {
+      if (logged.name) authoritative.c_ent_name = logged.name;
+      if (logged.credit_code) authoritative.c_credit_code = logged.credit_code;
+      authoritative.c_is_login = "1";
+    }
+
+    /** 只在字段为空时才写的值(未登录不修改;登录时也不会被它们改回去) */
+    const fillIfEmpty: DmsRow = {};
+    if (fromChat.name) fillIfEmpty.c_ent_name = fromChat.name;
+    // ⚠️ 这一列要等 DMS 侧加好再发(见 DMS_SESSION_CHAT_CREDIT_CODE_ENABLED 的说明)
+    if (fromChat.creditCode && DMS_SESSION_CHAT_CREDIT_CODE_ENABLED) {
+      fillIfEmpty[DMS_SESSION_CHAT_CREDIT_CODE_FIELD] = fromChat.creditCode;
+    }
+    if (fromChat.imageUrl) fillIfEmpty.c_ent_image_url = fromChat.imageUrl;
+    if (!logged) {
+      fillIfEmpty.c_is_login = "0";
+      if (!params.creditCode) fillIfEmpty.c_credit_code = resolveDmsCreditCode();
+    }
+
+    if (params.creditCode) fillIfEmpty.c_credit_code = params.creditCode;
+
+    const base: DmsRow = {
       c_session_id: params.sessionId,
-      c_title: params.title || "未命名会话",
-      // DMS 的系统字段 `title` 只在创建那一刻初始化,**改 c_title 不会带动它**
-      // (实测:改完 c_title 变成「标题B」,title 还是「标题A」)。
-      // 所以每次 upsert 都显式带上 title —— 前端改名才能真正同步过去。
-      title: params.title || "未命名会话",
       c_source: resolveSource(),
       c_updated_at: nowDmsTimestamp(),
     };
 
     const existing = await findDmsRowBy(DMS_COLUMN_SESSION, "c_session_id", params.sessionId);
     if (!existing) {
-      content.c_created_at = nowDmsTimestamp();
-      // 必填的运营字段:只在新增时带空值(见 buildRequiredSessionFields 的说明)
-      Object.assign(content, buildRequiredSessionFields());
+      // 没有任何可写内容(访客且本轮没带回企业资料)→ **不建行**。
+      // 尊重 2026-09-18 的决定「只传具体的会话记录(1889),另一个栏目不传了」:
+      // 只有确实有企业信息可记时才落到 1887。
+      if (!logged && !Object.keys(fillIfEmpty).length) {
+        return true;
+      }
+      const content: DmsRow = {
+        ...base,
+        ...fillIfEmpty,
+        ...authoritative,
+        c_created_at: nowDmsTimestamp(),
+        ...(params.sessionTitle ? { title: params.sessionTitle } : {}),
+        ...buildRequiredSessionFields(),
+      };
       const id = await addDmsContent(DMS_COLUMN_SESSION, DMS_MODEL_SESSION, content);
       if (!id) console.warn("[dms] 会话写入失败", params.sessionId);
       return !!id;
@@ -170,7 +270,13 @@ export const upsertDmsSession = (params: {
       console.warn("[dms] 会话行缺少 uuid,无法更新", existing);
       return false;
     }
-    return updateDmsContent(DMS_COLUMN_SESSION, DMS_MODEL_SESSION, id, content);
+    const patch: DmsRow = { ...base, ...authoritative };
+    for (const [field, value] of Object.entries(fillIfEmpty)) {
+      if (isEmptyRowValue(existing[field])) {
+        patch[field] = value;
+      }
+    }
+    return updateDmsContent(DMS_COLUMN_SESSION, DMS_MODEL_SESSION, id, patch);
   });
 
 /**