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

change(dms): 1887 口径修订 —— 访客标识进 c_ip,信用代码只放真实值,值变就更新

用户 2026-09-22 第二次修订:
1) 访客_xxx 改存 c_ip;c_credit_code 只存真实信用代码
2) 未登录时,后续对话的 company_info 值与原来不一致也要更新(不再只是填空)

实现(chat-sessions-dms.ts,规则写进函数注释):
- 新增 resolveVisitorLabel() → 写进 c_ip,且只填空(已有值可能是后端回填的真实 IP)
- c_credit_code:登录=企业代码;未登录=对话带出的真实公司代码。
  resolveDmsCreditCode()(仍返回 访客_<id>)保留给 1889 —— 1889 没有 c_ip 列
- 对话带出的三个字段:与库里值不同就更新(isSameRowValue 判等);
  但登录态已写死的 c_ent_name / c_credit_code 不许被对话值顶掉
- c_is_login 仍只填空
- 建行时系统 title 必须带(sessionTitle || '未命名会话')—— 这是测试里发现的:
  参数改名后测试仍传旧名,导致系统 title 没写、库里会落成字符串 "null",已补兜底
- 取消"新增 c_chat_credit_code 列"的需求(真实代码直接进 c_credit_code),
  删掉相应开关与导出;tech-debt 条目 8 改写为"读侧暴露待拍板"

验证:verify-dms-chat-storage(真实 DMS)59/59;verify-dms-payload-fields 17/17
(新字段集合、不含 c_title、更新不带标题与运营位、值没变不发 update、源码级断言只在
totalResponse 写一次);回归 84/84 + 27/27 + 165/165 + 30/30;build 与 validate-harness 通过。

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

+ 14 - 18
harness/docs/exec-plans/tech-debt-tracker.md

