Spring Boot 3 无感验证码集成:Cloudflare Turnstile 快速接入指南

Spring Boot 3 无感验证码集成:Cloudflare Turnstile 完整实战

本文摘要

告别滑动拼图,拥抱 “零点击” 验证体验。本文将带你集成 Cloudflare Turnstile 无感验证码,并通过策略模式预留 Google reCAPTCHA v3 的扩展能力。完全免费、无限调用、国内可用。


第一章. 验证服务商速览与选型

本章不教接入,只帮你快速决策 “用哪家”。如果你已经确定使用 Cloudflare Turnstile,可以直接跳到第二章。


1.1. 永久免费方案

市面上真正 “永久免费且额度充足” 的无感验证服务,只有两家值得考虑:

服务商免费额度国内可用验证方式核心特点
Cloudflare Turnstile无限制✅ 可用零点击 / 低风险点击确认隐私优先、体验极佳、本文首推
Google reCAPTCHA v3100 万次/月❌ 不可用纯后台评分(0.0-1.0)行业标准、仅限海外业务

选型建议

  • 如果你的用户在国内,Cloudflare Turnstile 是唯一选择
  • 如果你的产品纯面向海外,两者都可以,但 Turnstile 的用户体验更好

1.2. 国内厂商试用政策

如果你的业务对 “数据不出境” 有强要求,或者需要与国内云生态深度集成,可以考虑以下厂商。但请注意,它们都没有永久免费额度,仅提供短期试用:

服务商免费/试用政策官网
腾讯云天御新用户 2 万次 / 7 天体验包
网易易盾企业认证后 1-2 周试用
顶象基础版免费试用(功能受限)
阿里云验证码无永久免费,按量付费约 ¥0.7 / 千次

1.3. 本文技术选型

基于以上分析,本文的技术方案如下:

  • 主讲:Cloudflare Turnstile —— 免费、国内可用、体验最佳
  • 扩展:Google reCAPTCHA v3 —— 通过策略模式预留接口,代码层面兼容,但不展开讲解

1.3.1. 本节小结

决策点结论
国内业务首选Cloudflare Turnstile
海外业务备选Google reCAPTCHA v3
国内厂商仅试用,无永久免费

第二章. Cloudflare Turnstile 密钥获取

在开始编码之前,我们需要先在 Cloudflare 控制台创建 “小组件” 并获取密钥。这个过程大约需要 3 分钟。


2.1. 进入 Turnstile 控制台

步骤 1:登录 Cloudflare

访问 Cloudflare Dashboard,使用你的账号登录。

步骤 2:找到 Turnstile 入口

登录后,在左侧菜单栏的 “最近访问” 区域下方找到 Turnstile(标注为 “应用程序安全”),点击进入。

如果左侧没有显示,可以点击顶部搜索框(快捷键 Ctrl + K),输入 “Turnstile” 快速定位。


2.2. 添加小组件

进入 Turnstile 页面后,点击 添加小组件 按钮,进入配置页面。

步骤 1:填写小组件名称

在 “小组件名称” 输入框中填写一个便于识别的名称,例如:

1
本地开发测试

这个名称仅用于控制台管理,不会影响实际功能。

步骤 2:添加主机名

点击 + 添加主机名 按钮,在弹出的输入框中填写:

1
localhost

本地开发必须添加 localhost,否则验证码会报域名不匹配的错误。生产环境需要添加你的实际域名。

步骤 3:选择小组件模式

页面下方提供了三种模式,根据你的需求选择:

模式说明推荐场景
托管(推荐)Cloudflare 自动决定是否需要用户交互。大多数情况下无感通过,可疑时会弹出确认框通用场景,平衡安全与体验
非交互式完全非交互式质询,用户只会看到一个加载条的小组件对体验有极致要求
不可见不需要交互的无可见质询高级场景,需要纯 JS 调用

本文选择 托管 模式。

步骤 4:预先许可设置

页面底部有一个 “是否要为此站点选择预先许可” 的选项:

  • :Turnstile 会发布一个许可 Cookie,让已通过验证的用户在有效期内免于再次验证
  • :每次都需要重新验证

对于本地开发测试,选择 即可。

步骤 5:创建小组件

确认所有配置无误后,点击右下角的 创建 按钮。


2.3. 获取密钥

创建成功后,页面会跳转到小组件详情页,显示两个关键密钥:

密钥类型用途安全性
站点密钥(Site Key)前端使用,嵌入到网页中可以公开
密钥(Secret Key)后端使用,调用验证 API必须保密

请将这两个密钥复制保存,后续配置会用到。

Secret Key 一旦泄露,攻击者可以伪造验证结果。请勿将其提交到公开的代码仓库。


2.4. 测试密钥(开发神器)

Cloudflare 官方提供了一组 “永远通过” 的测试密钥,让你在开发阶段无需真正完成验证:

用途Key
Site Key(前端)1x00000000000000000000AA
Secret Key(后端)1x0000000000000000000000000000000AA

使用测试密钥时,验证码组件会直接返回成功,方便你专注于业务逻辑的开发。

测试密钥仅用于本地开发调试,生产环境必须替换为真实密钥。


2.4.1. 本节小结

配置项值 / 操作
控制台入口Cloudflare Dashboard → Turnstile → 添加小组件
小组件名称随意填写,便于识别即可
主机名本地开发填 localhost,生产环境填实际域名
小组件模式推荐选择 “托管”
预先许可本地开发选 “否”
测试 Site Key1x00000000000000000000AA
测试 Secret Key1x0000000000000000000000000000000AA

第三章. 后端实现:策略模式多厂商架构

在上一章中,我们已经在 Cloudflare 控制台完成了站点创建,拿到了 Site Key 和 Secret Key。但密钥只是"通行证",真正的验证逻辑需要在后端实现。

