ソースを参照

refactor(dms): 问答记录存储粒度改回「一问一答一条」;计划归档 + 归档检查

一、存储粒度改回初版(用户要求)
- chat-sessions-dms 恢复 upsertDmsRecord:幂等键 c_record_id,
  c_question / c_answer 各存一半;删掉「整段对话 JSON」那套
  (DmsTranscriptMessage / serializeTranscript / parseTranscript)
- fetchDmsSessionRecords 按 c_session_id 查、按 c_created_at 正序逐行映射
- writeDmsFeedback 改回按 c_record_id 找行、更新 c_feedback_* 列(不再读-改-写 JSON);
  定位不到时不新建行
- deleteDmsSession 删该会话名下全部问答行 + 会话行
- useBusinessAssistantChat 四处挂钩改调 saveTurnToDms();Session 026 的聚合 provider 保留
- 改回的理由(已写进代码注释):一问一答一条更贴近库表原本语义
  (chat_record 就是一行一轮问答),也便于按轮次检索/统计

二、计划归档(用户指出的问题,属实)
- completed/ 此前完全是空的,三个计划全堆在 active/,其中两个早已实现
- dms-chat-storage、policy-card-list-sidebar 移入 completed/ 并更新状态头
  (含「浏览器人工点验未做」的显式标注,不含糊)
- dms-api-replacement 留在 active,但加了「落地进度」表如实标出:
  1887/1889 相关已实现,企业信息(1888)未做
- validate-harness 新增两条机械检查:active/ 里状态写着已完成 → 提醒归档;
  completed/ 里状态仍写进行中 → 提醒不一致。已实测会响
- sops/session-end.md 收尾清单加「做完的计划已归档」一项

验证:npm run build 通过;verify-dms-chat-storage 36/36 通过(已改为
一问一答一条的断言:补 answer 仍同一行不覆盖 question、第 2 轮才是第 2 行、
反馈只改命中那一轮且另一轮未被误改、删除连带清理);其余脚本全绿
(31/8/10/21/30);validate-harness 通过且新检查实测可触发。
⚠️ 浏览器端到端仍未人工点验(DMS 与政策列表两条都还挂着)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 3 日 前
コミット
162f1b4b3d

+ 7 - 0
README.md

@@ -150,3 +150,10 @@
     旧列表逻辑靠既有守卫自然短路,`openPolicyList` 一行未动
     旧列表逻辑靠既有守卫自然短路,`openPolicyList` 一行未动
   - 新增 `harness/tools/verify-policy-card-list.mjs`(31 项断言:聚合并集保序、
   - 新增 `harness/tools/verify-policy-card-list.mjs`(31 项断言:聚合并集保序、
     坏 JSON/空输入不抛错、首见优先去重、降序+同分稳定、六面关键词过滤)
     坏 JSON/空输入不抛错、首见优先去重、降序+同分稳定、六面关键词过滤)
+- DMS 的问答记录存储粒度**改回「一问一答一条」**(幂等键 `c_record_id`,
+  问题与回答各存一半)。曾一度改成「一个会话一行、整段对话 JSON 塞进 `c_answer`」,
+  现按需求改回——更贴近库表原本语义(`chat_record` 即一行一轮问答),也便于按轮次检索统计。
+  反馈随之改为写在该轮的 `c_feedback_*` 列上;会话删除连带清理其名下所有问答行
+- 计划归档:`harness/docs/exec-plans/completed/` 开始启用,
+  已实现的两个计划(DMS 落地、政策列表侧边栏)移入并更新状态;
+  结构校验脚本新增两条检查(`active/` 里状态写着已完成、`completed/` 里状态仍写进行中)

+ 18 - 2
harness/docs/exec-plans/active/dms-api-replacement.md

@@ -1,10 +1,26 @@
-# 用 DMS 接口替换前端接口 — 方案(待拍板)
+# 用 DMS 接口替换前端接口 — 方案
 
 
-> **状态**:⏳ 等用户决定 —— 见文末「需要你决定
+> **状态**:🔄 **部分已实现**(2026-09-17 更新)—— 见下方「落地进度
 > **日期**:2026-09-17
 > **日期**:2026-09-17
 > **依据文档**:[`DMS_API.md`](../../reference/DMS_API.md)、[`DMS_COLUMNS.md`](../../reference/DMS_COLUMNS.md)、
 > **依据文档**:[`DMS_API.md`](../../reference/DMS_API.md)、[`DMS_COLUMNS.md`](../../reference/DMS_COLUMNS.md)、
 > [`DMS_MAPPING.md`](../../reference/DMS_MAPPING.md)(三份都来自 `DMS_Data_Migration` 项目)
 > [`DMS_MAPPING.md`](../../reference/DMS_MAPPING.md)(三份都来自 `DMS_Data_Migration` 项目)
 > **现状清单**:本文第四节逐条列出前端当前调用的全部接口
 > **现状清单**:本文第四节逐条列出前端当前调用的全部接口
+>
+> ## 落地进度(2026-09-17)
+>
+> | 本文第一节列的项 | 状态 |
+> |---|---|
+> | 1.2 会话列表(1887) | ✅ 已切 —— [`../completed/dms-chat-storage.md`](../completed/dms-chat-storage.md) |
+> | 1.3 会话历史/问答记录(1889) | ✅ 已切 —— 同上 |
+> | 2.1 会话改名 / 2.2 删除 / 2.3 反馈(1889) | ✅ 已切 —— 同上 |
+> | **1.1 企业信息(1888)** | ❌ **未做** —— 仍走 `{VITE_API}/fta_ent_policy/enterprise_info` |
+>
+> 用户当时选择「先切换助手会话和助手问答记录」,故本轮只做了 1887/1889 相关。
+> 企业信息那条**要单独决定**:它服务的是「企业登录后拉一次工商信息」,
+> 与对话链路无关,且前端只用到 `name` / `credit_code` 两个字段(见本文 1.1)。
+>
+> 另:本文原先问的四个问题里,「前端直连还是后端转发」实际选了 **vite 代理注入 token**
+> (浏览器不持有 DMS token),生产需 nginx 等价转发。
 
 
 ---
 ---
 
 

+ 8 - 2
harness/docs/exec-plans/active/dms-chat-storage.md → harness/docs/exec-plans/completed/dms-chat-storage.md

@@ -1,8 +1,14 @@
 # 会话/问答记录/反馈落地 DMS
 # 会话/问答记录/反馈落地 DMS
 
 
-> **状态**:🔄 进行中(存储粒度已按用户要求改为「一会话一行」;浏览器端到端待人工验证)
+> **状态**:✅ **已完成**(2026-09-17 归档到 `completed/`)
+> **结果**:会话、问答记录、反馈全部切到 DMS;回归脚本 36/36 通过(打真实 DMS)。
+> **存储粒度**:**一问一答一条**(1889 幂等键 `c_record_id`)。曾一度改成「一个会话一行、
+> 整段对话 JSON 塞进 c_answer」,同日按用户要求**改回本版**——理由是这样更贴近库表原本的
+> 语义(`chat_record` 就是一行一轮问答),也便于按轮次检索/统计。
+> ⚠️ **未做**:浏览器人工点验(发消息 → DMS 出现行 → 刷新 → 列表/历史 → 点赞 → 改名/删除)。
+> 自动化证据见 `harness/tools/verify-dms-chat-storage.mjs`。
 > **日期**:2026-09-17
 > **日期**:2026-09-17
