You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

699 lines
46 KiB

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>A7 重接口补齐 E2E Demo(a7-heavy · 同步任务 / 全局日志 / 公海绑定数)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font: 14px/1.6 "Microsoft YaHei", system-ui, sans-serif; color: #333; background: #f5f6fa; }
header { display: flex; align-items: center; gap: 14px; padding: 10px 20px; background: #1f2d3d; color: #fff; flex-wrap: wrap; }
header h1 { font-size: 17px; font-weight: 600; }
header select, header button, header input { padding: 4px 10px; border-radius: 4px; border: 1px solid #45566b; background: #2c3e50; color: #fff; }
header button.primary { background: #409eff; border-color: #409eff; cursor: pointer; }
header .who { color: #9fb3c8; }
nav.tabs { display: flex; gap: 2px; padding: 0 16px; background: #fff; border-bottom: 1px solid #e4e7ed; flex-wrap: wrap; }
nav.tabs button { padding: 10px 13px; border: none; background: none; font-size: 14px; cursor: pointer; color: #666; border-bottom: 2px solid transparent; }
nav.tabs button.on { color: #409eff; border-bottom-color: #409eff; font-weight: 600; }
main { padding: 16px 20px; }
.panel { background: #fff; border: 1px solid #e4e7ed; border-radius: 6px; padding: 14px; margin-bottom: 14px; }
.viewbar { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
.viewbar .sp { flex: 1; }
input, select, textarea { padding: 5px 8px; border: 1px solid #dcdfe6; border-radius: 4px; font: inherit; }
button.act { padding: 5px 12px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; }
button.act.primary { background: #409eff; color: #fff; border-color: #409eff; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #ebeef5; white-space: nowrap; }
th { color: #909399; font-weight: 500; font-size: 13px; background: #fafafa; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; margin-right: 4px; }
.b1 { background: #ecf0f5; color: #606266; } .b2 { background: #e8f7e8; color: #2fa52f; }
.b3 { background: #fdf0e0; color: #d9820c; } .b4 { background: #fdeaea; color: #d84a4a; }
.b5 { background: #efe7fb; color: #8a5bd6; }
.empty { color: #999; text-align: center; padding: 24px 0; }
#toast { position: fixed; top: 14px; right: 14px; z-index: 99; display: flex; flex-direction: column; gap: 8px; max-width: 520px; }
.toast-item { padding: 10px 14px; border-radius: 6px; color: #fff; font-size: 13px; word-break: break-all; }
.toast-ok { background: #52c41a; } .toast-err { background: #d84a4a; } .toast-info { background: #3b7dbd; }
.errbox { background: #fff4f4; border: 1px solid #f3c1c1; color: #b03030; padding: 8px 12px; border-radius: 4px; font-size: 12px; white-space: pre-wrap; }
.okbox { background: #f4fff4; border: 1px solid #bfe3bf; color: #2a7a2a; padding: 8px 12px; border-radius: 4px; font-size: 12px; white-space: pre-wrap; }
.infobox { background: #f4f8ff; border: 1px solid #bfd3f3; color: #2a5a8a; padding: 8px 12px; border-radius: 4px; font-size: 12px; white-space: pre-wrap; }
.hint { color: #999; font-size: 12px; }
/* 联测步骤面板(沿一期底座) */
.refbox .step { display: flex; gap: 10px; align-items: flex-start; padding: 10px 0; border-bottom: 1px dashed #ebeef5; }
.refbox .step b { flex-shrink: 0; width: 52px; color: #909399; }
.refbox .step .hint { display: block; }
.reflog { font-family: Consolas, monospace; font-size: 12px; background: #1f2d3d; color: #cde; padding: 10px 14px; border-radius: 6px; max-height: 320px; overflow: auto; white-space: pre-wrap; }
.kv { display: flex; gap: 8px; flex-wrap: wrap; } .kv b { color: #909399; font-weight: 400; min-width: 96px; }
</style>
</head>
<body>
<header>
<h1>A7 重接口补齐 E2E Demo(a7-heavy)</h1>
<span class="hint" id="apiBox"></span>
<span>当前用户:</span>
<select id="userSel">
<option value="739564171091247104">罗伟健(admin)</option>
<option value="744842318024015872">曾偲青(BUDDY)</option>
</select>
<input id="uidInput" placeholder="测试环境 userId 直登" style="width:230px">
<button class="primary" onclick="loginByUid()">uid 登录</button>
<span class="who" id="whoBox">未登录</span>
</header>
<nav class="tabs">
<button data-tab="sync" class="on" onclick="switchTab('sync')">同步任务</button>
<button data-tab="glog" onclick="switchTab('glog')">全局日志</button>
<button data-tab="poolrule" onclick="switchTab('poolrule')">公海绑定数</button>
</nav>
<main>
<!-- ========== Tab 同步任务(票 01 · A7-1-2) ========== -->
<section id="tab-sync">
<div class="panel">
<div class="viewbar">
<input id="sTaskNo" placeholder="任务编号" style="width:150px">
<select id="sTrigger"><option value="">全部触发方式</option><option value="manual">手动同步</option><option value="auto">自动同步</option></select>
<select id="sStatus"><option value="">全部执行状态</option><option value="running">同步中</option><option value="success">成功</option><option value="partial_failed">部分失败</option><option value="all_failed">全部失败</option></select>
<button class="act" onclick="syncPage()">查询</button>
<span class="sp"></span>
<button class="act primary" onclick="syncLaunch()">发起同步</button>
</div>
<table><thead><tr>
<th>任务编号</th><th>开始时间</th><th>触发方式</th><th>执行状态</th><th>部门变更</th><th>人员变更</th><th>异常数</th><th>进度</th>
</tr></thead><tbody id="sBody"><tr><td colspan="8" class="empty">登录后自动加载</td></tr></tbody></table>
<div class="hint" style="margin-top:6px">列对齐原型 u88-u135;部门/人员变更为「新增 X / 更新 X」前端拼装口径</div>
</div>
<div class="panel refbox">
<b>同步任务联测(票 01:任务化契约 / 分页倒序 / 详情五块 / 设置读写+边界 / lastSyncTime 切表)</b>
<div class="hint" style="margin:6px 0 8px">
发起即建任务行返任务编号(SYNC-MMdd-序号),执行异步;verify 环境钉钉凭据不可达时任务以失败终态落行(两态容忍);
已有任务运行中发起报 61023 并发占位——两态均验任务化契约。
</div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="syncLaunch()">发起同步任务(任务编号格式 + 异步终态轮询)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="syncPage()">同步记录分页(筛选 + 开始时间倒序断言)</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="syncDetail()">任务详情(执行结果/失败对象/接口错误/处理结果/进度五块)</button></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="syncSettingsRead()">读取同步设置(默认值 + 派生出参)</button></span></div>
<div class="step"><b>步骤5</b><span><button class="act" onclick="syncSettingsSave()">保存合法设置(interval=2 次数=3 并发=1,回显断言)</button></span></div>
<div class="step"><b>步骤6</b><span><button class="act" onclick="syncSettingsBoundary()">边界校验(间隔&lt;1min / 次数&lt;1 → 期望 61022)</button></span></div>
<div class="step"><b>步骤7</b><span><button class="act" onclick="syncSettingsRestore()">还原设置(不污染环境)</button></span></div>
<div class="step"><b>步骤8</b><span><button class="act" onclick="syncStats()">lastSyncTime 数据源切换(任务表最新成功行口径)+ 下次执行时间推导</button></span></div>
</div>
<div id="syncOut"></div>
<div class="panel"><b>执行日志</b><div id="syncLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 全局日志(票 02 · A7-4,crm-log) ========== -->
<section id="tab-glog" style="display:none">
<div class="panel refbox">
<b>全局日志联测(票 02:AOP 写端点采集 / 内联退出埋点 / 批量导出导入留档 / 结果文件下载 / 权限口径)</b>
<div class="hint" style="margin:6px 0 8px">
采集=混合:AOP 兜底全 Controller POST(auth/log/file 排除)+ 内联埋点采登录/退出/批量;
覆盖面=写侧先行不采「查询」;结果文件 90 天保留(过期分支单测覆盖,e2e 验不存在 id 错误形态)。
</div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="logPrepare()">建/删演示角色(AOP 写端点采集素材)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="logOperationPage()">操作日志分页(operatorName/opType 筛选命中 + 逐字段)</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="logLogoutRow()">退出登录(内联埋点)→ 登录日志「退出」行断言</button><span class="hint">debug/token 不走登录采集点(票内已核实),退出钩子按请求登录态采集——验证后自动重铸 token</span></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="logExport()">线索池导出 → batch/page「导出」行(result+结果文件 id)</button></span></div>
<div class="step"><b>步骤5</b>
<span>
<input type="file" id="logImportFile">
<button class="act" onclick="logImport()">导入上方文件 → batch/page「导入」行(BatchResult 计数)</button>
<span class="hint">脚本预置 12 列模板 xlsx(1 条 e2d-导入池,部门取空闲顺位避让 64003)</span>
</span>
</div>
<div class="step"><b>步骤6</b><span><button class="act" onclick="logFileDownload()">结果文件下载 /api/log/batch/file?id=(ZIP 魔数)</button></span></div>
<div class="step"><b>步骤7</b><span><button class="act" onclick="logPackDownload()">打包下载 /api/log/batch/download ids[](ZIP 魔数)</button></span></div>
<div class="step"><b>步骤8</b><span><button class="act" onclick="logMissingFile()">不存在文件 id 错误形态(40401/404 形态)</button></span></div>
<div class="step"><b>步骤9</b><span><button class="act" onclick="logDenied()">非管理员(BUDDY)调三 page(期望权限拒绝)</button></span></div>
</div>
<div id="glogOut"></div>
<div class="panel"><b>执行日志</b><div id="glogLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 公海绑定数(票 03 · A7-3-4 挂账毕业) ========== -->
<section id="tab-poolrule" style="display:none">
<div class="panel refbox">
<b>公海规则「绑定商机(数量)」联测(票 03:detail 动态统计零 DDL,match 语义)</b>
<div class="hint" style="margin:6px 0 8px">
口径:部门专用 = deptIds 内商机数;默认 = 不命中任何发布中专用规则的商机数(排除集=发布中专用规则部门并集);
公海态照常计入(owner_dept_id 保留即 match 输入);排除已转项目 opp_status=5;port 缺失 fail-open 按 0。
断言用增量口径(base+n):已关闭商机仍计数(仅 opp_status=5 排除),保证两轮幂等重跑。
</div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="prPrepare()">建演示部门专用规则并发布(e2d-绑定数规则;部门被占则回退既有发布中专用规则)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="prCreateA()">建演示商机 A → detail.boundOppCount = base+1</button><span class="hint">POST /api/opportunity 六必填+省市 code(照 opportunity-e2e 先例)</span></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="prCreateB()">建演示商机 B → detail.boundOppCount = base+2</button></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="prDefaultRule()">默认规则兜底口径(发布中默认规则计数不因本部门建商机而增)</button><span class="hint">无发布中默认规则时降级为提示(单测已覆盖口径)</span></span></div>
<div class="step"><b>步骤5</b><span><button class="act" onclick="prCleanup()">清理(关停演示商机 close / 停用演示规则)</button><span class="hint">商机无删除端点(API 正门终态=关闭);规则发布中→停用留存可辨</span></span></div>
</div>
<div id="poolruleOut"></div>
<div class="panel"><b>执行日志</b><div id="prLog" class="reflog">(等待操作)</div></div>
</section>
</main>
<div id="toast"></div>
<script>
/* ================= 常量与工具(沿 a7-parity demo 底座) ================= */
const API_BASE = (() => {
const q = new URLSearchParams(location.search).get('api');
if (q !== null) {
if (q && q !== 'reset') localStorage.setItem('e2d_api', q);
else localStorage.removeItem('e2d_api');
}
const saved = localStorage.getItem('e2d_api');
if (saved) return saved;
return location.protocol.startsWith('http') ? '' : 'http://localhost:8080';
})();
const ADMIN_UID = '739564171091247104'; // 罗伟健(admin)
const BUDDY_UID = '744842318024015872'; // 曾偲青(BUDDY)
const TS = (() => { const q = new URLSearchParams(location.search).get('ts'); if (q) return q;
const d = new Date(); const p = n => String(n).padStart(2, '0');
return '' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()); })();
const $ = id => document.getElementById(id);
const esc = s => s == null ? '' : String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const TOKEN = () => localStorage.getItem('e2d_token');
const ME = () => { try { return JSON.parse(localStorage.getItem('e2d_me') || 'null'); } catch (e) { return null; } };
const codeSuffix = e => e && e.code ? ' (code=' + e.code + ')' : '';
const fmt = v => v == null ? '—' : String(v).replace('T', ' ').slice(0, 19);
function toast(msg, type) {
const d = document.createElement('div');
d.className = 'toast-item toast-' + (type === 'ok' ? 'ok' : type === 'err' ? 'err' : 'info');
d.textContent = msg;
$('toast').appendChild(d);
setTimeout(() => d.remove(), 6000);
}
function qs(o) {
return Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
}
function errHtml(e) { return '<div class="errbox">失败: ' + esc(e.message) + codeSuffix(e) + '</div>'; }
async function api(path, opt = {}) {
const headers = opt.headers || (opt.headers = {});
if (!(opt.body instanceof FormData)) headers['Content-Type'] = headers['Content-Type'] || 'application/x-www-form-urlencoded;charset=UTF-8';
if (TOKEN()) headers['Authorization'] = 'Bearer ' + TOKEN();
const r = await fetch(API_BASE + path, opt);
let j = null;
try { j = await r.json(); } catch (e) {}
if (r.status === 401) { toast('未登录或 token 失效,请先在页头登录', 'err'); throw new Error('401 未授权'); }
if (j && j.code !== 0 && j.code !== undefined) {
if (j.code === 40401 || r.status === 404) toast('接口不存在(404):' + path, 'err');
const e = new Error(j.message || ('HTTP ' + r.status)); e.code = j.code; e.data = j.data; throw e;
}
if (!r.ok) throw new Error('HTTP ' + r.status);
return j ? j.data : null;
}
/* 二进制下载助手(对称一期 poolExport):带 token fetch → bytes */
async function fetchBytes(path) {
const r = await fetch(API_BASE + path, { headers: { Authorization: 'Bearer ' + TOKEN() } });
if (!r.ok) throw new Error('HTTP ' + r.status);
return new Uint8Array(await r.arrayBuffer());
}
const isZip = b => b && b.length > 4 && b[0] === 0x50 && b[1] === 0x4b;
/* 步骤输出:box 传裸 id,勿带 #(v2 修订形态) */
function outBox(box, html) { document.getElementById(box).innerHTML = html; }
function okBox(box, msg) { outBox(box, '<div class="okbox">' + msg + '</div>'); }
function infoBox(box, msg) { outBox(box, '<div class="infobox">' + msg + '</div>'); }
function failBox(box, e) { outBox(box, errHtml(e)); }
function logLine(box, line) {
const b = document.getElementById(box);
b.textContent += '\n[' + new Date().toLocaleTimeString() + '] ' + line;
b.scrollTop = b.scrollHeight;
}
/* 轮询助手:谓词命中即返(采集落库/异步任务终态等待) */
async function waitFor(fn, timeoutMs, intervalMs) {
const deadline = Date.now() + (timeoutMs || 10000);
for (;;) {
let v = null;
try { v = await fn(); } catch (e) {}
if (v) return v;
if (Date.now() > deadline) return null;
await new Promise(r => setTimeout(r, intervalMs || 1000));
}
}
/* ================= 登录(沿底座) ================= */
async function loginByUid() {
const uid = ($('uidInput').value || '').trim() || $('userSel').value;
if (!uid) return toast('请输入 userId', 'err');
try {
const r = await fetch(API_BASE + '/api/auth/debug/token?userId=' + encodeURIComponent(uid));
const j = await r.json();
const d = j && j.data;
const tok = typeof d === 'string' ? d : (d && d.token);
if (!tok) return toast('debug/token 失败: ' + ((j && j.message) || r.status), 'err');
localStorage.setItem('e2d_token', tok);
let me = null;
try {
const r2 = await fetch(API_BASE + '/api/auth/me', { headers: { Authorization: 'Bearer ' + tok } });
const j2 = await r2.json();
if (j2 && j2.code === 0) me = j2.data;
} catch (e) {}
if (!me) me = { userId: uid };
localStorage.setItem('e2d_me', JSON.stringify(me));
paintWho();
toast('登录成功: ' + (me.userName || me.nickName || uid), 'ok');
syncPage();
} catch (e) { toast('登录异常: ' + e.message, 'err'); }
}
async function remintToken() {
const r = await fetch(API_BASE + '/api/auth/debug/token?userId=' + ADMIN_UID);
const j = await r.json();
const d = j && j.data;
const tok = typeof d === 'string' ? d : (d && d.token);
if (tok) localStorage.setItem('e2d_token', tok);
return tok;
}
function opName() { const me = ME() || {}; return me.userName || me.nickName || '罗伟健'; }
function paintWho() {
const me = ME();
$('whoBox').textContent = me ? '已登录:' + (me.userName || me.nickName || '') + ' (' + (me.userId || '') + ')' : '未登录';
}
/* ================= Tab 切换 ================= */
function switchTab(t) {
document.querySelectorAll('nav.tabs button').forEach(b => b.classList.toggle('on', b.dataset.tab === t));
document.querySelectorAll('main > section').forEach(s => s.style.display = s.id === 'tab-' + t ? '' : 'none');
}
/* ================= 票 01 · 同步任务 ================= */
const SY = { taskId: null, taskNo: null, reused: false, settingsOrig: null };
const TRIGGER_TXT = { manual: '手动同步', auto: '自动同步' };
const STATUS_TXT = { running: '同步中', success: '成功', partial_failed: '部分失败', all_failed: '全部失败' };
function statusBadge(s) {
const t = STATUS_TXT[s] || s || '—';
const cls = s === 'success' ? 'b2' : s === 'running' ? 'b1' : s ? 'b4' : 'b1';
return '<span class="badge ' + cls + '">' + esc(t) + '</span>';
}
async function syncLaunch() {
try {
let d = null;
try {
d = await api('/api/system/sync/dingtalk', { method: 'POST' });
} catch (e) {
if (e.code === 61023) {
SY.reused = true;
okBox('syncOut', '61023 并发占位生效:' + esc(e.message) + '\n(已有任务运行中——复用运行中任务验证任务化结构,契约两态容忍)');
logLine('syncLog', 'launch → 61023 复用运行中任务');
return;
}
throw e;
}
SY.taskId = d && d.taskId; SY.taskNo = d && d.taskNo; SY.reused = false;
logLine('syncLog', 'launch → taskId=' + SY.taskId + ' taskNo=' + SY.taskNo);
const fmtOk = /^SYNC-\d{4}-\d+$/.test(SY.taskNo || '');
if (!fmtOk) { failBox('syncOut', new Error('任务编号格式不符: ' + SY.taskNo)); return; }
// 异步终态轮询(verify 环境钉钉不可达 → 失败终态亦两态容忍;75s 上限防挂死)
const fin = await waitFor(async () => {
const det = await api('/api/system/sync/tasks/detail?id=' + SY.taskId);
return det && det.endTime ? det : null;
}, 75000, 3000);
const st = fin ? fin.status : 'running(75s 未终态,任务行在位即可)';
okBox('syncOut', '任务化契约验证:taskId=' + esc(SY.taskId) + ',taskNo=' + esc(SY.taskNo) + '(SYNC-MMdd-序号 ✓)' +
'\n异步终态:' + esc(st) + (fin ? ',progress=' + esc(fin.progress) + ',errorCount=' + esc(fin.errorCount) : '') +
'\n(verify 环境两态容忍:success 或 all_failed 均有完整任务行)');
} catch (e) { failBox('syncOut', e); }
}
async function syncPage() {
try {
const d = await api('/api/system/sync/tasks/page', { method: 'POST', body: qs({
current: 1, size: 50, taskNo: $('sTaskNo').value.trim(), triggerType: $('sTrigger').value, status: $('sStatus').value }) });
const rows = d.content || [];
$('sBody').innerHTML = rows.length ? rows.map(t => '<tr>' +
'<td>' + esc(t.taskNo) + '</td><td>' + esc(fmt(t.startTime)) + '</td><td>' + esc(TRIGGER_TXT[t.triggerType] || t.triggerType) + '</td>' +
'<td>' + statusBadge(t.status) + '</td>' +
'<td>新增 ' + esc(t.deptInserted == null ? '—' : t.deptInserted) + ' / 更新 ' + esc(t.deptUpdated == null ? '—' : t.deptUpdated) + '</td>' +
'<td>新增 ' + esc(t.userInserted == null ? '—' : t.userInserted) + ' / 更新 ' + esc(t.userUpdated == null ? '—' : t.userUpdated) + '</td>' +
'<td>' + esc(t.errorCount == null ? '—' : t.errorCount) + '</td><td>' + esc(t.progress == null ? '—' : t.progress + '%') + '</td>' +
'</tr>').join('') : '<tr><td colspan="8" class="empty">暂无同步任务</td></tr>';
window._syncRows = rows;
} catch (e) { $('sBody').innerHTML = '<tr><td colspan="8" class="errbox">' + esc(e.message) + codeSuffix(e) + '</td></tr>'; }
}
async function syncDetail() {
try {
let id = SY.taskId;
if (!id) {
const rows = window._syncRows || [];
id = rows.length ? rows[0].id : null;
SY.reused = true;
if (!id) { failBox('syncOut', new Error('无任务行可验详情(先执行步骤1或查询)')); return; }
}
const det = await api('/api/system/sync/tasks/detail?id=' + id);
const need = ['taskNo', 'triggerType', 'status', 'startTime', 'progress'];
const missing = need.filter(k => det[k] === undefined);
const fails = det.failDetails || [];
const failPart = fails.length ? '\n失败对象=' + esc(fails[0].failObject == null ? fails[0].objectName || '—' : fails[0].failObject) +
',接口错误=' + esc((fails[0].apiError || fails[0].errorMsg || '—') + '').slice(0, 80) +
',处理结果=' + esc(fails[0].handleResult == null ? '—' : fails[0].handleResult) : '';
if (missing.length) { failBox('syncOut', new Error('详情缺字段: ' + missing.join(','))); return; }
okBox('syncOut', '详情五块结构在位:taskNo=' + esc(det.taskNo) + ',status=' + esc(STATUS_TXT[det.status] || det.status) +
',进度=' + esc(det.progress) + '%,失败明细 ' + fails.length + ' 条' + failPart);
logLine('syncLog', 'detail → ' + JSON.stringify(det).slice(0, 300));
} catch (e) { failBox('syncOut', e); }
}
async function syncSettingsRead() {
try {
const d = await api('/api/system/sync/settings');
SY.settingsOrig = d;
okBox('syncOut', '设置回显:autoSyncEnabled=' + esc(d.autoSyncEnabled) + ',dailyTime=' + esc(d.dailyTime) +
',retryIntervalMinutes=' + esc(d.retryIntervalMinutes) + ',retryMaxTimes=' + esc(d.retryMaxTimes) +
',concurrency=' + esc(d.concurrency) +
'\n派生:当前计划=' + esc(d.plan) + ',下次执行时间=' + esc(fmt(d.nextExecuteTime)) +
',最近修改人=' + esc(d.lastModifierName == null ? '—' : d.lastModifierName));
} catch (e) { failBox('syncOut', e); }
}
async function syncSettingsSave() {
try {
const o = SY.settingsOrig || {};
const d = await api('/api/system/sync/settings', { method: 'POST', body: qs({
autoSyncEnabled: o.autoSyncEnabled == null ? 'true' : String(o.autoSyncEnabled),
dailyTime: o.dailyTime || '02:00',
retryIntervalMinutes: 2, retryMaxTimes: 3, concurrency: 1 }) });
SY.savedValues = { retryIntervalMinutes: 2, retryMaxTimes: 3, concurrency: 1 };
const ok = d && Number(d.retryIntervalMinutes) === 2 && Number(d.retryMaxTimes) === 3 && Number(d.concurrency) === 1;
if (ok) okBox('syncOut', '合法设置保存并回显:interval=2,次数=3,并发=1(autoSyncEnabled/dailyTime 保留原值不污染调度)');
else failBox('syncOut', new Error('保存回显不符: ' + JSON.stringify(d).slice(0, 160)));
} catch (e) { failBox('syncOut', e); }
}
async function syncSettingsBoundary() {
try {
const o = SY.settingsOrig || {};
const base = { autoSyncEnabled: o.autoSyncEnabled == null ? 'true' : String(o.autoSyncEnabled), dailyTime: o.dailyTime || '02:00' };
let blocked1 = null, blocked2 = null;
try { await api('/api/system/sync/settings', { method: 'POST', body: qs(Object.assign({}, base, { retryIntervalMinutes: 0, retryMaxTimes: 3, concurrency: 1 })) }); }
catch (e) { blocked1 = e; }
try { await api('/api/system/sync/settings', { method: 'POST', body: qs(Object.assign({}, base, { retryIntervalMinutes: 2, retryMaxTimes: 0, concurrency: 1 })) }); }
catch (e) { blocked2 = e; }
if (blocked1 && blocked1.code === 61022 && blocked2 && blocked2.code === 61022)
okBox('syncOut', '61022 边界校验两路生效:间隔<1min 拒(' + esc(blocked1.message) + ');次数<1 拒(' + esc(blocked2.message) + ')');
else failBox('syncOut', new Error('边界校验不符: interval=' + (blocked1 ? blocked1.code : '放行!') + ' times=' + (blocked2 ? blocked2.code : '放行!')));
} catch (e) { failBox('syncOut', e); }
}
async function syncSettingsRestore() {
try {
const o = SY.settingsOrig;
if (!o) { failBox('syncOut', new Error('先执行步骤4读取设置')); return; }
const d = await api('/api/system/sync/settings', { method: 'POST', body: qs({
autoSyncEnabled: String(o.autoSyncEnabled), dailyTime: o.dailyTime || '02:00',
retryIntervalMinutes: o.retryIntervalMinutes == null ? 5 : o.retryIntervalMinutes,
retryMaxTimes: o.retryMaxTimes == null ? 3 : o.retryMaxTimes,
concurrency: o.concurrency == null ? 1 : o.concurrency }) });
const ok = d && Number(d.retryIntervalMinutes) === Number(o.retryIntervalMinutes == null ? 5 : o.retryIntervalMinutes);
if (ok) okBox('syncOut', '设置已还原(interval=' + esc(d.retryIntervalMinutes) + ',次数=' + esc(d.retryMaxTimes) + ',并发=' + esc(d.concurrency) + ',dailyTime=' + esc(d.dailyTime) + ')');
else failBox('syncOut', new Error('还原回显不符: ' + JSON.stringify(d).slice(0, 160)));
} catch (e) { failBox('syncOut', e); }
}
async function syncStats() {
try {
const d = await api('/api/system/users/stats');
if (d.lastSyncTime === undefined) { failBox('syncOut', new Error('stats 出参缺 lastSyncTime 字段')); return; }
// 数据源切换口径:任务表最新成功行 endTime;无成功任务行时允许为空(verify 环境钉钉不可达常态)
const pg = await api('/api/system/sync/tasks/page', { method: 'POST', body: qs({ current: 1, size: 100, status: 'success' }) });
const rows = pg.content || [];
let verdict;
if (rows.length) {
const latest = rows[0]; // 开始时间倒序首行=最新
const eq = fmt(d.lastSyncTime) === fmt(latest.endTime);
verdict = eq ? '与最新成功任务行 endTime 一致(' + esc(fmt(latest.endTime)) + ')——切表生效'
: '⚠ 与最新成功行不一致: stats=' + esc(fmt(d.lastSyncTime)) + ' task=' + esc(fmt(latest.endTime));
if (!eq) { failBox('syncOut', new Error(verdict)); return; }
} else {
verdict = d.lastSyncTime == null ? '无成功任务行 → lastSyncTime 为空(口径自洽)' : '⚠ 无成功任务行但 lastSyncTime=' + esc(fmt(d.lastSyncTime));
if (d.lastSyncTime != null) { failBox('syncOut', new Error(verdict)); return; }
}
const st = await api('/api/system/sync/settings');
const nextOk = st.autoSyncEnabled ? st.nextExecuteTime != null : st.nextExecuteTime == null;
okBox('syncOut', 'lastSyncTime 验证:' + verdict + '\n下次执行时间推导:autoSyncEnabled=' + esc(st.autoSyncEnabled) +
',nextExecuteTime=' + esc(fmt(st.nextExecuteTime)) + (nextOk ? '(推导一致)' : '(⚠ 推导不符)'));
} catch (e) { failBox('syncOut', e); }
}
/* ================= 票 02 · 全局日志 ================= */
const GL = { exportRow: null, importRow: null, exportFileId: null };
async function logPrepare() {
try {
const name = 'e2d-日志演示角色-' + TS;
await api('/api/roles/saveOrUpdate', { method: 'POST', body: qs({ roleName: name, roleCode: 'e2d_lrole_' + TS, remark: 'a7-heavy e2e 写操作素材' }) });
const pg = await api('/api/roles/page', { method: 'POST', body: qs({ current: 1, size: 20, roleName: name }) });
const row = (pg.content || []).find(x => x.roleName === name);
if (!row) throw new Error('演示角色创建后查不到');
await api('/api/roles/delete?id=' + row.id, { method: 'POST' });
logLine('glogLog', '写操作素材:建+删角色 ' + name + '(id=' + row.id + ',AOP 应采「新增」「删除」两行)');
okBox('glogOut', '写操作完成:建/删演示角色 ' + esc(name) + '(等待采集落库后断言)');
} catch (e) { failBox('glogOut', e); }
}
async function findLogRow(path, body, pred, timeoutMs) {
return waitFor(async () => {
const d = await api(path, { method: 'POST', body: qs(body) });
const hit = (d.content || []).find(pred);
return hit ? { row: hit, page: d } : null;
}, timeoutMs || 15000, 1500);
}
async function logOperationPage() {
try {
const op = opName();
const c1 = await findLogRow('/api/log/operation/page', { current: 1, size: 50, operatorName: op, opType: '新增' },
r => (r.content || '').includes('/api/roles/saveOrUpdate'));
const c2 = await findLogRow('/api/log/operation/page', { current: 1, size: 50, operatorName: op, opType: '删除' },
r => (r.content || '').includes('/api/roles/delete'));
if (!c1 || !c2) { failBox('glogOut', new Error('AOP 采集行未命中: 新增=' + !!c1 + ' 删除=' + !!c2)); return; }
// 逐字段:时间范围收口(今日有行 / 昨日 0 行)
const today = new Date(); const p = n => String(n).padStart(2, '0');
const dstr = today.getFullYear() + '-' + p(today.getMonth() + 1) + '-' + p(today.getDate());
const inRange = await api('/api/log/operation/page', { method: 'POST', body: qs({ current: 1, size: 10, operatorName: op, timeFrom: dstr, timeTo: dstr }) });
const outRange = await api('/api/log/operation/page', { method: 'POST', body: qs({ current: 1, size: 10, operatorName: op, timeFrom: '2000-01-01', timeTo: '2000-01-02' }) });
const r = c1.row;
okBox('glogOut', 'AOP 采集行命中:新增行 content=' + esc((r.content || '').slice(0, 60)) +
',操作人=' + esc(r.operatorName) + ',结果=' + esc(r.result) +
'\n逐字段:今日范围 ' + (inRange.content || []).length + ' 行 / 2000-01-01 范围 ' + (outRange.content || []).length + ' 行(时间过滤生效)');
logLine('glogLog', 'operation rows: ' + JSON.stringify(c1.row).slice(0, 200));
} catch (e) { failBox('glogOut', e); }
}
async function logLogoutRow() {
try {
const op = opName();
await api('/api/auth/logout', { method: 'POST' });
await remintToken(); // session 已关闭,立即重铸(采集行已在退出请求内落库)
const c = await findLogRow('/api/log/login/page', { current: 1, size: 20, operatorName: op, opType: '退出' },
r => r.operatorName === op && (r.result || r.status) === '成功'); // 登录日志行字段是 status
if (!c) { failBox('glogOut', new Error('登录日志「退出」行未命中(operatorName=' + op + ')')); return; }
const r = c.row;
okBox('glogOut', '退出埋点命中:opType=退出,operatorName=' + esc(r.operatorName) + ',status/result=' + esc(r.status || r.result) +
',ip=' + esc(r.ip || '—') + ',opTime=' + esc(fmt(r.opTime)) + '\n(token 已重铸,后续步骤不受影响)');
} catch (e) { failBox('glogOut', e); }
}
async function logExport() {
try {
const op = opName();
const buf = await fetchBytes('/api/rule/pool/export?current=1&size=5');
if (!isZip(buf)) { failBox('glogOut', new Error('导出内容非 ZIP: ' + buf.length + ' 字节')); return; }
logLine('glogLog', 'pool export → ' + buf.length + ' 字节 ZIP ✓');
const c = await findLogRow('/api/log/batch/page', { current: 1, size: 20, operatorName: op, opType: '导出' },
r => r.fileId != null && r.fileId !== '');
if (!c) { failBox('glogOut', new Error('批量日志「导出」行(含结果文件)未命中')); return; }
GL.exportRow = c.row; GL.exportFileId = String(c.row.fileId);
okBox('glogOut', '导出留档行命中:page=' + esc(c.row.page || '—') + ',result=' + esc(c.row.result) +
',countDetail=' + esc(c.row.countDetail || '—') + ',fileId=' + esc(GL.exportFileId) + '\n(导出字节 ' + buf.length + ',ZIP 魔数 ✓)');
} catch (e) { failBox('glogOut', e); }
}
async function logImport() {
try {
const op = opName();
const f = $('logImportFile').files[0];
if (!f) throw new Error('请先在步骤5选择导入文件(脚本预置)');
const fd = new FormData();
fd.append('file', f);
const d = await api('/api/rule/pool/import', { method: 'POST', body: fd });
const succ = d && d.successCount != null ? Number(d.successCount) : -1;
logLine('glogLog', 'pool import → ' + JSON.stringify(d).slice(0, 200));
const c = await findLogRow('/api/log/batch/page', { current: 1, size: 20, operatorName: op, opType: '导入' },
r => (r.result === '成功' || r.result === '部分成功'));
if (!c) { failBox('glogOut', new Error('批量日志「导入」行未命中')); return; }
GL.importRow = c.row;
okBox('glogOut', '导入行命中:result=' + esc(c.row.result) + ',countDetail=' + esc(c.row.countDetail || '—') +
',fileId=' + esc(c.row.fileId == null || c.row.fileId === '' ? '(无失败明细→无错误报告文件,合理)' : c.row.fileId) +
'\nBatchResult: total=' + esc(d.total) + ',success=' + esc(succ) + ',fail=' + esc(d.failCount));
} catch (e) { failBox('glogOut', e); }
}
async function logFileDownload() {
try {
if (!GL.exportRow) throw new Error('先执行步骤4(导出留档行)');
// 端点收的是批量日志行主键(requireBatchLog(id) → row.fileId),非 fileId 本身;导出行才有结果文件
const buf = await fetchBytes('/api/log/batch/file?id=' + GL.exportRow.id);
if (!isZip(buf)) { failBox('glogOut', new Error('结果文件非 ZIP(xlsx):' + buf.length + ' 字节')); return; }
okBox('glogOut', '结果文件下载成功:日志行 id=' + esc(GL.exportRow.id) + '(fileId=' + esc(GL.exportFileId) + '),' + buf.length + ' 字节,ZIP 魔数 PK ✓(90 天内有效)');
} catch (e) { failBox('glogOut', e); }
}
async function logPackDownload() {
try {
const ids = [GL.exportRow, GL.importRow].filter(Boolean).map(r => 'ids=' + r.id);
if (!ids.length) throw new Error('先执行步骤4/5(批量日志行)');
// 端点 @PostMapping(GET 会被安全层 405 且无 CORS 头)——POST 表单多值 ids
const r = await fetch(API_BASE + '/api/log/batch/download', { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', Authorization: 'Bearer ' + TOKEN() },
body: ids.join('&') });
if (!r.ok) throw new Error('HTTP ' + r.status);
const buf = new Uint8Array(await r.arrayBuffer());
if (!isZip(buf)) { failBox('glogOut', new Error('打包下载非 ZIP: ' + buf.length + ' 字节')); return; }
okBox('glogOut', '打包下载成功:ids=[' + ids.map(s => s.replace('ids=', '')).join(', ') + '],' + buf.length + ' 字节,ZIP 魔数 PK ✓');
} catch (e) { failBox('glogOut', e); }
}
async function logMissingFile() {
let code = null, msg = '';
try {
const buf = await fetchBytes('/api/log/batch/file?id=999999999999');
// 全局异常处理器对该端点返 HTTP 200 + JSON 信封(requireBatchLog→ResourceNotExistException 40401)
const head = new TextDecoder().decode(buf.slice(0, 500));
try { const j = JSON.parse(head); code = j.code; msg = j.message || ''; }
catch (e2) { msg = '非 JSON 响应 ' + buf.length + ' 字节'; }
} catch (e) {
code = e.code; msg = e.message || ''; // HTTP 层错误(404 等)形态兜底
}
const codeOk = code === 40401 || code === 66001 || code === 66002 || /HTTP 404/.test(msg);
if (codeOk) okBox('glogOut', '不存在文件 id 错误形态生效:code=' + esc(code) + ',' + esc(msg) + '\n(查无日志行→40401/HTTP404;过期→66001;行存在但无文件→66002;90 天分支单测覆盖)');
else failBox('glogOut', new Error('错误形态不符: code=' + code + ' msg=' + msg));
}
async function logDenied() {
try {
const r = await fetch(API_BASE + '/api/auth/debug/token?userId=' + BUDDY_UID);
const j = await r.json();
const d = j && j.data;
const btok = typeof d === 'string' ? d : (d && d.token);
if (!btok) throw new Error('BUDDY token 获取失败');
const paths = ['/api/log/operation/page', '/api/log/login/page', '/api/log/batch/page'];
const verdicts = [];
for (const p of paths) {
let denied = false, shape = '';
try {
const resp = await fetch(API_BASE + p, { method: 'POST', headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Authorization': 'Bearer ' + btok }, body: 'current=1&size=5' });
shape = 'HTTP ' + resp.status;
denied = resp.status === 403;
if (!denied) { const jj = await resp.json().catch(() => null); if (jj && jj.code !== 0) { denied = true; shape += ' code=' + jj.code; } }
} catch (e2) { shape = e2.message; }
verdicts.push(p.split('/').pop() + '=' + (denied ? '拒绝 ✓(' + shape + ')' : '⚠ 未拒绝(' + shape + ')'));
}
const allDenied = verdicts.every(v => v.includes('✓'));
if (allDenied) okBox('glogOut', '非管理员权限口径生效(ADMIN 门禁):\n' + verdicts.join('\n'));
else failBox('glogOut', new Error('权限拒绝不符:\n' + verdicts.join('\n')));
} catch (e) { failBox('glogOut', e); }
}
/* ================= 票 03 · 公海绑定数 ================= */
const PR = { ruleId: null, created: false, base: null, opps: [], defaultRow: null, defaultBase: null };
const OPP_BASE = { oppSource: 'opp_source_03', industryCode: 'gov', bidForm: 'bid_form_02', localityType: 'locality_type_02',
provinceCode: '510000', cityCode: '510100', partyAClear: 0, partyA: 'E2E甲方-a7heavy', remark: 'a7-heavy 票05 e2e' };
async function findPoolRuleByName(name) {
const d = await api('/api/rule/opp-pool-rule/page', { method: 'POST', body: qs({ current: 1, size: 100, ruleName: 'e2d-绑定数规则' }) });
return (d.content || []).filter(x => (x.ruleName || '').startsWith('e2d-绑定数规则'));
}
async function prPrepare() {
try {
const me = ME() || {};
if (!me.deptId) throw new Error('me 出参缺 deptId(无法定位规则绑定部门)');
const deptId = String(me.deptId);
// 幂等:清掉前轮演示规则(草稿删 / 发布中停用——停用行留痕可辨)
for (const old of await findPoolRuleByName()) {
try {
if (Number(old.status) === 1) { await api('/api/rule/opp-pool-rule/delete?id=' + old.id, { method: 'POST' }); logLine('prLog', 'pre-clean 删草稿 ' + old.ruleName); }
else if (Number(old.status) === 2) { await api('/api/rule/opp-pool-rule/disable?id=' + old.id, { method: 'POST' }); logLine('prLog', 'pre-clean 停用前轮发布行 ' + old.ruleName); }
} catch (e) { logLine('prLog', 'pre-clean 跳过 ' + old.ruleName + ': ' + e.message); }
}
// 发布演示部门专用规则(部门被既有发布中专用规则占用时回退用该规则断言)
let created = true;
try {
await api('/api/rule/opp-pool-rule/publish', { method: 'POST', body: qs({
ruleName: 'e2d-绑定数规则-' + TS, applyScope: 2, deptIds: deptId, isDefault: 0,
allowManualPool: 1, autoRecycleEnabled: 0, recycleRemindEnabled: 0, allowFreeClaim: 1,
ruleDesc: 'a7-heavy 票03 e2e' }) }); // 四旗标全必传(缺 recycleRemindEnabled → 64008)
} catch (e) {
created = false;
logLine('prLog', '发布被拒(' + e.message + ')→ 回退既有发布中专用规则');
}
let rule = null;
if (created) {
const pg = await api('/api/rule/opp-pool-rule/page', { method: 'POST', body: qs({ current: 1, size: 100, ruleName: 'e2d-绑定数规则-' + TS }) });
rule = (pg.content || []).find(x => x.ruleName === 'e2d-绑定数规则-' + TS && Number(x.status) === 2);
}
if (!rule) {
const pg = await api('/api/rule/opp-pool-rule/page', { method: 'POST', body: qs({ current: 1, size: 200 }) });
for (const r of (pg.content || []).filter(x => Number(x.status) === 2 && Number(x.applyScope) === 2)) {
const det = await api('/api/rule/opp-pool-rule/detail?id=' + r.id);
if ((det.deptIds || []).map(String).includes(deptId)) { rule = det; break; }
}
}
if (!rule) throw new Error('无可用发布中部门专用规则(含本部门)');
PR.ruleId = rule.id; PR.created = created;
const det = await api('/api/rule/opp-pool-rule/detail?id=' + rule.id);
PR.base = Number(det.boundOppCount == null ? 0 : det.boundOppCount);
PR.opps = [];
okBox('poolruleOut', '规则就绪:id=' + esc(rule.id) + ',name=' + esc(rule.ruleName) +
',scope=' + esc(rule.applyScope) + '(部门专用),deptIds=[' + esc((det.deptIds || []).join(',')) + ']' +
'\nboundOppCount 基线 base=' + esc(PR.base) + (created ? '(本轮新建发布)' : '(部门已被占用→复用既有发布中专用规则)') +
'\n后续按增量断言 base+n(已关闭商机仍计数,两轮幂等安全)');
} catch (e) { failBox('poolruleOut', e); }
}
async function prCreateOpp(label) {
const d = await api('/api/opportunity', { method: 'POST', body: qs(Object.assign({}, OPP_BASE, {
opportunityName: 'e2d-绑定数opp-' + TS + '-' + label })) });
const oppId = String(typeof d === 'object' && d !== null ? (d.id || d) : d);
PR.opps.push(oppId);
const det = await api('/api/rule/opp-pool-rule/detail?id=' + PR.ruleId);
return { oppId: oppId, count: Number(det.boundOppCount == null ? 0 : det.boundOppCount) };
}
async function prCreateA() {
try {
if (!PR.ruleId) throw new Error('先执行步骤1');
const r = await prCreateOpp('A');
const expect = PR.base + 1;
if (r.count === expect) okBox('poolruleOut', '商机 A(id=' + esc(r.oppId) + ')→ boundOppCount=' + r.count + '(base+1 ✓,match 语义生效)');
else failBox('poolruleOut', new Error('计数不符: 实际=' + r.count + ' 期望=' + expect + '(base=' + PR.base + ')'));
} catch (e) { failBox('poolruleOut', e); }
}
async function prCreateB() {
try {
if (!PR.ruleId) throw new Error('先执行步骤1');
const r = await prCreateOpp('B');
const expect = PR.base + 2;
if (r.count === expect) okBox('poolruleOut', '商机 B(id=' + esc(r.oppId) + ')→ boundOppCount=' + r.count + '(base+2 ✓,动态统计非快照)');
else failBox('poolruleOut', new Error('计数不符: 实际=' + r.count + ' 期望=' + expect + '(base=' + PR.base + ')'));
} catch (e) { failBox('poolruleOut', e); }
}
async function prDefaultRule() {
try {
const pg = await api('/api/rule/opp-pool-rule/page', { method: 'POST', body: qs({ current: 1, size: 200 }) });
const def = (pg.content || []).find(x => Number(x.status) === 2 && Number(x.applyScope) === 1 && Number(x.isDefault) === 1);
if (!def) {
infoBox('poolruleOut', '当前无发布中默认规则 → 兜底口径断言降级为提示(口径已由 crm-rule 单测覆盖:默认计数=不命中发布中专用规则的商机数,排除集=发布中专用规则部门并集)');
return;
}
const d0 = await api('/api/rule/opp-pool-rule/detail?id=' + def.id);
const baseD = Number(d0.boundOppCount == null ? 0 : d0.boundOppCount);
const r = await prCreateOpp('C');
const d1 = await api('/api/rule/opp-pool-rule/detail?id=' + def.id);
const nowD = Number(d1.boundOppCount == null ? 0 : d1.boundOppCount);
if (nowD === baseD) okBox('poolruleOut', '默认规则兜底口径生效:发布中默认规则(id=' + esc(def.id) + ')boundOppCount=' + nowD +
' 建商机后不变(本部门被专用规则覆盖 → 进排除集,不入默认计数);专用规则计数=' + r.count);
else failBox('poolruleOut', new Error('默认计数异常变化: ' + baseD + ' → ' + nowD + '(本部门商机不应入默认计数)'));
} catch (e) { failBox('poolruleOut', e); }
}
async function prCleanup() {
try {
let closed = 0;
for (const id of PR.opps) {
try { await api('/api/opportunity/close?id=' + id + '&closeReason=e2e清理&closeRemark=a7-heavy 票05 清理', { method: 'POST' }); closed++; }
catch (e) { logLine('prLog', '关停商机 ' + id + ': ' + e.message + codeSuffix(e)); }
}
let ruleMsg = '无本轮规则';
if (PR.ruleId && PR.created) {
try { await api('/api/rule/opp-pool-rule/disable?id=' + PR.ruleId, { method: 'POST' }); ruleMsg = '演示规则已停用(发布行留存可辨)'; }
catch (e) { ruleMsg = '停用失败: ' + e.message; }
} else if (PR.ruleId) ruleMsg = '复用的既有规则不动';
okBox('poolruleOut', '清理完成:关停演示商机 ' + closed + '/' + PR.opps.length + '(close 终态;已关闭仍计数,仅 opp_status=5 排除——增量口径跨轮安全);' + ruleMsg);
} catch (e) { failBox('poolruleOut', e); }
}
/* ================= 初始化 ================= */
$('apiBox').textContent = 'API: ' + API_BASE;
paintWho();
// 仅已登录才预加载:未登录请求会被 401 拒绝,而安全层响应不带 CORS 头,会刷 console CORS error
if (ME()) { syncPage(); }
</script>
</body>
</html>