本章的核心挑战是:如何设计一套架构,让系统能够优雅地支持多个验证服务商,并且切换时不需要修改业务代码?答案是策略模式。

本章学习路径

阶段内容认知里程碑
阶段一理解验证流程掌握 Cloudflare API 的请求/响应结构
阶段二策略模式设计理解为什么用策略模式,而不是 if-else
阶段三代码实现完成可运行的后端服务

3.1. Cloudflare 验证流程深度拆解

在写代码之前,我们必须先彻底理解 Cloudflare Turnstile 的验证机制。很多开发者直接复制代码却不理解原理,遇到问题时就束手无策。

3.1.1. 整体验证流程

无感验证码的核心思想是:前端负责"人机交互",后端负责"结果校验"。两者通过一个 Token 串联起来。

mermaid-diagram-2026-01-02-202815

这个流程中,后端的职责集中在第 8-9 步:拿着前端传来的 Token,去 Cloudflare 服务器验证真伪。

3.1.2. siteverify 接口详解

后端需要调用的核心接口是 siteverify,我们来拆解它的请求和响应结构。

接口地址

1
POST https://challenges.cloudflare.com/turnstile/v0/siteverify

请求参数

参数名类型必填说明
secretString✅ 是后端密钥(Secret Key),在控制台获取
responseString✅ 是前端传来的 Token
remoteipString❌ 否用户的真实 IP 地址,传递后可提高验证准确性

响应结构

1
2
3
4
5
6
7
8
{
"success": true,
"challenge_ts": "2025-01-15T10:30:00.000Z",
"hostname": "localhost",
"error-codes": [],
"action": "",
"cdata": ""
}
字段类型说明
successBoolean核心字段,true 表示验证通过
challenge_tsString验证时间戳(ISO 8601 格式)
hostnameString验证发生的域名
error-codesArray错误码列表,验证失败时会包含具体原因

常见错误码

错误码含义排查方向
missing-input-secret未提供 Secret Key检查请求参数
invalid-input-secretSecret Key 无效检查密钥是否正确
missing-input-response未提供 Token检查前端是否传递了 Token
invalid-input-responseToken 无效或已过期Token 只能使用一次,且有时效性
bad-request请求格式错误检查 Content-Type 和参数格式

3.1.3. Google reCAPTCHA v3 的差异

虽然本文主讲 Cloudflare,但我们的架构需要兼容 Google。两者的核心差异在于:

对比维度Cloudflare TurnstileGoogle reCAPTCHA v3
验证接口/turnstile/v0/siteverify/recaptcha/api/siteverify
判断逻辑只看 success 字段需要同时检查 successscore
评分机制0.0(机器人)到 1.0(人类)
推荐阈值通常设为 0.5

Google 的响应示例:

1
2
3
4
5
6
7
{
"success": true,
"score": 0.9,
"action": "login",
"challenge_ts": "2025-01-15T10:30:00.000Z",
"hostname": "localhost"
}

这意味着:对于 Google,即使 success: true,如果 score 低于阈值,我们仍然应该拒绝请求。


3.2. 策略模式架构设计

理解了验证流程后,我们来思考代码架构。最直观的实现方式是用 if-else:

1
2
3
4
5
6
7
8
9
10
11
// ❌ 错误示范:if-else 硬编码
public boolean verify(String token, String provider) {
if ("cloudflare".equals(provider)) {
// Cloudflare 验证逻辑
} else if ("google".equals(provider)) {
// Google 验证逻辑
} else if ("tencent".equals(provider)) {
// 腾讯云验证逻辑
}
// 每新增一个服务商,就要改这里...
}

这种写法的问题显而易见:

  1. 违反开闭原则:新增服务商必须修改已有代码
  2. 代码臃肿:随着服务商增多,if-else 会越来越长
  3. 难以测试:所有逻辑耦合在一起,无法单独测试某个服务商

3.2.1. 策略模式的解决方案

策略模式的核心思想是:将每种验证逻辑封装成独立的类,通过统一接口调用,运行时动态选择

mermaid-diagram-2026-01-02-202847

各组件职责

现在我们看看这张图到底讲了一个什么故事:

  1. 老板(顶层 Factory):
    • 有一个叫 CaptchaVerifierFactory 的工厂。
    • 它肚子里有个小本本(-verifierMap),记录着都有哪些验证服务可用。
    • 它的工作是(+getVerifier):你告诉它你要哪家服务,它就给你哪家。
  2. 合同/职位描述(中间 Interface):
    • 有一个叫 CaptchaVerifier 的职位标准。
    • 任何人想干这个职位,必须会做两件事:
      1. 能说出自己是谁(getProvider)。
      2. 能进行验证(verify)。
  3. 具体打工人(底层 Concrete Classes):
    • CloudflareCaptchaVerifier:我是 Cloudflare 验证器,我签了合同(虚线),我会按我的方式去验证。
    • GoogleCaptchaVerifier:我是 Google 验证器,我也签了合同(虚线),我会按我的方式去验证。