@@ -101,24 +101,20 @@
   仍无 `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 侧加列后改一个布尔值即可(脚本里已有对应断言,会自动从「跳过」转为「校验」)。
+### 8. 访客会话会出现在企业登录后的列表里(需产品拍板)
+
+- **是什么**:2026-09-22 起 1887 的口径改为 —— `c_credit_code` **只放真实信用代码**
+  (登录 → 企业代码;未登录 → 对话带出的公司代码),访客标识 `访客_<id>` 改放 `c_ip`。
+- **带来的副作用**:`fetchDmsSessions`(读「我的会话」)是按 `c_credit_code` 查的,
+  而它只在**登录后**被调用。于是**那家公司登录后,会看到访客关于它的那些会话**。
+  改造前访客行是 `访客_<id>`,匹配不上,所以这**是新出现的暴露**。
+- **两种处理**:
+  1. 认为「访客问的就是这家公司的问题,归到这家公司名下」是**想要的**(运营视角)→ 保持现状;
+  2. 认为不该让企业看到陌生访客的会话 → 在 `fetchDmsSessions` 里加一个
+     「只认 `c_is_login === "1"`」的条件(**一行改动**,且与改造前的可见范围完全一致)。
+- **现状**:**未处理**(保持现状 = 上面第 1 种)。前端实现里已写明这一点,等产品拍板。
+- **相关**:1889(问答记录)**没有 `c_ip` 列**,所以它的归属键仍用 `访客_<id>`;
+  若也要统一口径,得 DMS 侧先给 1889 加列。
 
 ## 已还
 

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

@@ -55,8 +55,6 @@ export DMS_TOKEN='...'
 >
 > **踩过的后果**:给 1887 加新列时,前端把新列名一起放进更新 patch,
 > 于是「企业名称 / 归属 / 是否登录」这些本来能写的字段**一起失效**,且返回码完全正常。
-> 现在用一个开关挡住(见 `chat-sessions-dms.ts` 的 `DMS_SESSION_CHAT_CREDIT_CODE_ENABLED`),
-> **列没加好之前不发它**。
 >
 > 结论:**新增列时,先确认 DMS 侧已加,再让前端发它**;排查「写入没生效」时,
 > 第一个要怀疑的就是 payload 里混进了模型里没有的列。

+ 36 - 0
harness/progress.md

@@ -3517,3 +3517,39 @@ F073 之后正文是**即时渲染**的(流式期间打字机关着),但**
 
 **给后端的需求**:见 `docs/exec-plans/tech-debt-tracker.md` 条目 8 ——
 在模型 2034(栏目 1887)加 `c_chat_credit_code`(varchar,别名建议「对话企业信用代码」)。
+
+### 续 18:1887 口径修订(用户当天第二次改)
+
+**用户要求**:
+1. `访客_xxx` 之类的内容改存 **`c_ip`**;**`c_credit_code` 只存真实信用代码**
+2. 未登录时,后续对话的 `company_info` 值与原来的**不一致也要更新**(不再只是"填空")
+
+**实现**(`chat-sessions-dms.ts` 的 `upsertDmsSession`,规则写进函数注释):
+- 新增 `resolveVisitorLabel()` = `访客_<id>` → 写进 **`c_ip`**(且**只填空**:已有值可能是后端回填的真实 IP)
+- `c_credit_code`:登录 → 企业代码;未登录 → 对话带出的**真实**公司代码;
+  `resolveDmsCreditCode()`(仍返回 `访客_<id>`)**保留给 1889** —— 1889 没有 `c_ip` 列
+- 对话带出的三个字段(`c_ent_name`/`c_credit_code`/`c_ent_image_url`):
+  **与库里值不同就更新**(`isSameRowValue` 判等);但**登录态已写死的两个字段不许被顶掉**
+- `c_is_login` 仍只填空(空值形态 = 这一行还没被登录态认领过)
+- 建行时系统 `title` **必须有**(`sessionTitle || '未命名会话'`)——
+  这条是测试里发现的:参数改名后测试还在传旧名 `title`,导致**系统 title 没写**,
+  库里会落成字符串 `"null"`。已补兜底 ✓
+- **新列的需求取消**:不再需要 DMS 侧加 `c_chat_credit_code`(真实代码直接进 `c_credit_code`),
+  相应开关与入口导出已删除;`tech-debt-tracker` 条目 8 改写成"读侧暴露待拍板"
+
+**运行过的验证**:
+- `verify-dms-chat-storage.mjs`(真实 DMS)**59/59**:新增/改写 14 项覆盖新口径
+  (真实代码进 `c_credit_code`、访客标识进 `c_ip`、值不同就更新、值相同不写、
+  登录值不被对话值顶掉、退出后按新值更新…)
+- `verify-dms-payload-fields.mjs` **17/17**:断言新字段集合(含 `c_ip`/`c_is_login`、
+  **不含 `c_title`**、创建带系统 `title`)、更新不带标题与运营位、**值没变就不发 update**;
+  另加源码级断言「1887 只在 totalResponse 处写一次」
+- 回归:企业分类同步 84/84、适配层直通 27/27、答案流式 165/165、判空口径 30/30;
+  `npm run build` 与 `validate-harness` 通过
+
+**⚠️ 未处理(需用户拍板)**:访客行现在带真实公司代码,而 `fetchDmsSessions` 按
+`c_credit_code` 查「我的会话」且只在登录后调用 → **那家公司登录后会看到访客关于它的会话**
+(改造前匹配不上)。挡住只需一行(只认 `c_is_login="1"`)。已写进 tech-debt 条目 8。
+
+**⚠️ 待确认**:1889 仍用 `访客_<id>` 当 `c_credit_code`(它没有 `c_ip` 列)。要不要统一口径,
+得 DMS 侧先给 1889 加列。

+ 1 - 1
harness/tools/_entry-dms.ts

@@ -17,7 +17,7 @@ export { enterpriseInfo } from '../../src/components/useEnterpriseAuth';
 export {
   upsertDmsSession,
   readSessionCompanyFields,
-  DMS_SESSION_CHAT_CREDIT_CODE_FIELD,
+  resolveVisitorLabel,
   upsertDmsRecord,
   fetchDmsSessions,
   fetchDmsSessionRecords,

+ 27 - 41
harness/tools/verify-dms-chat-storage.mjs

@@ -42,7 +42,7 @@ const {
   DMS_COLUMN_SESSION,
   DMS_COLUMN_RECORD,
   enterpriseInfo,
-  DMS_SESSION_CHAT_CREDIT_CODE_FIELD,
+  resolveVisitorLabel,
 } = await import('./_dms.mjs');
 
 let pass = 0;
@@ -205,45 +205,32 @@ try {
     profile: { data: { Name: '乙测试公司', CreditCode: '91310000BBBBBBBBBB', ImageUrl: 'https://example.com/b.png' } },
   };
 
-  // 8.1 未登录 + company_info → 填进 1887
+  // 8.1 未登录 + company_info → 填进 1887(c_credit_code 放**真实**代码,访客标识进 c_ip)
   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(
+    '未登录:真实信用代码写进 c_credit_code',
+    entRow?.c_credit_code === '91310000AAAAAAAAAA',
+    JSON.stringify(entRow?.c_credit_code)
+  );
+  check('未登录:**访客标识进 c_ip**', /^访客_/.test(String(entRow?.c_ip)), JSON.stringify(entRow?.c_ip));
   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));
+  // ⚠️ 不断言「等于 resolveVisitorLabel() 的当前返回值」:node 里没有 localStorage,
+  //    每次调用都会新生成一个访客 id(浏览器里有 localStorage,是稳定的)。只查前缀形态。
 
-  // 8.2 未登录再来一轮、带的是**另一家**公司 → 已有值不动(「未登录则不修改」)
+  // 8.2 未登录再来一轮、带的是**另一家**公司 → **值不同就更新**(用户 2026-09-22 修订)
   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])
-    );
-  }
+  check('未登录:值不同 → 企业名称被更新', entRow?.c_ent_name === '乙测试公司', JSON.stringify(entRow?.c_ent_name));
+  check('未登录:值不同 → 信用代码被更新', entRow?.c_credit_code === '91310000BBBBBBBBBB', JSON.stringify(entRow?.c_credit_code));
+
+  // 8.2b 同一家公司再来一轮(值相同)→ 不该产生无谓写入:读回值不变即可
+  await upsertDmsSession({ sessionId: SID8, companyInfo: COMPANY_B });
+  entRow = await sessionRowOf(SID8);
+  check('未登录:值相同 → 保持原值', entRow?.c_ent_name === '乙测试公司', JSON.stringify(entRow?.c_ent_name));
 
   // 8.3 登录 → 登录态的值优先级最高,**覆盖**库里已有的
   enterpriseInfo.value = { credit_code: '91310000LOGIN000001', name: '登录态公司' };
@@ -252,20 +239,19 @@ try {
   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])
-    );
-  }
+  check(
+    '登录:对话带出的公司代码**不会**顶掉登录信用代码',
+    entRow?.c_credit_code === '91310000LOGIN000001',
+    JSON.stringify(entRow?.c_credit_code)
+  );
 
-  // 8.4 退出登录后再写 → 登录值**不被**访客值改回去
+  // 8.4 退出登录后再写:登录态已经不在,就没有"登录值"要保护了 →
+  //     对话带出的值与库里不同 → 按新规则更新;但 c_is_login 只填空,不被改回 "0"
   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));
+  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【清理】');

+ 60 - 27
harness/tools/verify-dms-payload-fields.mjs

@@ -65,38 +65,65 @@ const keysOfLast = (endpoint) => {
 const DISALLOWED = ['summary', 'qpyszx', 'qyzt', 'rzqpyx'];
 
 console.log('\n【1】会话新增:字段必须「不多不少」');
-await upsertDmsSession({ sessionId: 'sess-A', title: '会话标题甲', creditCode: '91310118TEST' });
+await upsertDmsSession({ sessionId: 'sess-A', sessionTitle: '会话标题甲', creditCode: '91310118TEST' });
 let keys = keysOfLast('/content/addContent');
-const EXPECT_SESSION_ADD = ['c_created_at', 'c_credit_code', 'c_session_id', 'c_source', 'c_title', 'c_updated_at', 'title',
+const EXPECT_SESSION_ADD = [
+  'c_created_at', 'c_credit_code', 'c_session_id', 'c_source', 'c_updated_at',
+  // 访客标识(2026-09-22 起进 c_ip)与登录标记
+  'c_ip', 'c_is_login',
+  // DMS 的系统字段:只在创建那一刻带,不带会落成字符串 "null"
+  'title',
   // 1887 的模型把这四个设为 must=true,四个全带才写得进去(少一个就 214);
   // 值故意留空 —— 运营口径由业务方定,前端不猜
-  'summary', 'qpyszx', 'qyzt', 'rzqpyx'].sort();
+  'summary', 'qpyszx', 'qyzt', 'rzqpyx',
+].sort();
 check('字段集合与预期完全一致', JSON.stringify(keys) === JSON.stringify(EXPECT_SESSION_ADD), `实际 ${JSON.stringify(keys)}`);
-check('**带 title**(用户需求)', keys?.includes('title') === true);
-check('带上四个必填运营字段(值必须为空/默认,不是业务值)', ['summary','qpyszx','qyzt','rzqpyx'].every((k) => keys?.includes(k)), JSON.stringify(keys));
-check('这四个字段的值是空值/默认(不是猜的业务值)', (() => {
-  const hit = [...captured].reverse().find((c) => c.url.includes('/content/addContent'));
-  const c = JSON.parse(new URLSearchParams(hit.body).get('content'));
-  return c.summary === '' && c.qpyszx === false && c.qyzt === false && c.rzqpyx === false;
-})(), '');
-
-console.log('\n【2】会话改名(更新):也要带 title');
+check('**带系统 title**(不带会落成字符串 "null")', keys?.includes('title') === true);
+check('**不带 c_title**(用户 2026-09-22:标题由运营侧生成)', keys?.includes('c_title') !== true, JSON.stringify(keys));
+
+console.log('\n【2】会话更新:不碰标题、不带运营字段、已有值不重写');
 captured.length = 0;
-// 让这次走「更新」分支:先让 search 返回一行
+// 让这次走「更新」分支:search 返回一行,且 c_ip / c_is_login 都已有值
+const existingRow = { id: 'row-uuid', c_session_id: 'sess-A', c_ip: '访客_既有值', c_is_login: '1', c_ent_name: '甲测试公司' };
 globalThis.fetch = async (url, init = {}) => {
   const body = init.body ? String(init.body) : '';
   captured.push({ url: String(url), body });
   if (String(url).includes('/content/selectContentList')) {
-    return json({ code: 200, message: '成功', content: { data: [{ id: 'row-uuid', c_session_id: 'sess-A' }], count: 1 } });
+    return json({ code: 200, message: '成功', content: { data: [existingRow], count: 1 } });
   }
   return json({ code: 200, message: '成功', content: '执行成功' });
 };
-await upsertDmsSession({ sessionId: 'sess-A', title: '会话标题乙', creditCode: '91310118TEST' });
+await upsertDmsSession({ sessionId: 'sess-A', sessionTitle: '会话标题乙', creditCode: '91310118TEST' });
 keys = keysOfLast('/content/updateContent');
 check('更新走的是 updateContent', keys !== null);
-check('**更新也带 title**(原先不带,改名后 DMS 里标题不变)', keys?.includes('title') === true, JSON.stringify(keys));
+check('**更新不带 title / c_title**(前端不碰标题)', !keys?.includes('title') && !keys?.includes('c_title'), JSON.stringify(keys));
 check('**更新不带这四个字段**(否则会把后端回填的值冲成空)', !DISALLOWED.some((k) => keys?.includes(k)), JSON.stringify(keys));
+check('**c_ip 已有值 → 不重写**(可能是后端回填的真实 IP)', !keys?.includes('c_ip'), JSON.stringify(keys));
+check('**c_is_login 已有值 → 不重写**', !keys?.includes('c_is_login'), JSON.stringify(keys));
+
+console.log('\n【2b】值不同就更新 / 值相同不写');
+{
+  await upsertDmsSession({
+    sessionId: 'sess-A',
+    sessionTitle: '会话标题乙',
+    creditCode: '91310118TEST',
+    companyInfo: { profile: { data: { Name: '乙测试公司', CreditCode: '91310118CHAT' } } },
+  });
+  keys = keysOfLast('/content/updateContent');
+  check('**值不同 → 企业名称写进 patch**', keys?.includes('c_ent_name'), JSON.stringify(keys));
+  check('**值不同 → 真实信用代码写进 patch**', keys?.includes('c_credit_code'), JSON.stringify(keys));
 
+  existingRow.c_ent_name = '乙测试公司';
+  existingRow.c_credit_code = '91310118CHAT';
+  await upsertDmsSession({
+    sessionId: 'sess-A',
+    sessionTitle: '会话标题乙',
+    companyInfo: { profile: { data: { Name: '乙测试公司', CreditCode: '91310118CHAT' } } },
+  });
+  keys = keysOfLast('/content/updateContent');
+  check('**值相同 → 不写 c_ent_name**(不产生无谓写入)', !keys?.includes('c_ent_name'), JSON.stringify(keys));
+  check('**值相同 → 不写 c_credit_code**', !keys?.includes('c_credit_code'), JSON.stringify(keys));
+}
 console.log('\n【3】问答记录新增:title = 问题文本,且不多发字段');
 captured.length = 0;
 globalThis.fetch = async (url, init = {}) => {
@@ -116,23 +143,29 @@ const hit = [...captured].reverse().find((c) => c.url.includes('/content/addCont
 const content = JSON.parse(new URLSearchParams(hit.body).get('content'));
 check('**title 的值就是问题文本**', content.title === '这是问题文本', JSON.stringify(content.title));
 
-console.log('\n【4】会话写入(1887)必须保持停用 —— 源码级断言');
+console.log('\n【4】会话写入(1887)只在回答完成处发生一次 —— 源码级断言');
 {
-  // 用户 2026-09-18 指示:只写问答记录(1889),会话行(1887)不写了。
-  // 这条靠「调用点被注释掉」实现,极易在后续改动里被顺手恢复,所以在源码上钉死。
+  // 2026-09-22:1887 改为写企业字段,触发点**只有一处** —— 回答完成的 totalResponse
+  // (那时才知道本轮有没有 company_info)。
+  // 曾经在 sendMessage / submitQuestionAnswers 里也各写一次,那会让同一次会话写两遍,
+  // 极易在后续改动里被顺手恢复,所以在源码上钉死。
   const file = 'src/components/business-assistant/useBusinessAssistantChat.ts';
-  const lines = readFileSync(file, 'utf8').split('\n');
+  const src = readFileSync(file, 'utf8');
+  const lines = src.split('\n');
   const live = lines
     .map((l, i) => ({ l, n: i + 1 }))
-    .filter(({ l }) => !l.trim().startsWith('//') && /(upsertDmsSession|syncSessionTitleToServer)\s*\(/.test(l));
+    .filter(({ l }) => !l.trim().startsWith('//') && /upsertDmsSession\s*\(/.test(l));
+  check('活跃调用点只有 1 处', live.length === 1, live.map(({ l, n }) => `第${n}行: ${l.trim()}`).join(' | '));
+
+  // 它必须在 totalResponse 监听器之后、下一个 addEventListener 之前
+  const at = live.length ? live[0].n - 1 : -1;
+  const totalResponseAt = lines.findIndex((l) => l.includes("addEventListener('totalResponse'"));
+  const nextListenerAt = lines.findIndex((l, i) => i > totalResponseAt && l.includes("addEventListener('"));
   check(
-    '不再调用 upsertDmsSession / syncSessionTitleToServer(1887 已停用)',
-    live.length === 0,
-    live.map(({ l, n }) => `第${n}行: ${l.trim()}`).join(' | ')
+    '那一处在 totalResponse 监听器内(回答完成时才写)',
+    at > totalResponseAt && (nextListenerAt === -1 || at < nextListenerAt),
+    `调用@第${at + 1}行 / totalResponse@第${totalResponseAt + 1}行 / 下一个监听器@第${nextListenerAt + 1}行`
   );
-  const commented = lines.filter((l) => l.trim().startsWith('//') && /upsertDmsSession\s*\(/.test(l));
-  check('停用的调用点以注释形式保留(符合项目「注释不删除」的约定)', commented.length >= 2, `找到 ${commented.length} 处`);
 }
-
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 55 - 41
src/network/api/dms/chat-sessions-dms.ts

@@ -134,30 +134,14 @@ export const parseDmsTimestamp = (value: unknown): number => {
 // 已上移到 ./client 共用,名字加 Dms 前缀:`enqueueDmsWrite` / `findDmsRowBy`。
 
 /**
- * 1887 里「**对话带出的**公司统一社会信用代码」列。
+ * 访客标识(`访客_<id>`)。
  *
- * 为什么要单开一列(用户 2026-09-22 拍板):`c_credit_code` 是这一行的**归属键**
- * (登录 → 企业代码;访客 → `访客_<id>`;`fetchDmsSessions` 就是按它查「我的会话」)。
- * 未登录时如果把公司代码写进归属键,那个访客**查不到自己的会话**,
- * 而那家公司登录后**会看到这个访客的会话**。所以归属键保持原样,公司代码另存一列。
+ * 2026-09-22 用户改口径:**它写进 1887 的 `c_ip`(访客IP)列,不再占用 `c_credit_code`** ——
+ * `c_credit_code` 只放**真实**的统一社会信用代码(登录 → 企业代码;未登录 → 对话带出的公司代码)。
  *
- * ⚠️ **本列需要 DMS 侧先加**(模型 2034)。加好之前写它会被 DMS 静默忽略
- * (未知字段不报错,见 DMS_API.md),不影响其它字段落库。
+ * ⚠️ 与 `resolveDmsCreditCode()` 的分工:那个仍给 **1889** 用它当归属键(1889 没有 `c_ip` 列)。
  */
-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;
+export const resolveVisitorLabel = (): string => `访客_${getOrCreateAssistantVisitorId()}`;
 
 const asRecord = (value: unknown): Record<string, unknown> | null =>
   value && typeof value === "object" && !Array.isArray(value)
@@ -188,19 +172,31 @@ export const readSessionCompanyFields = (
 const isEmptyRowValue = (value: unknown): boolean =>
   value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
 
+/** 两个 DMS 值算不算**同一个值**(按字符串比,忽略 null/undefined 与首尾空白) */
+const isSameRowValue = (a: unknown, b: unknown): boolean =>
+  String(a ?? "").trim() === String(b ?? "").trim();
+
 /**
  * 会话 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>`,且未登录时只填空。
