|
@@ -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);
|
|
|
|
|
+ });
|