Browse Source

feat(chat): 会话、问答记录与反馈落地到 DMS

把原先打 {VITE_API}/chat/* 的会话与历史读写整段换成 DMS
(栏目 1887 助手会话 / 1889 助手问答记录),并让反馈也写进 DMS。

写入时机(用户要求「点击提交时调用」):
- sendMessage 提交时 upsert 会话(按 c_session_id:没有就新增、有就更新)
  与问答记录(先写 question)
- totalResponse 补写 answer;close(停止/断流)也补一次,避免中断的
  问答只有 question 没有 answer
- 补问提交路径同样写入,存真实提交文本而非「已提交企业信息补充」占位文案

标识选取:
- 会话用前端 session.id —— 它恒等于聊天协议 thread_id、跨刷新稳定
- 问答记录用 AI 消息 id —— 它同时是反馈组件用的 record.id,因此从 DMS
  读回的历史消息点赞/点踩仍能命中原始行(否则反馈会另起一行对不上账)
- 访客(未登录)以「访客_<埋点访客id>」归属写入

安全:token 由 vite 代理注入(.env.development.local,按 .gitignore 约定
不入库),浏览器产物里没有 token。生产需 nginx 等价转发。

实测踩到的三个坑(Step-0,详见 harness/progress.md Session 022):
- DMS 的删除不是 delContentById(各种传法都 code=-1 / POST 405),
  而是 POST /content/updateAudit 把 state 改成 4(销毁)
- addContent 返回记录 uuid,形态是纯字符串;c_id(must=true)实测不强校验
- findRowBy 不能加 orderBy:1889 没有 c_updated_at,按不存在的字段排序会让
  查询恒空 → upsert 每次都新增 → 同一问答写成多行(验证脚本抓到的真 bug)

验证:npm run build 通过;harness/tools/verify-dms-chat-storage.mjs 26/26
通过(打真实 DMS,经 vite 代理,与浏览器同一路径);代理注入 token 已实证
(经代理 202、直连不带 token 208)。⚠️ 浏览器端到端尚未人工点过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 3 days ago
parent
commit
d8707107cf

+ 6 - 0
.env.development

@@ -38,3 +38,9 @@ VITE_PARENT_ORIGIN = 'https://192.168.14.50:5174'
 # 开发环境走 vite 同源代理(见 vite.config.ts 的 /chat-api/),
 # 避免 HTTPS 页面直连 HTTP 后端被浏览器拦截,同时绕开后端未实现 CORS 的问题
 VITE_CHAT_API="/chat-api"
+
+# DMS 数据管理服务(会话/问答记录/反馈的落地库)
+# 走 vite 同源代理:HTTPS 页面直连 http:// 的 DMS 会被浏览器按混合内容拦截,
+# 且 token 由代理侧注入(见 vite.config.ts),不进浏览器产物
+VITE_DMS_API="/dms-api"
+VITE_DMS_TARGET="http://121.43.55.7:10081"

+ 12 - 0
README.md

@@ -94,3 +94,15 @@
   - 新增 `harness/tools/check-policy-sources.mjs`:对比两个来源的条数与字段覆盖率,
     并在「远端也带上 id 了」时提醒可以移除该开关
   - 代价:开发环境用的这份比远端少 65 条申报事项(262 vs 327)
+- 会话、问答记录与反馈**落地到 DMS 数据管理服务**(栏目 1887 助手会话 / 1889 助手问答记录)
+  - 提交问题时写入会话(按 `c_session_id` upsert:没有就新增、有就更新)与问答记录(先写问题);
+    回答完成补写回答;停止/断流也补写已生成的内容,避免中断的问答只有问题没有回答
+  - 会话列表、历史记录改为**从 DMS 读取**;会话改名、删除同步到 DMS
+  - 点赞/点踩/取消写入 DMS 的问答记录(按 `c_record_id` 定位那一行)
+  - **未登录的访客也写入**,以 `访客_<访客id>` 归属(访客 id 复用埋点的那个)
+  - 会话标识用前端的 `session.id`(它恒等于聊天协议的 `thread_id`);问答记录标识用
+    AI 消息 id(同时是反馈组件用的 `record.id`,因此从 DMS 读回的历史消息点赞仍能命中原始行)
+  - **token 只存在于 vite 代理侧**(`.env.development.local`,按约定不入库),
+    浏览器产物里没有 token;DMS 不可用时静默降级,聊天与本地历史不受影响
+  - 实测踩坑:DMS 的「删除」不是 `delContentById`(各种传法都返回参数错误),
+    而是 `POST /content/updateAudit` 把状态改成 `4`(销毁);`addContent` 会返回记录 uuid

+ 2 - 0
index.html

@@ -17,6 +17,8 @@
       globalThis.isKnowledgeApi = "%VITE_USE_KNOWLEDGE_API%" === "true";
       // 新版聊天协议后端基础地址(POST /api/chat,SSE)
       globalThis.VITE_CHAT_API = "%VITE_CHAT_API%" || "";
+      // DMS 数据管理服务(会话/问答记录/反馈)。token 由 vite 代理注入,前端不持有
+      globalThis.VITE_DMS_API = "%VITE_DMS_API%" || "";
       globalThis.isQingpuEnv = "MODE" === "qingpu";
       globalThis.__ZHAOSHANG_LLM_MODELS = ["zhaoshang", "zhaoshang_staging", "zhaoshang_safe"];
       globalThis.__isZhaoshangLLM = function (model) {

+ 16 - 5
src/components/Chat/BusinessRecord.vue

@@ -367,7 +367,7 @@ import {
 import { MessageState } from "../stream-message-coordinator";
 import { FeedbackType, From } from "@/types/record";
 import { reportAssistantFeedback } from "@/network/api/assistant-statistics";
-import cardAPI from "@/network/api/card/index";
+import { writeDmsFeedback } from "@/network/api/dms/chat-sessions-dms";
 import DotsAnim from "../Common/DotsAnim.vue";
 import BarAnim from "../Common/BarAnim.vue";
 import FeedbackWidget from "../Common/FeedbackWidget.vue";
@@ -505,18 +505,29 @@ function openDislikeDialog(recordId: string | number) {
   showDislikeDialog.value = true;
 }
 
+// 反馈写入 DMS 的问答记录(栏目 1889 的 c_feedback_*),按 c_record_id 定位那一行。
+// recordId 就是 AI 消息 id,与写入时的 c_record_id 是同一个值(见 chat-sessions-dms.ts)
 function submitLikeFeedback(recordId: string | number) {
-  void cardAPI.giveFeedback({ record_id: recordId, status: FeedbackType.Like });
+  void writeDmsFeedback({
+    recordId: String(recordId),
+    sessionId: policyMatchSessionId.value,
+    status: FeedbackType.Like,
+  });
 }
 
 function cancelFeedback(recordId: string | number) {
-  void cardAPI.giveFeedback({ record_id: recordId, status: FeedbackType.None });
+  void writeDmsFeedback({
+    recordId: String(recordId),
+    sessionId: policyMatchSessionId.value,
+    status: FeedbackType.None,
+  });
 }
 
 function handleDislikeConfirm(option: string, remark: string) {
   if (pendingDislikeRecordId === null) return;
-  void cardAPI.giveFeedback({
-    record_id: pendingDislikeRecordId,
+  void writeDmsFeedback({
+    recordId: String(pendingDislikeRecordId),
+    sessionId: policyMatchSessionId.value,
     status: FeedbackType.Dislike,
     option,
     remark,

+ 67 - 7
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -24,6 +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';
 
 interface UseBusinessAssistantChatOptions {
   apiBaseUrl?: string;
@@ -52,6 +53,8 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   let activeGenerationSessionId: string | null = null;
   let typewriterTestTimer: number | null = null;
   let typewriterTestSession: BusinessAssistantSession | null = null;
+  /** 已把 answer 写进 DMS 的那条 AI 消息 id(避免 totalResponse 与 close 各写一次) */
+  let dmsAnswerWrittenFor: string | null = null;
 
   const clearTypewriterTestTimer = () => {
     if (typewriterTestTimer === null) {
@@ -65,13 +68,12 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   const isEnterpriseLoggedIn = () => !!enterpriseInfo.value;
   const getCreditCode = () => enterpriseInfo.value?.credit_code || '';
 
+  // DMS 版本不再要求登录:访客的记录也要落库(c_credit_code = 访客_<访客id>)
   const syncSessionTitleToServer = (sessionId: string, title: string) => {
-    if (!isEnterpriseLoggedIn()) return;
     void updateSessionInfo(sessionId, getCreditCode(), title);
   };
 
   const syncSessionDeleteToServer = (sessionId: string) => {
-    if (!isEnterpriseLoggedIn()) return;
     void deleteSessionInfo(sessionId, getCreditCode());
   };
 
@@ -305,6 +307,11 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     });
 
     smc.addEventListener('totalResponse', (payload: any) => {
+      // ⚠️ 必须在 finishActiveAiMessage() 之前捕获:它会清空 activeGenerationSessionId,
+      // 而且用户此刻可能已经切走会话,之后取 currentSession 会指错对象
+      const targetSession = getActiveGenerationSession();
+      const lastAiMessage = targetSession ? getLastAiMessage(targetSession.messages) : null;
+
       finishActiveAiMessage();
       const historyPayload = buildAppendHistoryPayload(payload);
       // appendHistory 已禁用:历史记录改由 chat 接口的 enable_history 控制
@@ -313,16 +320,44 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       // }
       saveHistory();
 
-      // 首次创建 session: 完成第一个 appendHistory 后同步标题到服务端
+      // 回答完成 → 把 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 } : {}),
+        });
+      }
+
+      // 首轮完成时同步标题(会话行在 sendMessage 里已建,这里按 c_session_id 更新)
       const session = currentSession.value;
