# Corptcha 人机验证接入指南（AI Agent 版）

> 本文件是给 AI 编码助手（Trae / Claude Code / Cursor / Codex 等）读取的完整接入规范。
> 请 AI 严格按以下步骤为用户项目接入 Corptcha，涉及"你的 Site ID / 你的 Secret"之处，先向用户索取或在项目配置中查找，找不到则提示用户到控制台创建站点。

## 产品简介

Corptcha 是免安装、无侵入的人机验证服务。接入分四步：
1. 前端引入 SDK 脚本（一个 `<script>`，无其它依赖）
2. 前端挂载验证组件，用户完成验证后通过 `onSuccess` 拿到**一次性验证令牌 token**
3. 前端把 token 随业务表单提交给后端
4. **后端必须调用验证服务接口核验 token，通过后才执行业务逻辑**（禁止信任前端传来的任何布尔值）

## 关键信息

- 前端 SDK（国内 CDN，文件名固定）：`https://res.25y.cn/corptcha/corptcha.iife.js`
- 验证服务 API 地址（apiBaseUrl）：`https://cpt-api.25y.cn`
- 校验接口：`POST https://cpt-api.25y.cn/v1/verify`
- 前端全局对象：`window.Corptcha`，方法 `Corptcha.render(container, options)`
- Site ID 形如 `cpt_xxxxxxxxxxxx`（公开，可放前端）；Secret 是站点密钥（**只能出现在后端**）
- 控制台：https://dash.corptcha.com（创建站点、获取 Site ID 与 Secret）

## 第 1 步：前端引入 SDK

在目标 HTML 页面的 `</body>` 前添加（或使用 `defer`）：

```html
<script src="https://res.25y.cn/corptcha/corptcha.iife.js"></script>
```

若是 Vue / React 等单页应用，建议在应用入口（如 `index.html`、`main.ts`、`public/index.html`）中引入。

## 第 2 步：挂载验证组件

准备容器节点与隐藏 token 字段：

```html
<div id="corptcha-widget"></div>
<input type="hidden" id="captcha-token" name="captchaToken" />
```

初始化：

```js
const widget = Corptcha.render(document.querySelector('#corptcha-widget'), {
  apiBaseUrl: 'https://cpt-api.25y.cn',
  siteKey: '你的 Site ID',   // 形如 cpt_xxxxxxxxxxxx，从控制台「站点管理」获取
  purpose: 'login',          // 与后端校验时保持一致：login / register / comment 等
  language: 'zh-CN',
  theme: { mode: 'auto' },
  onSuccess: (token) => {
    // 把一次性 token 写入表单，随业务请求提交给后端
    document.querySelector('#captcha-token').value = token;
  },
  onError: (error) => {
    console.error(error.errorCode, error.message);
  },
  onExpired: () => {
    widget.execute(); // token 过期，重新验证
  },
});
```

常用选项：

| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `apiBaseUrl` | string | 验证服务 API 地址 |
| `siteKey` | string | 站点 Site ID |
| `purpose` | string | 场景标识，前后端必须一致 |
| `language` | 'zh-CN' \| 'en-US' | 界面语言 |
| `theme` | object | `{ mode, accentColor, borderRadius, fontFamily }` |
| `autoExecute` | boolean | 挂载后自动开始验证 |
| `onSuccess` / `onError` / `onStateChange` / `onExpired` | function | 回调 |

实例方法：`execute()`（手动开始）、`reset()`（重置）、`destroy()`（销毁）。

## 第 3 步：后端校验 token

前端把 `captchaToken` 随业务请求一起发给后端。后端调接口：

- **接口**：`POST {apiBaseUrl}/v1/verify`
- **鉴权**：请求头 `Authorization: Bearer {Secret}`
- **请求体**（JSON）：`{ "token": "...", "purpose": "login", "siteKey": "cpt_xxx" }`
- **成功**：HTTP 200，`{ "success": true, "siteId": "...", "purpose": "login", "issuedAt": "...", "riskLevel": "low|medium|high|critical", "requestId": "..." }`
- **失败**：HTTP 4xx，`{ "errorCode": "...", "message": "...", "requestId": "..." }`

常见错误码：

| errorCode | 含义 |
| --- | --- |
| `TOKEN_NOT_FOUND` | 令牌无效或已被使用（一次性） |
| `TOKEN_EXPIRED` | 令牌已过期 |
| `TOKEN_PURPOSE_MISMATCH` | purpose 与签发时不一致 |
| `TOKEN_SITE_MISMATCH` | 站点不匹配 |
| `UNAUTHORIZED` | Secret 错误或缺失 |

多语言校验示例（任选与用户后端匹配的语言，逻辑相同：非 200 或 `success !== true` 即拒绝）：

### Node.js / TypeScript

