client.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. /**
  2. * DMS(数据管理服务)HTTP 客户端。
  3. *
  4. * 契约来源:`harness/docs/reference/DMS_API.md` + **本项目的 Step-0 实测**
  5. * (`harness/tools/dms-step0-verify.mjs`、`dms-delete-probe.mjs`)。
  6. * 三处与常规 REST 不同,改代码前先记住:
  7. *
  8. * 1. **鉴权头叫 `token`**(不是 `Authorization`)。**2026-09-29 起由前端自己带**:
  9. * 未登录访客用服务账号换一个(`service-token.ts`),随请求发 `token` 头;
  10. * 代理(dev 的 vite / 生产的 nginx)**只在请求没带时才注入**,命令行走代理的方式不受影响。
  11. * 2. **参数走 form body**(`application/x-www-form-urlencoded`),不是 JSON。
  12. * 3. **成功判定是 `code === 200`**;`202` 表示「数据不存在」(栏目为空),不是错误。
  13. *
  14. * ⚠️ **所有函数都不抛异常**:失败一律 `console.warn` 并返回空值。
  15. * 聊天主链路是 fire-and-forget 调用它们,DMS 挂了不能让聊天挂。
  16. */
  17. import { getDmsApiBaseUrl } from "@/utils/runtime-config";
  18. import { getServiceDmsToken, clearServiceDmsToken } from "./service-token";
  19. /** 栏目与栏目模型 id(实测自 DMS,见 DMS_COLUMNS.md) */
  20. export const DMS_COLUMN_SESSION = 1887;
  21. export const DMS_MODEL_SESSION = 2034;
  22. export const DMS_COLUMN_RECORD = 1889;
  23. export const DMS_MODEL_RECORD = 2038;
  24. /** 企业基础信息(一行一家企业,幂等键 `c_credit_code`;modelId 实测自 getModelById) */
  25. export const DMS_COLUMN_ENTERPRISE = 1888;
  26. export const DMS_MODEL_ENTERPRISE = 2036;
  27. /** 企业荣誉信息(本同步里「一个标签一行」,幂等靠我们自己的 c_source 标记) */
  28. export const DMS_COLUMN_HONOR = 1886;
  29. export const DMS_MODEL_HONOR = 2032;
  30. /** `state` 取值:0 草稿 / 1 待审 / 2 审完 / 3 发布 / 4 销毁 / 5 退回 */
  31. export const DMS_STATE_DESTROYED = 4;
  32. export interface DmsSearchCondition {
  33. field: string;
  34. /** 1 精确 / 2 模糊 */
  35. searchType: 1 | 2;
  36. content: { value: string | number };
  37. }
  38. export interface DmsOrderBy {
  39. field: string;
  40. /** 1 升序 / 2 降序 */
  41. orderByType: 1 | 2;
  42. }
  43. export interface DmsResponse<T = unknown> {
  44. code: number;
  45. message?: string;
  46. content: T;
  47. }
  48. export type DmsRow = Record<string, any>;
  49. type DmsMethod = "GET" | "POST" | "DELETE";
  50. /** 取一个可用的 token;拿不到就返回空串(并降级——dev 下代理还会兜底注入) */
  51. const ensureToken = async (): Promise<string> => {
  52. try {
  53. return await getServiceDmsToken();
  54. } catch (error) {
  55. console.warn(
  56. "[dms] 换服务账号 token 失败,本次不带 token(dev 由代理兜底注入,生产会失败)",
  57. error instanceof Error ? error.message : error
  58. );
  59. return "";
  60. }
  61. };
  62. /** 发一个 DMS 请求;任何失败都返回 null(调用方无需 try/catch) */
  63. async function dmsRequest(
  64. path: string,
  65. method: DmsMethod = "POST",
  66. form?: Record<string, string>
  67. ): Promise<DmsResponse | null> {
  68. const base = getDmsApiBaseUrl();
  69. if (!base) {
  70. console.warn("[dms] 未配置 VITE_DMS_API,跳过请求", path);
  71. return null;
  72. }
  73. /** 发一次,返回解析结果;`token` 为空就不带这个头(交给代理兜底) */
  74. const sendOnce = async (token: string) => {
  75. const init: RequestInit = { method, headers: {} };
  76. const headers = init.headers as Record<string, string>;
  77. if (token) headers["token"] = token;
  78. if (form) {
  79. headers["Content-Type"] = "application/x-www-form-urlencoded";
  80. init.body = new URLSearchParams(form).toString();
  81. }
  82. const response = await fetch(`${base}${path}`, init);
  83. const text = await response.text();
  84. let data: DmsResponse | null = null;
  85. try {
  86. data = JSON.parse(text) as DmsResponse;
  87. } catch {
  88. console.warn("[dms] 响应不是 JSON", path, text.slice(0, 120));
  89. }
  90. return { data, text };
  91. };
  92. try {
  93. let token = await ensureToken();
  94. let { data, text } = await sendOnce(token);
  95. // token 失效(201/212):丢掉缓存、重换一次再试一遍 —— 只重试一次
  96. if (data && (data.code === 201 || data.code === 212) && token) {
  97. clearServiceDmsToken();
  98. token = await ensureToken();
  99. if (token) ({ data, text } = await sendOnce(token));
  100. }
  101. if (!data) return null;
  102. if (data.code === 200) return data;
  103. // 202 = 数据不存在(栏目为空),属正常空结果,不算失败
  104. if (data.code === 202) return null;
  105. if (typeof data.code !== "number") {
  106. // 不是 DMS 的标准响应(例如 Spring 的 400/405 错误体没有 code 字段)——
  107. // 把原始片段打出来,否则只看到 undefined 无从排查
  108. console.warn("[dms] 非标准响应", path, text.slice(0, 200));
  109. } else if (data.code === 201 || data.code === 212) {
  110. // 前端换 token 的路径见 service-token.ts;换成命令行排查时也可以 `npm run dms:token`
  111. console.warn(
  112. "[dms] token 无效或无权限(服务账号可能被停用/改密,见 src/network/api/dms/service-token.ts)",
  113. data.code,
  114. data.message
  115. );
  116. } else {
  117. console.warn("[dms] 请求失败", path, data.code, data.message);
  118. }
  119. return null;
  120. } catch (error) {
  121. console.warn("[dms] 请求异常", path, error);
  122. return null;
  123. }
  124. }
  125. /** 分页查询内容;无数据返回 [] */
  126. export const searchDmsContents = async (
  127. columnId: number,
  128. options: {
  129. search?: DmsSearchCondition[];
  130. orderBy?: DmsOrderBy[];
  131. page?: number;
  132. pageSize?: number;
  133. states?: number[];
  134. } = {}
  135. ): Promise<DmsRow[]> => {
  136. const form: Record<string, string> = {
  137. columnId: String(columnId),
  138. page: String(options.page ?? 0), // DMS 的 page 从 0 起
  139. pageSize: String(options.pageSize ?? 100),
  140. };
  141. if (options.search?.length) form.search = JSON.stringify(options.search);
  142. if (options.orderBy?.length) form.orderBy = JSON.stringify(options.orderBy);
  143. if (options.states?.length) form.states = options.states.join(",");
  144. const res = await dmsRequest("/content/selectContentList", "POST", form);
  145. const content = res?.content as { data?: DmsRow[] } | undefined;
  146. return content?.data || [];
  147. };
  148. /**
  149. * 新增内容。成功时返回**记录的 uuid**(DMS 把它作为纯字符串放在 `content` 里,
  150. * 不是对象——这是 Step-0 实测出来的),失败返回空串。
  151. */
  152. export const addDmsContent = async (
  153. columnId: number,
  154. modelId: number,
  155. content: DmsRow
  156. ): Promise<string> => {
  157. const res = await dmsRequest("/content/addContent", "POST", {
  158. columnId: String(columnId),
  159. modelId: String(modelId),
  160. content: JSON.stringify(content),
  161. });
  162. const value = res?.content;
  163. return typeof value === "string" ? value : "";
  164. };
  165. /** 修改内容。`id` 是 DMS 记录 uuid(不是业务字段 c_id),必须放在 content 里 */
  166. export const updateDmsContent = async (
  167. columnId: number,
  168. modelId: number,
  169. id: string,
  170. content: DmsRow
  171. ): Promise<boolean> => {
  172. const res = await dmsRequest("/content/updateContent", "POST", {
  173. columnId: String(columnId),
  174. modelId: String(modelId),
  175. content: JSON.stringify({ ...content, id }),
  176. });
  177. return !!res;
  178. };
  179. /**
  180. * 删除内容。
  181. *
  182. * ⚠️ **不是 `delContentById`**——那个端点各种传法都返回 `code=-1`/405(Step-0 实测)。
  183. * DMS 的删除是**改状态**:`POST /content/updateAudit`,`state=4`(销毁)。
  184. */
  185. export const deleteDmsContent = async (columnId: number, id: string): Promise<boolean> => {
  186. const res = await dmsRequest("/content/updateAudit", "POST", {
  187. columnId: String(columnId),
  188. id,
  189. state: String(DMS_STATE_DESTROYED),
  190. });
  191. return !!res;
  192. };
  193. /* ------------------------------------------------------------------ *
  194. * 通用工具(各业务模块共用,原先只写在 chat-sessions-dms.ts 里)
  195. * ------------------------------------------------------------------ */
  196. /**
  197. * 按字段精确查一行。
  198. *
  199. * ⚠️ **不要加 orderBy**:各栏目的字段并不相同(1889 没有 `c_updated_at`),
  200. * 按不存在的字段排序会让 DMS 直接报错、查询恒空 —— 后果是 upsert 每次都走「新增」,
  201. * 同一份数据被写成多行(踩过,见 `verify-dms-chat-storage.mjs` 的回归断言)。
  202. */
  203. export const findDmsRowBy = async (
  204. columnId: number,
  205. field: string,
  206. value: string
  207. ): Promise<DmsRow | null> => {
  208. const rows = await searchDmsContents(columnId, {
  209. search: [{ field, searchType: 1, content: { value } }],
  210. page: 0,
  211. pageSize: 10,
  212. });
  213. return rows.length ? rows[0] : null;
  214. };
  215. /**
  216. * 同一幂等键的写操作串行化。
  217. * 避免「提交写 question」与「完成补 answer」两次调用并发反查、各自 add 出重复行。
  218. */
  219. const writeChains = new Map<string, Promise<unknown>>();
  220. export const enqueueDmsWrite = <T>(key: string, job: () => Promise<T>): Promise<T> => {
  221. const prev = writeChains.get(key) || Promise.resolve();
  222. const next = prev.then(job, job); // 前一个失败也要继续跑
  223. writeChains.set(key, next);
  224. void next.finally(() => {
  225. if (writeChains.get(key) === next) writeChains.delete(key);
  226. });
  227. return next;
  228. };