Jelajahi Sumber

fix(dms): 企业看不到访客的会话 + 登录认领过的行不被对话值改写

用户 2026-09-22 决定:不该让企业看到陌生访客的会话。

① 读侧护栏:fetchDmsSessions 排除「明确是访客」的行(c_is_login === "0")。
   只排除显式访客行(c_is_login 缺失的历史行照旧返回),可见范围与改造前完全一致。

② 顺带修掉一个更隐蔽的(差点自己造出串台):按上一轮「值不同就更新」的规则,
   登录认领过的行(c_is_login === "1")在用户退登后又问到别的公司时,c_credit_code
   会被对话值改写、而 c_is_login 仍是 "1" —— 既被改到别家公司名下,又会被读侧当
   登录行返回出去。修法:chatOwnsCompany = !logged && !claimedByLogin,
   即登录态认领过的行,公司字段只属于那家企业,对话值一律不碰。

验证:verify-dms-chat-storage(真实 DMS)65/65;verify-dms-payload-fields 19/19(新增
【2c】);两条守卫都实测「阉掉即失败」。回归 84/84 + 27/27 + 165/165 + 30/30 + 33/33;
build 与 validate-harness 通过。

另:回答用户「1889 靠 sessionId 能否读回」——能,fetchDmsSessionRecords 与
deleteDmsSession 都只按 c_session_id/c_record_id 查,不看 c_credit_code,所以 1889 的
该字段只是归属/统计用,不统一口径不影响功能。但由此发现部署层面的暴露:DMS 代理注入
全权限 token 且不区分调用者,任何能访问站点的人可读 DMS 全部数据 —— 已记为
tech-debt-tracker 条目 8(生产接入前必须处理)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 bulan lalu
induk
melakukan
50634ec31e

+ 31 - 15
harness/docs/exec-plans/tech-debt-tracker.md

@@ -101,24 +101,40 @@
   仍无 `text-size-adjust`、思考卡片仍靠 JS 测量切样式,`padding-bottom: 180px` 也仍是写死的)。
   **换台手机、或用户调大系统字号,仍可能变样**;方案 A/B/C 依然有效,等用户决定要不要做。
 
-### 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 加列。
+### 8. DMS 代理注入全权限 token:站点可访问 = DMS 数据可读(部署前必须处理)
+
+- **是什么**:前端所有 DMS 请求都走 `/dms-api/` 代理,**token 由代理服务端注入**
+  (dev 是 `vite.config.ts`,生产计划是 nginx 同款转发)。这个 token **不区分调用者** ——
+  任何能打开站点的人,在浏览器 devtools 里对 `/dms-api/content/selectContentList` 发任意查询,
+  就能读到 DMS 的**全部**数据:1887 会话、1888 企业工商信息、1889 问答记录,
+  **包括别家企业的**。代理只做「补 `/dms` 前缀 + 注入 token」,不做任何鉴权。
+- **为什么先欠着**:还在联调阶段、**生产尚未接入**(`.env.production` 没配 `VITE_DMS_API`)。
+  dev 环境无所谓(本机、只有自己)。
+- **欠着会怎样**:**生产一旦按现方案接上,DMS 数据等同公开** —— 含企业工商信息与全部问答记录。
+  这与「访客 / 登录」无关,是**部署层面**的暴露,登录态也挡不住(token 是共享的)。
+- **该还的条件(两条路)**:
+  1. **推荐**:后端提供 DMS 转发接口,按调用者身份(登录企业 / 访客 id)做**行级授权**,
+     前端不再直连 DMS —— 与 `docs/plans/active/dms-api-replacement.md` 里"前端直连还是后端转发"
+     那道待拍板的问题是同一件事,**这里给出了必须选后端的理由**;
+  2. 临时缓解:nginx 只放行前端真正用到的端点**且**强制按调用者身份改写查询条件 ——
+     实践上很难做对,不建议。
+- **现状**:**未处理**,生产接入前必须解决。
 
 ## 已还
 