-> **前置**:[`dms-api-replacement.md`](dms-api-replacement.md)(DMS 替换总方案,用户已批准的 3 读 + 3 写)
+> **前置**:[`../active/dms-api-replacement.md`](../active/dms-api-replacement.md)(DMS 替换总方案)
 
 
 ## 目标
 ## 目标
 
 

+ 7 - 0
harness/docs/exec-plans/active/policy-card-list-sidebar.md → harness/docs/exec-plans/completed/policy-card-list-sidebar.md

@@ -1,5 +1,12 @@
 # 卡片区「更多」→ 新侧边栏「政策列表」— 实施计划
 # 卡片区「更多」→ 新侧边栏「政策列表」— 实施计划
 
 
+> **状态**:✅ **已完成**(2026-09-17 归档到 `completed/`)
+> **结果**:面板新增 `cardList` 模式,卡片区「更多」打开「政策列表」侧边栏
+> (本会话卡片跨消息聚合、去重、按匹配度排序;保留搜索框与热门搜索,无部门/分类筛选);
+> 详情面板的「返回政策事项列表」仍走旧列表(按要求只换「更多」入口)。
+> 回归脚本 `harness/tools/verify-policy-card-list.mjs` 31/31 通过。
+> ⚠️ **未做**:浏览器人工点验。
+
 ## Context(为什么做这件事)
 ## Context(为什么做这件事)
 
 
 用户要求:点卡片区「更多」按钮,打开**一套新侧边栏**——标题「政策列表」,
 用户要求:点卡片区「更多」按钮,打开**一套新侧边栏**——标题「政策列表」,

+ 58 - 0
harness/progress.md

@@ -28,6 +28,64 @@
 
 
 ## 会话记录
 ## 会话记录
 
 
+## Session 027
+
+- **日期**:2026-09-17
+- **本轮目标**:用户两条指示 —— ①DMS 存储粒度**改回初版「一问一答一条」** ②**计划文件更新不及时**
+  (实现完的还堆在 `active/`,没移进 `completed/`)
+- **已完成**:
+
+### ① 存储粒度改回「一问一答一条」(用户要求)
+
+- `chat-sessions-dms.ts`:撤掉「一会话一行 + 整段对话 JSON」的实现,恢复
+  `upsertDmsRecord()`(幂等键 `c_record_id`,`c_question` / `c_answer` 各存一半);
+  删除 `DmsTranscriptMessage` / `serializeTranscript` / `parseTranscript`
+- `fetchDmsSessionRecords()` 改成按 `c_session_id` 查、按 `c_created_at` 正序,
+  逐行映射(`record_id` 回填 `c_record_id`,反馈闭环保留)
+- `writeDmsFeedback()` 改回「按 `c_record_id` 找行 → 更新 `c_feedback_*` 列」,
+  不再做 JSON 读-改-写;定位不到时**不新建行**(避免造出只有反馈没有问答的空记录)
+- `deleteDmsSession()` 改回「删该会话名下全部问答行 + 会话行」
+- `useBusinessAssistantChat.ts` 四处挂钩(sendMessage / totalResponse / close /
+  submitQuestionAnswers)改为调 `saveTurnToDms()`(提交写 question、完成补 answer);
+  **Session 026 加的聚合 provider 原样保留**
+- ⚠️ 改回的**理由**(写进代码注释与计划归档):一问一答一条更贴近库表原本语义
+  (`chat_record` 就是一行一轮问答),也便于按轮次检索/统计
+
+### ② 计划归档 + 加机械约束(用户指出)
+
+- **用户指出属实**:`completed/` **完全是空的**,三个计划全在 `active/`,
+  其中 `dms-chat-storage` 与 `policy-card-list-sidebar` 早已实现
+- 已归档两个到 `completed/`,并**更新其状态头**为实际结果(含「浏览器人工点验未做」的显式标注)
+- `dms-api-replacement.md` 保留在 active,但**加了「落地进度」表**:
+  1887/1889 相关的 3 读 3 写已实现,**企业信息(1888)未做**——如实标出,不假装全做完
+- **给 `validate-harness.mjs` 加了两条检查**(机械约束,不靠人记):
+  1. `active/` 里的计划若状态写着「✅/已完成」→ 提醒该归档
+  2. `completed/` 里的计划若状态仍写「🔄/进行中/待拍板」→ 提醒状态与位置不一致
+  - **实测会响**:把 completed 里的文件复制回 active,校验器立刻报出该文件
+- `harness/docs/exec-plans/README.md` 的「机械检查」一节已说明该脚本会管归档
+
+- **运行过的验证**:
+  - `npm run build` 通过
+  - `node harness/tools/verify-dms-chat-storage.mjs` **36/36 通过**(已改为「一问一答一条」的断言:
+    第 1 轮 1 行 → 补 answer 仍 1 行且不覆盖 question → 第 2 轮 2 行 →
+    反馈只改命中那一轮、另一轮未被误改 → 删除连带清理)
+  - `node harness/tools/validate-harness.mjs` 通过;**并实测新检查会报错**
+- **已记录证据**:本文件 Session 027;`completed/` 下两个计划的状态头;
+  `harness/tools/validate-harness.mjs` 的归档检查
+- **更新过的文件或工件**:`src/network/api/dms/chat-sessions-dms.ts`、
+  `src/components/business-assistant/useBusinessAssistantChat.ts`、
+  `harness/tools/{_entry-dms.ts,verify-dms-chat-storage.mjs,validate-harness.mjs}`、
+  `harness/docs/exec-plans/`(两个计划归档 + 一个加进度表)、根 `README.md`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **浏览器端到端仍未人工点验**(DMS 与政策列表两条都还挂着)——
+    这是目前**唯一的验证缺口**,自动化证据都是齐的
+  - ⚠️ **归档不及时是我的执行问题,不是规则缺失**:`exec-plans/README.md` 里早就写了
+    「做完移入 completed/」,我没做。现在有了机械检查,但仍需要我每次收尾时真的跑它
+  - ⚠️ 改回一问一答一条后,若将来又要「整段对话」的视图,需要重新设计(当时的代码在
+    git 历史 `8f60119` 里,未丢失)
+- **下一步最佳动作**:浏览器验收 DMS 与政策列表两条;以及**从本轮起,收尾必跑
+  `validate-harness.mjs` 并当场归档计划**(已加进 `sops/session-end.md` 的清单)
+
 ## Session 026
 ## Session 026
 
 
 - **日期**:2026-09-17
 - **日期**:2026-09-17

+ 3 - 0
harness/sops/session-end.md

