你的 PHP 表单收到一张护照照片,代码需要把姓名、证件号码、日期和机读区(MRZ)作为数据拿到。本文把这个读取器写两遍:一遍是只用 ext-curl 的纯 PHP 8.1 版本,另一遍是基于 Http 客户端的 Laravel 服务。两个版本都只发送一次请求,按结果分支,重试时不会重复付费,并把每一次拒绝都转换成可以据此处理的错误码。

用到的 API 是 doc.cheap 的护照与身份证 OCR API,而这里是 doc.cheap 自己的博客,请据此看待其中的产品选择。这些模式(每张图片一个幂等键、按稳定的错误码分支、为每次调用保存计费标志)同样适用于你从 PHP 调用的任何付费 API。

无需账号的第一次调用

文档里给出了公共 sandbox 密钥 sk_sandbox_public。它执行真实的识别,无需注册,也不消耗额度。限制是每个 IP 地址每小时 10 个请求,每个客户端总计 10 次免费识别,用完之后会返回 403 registration_required。

请求是带 JSON 请求体的 POST https://api.doc.cheap/v1/scans。只有一个必填字段 image:文件字节的 base64 编码,格式为 JPEG 或 PNG。

<?php
$ch = curl_init('https://api.doc.cheap/v1/scans');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode(['image' => base64_encode(file_get_contents('specimen.jpg'))]),
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer sk_sandbox_public',
        'Content-Type: application/json',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 60,
]);
$body = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL, $body, PHP_EOL;

调用是同步的:字段就在这个响应里返回,没有需要轮询的任务 ID,也不需要托管 webhook。请用合成的样本测试,绝不要用你自己的护照。

请求体会被严格检查。未知的键(比如拼错的选项名)会以 422 validation_failed 拒绝,并在消息中指出路径,所以拼写错误会明确报错,而不是被忽略。超出大小上限的请求体会得到 413 payload_too_large;照片尺寸和所有选项见护照识别指南。

返回的内容

每个响应都有同样的八个分组,而且每个键都始终存在:meta、document、holder、fields、mrz、images、quality 和 authenticity。经过 json_decode($body, true) 之后,未知的值是 null,绝不会缺少键。下面是一个删节并脱敏的示例:

{
  "meta": { "id": "<SCAN_ID>", "status": "recognized", "billed": true, "confidence": "high", "reference": null },
  "document": { "kind": "passport", "country": "<ISO3>", "number": "<DOCUMENT_NUMBER>", "expiry_date": "<YYYY-MM-DD>", "is_expired": false },
  "holder": { "given_names": "<GIVEN_NAMES>", "surname": "<SURNAME>", "birth_date": "<YYYY-MM-DD>", "sex": "<M|F|X>", "nationality": "<ISO3>" },
  "mrz": { "status": "passed", "reason": null, "lines": ["<MRZ_LINE_1>", "<MRZ_LINE_2>"], "text": "<MRZ_TEXT>" }
}

决定代码下一步怎么做的是这四部分:

  • meta.status 是五个字符串之一:recognized、no_document_found、unreadable、unsupported_document、rejected。五个都以 HTTP 200 返回,只有第一个带有数据。
  • document 和 holder 是整理后的值,日期格式为 YYYY-MM-DD。某个分组什么都没读出来时,整个分组为 null。
  • mrz.status 是 passed、failed 或 absent。passed 表示找到了机读区,所有校验位都通过,而且其中没有任何内容与印刷页相矛盾。
  • meta.billed 表示这次调用是否消耗了额度。

决定两个客户端写法的关键是:读不出来的照片是 200,不是错误。 要分支两次:拒绝按错误码分支,结果按 meta.status 分支。遇到 no_document_found 就重试,你会重新发送一张永远读不出来的照片;把它当成成功,你会保存一份没有任何字段的证件。

在 PHP 中自己校验 MRZ

mrz.lines 和 mrz.text 是原样读出的机读区,也是响应中唯一可以独立验证的部分。在护照(TD3 格式)上,第二行带有三个很容易重新计算的校验位:证件号码、出生日期和有效期。每个校验位使用循环的权重 7、3、1;数字按自身计值,A 到 Z 计为 10 到 35,填充符 < 计为 0。

