Răsfoiți Sursa

feat(dms): 会话改名同步系统 title;问答记录的 title/summary 用问题文本

用户两条要求:①改会话标题后 DMS 的 title 同步 ②存问答记录时把问题文本写进 title。

动手前先用新脚本 probe-dms-title.mjs 验清契约(探完自动清理),查出三件原先不知道的事:
1. 1887 的栏目模型今天 17:50 被扩过(字段 7 → 23),新增 4 个 must=true:
   summary / qpyszx / qyzt / rzqpyx —— **不带它们写入会被 214 数据错误拒绝**
   (实测:不带 214、带上 200);更新不校验 must
2. DMS 的系统字段 title **只在创建那一刻初始化**,改 c_title 不会带动它
   (实测:改完 c_title=标题B、title 仍是标题A)—— 这正是「改名后 title 不同步」的真因
3. title 可以直接在 content 里写;DMS 会静默忽略未知字段(1889 没有 summary 字段)

改动(src/network/api/dms/chat-sessions-dms.ts):
- 新增 buildAnalyticsFields(summary):补上那 4 个 must 字段,仅新增时带。
  qpyszx 恒 true、qyzt = 有企业信用代码、summary = 会话用标题/记录用问题文本、
  **rzqpyx(落户青浦意向)前端无从判断 → 先 false,代码注释已标明是猜的**
- upsertDmsSession:每次 upsert 都显式带 title = 会话标题 → 改名后 DMS 的 title 跟着变
- upsertDmsRecord:新增时 title 与 summary = 问题文本 → 不再是 "null",列表中可辨认

验证:verify-dms-chat-storage.mjs 扩到 42 项(新增第 7 节 title 断言),42/42 通过;
过程中有一条断言是我写错的(以为 1889 也有 summary),已改成如实断言
「1889 无 summary 字段、多发被忽略」;npm run build 通过;其余脚本全绿(33/11/31);
validate-harness 通过。

⚠️ 需确认:rzqpyx 的取值是猜的;qpyszx/qyzt 的口径也请核对。另:本轮之前前端的
会话写入其实已被 214 拒绝(模型扩容后缺 must 字段),补上后恢复。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 3 zile în urmă
părinte
comite
beac39d544

+ 8 - 0
README.md

@@ -169,6 +169,14 @@
   - 新增回归脚本:`verify-chat-task-utils.mjs`(33 项,含把「响应式容器破坏对象同一性」
     这个陷阱钉死的断言)、`verify-coordinator-multi-instance.mjs`
     (11 项,验证多实例事件隔离——并行化的地基)
+- DMS 写入补齐 `title` 与运营分析字段:
+  - 会话(1887):每次 upsert 都显式带 `title` = 会话标题 —— DMS 的系统字段 `title`
+    原本只在创建时初始化,**改 `c_title` 不会带动它**,所以前端改名后 DMS 里看到的标题不变
+  - 问答记录(1889):`title` 与 `summary` 用**问题文本**(原先库里是字符串 "null",
+    在 DMS 列表里看不出是哪一轮)
+  - 补上 DMS 模型新增的 4 个 `must=true` 字段(`summary` / `qpyszx` / `qyzt` / `rzqpyx`)——
+    不带它们写入会被 `214 数据错误` 拒绝;其中「是否落户青浦意向」前端无从判断,
+    先给 `false` 待确认
 - 环境变量整理:**地址类配置收进 env,不再写死在代码里**
   - 新增 `VITE_CHAT_TARGET`(聊天后端真实地址)——此前它硬编码在 `vite.config.ts` 的
     代理配置里;现由 `vite.config.ts` 从 env 读取(保留同值兜底)

+ 39 - 0
harness/progress.md

@@ -1634,3 +1634,42 @@
 - **下一步最佳动作**:若要继续收敛,优先级仍是①企业信息切 DMS(前端只用 name/credit_code,
   但注意它有 5 处依赖)②统计埋点是否收口 ③清理死代码
 