@@ -21,6 +21,9 @@
 - [ ] **跑过结构校验** —— `node harness/tools/validate-harness.mjs` 退出码为 0
 - [ ] **跑过结构校验** —— `node harness/tools/validate-harness.mjs` 退出码为 0
 - [ ] **计划类文件在仓库里** —— 本轮的方案/计划写进了 `harness/docs/exec-plans/`,
 - [ ] **计划类文件在仓库里** —— 本轮的方案/计划写进了 `harness/docs/exec-plans/`,
       而不是只存在于聊天、或工具自带的仓库外计划目录(`~/.claude/plans/`)
       而不是只存在于聊天、或工具自带的仓库外计划目录(`~/.claude/plans/`)
+- [ ] **做完的计划已归档** —— 已实现的计划从 `active/` 移到 `completed/`,
+      并把状态头改成实际结果(未做的验证要显式写「未做」,不要含糊过去)。
+      校验器会检查这条,但**别等它报错才做**
 
 
 ## 最常见的漏项:记录比改动旧
 ## 最常见的漏项:记录比改动旧
 
 

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

@@ -12,7 +12,7 @@ export {
 
 
 export {
 export {
   upsertDmsSession,
   upsertDmsSession,
-  saveDmsTranscript,
+  upsertDmsRecord,
   fetchDmsSessions,
   fetchDmsSessions,
   fetchDmsSessionRecords,
   fetchDmsSessionRecords,
   deleteDmsSession,
   deleteDmsSession,

+ 26 - 1
harness/tools/validate-harness.mjs

@@ -123,13 +123,38 @@ try {
   bad(`progress.md 读取失败:${err.message}`);
   bad(`progress.md 读取失败:${err.message}`);
 }
 }
 
 
-// ── 5. 活跃计划不该长期空着(提醒,不算错)────────────────────────────
+// ── 5. 计划归档:做完了就该从 active/ 移到 completed/ ──────────────────
+// 这条是被用户指出来的:三个计划全堆在 active/,其中两个早就实现了。
+// 判据很朴素——**文件自己写的状态**。状态写着已完成却还在 active/ 就是漏归档。
 const activeDir = join(HARNESS, 'docs/exec-plans/active');
 const activeDir = join(HARNESS, 'docs/exec-plans/active');
+const completedDir = join(HARNESS, 'docs/exec-plans/completed');
+const readStatusLine = (file) => {
+  const text = readFileSync(file, 'utf8');
+  const match = text.match(/^>\s*\*\*状态\*\*[::]\s*(.+)$/m);
+  return match ? match[1].trim() : '';
+};
+
 if (existsSync(activeDir)) {
 if (existsSync(activeDir)) {
   const actives = readdirSync(activeDir).filter((f) => f.endsWith('.md'));
   const actives = readdirSync(activeDir).filter((f) => f.endsWith('.md'));
   if (!actives.length && features.some((f) => f.status === 'in_progress')) {
   if (!actives.length && features.some((f) => f.status === 'in_progress')) {
     warn('有 in_progress 的功能,但 exec-plans/active/ 里没有计划文件——大改动应当先写计划');
     warn('有 in_progress 的功能,但 exec-plans/active/ 里没有计划文件——大改动应当先写计划');
   }
   }
+  for (const file of actives) {
+    const status = readStatusLine(join(activeDir, file));
+    if (status && /✅|已完成/.test(status)) {
+      warn(`exec-plans/active/${file} 的状态写着「${status}」却还在 active/ —— 实现完就该移到 completed/`);
+    }
+  }
+}
+
+// completed/ 里的计划反过来不该再写「进行中」
+if (existsSync(completedDir)) {
+  for (const file of readdirSync(completedDir).filter((f) => f.endsWith('.md'))) {
+    const status = readStatusLine(join(completedDir, file));
+    if (status && /🔄|进行中|待拍板|等用户决定/.test(status)) {
+      warn(`exec-plans/completed/${file} 已归档,但状态仍写着「${status}」—— 状态与位置不一致`);
+    }
+  }
 }
 }
 
 
 // ── 输出 ───────────────────────────────────────────────────────────────
 // ── 输出 ───────────────────────────────────────────────────────────────

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

@@ -1,16 +1,17 @@
 /**
 /**
- * 端到端验证「会话 / 整段对话 / 反馈」在 DMS 里的读写(栏目 1887 / 1889)。
+ * 端到端验证「会话 / 问答记录 / 反馈」在 DMS 里的读写(栏目 1887 / 1889)。
  *
  *
- * 存储粒度约定(用户 2026-09-17 定的):
- *   - 1887 助手会话:**一个会话一行**(标题、归属、更新时间)
- *   - 1889 助手问答记录:**也是一个会话一行**,整段对话的消息数组以 JSON 存在 `c_answer`;
- *     反馈记在 JSON 里对应那条消息上
+ * 存储粒度约定(2026-09-17 用户最终确认):
+ *   - 1887 助手会话:一个会话一行(标题、归属、更新时间)
+ *   - 1889 助手问答记录:**一问一答一条**(幂等键 c_record_id),
+ *     c_question / c_answer 各存一半;反馈写在该行的 c_feedback_* 列上
+ *   (曾一度改成「一个会话一行、整段对话 JSON」,按用户要求已改回本版)
  *
  *
  * 验证什么:前端真实调用的那些函数(引用 src 里同一份实现,不是复制逻辑)
  * 验证什么:前端真实调用的那些函数(引用 src 里同一份实现,不是复制逻辑)
  *   打到真实 DMS 上能否正确 增/改/查/删,尤其是:
  *   打到真实 DMS 上能否正确 增/改/查/删,尤其是:
- *   ① 反复保存同一会话**只有一行**(不是一轮问答一行)
- *   ② 反馈能在 JSON 里精确定位到某条消息
- *   ③ 历史读回能与写入的消息一一对应
+ *   ① 反复保存同一轮**只有一行**(不是每问一次多一行)
+ *   ② 补 answer 时不覆盖 question、不变成两行
+ *   ③ 反馈能命中该轮那一行
  *
  *
  * 怎么跑(需要 dev server 起着,代理会注入 token):
  * 怎么跑(需要 dev server 起着,代理会注入 token):
  *   1) npm run dev
  *   1) npm run dev
@@ -28,7 +29,7 @@ globalThis.VITE_DMS_API = base;
 
 
 const {
 const {
   upsertDmsSession,
   upsertDmsSession,
-  saveDmsTranscript,
+  upsertDmsRecord,
   fetchDmsSessions,
   fetchDmsSessions,
   fetchDmsSessionRecords,
   fetchDmsSessionRecords,
   deleteDmsSession,
   deleteDmsSession,
@@ -56,10 +57,8 @@ const check = (name, ok, extra = '') => {
 
 
 const TEST_CREDIT = `verify_cc_${Date.now().toString(36)}`;
 const TEST_CREDIT = `verify_cc_${Date.now().toString(36)}`;
 const SESSION_ID = `verify_sess_${Date.now().toString(36)}`;
 const SESSION_ID = `verify_sess_${Date.now().toString(36)}`;
-const MSG_USER_1 = 'msg_u1_' + Date.now().toString(36);
-const MSG_AI_1 = 'msg_a1_' + Date.now().toString(36);
-const MSG_USER_2 = 'msg_u2_' + Date.now().toString(36);
-const MSG_AI_2 = 'msg_a2_' + Date.now().toString(36);
+const REC_1 = 'rec1_' + Date.now().toString(36);
+const REC_2 = 'rec2_' + Date.now().toString(36);
 const TITLE_A = '验证会话标题';
 const TITLE_A = '验证会话标题';
 const TITLE_B = '验证会话标题(改过)';
 const TITLE_B = '验证会话标题(改过)';
 const Q1 = '高新技术企业的奖励是多少?';
 const Q1 = '高新技术企业的奖励是多少?';
@@ -67,24 +66,13 @@ const A1 = '<!-- POLICY_TABLE {"data":[]} -->\n<scope title="思考中">\n</scop
 const Q2 = '那申报条件呢?';
 const Q2 = '那申报条件呢?';
 const A2 = '第二轮回答,带换行。\n第二行。';
 const A2 = '第二轮回答,带换行。\n第二行。';
 
 
-/** 模拟前端的消息列表:两轮问答 */
-const transcriptAfterRound1 = [
-  { id: MSG_USER_1, role: 'user', content: Q1 },
-  { id: MSG_AI_1, role: 'ai', content: A1 },
-];
-const transcriptAfterRound2 = [
-  ...transcriptAfterRound1,
-  { id: MSG_USER_2, role: 'user', content: Q2 },
-  { id: MSG_AI_2, role: 'ai', content: A2 },
-];
-
-console.log(`\nDMS 会话/整段对话/反馈 端到端验证\n  代理地址: ${base}\n  测试标记: ${TEST_CREDIT}\n`);
-
-const rowsForSession = (columnId) =>
-  searchDmsContents(columnId, {
+console.log(`\nDMS 会话/问答记录/反馈 端到端验证\n  代理地址: ${base}\n  测试标记: ${TEST_CREDIT}\n`);
+
+const rowsForSession = () =>
+  searchDmsContents(DMS_COLUMN_RECORD, {
     search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }],
     search: [{ field: 'c_session_id', searchType: 1, content: { value: SESSION_ID } }],
     page: 0,
     page: 0,
-    pageSize: 20,
+    pageSize: 50,
   });
   });
 
 
 const cleanup = async () => {
 const cleanup = async () => {
@@ -113,70 +101,67 @@ try {
   check('改名后只有一行', sessions.filter((s) => s.session_id === SESSION_ID).length === 1);
   check('改名后只有一行', sessions.filter((s) => s.session_id === SESSION_ID).length === 1);
   check('标题已更新', sessions.find((s) => s.session_id === SESSION_ID)?.title === TITLE_B);
   check('标题已更新', sessions.find((s) => s.session_id === SESSION_ID)?.title === TITLE_B);
 
 
-  // ── 2. 整段对话:一轮一问一答 → 一行 ───────────────────────────────
-  console.log('\n【2】整段对话(1889)—— 粒度必须是「一会话一行」');
-  await saveDmsTranscript({ sessionId: SESSION_ID, messages: transcriptAfterRound1, creditCode: TEST_CREDIT });
-  let rows = await rowsForSession(DMS_COLUMN_RECORD);
-  check('第一轮后:只有一行', rows.length === 1, `实际 ${rows.length} 行`);
-
-  // ── 3. 第二轮:仍是同一行(不能变成两行)───────────────────────────
-  await saveDmsTranscript({ sessionId: SESSION_ID, messages: transcriptAfterRound2, creditCode: TEST_CREDIT });
-  rows = await rowsForSession(DMS_COLUMN_RECORD);
-  check('第二轮后:仍是同一行(不是一封问答一行)', rows.length === 1, `实际 ${rows.length} 行`);
-
-  const stored = JSON.parse(rows[0]?.c_answer || '{}');
-  check('存的是包装对象 {version, messages}', stored.version === 1 && Array.isArray(stored.messages), Object.keys(stored).join(','));
-  check('整段 4 条消息都在', stored.messages?.length === 4, `实际 ${stored.messages?.length} 条`);
-  check('消息顺序与角色正确', stored.messages?.map((m) => m.role).join(',') === 'user,ai,user,ai', stored.messages?.map((m) => m.role).join(','));
-  check('第一轮回答完整往返(含协议标记)', stored.messages?.[1]?.content === A1, `长 ${String(stored.messages?.[1]?.content || '').length}/${A1.length}`);
-  check('第二轮回答完整往返', stored.messages?.[3]?.content === A2);
-  check('消息 id 保留(反馈定位依赖)', stored.messages?.map((m) => m.id).join(',') === [MSG_USER_1, MSG_AI_1, MSG_USER_2, MSG_AI_2].join(','));
-  check('c_question 存了首问', rows[0]?.c_question === Q1, JSON.stringify(rows[0]?.c_question));
-
-  // ── 4. 历史读回 ───────────────────────────────────────────────────
-  console.log('\n【4】历史读回');
+  // ── 2. 粒度:一问一答一条 ──────────────────────────────────────────
+  console.log('\n【2】问答记录(1889)—— 粒度必须是「一问一答一条」');
+  await upsertDmsRecord({ recordId: REC_1, sessionId: SESSION_ID, creditCode: TEST_CREDIT, question: Q1 });
+  let rows = await rowsForSession();
+  check('第 1 轮提交后:1 行', rows.length === 1, `实际 ${rows.length} 行`);
+  check('c_question 正确', rows[0]?.c_question === Q1, JSON.stringify(rows[0]?.c_question));
+  check('c_record_id 存的是该轮标识', rows[0]?.c_record_id === REC_1, String(rows[0]?.c_record_id));
+  check('此时 c_answer 为空', !rows[0]?.c_answer, JSON.stringify(rows[0]?.c_answer));
+
+  // 回答完成 → 补 answer,不能变成两行、不能冲掉 question
+  await upsertDmsRecord({ recordId: REC_1, sessionId: SESSION_ID, creditCode: TEST_CREDIT, answer: A1 });
+  rows = await rowsForSession();
+  check('补 answer 后仍是 1 行', rows.length === 1, `实际 ${rows.length} 行`);
+  check('answer 完整往返(含协议标记)', rows[0]?.c_answer === A1, `长 ${String(rows[0]?.c_answer || '').length}/${A1.length}`);
+  check('question 未被覆盖', rows[0]?.c_question === Q1, JSON.stringify(rows[0]?.c_question));
+
+  // 第 2 轮 → 应该是**新的一行**(一问一答一条)
+  await upsertDmsRecord({ recordId: REC_2, sessionId: SESSION_ID, creditCode: TEST_CREDIT, question: Q2 });
+  await upsertDmsRecord({ recordId: REC_2, sessionId: SESSION_ID, creditCode: TEST_CREDIT, answer: A2 });
+  rows = await rowsForSession();
+  check('第 2 轮后:2 行(一问一答一条)', rows.length === 2, `实际 ${rows.length} 行`);
+
+  // ── 3. 历史读回 ───────────────────────────────────────────────────
+  console.log('\n【3】历史读回(按创建时间正序)');
   const records = await fetchDmsSessionRecords(SESSION_ID);
   const records = await fetchDmsSessionRecords(SESSION_ID);
-  check('读出两轮问答', records.length === 2, `实际 ${records.length} 轮`);
+  check('读出两轮', records.length === 2, `实际 ${records.length} 轮`);
   check('第一轮 question/answer 正确', records[0]?.question === Q1 && records[0]?.answer === A1);
   check('第一轮 question/answer 正确', records[0]?.question === Q1 && records[0]?.answer === A1);
   check('第二轮 question/answer 正确', records[1]?.question === Q2 && records[1]?.answer === A2);
   check('第二轮 question/answer 正确', records[1]?.question === Q2 && records[1]?.answer === A2);
-  check('record_id 回填的是 AI 消息 id(反馈闭环)', records[0]?.record_id === MSG_AI_1 && records[1]?.record_id === MSG_AI_2,
-    `${records[0]?.record_id} / ${records[1]?.record_id}`);
-
-  // ── 5. 反馈:定位到 JSON 里那一条消息 ──────────────────────────────
-  console.log('\n【5】反馈(写进该会话行 JSON 的第二条 AI 消息)');
-  await writeDmsFeedback({ recordId: MSG_AI_2, sessionId: SESSION_ID, status: 2, option: '没有回答我的问题', remark: '验证备注' });
-  rows = await rowsForSession(DMS_COLUMN_RECORD);
-  check('反馈后仍是一行', rows.length === 1, `实际 ${rows.length} 行`);
-  const after = JSON.parse(rows[0]?.c_answer || '{}');
-  const fbMsg = after.messages?.find((m) => m.id === MSG_AI_2);
-  const untouched = after.messages?.find((m) => m.id === MSG_AI_1);
-  check('命中的那条消息带上了反馈', fbMsg?.feedback === 2, String(fbMsg?.feedback));
-  check('feedbackOption 正确', fbMsg?.feedbackOption === '没有回答我的问题', JSON.stringify(fbMsg?.feedbackOption));
-  check('feedbackRemark 正确', fbMsg?.feedbackRemark === '验证备注', JSON.stringify(fbMsg?.feedbackRemark));
-  check('feedbackAt 已写入', typeof fbMsg?.feedbackAt === 'number' && fbMsg.feedbackAt > 0, String(fbMsg?.feedbackAt));
-  check('**另一条消息没被误改**', untouched && untouched.feedback === undefined, JSON.stringify(untouched?.feedback));
-  check('对话内容未被反馈覆盖', after.messages?.length === 4 && after.messages[1].content === A1);
-
-  // 取消反馈
-  await writeDmsFeedback({ recordId: MSG_AI_2, sessionId: SESSION_ID, status: 0 });
-  rows = await rowsForSession(DMS_COLUMN_RECORD);
-  const cancelled = JSON.parse(rows[0]?.c_answer || '{}').messages?.find((m) => m.id === MSG_AI_2);
-  check('取消反馈 status=0', cancelled?.feedback === 0, String(cancelled?.feedback));
-
-  // 反馈定位不到时不能造出空行
-  const beforeCount = (await rowsForSession(DMS_COLUMN_RECORD)).length;
-  await writeDmsFeedback({ recordId: 'not_exist_msg', sessionId: SESSION_ID, status: 1 });
-  await writeDmsFeedback({ recordId: MSG_AI_1, sessionId: 'not_exist_session', status: 1 });
-  check('定位不到时不新增行', (await rowsForSession(DMS_COLUMN_RECORD)).length === beforeCount);
-
-  // ── 6. 删除 ───────────────────────────────────────────────────────
-  console.log('\n【6】删除会话');
+  check('record_id 回填(反馈闭环)', records[0]?.record_id === REC_1 && records[1]?.record_id === REC_2, `${records[0]?.record_id} / ${records[1]?.record_id}`);
+  check('created_at 被解析成合理时间戳', Math.abs((records[0]?.created_at || 0) - Date.now()) < 10 * 60 * 1000, String(records[0]?.created_at));
+
+  // ── 4. 反馈:写在该轮那一行 ────────────────────────────────────────
+  console.log('\n【4】反馈(写在第 2 轮那一行)');
+  await writeDmsFeedback({ recordId: REC_2, sessionId: SESSION_ID, status: 2, option: '没有回答我的问题', remark: '验证备注' });
+  rows = await rowsForSession();
+  const target = rows.find((r) => r.c_record_id === REC_2);
+  const untouched = rows.find((r) => r.c_record_id === REC_1);
+  check('行数不变(仍 2 行)', rows.length === 2, `实际 ${rows.length} 行`);
+  check('c_feedback_status = 2', Number(target?.c_feedback_status) === 2, String(target?.c_feedback_status));
+  check('c_feedback_option 正确', target?.c_feedback_option === '没有回答我的问题', JSON.stringify(target?.c_feedback_option));
+  check('c_feedback_remark 正确', target?.c_feedback_remark === '验证备注', JSON.stringify(target?.c_feedback_remark));
+  check('c_feedback_at 已写入', !!target?.c_feedback_at, String(target?.c_feedback_at));
+  check('**另一轮未被误改**', untouched && untouched.c_feedback_status == null, JSON.stringify(untouched?.c_feedback_status));
+  check('问答内容未被反馈覆盖', target?.c_question === Q2 && target?.c_answer === A2);
+
+  await writeDmsFeedback({ recordId: REC_2, sessionId: SESSION_ID, status: 0 });
+  rows = await rowsForSession();
+  check('取消反馈 status=0', Number(rows.find((r) => r.c_record_id === REC_2)?.c_feedback_status) === 0);
+
+  const before = (await rowsForSession()).length;
+  await writeDmsFeedback({ recordId: 'not_exist_rec', sessionId: SESSION_ID, status: 1 });
+  check('定位不到时不新增行', (await rowsForSession()).length === before);
+
+  // ── 5. 删除 ───────────────────────────────────────────────────────
+  console.log('\n【5】删除会话');
   check('deleteDmsSession 返回 true', (await deleteDmsSession(SESSION_ID)) === true);
   check('deleteDmsSession 返回 true', (await deleteDmsSession(SESSION_ID)) === true);
   check('会话行已删', !(await fetchDmsSessions(TEST_CREDIT)).some((s) => s.session_id === SESSION_ID));
   check('会话行已删', !(await fetchDmsSessions(TEST_CREDIT)).some((s) => s.session_id === SESSION_ID));
-  check('对话行已连带删除', (await rowsForSession(DMS_COLUMN_RECORD)).length === 0);
+  check('问答记录已连带删除', (await rowsForSession()).length === 0);
 
 
-  // ── 7. 工具函数 ───────────────────────────────────────────────────
-  console.log('\n【7】时间戳与访客归属');
+  // ── 6. 工具函数 ───────────────────────────────────────────────────
+  console.log('\n【6】时间戳与访客归属');
   check('nowDmsTimestamp 格式', /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(nowDmsTimestamp()), nowDmsTimestamp());
   check('nowDmsTimestamp 格式', /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(nowDmsTimestamp()), nowDmsTimestamp());
   check('parseDmsTimestamp 毫秒', parseDmsTimestamp(1789651800000) === 1789651800000);
   check('parseDmsTimestamp 毫秒', parseDmsTimestamp(1789651800000) === 1789651800000);
   check('parseDmsTimestamp 秒', parseDmsTimestamp(1789651800) === 1789651800000);
   check('parseDmsTimestamp 秒', parseDmsTimestamp(1789651800) === 1789651800000);

+ 33 - 26
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -24,7 +24,7 @@ import { buildTestDocumentLineStreamChunks } from './test-document-line-stream';
 
 
 import { getChatHistoryStorageKey, enterpriseInfo } from '@/components/useEnterpriseAuth';
 import { getChatHistoryStorageKey, enterpriseInfo } from '@/components/useEnterpriseAuth';
 import { fetchRemoteSessionRecords, updateSessionInfo, deleteSessionInfo, type RemoteSession } from '@/network/api/chat-sessions';
 import { fetchRemoteSessionRecords, updateSessionInfo, deleteSessionInfo, type RemoteSession } from '@/network/api/chat-sessions';
-import { upsertDmsSession, saveDmsTranscript } from '@/network/api/dms/chat-sessions-dms';
+import { upsertDmsSession, upsertDmsRecord } from '@/network/api/dms/chat-sessions-dms';
 import { collectPolicyCardsFromMessages } from '../Chat/policy-match-utils';
 import { collectPolicyCardsFromMessages } from '../Chat/policy-match-utils';
 
 
 interface UseBusinessAssistantChatOptions {
 interface UseBusinessAssistantChatOptions {
@@ -70,20 +70,16 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   const getCreditCode = () => enterpriseInfo.value?.credit_code || '';
   const getCreditCode = () => enterpriseInfo.value?.credit_code || '';
 
 
   /**
   /**
-   * 把本地消息列表转成要存进 DMS 的整段对话
-   * 每次保存都写全量(DMS 里一个会话一行),以本地状态为准,不做读-改-写
+   * 把一轮问答写进 DMS(一问一答一条,幂等键 = 该轮 AI 消息 id)
+   * 提交时只带 question,回答完成/中断时再带 answer 补一次
    */
    */
-  const saveSessionTranscriptToDms = (session: BusinessAssistantSession) => {
-    void saveDmsTranscript({
-      sessionId: session.id,
-      messages: session.messages
-        .filter((message) => message.role === 'user' || message.role === 'ai')
-        .map((message) => ({
-          id: message.id,
-          role: message.role as 'user' | 'ai',
-          content: String(message.content || ''),
-        })),
-    });
+  const saveTurnToDms = (params: {
+    sessionId: string;
+    recordId: string;
+    question?: string;
+    answer?: string;
+  }) => {
+    void upsertDmsRecord(params);
   };
   };
 
 
   // DMS 版本不再要求登录:访客的记录也要落库(c_credit_code = 访客_<访客id>)
   // DMS 版本不再要求登录:访客的记录也要落库(c_credit_code = 访客_<访客id>)
@@ -338,10 +334,15 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       // }
       // }
       saveHistory();
       saveHistory();
 
 
-      // 回答完成 → 重写该会话在 DMS 的整段对话(此时 answer 已在消息内容里)
-      if (targetSession) {
-        dmsAnswerWrittenFor = lastAiMessage?.id || null; // 标记:close 时不必再写一次
-        saveSessionTranscriptToDms(targetSession);
+      // 回答完成 → 把 answer 补进这一轮的 DMS 记录(提交时已写 question)
+      if (targetSession && lastAiMessage) {
+        dmsAnswerWrittenFor = lastAiMessage.id; // 标记:close 时不必再补一次
+        saveTurnToDms({
+          sessionId: targetSession.id,
+          recordId: lastAiMessage.id,
+          ...(payload?.question ? { question: payload.question } : {}),
+          ...(payload?.answer ? { answer: payload.answer } : {}),
+        });
       }
       }
 
 
       // 首轮完成时同步标题(会话行在 sendMessage 里已建,这里按 c_session_id 更新)
       // 首轮完成时同步标题(会话行在 sendMessage 里已建,这里按 c_session_id 更新)
@@ -365,7 +366,11 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
         lastAiMessage.id !== dmsAnswerWrittenFor && // 正常完成时已写过,别重复
         lastAiMessage.id !== dmsAnswerWrittenFor && // 正常完成时已写过,别重复
         String(lastAiMessage.content || '').trim()
         String(lastAiMessage.content || '').trim()
       ) {
       ) {
-        saveSessionTranscriptToDms(targetSession);
+        saveTurnToDms({
+          sessionId: targetSession.id,
+          recordId: lastAiMessage.id,
+          answer: lastAiMessage.content,
+        });
       }
       }
     });
     });
 
 