+ * 写哪些、谁能覆盖谁(用户 2026-09-22 定,含当天第二次修订):
+ * - **`c_credit_code` 只放真实信用代码**:登录 → 企业代码;未登录 → 对话带出的公司代码。
+ *   **访客标识 `访客_<id>` 改放 `c_ip`**(当年把它塞进 c_credit_code 是权宜)。
+ * - **登录态的值优先级最高**:`c_ent_name` / `c_credit_code` / `c_is_login="1"` 无条件写(覆盖)。
+ * - **对话带出的 `company_info`**(`c_ent_name` / `c_credit_code` / `c_ent_image_url`):
+ *   与库里**值不同就更新**(用户 2026-09-22 修订:不再只是"填空",值变了也要改);
+ *   但**登录态已经写死的两个字段不会被它顶掉**(登录值仍然优先)。
+ * - **`c_ip` / `c_is_login` 只在为空时写**:`c_ip` 已有值就不动(可能是后端回填的真实 IP),
+ *   `c_is_login` 的空值形态说明这一行还没被登录态认领过。
  * - **`c_title` 不写**(用户要求:标题由运营侧生成);DMS 的系统字段 `title` 只在**创建那一刻**
  *   带上会话标题 —— 不写会落成字符串 `"null"`(1889 踩过)。
  * - 必填运营位(`summary` / `qpyszx` / `qyzt` / `rzqpyx`)**只在创建时带空值**:
  *   它们是运营分析系统回填的(实测库里那几行是分析产出的 JSON),更新时绝不能碰。
