Selaa lähdekoodia

feat(chat): 会话存 DMS 改为一会话一行;去掉开发环境自动登录

一、存储粒度(用户要求:一条记录包含整个会话窗口的所有消息)
- 1889 的幂等键从 c_record_id 改为 c_session_id,一个会话一行;
  整段消息序列化成 {version:1,messages:[…]} 存进 c_answer,c_question 存首问
- 写入改为「以本地消息列表为准整段重写」,不做读-改-写:本地状态是权威,
  从根上消除并发覆盖
- 反馈改为「读整行 → 只改命中那条消息 → 写回」,与对话写入共用同一条
  顺序链;定位不到时不新增行(避免库里出现只有反馈没有内容的记录)
- 不存消息级时间戳:本地消息本就没有,整段重写时现造 Date.now() 会让所有
  消息时间都变成最后一次保存的时刻——假数据比没有更糟

二、去掉开发环境硬编码账号的自动登录
- 根因:useEnterpriseAuth 里 isDevLogin 在 development 下无条件用内置 token
  登录,表现为「没登录也进入登录态」。已整段移除;要企业态需显式带
  ?access_token= 或 ?credit_code=。开发环境默认即访客态
- 影响:dev 下 globalThis.token 不再自动注入;本轮涉及的会话/历史/反馈
  已全部走 DMS(token 由 vite 代理注入),不受影响

三、harness 结构对齐官方资料(Learn Harness Engineering 的 OpenAI 高级包)
- docs/plans/ → docs/exec-plans/(completed/ + tech-debt-tracker.md)
- 新增硬规则:计划类文件只能写进 harness/docs/exec-plans/active/。此前实施
  计划被写到工具自带的仓库外目录,换会话接手看不到;已落进仓库并在
  CLAUDE.md、exec-plans/README.md、sops/session-end.md 三处写明
- 新增 harness/tools/validate-harness.mjs:把口头约定变成机械约束,
  可检出假 passing、重复 id、多个 in_progress、游离的计划文件等;
  实测能报错(放一个 plan-test.md 后 exit=1)
- 更正 reference/README.md 里已过期的「DMS 栏目全空」说法:1887/1889
  现在正是前端在用的在线数据源

验证:npm run build 通过;verify-dms-chat-storage.mjs 36/36 通过(新增关键
断言:第二轮后仍是同一行、反馈只改命中那条消息、另一条未被误改、定位不到
时不新增行);validate-harness.mjs 通过。⚠️ 浏览器端到端仍未人工点过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 3 päivää sitten
vanhempi
sitoutus
8f60119801

+ 10 - 0
README.md

@@ -106,3 +106,13 @@
     浏览器产物里没有 token;DMS 不可用时静默降级,聊天与本地历史不受影响
   - 实测踩坑:DMS 的「删除」不是 `delContentById`(各种传法都返回参数错误),
     而是 `POST /content/updateAudit` 把状态改成 `4`(销毁);`addContent` 会返回记录 uuid