-      if (session && session.messages.length <= 2 && isEnterpriseLoggedIn()) {
+      if (session && session.messages.length <= 2) {
         syncSessionTitleToServer(session.id, session.title || '');
       }
     });
 
     smc.addEventListener('close', () => {
+      // 停止/断流走这里(没有 totalResponse):把已经生成出来的内容补写进 DMS,
+      // 否则被中断的问答在库里会只有 question 没有 answer
+      const targetSession = getActiveGenerationSession();
+      const lastAiMessage = targetSession ? getLastAiMessage(targetSession.messages) : null;
       finishActiveAiMessage();
       saveHistory();
+
+      if (
+        targetSession &&
+        lastAiMessage &&
+        lastAiMessage.id !== dmsAnswerWrittenFor && // 正常完成时已写过,别重复
+        String(lastAiMessage.content || '').trim()
+      ) {
+        void upsertDmsRecord({
+          recordId: lastAiMessage.id,
+          sessionId: targetSession.id,
+          answer: lastAiMessage.content,
+        });
+      }
     });
 
     smc.addEventListener('error', () => {
@@ -384,12 +419,24 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     // globalThis.transmission.file_pos = fileTransmission?.file_pos || [];
 
     session.messages.push(createUserMessage(text, fileTransmission?.attachments || []));
-    ensurePendingAiMessage(session.messages);
+    // 这条 AI 消息的 id 同时是:渲染层的 record id、反馈组件用的 record.id、DMS 的 c_record_id
+    const pendingAiMessage = ensurePendingAiMessage(session.messages);
     inputText.value = '';
     isGenerating.value = true;
     activeGenerationSessionId = session.id;
+    dmsAnswerWrittenFor = null; // 新一轮开始,允许 close 补写
     saveHistory();
     await options.afterSend?.();
+
+    // 点击提交即写 DMS:会话 upsert(没有就新增、有就更新)+ 问答记录(先写 question)
+    // fire-and-forget,失败只 warn,不影响聊天
+    void upsertDmsSession({ sessionId: session.id, title: session.title });
+    void upsertDmsRecord({
+      recordId: pendingAiMessage.id,
+      sessionId: session.id,
+      question: text,
+    });
+
     smc?.generateAnswer(text);
   };
 
@@ -1526,7 +1573,9 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
           history: true,
         },
         {
-          id: createBusinessAssistantId(),
+          // 用 DMS 回填的 record_id 当消息 id:这样从 DMS 读回的历史消息,
+          // 点赞/点踩时仍能命中 DMS 里的原始行(否则反馈会另起一行、对不上账)
+          id: msg.record_id || createBusinessAssistantId(),
           role: 'ai' as const,
           content: msg.answer,
           state: 3,
@@ -1557,11 +1606,22 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     }
 
     session.messages.push(createUserMessage('已提交企业信息补充'));
