fix: 修复腾讯验证码票据过期与区域切换

This commit is contained in:
shaw
2026-08-06 21:27:06 +08:00
parent 8e102b3a0f
commit 287a9f386b
7 changed files with 99 additions and 10 deletions
+26 -7
View File
@@ -33,8 +33,12 @@ let instance: { show(): void; destroy(): void } | null = null
let pending: Promise<TencentCaptchaProof | null> | null = null
let cancelPending: (() => void) | null = null
let cachedProof: TencentCaptchaProof | null = null
let cachedProofCreatedAt = 0
let isMounted = false
// 腾讯国际站票据官方有效期为 5 分钟,提前 1 分钟放弃缓存,避免提交时刚好过期。
const cachedProofMaxAgeMs = 4 * 60 * 1000
function createVerificationPromise(revealInternational: boolean = true): Promise<TencentCaptchaProof | null> {
return new Promise((resolve, reject) => {
let settled = false
@@ -106,9 +110,17 @@ function createVerificationPromise(revealInternational: boolean = true): Promise
function verify(): Promise<TencentCaptchaProof | null> {
if (cachedProof) {
const proof = cachedProof
cachedProof = null
return Promise.resolve(proof)
if (Date.now() - cachedProofCreatedAt >= cachedProofMaxAgeMs) {
// 过期票据不能再次提交;先同步销毁旧实例,再在本次调用中创建新验证。
reset(false)
} else {
const proof = cachedProof
cachedProof = null
cachedProofCreatedAt = 0
// 预加载 promise 已经完成,消费缓存时同步清除引用,避免同一票据被并发复用。
pending = null
return Promise.resolve(proof)
}
}
if (pending) {
@@ -119,7 +131,10 @@ function verify(): Promise<TencentCaptchaProof | null> {
if (pending === verification) instance?.show()
})
return verification.then((proof) => {
if (cachedProof === proof) cachedProof = null
if (cachedProof === proof) {
cachedProof = null
cachedProofCreatedAt = 0
}
return proof
})
}
@@ -146,7 +161,10 @@ function preload(): void {
pending = verification
void verification
.then((proof) => {
if (proof) cachedProof = proof
if (proof) {
cachedProof = proof
cachedProofCreatedAt = Date.now()
}
})
.catch(() => undefined)
.finally(() => {
@@ -154,18 +172,19 @@ function preload(): void {
})
}
function reset(): void {
function reset(reinitialize: boolean = true): void {
instance?.destroy()
instance = null
cancelPending?.()
cancelPending = null
pending = null
cachedProof = null
cachedProofCreatedAt = 0
internationalContainerVisible.value = isInternational.value
// 国际站的票据一次性使用,登录失败后需要立即创建新的 checkbox,
// 不能等下一次点击登录才重新初始化。卸载阶段不再启动预加载。
if (isMounted && isInternational.value) {
if (reinitialize && isMounted && isInternational.value) {
void nextTick().then(() => {
if (isMounted) preload()
})
@@ -1,7 +1,7 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TencentCaptchaGate from '@/components/TencentCaptchaGate.vue'
import { resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
import { loadTencentCaptcha, resetTencentCaptchaLoaderForTest } from '@/utils/tencentCaptcha'
const locale = { value: 'zh' }
@@ -197,6 +197,43 @@ describe('TencentCaptchaGate', () => {
)
})
it('refreshes a preloaded proof before the provider ticket expires', async () => {
vi.useFakeTimers()
try {
let callback: ((result: CaptchaResult) => void) | undefined
let constructorCount = 0
window.TCaptchaGlobal = true
window.TencentCaptcha = class {
constructor(...received: unknown[]) {
constructorCount += 1
callback = received[2] as (result: CaptchaResult) => void
}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const wrapper = mount(TencentCaptchaGate, {
props: { appId: '123456789', region: 'intl' }
})
await flushPromises()
callback?.({ ret: 0, ticket: 'expired-ticket', randstr: 'expired-rand' })
await flushPromises()
vi.advanceTimersByTime(4 * 60 * 1000)
const verification = wrapper.vm.verify()
await flushPromises()
expect(constructorCount).toBe(2)
callback?.({ ret: 0, ticket: 'fresh-ticket', randstr: 'fresh-rand' })
await expect(verification).resolves.toEqual({
ticket: 'fresh-ticket',
randstr: 'fresh-rand'
})
} finally {
vi.useRealTimers()
}
})
it('keeps the three-argument form for the Chinese mainland site', async () => {
const args: unknown[][] = []
window.TencentCaptcha = class {
@@ -248,6 +285,26 @@ describe('TencentCaptchaGate', () => {
).not.toBeNull()
})
it('does not inject a second SDK when the region changes in one page', async () => {
const constructor = class {
constructor() {}
show = vi.fn()
destroy = vi.fn()
} as unknown as typeof window.TencentCaptcha
const firstLoad = loadTencentCaptcha('cn')
const firstScript = document.head.querySelector<HTMLScriptElement>('script[src*="TJCaptcha.js"]')
expect(firstScript).not.toBeNull()
window.TencentCaptcha = constructor
firstScript?.dispatchEvent(new Event('load'))
await expect(firstLoad).resolves.toBe(constructor)
await expect(loadTencentCaptcha('intl')).rejects.toThrow(
'Tencent Captcha region changed; reload the page to apply it'
)
expect(document.head.querySelector('script[src*="TJNCaptcha-global.js"]')).toBeNull()
})
it('settles a pending verification when reset', async () => {
const destroy = vi.fn()
window.TencentCaptcha = class {
+5
View File
@@ -79,6 +79,11 @@ export function loadTencentCaptcha(
if (window.TencentCaptcha && (loadedRegion === region || globalRegion === region)) {
return Promise.resolve(window.TencentCaptcha)
}
if (window.TencentCaptcha && loadedRegion !== null && loadedRegion !== region) {
// 两个站点共享 TencentCaptcha 全局且构造签名不兼容,不能在同一页面注入第二份 SDK。
// 管理员切换区域后由部署流程刷新页面,刷新前直接失败可避免全局构造函数被污染。
return Promise.reject(new Error('Tencent Captcha region changed; reload the page to apply it'))
}
if (scriptPromise && loadedRegion === region) return scriptPromise
loadedRegion = region