# 开发者文档 (zh-CN) > 从嵌入组件到服务端核验,口贴验证码的接入步骤、接口约定和实现示例都在这里。 Human UI: https://challenge.koutee.com/docs This file is generated from the same source as the Vue docs page. Do not edit by hand. ## Contents ### 接入 - [快速开始](#quickstart) - [组件属性](#component) - [自动提交](#autosubmit) - [开发者模式](#devmode) - [SPA 集成](#spa) ### 服务端 - [API 接口](#api) - [服务端验证](#verification) - [后端集成示例](#backend) ### 参考 - [Widget JS API](#widgetapi) - [浏览器兼容](#compat) - [语言 / 国际化](#i18n) ## 快速开始 {#quickstart} 采用组件集成,4 步完成接入 ### 1. 配置 Site Key 选择以下任一方式配置您的 Site Key(更安全,不直接暴露在组件标签中): #### 方式一:通过 Meta 标签(推荐) ```html ``` #### 方式二:通过全局配置 ```html ``` ### 2. 放置验证码组件 在表单中添加验证码组件,无需直接写 sitekey 属性: ```html
``` ### 3. 后端验证 表单提交后,后端可通过 /api/v1/verify 接口验证令牌有效性:(使用 `secretKey` Basic Auth,**必须在服务端完成**,不能在前端暴露密钥) ```javascript // Node.js 后端验证示例 // ✅ SVT 令牌验证(唯一安全方式,必须使用 secretKey) const SITE_KEY = process.env.KT_SITE_KEY; // siteKey(公开) const SECRET_KEY = process.env.KT_SECRET_KEY; // secretKey(保密,不得暴露给前端) const verifySvt = async (svt) => { const auth = Buffer.from(`${SITE_KEY}:${SECRET_KEY}`).toString('base64'); const response = await fetch('https://challenge.koutee.com/api/v1/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}` }, body: JSON.stringify({ svt }) }); const data = await response.json(); return data.code === 1000 && data.data?.verified === true; }; // 在 API 路由中使用 app.post('/api/contact', async (req, res) => { const { email, message, koutee } = req.body; // 验证验证码 const isValid = await verifySvt(koutee); if (!isValid) { return res.status(400).json({ error: 'Captcha verification failed' }); } // 处理表单数据... res.json({ success: true }); }); ``` ### 4. 完整示例 以下是一个完整的 HTML 页面示例,您可以直接复制并修改使用: ```html 口贴验证码接入示例

联系我们

