# 开发者文档 (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 场景
对话框里没有
```
> **不要用它替代后端校验**
>
> 自动提交只是省掉一次点击,令牌本身仍然必须由你的服务端调用 /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 (
)
}
```
### 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
```