```js
const VERIFY_URL = 'https://cpt-api.25y.cn/v1/verify';

async function verifyCaptcha(token, purpose, siteKey, secret) {
  const response = await fetch(VERIFY_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json', authorization: `Bearer ${secret}` },
    body: JSON.stringify({ token, purpose, siteKey }),
  });
  const result = await response.json();
  if (!response.ok || result.success !== true) {
    throw new Error(`人机验证未通过：${result.errorCode ?? 'UNKNOWN'}`);
  }
  return result; // siteId / purpose / riskLevel
}
```

### Python

```python
import requests

def verify_captcha(token, purpose, site_key, secret):
    resp = requests.post(
        "https://cpt-api.25y.cn/v1/verify",
        headers={"content-type": "application/json", "authorization": f"Bearer {secret}"},
        json={"token": token, "purpose": purpose, "siteKey": site_key},
        timeout=5,
    )
    result = resp.json()
    if resp.status_code != 200 or result.get("success") is not True:
        raise ValueError(f"人机验证未通过：{result.get('errorCode', 'UNKNOWN')}")
    return result
```

### Java

```java
// okhttp 4.x + gson
public static JsonObject verifyCaptcha(String token, String purpose, String siteKey, String secret) throws Exception {
    OkHttpClient client = new OkHttpClient();
    JsonObject body = new JsonObject();
    body.addProperty("token", token);
    body.addProperty("purpose", purpose);
    body.addProperty("siteKey", siteKey);
    Request request = new Request.Builder()
            .url("https://cpt-api.25y.cn/v1/verify")
            .post(RequestBody.create(body.toString(), MediaType.get("application/json")))
            .header("authorization", "Bearer " + secret)
            .build();
    try (Response response = client.newCall(request).execute()) {
        JsonObject result = JsonParser.parseString(response.body().string()).getAsJsonObject();
        if (!response.isSuccessful() || !result.get("success").getAsBoolean()) {
            throw new IllegalStateException("人机验证未通过：" + result.get("errorCode").getAsString());
        }
        return result;
    }
}
```

### Go

```go
func verifyCaptcha(token, purpose, siteKey, secret string) error {
	payload, _ := json.Marshal(map[string]string{"token": token, "purpose": purpose, "siteKey": siteKey})
	req, _ := http.NewRequest(http.MethodPost, "https://cpt-api.25y.cn/v1/verify", bytes.NewReader(payload))
	req.Header.Set("content-type", "application/json")
	req.Header.Set("authorization", "Bearer "+secret)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("人机验证未通过")
	}
	return nil
}
```

### PHP

```php
function verifyCaptcha(string $token, string $purpose, string $siteKey, string $secret): array {
    $ch = curl_init('https://cpt-api.25y.cn/v1/verify');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => json_encode(['token' => $token, 'purpose' => $purpose, 'siteKey' => $siteKey]),
        CURLOPT_HTTPHEADER => ['content-type: application/json', 'authorization: Bearer ' . $secret],
        CURLOPT_TIMEOUT => 5,
    ]);
    $response = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    $result = json_decode($response, true);
    if ($status !== 200 || ($result['success'] ?? false) !== true) {
        throw new RuntimeException('人机验证未通过');
    }
    return $result;
}
```

## 接入检查清单

- [ ] SDK 脚本已引入且无报错
- [ ] `siteKey` 使用真实的 Site ID（`cpt_` 开头）
- [ ] `purpose` 在前端初始化与后端校验时一致
- [ ] 后端校验使用站点 Secret 作为 `Bearer` 令牌，Secret 未出现在前端代码
- [ ] 后端在非 200 或 `success !== true` 时拒绝业务请求
- [ ] 未信任前端传来的布尔值（如 `captchaPassed: true`）
- [ ] 校验失败时引导用户重新验证（调用 `widget.reset()` 或 `widget.execute()`）

## 排错

| 现象 | 原因与处理 |
| --- | --- |
| SDK 脚本 404 | CDN 地址写错；确认 `https://res.25y.cn/corptcha/corptcha.iife.js` 可访问 |
| `Corptcha is not defined` | 脚本未加载完成就执行，加 `defer` 或把初始化代码放在脚本引入之后 |
| 接口返回 `TOKEN_NOT_FOUND` | token 已使用过（一次性）或根本没拿到有效 token；重新验证 |
| 接口返回 `TOKEN_PURPOSE_MISMATCH` | 前端 `purpose` 与后端校验传的 `purpose` 不一致 |
| 接口返回 `UNAUTHORIZED` | Secret 错误；在控制台「站点管理」重新复制 |
| 前端 403 跨域 | 确认请求走的是 `https://cpt-api.25y.cn`（widget 接口已开放任意站点来源）；自建部署时确认 CORS 配置 |

## 控制台操作（给用户的操作指引）

1. 登录 https://dash.corptcha.com
2. 进入「站点管理」→ 新建站点，复制 Site ID 与 Secret
3. 可在「配置」中调整主题与挑战类型
4. 在「数据大盘」与「验证流水」中查看接入效果