-    ensurePendingAiMessage(session.messages);
+    const pendingAiMessage = ensurePendingAiMessage(session.messages);
     isGenerating.value = true;
     activeGenerationSessionId = session.id;
+    dmsAnswerWrittenFor = null;
     saveHistory();
     await options.afterSend?.();
+
+    // 补问也是一次问答:记进 DMS。问题存**真实提交文本**(answerText),
+    // 不存界面上那句「已提交企业信息补充」——否则从 DMS 读回的历史会失真
+    void upsertDmsSession({ sessionId: session.id, title: session.title });
+    void upsertDmsRecord({
+      recordId: pendingAiMessage.id,
+      sessionId: session.id,
+      question: answerText,
+    });
+
     smc.generateAnswer(answerText);
   };
 

+ 50 - 130
src/network/api/chat-sessions.ts

@@ -1,3 +1,20 @@
+/**
+ * 会话与问答记录的远程读写。
+ *
+ * ⚠️ **内部实现已切到 DMS**(栏目 1887 助手会话 / 1889 助手问答记录),
+ * 对外函数签名与返回类型保持不变,调用方(PC / Mobile / useBusinessAssistantChat)无需改动。
+ * DMS 侧的实现与踩坑见 `./dms/chat-sessions-dms.ts` 与 `./dms/client.ts` 的注释。
+ *
+ * 原来的实现打的是 `{VITE_API}/chat/sessions` 等旧接口,已整段替换(可查 git 历史)。
+ */
+
+import {
+  fetchDmsSessions,
+  fetchDmsSessionRecords,
+  upsertDmsSession,
+  deleteDmsSession,
+} from "./dms/chat-sessions-dms";
+
 export interface RemoteSession {
   session_id: string;
   title?: string;
@@ -10,172 +27,75 @@ export interface RemoteSessionRecord {
   question: string;
   answer: string;
   created_at: number;
+  /**
+   * 该条问答的记录 id(= 写入 DMS 时的 `c_record_id`)。
+   * 前端用它作为 AI 消息的 id,这样从 DMS 读回的历史消息在点赞/点踩时
+   * 仍能命中 DMS 里的原始行(反馈闭环)。
+   */
+  record_id?: string;
 }
 
-export interface RemoteSessionsResponse {
-  err_code: number;
-  err_msg?: string;
-  ret?: {
-    data: RemoteSession[];
-    total?: number;
-    page_index?: number;
-    page_size?: number;
-  };
-}
-
-export interface RemoteSessionRecordResponse {
-  err_code: number;
-  err_msg?: string;
-  ret?: {
-    total?: number;
-    page_size?: number;
-    page_index?: number;
-    data: RemoteSessionRecord[];
-  };
-}
-
-function authHeaders(): Record<string, string> {
-  const token = (globalThis as any).authToken || (globalThis as any).token;
-  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
-  if (token) {
-    headers['Authorization'] = token;
-  }
-  return headers;
-}
-
+/**
+ * 会话列表。访客(无 credit_code)返回空数组——访客列表只走本地历史。
+ * 后两个参数为兼容旧签名保留,DMS 侧按 credit_code 查、不使用它们。
+ */
 export async function fetchRemoteSessions(
   creditCode: string,
-  pageIndex = 1,
-  pageSize = 100,
-  source = ''
+  _pageIndex = 1,
+  _pageSize = 100,
+  _source = ""
 ): Promise<RemoteSession[]> {
-  try {
-    const apiBase = import.meta.env.VITE_API || '';
-    const params = new URLSearchParams();
-    params.set('credit_code', creditCode);
-    params.set('page_index', String(pageIndex));
-    params.set('page_size', String(pageSize));
-    const storedSource = (globalThis as any).source;
-    const finalSource = source || storedSource || '';
-    if (finalSource) {
-      params.set('source', finalSource);
-    }
-    const url = `${apiBase}/chat/sessions?${params.toString()}`;
-
-    const response = await fetch(url, {
-      method: 'GET',
-      headers: authHeaders(),
-    });
-
-    if (!response.ok) {
-      console.warn('fetch remote sessions failed:', response.status);
-      return [];
-    }
-
-    const data: RemoteSessionsResponse = await response.json();
-    if (data.err_code !== 0 || !data.ret) {
-      console.warn('fetch remote sessions error:', data.err_msg);
-      return [];
-    }
+  if (!creditCode) {
+    return [];
+  }
 
-    return data.ret.data || [];
+  try {
+    return await fetchDmsSessions(creditCode);
   } catch (error) {
-    console.warn('fetch remote sessions error:', error);
+    console.warn("fetch remote sessions error:", error);
     return [];
   }
 }
 
+/** 某个会话的全部问答记录 */
 export async function fetchRemoteSessionRecords(
   sessionId: string
 ): Promise<RemoteSessionRecord[]> {
-  try {
-    const apiBase = import.meta.env.VITE_API || '';
-    const storedSource = (globalThis as any).source;
-    const sourceParam = storedSource ? `&source=${encodeURIComponent(storedSource)}` : '';
-    const url = `${apiBase}/chat/session_record?session_id=${encodeURIComponent(sessionId)}${sourceParam}`;
-
-    const response = await fetch(url, {
-      method: 'GET',
-      headers: authHeaders(),
-    });
-
-    if (!response.ok) {
-      console.warn('fetch remote session records failed:', response.status);
-      return [];
-    }
-
-    const data: RemoteSessionRecordResponse = await response.json();
-    if (data.err_code !== 0 || !data.ret || !data.ret.data || data.ret.data.length === 0) {
-      return [];
-    }
+  if (!sessionId) {
+    return [];
+  }
 
-    return data.ret.data;
+  try {
+    return await fetchDmsSessionRecords(sessionId);
   } catch (error) {
-    console.warn('fetch remote session records error:', error);
+    console.warn("fetch remote session records error:", error);
     return [];
   }
 }
 
+/** 保存会话信息(标题/归属企业)。语义是 upsert:DMS 里没有该会话时补建 */
 export async function updateSessionInfo(
   sessionId: string,
   creditCode: string,
   title: string
 ): Promise<boolean> {
   try {
-    const apiBase = import.meta.env.VITE_API || '';
-    const source = (globalThis as any).source || 'zhaoshang';
-
-    const response = await fetch(`${apiBase}/chat/session_info`, {
-      method: 'PUT',
-      headers: authHeaders(),
-      body: JSON.stringify({
-        session_id: sessionId,
-        credit_code: creditCode,
-        title,
-        source,
-      }),
-    });
-
-    if (!response.ok) {
-      console.warn('update session info failed:', response.status);
-      return false;
-    }
-
-    const data = await response.json();
-    return data.err_code === 0;
+    return await upsertDmsSession({ sessionId, title, creditCode });
   } catch (error) {
-    console.warn('update session info error:', error);
+    console.warn("update session info error:", error);
     return false;
   }
 }
 
+/** 删除会话(连带它的问答记录) */
 export async function deleteSessionInfo(
   sessionId: string,
-  creditCode: string
+  _creditCode?: string
 ): Promise<boolean> {
   try {
-    const apiBase = import.meta.env.VITE_API || '';
-    const source = (globalThis as any).source || 'zhaoshang';
-
-    const response = await fetch(`${apiBase}/chat/del_session_info`, {
-      method: 'POST',
-      headers: authHeaders(),
-      body: JSON.stringify({
-        session_id: sessionId,
-        credit_code: creditCode,
-        source,
-      }),
-    });
-
-    if (!response.ok) {
-      console.warn('delete session info failed:', response.status);
-      return false;
-    }
-
-    const data = await response.json();
-    return data.err_code === 0;
+    return await deleteDmsSession(sessionId);
   } catch (error) {
-    console.warn('delete session info error:', error);
+    console.warn("delete session info error:", error);
     return false;
   }
 }

+ 281 - 0
src/network/api/dms/chat-sessions-dms.ts

@@ -0,0 +1,281 @@
+/**
+ * 会话与问答记录在 DMS 里的读写(栏目 1887 助手会话 / 1889 助手问答记录)。
+ *
+ * 设计要点:
+ *
+ * - **会话标识用前端的 `session.id`**(UUID)。它在 `sendMessage` 里恒等于聊天协议的
+ *   `thread_id`、且跨刷新稳定(存 localStorage),所以拿它当 `c_session_id` 能与协议对账。
+ * - **问答记录标识用 AI 消息 id**(`BusinessAssistantMessage.id`)。它同时也是
+ *   反馈组件 `BusinessRecord` 拿到的 `record.id`,所以三条链路(写入/读回/反馈)用同一个值,
+ *   反馈能命中原来那一行。
+ * - **upsert**:先按幂等键精确搜(1887→`c_session_id`,1889→`c_record_id`),
+ *   命中就 `updateContent`,没命中就 `addContent`。DMS 没有唯一约束,只能这么来。
+ * - **顺序链**:同一幂等键的写操作串行排队。「提交时写 question」与「回答完成补 answer」
+ *   是两次独立调用,不排队的话第二次的反查可能先于第一次的写入,导致同一问答出现两行。
+ * - **访客也能写**:未登录时 `c_credit_code` 用 `访客_<埋点访客id>`,
+ *   埋点访客 id 存在 localStorage,同一浏览器跨刷新稳定。
+ *
+ * ⚠️ 全部 fire-and-forget:失败只 warn,不抛异常、不阻塞聊天。
+ */
+
+import {
+  DMS_COLUMN_SESSION,
+  DMS_MODEL_SESSION,
+  DMS_COLUMN_RECORD,
+  DMS_MODEL_RECORD,
+  addDmsContent,
+  deleteDmsContent,
+  searchDmsContents,
+  updateDmsContent,
+  type DmsRow,
+} from "./client";
+import { enterpriseInfo } from "@/components/useEnterpriseAuth";
+import { getOrCreateAssistantVisitorId } from "@/network/api/assistant-statistics";
+import type { RemoteSession, RemoteSessionRecord } from "@/network/api/chat-sessions";
+
+/** 本轮的来源标识,与其它接口保持一致 */
+const resolveSource = (): string => ((globalThis as any).source as string) || "zhaoshang";
+
+/**
+ * 记录归属的「统一社会信用代码」。
+ * 已登录 → 企业的 credit_code;未登录(访客)→ `访客_<访客id>`。
+ */
+export const resolveDmsCreditCode = (): string =>
+  enterpriseInfo.value?.credit_code || `访客_${getOrCreateAssistantVisitorId()}`;
+
+/** 写 DMS 的时间格式(Step-0 实测:`YYYY-MM-DD HH:mm:ss` 被接受) */
+export const nowDmsTimestamp = (): string => {
+  const d = new Date();
+  const p = (n: number) => String(n).padStart(2, "0");
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(
+    d.getMinutes()
+  )}:${p(d.getSeconds())}`;
+};
+
+/**
+ * DMS 的时间戳解析成 epoch 毫秒。
+ * Step-0 实测:读回来是**毫秒数字**;这里额外兼容秒数与字符串,避免格式变了就崩。
+ */
+export const parseDmsTimestamp = (value: unknown): number => {
+  if (typeof value === "number") return value < 1e12 ? value * 1000 : value;
+  if (typeof value === "string" && value) {
+    if (/^\d{13}$/.test(value)) return Number(value);
+    if (/^\d{10}$/.test(value)) return Number(value) * 1000;
+    // "2026-09-17 21:30:00" 按东八区解析(DMS 服务器在国内)
+    const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)
+      ? value.replace(" ", "T") + "+08:00"
+      : value;
+    const t = new Date(normalized).getTime();
+    if (!Number.isNaN(t)) return t;
+  }
+  return Date.now();
+};
+
+/**
+ * 同一幂等键的写操作串行化。
+ * 避免「提交写 question」与「完成补 answer」两次调用并发反查、各自 add 出重复行。
+ */
+const writeChains = new Map<string, Promise<unknown>>();
+
+const enqueueWrite = <T>(key: string, job: () => Promise<T>): Promise<T> => {
+  const prev = writeChains.get(key) || Promise.resolve();
+  const next = prev.then(job, job); // 前一个失败也要继续跑
+  writeChains.set(key, next);
+  void next.finally(() => {
+    if (writeChains.get(key) === next) writeChains.delete(key);
+  });
+  return next;
+};
+
+/**
+ * 按字段精确查一行。
+ *
+ * ⚠️ **不要加 orderBy**:两个栏目的字段并不相同(1889 没有 `c_updated_at`),
+ * 按不存在的字段排序会让 DMS 直接报错、查询恒空 —— 后果是 upsert 每次都走「新增」,
+ * 同一问答被写成多行(踩过,见 verify-dms-chat-storage.mjs 的回归断言)。
+ */
+const findRowBy = async (
+  columnId: number,
+  field: string,
+  value: string
+): Promise<DmsRow | null> => {
+  const rows = await searchDmsContents(columnId, {
+    search: [{ field, searchType: 1, content: { value } }],
+    page: 0,
+    pageSize: 10,
+  });
+  return rows.length ? rows[0] : null;
+};
+
+/**
+ * 会话 upsert(栏目 1887,幂等键 `c_session_id`)。
+ * 首次提问时创建,改名/回答完成时更新标题与更新时间。
+ */
+export const upsertDmsSession = (params: {
+  sessionId: string;
+  title: string;
+  creditCode?: string;
+}): Promise<boolean> =>
+  enqueueWrite(`session:${params.sessionId}`, async () => {
+    const content: DmsRow = {
+      c_credit_code: params.creditCode || resolveDmsCreditCode(),
+      c_session_id: params.sessionId,
+      c_title: params.title || "未命名会话",
+      c_source: resolveSource(),
+      c_updated_at: nowDmsTimestamp(),
+    };
+
+    const existing = await findRowBy(DMS_COLUMN_SESSION, "c_session_id", params.sessionId);
+    if (!existing) {
+      content.c_created_at = nowDmsTimestamp();
+      const id = await addDmsContent(DMS_COLUMN_SESSION, DMS_MODEL_SESSION, content);
+      if (!id) console.warn("[dms] 会话写入失败", params.sessionId);
+      return !!id;
+    }
+
+    const id = existing.id as string | undefined;
+    if (!id) {
+      console.warn("[dms] 会话行缺少 uuid,无法更新", existing);
+      return false;
+    }
+    return updateDmsContent(DMS_COLUMN_SESSION, DMS_MODEL_SESSION, id, content);
+  });
+
+/**
+ * 问答记录 upsert(栏目 1889,幂等键 `c_record_id`)。
+ * 提交时只带 question,回答完成后再带 answer 补一次。
+ */
+export const upsertDmsRecord = (params: {
+  recordId: string;
+  sessionId: string;
+  creditCode?: string;
+  question?: string;
+  answer?: string;
+}): Promise<boolean> =>
+  enqueueWrite(`record:${params.recordId}`, async () => {
+    const base: DmsRow = {
+      c_credit_code: params.creditCode || resolveDmsCreditCode(),
+      c_session_id: params.sessionId,
+      c_record_id: params.recordId,
+      c_source: resolveSource(),
+    };
+
+    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.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);
+      return !!id;
+    }
+
+    const id = existing.id as string | undefined;
+    if (!id) {
+      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);
+  });
+
+/** 会话列表(按更新时间倒序) */
+export const fetchDmsSessions = async (creditCode: string): Promise<RemoteSession[]> => {
+  const rows = await searchDmsContents(DMS_COLUMN_SESSION, {
+    search: [{ field: "c_credit_code", searchType: 1, content: { value: creditCode } }],
+    orderBy: [{ field: "c_updated_at", orderByType: 2 }],
+    page: 0,
+    pageSize: 100,
+  });
+
+  return rows
+    .map(
+      (row): RemoteSession => ({
+        session_id: String(row.c_session_id || ""),
+        title: row.c_title ? String(row.c_title) : undefined,
+        updated_at: parseDmsTimestamp(row.c_updated_at),
+        credit_code: row.c_credit_code ? String(row.c_credit_code) : undefined,
+      })
+    )
+    .filter((s) => s.session_id);
+};
+
+/** 某个会话的全部问答记录(按创建时间正序,保证历史消息顺序正确) */
+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,
+  });
+
+  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) */
+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 sessionRow = await findRowBy(DMS_COLUMN_SESSION, "c_session_id", sessionId);
+    if (!sessionRow?.id) return true; // 本就不存在,视为已删
+    return deleteDmsContent(DMS_COLUMN_SESSION, String(sessionRow.id));
+  });
+
+/**
+ * 反馈写入(栏目 1889 的 `c_feedback_*`)。
+ * 先按 `c_record_id` 找到那一行再更新;找不到(历史遗留的本地消息)就补建一行。
+ */
+export const writeDmsFeedback = (params: {
+  recordId: string;
+  sessionId?: string;
+  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;
+
+    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 id = existing.id as string | undefined;
+    if (!id) return false;
+    return updateDmsContent(DMS_COLUMN_RECORD, DMS_MODEL_RECORD, id, feedback);
+  });

+ 172 - 0
src/network/api/dms/client.ts

@@ -0,0 +1,172 @@
+/**
+ * DMS(数据管理服务)HTTP 客户端。
+ *
+ * 契约来源:`harness/docs/reference/DMS_API.md` + **本项目的 Step-0 实测**
+ * (`harness/tools/dms-step0-verify.mjs`、`dms-delete-probe.mjs`)。
+ * 三处与常规 REST 不同,改代码前先记住:
+ *
+ * 1. **鉴权头叫 `token`**(不是 `Authorization`)。开发环境由 vite 代理注入
+ *    (见 vite.config.ts 的 `/dms-api/`),因此这里**不手动带 token**,前端也拿不到它。
+ * 2. **参数走 form body**(`application/x-www-form-urlencoded`),不是 JSON。
+ * 3. **成功判定是 `code === 200`**;`202` 表示「数据不存在」(栏目为空),不是错误。
+ *
+ * ⚠️ **所有函数都不抛异常**:失败一律 `console.warn` 并返回空值。
+ *    聊天主链路是 fire-and-forget 调用它们,DMS 挂了不能让聊天挂。
+ */
+
+import { getDmsApiBaseUrl } from "@/utils/runtime-config";
+
+/** 栏目与栏目模型 id(实测自 DMS,见 DMS_COLUMNS.md) */
+export const DMS_COLUMN_SESSION = 1887;
+export const DMS_MODEL_SESSION = 2034;
+export const DMS_COLUMN_RECORD = 1889;
+export const DMS_MODEL_RECORD = 2038;
+
+/** `state` 取值:0 草稿 / 1 待审 / 2 审完 / 3 发布 / 4 销毁 / 5 退回 */
+export const DMS_STATE_DESTROYED = 4;
+
+export interface DmsSearchCondition {
+  field: string;
+  /** 1 精确 / 2 模糊 */
+  searchType: 1 | 2;
+  content: { value: string | number };
+}
+
+export interface DmsOrderBy {
+  field: string;
+  /** 1 升序 / 2 降序 */
+  orderByType: 1 | 2;
+}
+
+export interface DmsResponse<T = unknown> {
+  code: number;
+  message?: string;
+  content: T;
+}
+
+export type DmsRow = Record<string, any>;
+
+type DmsMethod = "GET" | "POST" | "DELETE";
+
+/** 发一个 DMS 请求;任何失败都返回 null(调用方无需 try/catch) */
+async function dmsRequest(
+  path: string,
+  method: DmsMethod = "POST",
+  form?: Record<string, string>
+): Promise<DmsResponse | null> {
+  const base = getDmsApiBaseUrl();
+  if (!base) {
+    console.warn("[dms] 未配置 VITE_DMS_API,跳过请求", path);
+    return null;
+  }
+
+  const init: RequestInit = { method, headers: {} };
+  if (form) {
+    (init.headers as Record<string, string>)["Content-Type"] =
+      "application/x-www-form-urlencoded";
+    init.body = new URLSearchParams(form).toString();
+  }
+
+  try {
+    const response = await fetch(`${base}${path}`, init);
+    const text = await response.text();
+    let data: DmsResponse | null = null;
+    try {
+      data = JSON.parse(text);
+    } catch {
+      console.warn("[dms] 响应不是 JSON", path, text.slice(0, 120));
+      return null;
+    }
+
+    if (!data) return null;
+    if (data.code === 200) return data;
+    // 202 = 数据不存在(栏目为空),属正常空结果,不算失败
+    if (data.code === 202) return null;
+    if (typeof data.code !== "number") {
+      // 不是 DMS 的标准响应(例如 Spring 的 400/405 错误体没有 code 字段)——
+      // 把原始片段打出来,否则只看到 undefined 无从排查
+      console.warn("[dms] 非标准响应", path, text.slice(0, 200));
+    } else if (data.code === 201 || data.code === 212) {
+      console.warn("[dms] token 无效或无权限(检查 .env.development.local 的 DMS_TOKEN)", data.code, data.message);
+    } else {
+      console.warn("[dms] 请求失败", path, data.code, data.message);
+    }
+    return null;
+  } catch (error) {
+    console.warn("[dms] 请求异常", path, error);
+    return null;
+  }
+}
+
+/** 分页查询内容;无数据返回 [] */
+export const searchDmsContents = async (
+  columnId: number,
+  options: {
+    search?: DmsSearchCondition[];
+    orderBy?: DmsOrderBy[];
+    page?: number;
+    pageSize?: number;
+    states?: number[];
+  } = {}
+): Promise<DmsRow[]> => {
+  const form: Record<string, string> = {
+    columnId: String(columnId),
+    page: String(options.page ?? 0), // DMS 的 page 从 0 起
+    pageSize: String(options.pageSize ?? 100),
+  };
+  if (options.search?.length) form.search = JSON.stringify(options.search);
+  if (options.orderBy?.length) form.orderBy = JSON.stringify(options.orderBy);
+  if (options.states?.length) form.states = options.states.join(",");
+
+  const res = await dmsRequest("/content/selectContentList", "POST", form);
+  const content = res?.content as { data?: DmsRow[] } | undefined;
+  return content?.data || [];
+};
+
+/**
+ * 新增内容。成功时返回**记录的 uuid**(DMS 把它作为纯字符串放在 `content` 里,
+ * 不是对象——这是 Step-0 实测出来的),失败返回空串。
+ */
+export const addDmsContent = async (
+  columnId: number,
+  modelId: number,
+  content: DmsRow
+): Promise<string> => {
+  const res = await dmsRequest("/content/addContent", "POST", {
+    columnId: String(columnId),
+    modelId: String(modelId),
+    content: JSON.stringify(content),
+  });
+  const value = res?.content;
+  return typeof value === "string" ? value : "";
+};
+
+/** 修改内容。`id` 是 DMS 记录 uuid(不是业务字段 c_id),必须放在 content 里 */
+export const updateDmsContent = async (
+  columnId: number,
+  modelId: number,
+  id: string,
+  content: DmsRow
+): Promise<boolean> => {
+  const res = await dmsRequest("/content/updateContent", "POST", {
+    columnId: String(columnId),
+    modelId: String(modelId),
+    content: JSON.stringify({ ...content, id }),
+  });
+  return !!res;
+};
+
+/**
+ * 删除内容。
+ *
+ * ⚠️ **不是 `delContentById`**——那个端点各种传法都返回 `code=-1`/405(Step-0 实测)。
+ * DMS 的删除是**改状态**:`POST /content/updateAudit`,`state=4`(销毁)。
+ */
+export const deleteDmsContent = async (columnId: number, id: string): Promise<boolean> => {
+  const res = await dmsRequest("/content/updateAudit", "POST", {
+    columnId: String(columnId),
+    id,
+    state: String(DMS_STATE_DESTROYED),
+  });
+  return !!res;
+};

+ 14 - 0
src/utils/runtime-config.ts

@@ -21,3 +21,17 @@ export function getChatApiBaseUrl() {
   const value = bootstrap && bootstrap !== "%VITE_CHAT_API%" ? bootstrap : fromEnv;
   return value.trim().replace(/\/+$/, "");
 }
+
+/**
+ * DMS(数据管理服务)基础地址。
+ *
+ * 开发环境是 `/dms-api`(vite 同源代理,见 vite.config.ts)——代理负责
+ * 补 `/dms` 前缀并把 token 注入到请求头,**前端不持有 token**。
+ * 写法与 getChatApiBaseUrl 一致(globalThis 注入优先,构建期环境变量兜底)。
+ */
+export function getDmsApiBaseUrl() {
+  const bootstrap = ((globalThis as any).VITE_DMS_API || "") as string;
+  const fromEnv = (import.meta.env.VITE_DMS_API || "") as string;
+  const value = bootstrap && bootstrap !== "%VITE_DMS_API%" ? bootstrap : fromEnv;
+  return value.trim().replace(/\/+$/, "");
+}

+ 94 - 77
vite.config.ts

@@ -1,4 +1,4 @@
-import { defineConfig } from "vite";
+import { defineConfig, loadEnv } from "vite";
 // import { darkTheme } from "./src/theme.js";
 import vue from "@vitejs/plugin-vue";
 import Components from "unplugin-vue-components/vite";
@@ -10,89 +10,106 @@ import path from "path";
 import basicSsl from "@vitejs/plugin-basic-ssl";
 
 // https://vitejs.dev/config/
-export default defineConfig({
-  plugins: [
-    vue(),
-    Components({
-      resolvers: [AntDesignVueResolver()],
-    }),
-    visualizer({
-      open: false,
-      gzipSize: true,
-      file: "./dist/stats.html",
-      brotliSize: true,
-    }),
-    svgLoader(),
-    basicSsl(),
-  ],
-  server: {
-    proxy: {
-      //配置本地代理
-      "/api/": {
-        //匹配的路径
-        target: "http://aixq.shqp.gov.cn", //目标url
-        changeOrigin: true, //跨域
-        rewrite: (path) => path.replace(/^\/api/, ""), //重写路径
-      },
-      // 新版聊天后端代理(POST /api/chat,SSE)
-      // 开发服务器是 HTTPS 而后端是 HTTP,浏览器会拦截直连(混合内容),
-      // 且后端未实现 CORS,因此开发环境统一走同源代理。
-      // 对应 .env.development 中 VITE_CHAT_API="/chat-api"
-      "/chat-api/": {
-        target: "http://192.168.2.23:8000",
-        changeOrigin: true,
-        rewrite: (path) => path.replace(/^\/chat-api/, ""),
-      },
-      "/asr/": {
-        //匹配的路径
-        target: "https://human-screen-v3.metamaker.cn", //目标url
-        changeOrigin: true, //跨域
-        rewrite: (path) => path.replace(/^\/asr/, ""), //重写路径
-        ws: true,
-      },
-      "/stream/": {
-        //匹配的路径
-        target: "https://flv-enc.metamaker.cn", //目标url
-        changeOrigin: true, //跨域
-        rewrite: (path) => path.replace(/^\/stream/, ""), //重写路径
-        ws: true,
-        headers: {
-          Connection: "keep-alive",
+export default defineConfig(({ mode }) => {
+  // 第三个参数传空串:不加前缀过滤,才能读到 DMS_TOKEN 这类非 VITE_ 前缀的变量
+  // (DMS_TOKEN 只写在 .env.development.local,该文件按 .gitignore 不入库)
+  const env = loadEnv(mode, process.cwd(), "");
+
+  return {
+    plugins: [
+      vue(),
+      Components({
+        resolvers: [AntDesignVueResolver()],
+      }),
+      visualizer({
+        open: false,
+        gzipSize: true,
+        file: "./dist/stats.html",
+        brotliSize: true,
+      }),
+      svgLoader(),
+      basicSsl(),
+    ],
+    server: {
+      proxy: {
+        //配置本地代理
+        "/api/": {
+          //匹配的路径
+          target: "http://aixq.shqp.gov.cn", //目标url
+          changeOrigin: true, //跨域
+          rewrite: (path) => path.replace(/^\/api/, ""), //重写路径
+        },
+        // 新版聊天后端代理(POST /api/chat,SSE)
+        // 开发服务器是 HTTPS 而后端是 HTTP,浏览器会拦截直连(混合内容),
+        // 且后端未实现 CORS,因此开发环境统一走同源代理。
+        // 对应 .env.development 中 VITE_CHAT_API="/chat-api"
+        "/chat-api/": {
+          target: "http://192.168.2.23:8000",
+          changeOrigin: true,
+          rewrite: (path) => path.replace(/^\/chat-api/, ""),
+        },
+        // DMS 数据管理服务(会话/问答记录/反馈)
+        // 两件事都在代理侧做,浏览器拿不到:
+        //   ① 补回 /dms 前缀(DMS 的真实路由都在 /dms/ 下)
+        //   ② 注入 token 头(DMS 的鉴权头名就叫 token,不是 Authorization)
+        // token 来源:.env.development.local 的 DMS_TOKEN(不入库)
+        "/dms-api/": {
+          target: env.VITE_DMS_TARGET || "http://121.43.55.7:10081",
+          changeOrigin: true,
+          rewrite: (path) => path.replace(/^\/dms-api/, "/dms"),
+          headers: env.DMS_TOKEN ? { token: env.DMS_TOKEN } : {},
+        },
+        "/asr/": {
+          //匹配的路径
+          target: "https://human-screen-v3.metamaker.cn", //目标url
+          changeOrigin: true, //跨域
+          rewrite: (path) => path.replace(/^\/asr/, ""), //重写路径
+          ws: true,
+        },
+        "/stream/": {
+          //匹配的路径
+          target: "https://flv-enc.metamaker.cn", //目标url
+          changeOrigin: true, //跨域
+          rewrite: (path) => path.replace(/^\/stream/, ""), //重写路径
+          ws: true,
+          headers: {
+            Connection: "keep-alive",
+          },
         },
       },
     },
-  },
-  build: {
-    rollupOptions: {
-      input: {
-        main: path.resolve(__dirname, "index.html"),
-        redirect: path.resolve(__dirname, "redirect.html"),
-      },
-      output: {
-        manualChunks: (id) => {
-          console.log(id);
-          if (id.includes("node_modules/three")) {
-            return "three";
-          } else if (id.includes("node_modules")) {
-            return "vendor";
-          }
+    build: {
+      rollupOptions: {
+        input: {
+          main: path.resolve(__dirname, "index.html"),
+          redirect: path.resolve(__dirname, "redirect.html"),
+        },
+        output: {
+          manualChunks: (id) => {
+            console.log(id);
+            if (id.includes("node_modules/three")) {
+              return "three";
+            } else if (id.includes("node_modules")) {
+              return "vendor";
+            }
+          },
         },
       },
+      assetsInlineLimit: 20480,
     },
-    assetsInlineLimit: 20480,
-  },
-  resolve: {
-    alias: {
-      "@": path.resolve("./src/"),
-      three$: path.resolve(__dirname, "./node_modules/three/build/three.cjs"),
+    resolve: {
+      alias: {
+        "@": path.resolve("./src/"),
+        three$: path.resolve(__dirname, "./node_modules/three/build/three.cjs"),
+      },
     },
-  },
-  base: "./",
-  css: {
-    preprocessorOptions: {
-      scss: {
-        api: "modern-compiler",
+    base: "./",
+    css: {
+      preprocessorOptions: {
+        scss: {
+          api: "modern-compiler",
+        },
       },
     },
-  },
+  };
 });