@@ -428,8 +433,9 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     // globalThis.transmission.file_pos = fileTransmission?.file_pos || [];
     // globalThis.transmission.file_pos = fileTransmission?.file_pos || [];
 
 
     session.messages.push(createUserMessage(text, fileTransmission?.attachments || []));
     session.messages.push(createUserMessage(text, fileTransmission?.attachments || []));
-    // 预置一条空的 AI 消息(它的 id 会随整段对话一起写进 DMS,也是反馈定位用的 record id)
-    ensurePendingAiMessage(session.messages);
+    // 预置一条空的 AI 消息:它的 id 既是本轮的 DMS 记录标识(c_record_id),
+    // 也是反馈组件用的 record.id —— 三条链路同一个值,反馈才定位得到
+    const pendingAiMessage = ensurePendingAiMessage(session.messages);
     inputText.value = '';
     inputText.value = '';
     isGenerating.value = true;
     isGenerating.value = true;
     activeGenerationSessionId = session.id;
     activeGenerationSessionId = session.id;
@@ -437,10 +443,10 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     saveHistory();
     saveHistory();
     await options.afterSend?.();
     await options.afterSend?.();
 
 
-    // 点击提交即写 DMS:会话 upsert(没有就新增、有就更新)+ 该会话的整段对话
+    // 点击提交即写 DMS:会话 upsert(没有就新增、有就更新)+ 本轮问答(先写 question)
     // fire-and-forget,失败只 warn,不影响聊天
     // fire-and-forget,失败只 warn,不影响聊天
     void upsertDmsSession({ sessionId: session.id, title: session.title });
     void upsertDmsSession({ sessionId: session.id, title: session.title });
