| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- /**
- * 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<T = unknown> {
- code: number;
- message?: string;
- content: T;
- }
- export type DmsRow = Record<string, any>;
- type DmsMethod = "GET" | "POST" | "DELETE";
- /** 取一个可用的 token;拿不到就返回空串(并降级——dev 下代理还会兜底注入) */
- const ensureToken = async (): Promise<string> => {
- 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<string, string>
- ): Promise<DmsResponse | null> {
- 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<string, string>;
- 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<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;
- };
- /* ------------------------------------------------------------------ *
- * 通用工具(各业务模块共用,原先只写在 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<DmsRow | null> => {
- 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<string, Promise<unknown>>();
- export const enqueueDmsWrite = <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;
- };
|