-(还清后从上面移到此处,保留一行说明怎么还的,便于追溯)
+### 访客行会被企业看到(2026-09-22 已还)
+
+- **当时的问题**:1887 口径改为「`c_credit_code` 只放真实信用代码」后,访客行也带着
+  对话提到的那家公司的代码;而 `fetchDmsSessions`(读「我的会话」)按 `c_credit_code` 查、
+  且只在登录后调用 → **那家公司登录后会看到陌生访客的会话**。
+- **怎么还的**:`fetchDmsSessions` 增加一道过滤 —— **排除「明确是访客」的行**
+  (`c_is_login === "0"`)。只排除显式访客行(`c_is_login` 缺失的历史行照旧返回),
+  因此可见范围与改造前**完全一致**。
+- **同时修掉一个更隐蔽的**:登录认领过的行(`c_is_login === "1"`)不再允许访客对话改写
+  它的公司字段 —— 否则退登后再问到别的公司,会把这行改到那家公司名下、而 `c_is_login`
+  仍是 `"1"`,既串台又会被读侧当登录行返回。
+- **验证**:`verify-dms-chat-storage.mjs` 打真实 DMS,两个方向都断言(访客行查不到 /
+  登录行查得到),且实测「拿掉过滤该断言立刻失败」——是真守卫。
 
 ---
 

+ 37 - 0
harness/progress.md

@@ -3553,3 +3553,40 @@ F073 之后正文是**即时渲染**的(流式期间打字机关着),但**
 
 **⚠️ 待确认**:1889 仍用 `访客_<id>` 当 `c_credit_code`(它没有 `c_ip` 列)。要不要统一口径,
 得 DMS 侧先给 1889 加列。
+
+### 续 19:加护栏「企业看不到访客会话」+ 回答 1889 口径与风险问题
+
+**用户决定**:不该让企业看到陌生访客的会话 → 加护栏。并问:
+1889 靠 sessionId 是不是还是能显示过往对话?有别的风险就告诉他。
+
+**① 护栏(已加,双向断言)**:
+`fetchDmsSessions` 增加过滤:**排除「明确是访客」的行**(`c_is_login === "0"`)。
+只排除显式访客行(`c_is_login` 缺失的历史行照旧返回)→ **可见范围与改造前完全一致**。
+
+**② 过程中发现并修掉一个更隐蔽的问题(差点自己造出串台)**:
+按上一轮"值不同就更新"的规则,**登录认领过的行**(`c_is_login === "1"`)在用户退登后
+问到**另一家公司**时,`c_credit_code` 会被对话值改写,而 `c_is_login` 仍是 `"1"`
+—— 这行既被改到别家公司名下、又会被读侧当"登录行"返回出去。
+修法:`chatOwnsCompany = !logged && !claimedByLogin` ——
+**登录态认领过的行,公司字段只属于那家企业,对话值一律不碰**(图片另有 `chatOwnsImage` 判定)。
+
+**③ 用户第二个问题的答案(已当面回复)**:**是的,靠 sessionId 就能读回** ——
+`fetchDmsSessionRecords(sessionId)` 与 `deleteDmsSession(sessionId)` 都只按
+`c_session_id`(+ `c_record_id`)查,**完全不看 `c_credit_code`**;
+所以 1889 的 `c_credit_code` 是**纯归属/统计字段**,不参与任何访问判定 → 不统一口径不影响功能。
+**但**由此发现一个**部署层面的暴露**(与 1889 无关):
+DMS 代理**注入全权限 token 且不区分调用者** → 任何能打开站点的人用 devtools 就能读 DMS **全部**数据
+(1887/1888/1889,含别家企业工商信息)。生产按现方案接上就等于公开。
+已记为 `tech-debt-tracker` 条目 8,并给出「必须选后端转发」的理由。
+
+**运行过的验证**:
+- `verify-dms-chat-storage.mjs`(真实 DMS)**65/65**:新增「企业登录后看不到访客的会话」
+  「登录态造的会话能查到」「登录认领过的行不被对话值改写」「纯访客行值不同就更新」
+- `verify-dms-payload-fields.mjs` **19/19**:新增【2c】断言「登录认领过的行不写公司字段」
+- **两条守卫都实测「阉掉即失败」**:拿掉读侧过滤 → 泄漏断言失败;
+  拿掉认领保护 → 【2c】与 8.4 各失败 2 项
+- 回归:企业分类 84/84、适配层 27/27、答案流式 165/165、判空 30/30、聊天任务 33/33;
+  `npm run build` 与 `validate-harness` 通过
+
+**顺带修正**:测试里两处**假通过**的断言(第一版查错了公司代码 → 断言恒真)已改为查
+「该行实际带着的代码」,并注明原因,避免以后再犯。

+ 39 - 4
harness/tools/verify-dms-chat-storage.mjs

@@ -91,7 +91,10 @@ const cleanup = async () => {
 
 try {
   // ── 1. 会话行 ──────────────────────────────────────────────────────
+  // ⚠️ 先置登录态:1887 的读取会**排除访客行**(c_is_login="0"),
+  //    这一段测的是「企业自己的会话」,所以照登录态造数据。
   console.log('【1】会话(1887)');
+  enterpriseInfo.value = { credit_code: TEST_CREDIT, name: '验证企业' };
   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));