``` ## 组件属性 {#component} 自定义验证码组件的行为和外观 | 属性 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `lang` | String | zh-CN | 组件语言 (• zh-CN (简体中文) • en (English) • pt-BR (Português)) | | `data-auto-submit` | Boolean | false | 验证通过后继续宿主流程(交表或点继续按钮) (写上属性即为开启,不需要值。 有 form 就提交表单;没有 form 就点 [data-kt-continue] 或 data-auto-submit-target。 登录、注册、支付不要开启。 详见左侧「自动提交」章节。) | | `data-auto-submit-target` | String | - | 在最近的对话框 / 表单里查找继续控件的选择器 (CSS 选择器,只在最近的 form / dialog 里查询。找不到或按钮仍 disabled 时 会派发 continue 事件。) | ## 自动提交 {#autosubmit} 验证通过后立刻提交所在表单,用户不必再点一次按钮。 | | 适合开启 | 不要开启 | | --- | --- | --- | | 典型场景 | 进站风控校验、下载页放行、匿名投票、纯"证明你是人类"的关卡 | 登录、注册、支付、删除等破坏性操作,以及需要用户先填写内容的表单 | | 原因 | 表单里除了验证码没有别的必填内容,多一次点击只是徒增摩擦。 | 用户可能还没填完或还想改,自动提交会造成误操作,而且用户会失去对提交时机的控制。 | ### 1. 容器写法 在容器 div 上加 data-auto-submit,loader 会把它复制到生成的组件上。属性不需要写值,写上即为开启。 ```html
``` ### 2. 直接使用自定义元素 如果你是手写 (例如在 Vue / React 里),把属性直接放在元素上即可。属性是实时读取的,动态绑定也能生效。 ```html
``` ### 3. 全局开启 页面上所有验证码都要自动提交时,可以在全局配置里打开,省去逐个添加属性。元素上的 data-auto-submit 优先级更高,可以单独关闭:写 data-auto-submit="false" 即可。 ```html ``` ### 4. 弹窗 / 无 form 场景 对话框里没有
时,给真正会继续流程的按钮加上 data-kt-continue。验证通过后组件只在这个对话框里点它,不按文案或 class 猜测。 ```html ``` 按钮暂时改不了时,把选择器写在组件上,作用域仍然是最近的对话框。 ```html ``` 没有可点目标时会派发 continue 事件。更干净的做法是听这个事件直接恢复查询,不必再点确认按钮。 ```javascript const widget = document.querySelector('ktcaptcha-widget'); // 找不到按钮时的兜底:直接继续刚才被拦住的操作 widget.addEventListener('continue', (e) => { resumePendingQuery(e.detail.payload); closeDialog(); }); ``` ### 行为说明 1. 解析顺序:data-auto-submit-target → 作用域内 [data-kt-continue] → 祖先 的 requestSubmit() → 派发 continue 事件。有 form 时原生校验和 submit 事件都会触发。 2. 每个组件实例只会自动提交一次。SVT 每 300 秒静默刷新会重新走验证成功流程,内部有守卫防止表单被反复提交。 3. 提交动作延后一个宏任务执行,保证你自己的 verified 事件监听器先跑完,不会因为页面跳转被打断。 4. 调用组件的 reset() 后守卫会重置,下一次验证通过会再次自动提交。 5. 选择器只在最近的 form / dialog / [role=dialog] 里查找,不会满页乱点。只接受 form、button、input[type=submit ### 配合 AJAX 使用 自动提交触发的是标准 submit 事件,所以在事件里 preventDefault() 就能接管成 AJAX 提交,令牌照常从隐藏字段 koutee 里读。 ```html
``` > **不要用它替代后端校验** > > 自动提交只是省掉一次点击,令牌本身仍然必须由你的服务端调用 /api/v1/verify 校验。前端任何行为都不能作为通过凭据。 ## 开发者模式 {#devmode} 在本机开发时接入验证码,不必把 localhost 写进正式域名白名单。 ### 1. 在控制台开启 进入 控制台 → 应用管理 → 选择应用 → 开发者模式,打开开关即可。开关只影响该应用自己,不影响你的其他应用。联调结束后请关掉。 ### 2. 哪些地址会被放行 开关打开后,以下来源无需出现在域名白名单里就能加载验证码: | 来源 | 说明 | | --- | --- | | `localhost / 127.0.0.1 / ::1 / 0.0.0.0` | 本机回环地址,默认放行,不用手动添加。 | | `*.localhost / *.test / *.local / *.internal` | 保留用途 TLD,不可能在公网解析,默认放行。 | | `10.x / 172.16-31.x / 192.168.x / 169.254.x` | 私网地址,需要在控制台"额外调试域名"里登记后才放行;方便用手机等同局域网设备连本机。 | 端口不参与匹配,任何端口都放行,所以 localhost:3000 和 localhost:5173 都能直接用。生产域名不能登记为调试域名,控制台会拒绝。 ### 用哪套钥决定模式 不再依赖服务端总开关。请求携带哪套 sitekey,就按那套规则放行: 1. 生产钥只接受正式域名白名单,本机地址请改用开发站点钥。 2. 在控制台打开开发者模式后会签发开发钥对(不是请求参数,攻击者无法自行开启)。 3. 开发钥只接受本机、保留 TLD 和你登记的调试域名,不能用于正式域名。 ### 识别 dev 令牌 通过开发者模式放行签发的令牌会带上 dev 标记,你的后端调用 /api/v1/verify 时能在返回里看到 dev 和 hostname 两个字段。 > **生产环境请拒绝 dev 令牌** > > 这是这套机制的最后一道防线:即使开关被误开,只要你的生产后端拒绝带 dev 标记的令牌,本地调试产生的令牌就无法被拿去打生产接口。 ```javascript // 后端校验(Node.js) const res = await fetch('https://challenge.koutee.com/api/v1/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + Buffer.from(`${SITE_KEY}:${SECRET_KEY}`).toString('base64') }, body: JSON.stringify({ svt: token }) }); const { data } = await res.json(); if (!data?.verified) { return res.status(400).json({ error: '验证失败' }); } // 生产环境拒绝开发者模式签发的令牌 if (data.dev && process.env.NODE_ENV === 'production') { return res.status(400).json({ error: 'dev token rejected' }); } // hostname 是签发这枚令牌时的来源主机 console.log(data.hostname); // "localhost" ``` ### 常见问题 | 现象 | 原因 | 解决 | | --- | --- | --- | | 开关打开了但本地还是报域名不允许 | 页面仍在使用生产站点钥,或开发钥被用来打正式域名。 | 本机请改用开发站点钥;正式域名请用生产钥并加入白名单。 | | 用 192.168.x.x 访问不通 | 私网地址不在默认放行范围内。 | 在控制台"额外调试域名"里把这个 IP 登记一下。 | | 想把公司测试域名加进去被拒绝 | 只允许本机、私网和保留用途 TLD,防止开发者模式变成常驻旁路。 | 测试域名请走正常的域名白名单,或把测试环境的 host 改成 .test 结尾。 | ## SPA 集成:React / Vue / Next.js {#spa} 验证码是原生自定义元素,任何框架都能直接用。下面三份是可直接复制的完整实现。 > **SPA 里最容易踩的三个坑** > > 1. 组件卸载时必须解绑事件监听,否则热更新后会重复触发。 > 2. 收到 expired / failed 事件要立刻清空本地令牌,过期令牌提交到后端必然失败。 > 3. SVT 一次性消费,提交成功后调用 reset(),不要缓存复用。 ### React useKoutee hook 封装事件与状态,配一个受控表单示例。 ```tsx // useKoutee.ts — 把自定义元素的事件封装成 React hook,卸载时记得解绑 import { useEffect, useRef, useState, useCallback } from 'react' type KouteeWidget = HTMLElement & { reset: () => void refresh: () => Promise setLang: (lang: string) => boolean } export function useKoutee() { const ref = useRef(null) const [token, setToken] = useState('') const [error, setError] = useState('') useEffect(() => { const el = ref.current if (!el) return const onVerified = (e: Event) => { setError('') setToken((e as CustomEvent<{ payload: string }>).detail.payload) } // 令牌过期就必须清空:过期令牌提交到后端一定失败 const onExpired = () => setToken('') const onFailed = (e: Event) => { setToken('') setError((e as CustomEvent<{ code: string }>).detail.code) } el.addEventListener('verified', onVerified) el.addEventListener('expired', onExpired) el.addEventListener('failed', onFailed) return () => { el.removeEventListener('verified', onVerified) el.removeEventListener('expired', onExpired) el.removeEventListener('failed', onFailed) } }, []) // 提交完成后重置:一个 SVT 只能用一次 const reset = useCallback(() => { setToken('') setError('') ref.current?.reset() }, []) return { ref, token, error, reset } } // ---------- ContactForm.tsx ---------- export default function ContactForm() { const { ref, token, error, reset } = useKoutee() const [sending, setSending] = useState(false) async function onSubmit(e: React.FormEvent) { e.preventDefault() if (!token || sending) return setSending(true) try { const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'a@b.com', koutee: token }) }) // SVT 是一次性凭证,且只有 300 秒有效期:校验成功后立刻作废,同一个令牌不能校验两次 if (!res.ok) reset() } finally { setSending(false) } } return (
{/* @ts-expect-error 自定义元素在 JSX 里需要声明类型,或用 ts-expect-error 跳过 */} {error &&

{error}

} ) } ``` ### Vue 3 组合式函数 + SFC 示例,另附 Vite 自定义元素配置。 ```vue // useKoutee.ts — 组合式函数:把 verified / failed / expired 三个事件收敛成响应式状态 import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue' export function useKoutee(el: Ref) { const token = ref('') const error = ref('') const onVerified = (e: Event) => { error.value = '' token.value = (e as CustomEvent<{ payload: string }>).detail.payload } // 令牌过期就必须清空:过期令牌提交到后端一定失败 const onExpired = () => { token.value = '' } const onFailed = (e: Event) => { token.value = '' error.value = (e as CustomEvent<{ code: string }>).detail.code } onMounted(() => { el.value?.addEventListener('verified', onVerified) el.value?.addEventListener('expired', onExpired) el.value?.addEventListener('failed', onFailed) }) onBeforeUnmount(() => { el.value?.removeEventListener('verified', onVerified) el.value?.removeEventListener('expired', onExpired) el.value?.removeEventListener('failed', onFailed) }) // 提交完成后重置:一个 SVT 只能用一次 const reset = () => { token.value = '' error.value = '' ;(el.value as any)?.reset?.() } return { token, error, reset } } // ---------- ContactForm.vue ---------- // ---------- vite.config.ts ---------- // 必须告诉 Vue 编译器 ktcaptcha-widget 是自定义元素,否则会当成未注册组件报警 // vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'ktcaptcha-widget' } } }) ``` ### Next.js App Router 的 Route Handler 服务端校验,密钥不出服务端。 ```ts // app/api/contact/route.ts — Route Handler 做服务端校验,secretKey 只留在服务端 // 注意:secretKey 不能加 NEXT_PUBLIC_ 前缀,否则会被打进前端产物 import { NextResponse } from 'next/server' export const runtime = 'nodejs' const KT_URL = process.env.KT_SYSTEM_URL ?? 'https://challenge.koutee.com' type VerifyResult = | { ok: true; codeok: boolean } | { ok: false; code: number; reason: string; retry: boolean } async function verifyCaptcha(svt: unknown): Promise { if (typeof svt !== 'string' || svt === '') { return { ok: false, code: 4002, reason: 'missing_token', retry: false } } const siteKey = process.env.KT_SITE_KEY const secretKey = process.env.KT_SECRET_KEY if (!siteKey || !secretKey) { console.error('[koutee] siteKey / secretKey 没配好,这是部署问题不是用户问题') return { ok: false, code: 4101, reason: 'config_error', retry: false } } let data: any try { const res = await fetch(`${KT_URL}/api/v1/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + Buffer.from(`${siteKey}:${secretKey}`).toString('base64') }, body: JSON.stringify({ svt }), // 必须设超时:不设的话上游卡住会把你的业务线程一起拖死 signal: AbortSignal.timeout(8000), cache: 'no-store' }) data = await res.json() } catch { // fail-closed:网络异常时按「未通过」处理。若你的业务更看重可用性,可改为 fail-open,但要配合限流与日志告警 return { ok: false, code: 0, reason: 'network_error', retry: true } } if (data?.code === 1000 && data?.data?.verified === true) { return { ok: true, codeok: data.data.codeok === true } } // 按错误码分流:配置错误要告警,限流类错误要让用户稍后重试 if ([4101, 4105, 4301].includes(data?.code)) { return { ok: false, code: data.code, reason: 'config_error', retry: false } } if (data?.code === 4302) { return { ok: false, code: data.code, reason: 'site_not_allowed', retry: false } } if ([4290, 6000, 6003].includes(data?.code)) { return { ok: false, code: data.code, reason: 'try_later', retry: true } } return { ok: false, code: data?.code ?? 0, reason: data?.message ?? 'verify_failed', retry: false } } export async function POST(req: Request) { const body = await req.json().catch(() => ({})) const r = await verifyCaptcha(body.koutee) if (!r.ok) { // retry=true 时返回 503,让前端重新验证后再试;否则返回 400 return NextResponse.json({ error: r.reason }, { status: r.retry ? 503 : 400 }) } // SVT 是一次性凭证,且只有 300 秒有效期:校验成功后立刻作废,同一个令牌不能校验两次 // 幂等建议:用业务唯一键(订单号 / 请求 ID)去重,客户端重试时不要重复执行副作用 return NextResponse.json({ success: true }) } ``` ## API 接口 {#api} RESTful API 接口文档(点击查看示例) ### `POST /api/v1/verify` 你的后端验证 SVT 令牌(Basic Auth 认证)。⚠️ 与上方是同一端点 POST /api/v1/verify,通过携带 Authorization: Basic 区分;验证成功后会一次性消耗该 SVT,消耗后前端方可调用 refresh 接口静默刷新。 #### 请求示例 ```http POST /api/v1/verify Content-Type: application/json Authorization: Basic { "svt": "kt.eyJhbGciOiJFQ0RILUVTIi4uLn0..." } ``` #### 响应示例 ```json { "code": 1000, "message": "验证成功", "timestamp": 1769265698, "data": { "verified": true, "codeok": false } } ``` ### `POST /api/v1/svt/refresh` 静默刷新 SVT。旧 SVT 被后端消耗(调用 POST /api/v1/verify)后,前端可凭旧令牌获取新令牌,无需用户重新验证(最多 5 次)。⚠️ 需要后端先消耗旧 SVT,否则返回 svt_not_consumed 错误。 #### 请求示例 ```http POST /api/v1/svt/refresh Content-Type: application/json X-Site-Key: { "svt": "kt.eyJhbGciOiJFQ0RILUVTIi4uLn0...<旧的koutee字段值(已被后端消耗)>" } ``` #### 响应示例 ```json // 成功:返回新 SVT { "code": 1000, "message": "SVT 刷新成功", "timestamp": 1769265698, "data": { "svt": "kt.eyJhbGciOiJFQ0RILUVTIi4uLn0...<新SVT令牌>" } } // 失败:旧 SVT 未被后端消耗 { "code": 5000, "message": "svt_not_consumed" } ``` ## 服务端验证 {#verification} 验证用户提交的 SVT 令牌(koutee 字段值)。仅支持安全模式:后端持有 secretKey,向验证系统查询令牌结果。vqt 模式已废弃。 ### 验证流程 1. **用户浏览器** — 完成验证,获得 SVT 令牌 2. **网站后端** — 接收 SVT,用 secretKey 查询验证结果 3. **KTCaptcha** — 消耗令牌,返回验证结果 ### SVT 令牌安全特性 | | 旧版(vqt,已废弃) | SVT v2(当前) | | --- | --- | --- | | 令牌长度 | ~24 字符 | 600–800 字符 | | 认证要求 | 无认证(已废弃) | 必须 secretKey | | API 端点 | `/api/v1/verify/status` 410 | `/api/v1/verify` POST | | 防重放 | ❌ | ✅ jti Redis NX | | 安全评级 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ### 失败后的两种处理方案 表单提交失败(如密码错误)时,开发者可选择以下任一方案: **方案 A — 直接重置(无需后端适配):**开发者在 catch 块调用 `captchaRef.reset()` 重置验证码,让用户重新完成验证获得新 SVT。简单可靠,适合大多数场景。 **方案 B — 无感刷新(需后端适配):**开发者在 catch 块调用 `captchaRef.refresh()`,Widget 随即在后台向 `POST /api/v1/svt/refresh` 发请求,静默获取新 SVT,用户无需重新验证(最多 5 次)。 ⚠️ **前提:**后端必须在返回错误响应前调用 `POST /api/v1/verify` 验证并消耗旧 SVT。若后端未集成验证(旧 SVT 未被消耗),refresh 将返回 `svt_not_consumed`,widget 自动回退到方案 A(reset)。 ### 1. 方式一:SVT + secretKey 验证(唯一安全方式) 使用 siteKey + secretKey 通过 Basic Auth 向 POST /api/v1/verify 提交 SVT,验证系统消耗令牌并返回结果。secretKey 仅存储于服务端,不得暴露给前端。 > **重要安全提醒** > > secretKey 是你的私有密钥,。请使用环境变量存储。绝不能暴露给前端或提交到代码仓库 ```javascript // 方式一:使用 secretKey 验证(安全模式,唯一推荐方式) // secretKey 是网站密钥,只存储在服务端,绝不能暴露给前端 // koutee 字段包含 SVT 令牌(kt. 开头的 JWE,600-800 字符) const SITE_KEY = process.env.KT_SITE_KEY; // 公开密钥 const SECRET_KEY = process.env.KT_SECRET_KEY; // 私有密钥(保密!) app.post('/api/submit', async (req, res) => { const { koutee } = req.body; // SVT 令牌(kt. 开头的长字符串) // 使用 Basic Auth 认证调用验证接口 const auth = Buffer.from(`${SITE_KEY}:${SECRET_KEY}`).toString('base64'); const response = await fetch('https://challenge.koutee.com/api/v1/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${auth}` }, body: JSON.stringify({ svt: koutee }) // ← 使用 svt 字段 }); const data = await response.json(); // 响应格式: { code: 1000, message: "...", data: { verified: true, codeok: bool } } if (data.code !== 1000 || !data.data?.verified) { return res.status(400).json({ error: data.message || '验证失败' }); } // 验证通过,处理业务逻辑 res.json({ success: true }); }) ``` ## 后端集成示例 {#backend} 选择你的后端语言/框架,复制代码即可完成对接。所有示例均使用统一的 SVT 令牌体系,只需配置 siteKey 和 secretKey。 ### 对接步骤 1. 注册账号 → 创建应用 → 获取 siteKey(公开)和 secretKey(保密) 2. 在 HTML 中嵌入验证码 widget(前端,填写 siteKey) 3. 表单提交时,`koutee` 字段自动携带验证令牌 4. 后端读取 `koutee` 字段,调用 API 验证(需要 secretKey) ### Node.js ```javascript // Node.js 18+ / Express — 完整可复制版本(含超时、错误分支、幂等提示) // npm install express const express = require('express'); const app = express(); app.use(express.json()); const SITE_KEY = process.env.KT_SITE_KEY; // 在控制台获取 const SECRET_KEY = process.env.KT_SECRET_KEY; // 保密,绝不暴露给前端! const KT_URL = process.env.KT_SYSTEM_URL || 'https://challenge.koutee.com'; const TIMEOUT_MS = 8000; // 统一返回结构,业务层只看 ok,需要细分时看 reason / retry // { ok: true } → 验证通过 // { ok: false, code, reason, retry } → 验证不通过 async function verifyCaptcha(svt) { if (!svt || typeof svt !== 'string') { return { ok: false, code: 4002, reason: 'missing_token', retry: false }; } // 必须设超时:不设的话上游卡住会把你的业务线程一起拖死 const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), TIMEOUT_MS); let data; try { const auth = Buffer.from(SITE_KEY + ':' + SECRET_KEY).toString('base64'); const res = await fetch(KT_URL + '/api/v1/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Basic ' + auth }, body: JSON.stringify({ svt }), signal: ac.signal }); data = await res.json(); } catch (err) { // fail-closed:网络异常时按「未通过」处理。若你的业务更看重可用性,可改为 fail-open,但要配合限流与日志告警 return { ok: false, code: 0, reason: 'network_error', retry: true }; } finally { clearTimeout(timer); } if (data.code === 1000 && data.data?.verified === true) { // data.data.codeok = codeok=true 表示用户额外通过了图形码强校验,可用于高危操作二次判定 return { ok: true, codeok: data.data.codeok === true }; } // 按错误码分流:配置错误要告警,限流类错误要让用户稍后重试 switch (data.code) { case 4101: // 4101/4105/4301:密钥无效或站点被禁用 → 记录告警,不要提示用户重试 case 4105: case 4301: console.error('[koutee] siteKey / secretKey 没配好,这是部署问题不是用户问题', data.message); return { ok: false, code: data.code, reason: 'config_error', retry: false }; case 4302: // 4302:请求来源域名不在白名单 → 去控制台补上域名 return { ok: false, code: data.code, reason: 'site_not_allowed', retry: false }; case 4290: // 4290/6000/6003:被限流或服务暂时不可用 → 返回 503 让客户端重试 case 6000: case 6003: return { ok: false, code: data.code, reason: 'try_later', retry: true }; default: // 其余错误码:令牌过期、已被使用、签名不符等,让用户重新验证 return { ok: false, code: data.code, reason: data.message || 'verify_failed', retry: false }; } } app.post('/api/contact', async (req, res) => { const { email, message, koutee } = req.body; // 1. 验证验证码(必须在处理业务逻辑前) const r = await verifyCaptcha(koutee); if (!r.ok) { // retry=true 时返回 503,让前端重新验证后再试;否则返回 400 const status = r.retry ? 503 : 400; return res.status(status).json({ error: '验证码验证失败,请重新验证', reason: r.reason }); } // SVT 是一次性凭证,且只有 300 秒有效期:校验成功后立刻作废,同一个令牌不能校验两次 // 幂等建议:用业务唯一键(订单号 / 请求 ID)去重,客户端重试时不要重复执行副作用 console.log('Contact from:', email, message); res.json({ success: true, message: '提交成功' }); }); app.listen(3000); ``` ### Laravel ```php [ // 'site_key' => env('KT_SITE_KEY'), // 'secret_key' => env('KT_SECRET_KEY'), // 'system_url' => env('KT_SYSTEM_URL', 'https://challenge.koutee.com'), // ], // ---------- app/Services/KouteeCaptcha.php ---------- namespace App\Services; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class KouteeCaptcha { /** * 统一返回结构,业务层只看 ok,需要细分时看 reason / retry * ['ok' => true, 'codeok' => bool] * ['ok' => false, 'code' => int, 'reason' => string, 'retry' => bool] */ public function verify(?string $svt): array { if (empty($svt)) { return ['ok' => false, 'code' => 4002, 'reason' => 'missing_token', 'retry' => false]; } $siteKey = config('services.koutee.site_key'); $secretKey = config('services.koutee.secret_key'); $systemUrl = config('services.koutee.system_url'); try { // 必须设超时:不设的话上游卡住会把你的业务线程一起拖死 $response = Http::timeout(8) ->connectTimeout(3) ->withHeaders([ 'Content-Type' => 'application/json', 'Authorization' => 'Basic ' . base64_encode($siteKey . ':' . $secretKey), ]) ->post($systemUrl . '/api/v1/verify', ['svt' => $svt]); } catch (\Throwable $e) { // fail-closed:网络异常时按「未通过」处理。若你的业务更看重可用性,可改为 fail-open,但要配合限流与日志告警 Log::warning('[koutee] ' . $e->getMessage()); return ['ok' => false, 'code' => 0, 'reason' => 'network_error', 'retry' => true]; } $data = $response->json() ?? []; $code = (int) ($data['code'] ?? 0); // 响应格式: { "code": 1000, "data": { "verified": true, "codeok": false } } if ($code === 1000 && ($data['data']['verified'] ?? false) === true) { // codeok = codeok=true 表示用户额外通过了图形码强校验,可用于高危操作二次判定 return ['ok' => true, 'codeok' => ($data['data']['codeok'] ?? false) === true]; } // 按错误码分流:配置错误要告警,限流类错误要让用户稍后重试 return match (true) { // 4101/4105/4301:密钥无效或站点被禁用 → 记录告警,不要提示用户重试 in_array($code, [4101, 4105, 4301], true) => ['ok' => false, 'code' => $code, 'reason' => 'config_error', 'retry' => false], // 4302:请求来源域名不在白名单 → 去控制台补上域名 $code === 4302 => ['ok' => false, 'code' => $code, 'reason' => 'site_not_allowed', 'retry' => false], // 4290/6000/6003:被限流或服务暂时不可用 → 返回 503 让客户端重试 in_array($code, [4290, 6000, 6003], true) => ['ok' => false, 'code' => $code, 'reason' => 'try_later', 'retry' => true], // 其余错误码:令牌过期、已被使用、签名不符等,让用户重新验证 default => ['ok' => false, 'code' => $code, 'reason' => $data['message'] ?? 'verify_failed', 'retry' => false], }; } } // ---------- 在 Controller 中使用: ---------- public function submitContact(Request $request, KouteeCaptcha $captcha) { $request->validate([ 'email' => 'required|email', 'message' => 'required|min:10', 'koutee' => 'required|string', // 验证码字段 ]); $r = $captcha->verify($request->input('koutee')); if (!$r['ok']) { // retry=true 时返回 503,让前端重新验证后再试;否则返回 400 $status = $r['retry'] ? 503 : 422; return response()->json([ 'error' => '验证失败', 'reason' => $r['reason'], ], $status); } // SVT 是一次性凭证,且只有 300 秒有效期:校验成功后立刻作废,同一个令牌不能校验两次 // 幂等建议:用业务唯一键(订单号 / 请求 ID)去重,客户端重试时不要重复执行副作用 return response()->json(['success' => true]); } ``` ### PHP ```php true, 'codeok' => bool] * ['ok' => false, 'code' => int, 'reason' => string, 'retry' => bool] */ function kt_verify(string $svt): array { $siteKey = getenv('KT_SITE_KEY'); $secretKey = getenv('KT_SECRET_KEY'); if (!$siteKey || !$secretKey) { error_log('[koutee] ' . 'siteKey / secretKey 没配好,这是部署问题不是用户问题'); return ['ok' => false, 'code' => 4101, 'reason' => 'config_error', 'retry' => false]; } if ($svt === '') { return ['ok' => false, 'code' => 4002, 'reason' => 'missing_token', 'retry' => false]; } $body = json_encode(['svt' => $svt]); $ch = curl_init(KT_URL . '/api/v1/verify'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, // 必须设超时:不设的话上游卡住会把你的业务线程一起拖死 CURLOPT_TIMEOUT => KT_TIMEOUT, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Basic ' . base64_encode($siteKey . ':' . $secretKey), 'Content-Length: ' . strlen($body), ], ]); $resp = curl_exec($ch); $err = curl_error($ch); curl_close($ch); if ($resp === false || $resp === '') { // fail-closed:网络异常时按「未通过」处理。若你的业务更看重可用性,可改为 fail-open,但要配合限流与日志告警 error_log('[koutee] ' . $err); return ['ok' => false, 'code' => 0, 'reason' => 'network_error', 'retry' => true]; } $data = json_decode($resp, true); if (!is_array($data)) { return ['ok' => false, 'code' => 0, 'reason' => 'bad_response', 'retry' => true]; } $code = (int) ($data['code'] ?? 0); if ($code === 1000 && ($data['data']['verified'] ?? false) === true) { // codeok = codeok=true 表示用户额外通过了图形码强校验,可用于高危操作二次判定 return ['ok' => true, 'codeok' => ($data['data']['codeok'] ?? false) === true]; } // 按错误码分流:配置错误要告警,限流类错误要让用户稍后重试 if (in_array($code, [4101, 4105, 4301], true)) { // 4101/4105/4301:密钥无效或站点被禁用 → 记录告警,不要提示用户重试 return ['ok' => false, 'code' => $code, 'reason' => 'config_error', 'retry' => false]; } if ($code === 4302) { // 4302:请求来源域名不在白名单 → 去控制台补上域名 return ['ok' => false, 'code' => $code, 'reason' => 'site_not_allowed', 'retry' => false]; } if (in_array($code, [4290, 6000, 6003], true)) { // 4290/6000/6003:被限流或服务暂时不可用 → 返回 503 让客户端重试 return ['ok' => false, 'code' => $code, 'reason' => 'try_later', 'retry' => true]; } // 其余错误码:令牌过期、已被使用、签名不符等,让用户重新验证 return ['ok' => false, 'code' => $code, 'reason' => $data['message'] ?? 'verify_failed', 'retry' => false]; } // ---------- 在表单处理中使用: ---------- if ($_SERVER['REQUEST_METHOD'] === 'POST') { header('Content-Type: application/json'); $r = kt_verify((string) ($_POST['koutee'] ?? '')); if (!$r['ok']) { // retry=true 时返回 503,让前端重新验证后再试;否则返回 400 http_response_code($r['retry'] ? 503 : 400); echo json_encode(['error' => '验证码验证失败', 'reason' => $r['reason']]); exit; } // SVT 是一次性凭证,且只有 300 秒有效期:校验成功后立刻作废,同一个令牌不能校验两次 // 幂等建议:用业务唯一键(订单号 / 请求 ID)去重,客户端重试时不要重复执行副作用 echo json_encode(['success' => true]); } ``` ### Python ```python # Python + Flask — 完整可复制版本(含超时、错误分支、幂等提示) # pip install flask requests import os import logging from base64 import b64encode from dataclasses import dataclass import requests from flask import Flask, request, jsonify app = Flask(__name__) log = logging.getLogger(__name__) SITE_KEY = os.environ.get('KT_SITE_KEY') SECRET_KEY = os.environ.get('KT_SECRET_KEY') # 保密! KT_URL = os.environ.get('KT_SYSTEM_URL', 'https://challenge.koutee.com') # 必须设超时:不设的话上游卡住会把你的业务线程一起拖死 TIMEOUT = (3, 8) # (connect, read) CONFIG_ERRORS = {4101, 4105, 4301} RETRY_ERRORS = {4290, 6000, 6003} @dataclass class VerifyResult: ok: bool code: int = 0 reason: str = '' retry: bool = False codeok: bool = False def verify_captcha(svt: str) -> VerifyResult: """统一返回结构,业务层只看 ok,需要细分时看 reason / retry""" if not SITE_KEY or not SECRET_KEY: log.error('[koutee] siteKey / secretKey 没配好,这是部署问题不是用户问题') return VerifyResult(ok=False, code=4101, reason='config_error') if not svt: return VerifyResult(ok=False, code=4002, reason='missing_token') auth = b64encode(f"{SITE_KEY}:{SECRET_KEY}".encode()).decode() try: resp = requests.post( f'{KT_URL}/api/v1/verify', json={'svt': svt}, headers={'Authorization': f'Basic {auth}'}, timeout=TIMEOUT, ) data = resp.json() except (requests.RequestException, ValueError) as exc: # fail-closed:网络异常时按「未通过」处理。若你的业务更看重可用性,可改为 fail-open,但要配合限流与日志告警 log.warning('[koutee] %s', exc) return VerifyResult(ok=False, reason='network_error', retry=True) code = int(data.get('code') or 0) payload = data.get('data') or {} if code == 1000 and payload.get('verified') is True: # codeok = codeok=true 表示用户额外通过了图形码强校验,可用于高危操作二次判定 return VerifyResult(ok=True, code=code, codeok=payload.get('codeok') is True) # 按错误码分流:配置错误要告警,限流类错误要让用户稍后重试 if code in CONFIG_ERRORS: # 4101/4105/4301:密钥无效或站点被禁用 → 记录告警,不要提示用户重试 log.error('[koutee] %s', data.get('message')) return VerifyResult(ok=False, code=code, reason='config_error') if code == 4302: # 4302:请求来源域名不在白名单 → 去控制台补上域名 return VerifyResult(ok=False, code=code, reason='site_not_allowed') if code in RETRY_ERRORS: # 4290/6000/6003:被限流或服务暂时不可用 → 返回 503 让客户端重试 return VerifyResult(ok=False, code=code, reason='try_later', retry=True) # 其余错误码:令牌过期、已被使用、签名不符等,让用户重新验证 return VerifyResult(ok=False, code=code, reason=data.get('message') or 'verify_failed') @app.route('/api/contact', methods=['POST']) def contact(): body = request.get_json(silent=True) or {} r = verify_captcha(body.get('koutee', '')) if not r.ok: # retry=true 时返回 503,让前端重新验证后再试;否则返回 400 return jsonify({ 'error': '验证码验证失败', 'reason': r.reason, }), 503 if r.retry else 400 # SVT 是一次性凭证,且只有 300 秒有效期:校验成功后立刻作废,同一个令牌不能校验两次 # 幂等建议:用业务唯一键(订单号 / 请求 ID)去重,客户端重试时不要重复执行副作用 return jsonify({'success': True}) ``` ### 常见问题 | 问题 | 原因 | 解决方案 | | --- | --- | --- | | code: 4xx / 验证失败 | secretKey 错误或 siteKey 不匹配 | 检查环境变量 KT_SITE_KEY 和 KT_SECRET_KEY | | svt_replayed | 同一令牌重复提交过多 | 令牌最多使用 5 次,请引导用户重新验证 | | svt_expired | 令牌超时(默认5分钟) | 引导用户重新验证后再提交 | | koutee 字段为空 | 前端未等待验证完成就提交 | 提交前检查 input[name="koutee"] 是否有值 | 注意:验证令牌支持在 TTL 内最多刷新 5 次,登录/表单提交失败后 widget 会自动静默刷新,无需用户重新验证。 ## Widget JS API {#widgetapi} 组件对外暴露四个事件与四个方法,足够覆盖 AJAX 提交、多步表单、语言切换等场景。 ### 事件 | 事件 | detail 结构 | 触发时机 / 建议处理 | | --- | --- | --- | | `verified` | `{ payload: string }` | 验证通过。此时才允许提交,payload 就是要发给后端的 SVT。 | | `silent-passed` | `{ tier: string }` | 无感通过(用户未看到题目),在 verified 之后额外触发。令牌仍从 verified 事件取,这个事件只用于埋点。 | | `failed` | `{ code: string, message: string }` | 验证失败或提交被拒。禁用提交按钮,提示用户重试。 | | `expired` | `{ reason: 'timeout' \| 'svt_expired' }` | 令牌失效(60 秒未完成,或 SVT 续期失败)。清空令牌并禁用提交。 | | `continue` | `{ payload: string, reason: string, via: string }` | 自动提交找不到可点的目标,或目标仍是 disabled。payload 仍是 SVT,reason 为 no_target / target_disabled。成功点到按钮或交表时不会发这个事件。 | ```javascript const widget = document.querySelector('ktcaptcha-widget'); // verified:拿到 SVT,此时才允许提交 widget.addEventListener('verified', (e) => { const svt = e.detail.payload; // 这就是要发给你后端的令牌 document.querySelector('#submitBtn').disabled = false; }); // failed:验证失败,禁用提交并等用户重试 widget.addEventListener('failed', (e) => { console.warn('captcha failed:', e.detail.code, e.detail.message); document.querySelector('#submitBtn').disabled = true; }); // expired:令牌失效,必须禁用提交 // reason = 'timeout' → 60 秒内没完成验证 // reason = 'svt_expired' → SVT 自动续期失败 widget.addEventListener('expired', (e) => { console.warn('captcha expired:', e.detail.reason); document.querySelector('#submitBtn').disabled = true; }); // silent-passed:无感通过,用户没看到任何题目 widget.addEventListener('silent-passed', (e) => { // 这个事件不带令牌,令牌仍然从上面的 verified 事件拿 console.log('silent pass, tier =', e.detail.tier); }); // continue:自动提交找不到可点目标时的兜底 widget.addEventListener('continue', (e) => { // reason 为 no_target 或 target_disabled;成功交表或点到按钮时不会到这里 console.log('continue', e.detail.reason, e.detail.via); }); ``` ### 方法 | 方法 | 返回值 | 触发时机 / 建议处理 | | --- | --- | --- | | `reset()` | `void` | 清掉本轮 SVT 和成功态(含 iframe),用户需再验一次。提交成功后或续期失败时调用。 | | `refresh()` | `Promise` | 静默续期当前 SVT,不打扰用户。返回 false 时退回 reset()。 | | `setLang(lang)` | `boolean` | 运行时切换界面语言。语言不受支持时返回 false。 | | `ensureVerified()` | `Promise` | 以代码方式触发验证流程(等价于用户点击复选框),返回当前是否已通过。 | ```javascript const widget = document.querySelector('ktcaptcha-widget'); // reset() — 清空状态并重新出题,提交成功后调用 widget.reset(); // refresh() — 静默续期当前 SVT,返回是否成功 const ok = await widget.refresh(); if (!ok) widget.reset(); // 续期失败就退回完整重置 // setLang(lang) — 运行时切换语言 widget.setLang('pt-BR'); // 返回 false 表示语言不受支持 // 也可以直接从隐藏 input 读取令牌 const svt = document.querySelector('input[name="koutee"]').value; ``` ### AJAX 提交完整示例 事件驱动按钮状态,后端拒绝时先尝试续期,成功后立刻重置。 ```javascript // AJAX 提交完整示例:事件驱动 + 失败续期 + 成功重置 const widget = document.querySelector('ktcaptcha-widget'); const form = document.querySelector('#contactForm'); const btn = document.querySelector('#submitBtn'); let token = ''; btn.disabled = true; widget.addEventListener('verified', (e) => { token = e.detail.payload; btn.disabled = false; }); widget.addEventListener('failed', () => { token = ''; btn.disabled = true; }); widget.addEventListener('expired', () => { token = ''; btn.disabled = true; }); form.addEventListener('submit', async (e) => { e.preventDefault(); if (!token) return; btn.disabled = true; try { const res = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: form.email.value, koutee: token }) }); if (res.ok) { // 成功后重置:SVT 已被后端消费,不能复用 token = ''; widget.reset(); return; } // 后端拒绝时先尝试续期,避免让用户重新做题 const refreshed = await widget.refresh(); if (!refreshed) { token = ''; widget.reset(); } } catch (err) { btn.disabled = false; // 网络异常保留令牌,让用户直接重试提交 } }); ``` > **SVT 生命周期** > > SVT 签发后只有 300 秒有效期,且是一次性凭证:后端校验成功即作废,同一个令牌不能校验两次。组件会在临近过期时自动续期,但如果用户长时间停留在页面上不提交,仍可能收到 expired 事件——这时必须清空本地令牌。 ## 浏览器兼容 {#compat} 支持所有现代浏览器 ### 桌面浏览器 | Browser | Version | | --- | --- | | Chrome | 79+ | | Firefox | 68+ | | Safari | 13+ | | Edge | 79+ | ### 移动端 验证码组件在主流移动浏览器上完美运行,自适应触屏交互。 | Browser | Version | | --- | --- | | iOS Safari | 13+ | | Android Chrome | 79+ | ### 不再支持 Internet Explorer 11 及更早版本已停止支持。请引导用户升级到现代浏览器(Edge、Chrome、Firefox 等),以获得最佳体验和安全性。 ## 语言 / 国际化 {#i18n} 验证码组件内置多语言支持,可通过属性或 API 动态切换 ### 支持的语言 组件内置以下语言包,开箱即用: | Code | Language | | --- | --- | | zh-CN | 简体中文(默认) | | en | 英语 | | pt-BR | 巴西葡萄牙语 | ### 1. 设置初始语言 通过 HTML 属性设置组件的初始语言: ```html ``` ### 2. 动态切换语言 使用 JavaScript API 在运行时切换语言,无需销毁重建组件: ```javascript // JavaScript const widget = document.querySelector('ktcaptcha-widget'); // Method 1: setAttribute widget.setAttribute('lang', 'en'); // Method 2: setLang() API const success = widget.setLang('pt-BR'); console.log(success); // true or false ``` > **说明** setLang() 返回 boolean:成功返回 true,语言不支持或处于锁定状态返回 false。 ### 语言锁定机制 为保证用户体验一致性,组件在特定阶段会自动锁定语言切换: | 说明 | 状态 | | --- | --- | | 空闲状态(未点击验证) | ✅ 可切换 | | 正在验证(PoW 计算中) | ✅ 可切换 | | 图片验证码弹窗显示中 | 🔒 已锁定 | | 验证通过后 | ✅ 可切换 | 锁定仅在图片验证码弹窗打开期间生效,弹窗关闭后自动解锁。PoW 计算阶段(显示「正在验证…」)允许切换,切换后验证文案会实时更新为新语言。 ### 3. Vue 集成示例 在 Vue 项目中,可以将语言与应用的 i18n 联动: ```vue ```