如何在 Laravel 队列任务中识别验证码(PHP SDK)

要在 Laravel 队列任务中识别验证码,就在任务的 handle 方法里调用 CapSkip PHP 客户端,并在同一个任务里提交 token。调用本身很短。需要小心的是 Laravel 自带的三个默认数字,它们是为几秒内就能完成的任务设计的:worker 给每个任务 60 秒,队列在 90 秒后把任务交给另一个 worker,而每个任务只有一次尝试机会。一次 reCAPTCHA 识别最多可能轮询 300 秒,所以在默认设置下,Laravel 队列验证码任务要么中途被杀掉,要么还在识别就被标记为失败;一旦加上重试,它还可能运行两次。本指南涵盖任务本身、各项超时、一条重试规则,以及 worker 运行在 Windows 上时唯一会变的那件事。
你需要什么
- Laravel 11 或更高版本。这个任务使用的是 11 引入的单一 Queueable trait;除此之外,这里的其他内容在 10 上也都能用。Laravel 13 还可以用 #[Tries(3)] 这样的注解(attribute)来表达任务设置,下文使用的普通属性在那里照样有效。
- CapSkip PHP 包,它需要 PHP 8.0 或更高版本,并启用 curl 和 json 扩展。请确认 worker 实际加载的那个 php.ini 里启用了 curl,因为在 Windows 上它有时仍被注释掉。
- 一个队列连接。新的 Laravel 应用默认使用 database 驱动,下面的示例也是。
- 在 Windows 机器上运行的 CapSkip。在 Local 模式下,它只在 127.0.0.1 上响应,仅供本机使用,适合同一台电脑上的 worker。在 Server 模式下,它监听你的网络地址或公网 IP,这样 Linux 服务器上的 Laravel 应用、Forge 或任何其他主机都能通过 API 访问它。两种模式的设置位置都是 连接设置.
# Run in the Laravel project root composer require capskip/capskip
第 1 步:在容器中注册客户端
把识别工具的地址放在配置里,而不是写在任务里,这样同一份代码既能对接本地识别工具,也能对接服务器上的识别工具。在 config/services.php 里加一段配置,并在你的服务提供者里加一个单例绑定。
// config/services.php
'capskip' => [
'key' => env('CAPSKIP_API_KEY', 'capskip'),
'host' => env('CAPSKIP_HOST', '127.0.0.1'),
'port' => (int) env('CAPSKIP_PORT', 8080),
],
// app/Providers/AppServiceProvider.php, inside register()
$this->app->singleton(\CapSkip\CapSkip::class, fn () => new \CapSkip\CapSkip([
'apiKey' => config('services.capskip.key'),
'host' => config('services.capskip.host'),
'port' => config('services.capskip.port'),
]));客户端从不自己读取环境变量,所以绑定时要把每个值都传进去。在应用的其他地方,都要通过 config() 而不是 env() 读取它们:一旦运行了 config:cache,配置文件之外的 env() 就会返回 null。
第 2 步:在一个任务里完成识别和提交
在 handle() 上对客户端做类型提示,Laravel 就会注入这个单例。任务从页面读取 sitekey,完成识别,然后提交表单。
<?php
namespace App\Jobs;
use CapSkip\CapSkip;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
class SubmitSignup implements ShouldQueue
{
use Queueable;
public function __construct(public string $pageUrl, public array $fields) {}
public function handle(CapSkip $solver): void
{
$html = Http::timeout(30)->get($this->pageUrl)->throw()->body();
preg_match('/data-sitekey="([^"]+)"/', $html, $m);
// Solve and submit in one job: the token expires in two minutes.
$token = $solver->recaptcha($m[1], $this->pageUrl)['code'];
Http::asForm()->timeout(30)
->post($this->pageUrl, $this->fields + ['g-recaptcha-response' => $token])
->throw();
}
}派发它时带上页面和表单的其他字段,并放到它自己的连接和队列上,下一步会定义它们:
SubmitSignup::dispatch('https://example.com/signup', ['email' => 'YOUR_EMAIL'])
->onConnection('captcha')
->onQueue('captcha');识别和提交要放在一起。reCAPTCHA token 的有效期大约两分钟,而交给第二个链式任务的 token,可能把这段时间全耗在排队等待其他工作上。 reCAPTCHA token 能保持有效多久 这篇文章详细讲解了这个时限。PHP 客户端是同步的,所以在整个识别期间,worker 进程都被占着;在 Laravel 中要让多个识别并行,办法是增加 worker 进程,而不是换一个客户端。
第 3 步:理顺各项超时
一个 Laravel 队列验证码任务上有四个计时器在走。由内到外依次是:
- CapSkip 自身的设置。 一个 reCAPTCHA 识别任务最多可以等待 250 秒来获得空闲线程(Wait Timeout),识别本身有 250 秒(Row Timeout)。任一时限用完,CapSkip 就会把识别任务判为失败,客户端随即抛出 ApiException,除非下面讲到的客户端自身的时限已经先触发。
- 客户端的轮询。 recaptcha() 最多按 recaptchaTimeout 设定的时长轮询(默认 300 秒),然后抛出 TimeoutException。再加上前后两次 HTTP 调用(上面的代码把每次都限制在 30 秒以内),CapSkip 正常响应时,整个 handle() 大约六分钟内完成。不过,300 秒的期限是在两次轮询之间检查的,而客户端发出的每个请求最多可能挂起 120 秒,所以一旦 CapSkip 停止响应,任务可能会拖得比这更久。
- 任务超时。 worker 的 timeout 选项默认为 60 秒,运行超过这个时长的任务,其 worker 进程会被杀掉。在任务上设置一个比上面六分钟更长的 $timeout。
- retry_after. 每个队列连接都会在已保留的任务运行了这么长时间之后,把它放回队列,默认为 90 秒。如果任务还有剩余的尝试次数,一个仍在某个 worker 上进行的慢识别,就会在另一个 worker 上重新开始,这意味着第二次识别和第二次表单提交。如果没有剩余次数,第二个 worker 会立刻把它标记为失败,而第一个 worker 仍可能继续把表单提交出去。
Laravel 的文档说,超时时间 应当始终比 retry_after 至少短几秒。所以顺序是:客户端最先,任务超时其次,retry_after 最后。给验证码任务单独配一个连接,这样较长的 retry_after 就不会拖慢应用里其他所有任务的恢复:
// config/queue.php, under 'connections'
'captcha' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => 'captcha',
// Longer than the job's timeout, so no second worker takes it.
'retry_after' => 420,
'after_commit' => false,
],
// On the job class
public $timeout = 390;让 worker 针对这个连接和队列运行:
php artisan queue:work captcha --queue=captcha
任务的 $timeout 优先于 worker 自己的 timeout 选项,所以命令里不需要再设置。
第 4 步:只重试那些重试能解决的问题
除非你另行指定,否则一个任务只有一次尝试机会,所以任何异常都会让它彻底失败。有些验证码失败值得再试一次:ERROR_CAPTCHA_UNSOLVABLE 结果、TimeoutException,或者因 CapSkip 正在重启而出现的 NetworkException。另一些则重试多少次都不会成功:错误的 API 密钥、格式不正确的 sitekey(ERROR_GOOGLEKEY),或者被客户端拒绝的参数。被 Google 拒绝的 sitekey 会以 ERROR_CAPTCHA_UNSOLVABLE 返回,所以它会像其他无法识别的结果一样耗尽重试次数。允许三次尝试,把它们错开,没有希望的情况则立即判为失败。
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\ValidationException;
// On the job class
public $tries = 3;
public $backoff = [30, 120];
// Inside handle(), around the solve
try {
$token = $solver->recaptcha($m[1], $this->pageUrl)['code'];
} catch (ValidationException $e) {
$this->fail($e); // a bad parameter will not fix itself
return;
} catch (ApiException $e) {
if (! str_contains($e->getMessage(), 'UNSOLVABLE')) {
$this->fail($e); // a wrong API key and the like
return;
}
throw $e; // retried after the backoff
}$this->fail() 会把任务标记为失败,而不消耗剩余的尝试次数;任何被重新抛出的异常,都会在 30 秒后重试,然后是 120 秒后。被自身超时杀掉的任务同样会用掉一次尝试,但它只会在 retry_after 到期后才被重新拾起,而不是在退避时间过后。表单返回 4xx 时也会抛出异常,重试会再次识别并再次提交。如果站点对错误输入返回 4xx,就在提交代码外面捕获 Illuminate\Http\Client\RequestException,并在 $e->response->clientError() 为 true 时调用 $this->fail($e)。
在 Windows 上运行 worker
在与 CapSkip 相同的 Windows 电脑上运行 Laravel 是最简单的部署方式,但它有一个陷阱。Laravel 用 pcntl 扩展来强制执行任务超时,而 Windows 上根本没有 pcntl。在那里,下面这行会输出 bool(false):
php -r "var_dump(extension_loaded('pcntl'));"没有 pcntl,任务的 $timeout 和 worker 的 timeout 选项都会被悄无声息地忽略,任务会一直运行到返回为止,不管要多久。retry_after 依然有效,因为它是在下一个 worker 拉取任务时检查的,不靠信号来强制执行。所以在 Windows 上,Laravel 里没有任何东西能叫停一个慢任务。结束一次识别的,是 CapSkip 自身的 Wait Timeout 和 Row Timeout,以及客户端 300 秒的轮询上限,所以要保留它们。让 retry_after 高于一个任务可能耗费的最长时间:420 能覆盖 CapSkip 正常响应的情况,660 还能覆盖连接卡住的情况,比如跨公网使用 Server 模式时。
另外还有两点不同。Laravel Horizon 需要 pcntl 和 posix 扩展,所以在 Windows 上装不上;在那里请使用普通的 queue:work,或者把 Horizon 放在一台 Linux 主机上运行,让它调用处于 Server 模式的 CapSkip。而且 Windows 上没有 Supervisor,所以要把每个 worker 放在计划任务或服务包装器下运行,由它们负责重启 worker。在 Linux 上,Supervisor 的 stopwaitsecs 必须比你最长的任务更长,否则一次部署就会把正在进行的识别中途杀掉。两种情况下,在部署或修改 .env 之后,如果你缓存了配置,就先运行 php artisan config:cache,然后运行 php artisan queue:restart,因为 worker 会一直沿用它启动时的配置。
每个 worker 进程一次只识别一个验证码,而 CapSkip 默认最多同时运行 10 个 reCAPTCHA 识别(即其 reCAPTCHA 设置中的 Max. Threads)。captcha 队列上的 worker 超过大约十个,只会让任务在 CapSkip 内部排队等待,而这段时间会计入 Wait Timeout。
完整可运行示例
<?php
namespace App\Jobs;
use CapSkip\CapSkip;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\ValidationException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
class SubmitSignup implements ShouldQueue
{
use Queueable;
public $tries = 3;
public $backoff = [30, 120];
// GET 30 s + solve up to 300 s + POST 30 s, plus a margin.
public $timeout = 390;
public function __construct(public string $pageUrl, public array $fields) {}
public function handle(CapSkip $solver): void
{
$html = Http::timeout(30)->get($this->pageUrl)->throw()->body();
if (! preg_match('/data-sitekey="([^"]+)"/', $html, $m)) {
$this->fail(new RuntimeException("No sitekey on {$this->pageUrl}"));
return;
}
try {
$token = $solver->recaptcha($m[1], $this->pageUrl)['code'];
} catch (ValidationException $e) {
$this->fail($e);
return;
} catch (ApiException $e) {
if (! str_contains($e->getMessage(), 'UNSOLVABLE')) {
$this->fail($e);
return;
}
throw $e;
}
// Submit in the same job, while the token is still valid.
Http::asForm()->timeout(30)
->post($this->pageUrl, $this->fields + ['g-recaptcha-response' => $token])
->throw();
}
public function failed(?Throwable $exception): void
{
logger()->warning('Signup gave up', [
'url' => $this->pageUrl,
'error' => $exception?->getMessage(),
]);
}
}把它和第 3 步的 captcha 连接、第 1 步的绑定配合起来,按第 2 步所示派发任务,再用第 3 步的命令启动一个 worker。sitekey 匹配模式要求 data-sitekey 属性使用双引号,这与 reCAPTCHA 自己的代码片段写法一致。对于隐形或 Enterprise 小组件,要给 recaptcha() 传入相应的选项,具体见 reCAPTCHA v2 识别页面;这次调用背后的原始接口见 API 参考文档.
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 60 秒后报错 "has timed out",任务被标记为失败 | worker 的默认超时是 60 秒,而任务只有一次尝试机会 | 在任务上设置 $timeout;如果需要重试,再设置 $tries |
| 表单被提交了两次,或者一个任务跑了两次识别 | 任务运行时间超过了 retry_after(默认 90 秒),被第二个 worker 接手 | 把验证码任务放到一个 retry_after 比 $timeout 更长的连接上 |
| 在 Windows 上,任务远远超出它的 $timeout 仍在运行 | 任务超时需要 pcntl,而 Windows 上没有 | 依靠 CapSkip 自身的超时和客户端 300 秒的上限,并让 retry_after 高于整个任务的耗时 |
| 报错 "has been attempted too many times" | 任务在 worker 挂掉或自身运行超过 retry_after 之后被重新拾起,次数超过了 $tries 允许的上限 | 排查在任务中途被部署杀掉的 worker,以及运行时间超过 retry_after 的任务 |
| 包含 ERROR_CAPTCHA_UNSOLVABLE 的 ApiException | CapSkip 把识别任务判为失败,比如运行超过了 Row Timeout | 重新抛出它,让 Laravel 在退避时间过后重试 |
| 包含 ERROR_KEY_DOES_NOT_EXIST 的 ApiException | CapSkip 开启了 API 密钥校验,而 CAPSKIP_API_KEY 与那里的任何密钥都不匹配 | 修正密钥并重启 worker;直接让任务失败,而不是重试 |
| 修改 .env 后第一次识别就抛出 NetworkException | worker 仍在使用旧的主机地址,或者 CapSkip 没有在运行 | 如果你缓存了配置,先运行 php artisan config:cache,再运行 php artisan queue:restart,并检查 Local 或 Server 模式 |
| 报错 Call to undefined function curl_init() | worker 加载的 php.ini 里没有启用 curl 扩展 | 在那个 php.ini 里启用 extension=curl |
| 任务在"运行"时,页面请求却超时了 | 派发任务时没有指定 captcha 连接,而 QUEUE_CONNECTION 是 sync,所以任务是在 Web 请求内部运行的 | 像第 2 步那样派发到 captcha 连接上,并在该连接上运行一个 worker |
| 站点拒绝了 token | token 在过期后才提交,或者提交到了错误的 URL | 识别完成后立刻在同一个任务里提交,提交到表单的 action URL |
常见问题
为什么不在控制器里识别验证码?
因为一次识别要花几十秒,甚至可能要好几分钟。这比访客愿意等待的时间更长,也比大多数 PHP 安装给 Web 请求的 30 秒 max_execution_time 更长。把任务放进队列,控制器就能立刻返回响应,慢的部分则交给没有这种限制的 worker 进程去做。
Laravel 队列验证码任务能在 Horizon 下运行吗?
可以,只要主机上有 pcntl,也就是 Linux 或 macOS。Horizon 在 config/horizon.php 里按 supervisor 设置超时,所以给运行 captcha 队列的 supervisor 设一个比任务的 390 秒略高的超时,比如 400,因为采用 auto 均衡策略时,Horizon 缩容会停掉运行时间超过它自身超时的 worker。让 Redis 连接的 retry_after 高于这个值(420 可以),并把 CAPSKIP_HOST 指向以 Server 模式运行 CapSkip 的那台 Windows 机器。
Linux 服务器上的 Laravel 应用能使用我 Windows 电脑上的 CapSkip 吗?
可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址,然后在应用的 .env 里设置 CAPSKIP_HOST,并重启 worker。如果链路要经过公网,请使用静态公网 IP,并配一条只放行你的服务器的防火墙规则,同时打开 API 密钥校验。识别工具始终运行在你自己的 Windows 机器上,因此识别的计数方式不会有任何变化。
这和 Hangfire 或 Celery 相比如何?
各处的结构都一样:在一个任务里完成识别和提交,只重试那些重试能解决的失败,并按 CapSkip 的线程数来确定 worker 数量。不同的是哪个默认值会坑到你。Laravel 只给一次尝试,并把慢任务交给第二个 worker,后者要么让它失败,要么在你允许重试之后再运行它一次;Hangfire 则会在几个小时里重试十次。.NET 版本见 Hangfire 验证码作业指南.
简短版结论
对于 Laravel 队列验证码任务,要在容器中绑定 CapSkip 客户端,并在同一个任务里完成识别和提交。给验证码任务单独配一个连接,retry_after 设为 420 秒,$timeout 设为 390,$tries 设为 3 并配上退避时间,重试解决不了的错误则直接判为失败。在 Windows 上,记住没有 pcntl 就不会强制执行任何超时,并且每次部署后都要重启 worker。
- PHP 包能处理的所有验证码类型,附带纯 PHP 示例: PHP 验证码识别工具页面.
- 同样的 reCAPTCHA 调用,放在 Laravel 之外: 在 PHP 中识别 reCAPTCHA v2.
这些设置的价值,正是在重试上体现出来的。当 验证码识别工具 运行在你自己的机器上时,一个对无法识别的验证码重试两次的任务再平常不过,因为每多一次尝试,花掉的只是那里的 worker 时间和线程时间,而不是又一次按量计费的识别。
