Files
jiapuapp/components/TacVerification.vue
T
2026-07-27 06:50:23 +08:00

352 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view
v-show="visible"
class="tac-layer"
:aria-hidden="visible ? 'false' : 'true'"
@click.stop
>
<view
id="jiapu-tac-host"
:prop="renderContext"
:change:prop="tacRenderer.onContextChange"
/>
</view>
</template>
<script>
export default {
name: "TacVerification",
props: {
visible: {
type: Boolean,
default: false,
},
context: {
type: Object,
default: null,
},
},
emits: ["success", "failure", "error", "cancel"],
computed: {
renderContext() {
return {
...(this.context || {}),
visible: this.visible,
};
},
},
methods: {
handleTacSuccess(payload) {
this.$emit("success", payload);
},
handleTacFailure(payload) {
this.$emit("failure", payload);
},
handleTacError(payload) {
this.$emit("error", payload);
},
handleTacCancel(payload) {
this.$emit("cancel", payload);
},
},
};
</script>
<script module="tacRenderer" lang="renderjs">
const TAC_STYLE_URL = "./static/tac/css/tac.css";
const TAC_SCRIPT_URL = "./static/tac/js/tac.min.js";
const TAC_ADAPTER_URL = "./static/tac/js/jiapu-tac-adapter.js";
const loadStyle = (href) => {
const existing = document.querySelector(`link[data-jiapu-tac="${href}"]`);
if (existing && (existing.dataset.jiapuState === "loaded" || existing.sheet)) {
return Promise.resolve();
}
if (existing && existing.dataset.jiapuState === "loading") {
return new Promise((resolve, reject) => {
existing.addEventListener("load", resolve, { once: true });
existing.addEventListener("error", () => reject(new Error(`安全验证样式加载失败:${href}`)), { once: true });
});
}
if (existing) existing.remove();
return new Promise((resolve, reject) => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.setAttribute("data-jiapu-tac", href);
link.dataset.jiapuState = "loading";
link.onload = () => {
link.dataset.jiapuState = "loaded";
resolve();
};
link.onerror = () => {
link.remove();
reject(new Error(`安全验证样式加载失败:${href}`));
};
document.head.appendChild(link);
});
};
const loadScript = (src, ready) => {
if (ready()) return Promise.resolve();
const existing = document.querySelector(`script[data-jiapu-tac="${src}"]`);
if (existing && existing.dataset.jiapuState === "loading") {
return new Promise((resolve, reject) => {
existing.addEventListener("load", () => {
if (ready()) {
existing.dataset.jiapuState = "loaded";
resolve();
} else {
existing.remove();
reject(new Error(`安全验证脚本未提供预期能力:${src}`));
}
}, { once: true });
existing.addEventListener("error", () => {
existing.remove();
reject(new Error(`安全验证脚本加载失败:${src}`));
}, { once: true });
});
}
if (existing) existing.remove();
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.setAttribute("data-jiapu-tac", src);
script.dataset.jiapuState = "loading";
script.onload = () => {
if (!ready()) {
script.remove();
reject(new Error(`安全验证脚本未提供预期能力:${src}`));
return;
}
script.dataset.jiapuState = "loaded";
resolve();
};
script.onerror = () => {
script.remove();
reject(new Error(`安全验证脚本加载失败:${src}`));
};
document.head.appendChild(script);
});
};
export default {
data() {
return {
generation: 0,
tac: null,
requestContext: null,
challenge: null,
activeXhr: null,
fatalSent: false,
completionSent: false,
};
},
methods: {
async onContextChange(nextContext) {
const nextVisible = Boolean(nextContext && nextContext.visible === true);
this.generation += 1;
const generation = this.generation;
this.destroyTac();
this.requestContext = nextContext || null;
this.challenge = null;
this.fatalSent = false;
this.completionSent = false;
if (!nextContext || nextContext.visible !== true) return;
try {
await Promise.all([
loadStyle(TAC_STYLE_URL),
loadScript(TAC_SCRIPT_URL, () => Boolean(window.TAC && window.CaptchaConfig)),
]);
await loadScript(TAC_ADAPTER_URL, () => Boolean(window.JiapuTacAdapter));
if (generation !== this.generation || !this.requestContext || this.requestContext.visible !== true) return;
this.createTac();
} catch (error) {
if (generation !== this.generation || !this.requestContext || this.requestContext.visible !== true) return;
this.notifyFatal(error);
}
},
createTac() {
if (!window.TAC || !window.CaptchaConfig || !window.JiapuTacAdapter) {
throw new Error("安全验证组件未完整加载");
}
this.completionSent = false;
const generation = this.generation;
const requestContext = this.requestContext;
const isCurrent = () =>
generation === this.generation &&
this.requestContext === requestContext &&
requestContext.visible === true;
const config = new window.CaptchaConfig({
bindEl: "#jiapu-tac-host",
requestCaptchaDataUrl: this.requestContext.challengeUrl,
validCaptchaUrl: this.requestContext.verifyUrl,
requestHeaders: {
clientid: this.requestContext.clientId,
},
validSuccess: (response, captcha, tac) => {
if (!isCurrent() || this.completionSent) return;
// 供应商脚本在动画结束、窗口销毁等边界下可能重复触发回调。
// 先封闭本轮并使所有旧闭包失效,再通知逻辑层,避免重复发短信或重复提交认证。
this.completionSent = true;
this.generation += 1;
this.abortActiveRequest();
const data = response && response.data;
tac.destroyWindow();
this.tac = null;
this.$ownerInstance.callMethod("handleTacSuccess", {
requestId: requestContext.requestId,
validToken: data && data.validToken,
expireSeconds: data && data.expireSeconds,
});
},
validFail: (response, captcha, tac) => {
if (!isCurrent()) return;
this.$ownerInstance.callMethod("handleTacFailure", {
requestId: requestContext.requestId,
message: (response && response.msg) || "行为验证未通过,请重试",
});
tac.reloadCaptcha();
},
btnCloseFun: (event, tac) => {
if (!isCurrent() || this.completionSent) return;
this.completionSent = true;
this.generation += 1;
this.abortActiveRequest();
tac.destroyWindow();
this.tac = null;
this.$ownerInstance.callMethod("handleTacCancel", {
requestId: requestContext.requestId,
});
},
btnRefreshFun: (event, tac) => {
if (isCurrent()) tac.reloadCaptcha();
},
});
// SDK 原生传输会把空体 HTTP 500 当成功,并且 verify 只看外层 code。
// 此处统一替换为严格 2xx JSON 传输,再由唯一适配器验证 passed 与 validToken。
config.doSendRequest = (options) => this.sendStrictRequest(options);
config.addRequestChain({
preRequest: (type, request) => this.beforeRequest(type, request),
postRequest: (type, request, response) => this.afterRequest(type, response),
});
this.tac = new window.TAC(config);
this.tac.init();
},
beforeRequest(type, request) {
if (type === "requestCaptchaData") {
request.data = {
tenantId: this.requestContext.tenantId,
subject: this.requestContext.subject,
};
return true;
}
if (type === "validCaptcha") {
request.data = window.JiapuTacAdapter.buildVerifyBody(
request.data,
this.requestContext,
this.challenge,
);
}
return true;
},
afterRequest(type, response) {
try {
if (type === "requestCaptchaData") {
const mapped = window.JiapuTacAdapter.normalizeChallengeResponse(response, this.requestContext);
this.challenge = mapped.challenge;
this.replaceResponse(response, mapped.sdkResponse);
} else if (type === "validCaptcha") {
const mapped = window.JiapuTacAdapter.normalizeVerifyResponse(response);
this.replaceResponse(response, mapped);
}
} catch (error) {
const failure = window.JiapuTacAdapter.toSdkFailure(error);
this.replaceResponse(response, failure);
if (type === "requestCaptchaData") this.notifyFatal(error);
}
return true;
},
replaceResponse(target, source) {
Object.keys(target).forEach((key) => delete target[key]);
Object.assign(target, source);
},
sendStrictRequest(options) {
return new Promise((resolve) => {
this.abortActiveRequest();
const generation = this.generation;
const xhr = new XMLHttpRequest();
this.activeXhr = xhr;
let settled = false;
const settle = (response) => {
if (settled || generation !== this.generation || this.activeXhr !== xhr) return;
settled = true;
this.activeXhr = null;
resolve(response);
};
xhr.open(options.method || "POST", options.url);
xhr.timeout = 15000;
Object.keys(options.headers || {}).forEach((name) => {
xhr.setRequestHeader(name, String(options.headers[name]));
});
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) return;
if (!(xhr.status >= 200 && xhr.status < 300)) {
settle({ code: xhr.status || 503, msg: `安全验证服务请求失败(${xhr.status || "网络异常"}` });
return;
}
try {
const data = JSON.parse(xhr.responseText);
settle(data && typeof data === "object" ? data : { code: 502, msg: "安全验证服务返回无效数据" });
} catch (error) {
settle({ code: 502, msg: "安全验证服务返回了非 JSON 数据" });
}
};
xhr.onerror = () => settle({ code: 503, msg: "安全验证服务网络连接失败" });
xhr.ontimeout = () => settle({ code: 504, msg: "安全验证服务请求超时" });
const payload = typeof options.data === "string" ? options.data : JSON.stringify(options.data || {});
xhr.send(payload);
});
},
abortActiveRequest() {
const xhr = this.activeXhr;
this.activeXhr = null;
if (!xhr || xhr.readyState === XMLHttpRequest.DONE) return;
xhr.onreadystatechange = null;
xhr.onerror = null;
xhr.ontimeout = null;
xhr.onabort = null;
xhr.abort();
},
notifyFatal(error) {
if (this.fatalSent) return;
this.fatalSent = true;
this.$ownerInstance.callMethod("handleTacError", {
requestId: this.requestContext && this.requestContext.requestId,
message: (error && error.message) || "安全验证暂不可用",
});
},
destroyTac() {
this.abortActiveRequest();
if (this.tac) this.tac.destroyWindow();
this.tac = null;
const host = document.querySelector("#jiapu-tac-host");
if (host) host.innerHTML = "";
},
},
};
</script>
<style scoped>
.tac-layer {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
}
</style>