<?php
function mrz_check_digit(string $field): int
{
    $weights = [7, 3, 1];
    $sum = 0;
    foreach (str_split($field) as $i => $char) {
        $value = match (true) {
            ctype_digit($char) => (int) $char,
            ctype_upper($char) => ord($char) - 55, // A = 10 ... Z = 35
            default => 0,                          // 填充符 <
        };
        $sum += $value * $weights[$i % 3];
    }
    return $sum % 10;
}

function td3_line2_checks(string $line): array
{
    return [
        'number' => mrz_check_digit(substr($line, 0, 9)) === (int) $line[9],
        'birth_date' => mrz_check_digit(substr($line, 13, 6)) === (int) $line[19],
        'expiry_date' => mrz_check_digit(substr($line, 21, 6)) === (int) $line[27],
    ];
}

当 $scan['document']['kind'] 为 passport 时,对 $scan['mrz']['lines'][1] 运行它。你并不需要它来信任 mrz.status;它在测试中很有用,或者在你保存了机读区、之后想证明当时读出了什么的时候。

谁来付费:billed 标志

sandbox 密钥从不动用余额。使用正式密钥时,只有在证件类型已确定,并且从中得到了以下至少一项时才会扣费:校验位通过的 MRZ、印刷页上的五个或更多字段,或一个成功解码的条形码。什么都没找到、照片无法读取、类型不受支持,或服务端失败,都不收费。在 sk_sandbox_public 上,billed 仍会报告同样的扫描是否会被扣费,因此值得拿它来测试。

把这个标志和你自己的订单或用户记录存在一起,你的记录就能与账单对上,无需额外的对账步骤。账号每月有 100 次免费识别,会先于任何付费额度使用。

不会重复扣费的重试

有风险的是超时之后的重试:你不知道第一次请求是否到达了服务器。请发送 Idempotency-Key 请求头(1 到 255 个字符),同一张图片的每次尝试都复用它。使用正式密钥时,服务器会把这个键和整个请求体的指纹一起匹配:

  • 同一个键、同样的请求体、结果已保存:返回第一次的结果,不会再次扣费。
  • 同一个键、不同的请求体:409 idempotency_conflict。请在循环之外只构建一次请求体。
  • 第一次请求仍在处理:409 idempotency_in_progress。等待后用同一个键重试。
  • 同一个键、没有保存的结果(例如用了 retain_hours: 0 之后):409 idempotency_replay_unavailable。请换一个新键发送。

所有错误响应体的结构都一样(code、message、docs_url、request_id、event_id),要分支的值是 code,因为同一个 HTTP 状态下的不同错误码需要完全相反的处理。错误处理指南把它们全部分了类;扫描客户端会遇到的是这些:

错误码 怎么做
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance 等待(遵守 Retry-After),然后重试
idempotency_in_progress 等待,用同一个键重试
validation_failed, invalid_request, payload_too_large, unsupported_media_type 修正请求;重试还会失败
unauthorized, registration_required, insufficient_credits 修正密钥或账号;等待不会改变什么

Retry-After 是整数秒。sandbox 的每小时限制可能要求等待将近一个小时,所以下面两个客户端在等待超过一分钟时会放弃,而不是阻塞 PHP worker。

纯 PHP 客户端

<?php
// scan.php – PHP 8.1+,只用 ext-curl 和 ext-json

const API = 'https://api.doc.cheap/v1/scans';
const RETRYABLE = [
    'rate_limited', 'document_repeated', 'internal_error', 'engine_unavailable',
    'service_unavailable', 'maintenance', 'idempotency_in_progress',
];
const MAX_WAIT = 60; // 秒;更长的 Retry-After 直接报告,不去干等

final class ScanError extends RuntimeException
{
    public function __construct(public readonly int $status, public readonly array $error)
    {
        parent::__construct(sprintf('%s (%d): %s', $error['code'], $status, $error['message']));
    }
}

/** 一次 HTTP 尝试:[状态码, 解码后的响应体, Retry-After 秒数]。 */
function post_scan(string $apiKey, string $idempotencyKey, string $json): array
{
    $retryAfter = 0;
    $ch = curl_init(API);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $json,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 60,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json',
            'Idempotency-Key: ' . $idempotencyKey,
        ],
        CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$retryAfter): int {
            if (stripos($header, 'Retry-After:') === 0) {
                $retryAfter = (int) trim(substr($header, 12));
            }
            return strlen($header);
        },
    ]);
    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException('transport: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    return [$status, json_decode($body, true, flags: JSON_THROW_ON_ERROR), $retryAfter];
}