+## Session 038
+
+- **日期**:2026-09-17
+- **本轮目标**:用户两条要求 ——①改会话标题后 DMS 的 `title` 同步 ②存问答记录时把问题文本写进 `title`
+- **先验契约再动手**(新脚本 `harness/tools/probe-dms-title.mjs`,用完即清理),查出**三件原本不知道的事**:
+  1. **1887 的栏目模型今天 17:50 被扩过**:字段 7 → 23 个,新增 **4 个 `must=true`**
+     (`summary` 摘要 / `qpyszx` 是否青浦营商咨询 / `qyzt` 是否识别到企业主体 / `rzqpyx` 是否落户青浦意向)。
+     **不带它们写入会被 `214 数据错误` 拒绝**(实测:不带 214、带上 200)
+  2. **DMS 的系统字段 `title` 只在创建那一刻初始化**:改 `c_title` 后 `title` **不跟随**
+     (实测:改完 `c_title=标题B`、`title` 仍是 `标题A`)——**这正是用户说"改名后 title 不同步"的真因**
+  3. `title` **可以直接在 content 里写**(写了就是那个值),且 DMS 会**静默忽略未知字段**
+     (1889 没有 `summary` 字段,多发不会报错)
+- **改动**(`src/network/api/dms/chat-sessions-dms.ts`):
+  - 新增 `buildAnalyticsFields(summary)`:补上那 4 个 must 字段。
+    **只有新增时才带**(实测更新不校验 must);取值:`qpyszx` 恒 true、
+    `qyzt` = 有企业信用代码、`summary` = 会话用标题 / 记录用问题文本、
+    **`rzqpyx` 前端无从判断 → 先 false(已在代码注释里标明是猜的,待确认/后端回填)**
+  - `upsertDmsSession`:每次 upsert 都显式带 `title` = 会话标题 → **改名后 DMS 的 title 跟着变**
+  - `upsertDmsRecord`:新增记录时 `title` = 问题文本、`summary` = 问题文本
+    → 不再是字符串 "null",DMS 列表里能看出是哪一轮
+- **运行过的验证**:
+  - `verify-dms-chat-storage.mjs` 扩到 **42 项**(新增第 7 节 title 断言),**42/42 通过**:
+    新建带 title、改名后 c_title 与 title **都变**、记录 title = 问题文本、
+    以及「1889 无 summary 字段、多发被忽略」这条差异
+  - 过程中有一条断言是**我写错的**(以为 1889 也有 summary),已改成如实断言
+  - `npm run build` 通过;其余脚本全绿(33/11/31);`validate-harness` 通过
+- **已记录证据**:本文件 Session 038;`verify-dms-chat-storage.mjs` 第 7 节;
+  `harness/tools/probe-dms-title.mjs`(探测脚本,保留可重跑)
+- **更新过的文件或工件**:`src/network/api/dms/chat-sessions-dms.ts`、
+  `harness/tools/{probe-dms-title.mjs,verify-dms-chat-storage.mjs}`、根 `README.md`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **`rzqpyx`(落户青浦意向)的取值是猜的**——前端拿不到判断依据。若该字段应由后端
+    分析会话后回填,请告知,我把前端这一项去掉或改值(改一个常量即可)
+  - ⚠️ 同理 `qpyszx`(恒 true)与 `qyzt`(有企业码为 true)也请确认是否符合业务口径
+  - ⚠️ **这条必须说清楚**:在我做这轮改动**之前**,前端的会话写入其实已经**被 214 拒绝**
+    (模型 17:50 扩容后缺 must 字段)。用户认为"前端写入没问题",但实测是坏的;
+    本轮补上 must 字段后恢复。若用户看到的"正常"另有原因,请告知
+- **下一步最佳动作**:确认上面三个分析字段的业务口径
+

+ 127 - 0
harness/tools/probe-dms-title.mjs

