app.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. /* Ai-DOP 第三方对接模拟器 · 本地控制台脚本
  2. * 安全约束:不使用 localStorage/sessionStorage 保存任何认证信息;Secret 只在服务端。
  3. */
  4. 'use strict';
  5. const $ = (id) => document.getElementById(id);
  6. async function api(path, options = {}) {
  7. const resp = await fetch(path, {
  8. headers: { 'Content-Type': 'application/json' },
  9. ...options,
  10. });
  11. const text = await resp.text();
  12. let data;
  13. try { data = text ? JSON.parse(text) : {}; } catch { data = text; }
  14. if (!resp.ok) {
  15. const detail = (data && data.detail) ? data.detail : data;
  16. throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail, null, 2));
  17. }
  18. return data;
  19. }
  20. function show(el, data) {
  21. $(el).textContent = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
  22. }
  23. function setError(el, ex) {
  24. $(el).textContent = '❌ ' + (ex && ex.message ? ex.message : String(ex));
  25. }
  26. /* ---------------- Tabs ---------------- */
  27. document.querySelectorAll('nav button').forEach((btn) => {
  28. btn.addEventListener('click', () => {
  29. document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
  30. document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
  31. btn.classList.add('active');
  32. $(btn.dataset.tab).classList.add('active');
  33. });
  34. });
  35. /* ---------------- 全局状态 ---------------- */
  36. let CATALOG = null;
  37. let SCENARIOS = null;
  38. function objects() { return (CATALOG && CATALOG.objects) || []; }
  39. function findObj(code) { return objects().find((o) => o.objectCode === code); }
  40. function channelSpec(obj, channel) {
  41. if (!obj) return null;
  42. return { DB_SYNC: obj.dbSync, API_PULL: obj.apiPull, API_INBOUND: obj.apiInbound }[channel] || null;
  43. }
  44. function fillObjectSelect(sel, filterFn) {
  45. sel.innerHTML = '';
  46. objects().filter(filterFn || (() => true)).forEach((o) => {
  47. const opt = document.createElement('option');
  48. opt.value = o.objectCode;
  49. opt.textContent = `${o.objectCode} · ${o.businessName}`;
  50. sel.appendChild(opt);
  51. });
  52. }
  53. /* ---------------- 环境状态 ---------------- */
  54. async function refreshStatus() {
  55. try {
  56. const s = await api('/api/status');
  57. const miss = [];
  58. if (!s.inbound.accessKeyLoaded) miss.push('AIDOP_SIM_INBOUND_ACCESS_KEY');
  59. if (!s.inbound.secretLoaded) miss.push('AIDOP_SIM_INBOUND_SECRET');
  60. if (!s.mysql.passwordLoaded) miss.push('AIDOP_SIM_MYSQL_PASSWORD');
  61. const banner = $('env-banner');
  62. if (miss.length) {
  63. banner.className = 'banner warn';
  64. banner.textContent = `⚠ 缺少环境变量:${miss.join('、')}(Secret 只从环境变量读取,页面不提供输入)`;
  65. } else {
  66. banner.className = 'banner ok';
  67. banner.textContent = `✓ 凭据已加载(AccessKey ${s.inbound.accessKeyMasked})`;
  68. }
  69. $('mock-state').textContent = s.mockApi.running
  70. ? `运行中 :${s.mockApi.port}(${s.mockApi.authMode})` : '未运行';
  71. $('mock-state').className = 'state ' + (s.mockApi.running ? 'ok' : 'warn');
  72. show('security-output', s);
  73. const counts = s.objectCounts;
  74. $('run-object-info').dataset.counts = JSON.stringify(counts);
  75. } catch (ex) {
  76. $('env-banner').className = 'banner err';
  77. $('env-banner').textContent = '❌ 控制服务不可用:' + ex.message;
  78. }
  79. }
  80. /* ---------------- 对象测试页 ---------------- */
  81. function currentRunId() {
  82. return $('run-id').value.trim() || null;
  83. }
  84. function refreshChannelState() {
  85. const obj = findObj($('run-object').value);
  86. const channel = $('run-channel').value;
  87. const spec = channelSpec(obj, channel);
  88. const el = $('run-channel-state');
  89. if (!obj) { el.textContent = ''; return; }
  90. if (!spec || !spec.supported) {
  91. el.className = 'state err';
  92. el.textContent = `代码层 UNSUPPORTED:${(spec && spec.reason) || '该通道不支持'}`;
  93. $('btn-exec').disabled = true;
  94. } else {
  95. el.className = 'state ok';
  96. const extra = spec.entityCode ? `(entityCode=${spec.entityCode},契约 ${spec.contractVersion || 'v1'})`
  97. : spec.table ? `(源表 ${spec.table})` : spec.path ? `(路径 ${spec.path})` : '';
  98. el.textContent = `代码层 SUPPORTED${extra};配置层需预检实测`;
  99. $('btn-exec').disabled = false;
  100. }
  101. }
  102. async function loadSample() {
  103. try {
  104. const obj = $('run-object').value;
  105. const runId = currentRunId();
  106. const qs = runId ? `?runId=${encodeURIComponent(runId)}` : '';
  107. const data = await api(`/api/sample/${obj}${qs}`);
  108. $('run-id').value = data.runId;
  109. const channel = $('run-channel').value;
  110. let rows = [];
  111. let missing = [];
  112. if (channel === 'DB_SYNC' && data.dbSync) rows = data.dbSync;
  113. else if (channel === 'API_PULL' && data.apiPull) rows = data.apiPull;
  114. else if (channel === 'API_INBOUND' && data.apiInbound) {
  115. rows = data.apiInbound.rows;
  116. missing = data.apiInbound.missingRequired || [];
  117. }
  118. $('run-rows').value = JSON.stringify(rows, null, 2);
  119. $('run-missing').textContent = missing.length
  120. ? `⚠ 样例缺少契约必填字段:${missing.join('、')}(推数将被部分拒绝,请补全样例)` : '';
  121. } catch (ex) { alert('加载样例失败:' + ex.message); }
  122. }
  123. function parseRows() {
  124. const text = $('run-rows').value.trim();
  125. if (!text) return null;
  126. const rows = JSON.parse(text);
  127. if (!Array.isArray(rows)) throw new Error('样例必须是 JSON 数组');
  128. return rows;
  129. }
  130. async function execRun() {
  131. const obj = findObj($('run-object').value);
  132. const channel = $('run-channel').value;
  133. const spec = channelSpec(obj, channel);
  134. if (!spec || !spec.supported) { alert('该对象不支持此通道(代码层 UNSUPPORTED)'); return; }
  135. try {
  136. const rows = parseRows();
  137. const runId = currentRunId();
  138. let result;
  139. if (channel === 'DB_SYNC') {
  140. await api('/api/db/ensure', { method: 'POST', body: JSON.stringify({ objectCode: obj.objectCode, runId, rows }) });
  141. result = await api('/api/db/load', { method: 'POST', body: JSON.stringify({ objectCode: obj.objectCode, runId, rows }) });
  142. result.hint = '模拟源库已装载;请在 Ai-DOP 配置页登记 SIM_MODE3_DB 来源后,由模块同步入口触发 MdpDbPullExecutor 真实拉取';
  143. } else if (channel === 'API_PULL') {
  144. result = await api('/api/mock-api/control', {
  145. method: 'POST',
  146. body: JSON.stringify({ override_path: spec.path, override_rows: rows || [] }),
  147. });
  148. result = {
  149. ok: true, overridePath: spec.path,
  150. rowCount: (rows || []).length,
  151. hint: '适配样例已挂载到 Mock API;请确认 Mock 已启动、Ai-DOP 已登记 SIM_MODE3_API 来源,由 MdpApiPullExecutor 真实拉取',
  152. layers: { code: 'SUPPORTED', config: 'READY(模拟端)', data: 'NOT_RUN' },
  153. };
  154. } else {
  155. result = await api('/api/inbound/exec', {
  156. method: 'POST',
  157. body: JSON.stringify({ objectCode: obj.objectCode, operation: 'push', case: 'happy', runId, rows }),
  158. });
  159. }
  160. renderRunResult(result);
  161. } catch (ex) {
  162. $('run-layers').innerHTML = '';
  163. setError('run-output', ex);
  164. }
  165. }
  166. function renderRunResult(result) {
  167. const layers = result.layers || {};
  168. $('run-layers').innerHTML = [
  169. `<span class="chip">代码层:${layers.code || '—'}</span>`,
  170. `<span class="chip">配置层:${layers.config || '—'}</span>`,
  171. `<span class="chip">数据层:${layers.data || '—'}</span>`,
  172. ].join(' ');
  173. show('run-output', result);
  174. show('run-sql', (result.followupSql || []).join('\n') || '—');
  175. }
  176. async function precheckRun() {
  177. const obj = findObj($('run-object').value);
  178. const channel = $('run-channel').value;
  179. try {
  180. const base = await api('/api/precheck');
  181. let out = { precheck: base };
  182. if (channel === 'API_INBOUND' && (obj.apiInbound || {}).supported) {
  183. out.entityPrecheck = await api(`/api/precheck/inbound/${obj.apiInbound.entityCode}`);
  184. }
  185. out.aidopConfigSuggestion = await api(`/api/db/aidop-config/${obj.objectCode}`);
  186. show('run-output', out);
  187. const cfg = (out.entityPrecheck || {}).configState;
  188. $('run-layers').innerHTML = [
  189. `<span class="chip">代码层:SUPPORTED</span>`,
  190. `<span class="chip">配置层:${cfg || (base.aidop.reachable ? '待人工核对来源/实体登记' : 'SOURCE_UNREACHABLE')}</span>`,
  191. `<span class="chip">数据层:NOT_RUN</span>`,
  192. ].join(' ');
  193. } catch (ex) { setError('run-output', ex); }
  194. }
  195. /* ---------------- 场景页 ---------------- */
  196. function renderScenarioGraph() {
  197. const sc = (SCENARIOS.scenarios || []).find((s) => s.scenarioId === $('scenario-select').value);
  198. if (!sc) return;
  199. $('scenario-desc').textContent = sc.description;
  200. $('scenario-graph').innerHTML = sc.nodes.map((n) => {
  201. const obj = findObj(n.objectCode);
  202. const spec = channelSpec(obj, n.channel);
  203. const ok = spec && spec.supported;
  204. return `<div class="node ${ok ? 'ok' : 'no'}">
  205. <b>${n.objectCode}</b><br/>
  206. <span>${n.channel}</span><br/>
  207. <small>${ok ? (n.relationKey || '') : '该通道不支持'}</small>
  208. </div>`;
  209. }).join('<span class="arrow">→</span>');
  210. }
  211. async function runScenario() {
  212. try {
  213. const result = await api('/api/scenarios/run', {
  214. method: 'POST',
  215. body: JSON.stringify({ scenarioId: $('scenario-select').value, runId: currentRunId() }),
  216. });
  217. show('scenario-output', result);
  218. } catch (ex) { setError('scenario-output', ex); }
  219. }
  220. /* ---------------- Mock API 页 ---------------- */
  221. async function mockControl(payload) {
  222. try { show('mock-audit', await api('/api/mock-api/control', { method: 'POST', body: JSON.stringify(payload) })); refreshStatus(); }
  223. catch (ex) { setError('mock-audit', ex); }
  224. }
  225. /* ---------------- DB 页 ---------------- */
  226. async function dbAction(path, payload, out) {
  227. try { show(out, await api(path, { method: 'POST', body: JSON.stringify(payload) })); }
  228. catch (ex) { setError(out, ex); }
  229. }
  230. /* ---------------- Inbound 页 ---------------- */
  231. async function precheckAll() {
  232. try { show('inbound-precheck', await api('/api/precheck/inbound')); }
  233. catch (ex) { setError('inbound-precheck', ex); }
  234. }
  235. async function inboundExec() {
  236. const body = {
  237. entityCode: $('inbound-entity').value,
  238. operation: $('inbound-op').value,
  239. case: $('inbound-case').value,
  240. syncBatchId: $('inbound-batch').value.trim() || null,
  241. snapshotId: $('inbound-snapshot').value.trim() || null,
  242. bizDate: $('inbound-date').value.trim() || null,
  243. runId: currentRunId(),
  244. };
  245. try { show('inbound-output', await api('/api/inbound/exec', { method: 'POST', body: JSON.stringify(body) })); }
  246. catch (ex) { setError('inbound-output', ex); }
  247. }
  248. /* ---------------- 初始化 ---------------- */
  249. async function init() {
  250. CATALOG = await api('/api/catalog');
  251. SCENARIOS = await api('/api/scenarios');
  252. fillObjectSelect($('run-object'));
  253. fillObjectSelect($('db-object'), (o) => (o.dbSync || {}).supported);
  254. const inboundSel = $('inbound-entity');
  255. inboundSel.innerHTML = '';
  256. const codes = [...new Set(objects()
  257. .filter((o) => (o.apiInbound || {}).supported)
  258. .map((o) => o.apiInbound.entityCode))].sort();
  259. codes.forEach((c) => {
  260. const opt = document.createElement('option');
  261. opt.value = c; opt.textContent = c;
  262. inboundSel.appendChild(opt);
  263. });
  264. const scSel = $('scenario-select');
  265. (SCENARIOS.scenarios || []).forEach((s) => {
  266. const opt = document.createElement('option');
  267. opt.value = s.scenarioId; opt.textContent = `${s.name}(${s.scenarioId})`;
  268. scSel.appendChild(opt);
  269. });
  270. $('run-object').addEventListener('change', refreshChannelState);
  271. $('run-channel').addEventListener('change', refreshChannelState);
  272. $('btn-load-sample').addEventListener('click', loadSample);
  273. $('btn-exec').addEventListener('click', execRun);
  274. $('btn-precheck').addEventListener('click', precheckRun);
  275. $('scenario-select').addEventListener('change', renderScenarioGraph);
  276. $('btn-scenario-run').addEventListener('click', runScenario);
  277. $('btn-mock-start').addEventListener('click', async () => {
  278. try { show('mock-audit', await api('/api/mock-api/start', { method: 'POST' })); refreshStatus(); }
  279. catch (ex) { setError('mock-audit', ex); }
  280. });
  281. $('btn-mock-stop').addEventListener('click', async () => {
  282. try { show('mock-audit', await api('/api/mock-api/stop', { method: 'POST' })); refreshStatus(); }
  283. catch (ex) { setError('mock-audit', ex); }
  284. });
  285. $('btn-mock-apply').addEventListener('click', () => mockControl({
  286. auth_mode: $('mock-auth').value,
  287. delay_seconds: parseFloat($('mock-delay').value || '0'),
  288. force_status: $('mock-force').value ? parseInt($('mock-force').value, 10) : null,
  289. response_path: $('mock-resp-path').value.trim() || 'data.list',
  290. }));
  291. $('btn-mock-clear-overrides').addEventListener('click', () => mockControl({ clear_overrides: true }));
  292. $('btn-mock-audit').addEventListener('click', async () => {
  293. try { show('mock-audit', await api('/api/mock-api/audit')); } catch (ex) { setError('mock-audit', ex); }
  294. });
  295. const dbObj = () => $('db-object').value;
  296. $('btn-db-ensure').addEventListener('click', () => dbAction('/api/db/ensure', { objectCode: dbObj(), runId: currentRunId() }, 'db-output'));
  297. $('btn-db-load').addEventListener('click', () => dbAction('/api/db/load', { objectCode: dbObj(), runId: currentRunId() }, 'db-output'));
  298. $('btn-db-reset').addEventListener('click', () => dbAction('/api/db/reset', { objectCode: dbObj() }, 'db-output'));
  299. $('btn-db-stats').addEventListener('click', async () => {
  300. try { show('db-output', await api(`/api/db/stats/${dbObj()}`)); } catch (ex) { setError('db-output', ex); }
  301. });
  302. $('btn-db-config').addEventListener('click', async () => {
  303. try { show('db-config-output', await api(`/api/db/aidop-config/${dbObj()}`)); } catch (ex) { setError('db-config-output', ex); }
  304. });
  305. $('btn-precheck-all').addEventListener('click', precheckAll);
  306. $('btn-inbound-exec').addEventListener('click', inboundExec);
  307. $('btn-runs-refresh').addEventListener('click', async () => {
  308. try { show('runs-output', await api('/api/runs')); } catch (ex) { setError('runs-output', ex); }
  309. });
  310. refreshChannelState();
  311. renderScenarioGraph();
  312. await refreshStatus();
  313. setInterval(refreshStatus, 15000);
  314. }
  315. init().catch((ex) => {
  316. $('env-banner').className = 'banner err';
  317. $('env-banner').textContent = '❌ 初始化失败:' + ex.message;
  318. });