-    saveSessionTranscriptToDms(session);
+    saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: text });
 
 
     smc?.generateAnswer(text);
     smc?.generateAnswer(text);
   };
   };
@@ -1611,16 +1617,17 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
     }
 
 
     session.messages.push(createUserMessage('已提交企业信息补充'));
     session.messages.push(createUserMessage('已提交企业信息补充'));
-    ensurePendingAiMessage(session.messages);
+    const pendingAiMessage = ensurePendingAiMessage(session.messages);
     isGenerating.value = true;
     isGenerating.value = true;
     activeGenerationSessionId = session.id;
     activeGenerationSessionId = session.id;
     dmsAnswerWrittenFor = null;
     dmsAnswerWrittenFor = null;
     saveHistory();
     saveHistory();
     await options.afterSend?.();
     await options.afterSend?.();
 
 
-    // 补问也是一轮对话,一并进 DMS 的整段记录
+    // 补问也是一轮问答:记进 DMS。问题存**真实提交文本**(answerText),
+    // 不存界面上那句「已提交企业信息补充」——否则从 DMS 读回的历史会失真
     void upsertDmsSession({ sessionId: session.id, title: session.title });
     void upsertDmsSession({ sessionId: session.id, title: session.title });
-    saveSessionTranscriptToDms(session);
+    saveTurnToDms({ sessionId: session.id, recordId: pendingAiMessage.id, question: answerText });
 
 
     smc.generateAnswer(answerText);
     smc.generateAnswer(answerText);
   };
   };