@@ -0,0 +1,127 @@
+/**
+ * 验证 DMS 的 `title` 字段行为 —— 为「改名同步 title」与「记录 title 用问题文本」两件事定契约。
+ *
+ * 要回答的问题:
+ *   ① 1887 的模型今天新增了 4 个 must 字段(summary/qpyszx/qyzt/rzqpyx),
+ *      不带它们写入会被 214 拒 —— 那**带上就成功吗**?
+ *   ② `title` 是系统字段,能不能直接在 content 里写?写了会被 214 还是被接受?
+ *   ③ 改 `c_title`(改名路径)后,系统的 `title` 会不会跟着变?
+ *   ④ 1889 的 `title` 现在恒为 "null",能否写成问题文本?
+ *
+ * 怎么跑(token 从参数读,不落盘):
+ *   node harness/tools/probe-dms-title.mjs <DMS_TOKEN>
+ *
+ * 脚本只写 `probe-title-*` 标记的数据,**结束时全部清理**。
+ */
+
+const HOST = process.env.DMS_HOST || '121.43.55.7:10081';
+const BASE = `http://${HOST}/dms`;
+const TOKEN = process.argv[2] || '';
+if (!TOKEN) {
+  console.error('用法:node harness/tools/probe-dms-title.mjs <DMS_TOKEN>');
+  process.exit(2);
+}
+
+const call = async (method, path, form) => {
+  const init = { method, headers: { token: TOKEN } };
+  if (form) {
+    init.headers['Content-Type'] = 'application/x-www-form-urlencoded';
+    init.body = new URLSearchParams(form).toString();
+  }
+  const res = await fetch(`${BASE}${path}`, init);
+  const text = await res.text();
+  let json = null;
+  try { json = JSON.parse(text); } catch { /* 非 JSON */ }
+  return { status: res.status, json, text };
+};
+
+const find = async (columnId, field, value) => {
+  const r = await call('POST', '/content/selectContentList', {
+    columnId: String(columnId), page: '0', pageSize: '10',
+    search: JSON.stringify([{ field, searchType: 1, content: { value } }]),
+  });
+  return (r.json?.content?.data || [])[0] || null;
+};
+
+const add = (columnId, modelId, content) =>
+  call('POST', '/content/addContent', { columnId: String(columnId), modelId: String(modelId), content: JSON.stringify(content) });
+
+const update = (columnId, modelId, content) =>
+  call('POST', '/content/updateContent', { columnId: String(columnId), modelId: String(modelId), content: JSON.stringify(content) });
+
+const del = (columnId, id) => call('POST', '/content/updateAudit', { columnId: String(columnId), id, state: '4' });
+
+const TAG = `probe-title-${Date.now().toString(36)}`;
+const created = [];
+
+const run = async () => {
+  console.log(`\nDMS title 字段探测  标记=${TAG}\n`);
+
+  // ── ① 1887:带 must 字段写入 ───────────────────────────────────────
+  console.log('【1】1887:带新 must 字段(summary/qpyszx/qyzt/rzqpyx)写入');
+  const base = {
+    c_credit_code: TAG, c_session_id: TAG, c_title: '标题A', c_source: 'zhaoshang',
+    summary: TAG, qpyszx: true, qyzt: true, rzqpyx: false,
+  };
+  let r = await add(1887, 2034, base);
+  console.log('    → code=%s %s', r.json?.code, r.json?.message);
+  if (r.json?.code === 200) {
+    created.push(['1887', String(r.json.content)]);
+    let row = await find(1887, 'c_session_id', TAG);
+    console.log('    读回: c_title=%s | 系统 title=%s', JSON.stringify(row?.c_title), JSON.stringify(row?.title));
+
+    // ── ③ 改名:改 c_title,看系统 title 跟不跟 ──────────────────────
+    console.log('\n【3】改 c_title → 看系统 title 是否跟随');
+    const u = await update(1887, 2034, { id: String(r.json.content), c_title: '标题B' });
+    console.log('    updateContent → code=%s %s', u.json?.code, u.json?.message);
+    row = await find(1887, 'c_session_id', TAG);
+    console.log('    读回: c_title=%s | 系统 title=%s', JSON.stringify(row?.c_title), JSON.stringify(row?.title));
+  }
+
+  // ── ② 1887:再试「显式带 title」能不能写 ──────────────────────────
+  console.log('\n【2】1887:content 里显式带 title,会被接受还是 214?');
+  const base2 = { ...base, c_session_id: TAG + '-2', title: '显式title值' };
+  r = await add(1887, 2034, base2);
+  console.log('    → code=%s %s', r.json?.code, r.json?.message);
+  if (r.json?.code === 200) {
+    created.push(['1887', String(r.json.content)]);
+    const row = await find(1887, 'c_session_id', TAG + '-2');
+    console.log('    读回: c_title=%s | 系统 title=%s', JSON.stringify(row?.c_title), JSON.stringify(row?.title));
+  }
+
+  // ── ④ 1889:记录行写 title = 问题文本 ─────────────────────────────
+  console.log('\n【4】1889:content 里带 title=问题文本');
+  const rec = {
+    c_credit_code: TAG, c_session_id: TAG, c_record_id: TAG,
+    c_question: '这是一条测试问题', c_answer: '这是测试回答',
+    c_source: 'zhaoshang', title: '这是一条测试问题',
+    summary: TAG,
+  };
+  r = await add(1889, 2038, rec);
+  console.log('    → code=%s %s', r.json?.code, r.json?.message);
+  if (r.json?.code === 200) {
+    created.push(['1889', String(r.json.content)]);
+    const row = await find(1889, 'c_record_id', TAG);
+    console.log('    读回: c_question=%s | 系统 title=%s', JSON.stringify(row?.c_question), JSON.stringify(row?.title));
+
+    console.log('\n    → 再试:不带 title,只带 c_question(对照)');
+    const r2 = await add(1889, 2038, { ...rec, c_record_id: TAG + '-2', title: undefined });
+    console.log('      code=%s %s', r2.json?.code, r2.json?.message);
+    if (r2.json?.code === 200) {
+      created.push(['1889', String(r2.json.content)]);
+      const row2 = await find(1889, 'c_record_id', TAG + '-2');
+      console.log('      读回: 系统 title=%s', JSON.stringify(row2?.title));
+    }
+  }
+
+  // ── 清理 ──────────────────────────────────────────────────────────
+  console.log('\n【清理】');
+  for (const [col, id] of created) {
+    const d = await del(Number(col), id);
+    console.log('    删 %s %s → %s', col, id.slice(0, 8), d.json?.code);
+  }
+  const left = [...(await find(1887, 'c_credit_code', TAG) ? [1] : []), ...(await find(1889, 'c_credit_code', TAG) ? [1] : [])];
+  console.log('    残留:', left.length ? left.length + ' 行(需手工处理)' : '0 ✓');
+};
+
+run().catch((e) => { console.error('脚本异常:', e); process.exit(1); });

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