+  - **存储粒度:一个会话一行**(不是一问一答一行)——整段对话的消息数组以 JSON 存进
+    该行,每次以本地消息列表为准整段重写;反馈记在 JSON 里对应那条消息上
+- 去掉**开发环境硬编码账号的自动登录**:此前开发环境下会无条件用内置账号登录,
+  表现为「没有登录也进入登录态」。现在默认即访客态,要企业态需显式带
+  `?access_token=` 或 `?credit_code=`
+- 本机工作流文件(`harness/`)结构对齐
+  [Learn Harness Engineering 的 OpenAI 高级包](https://walkinglabs.github.io/learn-harness-engineering/zh/resources/):
+  `docs/plans/` → `docs/exec-plans/`(`completed/` + `tech-debt-tracker.md`),
+  并新增结构校验脚本 `harness/tools/validate-harness.mjs`(机械约束优先于口头约定:
+  可检出假 passing、多个 in_progress、游离的计划文件等)

+ 30 - 31
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -24,7 +24,7 @@ import { buildTestDocumentLineStreamChunks } from './test-document-line-stream';
 
 import { getChatHistoryStorageKey, enterpriseInfo } from '@/components/useEnterpriseAuth';
 import { fetchRemoteSessionRecords, updateSessionInfo, deleteSessionInfo, type RemoteSession } from '@/network/api/chat-sessions';
-import { upsertDmsSession, upsertDmsRecord } from '@/network/api/dms/chat-sessions-dms';
+import { upsertDmsSession, saveDmsTranscript } from '@/network/api/dms/chat-sessions-dms';
 
 interface UseBusinessAssistantChatOptions {
   apiBaseUrl?: string;
@@ -68,6 +68,23 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   const isEnterpriseLoggedIn = () => !!enterpriseInfo.value;
   const getCreditCode = () => enterpriseInfo.value?.credit_code || '';
 
+  /**
+   * 把本地消息列表转成要存进 DMS 的整段对话。
+   * 每次保存都写全量(DMS 里一个会话一行),以本地状态为准,不做读-改-写。
+   */
+  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 || ''),
+        })),
+    });
+  };
+
   // DMS 版本不再要求登录:访客的记录也要落库(c_credit_code = 访客_<访客id>)
   const syncSessionTitleToServer = (sessionId: string, title: string) => {
     void updateSessionInfo(sessionId, getCreditCode(), title);
@@ -320,15 +337,10 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       // }
       saveHistory();
 
-      // 回答完成 → 把 answer 补进 DMS 的问答记录(提交时已写 question,这里补另一半)
-      if (targetSession && lastAiMessage) {
-        dmsAnswerWrittenFor = lastAiMessage.id; // 标记:close 时不必再补一次
-        void upsertDmsRecord({
-          recordId: lastAiMessage.id,
-          sessionId: targetSession.id,
-          ...(payload?.question ? { question: payload.question } : {}),
-          ...(payload?.answer ? { answer: payload.answer } : {}),
-        });
+      // 回答完成 → 重写该会话在 DMS 的整段对话(此时 answer 已在消息内容里)
+      if (targetSession) {
+        dmsAnswerWrittenFor = lastAiMessage?.id || null; // 标记:close 时不必再写一次
+        saveSessionTranscriptToDms(targetSession);
       }
 
       // 首轮完成时同步标题(会话行在 sendMessage 里已建,这里按 c_session_id 更新)
@@ -352,11 +364,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
         lastAiMessage.id !== dmsAnswerWrittenFor && // 正常完成时已写过,别重复
         String(lastAiMessage.content || '').trim()
       ) {
-        void upsertDmsRecord({
-          recordId: lastAiMessage.id,
-          sessionId: targetSession.id,
-          answer: lastAiMessage.content,
-        });
+        saveSessionTranscriptToDms(targetSession);
       }
     });
 
@@ -419,8 +427,8 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     // globalThis.transmission.file_pos = fileTransmission?.file_pos || [];
 
     session.messages.push(createUserMessage(text, fileTransmission?.attachments || []));
-    // 这条 AI 消息的 id 同时是:渲染层的 record id、反馈组件用的 record.id、DMS 的 c_record_id
-    const pendingAiMessage = ensurePendingAiMessage(session.messages);
+    // 预置一条空的 AI 消息(它的 id 会随整段对话一起写进 DMS,也是反馈定位用的 record id)
+    ensurePendingAiMessage(session.messages);
     inputText.value = '';
     isGenerating.value = true;
     activeGenerationSessionId = session.id;
@@ -428,14 +436,10 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     saveHistory();
     await options.afterSend?.();
 
-    // 点击提交即写 DMS:会话 upsert(没有就新增、有就更新)+ 问答记录(先写 question)
+    // 点击提交即写 DMS:会话 upsert(没有就新增、有就更新)+ 该会话的整段对话
     // fire-and-forget,失败只 warn,不影响聊天
     void upsertDmsSession({ sessionId: session.id, title: session.title });
-    void upsertDmsRecord({
-      recordId: pendingAiMessage.id,
-      sessionId: session.id,
-      question: text,
-    });
+    saveSessionTranscriptToDms(session);
 
     smc?.generateAnswer(text);
   };
@@ -1606,21 +1610,16 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
 
     session.messages.push(createUserMessage('已提交企业信息补充'));
-    const pendingAiMessage = ensurePendingAiMessage(session.messages);
+    ensurePendingAiMessage(session.messages);
     isGenerating.value = true;
     activeGenerationSessionId = session.id;
     dmsAnswerWrittenFor = null;
     saveHistory();
     await options.afterSend?.();
 
-    // 补问也是一次问答:记进 DMS。问题存**真实提交文本**(answerText),
-    // 不存界面上那句「已提交企业信息补充」——否则从 DMS 读回的历史会失真
+    // 补问也是一轮对话,一并进 DMS 的整段记录
     void upsertDmsSession({ sessionId: session.id, title: session.title });
-    void upsertDmsRecord({
-      recordId: pendingAiMessage.id,
-      sessionId: session.id,
-      question: answerText,
-    });
+    saveSessionTranscriptToDms(session);
 
     smc.generateAnswer(answerText);
   };

+ 4 - 15
src/components/useEnterpriseAuth.ts