新增服务商时的变化

  • ✅ 只需新增一个实现类(如 TencentCaptchaVerifier
  • ✅ 在配置文件中添加对应配置
  • ❌ 不需要修改任何已有代码

3.3. 项目结构与依赖

现在开始动手写代码。首先创建项目并配置依赖。

3.3.1. 环境要求

组件版本说明
JDK17+Spring Boot 3 最低要求
Spring Boot3.5+本文使用 3.5.9
Maven3.8+构建工具

3.3.2. Maven 依赖配置

创建一个新的 Spring Boot 项目,pom.xml 配置如下:

📄 文件:pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.9</version>
<relativePath/>
</parent>

<groupId>com.example</groupId>
<artifactId>turnstile</artifactId>
<version>1.0.0</version>

<properties>
<java.version>17</java.version>
</properties>

<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<!-- Hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.20</version>
</dependency>
</dependencies>
</project>

3.3.3. 项目目录结构

按照标准的分层架构组织代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
src/main/java/com/example/turnstile
├── TurnstileApplication.java # 启动类
├── common
│ ├── Result.java # 统一响应封装
│ └── exception
│ ├── BizException.java # 业务异常
│ └── GlobalExceptionHandler.java # 全局异常处理
├── config
│ ├── CaptchaProperties.java # 验证码配置属性
│ └── CorsConfig.java # 跨域配置
├── controller
│ └── AuthController.java # 认证接口
├── model
│ └── dto
│ └── LoginRequest.java # 登录请求 DTO
└── service
└── captcha
├── CaptchaVerifier.java # 验证策略接口
├── CaptchaVerifierFactory.java # 策略工厂
└── impl
├── CloudflareCaptchaVerifier.java # Cloudflare 实现
└── GoogleCaptchaVerifier.java # Google 实现

src/main/resources
└── application.yml # 配置文件

3.4. 基础设施层实现

在实现核心验证逻辑之前,我们先搭建好基础设施:统一响应、异常处理、跨域配置。这些是每个项目都需要的"脚手架"。

3.4.1. 统一响应封装

为什么需要统一响应?因为前端需要一个稳定的数据结构来解析后端返回值。如果每个接口返回格式都不一样,前端代码会变得混乱不堪。

📄 文件:src/main/java/com/example/turnstile/common/Result.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.example.turnstile.common;

import lombok.Data;

/**
* 统一响应封装
* <p>
* 所有接口都返回这个结构,前端可以统一处理:
* - code: 200 表示成功,其他表示失败
* - message: 提示信息
* - data: 业务数据
*/
@Data
public class Result<T> {

/** 响应码 */
private Integer code;

/** 提示信息 */
private String message;

/** 业务数据 */
private T data;

private Result(Integer code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}

/**
* 成功响应(带数据)
*/
public static <T> Result<T> success(T data) {
return new Result<>(200, "操作成功", data);
}

/**
* 成功响应(无数据)
*/
public static <T> Result<T> success() {
return success(null);
}

/**
* 失败响应
*/
public static <T> Result<T> fail(String message) {
return new Result<>(500, message, null);
}

/**
* 失败响应(自定义错误码)
*/
public static <T> Result<T> fail(Integer code, String message) {
return new Result<>(code, message, null);
}
}

3.4.2. 业务异常与全局处理

业务异常用于在代码任意位置抛出错误,全局异常处理器负责捕获并转换为统一响应格式。

📄 文件:src/main/java/com/example/turnstile/common/exception/BizException.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.turnstile.common.exception;

import lombok.Getter;

/**
* 业务异常
* <p>
* 用于业务逻辑中的主动异常抛出,会被全局异常处理器捕获
*/
@Getter
public class BizException extends RuntimeException {

/** 错误码 */
private final Integer code;

public BizException(String message) {
super(message);
this.code = 500;
}

public BizException(Integer code, String message) {
super(message);
this.code = code;
}
}

📄 文件:src/main/java/com/example/turnstile/common/exception/GlobalExceptionHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.example.turnstile.common.exception;

import com.example.turnstile.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
* 全局异常处理器
* <p>
* 捕获所有异常并转换为统一的 Result 格式返回
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

/**
* 处理业务异常
*/
@ExceptionHandler(BizException.class)
public Result<?> handleBizException(BizException e) {
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return Result.fail(e.getCode(), e.getMessage());
}

/**
* 处理参数校验异常(@RequestBody
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<?> handleValidException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldError() != null
? e.getBindingResult().getFieldError().getDefaultMessage()
: "参数校验失败";
log.warn("参数校验失败: {}", message);
return Result.fail(400, message);
}

/**
* 处理参数绑定异常
*/
@ExceptionHandler(BindException.class)
public Result<?> handleBindException(BindException e) {
String message = e.getBindingResult().getFieldError() != null
? e.getBindingResult().getFieldError().getDefaultMessage()
: "参数绑定失败";
log.warn("参数绑定失败: {}", message);
return Result.fail(400, message);
}

/**
* 处理其他未知异常
*/
@ExceptionHandler(Exception.class)
public Result<?> handleException(Exception e) {
log.error("系统异常", e);
return Result.fail("系统异常,请稍后重试");
}
}

3.4.3. 跨域配置

前端运行在 localhost:5173,后端运行在 localhost:8080,端口不同属于跨域。我们需要在后端显式允许前端的请求。

📄 文件:src/main/java/com/example/turnstile/config/CorsConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.example.turnstile.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

/**
* 跨域配置
* <p>
* 允许前端开发服务器(localhost:5173)访问后端接口
*/
@Configuration
public class CorsConfig {

@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();

// 允许的前端地址
config.addAllowedOrigin("http://localhost:5173");

// 允许携带 Cookie
config.setAllowCredentials(true);

// 允许所有请求方法(GET、POST 等)
config.addAllowedMethod("*");

// 允许所有请求头
config.addAllowedHeader("*");

// 预检请求缓存时间(秒)
config.setMaxAge(3600L);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);

return new CorsFilter(source);
}
}

3.5. 配置层实现

验证码相关的配置(密钥、接口地址等)应该外置到配置文件中,而不是硬编码在代码里。这样做的好处是:

  1. 环境隔离:开发环境用测试密钥,生产环境用真实密钥
  2. 安全性:密钥不会出现在代码仓库中
  3. 灵活性:切换服务商只需改配置,不需要改代码

3.5.1. 配置文件设计

📄 文件:src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
server:
port: 8080

spring:
application:
name: turnstile-demo

# ==================== 验证码配置 ====================
captcha:
# 当前使用的服务商:cloudflare / google
# 切换服务商只需修改这一行
provider: cloudflare

