409 lines
16 KiB
JavaScript
409 lines
16 KiB
JavaScript
(function (root, factory) {
|
|
const api = factory();
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
|
if (root) root.LskjBridgeClient = api;
|
|
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
"use strict";
|
|
|
|
const PROTOCOL_VERSION = "1.0";
|
|
const SAFE_IDENTIFIER = /^[A-Za-z0-9_.:-]{8,128}$/;
|
|
const SAFE_CODE = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
const SAFE_COMMAND = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
const SAFE_PLAN_ID = /^[A-Fa-f0-9]{32}$/;
|
|
const RESPONSE_FIELDS = new Set([
|
|
"protocolVersion", "requestId", "correlationId", "success", "code", "message", "data"
|
|
]);
|
|
const REQUIRED_RESPONSE_FIELDS = [
|
|
"protocolVersion", "requestId", "correlationId", "success", "code", "data"
|
|
];
|
|
const METHODS = new Set([
|
|
"health", "capabilities.list", "context.get", "command.plan", "command.execute"
|
|
]);
|
|
const RECOVERY_ACTIONS = new Set([
|
|
"review_and_retry",
|
|
"reconcile_execution",
|
|
"restart_erp_pet",
|
|
"inspect_existing_record",
|
|
"replan",
|
|
"correct_request",
|
|
"contact_administrator",
|
|
"wait_and_retry",
|
|
"contact_support"
|
|
]);
|
|
const EXECUTE_DATA_FIELDS = ["result", "followupPlan", "followupCode"];
|
|
const EXECUTE_RESULT_FIELDS = [
|
|
"success", "code", "message", "recordId", "replayed", "data"
|
|
];
|
|
const DIAGNOSTIC_DATA_FIELDS = [
|
|
"diagnosticContextSchemaVersion", "diagnosticId", "correlationId",
|
|
"evidencePersisted", "evidenceContentHash", "outcome",
|
|
"primaryFindingCode", "moduleOpenSucceeded", "eventCount",
|
|
"failedEventCount", "slowEventCount", "traceTruncated",
|
|
"summaryTruncated", "findings", "staticDiagnosis", "contextAvailable"
|
|
];
|
|
const DIAGNOSTIC_FINDING_FIELDS = [
|
|
"severity", "code", "category", "stage", "confidence", "message",
|
|
"recommendation", "occurrenceCount", "sqlFingerprint", "caller"
|
|
];
|
|
const STATIC_DIAGNOSIS_FIELDS = [
|
|
"moduleCode", "moduleKind", "healthy", "issueCount", "issues"
|
|
];
|
|
const STATIC_ISSUE_FIELDS = ["severity", "code", "source"];
|
|
const SAFE_HASH = /^[a-f0-9]{64}$/;
|
|
const SAFE_DIAGNOSTIC_ID = /^diag-[A-Fa-f0-9]{32}$/;
|
|
const SAFE_CALLER = /^caller_(?:[0-9]{4}|overflow)$/;
|
|
|
|
function createId() {
|
|
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
|
|
return globalThis.crypto.randomUUID().replace(/-/g, "");
|
|
}
|
|
return "id" + Date.now().toString(36) + Math.random().toString(36).slice(2);
|
|
}
|
|
|
|
class WebViewTransport {
|
|
constructor(webview, timeoutMs) {
|
|
if (!webview || typeof webview.postMessage !== "function") {
|
|
throw new TypeError("WebView2 transport is unavailable.");
|
|
}
|
|
this.webview = webview;
|
|
// 写操作需要用户阅读预览并在 ERP 原生窗口再次确认,默认保留 3 分钟。
|
|
this.timeoutMs = timeoutMs || 180000;
|
|
this.pending = new Map();
|
|
this.onMessage = this.onMessage.bind(this);
|
|
this.webview.addEventListener("message", this.onMessage);
|
|
}
|
|
|
|
send(request) {
|
|
return new Promise((resolve, reject) => {
|
|
if (this.pending.has(request.requestId)) {
|
|
reject(protocolError("ERP 桥请求 ID 重复。"));
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => {
|
|
this.pending.delete(request.requestId);
|
|
reject(clientError("bridge_timeout", "ERP 命令桥响应超时。"));
|
|
}, this.timeoutMs);
|
|
this.pending.set(request.requestId, { resolve, reject, timer });
|
|
this.webview.postMessage({ type: "lserp.bridge.request", request });
|
|
});
|
|
}
|
|
|
|
onMessage(event) {
|
|
const envelope = event && event.data;
|
|
if (!envelope || envelope.type !== "lserp.bridge.response" || !envelope.response) return;
|
|
const response = envelope.response;
|
|
const waiter = this.pending.get(response.requestId);
|
|
if (!waiter) return;
|
|
clearTimeout(waiter.timer);
|
|
this.pending.delete(response.requestId);
|
|
waiter.resolve(response);
|
|
}
|
|
|
|
dispose() {
|
|
this.webview.removeEventListener("message", this.onMessage);
|
|
this.pending.forEach(waiter => {
|
|
clearTimeout(waiter.timer);
|
|
waiter.reject(clientError("bridge_closed", "ERP 命令桥已关闭。"));
|
|
});
|
|
this.pending.clear();
|
|
}
|
|
}
|
|
|
|
class BridgeClient {
|
|
constructor(transport, options) {
|
|
if (!transport || typeof transport.send !== "function") {
|
|
throw new TypeError("A bridge transport is required.");
|
|
}
|
|
const settings = options || {};
|
|
this.transport = transport;
|
|
this.clientSessionId = settings.clientSessionId || createId();
|
|
if (typeof this.clientSessionId !== "string" || !SAFE_IDENTIFIER.test(this.clientSessionId)) {
|
|
throw new TypeError("clientSessionId has an invalid format.");
|
|
}
|
|
this.idempotencyByPlan = new Map();
|
|
}
|
|
|
|
async request(method, payload, correlationId) {
|
|
validateRequest(method, payload);
|
|
const trustedCorrelationId = correlationId === undefined || correlationId === null
|
|
? createId()
|
|
: correlationId;
|
|
if (typeof trustedCorrelationId !== "string"
|
|
|| !SAFE_IDENTIFIER.test(trustedCorrelationId)) {
|
|
throw protocolError("ERP 桥关联 ID 格式无效。");
|
|
}
|
|
const request = {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
requestId: createId(),
|
|
correlationId: trustedCorrelationId,
|
|
clientSessionId: this.clientSessionId,
|
|
method,
|
|
payload: payload === undefined || payload === null ? {} : payload
|
|
};
|
|
const response = validateResponse(await this.transport.send(request), request);
|
|
if (response.success !== true) {
|
|
const error = new Error(response.message || "ERP 命令执行失败。");
|
|
error.code = response.code;
|
|
error.response = response;
|
|
throw error;
|
|
}
|
|
return response.data;
|
|
}
|
|
|
|
health() {
|
|
return this.request("health");
|
|
}
|
|
|
|
context() {
|
|
return this.request("context.get");
|
|
}
|
|
|
|
capabilities() {
|
|
return this.request("capabilities.list");
|
|
}
|
|
|
|
plan(command, input) {
|
|
return this.request("command.plan", {
|
|
command,
|
|
input: input === undefined || input === null ? {} : input
|
|
});
|
|
}
|
|
|
|
async execute(planId, bridgeCorrelationId) {
|
|
if (typeof planId !== "string" || !SAFE_PLAN_ID.test(planId)
|
|
|| typeof bridgeCorrelationId !== "string"
|
|
|| !SAFE_IDENTIFIER.test(bridgeCorrelationId)) {
|
|
throw protocolError("ERP 执行请求格式无效。");
|
|
}
|
|
let key = this.idempotencyByPlan.get(planId);
|
|
if (!key) {
|
|
key = createId();
|
|
this.idempotencyByPlan.set(planId, key);
|
|
}
|
|
const data = await this.request(
|
|
"command.execute",
|
|
{ planId, idempotencyKey: key },
|
|
bridgeCorrelationId);
|
|
validateExecuteData(data, bridgeCorrelationId);
|
|
const projected = Object.assign({}, data, { bridgeCorrelationId });
|
|
if (isObject(data.followupPlan)) {
|
|
projected.followupPlan = Object.assign(
|
|
{}, data.followupPlan, { bridgeCorrelationId });
|
|
}
|
|
return projected;
|
|
}
|
|
}
|
|
|
|
function validateRequest(method, payload) {
|
|
if (!METHODS.has(method)) throw protocolError("ERP 桥方法不受支持。");
|
|
const value = payload === undefined || payload === null ? {} : payload;
|
|
if (!isObject(value)) throw protocolError("ERP 桥 payload 必须是对象。");
|
|
const fields = Object.keys(value);
|
|
if (method === "health" || method === "capabilities.list" || method === "context.get") {
|
|
if (fields.length !== 0) throw protocolError("该 ERP 桥方法不接受 payload 字段。");
|
|
return;
|
|
}
|
|
if (method === "command.plan") {
|
|
if (!hasExactFields(fields, ["command", "input"])
|
|
|| typeof value.command !== "string"
|
|
|| !SAFE_COMMAND.test(value.command)
|
|
|| !isObject(value.input)) {
|
|
throw protocolError("ERP 计划请求格式无效。");
|
|
}
|
|
return;
|
|
}
|
|
if (!hasExactFields(fields, ["planId", "idempotencyKey"])
|
|
|| typeof value.planId !== "string"
|
|
|| !SAFE_PLAN_ID.test(value.planId)
|
|
|| typeof value.idempotencyKey !== "string"
|
|
|| !SAFE_IDENTIFIER.test(value.idempotencyKey)) {
|
|
throw protocolError("ERP 执行请求格式无效。");
|
|
}
|
|
}
|
|
|
|
function validateResponse(response, request) {
|
|
if (!isObject(response)) throw protocolError("ERP 桥响应必须是对象。");
|
|
const fields = Object.keys(response);
|
|
if (fields.some(field => !RESPONSE_FIELDS.has(field))
|
|
|| REQUIRED_RESPONSE_FIELDS.some(field => !fields.includes(field))
|
|
|| response.protocolVersion !== PROTOCOL_VERSION
|
|
|| typeof response.requestId !== "string"
|
|
|| !SAFE_IDENTIFIER.test(response.requestId)
|
|
|| response.requestId !== request.requestId
|
|
|| typeof response.correlationId !== "string"
|
|
|| !SAFE_IDENTIFIER.test(response.correlationId)
|
|
|| response.correlationId !== request.correlationId
|
|
|| typeof response.success !== "boolean"
|
|
|| typeof response.code !== "string"
|
|
|| !SAFE_CODE.test(response.code)
|
|
|| !isObject(response.data)
|
|
|| (response.message !== undefined
|
|
&& response.message !== null
|
|
&& typeof response.message !== "string")
|
|
|| (typeof response.message === "string" && response.message.length > 2000)) {
|
|
throw protocolError("ERP 桥返回了无效或不匹配的协议消息。");
|
|
}
|
|
if (response.success === false) validateErrorRecovery(response);
|
|
return response;
|
|
}
|
|
|
|
function validateErrorRecovery(response) {
|
|
if (typeof response.message !== "string"
|
|
|| !safeDisplayText(response.message, 1, 2000)
|
|
|| !hasExactFields(Object.keys(response.data), ["recovery"])
|
|
|| !isObject(response.data.recovery)) {
|
|
throw protocolError("ERP 桥错误恢复契约无效。");
|
|
}
|
|
const recovery = response.data.recovery;
|
|
if (!hasExactFields(
|
|
Object.keys(recovery),
|
|
["action", "retryable", "planInvalidated", "message"])
|
|
|| typeof recovery.action !== "string"
|
|
|| !RECOVERY_ACTIONS.has(recovery.action)
|
|
|| typeof recovery.retryable !== "boolean"
|
|
|| typeof recovery.planInvalidated !== "boolean"
|
|
|| typeof recovery.message !== "string"
|
|
|| !safeDisplayText(recovery.message, 1, 300)) {
|
|
throw protocolError("ERP 桥错误恢复契约无效。");
|
|
}
|
|
}
|
|
|
|
function validateExecuteData(data, expectedCorrelationId) {
|
|
if (!isObject(data)
|
|
|| !hasExactFields(Object.keys(data), EXECUTE_DATA_FIELDS)
|
|
|| !isObject(data.result)
|
|
|| !hasExactFields(Object.keys(data.result), EXECUTE_RESULT_FIELDS)) {
|
|
throw protocolError("ERP 执行成功回执契约无效。");
|
|
}
|
|
const result = data.result;
|
|
if (result.success !== true
|
|
|| typeof result.code !== "string"
|
|
|| !SAFE_CODE.test(result.code)
|
|
|| !safeDisplayText(result.message, 1, 300)
|
|
|| (result.recordId !== null
|
|
&& !safeDisplayText(result.recordId, 1, 256))
|
|
|| typeof result.replayed !== "boolean"
|
|
|| !isObject(result.data)
|
|
|| (data.followupPlan !== null && !isObject(data.followupPlan))
|
|
|| (data.followupCode !== null
|
|
&& (typeof data.followupCode !== "string"
|
|
|| !SAFE_CODE.test(data.followupCode)))) {
|
|
throw protocolError("ERP 执行成功回执契约无效。");
|
|
}
|
|
const resultDataFields = Object.keys(result.data);
|
|
if (resultDataFields.length === 0) return;
|
|
validateDiagnosticData(result.data, expectedCorrelationId);
|
|
}
|
|
|
|
function validateDiagnosticData(data, expectedCorrelationId) {
|
|
if (!hasExactFields(Object.keys(data), DIAGNOSTIC_DATA_FIELDS)
|
|
|| data.diagnosticContextSchemaVersion !== "1.0"
|
|
|| typeof data.diagnosticId !== "string"
|
|
|| !SAFE_DIAGNOSTIC_ID.test(data.diagnosticId)
|
|
|| data.correlationId !== expectedCorrelationId
|
|
|| typeof data.evidencePersisted !== "boolean"
|
|
|| (data.evidenceContentHash !== null
|
|
&& (typeof data.evidenceContentHash !== "string"
|
|
|| !SAFE_HASH.test(data.evidenceContentHash)))
|
|
|| data.evidencePersisted !== (data.evidenceContentHash !== null)
|
|
|| !["failed", "degraded", "healthy"].includes(data.outcome)
|
|
|| typeof data.primaryFindingCode !== "string"
|
|
|| !SAFE_CODE.test(data.primaryFindingCode)
|
|
|| typeof data.moduleOpenSucceeded !== "boolean"
|
|
|| !boundedInteger(data.eventCount, 0, 200)
|
|
|| !boundedInteger(data.failedEventCount, 0, data.eventCount)
|
|
|| !boundedInteger(data.slowEventCount, 0, data.eventCount)
|
|
|| typeof data.traceTruncated !== "boolean"
|
|
|| typeof data.summaryTruncated !== "boolean"
|
|
|| data.contextAvailable !== true
|
|
|| !Array.isArray(data.findings)
|
|
|| data.findings.length < 1
|
|
|| data.findings.length > 16
|
|
|| !isObject(data.staticDiagnosis)) {
|
|
throw protocolError("ERP 初始化诊断回执契约无效。");
|
|
}
|
|
data.findings.forEach((finding, index) => {
|
|
if (!isObject(finding)
|
|
|| !hasExactFields(Object.keys(finding), DIAGNOSTIC_FINDING_FIELDS)
|
|
|| !["error", "warning", "info"].includes(finding.severity)
|
|
|| typeof finding.code !== "string"
|
|
|| !SAFE_CODE.test(finding.code)
|
|
|| typeof finding.category !== "string"
|
|
|| !SAFE_CODE.test(finding.category)
|
|
|| typeof finding.stage !== "string"
|
|
|| !SAFE_CODE.test(finding.stage)
|
|
|| !["observed", "inferred"].includes(finding.confidence)
|
|
|| !safeDisplayText(finding.message, 1, 300)
|
|
|| !safeDisplayText(finding.recommendation, 1, 500)
|
|
|| !boundedInteger(finding.occurrenceCount, 1, 200)
|
|
|| (finding.sqlFingerprint !== null
|
|
&& (typeof finding.sqlFingerprint !== "string"
|
|
|| !SAFE_HASH.test(finding.sqlFingerprint)))
|
|
|| (finding.caller !== null
|
|
&& (typeof finding.caller !== "string"
|
|
|| !SAFE_CALLER.test(finding.caller)))) {
|
|
throw protocolError("ERP 初始化诊断 finding 契约无效。");
|
|
}
|
|
if (index === 0 && finding.code !== data.primaryFindingCode) {
|
|
throw protocolError("ERP 初始化诊断主结论不一致。");
|
|
}
|
|
});
|
|
const staticDiagnosis = data.staticDiagnosis;
|
|
if (!hasExactFields(
|
|
Object.keys(staticDiagnosis),
|
|
STATIC_DIAGNOSIS_FIELDS)
|
|
|| typeof staticDiagnosis.moduleCode !== "string"
|
|
|| !SAFE_COMMAND.test(staticDiagnosis.moduleCode)
|
|
|| !["base", "bill"].includes(staticDiagnosis.moduleKind)
|
|
|| typeof staticDiagnosis.healthy !== "boolean"
|
|
|| !boundedInteger(staticDiagnosis.issueCount, 0, 200)
|
|
|| !Array.isArray(staticDiagnosis.issues)
|
|
|| staticDiagnosis.issues.length > 32
|
|
|| staticDiagnosis.issues.length > staticDiagnosis.issueCount) {
|
|
throw protocolError("ERP 静态诊断回执契约无效。");
|
|
}
|
|
staticDiagnosis.issues.forEach(issue => {
|
|
if (!isObject(issue)
|
|
|| !hasExactFields(Object.keys(issue), STATIC_ISSUE_FIELDS)
|
|
|| !["error", "warning", "info"].includes(issue.severity)
|
|
|| typeof issue.code !== "string"
|
|
|| !SAFE_CODE.test(issue.code)
|
|
|| typeof issue.source !== "string"
|
|
|| !SAFE_CODE.test(issue.source)) {
|
|
throw protocolError("ERP 静态诊断 issue 契约无效。");
|
|
}
|
|
});
|
|
}
|
|
|
|
function boundedInteger(value, minimum, maximum) {
|
|
return Number.isInteger(value) && value >= minimum && value <= maximum;
|
|
}
|
|
|
|
function safeDisplayText(value, minimumLength, maximumLength) {
|
|
return typeof value === "string"
|
|
&& value.length >= minimumLength
|
|
&& value.length <= maximumLength
|
|
&& !/[\u0000-\u001f\u007f-\u009f]/.test(value);
|
|
}
|
|
|
|
function hasExactFields(actual, expected) {
|
|
return actual.length === expected.length && expected.every(field => actual.includes(field));
|
|
}
|
|
|
|
function isObject(value) {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function protocolError(message) {
|
|
return clientError("bridge_protocol_error", message || "ERP 桥协议错误。");
|
|
}
|
|
|
|
function clientError(code, message) {
|
|
const error = new Error(message || "ERP 桥调用失败。");
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
return Object.freeze({ createId, WebViewTransport, BridgeClient, validateResponse });
|
|
});
|