function scan(string $path, ?string $reference = null, int $attempts = 4): array
{
    $bytes = file_get_contents($path);
    if ($bytes === false) {
        throw new RuntimeException("cannot read $path");
    }
    $apiKey = getenv('DOC_CHEAP_API_KEY') ?: 'sk_sandbox_public';
    $json = json_encode([
        'image' => base64_encode($bytes),
        'reference' => $reference,
        'options' => ['return_portrait' => false],
    ], JSON_THROW_ON_ERROR);
    $idempotencyKey = bin2hex(random_bytes(16)); // 每张图片一个键,每次尝试都复用

    for ($attempt = 1; ; $attempt++) {
        $last = $attempt >= $attempts;
        try {
            [$status, $payload, $retryAfter] = post_scan($apiKey, $idempotencyKey, $json);
        } catch (RuntimeException | JsonException $e) {
            // 超时、连接中断,或者代理返回了 HTML。
            if ($last) {
                throw $e;
            }
            sleep(2 ** $attempt);
            continue;
        }
        if ($status === 200) {
            return $payload;
        }
        $error = $payload['error'];
        $wait = $retryAfter > 0 ? $retryAfter : 2 ** $attempt;
        if ($last || !in_array($error['code'], RETRYABLE, true) || $wait > MAX_WAIT) {
            throw new ScanError($status, $error);
        }
        sleep($wait);
    }
}

function summarise(array $scan): array
{
    $meta = $scan['meta'];
    if ($meta['status'] !== 'recognized') {
        return ['ok' => false, 'status' => $meta['status'], 'billed' => $meta['billed']];
    }
    $document = $scan['document'] ?? [];
    $holder = $scan['holder'] ?? [];
    return [
        'ok' => true,
        'billed' => $meta['billed'],
        'kind' => $document['kind'] ?? null,
        'country' => $document['country'] ?? null,
        'expiry_date' => $document['expiry_date'] ?? null,
        'surname' => $holder['surname'] ?? null,
        'birth_date' => $holder['birth_date'] ?? null,
        'mrz' => $scan['mrz']['status'],
    ];
}

try {
    echo json_encode(summarise(scan($argv[1], reference: 'demo-1'))), PHP_EOL;
} catch (ScanError $e) {
    fwrite(STDERR, $e->getMessage() . ' ' . $e->error['docs_url'] . ' ' . $e->error['request_id'] . PHP_EOL);
    exit(1);
}

用 php scan.php specimen.jpg 运行。对一个可读的样本,它会打印出这样一行(值已脱敏):

{"ok":true,"billed":true,"kind":"passport","country":"<ISO3>","expiry_date":"<YYYY-MM-DD>","surname":"<SURNAME>","birth_date":"<YYYY-MM-DD>","mrz":"passed"}

有几个选择值得解释:

  • curl 不会因为 4xx 或 5xx 而失败。 客户端无论如何都会读取 JSON 响应体,因为错误码就在错误响应体里。
  • 请求体和键在循环之前只构建一次。 正因如此,在服务器看来每次尝试都是同一个请求。
  • return_portrait: false 会让响应不包含持证人的人像裁剪图,你的日志和存储里就少了一张人脸。
  • reference 会作为 meta.reference 返回(最多 128 个字符):把扫描和你自己的订单或用户关联起来的简单方法。
  • 绝不要记录请求体。 它是一份身份证件。请记录 code、request_id 和 docs_url;技术支持需要的就是这些。

Laravel 版本

Laravel 的 Http 客户端(Laravel 10 或更高版本)已经替你完成了循环的大部分:retry() 接收一个决定等待时间的闭包和一个判断是否值得再次尝试的闭包,而用数组调用 post() 会发送 JSON。把密钥放进 config/services.php:

'doc_cheap' => [
    'key' => env('DOC_CHEAP_API_KEY', 'sk_sandbox_public'),
],

然后写一个小服务:

<?php

namespace App\Services;