@@ -183,6 +186,7 @@ try {
   check('parseDmsTimestamp 毫秒', parseDmsTimestamp(1789651800000) === 1789651800000);
   check('parseDmsTimestamp 秒', parseDmsTimestamp(1789651800) === 1789651800000);
   check('parseDmsTimestamp 字符串', parseDmsTimestamp('2026-09-17 21:30:00') === 1789651800000, String(parseDmsTimestamp('2026-09-17 21:30:00')));
+  enterpriseInfo.value = null; // 下面这条断言的是**未登录**时的归属形态
   check('访客归属形态 访客_<id>', /^访客_.+/.test(resolveDmsCreditCode()), resolveDmsCreditCode());
 
   // ── 8. 1887 的企业字段(2026-09-22 用户要求)──────────────────────
@@ -245,13 +249,44 @@ try {
     JSON.stringify(entRow?.c_credit_code)
   );
 
-  // 8.4 退出登录后再写:登录态已经不在,就没有"登录值"要保护了 →
-  //     对话带出的值与库里不同 → 按新规则更新;但 c_is_login 只填空,不被改回 "0"
+  // 8.4 退出登录后再写(对话里是**另一家**公司)→ 这行已被登录态认领,公司字段**不许被改**
+  //     否则会把这行改到别的公司名下、而 c_is_login 还是 "1" → 串台
   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('退出后:信用代码**不被**对话值改写', entRow?.c_credit_code === '91310000LOGIN000001', JSON.stringify(entRow?.c_credit_code));
+  check('退出后:是否登录仍为 "1"(该字段只填空)', entRow?.c_is_login === '1', JSON.stringify(entRow?.c_is_login));
+
+  // 8.4b **纯访客行**(从没被登录认领过)→ 公司字段跟着对话走,值不同就更新
+  const SID_GUEST = `verify_guest_${Date.now().toString(36)}`;
+  const COMPANY_C = { profile: { data: { Name: '丙测试公司', CreditCode: '91310000CCCCCCCCCC' } } };
+  const COMPANY_D = { profile: { data: { Name: '丁测试公司', CreditCode: '91310000DDDDDDDDDD' } } };
+  await upsertDmsSession({ sessionId: SID_GUEST, sessionTitle: '纯访客', companyInfo: COMPANY_C });
+  await upsertDmsSession({ sessionId: SID_GUEST, companyInfo: COMPANY_D });
+  const guestRow = await sessionRowOf(SID_GUEST);
+  check('纯访客行:值不同 → 企业名称更新', guestRow?.c_ent_name === '丁测试公司', JSON.stringify(guestRow?.c_ent_name));
+  check('纯访客行:值不同 → 信用代码更新', guestRow?.c_credit_code === '91310000DDDDDDDDDD', JSON.stringify(guestRow?.c_credit_code));
+  check('纯访客行:是否登录 = "0"', guestRow?.c_is_login === '0', JSON.stringify(guestRow?.c_is_login));
+  // 8.5 访客行**不该**出现在那家公司的会话列表里(用户 2026-09-22 明确要求挡住)
+  // ⚠️ 要查**纯访客行实际带着的**公司代码(第一版查错了代码 → 断言恒真 = 假通过;
+  //    第二版查 SID8 → 它已被登录认领,不更新是对的)
+  const guestRows = await fetchDmsSessions('91310000DDDDDDDDDD');
+  check(
+    '**企业登录后看不到访客的会话**(按 c_is_login 过滤)',
+    !guestRows.some((r) => r.session_id === SID_GUEST),
+    `列表里有 ${guestRows.filter((r) => r.session_id === SID_GUEST).length} 行命中`
+  );
+  // 反向:登录态造的行**能**查到(否则就是过滤写反了)
+  // ⚠️ 用新建的行,别用 SESSION_ID —— 它在【5】删除会话那节已经被删掉了
+  const SID9 = `verify_login_${Date.now().toString(36)}`;
+  enterpriseInfo.value = { credit_code: TEST_CREDIT, name: '验证企业' };
+  await upsertDmsSession({ sessionId: SID9, sessionTitle: '登录态会话' });
+  const ownRows = await fetchDmsSessions(TEST_CREDIT);
+  check('登录态造的会话**能**查到(过滤没写反)', ownRows.some((r) => r.session_id === SID9), `共 ${ownRows.length} 行`);
+  enterpriseInfo.value = null;
+  await deleteDmsSession(SID9);
+  await deleteDmsSession(SID_GUEST);
   await deleteDmsSession(SID8);
 } finally {
   console.log('\n【清理】');

+ 17 - 1
harness/tools/verify-dms-payload-fields.mjs

@@ -84,7 +84,7 @@ check('**不带 c_title**(用户 2026-09-22:标题由运营侧生成)', ke
 console.log('\n【2】会话更新:不碰标题、不带运营字段、已有值不重写');
 captured.length = 0;
 // 让这次走「更新」分支: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: '甲测试公司' };
+const existingRow = { id: 'row-uuid', c_session_id: 'sess-A', c_ip: '访客_既有值', c_is_login: '0', c_ent_name: '甲测试公司' };
 globalThis.fetch = async (url, init = {}) => {
   const body = init.body ? String(init.body) : '';
   captured.push({ url: String(url), body });
@@ -124,6 +124,22 @@ console.log('\n【2b】值不同就更新 / 值相同不写');
   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【2c】登录认领过的行:对话值不许改它的公司字段');
+{
+  // 否则:用户登录过、之后退登又问到别的公司 → 这行会被改到那家公司名下,
+  // 而 c_is_login 仍是 "1" → 既串台、又会被读侧当登录行返回(用户 2026-09-22 要挡住的)
+  existingRow.c_is_login = '1';
+  existingRow.c_credit_code = 'LOGIN-CODE-0001';
+  await upsertDmsSession({
+    sessionId: 'sess-A',
+    sessionTitle: '会话标题乙',
+    companyInfo: { profile: { data: { Name: '别家公司', CreditCode: 'ANOTHER-CODE' } } },
+  });
+  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 = {}) => {

+ 24 - 4
src/network/api/dms/chat-sessions-dms.ts

@@ -244,10 +244,22 @@ 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);
+
+    /**
+     * 这一行的**公司身份由谁决定**?
+     * - 已被登录态认领过的行(库里 `c_is_login === "1"`)→ 属于那家企业,
+     *   **对话值不许改它的公司字段**。否则:用户登录过、之后退登又问到别的公司,
+     *   这行的 `c_credit_code` 会被改成那家公司,而 `c_is_login` 还是 `"1"`
+     *   —— 既串到了别的公司名下,又会被读侧当成"登录行"返回出去。
+     * - 否则(纯访客行)→ 公司字段跟着对话走(值不同就更新,用户 2026-09-22 要求)。
+     */
+    const claimedByLogin = String(existing?.c_is_login ?? "").trim() === "1";
+    const chatOwnsCompany = !logged && !claimedByLogin;
+    /** 企业图片:只在「对话讲的就是本行那家公司」时才更新(登录时要与登录公司对得上) */
+    const chatOwnsImage =
+      chatOwnsCompany ||
+      (!!logged && !!fromChat.creditCode && fromChat.creditCode === logged.credit_code);
     if (!existing) {
       // 没有任何可写内容(访客且本轮没带回企业资料,也没有显式归属)→ **不建行**。
       // 尊重 2026-09-18 的决定「只传具体的会话记录(1889),另一个栏目不传了」:
@@ -280,7 +292,8 @@ export const upsertDmsSession = (params: {
 
     const patch: DmsRow = { ...base, ...authoritative };
     for (const [field, value] of Object.entries(fromChatFields)) {
-      if (loginOwned.has(field)) continue; // 登录值优先,对话值不许顶掉
+      const writable = field === "c_ent_image_url" ? chatOwnsImage : chatOwnsCompany;
+      if (!writable) continue;
       if (!isSameRowValue(existing[field], value)) {
         patch[field] = value; // 值不同就更新(用户 2026-09-22 修订)
       }
@@ -359,6 +372,13 @@ export const fetchDmsSessions = async (creditCode: string): Promise<RemoteSessio
   });
 
   return rows
+    // 🚨 **排除「明确是访客」的行**(`c_is_login === "0"`)——
+    // 2026-09-22 起访客行也带**真实公司代码**(对话里提到的那家公司),
+    // 不加这道过滤,那家公司登录后就会在自己的会话列表里看到陌生访客的对话
+    // (用户 2026-09-22 明确要求挡住)。改造前访客行的代码是 `访客_<id>`、匹配不上,
+    // 所以这里只是**保持与改造前一致的可见范围**。
+    // ⚠️ 只排除「显式为访客」的行:`c_is_login` 缺失的历史行照旧返回,不缩小既有范围。
+    .filter((row) => String(row.c_is_login ?? "").trim() !== "0")
     .map(
       (row): RemoteSession => ({
         session_id: String(row.c_session_id || ""),