+ 53 - 107
src/network/api/dms/chat-sessions-dms.ts

@@ -142,84 +142,54 @@ export const upsertDmsSession = (params: {
   });
   });
 
 
 /**
 /**
- * 会话里的单条消息(存进 DMS 的那份结构)
+ * 问答记录 upsert(栏目 1889,幂等键 `c_record_id`)—— **一问一答一条**
  *
  *
- * 只存渲染与追溯需要的字段;`feedback*` 是反馈落地的地方。
- * **不存消息级时间戳**:本地消息本来就没有时间戳,而整段对话是每次覆盖重写的,
- * 若在这里现造一个 Date.now(),每条消息的时间都会变成「最后一次保存的时刻」——
- * 假数据比没有更糟。顺序由数组顺序表达,会话起始时间看该行的 `c_created_at`。
- */
-export interface DmsTranscriptMessage {
-  id: string;
-  role: "user" | "ai";
-  content: string;
-  feedback?: number;
-  feedbackOption?: string;
-  feedbackRemark?: string;
-  feedbackAt?: number;
-}
-
-/** 会话行 `c_answer` 里存的整段对话(用包装对象留出版本位,方便将来改格式) */
-interface DmsTranscript {
-  version: 1;
-  messages: DmsTranscriptMessage[];
-}
-
-const serializeTranscript = (messages: DmsTranscriptMessage[]): string =>
-  JSON.stringify({ version: 1, messages } satisfies DmsTranscript);
-
-const parseTranscript = (raw: unknown): DmsTranscriptMessage[] => {
-  if (typeof raw !== "string" || !raw.trim()) return [];
-  try {
-    const parsed = JSON.parse(raw);
-    // 兼容两种形态:包装对象 {version, messages} 与纯数组
-    const list = Array.isArray(parsed) ? parsed : parsed?.messages;
-    if (!Array.isArray(list)) return [];
-    return list.filter((m: any) => m && typeof m.content === "string");
-  } catch {
-    // 老数据(这个字段曾经存的是单条回答的纯文本)—— 当作一条 AI 消息读出来,不丢内容
-    return [];
-  }
-};
-
-/**
- * 写入某个会话的**整段对话**(栏目 1889,幂等键 `c_session_id`)。
- *
- * 粒度是「一个会话一行」:每次提交/回答完成都把该会话的完整消息列表
- * 序列化后覆盖写进这一行的 `c_answer`。以本地 messages 为准整段重写,
- * 因此不需要「读-改-写」,也就没有并发覆盖的问题。
+ * 提交时先写 question(回答还没出来),回答完成/中断时再补 answer
+ * (见 useBusinessAssistantChat 的三个挂钩点)。
  *
  *
  * ⚠️ 1889 **没有 `c_updated_at` 字段**(只有 `c_created_at`),别写它。
  * ⚠️ 1889 **没有 `c_updated_at` 字段**(只有 `c_created_at`),别写它。
+ *
+ * 历史:曾一度改成「一个会话一行、整段对话 JSON 塞进 c_answer」,
+ * 2026-09-17 按用户要求**改回本版(一问一答一条)**。改回的理由是那样更贴近
+ * 库表原本的语义(chat_record 就是一行一轮问答),也便于按轮次检索/统计。
  */
  */