use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class PassportReader
{
    private const RETRYABLE = [
        'rate_limited', 'document_repeated', 'internal_error', 'engine_unavailable',
        'service_unavailable', 'maintenance', 'idempotency_in_progress',
    ];

    public function read(string $bytes, string $idempotencyKey, ?string $reference = null): array
    {
        $response = Http::baseUrl('https://api.doc.cheap/v1')
            ->withToken(config('services.doc_cheap.key'))
            ->withHeaders(['Idempotency-Key' => $idempotencyKey])
            ->acceptJson()
            ->timeout(60)
            ->retry(
                4,
                fn (int $attempt, Exception $e) => $this->waitSeconds($attempt, $e) * 1000,
                fn (Exception $e) => $this->worthRetrying($e),
                throw: false,
            )
            ->post('scans', [
                'image' => base64_encode($bytes),
                'reference' => $reference,
                'options' => ['return_portrait' => false],
            ]);

        if ($response->successful()) {
            return $response->json();
        }

        $error = $response->json('error') ?? ['code' => 'unknown', 'message' => $response->reason()];
        throw new RuntimeException(sprintf('%s (%d): %s', $error['code'], $response->status(), $error['message']));
    }

    private function waitSeconds(int $attempt, Exception $e): int
    {
        $after = $e instanceof RequestException ? (int) $e->response->header('Retry-After') : 0;

        return $after > 0 ? $after : 2 ** $attempt;
    }

    private function worthRetrying(Exception $e): bool
    {
        if ($e instanceof ConnectionException) {
            return true; // 超时或连接中断:有了这个键,重试是安全的
        }

        return $e instanceof RequestException
            && in_array($e->response->json('error.code'), self::RETRYABLE, true)
            && (int) $e->response->header('Retry-After') <= 60;
    }
}

设置 throw: false 后,不值得重试的拒绝会作为响应返回,服务再把它转换成一个写明错误码的异常。在你自己的代码里,给这个异常一个专门的类,并把 error 数组挂在上面,这样调用方就能按 code 而不是按消息来分支。

一个接收上传、只保存所需内容的控制器:

public function store(Request $request, PassportReader $reader)
{
    $request->validate(['passport' => ['required', 'file', 'mimes:jpg,png', 'max:10240']]);

    $scan = $reader->read(
        $request->file('passport')->get(),
        (string) Str::uuid(),
        reference: (string) $request->user()->id,
    );

    if ($scan['meta']['status'] !== 'recognized') {
        return back()->withErrors(['passport' => 'We could not read that photo. Please try a sharper one.']);
    }

    $request->user()->passportChecks()->create([
        'scan_id' => $scan['meta']['id'],
        'billed' => $scan['meta']['billed'],
        'expiry_date' => $scan['document']['expiry_date'] ?? null,
        'mrz_status' => $scan['mrz']['status'],
    ]);

    return redirect()->route('profile');
}

在队列任务中,把键作为任务的一部分。 如果你把调用移到任务里,请在上传到达时生成幂等键,并传给任务的构造函数。这样,失败后被队列 worker 重试的任务会发送同一个键,已经执行过的扫描会被重放,而不是再次扣费。在 handle() 里新生成一个 Str::uuid() 就会让这一切失效。

用 Http::fake() 测试重试路径。 先返回 429、再返回 200 的序列,不发起任何网络调用,就能证明第二次尝试确实发生,并且带着同一个键:

Http::fake([
    'api.doc.cheap/*' => Http::sequence()
        ->push(['error' => ['code' => 'rate_limited', 'message' => 'Slow down', 'docs_url' => null, 'request_id' => 'req_test', 'event_id' => null]], 429, ['Retry-After' => '1'])
        ->push(['meta' => ['id' => 'scan_test', 'status' => 'no_document_found', 'billed' => false]], 200),
]);

$scan = app(PassportReader::class)->read('fake-bytes', 'key-1');

Http::assertSentCount(2);
Http::assertNotSent(fn ($request) => $request->header('Idempotency-Key') !== ['key-1']);

少保存数据

调用结束后不要保存上传的图片;从结果里读出需要的内容,然后丢弃字节。在服务端,结果会被保存,以便你用 GET /v1/scans/{id} 再次读取,保存期限可按请求选择:options.retain_hours 取值 0 到 8760,0 表示什么都不写入。代价就是上面的重放规则:使用 0 时,用同一个键重试会得到 idempotency_replay_unavailable。如果两者都想要,就选一个大于零的较短期限。

切换到正式环境

把 DOC_CHEAP_API_KEY 设为你自己的 sk_live_ 密钥,其他都不用改:同样的接口、同样的响应、同样的客户端。注册密钥允许每分钟 60 个请求,所以遵守 Retry-After 的批处理不需要自己再做限流。如果你还在挑选服务商,护照 OCR API 对比列出了付费替代方案及其公开价格。

**问你一个问题:**你现在是在 PHP 里自己解析 MRZ,还是交给服务来处理?欢迎在下方评论区告诉我们。