# Cloudflare Turnstile 配置
cloudflare:
# Site Key(前端使用,可公开)
site-key: 1x00000000000000000000AA
# Secret Key(后端使用,必须保密)
secret-key: 1x0000000000000000000000000000000AA
# 验证接口地址
verify-url: https://challenges.cloudflare.com/turnstile/v0/siteverify

# Google reCAPTCHA v3 配置(扩展预留)
google:
site-key: your-google-site-key
secret-key: your-google-secret-key
verify-url: https://www.google.com/recaptcha/api/siteverify
# 评分阈值:低于此值判定为机器人(范围 0.0 - 1.0)
score-threshold: 0.5

上面的 Cloudflare 密钥是官方提供的测试密钥,验证永远通过。生产环境必须替换为真实密钥。

3.5.2. 配置属性类

使用 @ConfigurationProperties 将配置文件映射为 Java 对象,享受类型安全和 IDE 自动补全。

📄 文件:src/main/java/com/example/turnstile/config/CaptchaProperties.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.example.turnstile.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
* 验证码配置属性
* <p>
* 映射 application.yml 中 captcha 开头的配置项
*/
@Data
@Component
@ConfigurationProperties(prefix = "captcha")
public class CaptchaProperties {

/**
* 当前使用的服务商标识
* 可选值:cloudflare / google
*/
private String provider = "cloudflare";

/**
* Cloudflare Turnstile 配置
*/
private CloudflareConfig cloudflare = new CloudflareConfig();

/**
* Google reCAPTCHA v3 配置
*/
private GoogleConfig google = new GoogleConfig();

/**
* Cloudflare 配置项
*/
@Data
public static class CloudflareConfig {
/** 前端 Site Key */
private String siteKey;
/** 后端 Secret Key */
private String secretKey;
/** 验证接口地址 */
private String verifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
}

/**
* Google 配置项
*/
@Data
public static class GoogleConfig {
/** 前端 Site Key */
private String siteKey;
/** 后端 Secret Key */
private String secretKey;
/** 验证接口地址 */
private String verifyUrl = "https://www.google.com/recaptcha/api/siteverify";
/** 评分阈值(0.0 - 1.0) */
private Double scoreThreshold = 0.5;
}
}

3.6. 策略模式核心实现

基础设施和配置都准备好了,现在进入本章的核心:策略模式实现。

3.6.1. 验证策略接口

首先定义统一的验证接口。除了验证方法,我们还将 getSiteKey() 也纳入接口,这样 Controller 就不需要关心"当前是哪个服务商",直接通过工厂获取即可。

📄 文件:src/main/java/com/example/turnstile/service/captcha/CaptchaVerifier.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.example.turnstile.service.captcha;

/**
* 验证码验证策略接口
* <p>
* 所有验证服务商(Cloudflare、Google 等)都需要实现此接口。
* 这是策略模式的核心:定义统一契约,具体实现各自封装。
*/
public interface CaptchaVerifier {

/**
* 获取服务商标识
* <p>
* 用于策略工厂根据配置选择对应的实现类。
* 返回值必须与 application.yml 中的 captcha.provider 值匹配。
*
* @return 服务商标识,如 "cloudflare"、"google"
*/
String getProvider();

/**
* 获取前端 Site Key
* <p>
* 前端加载验证码组件时需要此参数。
* 将此方法放在接口中,Controller 无需关心当前是哪个服务商。
*
* @return Site Key
*/
String getSiteKey();

/**
* 验证 Token
*
* @param token 前端传来的验证 Token
* @param remoteIp 用户真实 IP 地址(可选,但推荐传递以提高准确性)
* @return true 表示验证通过,false 表示验证失败
*/
boolean verify(String token, String remoteIp);
}

3.6.2. Cloudflare 验证实现

实现 Cloudflare Turnstile 的验证逻辑,使用 Hutool 的 HttpUtil 简化 HTTP 请求。

📄 文件:src/main/java/com/example/turnstile/service/captcha/impl/CloudflareCaptchaVerifier.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package com.example.turnstile.service.captcha.impl;

import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.example.turnstile.config.CaptchaProperties;
import com.example.turnstile.service.captcha.CaptchaVerifier;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