@@ -177,5 +177,45 @@ try {
   console.log(`  测试数据已清理(残留 ${left.length} 行)`);
 }
 
+// ── 7. title 字段(2026-09-17 用户要求:改名要跟随、记录写问题文本)──────
+console.log('\n【7】title 字段');
+{
+  const SID2 = SESSION_ID + '_t';
+  const REC3 = 'rec3_' + Date.now().toString(36);
+  const rowOf = async (columnId, field, value) =>
+    (await searchDmsContents(columnId, {
+      search: [{ field, searchType: 1, content: { value } }],
+      page: 0,
+      pageSize: 1,
+    }))[0];
+
+  await upsertDmsSession({ sessionId: SID2, title: '标题甲', creditCode: TEST_CREDIT });
+  let row = await rowOf(DMS_COLUMN_SESSION, 'c_session_id', SID2);
+  check('新建会话带上了系统 title', row?.title === '标题甲', JSON.stringify(row?.title));
+  check(
+    '运营分析字段已写入(must 字段,不带会 214)',
+    row?.summary === '标题甲' && row?.qpyszx === true,
+    JSON.stringify([row?.summary, row?.qpyszx])
+  );
+
+  await upsertDmsSession({ sessionId: SID2, title: '标题乙', 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));
+
+  await upsertDmsRecord({ recordId: REC3, sessionId: SID2, creditCode: TEST_CREDIT, question: '记录的问题文本' });
+  const recRow = await rowOf(DMS_COLUMN_RECORD, 'c_record_id', REC3);
+  check('记录的系统 title = 问题文本(原先写的是 "null")', recRow?.title === '记录的问题文本', JSON.stringify(recRow?.title));
+  // 1889 的模型里**没有** summary 字段(只有 1887 有),DMS 会静默忽略未知字段 ——
+  // 所以写入仍成功,但读回来不会有这个键。这条断言把这个差异钉下来。
+  check(
+    '1889 无 summary 字段,多发的字段被 DMS 忽略(写入不受影响)',
+    recRow !== undefined && recRow.summary === undefined,
+    JSON.stringify(recRow?.summary)
+  );
+
+  await deleteDmsSession(SID2);
+}
+
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 38 - 1
src/network/api/dms/chat-sessions-dms.ts