-export const saveDmsTranscript = (params: {
+export const upsertDmsRecord = (params: {
+  recordId: string;
   sessionId: string;
   sessionId: string;
-  messages: DmsTranscriptMessage[];
   creditCode?: string;
   creditCode?: string;
+  question?: string;
+  answer?: string;
 }): Promise<boolean> =>
 }): Promise<boolean> =>
-  enqueueWrite(`session:${params.sessionId}`, async () => {
-    const firstQuestion = params.messages.find((m) => m.role === "user")?.content || "";
-    const content: DmsRow = {
+  enqueueWrite(`record:${params.recordId}`, async () => {
+    const base: DmsRow = {
       c_credit_code: params.creditCode || resolveDmsCreditCode(),
       c_credit_code: params.creditCode || resolveDmsCreditCode(),
       c_session_id: params.sessionId,
       c_session_id: params.sessionId,
-      c_question: firstQuestion,
-      c_answer: serializeTranscript(params.messages),
+      c_record_id: params.recordId,
       c_source: resolveSource(),
       c_source: resolveSource(),
     };
     };
 
 
-    const existing = await findRowBy(DMS_COLUMN_RECORD, "c_session_id", params.sessionId);
+    const existing = await findRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
     if (!existing) {
     if (!existing) {
+      const content: DmsRow = { ...base };
+      if (params.question !== undefined) content.c_question = params.question;
+      if (params.answer !== undefined) content.c_answer = params.answer;
       content.c_created_at = nowDmsTimestamp();
       content.c_created_at = nowDmsTimestamp();
       const id = await addDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, content);
       const id = await addDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, content);
-      if (!id) console.warn("[dms] 会话对话写入失败", params.sessionId);
+      if (!id) console.warn("[dms] 问答记录写入失败", params.recordId);
       return !!id;
       return !!id;
     }
     }
 
 
     const id = existing.id as string | undefined;
     const id = existing.id as string | undefined;
     if (!id) {
     if (!id) {
-      console.warn("[dms] 会话对话行缺少 uuid,无法更新", existing);
+      console.warn("[dms] 问答记录行缺少 uuid,无法更新", existing);
       return false;
       return false;
     }
     }
-    return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, id, content);
+    const patch: DmsRow = {};
+    // 不覆盖已有内容:补 answer 时别把 question 冲掉
+    if (params.question !== undefined && !existing.c_question) patch.c_question = params.question;
+    if (params.answer !== undefined) patch.c_answer = params.answer;
+    if (!Object.keys(patch).length) return true;
+    return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, id, patch);
   });
   });
 
 
 /** 会话列表(按更新时间倒序) */
 /** 会话列表(按更新时间倒序) */