/**
* Cloudflare Turnstile 验证实现
* <p>
* 调用 Cloudflare siteverify 接口验证前端传来的 Token。
* 验证逻辑简单:只需检查响应中的 success 字段。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CloudflareCaptchaVerifier implements CaptchaVerifier {

private final CaptchaProperties properties;

@Override
public String getProvider() {
return "cloudflare";
}

@Override
public String getSiteKey() {
return properties.getCloudflare().getSiteKey();
}

@Override
public boolean verify(String token, String remoteIp) {
CaptchaProperties.CloudflareConfig config = properties.getCloudflare();

// 构建请求参数
Map<String, Object> params = new HashMap<>();
params.put("secret", config.getSecretKey());
params.put("response", token);
if (StrUtil.isNotBlank(remoteIp)) {
params.put("remoteip", remoteIp);
}

try {
// 发送请求并解析响应
String body = HttpUtil.post(config.getVerifyUrl(), params, 5000);
JSONObject result = JSONUtil.parseObj(body);
boolean success = result.getBool("success", false);

if (success) {
log.info("Cloudflare 验证通过, hostname={}", result.getStr("hostname"));
} else {
log.warn("Cloudflare 验证失败, error-codes={}", result.getJSONArray("error-codes"));
}

return success;

} catch (Exception e) {
log.error("Cloudflare 验证异常", e);
return false;
}
}
}

3.6.3. Google reCAPTCHA v3 验证实现

Google 的验证逻辑稍有不同:除了检查 success 字段,还需要检查 score 评分是否达到阈值。

📄 文件:src/main/java/com/example/turnstile/service/captcha/impl/GoogleCaptchaVerifier.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package com.example.turnstile.service.captcha.impl;

import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.example.turnstile.config.CaptchaProperties;
import com.example.turnstile.service.captcha.CaptchaVerifier;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

/**
* Google reCAPTCHA v3 验证实现
* <p>
* 与 Cloudflare 的核心差异:
* 1. 不仅要检查 success 字段
* 2. 还要检查 score 评分是否达到阈值
* <p>
* score 范围是 0.0(机器人)到 1.0(人类),通常阈值设为 0.5
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GoogleCaptchaVerifier implements CaptchaVerifier {

private final CaptchaProperties properties;

@Override
public String getProvider() {
return "google";
}

@Override
public String getSiteKey() {
return properties.getGoogle().getSiteKey();
}

@Override
public boolean verify(String token, String remoteIp) {
CaptchaProperties.GoogleConfig config = properties.getGoogle();

// 构建请求参数
Map<String, Object> params = new HashMap<>();
params.put("secret", config.getSecretKey());
params.put("response", token);
if (StrUtil.isNotBlank(remoteIp)) {
params.put("remoteip", remoteIp);
}

try {
// 发送请求并解析响应
String body = HttpUtil.post(config.getVerifyUrl(), params, 5000);
JSONObject result = JSONUtil.parseObj(body);

boolean success = result.getBool("success", false);
Double score = result.getDouble("score");

// Google v3 的核心逻辑:success 且 score 达到阈值
if (success && score != null && score >= config.getScoreThreshold()) {
log.info("Google reCAPTCHA 验证通过, score={}, action={}", score, result.getStr("action"));
return true;
}

log.warn("Google reCAPTCHA 验证失败, success={}, score={}, threshold={}",
success, score, config.getScoreThreshold());
return false;

} catch (Exception e) {
log.error("Google reCAPTCHA 验证异常", e);
return false;
}
}
}

3.6.4. 策略工厂

策略工厂负责根据配置文件中的 provider 值,动态选择对应的验证实现。利用 Spring 的依赖注入,将所有 CaptchaVerifier 实现类自动收集并转换为 Map。

📄 文件:src/main/java/com/example/turnstile/service/captcha/CaptchaVerifierFactory.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.example.turnstile.service.captcha;

import com.example.turnstile.common.exception.BizException;
import com.example.turnstile.config.CaptchaProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

/**
* 验证策略工厂
* <p>
* 核心职责:根据配置文件中的 provider 值,返回对应的验证器实现。
* <p>
* 实现原理:
* 1. Spring 自动注入所有 CaptchaVerifier 实现类
* 2. 构造时将它们按 provider 标识存入 Map
* 3. 调用时根据配置从 Map 中取出对应实现
*/
@Slf4j
@Component
public class CaptchaVerifierFactory {

private final Map<String, CaptchaVerifier> verifierMap;
private final CaptchaProperties properties;

public CaptchaVerifierFactory(List<CaptchaVerifier> verifiers, CaptchaProperties properties) {
this.properties = properties;
this.verifierMap = verifiers.stream()
.collect(Collectors.toMap(CaptchaVerifier::getProvider, Function.identity()));

log.info("验证策略工厂初始化完成, 已加载服务商: {}", verifierMap.keySet());
}

/**
* 获取当前配置的验证器
*/
public CaptchaVerifier getVerifier() {
String provider = properties.getProvider();
CaptchaVerifier verifier = verifierMap.get(provider);

if (verifier == null) {
log.error("未找到验证服务商: {}, 可用: {}", provider, verifierMap.keySet());
throw new BizException("未知的验证服务商: " + provider);
}

return verifier;
}
}

3.7. Controller 层实现

所有核心组件都已就绪,现在实现对外暴露的 REST 接口。由于 getSiteKey() 已经收口到策略接口中,Controller 变得非常简洁。

3.7.1. 登录请求 DTO

📄 文件:src/main/java/com/example/turnstile/model/dto/LoginRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.turnstile.model.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Data;

/**
* 登录请求 DTO
*/
@Data
public class LoginRequest {

@NotBlank(message = "用户名不能为空")
private String username;

@NotBlank(message = "密码不能为空")
private String password;

@NotBlank(message = "验证码 Token 不能为空")
private String captchaToken;
}

3.7.2. 认证接口

使用 Hutool 的 MapUtil 构建响应数据,使用 ServletUtil 获取客户端 IP,代码更加简洁。

📄 文件:src/main/java/com/example/turnstile/controller/AuthController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package com.example.turnstile.controller;

import cn.hutool.core.map.MapUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import com.example.turnstile.common.Result;
import com.example.turnstile.common.exception.BizException;
import com.example.turnstile.model.dto.LoginRequest;
import com.example.turnstile.service.captcha.CaptchaVerifier;
import com.example.turnstile.service.captcha.CaptchaVerifierFactory;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

/**
* 认证接口
*/
@Slf4j
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final CaptchaVerifierFactory verifierFactory;

/**
* 获取验证码配置
* <p>
* 前端需要调用此接口获取 Site Key,用于加载验证码组件。
* 通过策略工厂获取,Controller 无需关心当前是哪个服务商。
*/
@GetMapping("/captcha/config")
public Result<Map<String, String>> getCaptchaConfig() {
CaptchaVerifier verifier = verifierFactory.getVerifier();

Map<String, String> config = MapUtil.builder("provider", verifier.getProvider())
.put("siteKey", verifier.getSiteKey())
.build();

log.debug("返回验证码配置: provider={}", verifier.getProvider());
return Result.success(config);
}

/**
* 登录接口
*/
@PostMapping("/login")
public Result<String> login(@Validated @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
// 1. 获取客户端真实 IP(Hutool 自动处理代理头)
String remoteIp = JakartaServletUtil.getClientIP(httpRequest);
log.info("收到登录请求, 用户: {}, IP: {}", request.getUsername(), remoteIp);

// 2. 验证码校验
CaptchaVerifier verifier = verifierFactory.getVerifier();
boolean verified = verifier.verify(request.getCaptchaToken(), remoteIp);

if (!verified) {
log.warn("人机验证失败, 用户: {}", request.getUsername());
throw new BizException(4001, "人机验证失败,请重试");
}

// 3. 执行登录逻辑
// TODO: 实际项目中应该校验用户名密码、生成 JWT Token 等
log.info("用户 {} 登录成功", request.getUsername());

return Result.success("登录成功");
}
}

