/** * DMS(数据管理服务)HTTP 客户端。 * * 契约来源:`harness/docs/reference/DMS_API.md` + **本项目的 Step-0 实测** * (`harness/tools/dms-step0-verify.mjs`、`dms-delete-probe.mjs`)。 * 三处与常规 REST 不同,改代码前先记住: * * 1. **鉴权头叫 `token`**(不是 `Authorization`)。**2026-09-29 起由前端自己带**: * 未登录访客用服务账号换一个(`service-token.ts`),随请求发 `token` 头; * 代理(dev 的 vite / 生产的 nginx)**只在请求没带时才注入**,命令行走代理的方式不受影响。 * 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"; import { getServiceDmsToken, clearServiceDmsToken } from "./service-token"; /** 栏目与栏目模型 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; /** 企业基础信息(一行一家企业,幂等键 `c_credit_code`;modelId 实测自 getModelById) */ export const DMS_COLUMN_ENTERPRISE = 1888; export const DMS_MODEL_ENTERPRISE = 2036; /** 企业荣誉信息(本同步里「一个标签一行」,幂等靠我们自己的 c_source 标记) */ export const DMS_COLUMN_HONOR = 1886; export const DMS_MODEL_HONOR = 2032; /** `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 { code: number; message?: string; content: T; } export type DmsRow = Record; type DmsMethod = "GET" | "POST" | "DELETE"; /** 取一个可用的 token;拿不到就返回空串(并降级——dev 下代理还会兜底注入) */ const ensureToken = async (): Promise => { try { return await getServiceDmsToken(); } catch (error) { console.warn( "[dms] 换服务账号 token 失败,本次不带 token(dev 由代理兜底注入,生产会失败)", error instanceof Error ? error.message : error ); return ""; } }; /** 发一个 DMS 请求;任何失败都返回 null(调用方无需 try/catch) */ async function dmsRequest( path: string, method: DmsMethod = "POST", form?: Record ): Promise { const base = getDmsApiBaseUrl(); if (!base) { console.warn("[dms] 未配置 VITE_DMS_API,跳过请求", path); return null; } /** 发一次,返回解析结果;`token` 为空就不带这个头(交给代理兜底) */ const sendOnce = async (token: string) => { const init: RequestInit = { method, headers: {} }; const headers = init.headers as Record; if (token) headers["token"] = token; if (form) { headers["Content-Type"] = "application/x-www-form-urlencoded"; init.body = new URLSearchParams(form).toString(); } const response = await fetch(`${base}${path}`, init); const text = await response.text(); let data: DmsResponse | null = null; try { data = JSON.parse(text) as DmsResponse; } catch { console.warn("[dms] 响应不是 JSON", path, text.slice(0, 120)); } return { data, text }; }; try { let token = await ensureToken(); let { data, text } = await sendOnce(token); // token 失效(201/212):丢掉缓存、重换一次再试一遍 —— 只重试一次 if (data && (data.code === 201 || data.code === 212) && token) { clearServiceDmsToken(); token = await ensureToken(); if (token) ({ data, text } = await sendOnce(token)); } 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) { // 前端换 token 的路径见 service-token.ts;换成命令行排查时也可以 `npm run dms:token` console.warn( "[dms] token 无效或无权限(服务账号可能被停用/改密,见 src/network/api/dms/service-token.ts)", 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 => { const form: Record = { 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 => { 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 => { 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 => { const res = await dmsRequest("/content/updateAudit", "POST", { columnId: String(columnId), id, state: String(DMS_STATE_DESTROYED), }); return !!res; }; /* ------------------------------------------------------------------ * * 通用工具(各业务模块共用,原先只写在 chat-sessions-dms.ts 里) * ------------------------------------------------------------------ */ /** * 按字段精确查一行。 * * ⚠️ **不要加 orderBy**:各栏目的字段并不相同(1889 没有 `c_updated_at`), * 按不存在的字段排序会让 DMS 直接报错、查询恒空 —— 后果是 upsert 每次都走「新增」, * 同一份数据被写成多行(踩过,见 `verify-dms-chat-storage.mjs` 的回归断言)。 */ export const findDmsRowBy = async ( columnId: number, field: string, value: string ): Promise => { const rows = await searchDmsContents(columnId, { search: [{ field, searchType: 1, content: { value } }], page: 0, pageSize: 10, }); return rows.length ? rows[0] : null; }; /** * 同一幂等键的写操作串行化。 * 避免「提交写 question」与「完成补 answer」两次调用并发反查、各自 add 出重复行。 */ const writeChains = new Map>(); export const enqueueDmsWrite = (key: string, job: () => Promise): Promise => { 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; };