/* Ai-DOP 第三方对接模拟器 · 本地控制台脚本
* 安全约束:不使用 localStorage/sessionStorage 保存任何认证信息;Secret 只在服务端。
*/
'use strict';
const $ = (id) => document.getElementById(id);
async function api(path, options = {}) {
const resp = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const text = await resp.text();
let data;
try { data = text ? JSON.parse(text) : {}; } catch { data = text; }
if (!resp.ok) {
const detail = (data && data.detail) ? data.detail : data;
throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail, null, 2));
}
return data;
}
function show(el, data) {
$(el).textContent = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
}
function setError(el, ex) {
$(el).textContent = '❌ ' + (ex && ex.message ? ex.message : String(ex));
}
/* ---------------- Tabs ---------------- */
document.querySelectorAll('nav button').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
btn.classList.add('active');
$(btn.dataset.tab).classList.add('active');
});
});
/* ---------------- 全局状态 ---------------- */
let CATALOG = null;
let SCENARIOS = null;
function objects() { return (CATALOG && CATALOG.objects) || []; }
function findObj(code) { return objects().find((o) => o.objectCode === code); }
function channelSpec(obj, channel) {
if (!obj) return null;
return { DB_SYNC: obj.dbSync, API_PULL: obj.apiPull, API_INBOUND: obj.apiInbound }[channel] || null;
}
function fillObjectSelect(sel, filterFn) {
sel.innerHTML = '';
objects().filter(filterFn || (() => true)).forEach((o) => {
const opt = document.createElement('option');
opt.value = o.objectCode;
opt.textContent = `${o.objectCode} · ${o.businessName}`;
sel.appendChild(opt);
});
}
/* ---------------- 环境状态 ---------------- */
async function refreshStatus() {
try {
const s = await api('/api/status');
const miss = [];
if (!s.inbound.accessKeyLoaded) miss.push('AIDOP_SIM_INBOUND_ACCESS_KEY');
if (!s.inbound.secretLoaded) miss.push('AIDOP_SIM_INBOUND_SECRET');
if (!s.mysql.passwordLoaded) miss.push('AIDOP_SIM_MYSQL_PASSWORD');
const banner = $('env-banner');
if (miss.length) {
banner.className = 'banner warn';
banner.textContent = `⚠ 缺少环境变量:${miss.join('、')}(Secret 只从环境变量读取,页面不提供输入)`;
} else {
banner.className = 'banner ok';
banner.textContent = `✓ 凭据已加载(AccessKey ${s.inbound.accessKeyMasked})`;
}
$('mock-state').textContent = s.mockApi.running
? `运行中 :${s.mockApi.port}(${s.mockApi.authMode})` : '未运行';
$('mock-state').className = 'state ' + (s.mockApi.running ? 'ok' : 'warn');
// 异常注入状态同步回 Mock 页下拉框(fault 页预置 N2 场景后这里应显示对应状态码)
const force = s.mockApi.forceStatus;
$('mock-force').value = force ? String(force) : '';
show('security-output', s);
const counts = s.objectCounts;
$('run-object-info').dataset.counts = JSON.stringify(counts);
} catch (ex) {
$('env-banner').className = 'banner err';
$('env-banner').textContent = '❌ 控制服务不可用:' + ex.message;
}
}
/* ---------------- 对象测试页 ---------------- */
function currentRunId() {
return $('run-id').value.trim() || null;
}
function refreshChannelState() {
const obj = findObj($('run-object').value);
const channel = $('run-channel').value;
const spec = channelSpec(obj, channel);
const el = $('run-channel-state');
if (!obj) { el.textContent = ''; return; }
if (!spec || !spec.supported) {
el.className = 'state err';
el.textContent = `代码层 UNSUPPORTED:${(spec && spec.reason) || '该通道不支持'}`;
$('btn-exec').disabled = true;
} else {
el.className = 'state ok';
const extra = spec.entityCode ? `(entityCode=${spec.entityCode},契约 ${spec.contractVersion || 'v1'})`
: spec.table ? `(源表 ${spec.table})` : spec.path ? `(路径 ${spec.path})` : '';
el.textContent = `代码层 SUPPORTED${extra};配置层需预检实测`;
$('btn-exec').disabled = false;
}
}
async function loadSample() {
try {
const obj = $('run-object').value;
const runId = currentRunId();
const qs = runId ? `?runId=${encodeURIComponent(runId)}` : '';
const data = await api(`/api/sample/${obj}${qs}`);
$('run-id').value = data.runId;
const channel = $('run-channel').value;
let rows = [];
let missing = [];
if (channel === 'DB_SYNC' && data.dbSync) rows = data.dbSync;
else if (channel === 'API_PULL' && data.apiPull) rows = data.apiPull;
else if (channel === 'API_INBOUND' && data.apiInbound) {
rows = data.apiInbound.rows;
missing = data.apiInbound.missingRequired || [];
}
$('run-rows').value = JSON.stringify(rows, null, 2);
$('run-missing').textContent = missing.length
? `⚠ 样例缺少契约必填字段:${missing.join('、')}(推数将被部分拒绝,请补全样例)` : '';
} catch (ex) { alert('加载样例失败:' + ex.message); }
}
function parseRows() {
const text = $('run-rows').value.trim();
if (!text) return null;
const rows = JSON.parse(text);
if (!Array.isArray(rows)) throw new Error('样例必须是 JSON 数组');
return rows;
}
async function execRun() {
const obj = findObj($('run-object').value);
const channel = $('run-channel').value;
const spec = channelSpec(obj, channel);
if (!spec || !spec.supported) { alert('该对象不支持此通道(代码层 UNSUPPORTED)'); return; }
try {
const rows = parseRows();
const runId = currentRunId();
let result;
if (channel === 'DB_SYNC') {
await api('/api/db/ensure', { method: 'POST', body: JSON.stringify({ objectCode: obj.objectCode, runId, rows }) });
result = await api('/api/db/load', { method: 'POST', body: JSON.stringify({ objectCode: obj.objectCode, runId, rows }) });
result.hint = '模拟源库已装载;请在 Ai-DOP 配置页登记 SIM_MODE3_DB 来源后,由模块同步入口触发 MdpDbPullExecutor 真实拉取';
} else if (channel === 'API_PULL') {
result = await api('/api/mock-api/control', {
method: 'POST',
body: JSON.stringify({ override_path: spec.path, override_rows: rows || [] }),
});
result = {
ok: true, overridePath: spec.path,
rowCount: (rows || []).length,
hint: '适配样例已挂载到 Mock API;请确认 Mock 已启动、Ai-DOP 已登记 SIM_MODE3_API 来源,由 MdpApiPullExecutor 真实拉取',
layers: { code: 'SUPPORTED', config: 'READY(模拟端)', data: 'NOT_RUN' },
};
} else {
result = await api('/api/inbound/exec', {
method: 'POST',
body: JSON.stringify({ objectCode: obj.objectCode, operation: 'push', case: 'happy', runId, rows }),
});
}
renderRunResult(result);
} catch (ex) {
$('run-layers').innerHTML = '';
setError('run-output', ex);
}
}
function renderRunResult(result) {
const layers = result.layers || {};
$('run-layers').innerHTML = [
`代码层:${layers.code || '—'}`,
`配置层:${layers.config || '—'}`,
`数据层:${layers.data || '—'}`,
].join(' ');
show('run-output', result);
show('run-sql', (result.followupSql || []).join('\n') || '—');
}
async function precheckRun() {
const obj = findObj($('run-object').value);
const channel = $('run-channel').value;
try {
const base = await api('/api/precheck');
let out = { precheck: base };
if (channel === 'API_INBOUND' && (obj.apiInbound || {}).supported) {
out.entityPrecheck = await api(`/api/precheck/inbound/${obj.apiInbound.entityCode}`);
}
out.aidopConfigSuggestion = await api(`/api/db/aidop-config/${obj.objectCode}`);
show('run-output', out);
const cfg = (out.entityPrecheck || {}).configState;
$('run-layers').innerHTML = [
`代码层:SUPPORTED`,
`配置层:${cfg || (base.aidop.reachable ? '待人工核对来源/实体登记' : 'SOURCE_UNREACHABLE')}`,
`数据层:NOT_RUN`,
].join(' ');
} catch (ex) { setError('run-output', ex); }
}
/* ---------------- 场景页 ---------------- */
function renderScenarioGraph() {
const sc = (SCENARIOS.scenarios || []).find((s) => s.scenarioId === $('scenario-select').value);
if (!sc) return;
$('scenario-desc').textContent = sc.description;
$('scenario-graph').innerHTML = sc.nodes.map((n) => {
const obj = findObj(n.objectCode);
const spec = channelSpec(obj, n.channel);
const ok = spec && spec.supported;
return `
${n.objectCode}
${n.channel}
${ok ? (n.relationKey || '') : '该通道不支持'}
`;
}).join('→');
}
async function runScenario() {
try {
const result = await api('/api/scenarios/run', {
method: 'POST',
body: JSON.stringify({ scenarioId: $('scenario-select').value, runId: currentRunId() }),
});
show('scenario-output', result);
} catch (ex) { setError('scenario-output', ex); }
}
/* ---------------- Mock API 页 ---------------- */
async function mockControl(payload) {
try { show('mock-audit', await api('/api/mock-api/control', { method: 'POST', body: JSON.stringify(payload) })); refreshStatus(); }
catch (ex) { setError('mock-audit', ex); }
}
/* ---------------- DB 页 ---------------- */
async function dbAction(path, payload, out) {
try { show(out, await api(path, { method: 'POST', body: JSON.stringify(payload) })); }
catch (ex) { setError(out, ex); }
}
/* ---------------- Inbound 页 ---------------- */
async function precheckAll() {
try { show('inbound-precheck', await api('/api/precheck/inbound')); }
catch (ex) { setError('inbound-precheck', ex); }
}
async function inboundExec() {
const body = {
entityCode: $('inbound-entity').value,
operation: $('inbound-op').value,
case: $('inbound-case').value,
syncBatchId: $('inbound-batch').value.trim() || null,
snapshotId: $('inbound-snapshot').value.trim() || null,
bizDate: $('inbound-date').value.trim() || null,
runId: currentRunId(),
};
try { show('inbound-output', await api('/api/inbound/exec', { method: 'POST', body: JSON.stringify(body) })); }
catch (ex) { setError('inbound-output', ex); }
}
/* ---------------- 异常测试(第二阶段 N1-N4) ---------------- */
let FAULT_CATALOG = null;
let LAST_FAULT_REPORT_ID = null;
const FAULT_GROUP_TITLES = {
DB_SYNC: '方式甲 DB_SYNC(数据库同步)· 10 场景',
API_PULL: '方式乙 API_PULL(接口拉取)· 16 场景',
API_INBOUND: '方式丙 API_INBOUND(接口推送)· 17 场景',
};
function faultScenarios() { return (FAULT_CATALOG && FAULT_CATALOG.scenarios) || []; }
function esc(s) {
return String(s).replace(/&/g, '&').replace(//g, '>');
}
async function refreshFaultCatalog() {
try {
FAULT_CATALOG = await api('/api/fault/catalog');
renderFaultGroups();
fillFaultReportScenario();
} catch (ex) { setError('fault-groups', ex); }
}
function renderFaultGroups() {
const container = $('fault-groups');
const byChannel = { DB_SYNC: [], API_PULL: [], API_INBOUND: [] };
faultScenarios().forEach((s) => (byChannel[s.channel] || []).push(s));
container.innerHTML = ['DB_SYNC', 'API_PULL', 'API_INBOUND'].map((ch) => {
const items = (byChannel[ch] || []).map((s) => `
${s.id} ${esc(s.title)}
${s.kind === 'auto' ? 'auto · 一键预置' : 'guide · 人工操作'}
模拟方法:${esc(s.sim)}
预期观察:${esc(s.expect)}
恢复:${esc(s.recover)}
`).join('');
return `${FAULT_GROUP_TITLES[ch]}
${items}`;
}).join('');
container.querySelectorAll('.fault-item').forEach((item) => {
const id = item.dataset.id;
item.querySelector('button[data-act="apply"]').addEventListener('click', async () => {
const out = item.querySelector('.fault-result');
out.textContent = '预置中…';
try {
const r = await api('/api/fault/apply', { method: 'POST', body: JSON.stringify({ scenarioId: id }) });
const note = r.note || (r.result ? JSON.stringify(r.result).slice(0, 120) : '');
out.textContent = '✓ ' + (r.applied ? '已预置' : '请按指引操作') + (note ? ' · ' + note : '');
out.className = 'fault-result ok';
} catch (ex) {
out.textContent = '❌ ' + ex.message;
out.className = 'fault-result err';
}
});
item.querySelector('button[data-act="recover"]').addEventListener('click', async () => {
const out = item.querySelector('.fault-result');
const scenario = faultScenarios().find((s) => s.id === id);
out.textContent = '恢复中…';
try {
const r = await api('/api/fault/recover', {
method: 'POST', body: JSON.stringify({ channel: scenario ? scenario.channel : undefined }),
});
out.textContent = '✓ 已恢复:' + (r.notes || []).join(';');
out.className = 'fault-result ok';
} catch (ex) {
out.textContent = '❌ ' + ex.message;
out.className = 'fault-result err';
}
});
});
}
function fillFaultReportScenario() {
const sel = $('fault-report-scenario');
sel.innerHTML = '';
faultScenarios().forEach((s) => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = `${s.id} · ${s.title}`;
sel.appendChild(opt);
});
syncFaultReportPrefill();
}
function syncFaultReportPrefill() {
const s = faultScenarios().find((x) => x.id === $('fault-report-scenario').value);
if (!s) return;
$('fault-report-sim').value = s.sim;
$('fault-report-expect').value = s.expect;
}
async function refreshFaultReportList() {
try { show('fault-report-list', await api('/api/fault-report/list')); }
catch (ex) { setError('fault-report-list', ex); }
}
async function saveFaultReport() {
const btn = $('btn-fault-report-save');
if (btn) btn.disabled = true; // 防重复点击(同场景二次保存也会被后端原位更新)
const fields = {
scenarioId: $('fault-report-scenario').value,
simBehavior: $('fault-report-sim').value.trim(),
expected: $('fault-report-expect').value.trim(),
actualResponse: $('fault-report-actual').value.trim(),
aidopFlowStatus: $('fault-report-flow').value.trim(),
targetChanged: $('fault-report-target').value.trim(),
pass: $('fault-report-pass').value,
note: $('fault-report-note').value.trim(),
};
try {
const r = await api('/api/fault-report/save', { method: 'POST', body: JSON.stringify(fields) });
LAST_FAULT_REPORT_ID = r.report.id;
await refreshFaultReportList();
alert(r.report.replaced
? `已更新场景 ${r.report.scenarioId} 的报告(同场景覆盖,不产生重复行)`
: `已保存异常报告 ${r.report.id}(保存前已脱敏)`);
} catch (ex) { setError('fault-report-list', ex); }
finally { if (btn) btn.disabled = false; }
}
function exportFaultReport(id) {
if (!id) { alert('请先「保存报告」,再导出 Markdown'); return; }
const a = document.createElement('a');
a.href = '/api/fault-report/export/' + encodeURIComponent(id);
a.download = `aidop-sim-fault-report-${id}.md`;
document.body.appendChild(a);
a.click();
a.remove();
}
async function recoverAllFaults() {
try {
const r = await api('/api/fault/recover', { method: 'POST', body: JSON.stringify({}) });
alert('已恢复:\n' + (r.notes || []).join('\n'));
refreshStatus();
} catch (ex) { alert('恢复失败:' + ex.message); }
}
/* ---------------- 全链路报告(F5) ---------------- */
let LAST_REPORT_ID = null;
function reportFields() {
return {
runId: $('report-runid').value.trim(),
channel: $('report-channel').value,
environment: $('report-env').value.trim(),
objectCode: $('report-object').value.trim(),
simPrep: $('report-simprep').value.trim(),
manualSteps: $('report-steps').value.trim(),
simEvidence: $('report-simevidence').value.trim(),
aidopConn: $('report-conn').value.trim(),
flowIds: $('report-flowids').value.trim(),
stagingCheck: $('report-staging').value.trim(),
targetResult: $('report-target').value.trim(),
conclusion: $('report-conclusion').value.trim(),
gaps: $('report-gaps').value.trim(),
cleanup: $('report-cleanup').value.trim(),
};
}
async function refreshReportList() {
try {
const data = await api('/api/report/list');
show('report-list-output', data);
return data.reports || [];
} catch (ex) { setError('report-list-output', ex); return []; }
}
async function saveReport() {
const fields = reportFields();
if (!fields.runId || !fields.channel) { alert('请先填写运行编号 runId'); return; }
try {
const r = await api('/api/report/save', { method: 'POST', body: JSON.stringify(fields) });
LAST_REPORT_ID = r.report.id;
$('report-runid').value = r.report.runId || fields.runId;
await refreshReportList();
alert(`已保存报告 ${r.report.id}(保存前已脱敏;不保存密码、Secret、JWT、连接串)`);
} catch (ex) { setError('report-list-output', ex); }
}
function exportReport(id) {
if (!id) { alert('请先「保存报告」,再导出 Markdown'); return; }
const a = document.createElement('a');
a.href = '/api/report/export/' + encodeURIComponent(id);
a.download = `aidop-sim-report-${id}.md`;
document.body.appendChild(a);
a.click();
a.remove();
}
async function importReportEvidence() {
const channel = $('report-channel').value;
try {
const data = await api('/api/report/evidence/' + channel);
const entries = data.entries || [];
const lines = entries.length
? entries.map((e) => `[${e.time}] ${e.objectCode} · ${e.channel} · runId=${e.runId}\n`
+ JSON.stringify({ request: e.request, response: e.response, layers: e.layers }, null, 2))
.join('\n\n')
: `(${channel} 暂无运行记录;先在对应页签执行一次操作)`;
$('report-simevidence').value = lines;
} catch (ex) { alert('导入失败:' + ex.message); }
}
/* ---------------- 初始化 ---------------- */
async function init() {
CATALOG = await api('/api/catalog');
SCENARIOS = await api('/api/scenarios');
fillObjectSelect($('run-object'));
fillObjectSelect($('db-object'), (o) => (o.dbSync || {}).supported);
const inboundSel = $('inbound-entity');
inboundSel.innerHTML = '';
const codes = [...new Set(objects()
.filter((o) => (o.apiInbound || {}).supported)
.map((o) => o.apiInbound.entityCode))].sort();
codes.forEach((c) => {
const opt = document.createElement('option');
opt.value = c; opt.textContent = c;
inboundSel.appendChild(opt);
});
const scSel = $('scenario-select');
(SCENARIOS.scenarios || []).forEach((s) => {
const opt = document.createElement('option');
opt.value = s.scenarioId; opt.textContent = `${s.name}(${s.scenarioId})`;
scSel.appendChild(opt);
});
$('run-object').addEventListener('change', refreshChannelState);
$('run-channel').addEventListener('change', refreshChannelState);
$('btn-load-sample').addEventListener('click', loadSample);
$('btn-exec').addEventListener('click', execRun);
$('btn-precheck').addEventListener('click', precheckRun);
$('scenario-select').addEventListener('change', renderScenarioGraph);
$('btn-scenario-run').addEventListener('click', runScenario);
$('btn-mock-start').addEventListener('click', async () => {
try { show('mock-audit', await api('/api/mock-api/start', { method: 'POST' })); refreshStatus(); }
catch (ex) { setError('mock-audit', ex); }
});
$('btn-mock-stop').addEventListener('click', async () => {
try { show('mock-audit', await api('/api/mock-api/stop', { method: 'POST' })); refreshStatus(); }
catch (ex) { setError('mock-audit', ex); }
});
$('btn-mock-apply').addEventListener('click', () => mockControl({
auth_mode: $('mock-auth').value,
delay_seconds: parseFloat($('mock-delay').value || '0'),
force_status: $('mock-force').value ? parseInt($('mock-force').value, 10) : null,
response_path: $('mock-resp-path').value.trim() || 'data.list',
}));
$('btn-mock-clear-overrides').addEventListener('click', () => mockControl({ clear_overrides: true }));
$('btn-mock-audit').addEventListener('click', async () => {
try { show('mock-audit', await api('/api/mock-api/audit')); } catch (ex) { setError('mock-audit', ex); }
});
const dbObj = () => $('db-object').value;
$('btn-db-ensure').addEventListener('click', () => dbAction('/api/db/ensure', { objectCode: dbObj(), runId: currentRunId() }, 'db-output'));
$('btn-db-load').addEventListener('click', () => dbAction('/api/db/load', { objectCode: dbObj(), runId: currentRunId() }, 'db-output'));
$('btn-db-reset').addEventListener('click', () => dbAction('/api/db/reset', { objectCode: dbObj() }, 'db-output'));
$('btn-db-stats').addEventListener('click', async () => {
try { show('db-output', await api(`/api/db/stats/${dbObj()}`)); } catch (ex) { setError('db-output', ex); }
});
$('btn-db-config').addEventListener('click', async () => {
try { show('db-config-output', await api(`/api/db/aidop-config/${dbObj()}`)); } catch (ex) { setError('db-config-output', ex); }
});
$('btn-precheck-all').addEventListener('click', precheckAll);
$('btn-inbound-exec').addEventListener('click', inboundExec);
$('btn-runs-refresh').addEventListener('click', async () => {
try { show('runs-output', await api('/api/runs')); } catch (ex) { setError('runs-output', ex); }
});
$('btn-report-save').addEventListener('click', saveReport);
$('btn-report-export').addEventListener('click', () => exportReport(LAST_REPORT_ID));
$('btn-report-refresh').addEventListener('click', refreshReportList);
$('btn-report-evidence').addEventListener('click', importReportEvidence);
refreshReportList();
$('btn-fault-refresh').addEventListener('click', refreshFaultCatalog);
$('btn-fault-recover-all').addEventListener('click', recoverAllFaults);
$('fault-report-scenario').addEventListener('change', syncFaultReportPrefill);
$('btn-fault-report-save').addEventListener('click', saveFaultReport);
$('btn-fault-report-export').addEventListener('click', () => exportFaultReport(LAST_FAULT_REPORT_ID));
$('btn-fault-report-refresh').addEventListener('click', refreshFaultReportList);
refreshFaultCatalog();
// ---- 压力测试(第三阶段)----
$('load-window').value = defaultTimeWindow(); // 先填默认值,不依赖后续绑定成败
fillLoadObjectSelect(null);
$('load-object').addEventListener('change', syncLoadEntity);
$('load-channel').addEventListener('change', renderLoadParams);
const probeBtn = $('btn-entity-probe');
if (probeBtn) probeBtn.addEventListener('click', () => fillEntitySelect(true));
$('btn-load-plan').addEventListener('click', submitLoadPlan);
$('btn-load-start').addEventListener('click', startLoad);
$('btn-load-stop').addEventListener('click', stopLoad);
$('btn-load-report-save').addEventListener('click', saveLoadReport);
$('btn-load-report-export').addEventListener('click', () => exportLoadReport(LAST_LOAD_REPORT_ID));
$('btn-load-report-refresh').addEventListener('click', refreshLoadReportList);
$('btn-load-checklist').addEventListener('click', renderLoadChecklist);
renderLoadParams();
loadLimits();
setInterval(pollLoadStatus, 2000);
refreshChannelState();
renderScenarioGraph();
await refreshStatus();
setInterval(refreshStatus, 15000);
}
init().catch((ex) => {
$('env-banner').className = 'banner err';
$('env-banner').textContent = '❌ 初始化失败:' + ex.message;
});
/* ================= 压力测试(第三阶段 §16-§21) ================= */
let LAST_LOAD_REPORT_ID = null;
let LOAD_LIMITS = null;
function loadChannel() { return $('load-channel').value; }
async function loadLimits() {
try {
const d = await api('/api/load/limits');
LOAD_LIMITS = d;
const parts = Object.entries(d.defaultLimits)
.map(([k, v]) => `${k}≤${v}(硬限 ${d.hardLimits[k]})`);
$('load-limits').textContent = `保守上限:${parts.join(';')}。超出须勾选「提高上限二次确认」,越过硬限不允许。档位:S=${d.sizes.S} M=${d.sizes.M} L=${d.sizes.L}(XL 须审批自定义)`;
} catch (ex) { setError('load-limits', ex); }
}
function defaultTimeWindow(minutes = 15) {
// 预填「当前时间起 N 分钟」窗口(YYYY-MM-DD HH:MM-HH:MM),用户可改
const fmt = (d) => {
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
};
const start = new Date();
const end = new Date(start.getTime() + minutes * 60000);
return `${fmt(start)}-${fmt(end)}`;
}
function entityOptions(ch) {
// 按通道生成实体下拉选项:方式丙用入站实体码;方式甲/乙用对象对应的 SIM 实体码
if (ch === 'API_INBOUND') {
return objects().filter((o) => o.apiInbound && o.apiInbound.supported)
.map((o) => ({ value: o.apiInbound.entityCode, label: `${o.apiInbound.entityCode} · ${o.businessName}` }));
}
if (ch === 'DB_SYNC') {
return objects().filter((o) => o.dbSync && o.dbSync.supported)
.map((o) => ({ value: `SIM_${o.objectCode}_DB`, label: `SIM_${o.objectCode}_DB · ${o.businessName}` }));
}
if (ch === 'API_PULL') {
return objects().filter((o) => o.apiPull && o.apiPull.supported)
.map((o) => ({ value: `SIM_${o.objectCode}_API`, label: `SIM_${o.objectCode}_API · ${o.businessName}` }));
}
return [];
}
function fillLoadObjectSelect(authorized) {
// 对象下拉按当前通道过滤:方式丙只留「已授权入站对象」,保证选任何对象都能带出实体
const ch = loadChannel();
const sel = $('load-object');
const prev = sel.value;
const list = ch === 'API_INBOUND'
? objects().filter((o) => (o.apiInbound || {}).supported &&
(!authorized || authorized.has(o.apiInbound.entityCode)))
: objects();
sel.innerHTML = '';
list.forEach((o) => {
const opt = document.createElement('option');
opt.value = o.objectCode;
opt.textContent = `${o.objectCode} · ${o.businessName}`;
sel.appendChild(opt);
});
if (list.length && !Array.from(sel.options).some((o) => o.value === prev)) {
sel.value = list[0].objectCode;
}
}
let ENTITY_FILL_TOKEN = 0;
async function fillEntitySelect(force = false) {
const token = ++ENTITY_FILL_TOKEN;
const ch = loadChannel();
const sel = $('load-entity');
const opts = entityOptions(ch);
if (ch === 'API_INBOUND') {
// 方式丙:探测 Ai-DOP 侧授权状态(后端代理,secret 不出后端),只展示已授权实体
sel.disabled = true;
sel.innerHTML = '';
let authorized = null;
try {
const d = await api(`/api/inbound/auth-probe${force ? '?refresh=1' : ''}`);
if (token !== ENTITY_FILL_TOKEN) return; // 已有更新的填充请求,放弃本次写 UI
authorized = new Set(Object.keys(d.entities || {}).filter((e) => d.entities[e]));
} catch (ex) {
setError('load-limits', ex);
}
if (authorized) {
const ok = opts.filter((o) => authorized.has(o.value));
sel.innerHTML = ok.length
? '' +
ok.map((o) => ``).join('')
: '';
fillLoadObjectSelect(authorized);
} else {
// 探测失败:回退全量选项,保证表单可用
sel.innerHTML = '' +
opts.map((o) => ``).join('');
fillLoadObjectSelect(null);
}
sel.disabled = false;
} else {
sel.innerHTML = '' +
opts.map((o) => ``).join('');
sel.disabled = true;
fillLoadObjectSelect(null);
}
syncLoadEntity();
}
function syncLoadEntity() {
// 对象/通道切换后默认选中该对象对应的实体码(方式甲/乙为 SIM 实体码,仅展示)
const ch = loadChannel();
const obj = findObj($('load-object').value);
const val = obj ? {
API_INBOUND: (obj.apiInbound || {}).entityCode,
DB_SYNC: `SIM_${obj.objectCode}_DB`,
API_PULL: `SIM_${obj.objectCode}_API`,
}[ch] : '';
if (val) {
const sel = $('load-entity');
sel.value = Array.from(sel.options).some((o) => o.value === val) ? val : '';
}
}
function renderLoadParams() {
fillEntitySelect();
const ch = loadChannel();
const box = $('load-params');
const field = (label, inner) => ``;
if (ch === 'API_INBOUND') {
box.innerHTML = [
field('模式', ``),
field('批大小(bulk 每请求行数)', ``),
field('预热时间(秒)', ``),
field('样例模式', ``),
field('错误数据比例(0-1)', ``),
field('幂等重复', ``),
field('超时(秒)', ``),
field('自动停止阈值(连续错误/超时/429)',
` / ` +
` / ` +
``),
field('阶梯升压(可选 JSON)', ``),
].join('');
} else if (ch === 'API_PULL') {
box.innerHTML = [
field('每次返回行数(0=全部)', ``),
field('响应数据总量上限(0=不截断)', ``),
field('随机延迟上限(秒)', ``),
field('错误比例(0-1,间歇 5xx)', ``),
field('限流阈值(每秒最大请求数,0=不限)', ``),
].join('');
} else {
box.innerHTML = [
field('数据规模档位', ``),
field('增量比例(新业务键占比 0-1)', ``),
field('字段宽度(0=样例原样)', ``),
].join('');
const sizes = (LOAD_LIMITS && LOAD_LIMITS.sizes) || { S: 100, M: 10000, L: 100000 };
$('loadp-size').addEventListener('change', () => {
const v = $('loadp-size').value;
if (sizes[v] !== undefined) $('load-rows').value = sizes[v];
});
}
}
function loadPlanPayload() {
const ch = loadChannel();
const num = (id, dft = 0) => {
const v = parseFloat($(id).value);
return Number.isFinite(v) ? v : dft;
};
const plan = {
channel: ch,
objectCode: $('load-object').value,
entityCode: ch === 'API_INBOUND' ? $('load-entity').value.trim() : '',
environment: $('load-env').value.trim(),
tenant: $('load-tenant').value.trim(),
owner: $('load-owner').value.trim(),
timeWindow: $('load-window').value.trim(),
maxDataRows: num('load-rows'),
maxConcurrency: num('load-conc'),
maxQps: num('load-qps'),
maxDurationSeconds: num('load-dur'),
stopThreshold: $('load-stopth').value.trim(),
cleanupPlan: $('load-cleanup').value.trim(),
confirmed: $('load-confirmed').checked,
escalationConfirm: $('load-escalation').checked,
};
if (ch === 'API_INBOUND') {
Object.assign(plan, {
mode: $('loadp-mode').value,
batchSize: num('loadp-batch', 1),
warmupSeconds: num('loadp-warm'),
sampleMode: $('loadp-sample').value,
errorRate: num('loadp-error'),
idemReplay: $('loadp-idem').value === 'true',
timeoutSeconds: num('loadp-timeout', 30),
maxConsecutiveErrors: num('loadp-cerr', 5),
maxTimeouts: num('loadp-tout', 5),
maxRateLimited: num('loadp-r429', 5),
});
const ramp = $('loadp-ramp').value.trim();
if (ramp) {
try { plan.rampSteps = JSON.parse(ramp); } catch { plan.rampSteps = null; }
}
} else if (ch === 'API_PULL') {
Object.assign(plan, {
pageSize: num('loadp-page'),
totalRowsLimit: num('loadp-total'),
randomDelay: num('loadp-rdelay'),
errorRate: num('loadp-error'),
rateLimit: num('loadp-rlimit'),
});
} else {
Object.assign(plan, {
size: $('loadp-size').value,
incrementRatio: num('loadp-ratio', 1),
fieldWidth: num('loadp-width'),
});
}
return plan;
}
async function submitLoadPlan() {
try {
const d = await api('/api/load/plan', { method: 'POST', body: JSON.stringify(loadPlanPayload()) });
show('load-status', { message: '计划已通过校验(ready),点击「开始压测」显式开启', ...d });
} catch (ex) { setError('load-status', ex); }
}
async function startLoad() {
const btn = $('btn-load-start');
btn.disabled = true;
try {
const plan = loadPlanPayload();
const d = await api('/api/load/start', { method: 'POST', body: JSON.stringify(plan) });
show('load-status', d);
await pollLoadStatus();
} catch (ex) { setError('load-status', ex); } finally { btn.disabled = false; }
}
async function stopLoad() {
try {
const d = await api('/api/load/stop', { method: 'POST', body: JSON.stringify({ reason: 'manual' }) });
show('load-status', d);
await pollLoadStatus();
} catch (ex) { setError('load-status', ex); }
}
async function pollLoadStatus() {
// 只在压力页签激活且存在运行态时轮询,避免无谓请求
if (!$('tab-load').classList.contains('active')) return;
try {
const d = await api('/api/load/status');
const { guard, metrics, p2, p1 } = d;
const brief = {
状态: guard.state,
runId: guard.runId || '—',
停止原因: guard.stopReason || '—',
无残留: guard.noResidual,
请求数: metrics.totalRequests,
数据行: metrics.totalRows,
成功: metrics.success, 失败: metrics.failed,
成功率: metrics.successRate,
实际QPS: metrics.actualQps,
'P50/P95/P99(ms)': `${metrics.p50Ms}/${metrics.p95Ms}/${metrics.p99Ms}`,
超时: metrics.timeouts, 限流: metrics.rateLimited,
HTTP错误: metrics.httpErrors,
};
if (p2) Object.assign(brief, { 'P2请求/并发峰值': `${p2.stats.requests}/${p2.stats.peakConcurrency}`, P2限流: p2.stats.rateLimited });
if (p1 && p1.result) Object.assign(brief, { P1装载: `${p1.result.loaded}/${p1.result.requested}`, 耗时秒: p1.result.elapsedSeconds, 被停止: p1.result.stopped });
if (p1 && p1.error) Object.assign(brief, { P1错误: p1.error });
$('load-status').textContent = JSON.stringify(brief, null, 2);
} catch { /* 轮询失败静默,下次重试 */ }
}
async function saveLoadReport() {
const btn = $('btn-load-report-save');
btn.disabled = true;
try {
let runId = '';
try {
const s = await api('/api/load/status');
runId = (s.guard && s.guard.runId) || '';
} catch { /* 状态不可得时不阻塞报告保存 */ }
const d = await api('/api/load/report', {
method: 'POST',
body: JSON.stringify({
runId,
channel: loadChannel(),
aidopResource: $('load-report-res').value.trim(),
aidopFlow: $('load-report-flow').value.trim(),
conclusion: $('load-report-concl').value.trim(),
notes: $('load-report-notes').value.trim(),
}),
});
LAST_LOAD_REPORT_ID = d.report.id;
show('load-report-list', { message: `已保存压力报告 ${d.report.id}(保存前已脱敏)`, report: d.report });
} catch (ex) { setError('load-report-list', ex); } finally { btn.disabled = false; }
}
async function exportLoadReport(id) {
if (!id) { setError('load-report-list', '先保存报告再导出'); return; }
window.open(`/api/load/report/export/${id}`, '_blank');
}
async function refreshLoadReportList() {
try { show('load-report-list', await api('/api/load/report/list')); } catch (ex) { setError('load-report-list', ex); }
}
async function renderLoadChecklist() {
try { show('load-checklist', await api(`/api/load/aidop-checklist/${loadChannel()}`)); }
catch (ex) { setError('load-checklist', ex); }
}