3.8. 启动验证

代码编写完成,启动项目验证是否正常工作。

启动后端服务

1
mvn spring-boot:run

启动成功后,控制台会输出:

1
2
验证策略工厂初始化完成, 已加载服务商: [cloudflare, google]
Started TurnstileApplication in x.xxx seconds

测试获取配置接口

1
curl http://localhost:8080/auth/captcha/config

预期响应:

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"provider": "cloudflare",
"siteKey": "1x00000000000000000000AA"
}
}

测试登录接口

1
2
3
4
5
6
7
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "123456",
"captchaToken": "test-token"
}'

由于使用的是 Cloudflare 测试密钥,任何 Token 都会验证通过:

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": "登录成功"
}

3.8.1. 本章小结

本章我们完成了后端的核心架构设计和代码实现。通过策略模式,系统具备了灵活切换验证服务商的能力。

架构回顾

核心组件职责

组件职责
CaptchaVerifier统一验证接口,包含 getProvider()getSiteKey()verify()
CloudflareCaptchaVerifierCloudflare 验证实现
GoogleCaptchaVerifierGoogle 验证实现
CaptchaVerifierFactory策略工厂,动态选择实现
AuthController对外暴露 REST 接口,零 if-else

切换服务商的方式

只需修改 application.yml 中的一行配置:

1
2
captcha:
provider: google # 从 cloudflare 改为 google

无需修改任何 Java 代码,重启服务即可生效。


3.8. 启动验证

代码编写完成,启动项目验证是否正常工作。

启动后端服务

1
mvn spring-boot:run

启动成功后,控制台会输出:

1
2
验证策略工厂初始化完成, 已加载服务商: [cloudflare, google]
Started TurnstileApplication in x.xxx seconds

测试获取配置接口

1
curl http://localhost:8080/auth/captcha/config

预期响应:

1
2
3
4
5
6
7
8
{
"code": 200,
"message": "操作成功",
"data": {
"provider": "cloudflare",
"siteKey": "1x00000000000000000000AA"
}
}

测试登录接口(使用测试 Token)

1
2
3
4
5
6
7
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "123456",
"captchaToken": "test-token"
}'

由于使用的是 Cloudflare 测试密钥,任何 Token 都会验证通过,预期响应:

1
2
3
4
5
{
"code": 200,
"message": "操作成功",
"data": "登录成功"
}

3.8.1. 本章小结

本章我们完成了后端的核心架构设计和代码实现。通过策略模式,系统具备了灵活切换验证服务商的能力。

架构回顾

mermaid-diagram-2026-01-02-210215

核心组件职责

组件职责
CaptchaVerifier统一验证接口
CloudflareCaptchaVerifierCloudflare 验证实现
GoogleCaptchaVerifierGoogle 验证实现
CaptchaVerifierFactory策略工厂,动态选择实现
CaptchaProperties配置属性映射
AuthController对外暴露 REST 接口

切换服务商的方式

只需修改 application.yml 中的一行配置:

1
2
captcha:
provider: google # 从 cloudflare 改为 google

无需修改任何 Java 代码,重启服务即可生效。


第四章. 前端实现:Vue 3 组件封装

在上一章中,我们完成了后端的策略模式架构,系统已经能够验证 Cloudflare Turnstile 的 Token。但 Token 从哪里来?答案是前端。

本章我们将搭建 Vue 3 前端项目,封装 Turnstile 组件,并实现完整的登录流程。
本章学习路径

阶段内容认知里程碑
阶段一项目初始化掌握 Vite + Vue 3 + TypeScript 项目搭建
阶段二Turnstile 组件封装理解 SDK 加载机制和组件生命周期管理
阶段三登录流程实现串联前后端,完成完整业务闭环

4.1. 项目初始化

4.1.1. 创建项目

使用 pnpm 和 Vite 创建 Vue 3 + TypeScript 项目:

1
2
3
4
5
6
7
8
9
10
11
# 创建项目
pnpm create vite turnstile-frontend --template vue-ts

# 进入项目目录
cd turnstile-frontend

# 安装依赖
pnpm install

# 安装 axios(HTTP 请求库)
pnpm add axios

验证项目是否正常

1
pnpm dev

浏览器访问 http://localhost:5173,看到 Vite + Vue 欢迎页面即表示成功。

4.1.2. 项目目录结构

按照功能模块组织代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
src
├── api
│ ├── index.ts # Axios 实例配置
│ ├── auth.ts # 认证相关 API
│ └── types.ts # 类型定义
├── components
│ └── TurnstileWidget.vue # Turnstile 验证码组件
├── views
│ └── Login.vue # 登录页面
├── App.vue # 根组件
├── main.ts # 入口文件
├── style.css # 全局样式
└── vite-env.d.ts # 类型声明

4.1.3. 清理默认文件

删除 Vite 创建的示例文件:

1
2
rm src/components/HelloWorld.vue
rm -rf src/assets

4.2. API 请求层封装

在对接验证码组件之前,先封装好与后端通信的 API 层。

4.2.1. 类型定义

定义后端接口的请求和响应类型,确保类型安全。

📄 文件:src/api/types.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 后端统一响应结构
*/
export interface Result<T> {
code: number
message: string
data: T
}

/**
* 验证码配置(对应后端 /auth/captcha/config 接口)
*/
export interface CaptchaConfig {
/** 服务商标识:cloudflare / google */
provider: string
/** 前端 Site Key */
siteKey: string
}

/**
* 登录请求参数
*/
export interface LoginParams {
username: string
password: string
captchaToken: string
}