@@ -43,6 +43,30 @@ const resolveSource = (): string => ((globalThis as any).source as string) || "z
 export const resolveDmsCreditCode = (): string =>
   enterpriseInfo.value?.credit_code || `访客_${getOrCreateAssistantVisitorId()}`;
 
+/**
+ * 会话 / 问答记录共用的「运营分析字段」。
+ *
+ * ⚠️ 这几个在 DMS 的栏目模型里是 **`must=true`**(2026-09-17 模型扩充后加的),
+ * **写入时必须带上**,否则 `addContent` 会被 `214 数据错误` 拒绝(实测:
+ * 不带就是 214,带上就 200)。字段与取值依据:
+ *
+ * | 字段 | 别名 | 前端怎么取值 |
+ * |---|---|---|
+ * | `summary` | 摘要 | 会话用标题、记录用问题文本 |
+ * | `qpyszx` | 是否为青浦营商咨询 | 恒 `true`(本助手就是青浦营商咨询) |
+ * | `qyzt` | 是否识别到企业主体 | 有企业信用代码(非访客)为 `true` |
+ * | `rzqpyx` | 是否落户青浦意向 | **前端无从判断** → 先给 `false`,等后端回填 |
+ *
+ * ⚠️ `rzqpyx` 的取值是**猜的**(属业务判断,前端拿不到依据)——若不对,
+ * 改这一个常量即可;也可能该由后端在分析会话后回填。
+ */
+const buildAnalyticsFields = (summary: string): DmsRow => ({
+  summary: summary || "",
+  qpyszx: true,
+  qyzt: !!enterpriseInfo.value?.credit_code,
+  rzqpyx: false,
+});
+
 /** 写 DMS 的时间格式(Step-0 实测:`YYYY-MM-DD HH:mm:ss` 被接受) */
 export const nowDmsTimestamp = (): string => {
   const d = new Date();
@@ -121,6 +145,10 @@ export const upsertDmsSession = (params: {
       c_credit_code: params.creditCode || resolveDmsCreditCode(),
       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(),
     };
@@ -128,6 +156,8 @@ export const upsertDmsSession = (params: {
     const existing = await findRowBy(DMS_COLUMN_SESSION, "c_session_id", params.sessionId);
     if (!existing) {
       content.c_created_at = nowDmsTimestamp();
+      // 运营分析字段是 must=true,**只有新增时才必须带**(更新时不校验,实测)
+      Object.assign(content, buildAnalyticsFields(params.title || "未命名会话"));
       const id = await addDmsContent(DMS_COLUMN_SESSION, DMS_MODEL_SESSION, content);
       if (!id) console.warn("[dms] 会话写入失败", params.sessionId);
       return !!id;
@@ -171,9 +201,16 @@ export const upsertDmsRecord = (params: {
     const existing = await findRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
     if (!existing) {
       const content: DmsRow = { ...base };
-      if (params.question !== undefined) content.c_question = params.question;
+      if (params.question !== undefined) {
+        content.c_question = params.question;
+        // 系统字段 title 用问题文本:不写的话库里是字符串 "null",在 DMS 列表里
+        // 完全看不出这一条是哪一轮(用户要求)
+        content.title = params.question;
+      }
       if (params.answer !== undefined) content.c_answer = params.answer;
       content.c_created_at = nowDmsTimestamp();
+      // 运营分析字段(must=true):摘要用问题文本
+      Object.assign(content, buildAnalyticsFields(params.question || ""));
       const id = await addDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, content);
       if (!id) console.warn("[dms] 问答记录写入失败", params.recordId);
       return !!id;