@@ -62,29 +62,18 @@ export function useEnterpriseAuth() {
     const creditCodeParam = getQueryParamUniversal('credit_code');
     const accessToken = getQueryParamUniversal('access_token');
 
-    const devLogin = {
-        "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ6d2R0c3dfY3JlZGl0X2NvZGUiOiI5MTMxMDExOE1BMUpMUkwxNkciLCJ6d2R0c3dfdXNlcl9pZCI6Ijc3MTcwMzg0MTU4RTk2NDhFMDUzMTUwQjEyQUNCOTc0IiwiZXhwIjoxNzg3ODk3MjM1LCJpYXQiOjE3ODUzMDUyMzUsImlzcyI6InFpbmdwdS1kYXRhLWxsbSJ9.aKXVzQj-hizvrXZimf4d8me4pDwOOaqvwK4rYsw9Ey8",
-        "credit_code": "91310118MA1JLRL16G"
-    }
-
-    const isDevLogin = import.meta.env.MODE == "development";
     // 跟随 querystring 的 policyMode:A/B 测试变体(如 zhaoshang_staging_1)也需体现在 source 中
     globalThis.source = globalThis.policyMode || "zhaoshang";
 
     // 1. access_token 存在: 换取 credit_code + authToken, 再拉企业信息
-    if (accessToken && !creditCodeParam || isDevLogin) {
+    // [已移除] 开发环境的硬编码账号自动登录:它会让「没登录也进入登录态」,
+    //          与真实用户路径不符(访客记录就验证不了)。要企业态请显式带
+    //          ?access_token=<token> 或 ?credit_code=<代码>。
+    if (accessToken && !creditCodeParam) {
       authLoading.value = true;
       authError.value = false;
 
       try {
-        if (isDevLogin) {
-          // 开发环境: 直接使用 devLogin 登录
-          globalThis.authToken = devLogin.access_token;
-          globalThis.token = devLogin.access_token;
-          localStorage.setItem(AUTH_TOKEN_KEY, devLogin.access_token);
-          return await initAuthWithCreditCode(devLogin.credit_code);
-        }
-
         const loginResult = await fetchEnterpriseLogin(accessToken);
         if (loginResult) {
           globalThis.authToken = loginResult.authToken;

+ 120 - 64
src/network/api/dms/chat-sessions-dms.ts

@@ -142,46 +142,84 @@ export const upsertDmsSession = (params: {
   });
 
 /**
- * 问答记录 upsert(栏目 1889,幂等键 `c_record_id`)。
- * 提交时只带 question,回答完成后再带 answer 补一次。
+ * 会话里的单条消息(存进 DMS 的那份结构)。
+ *
+ * 只存渲染与追溯需要的字段;`feedback*` 是反馈落地的地方。
+ * **不存消息级时间戳**:本地消息本来就没有时间戳,而整段对话是每次覆盖重写的,
+ * 若在这里现造一个 Date.now(),每条消息的时间都会变成「最后一次保存的时刻」——
+ * 假数据比没有更糟。顺序由数组顺序表达,会话起始时间看该行的 `c_created_at`。
  */
-export const upsertDmsRecord = (params: {
-  recordId: string;
+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 为准整段重写,
+ * 因此不需要「读-改-写」,也就没有并发覆盖的问题。
+ *
+ * ⚠️ 1889 **没有 `c_updated_at` 字段**(只有 `c_created_at`),别写它。
+ */
+export const saveDmsTranscript = (params: {
   sessionId: string;
+  messages: DmsTranscriptMessage[];
   creditCode?: string;
-  question?: string;
-  answer?: string;
 }): Promise<boolean> =>
-  enqueueWrite(`record:${params.recordId}`, async () => {
-    const base: DmsRow = {
+  enqueueWrite(`session:${params.sessionId}`, async () => {
+    const firstQuestion = params.messages.find((m) => m.role === "user")?.content || "";
+    const content: DmsRow = {
       c_credit_code: params.creditCode || resolveDmsCreditCode(),
       c_session_id: params.sessionId,
-      c_record_id: params.recordId,
+      c_question: firstQuestion,
+      c_answer: serializeTranscript(params.messages),
       c_source: resolveSource(),
     };
 
-    const existing = await findRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
+    const existing = await findRowBy(DMS_COLUMN_RECORD, "c_session_id", params.sessionId);
     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();
       const id = await addDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, content);
-      if (!id) console.warn("[dms] 问答记录写入失败", params.recordId);
+      if (!id) console.warn("[dms] 会话对话写入失败", params.sessionId);
       return !!id;
     }
 
     const id = existing.id as string | undefined;
     if (!id) {
-      console.warn("[dms] 问答记录行缺少 uuid,无法更新", existing);
+      console.warn("[dms] 会话对话行缺少 uuid,无法更新", existing);
       return false;
     }
-    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);
+    return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, id, content);
   });
 
 /** 会话列表(按更新时间倒序) */
@@ -205,37 +243,46 @@ export const fetchDmsSessions = async (creditCode: string): Promise<RemoteSessio
     .filter((s) => s.session_id);
 };
 
-/** 某个会话的全部问答记录(按创建时间正序,保证历史消息顺序正确) */
+/**
+ * 某个会话的问答对(供历史消息渲染)。
+ * 数据源是该会话那一行的整段消息 JSON,这里按「用户消息 + 紧随其后的 AI 回复」配对还原。
+ */
 export const fetchDmsSessionRecords = async (
   sessionId: string
 ): Promise<RemoteSessionRecord[]> => {
   const rows = await searchDmsContents(DMS_COLUMN_RECORD, {
     search: [{ field: "c_session_id", searchType: 1, content: { value: sessionId } }],
-    orderBy: [{ field: "c_created_at", orderByType: 1 }],
     page: 0,
-    pageSize: 200,
+    pageSize: 10,
   });
+  const messages = parseTranscript(rows[0]?.c_answer);
 
-  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,
-  }));
+  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;
 };
 
-/** 删除会话:连带删掉它的问答记录(DMS 的删除是置 state=4) */
+/** 删除会话:会话行 + 它的整段对话行(DMS 的删除是置 state=4) */
 export const deleteDmsSession = (sessionId: string): Promise<boolean> =>
   enqueueWrite(`session:${sessionId}`, async () => {
-    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 transcriptRow = await findRowBy(DMS_COLUMN_RECORD, "c_session_id", sessionId);
+    if (transcriptRow?.id) {
+      await deleteDmsContent(DMS_COLUMN_RECORD, String(transcriptRow.id));
     }
 
     const sessionRow = await findRowBy(DMS_COLUMN_SESSION, "c_session_id", sessionId);
@@ -244,8 +291,10 @@ export const deleteDmsSession = (sessionId: string): Promise<boolean> =>
   });
 
 /**
- * 反馈写入(栏目 1889 的 `c_feedback_*`)。
- * 先按 `c_record_id` 找到那一行再更新;找不到(历史遗留的本地消息)就补建一行。
+ * 反馈写入:定位到该会话那一行,把反馈记在**对应那条消息**的 JSON 里。
+ *
+ * 因为是「读整行 → 改那条消息 → 写回」,同一会话的写操作共用 `session:` 顺序链,
+ * 避免与对话写入相互覆盖(单页内串行;跨标签页理论上仍可能覆盖,已知可接受)。
  */
 export const writeDmsFeedback = (params: {
   recordId: string;
@@ -253,29 +302,36 @@ export const writeDmsFeedback = (params: {
   status: number;
   option?: string;
   remark?: string;
-}): Promise<boolean> =>
-  enqueueWrite(`record:${params.recordId}`, async () => {
-    const feedback: DmsRow = {
-      c_feedback_status: params.status,
-      c_feedback_at: nowDmsTimestamp(),
-    };
-    if (params.option !== undefined) feedback.c_feedback_option = params.option;
-    if (params.remark !== undefined) feedback.c_feedback_remark = params.remark;
+}): Promise<boolean> => {
+  const sessionId = params.sessionId || "";
+  return enqueueWrite(`session:${sessionId}`, async () => {
+    const existing = await findRowBy(DMS_COLUMN_RECORD, "c_session_id", sessionId);
+    if (!existing?.id) {
+      // 找不到对应会话(例如从本地历史里点的赞,对话还没写进 DMS)——
+      // 不新建空行,避免在库里造出没有内容只有反馈的记录
+      console.warn("[dms] 反馈未落库:DMS 里还没有该会话的对话行", sessionId, params.recordId);
+      return false;
+    }
 
-    const existing = await findRowBy(DMS_COLUMN_RECORD, "c_record_id", params.recordId);
-    if (!existing) {
-      const id = await addDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, {
-        c_credit_code: resolveDmsCreditCode(),
-        c_record_id: params.recordId,
-        c_source: resolveSource(),
-        ...(params.sessionId ? { c_session_id: params.sessionId } : {}),
-        c_created_at: nowDmsTimestamp(),
-        ...feedback,
-      });
-      return !!id;
+    const messages = parseTranscript(existing.c_answer);
+    const target = messages.find((m) => m.id === params.recordId);
+    if (!target) {
+      console.warn("[dms] 反馈未落库:该会话的对话里没有这条消息", params.recordId);
+      return false;
     }
 
-    const id = existing.id as string | undefined;
-    if (!id) return false;
-    return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, id, feedback);
+    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), {
+      c_answer: serializeTranscript(messages),
+      c_feedback_status: params.status,
+      c_feedback_at: nowDmsTimestamp(),
+      ...(params.option !== undefined ? { c_feedback_option: params.option } : {}),
+      ...(params.remark !== undefined ? { c_feedback_remark: params.remark } : {}),
+    });
   });
+};