+ *
+ * ⚠️ 派生的影响(**尚未处理,需产品拍板**):访客行现在带着真实公司代码,
+ * 而 `fetchDmsSessions` 是按 `c_credit_code` 查「我的会话」的 ——
+ * 那家公司登录后,**这个访客的会话会出现在它的列表里**(改造前访客行是 `访客_<id>`,匹配不上)。
+ * 要挡的话在 `fetchDmsSessions` 里加一个「只认 `c_is_login="1"`」的条件即可。
  */
 export const upsertDmsSession = (params: {
   sessionId: string;
@@ -223,20 +219,24 @@ export const upsertDmsSession = (params: {
       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;
+    /** 对话带出的值:**与库里不同就更新**(值变了也改) */
+    const fromChatFields: DmsRow = {};
+    if (fromChat.name) fromChatFields.c_ent_name = fromChat.name;
+    if (fromChat.creditCode) fromChatFields.c_credit_code = fromChat.creditCode;
+    if (fromChat.imageUrl) fromChatFields.c_ent_image_url = fromChat.imageUrl;
+
+    /** 只在字段为空时才写的值 */
+    const fillIfEmpty: DmsRow = {
+      // 访客标识进 c_ip(已有值不动:可能是后端回填的真实 IP)
+      c_ip: resolveVisitorLabel(),
+    };
     if (!logged) {
       fillIfEmpty.c_is_login = "0";
-      if (!params.creditCode) fillIfEmpty.c_credit_code = resolveDmsCreditCode();
     }
-
-    if (params.creditCode) fillIfEmpty.c_credit_code = params.creditCode;
+    if (params.creditCode) {
+      // 验证脚本用的显式归属
+      fillIfEmpty.c_credit_code = params.creditCode;
+    }
 
     const base: DmsRow = {
       c_session_id: params.sessionId,
@@ -244,20 +244,27 @@ export const upsertDmsSession = (params: {
       c_updated_at: nowDmsTimestamp(),
     };
 
+    /** 登录态已写死的字段:对话带出的值不许顶掉 */
+    const loginOwned = logged ? new Set(["c_ent_name", "c_credit_code"]) : new Set<string>();
+
     const existing = await findDmsRowBy(DMS_COLUMN_SESSION, "c_session_id", params.sessionId);
     if (!existing) {
-      // 没有任何可写内容(访客且本轮没带回企业资料)→ **不建行**。
+      // 没有任何可写内容(访客且本轮没带回企业资料,也没有显式归属)→ **不建行**。
       // 尊重 2026-09-18 的决定「只传具体的会话记录(1889),另一个栏目不传了」:
       // 只有确实有企业信息可记时才落到 1887。
-      if (!logged && !Object.keys(fillIfEmpty).length) {
+      const hasChatContent = Object.keys(fromChatFields).length > 0;
+      if (!logged && !hasChatContent && !params.creditCode) {
         return true;
       }
       const content: DmsRow = {
         ...base,
         ...fillIfEmpty,
+        ...fromChatFields,
         ...authoritative,
         c_created_at: nowDmsTimestamp(),
-        ...(params.sessionTitle ? { title: params.sessionTitle } : {}),
+        // 系统字段 title:**创建时必须带**(不带 DMS 会落成字符串 "null",1889 踩过)。
+        // 兜底成「未命名会话」,与旧的 upsert 行为一致。
+        title: params.sessionTitle || "未命名会话",
         ...buildRequiredSessionFields(),
       };
       const id = await addDmsContent(DMS_COLUMN_SESSION, DMS_MODEL_SESSION, content);
@@ -270,7 +277,14 @@ export const upsertDmsSession = (params: {
       console.warn("[dms] 会话行缺少 uuid,无法更新", existing);
       return false;
     }
+
     const patch: DmsRow = { ...base, ...authoritative };
+    for (const [field, value] of Object.entries(fromChatFields)) {
+      if (loginOwned.has(field)) continue; // 登录值优先,对话值不许顶掉
+      if (!isSameRowValue(existing[field], value)) {
+        patch[field] = value; // 值不同就更新(用户 2026-09-22 修订)
+      }
+    }
     for (const [field, value] of Object.entries(fillIfEmpty)) {
       if (isEmptyRowValue(existing[field])) {
         patch[field] = value;