4.2.2. Axios 实例配置

创建 Axios 实例,配置基础地址和响应拦截器。拦截器的作用是统一解包 Result<T> 结构,让业务代码直接拿到 data

📄 文件:src/api/index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import axios from 'axios'
import type { Result } from './types'

/**
* 创建 Axios 实例
*/
const request = axios.create({
// 后端地址
baseURL: 'http://localhost:8080',
// 请求超时时间
timeout: 10000
})

/**
* 响应拦截器
*
* 作用:统一处理后端返回的 Result<T> 结构
* - code === 200:返回 data 部分
* - code !== 200:抛出错误,由调用方 catch 处理
*/
request.interceptors.response.use(
(response) => {
const result = response.data as Result<unknown>

if (result.code === 200) {
// 业务成功,直接返回 data
return result.data as any
}

// 业务失败,抛出错误
return Promise.reject(new Error(result.message || '请求失败'))
},
(error) => {
// 网络错误或服务器错误
const message = error.response?.data?.message || error.message || '网络异常'
return Promise.reject(new Error(message))
}
)

export default request

4.2.3. 认证 API 封装

封装与认证相关的接口调用。

📄 文件:src/api/auth.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import request from './index'
import type { CaptchaConfig, LoginParams } from './types'

/**
* 获取验证码配置
*
* 前端需要调用此接口获取 Site Key,用于加载验证码组件
*/
export function getCaptchaConfig(): Promise<CaptchaConfig> {
return request.get('/auth/captcha/config')
}

/**
* 登录
*
* @param params 登录参数(用户名、密码、验证码 Token)
*/
export function login(params: LoginParams): Promise<string> {
return request.post('/auth/login', params)
}

4.3. Turnstile 组件封装

这是本章的核心:将 Cloudflare Turnstile SDK 封装为可复用的 Vue 组件。

4.3.1. 组件设计思路

封装组件需要解决以下问题:

  1. SDK 加载:动态加载 Cloudflare 的 JavaScript SDK
  2. 组件渲染:在 SDK 加载完成后渲染验证码 Widget
  3. 事件通信:将验证结果(Token)通过事件传递给父组件
  4. 生命周期管理:组件销毁时清理 Widget,避免内存泄漏

mermaid-diagram-2026-01-02-211344

4.3.2. TypeScript 类型声明

Cloudflare Turnstile SDK 会在 window 对象上挂载 turnstile 对象,我们需要为其添加类型声明。

📄 文件:src/vite-env.d.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/// <reference types="vite/client" />

/**
* Turnstile Widget 配置选项
*/
interface TurnstileOptions {
/** Site Key */
sitekey: string
/** 验证成功回调,参数为 Token */
callback?: (token: string) => void
/** 验证失败回调 */
'error-callback'?: () => void
/** Token 过期回调 */
'expired-callback'?: () => void
/** 主题:light / dark / auto */
theme?: 'light' | 'dark' | 'auto'
/** 语言 */
language?: string
}

/**
* Turnstile SDK 实例
*/
interface TurnstileInstance {
/** 渲染 Widget,返回 Widget ID */
render: (element: HTMLElement, options: TurnstileOptions) => string
/** 重置 Widget */
reset: (widgetId: string) => void
/** 移除 Widget */
remove: (widgetId: string) => void
/** 获取当前 Token */
getResponse: (widgetId: string) => string | undefined
}

/**
* 扩展 Window 接口
*/
interface Window {
turnstile?: TurnstileInstance
}

4.3.3. 组件实现

📄 文件:src/components/TurnstileWidget.vue

封装重点

  1. 自动加载:组件挂载时自动检查并注入 Cloudflare 脚本。
  2. 防抖动:预留了高度,避免脚本加载时页面跳动。
  3. 对外接口:通过 defineExpose 暴露 reset 方法,方便父组件在登录失败时调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<template>
<div ref="widgetRef" class="turnstile-container"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";

const props = defineProps<{ siteKey: string }>();
const emit = defineEmits(["success", "error"]);

const widgetRef = ref<HTMLElement>();
const widgetId = ref<string>();

// 渲染核心逻辑
const renderWidget = () => {
if (!widgetRef.value || !window.turnstile) return;

// 渲染并保存 ID (用于销毁和重置)
widgetId.value = window.turnstile.render(widgetRef.value, {
sitekey: props.siteKey,
callback: (token: string) => emit("success", token),
"error-callback": () => emit("error"),
});
};

// 暴露给父组件的方法
const reset = () => {
if (widgetId.value && window.turnstile) {
window.turnstile.reset(widgetId.value);
}
};

onMounted(() => {
// 1. 如果脚本已存在,直接渲染
if (window.turnstile) {
renderWidget();
return;
}

// 2. 如果脚本不存在,动态注入(防止重复注入)
if (!document.getElementById("cf-turnstile")) {
const script = document.createElement("script");
script.id = "cf-turnstile";
script.src =
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.onload = renderWidget;
document.head.appendChild(script);
} else {
// 极少数情况:脚本标签有了但还没加载完,轮询等待一下即可
const timer = setInterval(() => {
if (window.turnstile) {
clearInterval(timer);
renderWidget();
}
}, 100);
}
});

onUnmounted(() => {
if (widgetId.value && window.turnstile) {
window.turnstile.remove(widgetId.value);
}
})

defineExpose({ reset });
</script>

<style scoped>
.turnstile-container {
min-height: 65px; /* 占位高度,防止布局抖动 */
display: flex;
justify-content: center;
}
</style>


4.4. 登录页面实现

📄 文件:src/views/Login.vue

实现逻辑

  1. 表单与验证码解耦:验证码组件只负责给 Token,Login 页面负责拿着 Token 去请求。
  2. 错误处理闭环:如果后端返回“密码错误”,必须调用 widgetRef.value.reset() 重置验证码,强制用户重新验证。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