@@ -244,45 +214,39 @@ export const fetchDmsSessions = async (creditCode: string): Promise<RemoteSessio
 };
 };
 
 
 /**
 /**
- * 某个会话的问答对(供历史消息渲染)。
- * 数据源是该会话那一行的整段消息 JSON,这里按「用户消息 + 紧随其后的 AI 回复」配对还原
+ * 某个会话的全部问答记录(供历史消息渲染)。
+ * **一问一答一条**,按创建时间正序,保证历史消息顺序正确
  */
  */
 export const fetchDmsSessionRecords = async (
 export const fetchDmsSessionRecords = async (
   sessionId: string
   sessionId: string
 ): Promise<RemoteSessionRecord[]> => {
 ): Promise<RemoteSessionRecord[]> => {
   const rows = await searchDmsContents(DMS_COLUMN_RECORD, {
   const rows = await searchDmsContents(DMS_COLUMN_RECORD, {
     search: [{ field: "c_session_id", searchType: 1, content: { value: sessionId } }],
     search: [{ field: "c_session_id", searchType: 1, content: { value: sessionId } }],
+    orderBy: [{ field: "c_created_at", orderByType: 1 }],
     page: 0,
     page: 0,
-    pageSize: 10,
+    pageSize: 200,
   });
   });
-  const messages = parseTranscript(rows[0]?.c_answer);
 
 
-  const records: RemoteSessionRecord[] = [];
-  for (let i = 0; i < messages.length; i++) {
-    const msg = messages[i];
-    if (msg.role !== "user") continue;
-    const next = messages[i + 1];
-    const reply = next?.role === "ai" ? next : null;
-    records.push({
-      session_id: sessionId,
-      question: msg.content,
-      answer: reply?.content || "",
-      created_at: parseDmsTimestamp(rows[0]?.c_created_at),
-      // 回填 AI 消息 id:从 DMS 读回的历史消息点赞时仍能命中同一条消息
-      // (反馈写回 JSON 时靠它定位)
-      record_id: reply?.id,
-    });
-    if (reply) i++; // 跳过已消费的回复
-  }
-  return records;
+  return rows.map((row) => ({
+    session_id: sessionId,
+    question: String(row.c_question || ""),
+    answer: String(row.c_answer || ""),
+    created_at: parseDmsTimestamp(row.c_created_at),
+    // 回填记录 id:从 DMS 读回的历史消息点赞/点踩时仍能命中原始行
+    record_id: row.c_record_id ? String(row.c_record_id) : undefined,
+  }));
 };
 };
 
 
-/** 删除会话:会话行 + 它的整段对话行(DMS 的删除是置 state=4) */
+/** 删除会话:会话行 + 它名下的全部问答记录(DMS 的删除是置 state=4) */
 export const deleteDmsSession = (sessionId: string): Promise<boolean> =>
 export const deleteDmsSession = (sessionId: string): Promise<boolean> =>
   enqueueWrite(`session:${sessionId}`, async () => {
   enqueueWrite(`session:${sessionId}`, async () => {
-    const transcriptRow = await findRowBy(DMS_COLUMN_RECORD, "c_session_id", sessionId);
-    if (transcriptRow?.id) {
-      await deleteDmsContent(DMS_COLUMN_RECORD, String(transcriptRow.id));
+    const rows = await searchDmsContents(DMS_COLUMN_RECORD, {
+      search: [{ field: "c_session_id", searchType: 1, content: { value: sessionId } }],
+      page: 0,
+      pageSize: 200,
+    });
+    for (const row of rows) {
+      if (row.id) await deleteDmsContent(DMS_COLUMN_RECORD, String(row.id));
     }
     }
 
 
     const sessionRow = await findRowBy(DMS_COLUMN_SESSION, "c_session_id", sessionId);
     const sessionRow = await findRowBy(DMS_COLUMN_SESSION, "c_session_id", sessionId);
@@ -291,10 +255,9 @@ export const deleteDmsSession = (sessionId: string): Promise<boolean> =>
   });
   });
 
 
 /**
 /**
- * 反馈写入:定位到该会话那一行,把反馈记在**对应那条消息**的 JSON 里
+ * 反馈写入(栏目 1889 的 `c_feedback_*`):按 `c_record_id` 找到那一行再更新
  *
  *
- * 因为是「读整行 → 改那条消息 → 写回」,同一会话的写操作共用 `session:` 顺序链,
- * 避免与对话写入相互覆盖(单页内串行;跨标签页理论上仍可能覆盖,已知可接受)。
+ * 找不到时**不新建行**——避免在库里造出只有反馈、没有问题与回答的空记录。
  */
  */
 export const writeDmsFeedback = (params: {
 export const writeDmsFeedback = (params: {
   recordId: string;
   recordId: string;
@@ -302,36 +265,19 @@ export const writeDmsFeedback = (params: {
   status: number;
   status: number;
   option?: string;
   option?: string;
   remark?: string;
   remark?: string;
-}): Promise<boolean> => {
-  const sessionId = params.sessionId || "";
-  return enqueueWrite(`session:${sessionId}`, async () => {
-    const existing = await findRowBy(DMS_COLUMN_RECORD, "c_session_id", sessionId);
+}): Promise<boolean> =>
+  enqueueWrite(`record:${params.recordId}`, async () => {
+    const existing = await findRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
     if (!existing?.id) {
     if (!existing?.id) {
-      // 找不到对应会话(例如从本地历史里点的赞,对话还没写进 DMS)——
-      // 不新建空行,避免在库里造出没有内容只有反馈的记录
-      console.warn("[dms] 反馈未落库:DMS 里还没有该会话的对话行", sessionId, params.recordId);
+      // 例如从本地历史里点的赞,那一轮还没写进 DMS
+      console.warn("[dms] 反馈未落库:DMS 里没有这条问答记录", params.recordId);
       return false;
       return false;
     }
     }
 
 
-    const messages = parseTranscript(existing.c_answer);
-    const target = messages.find((m) => m.id === params.recordId);
-    if (!target) {
-      console.warn("[dms] 反馈未落库:该会话的对话里没有这条消息", params.recordId);
-      return false;
-    }
-
-    target.feedback = params.status;
-    target.feedbackAt = Date.now();
-    if (params.option !== undefined) target.feedbackOption = params.option;
-    if (params.remark !== undefined) target.feedbackRemark = params.remark;
-
-    // 同时把列级字段写上(按会话维度看反馈时用;明细在 c_answer 的 JSON 里)
     return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, String(existing.id), {
     return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, String(existing.id), {
-      c_answer: serializeTranscript(messages),
       c_feedback_status: params.status,
       c_feedback_status: params.status,
       c_feedback_at: nowDmsTimestamp(),
       c_feedback_at: nowDmsTimestamp(),
       ...(params.option !== undefined ? { c_feedback_option: params.option } : {}),
       ...(params.option !== undefined ? { c_feedback_option: params.option } : {}),
       ...(params.remark !== undefined ? { c_feedback_remark: params.remark } : {}),
       ...(params.remark !== undefined ? { c_feedback_remark: params.remark } : {}),
     });
     });
   });
   });
-};