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

laravel queue captcha - How to Solve CAPTCHAs in a Laravel Queue Job (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 的 ApiExceptionCapSkip 把识别任务判为失败,比如运行超过了 Row Timeout重新抛出它,让 Laravel 在退避时间过后重试
包含 ERROR_KEY_DOES_NOT_EXIST 的 ApiExceptionCapSkip 开启了 API 密钥校验,而 CAPSKIP_API_KEY 与那里的任何密钥都不匹配修正密钥并重启 worker;直接让任务失败,而不是重试
修改 .env 后第一次识别就抛出 NetworkExceptionworker 仍在使用旧的主机地址,或者 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
站点拒绝了 tokentoken 在过期后才提交,或者提交到了错误的 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。

这些设置的价值,正是在重试上体现出来的。当 验证码识别工具 运行在你自己的机器上时,一个对无法识别的验证码重试两次的任务再平常不过,因为每多一次尝试,花掉的只是那里的 worker 时间和线程时间,而不是又一次按量计费的识别。