<template>
<div class="login-container">
<div class="login-card">
<h2>系统登录</h2>

<form @submit.prevent="handleLogin">
<div class="form-item">
<input
v-model="form.username"
type="text"
placeholder="请输入用户名"
required
/>
</div>
<div class="form-item">
<input
v-model="form.password"
type="password"
placeholder="请输入密码"
required
/>
</div>

<TurnstileWidget
ref="turnstileRef"
:site-key="siteKey"
@success="(val) => (form.captchaToken = val)"
/>

<button type="submit" :disabled="!form.captchaToken || loading">
{{ loading ? "登录中..." : "立即登录" }}
</button>
</form>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, reactive } from "vue";
import TurnstileWidget from "../components/TurnstileWidget.vue";
import { login } from "../api/auth";

const siteKey = "0x4AAAAAAAxxxxxxxxxx"; // 替换你的 SiteKey
const turnstileRef = ref(); //以此调用子组件方法
const loading = ref(false);

const form = reactive({
username: "",
password: "",
captchaToken: "",
});

const handleLogin = async () => {
if (!form.captchaToken) return alert("请先完成人机验证");

loading.value = true;
try {
await login(form);
alert("登录成功!跳转首页...");
} catch (error: any) {
alert(error.message || "登录失败");
// 关键点:登录失败后,Token 失效,必须重置验证码
form.captchaToken = "";
turnstileRef.value?.reset();
} finally {
loading.value = false;
}
};
</script>

<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f0f2f5;
}
.login-card {
width: 360px;
padding: 30px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
text-align: center;
}
.form-item {
margin-bottom: 15px;
}
input {
width: 100%;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 10px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
}
button:disabled {
background-color: #bfbfbf;
cursor: not-allowed;
}
</style>


4.5. 入口文件配置

保持最简配置即可,无需变动。

4.5.1. 根组件

📄 文件:src/App.vue

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<Login />
</template>

<script setup lang="ts">
import Login from './views/Login.vue'
</script>

<style>
body { margin: 0; font-family: sans-serif; }
</style>

image-20260102213522305


4.5.4. 本章小结

本章我们完成了前端项目的搭建和 Turnstile 组件的封装。

核心组件职责

组件职责
TurnstileWidget.vue封装 Cloudflare SDK,管理加载、渲染、事件回调
Login.vue登录页面,集成表单和验证码组件
api/auth.ts封装后端接口调用

完整业务流程

mermaid-diagram-2026-01-02-213539


第五章. 联调测试与常见问题

代码编写完成,现在启动前后端服务进行联调测试。


5.1. 启动服务

启动后端

在后端项目目录下执行:

1
mvn spring-boot:run

启动成功后会看到:

1
2
验证策略工厂初始化完成, 已加载服务商: [cloudflare, google]
Started TurnstileApplication in x.xxx seconds

启动前端

在前端项目目录下执行:

1
pnpm dev

启动成功后访问 http://localhost:5173


5.2. 功能验证

步骤 1:验证码加载

页面加载后,应该能看到 Cloudflare Turnstile 的验证组件。由于使用的是测试密钥,验证会自动通过(或只需简单点击)。

步骤 2:完成登录

输入任意用户名和密码,点击登录按钮,应该能看到"登录成功"的提示。

步骤 3:查看后端日志

后端控制台应该输出:

1
2
3
收到登录请求, 用户: admin, IP: 127.0.0.1
Cloudflare 验证通过, hostname=localhost
用户 admin 登录成功

5.3. 常见问题排查

联调问题排查
2025-01-15 14:00
S

验证码组件一直显示加载中,没有出现?

T
teacher

检查浏览器控制台是否有网络错误。Cloudflare 的 SDK 地址在国内偶尔会慢,等待几秒或刷新页面。也可以检查 ‘/auth/captcha/config’ 接口是否正常返回 siteKey。

S

后端报错 ‘secret’ 参数无效?

T
teacher

检查 application.yml 中的 secret-key 是否正确复制,注意不要有多余的空格。测试密钥是 ‘1x0000000000000000000000000000000AA’,共 32 个字符。

S

验证通过了但登录失败,提示人机验证失败?

T
teacher

Token 有时效性,获取后需要尽快使用。检查前端是否在验证成功后立即提交,而不是等待太久。另外确认后端收到的 captchaToken 不是空字符串。

问题速查表

现象可能原因解决方案
验证码不显示SDK 加载失败检查网络,确认能访问 challenges.cloudflare.com
验证码不显示siteKey 未获取检查 /auth/captcha/config 接口是否正常返回
验证始终失败secretKey 错误检查后端配置文件中的密钥
登录提示验证失败Token 过期验证成功后尽快提交登录请求
CORS 错误跨域未配置检查后端 CorsConfig 是否包含 http://localhost:5173

5.4. 生产环境注意事项

上线前必须完成以下配置,否则验证码将无法正常工作。

1. 替换测试密钥

application.yml 中的测试密钥替换为 Cloudflare 控制台获取的真实密钥:

1
2
3
4
captcha:
cloudflare:
site-key: 你的真实 Site Key
secret-key: 你的真实 Secret Key

2. 添加生产域名

在 Cloudflare Turnstile 控制台,将你的生产域名添加到站点配置中(如 example.com)。

3. 更新 CORS 配置

修改后端 CorsConfig,将前端生产地址加入白名单:

1
config.addAllowedOrigin("https://your-production-domain.com");

4. 前端 API 地址配置

生产环境中,前端的 baseURL 需要改为后端的生产地址。建议使用环境变量:

1
2
3
4
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
timeout: 10000
})

5.4.1. 本章小结

检查项开发环境生产环境
Site Key测试密钥真实密钥
Secret Key测试密钥真实密钥
域名配置localhost生产域名
CORSlocalhost:5173生产前端地址
API 地址localhost:8080生产后端地址