你手上有一张护照照片,还有一个 .NET 8 服务,需要把姓名、证件号码、日期和机读区(MRZ)作为数据拿到。本文用纯 C# 12 来写这个客户端:只用 HttpClient、System.Net.Http.Json 和 System.Text.Json,不用 SDK。内容包括第一次调用、接收响应的 record、不算错误的状态、绝不会重复扣费的重试、按错误码处理错误,以及适用于 ASP.NET Core 的类型化客户端。

用到的 API 是 doc.cheap 的护照与身份证 OCR API。它是同步的:一次 POST,一个响应,没有需要轮询的任务 ID。

无需账号的第一次调用

公共 sandbox 密钥是 sk_sandbox_public。无需注册,也不消耗额度。它的限制是每个 IP 地址每小时 10 个请求,外加总计少量的免费试用次数,用完之后会返回 403 registration_required。

请求体是 JSON,只有一个必填字段 image:文件字节的 base64 编码。支持 JPEG 和 PNG;其他格式会以 415 unsupported_media_type 拒绝,判断依据是文件签名而不是文件名。

using System.Net.Http.Headers;
using System.Net.Http.Json;

var http = new HttpClient { BaseAddress = new Uri("https://api.doc.cheap/v1/") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", "sk_sandbox_public");

var image = Convert.ToBase64String(await File.ReadAllBytesAsync("passport.jpg"));
using var response = await http.PostAsJsonAsync("scans", new { image });

Console.WriteLine((int)response.StatusCode);
Console.WriteLine(await response.Content.ReadAsStringAsync());

请求的 schema 很严格。请求体里出现未知的键会以 422 validation_failed 拒绝,所以选项名拼错了会明确报错,而不是被悄悄忽略。

返回的内容

每个响应都有同样的八个分组,而且每个键都始终存在:meta、document、holder、fields、mrz、images、quality 和 authenticity。下面是一个删节并脱敏的示例:

{
  "meta": {
    "schema_version": "1.0",
    "id": "<SCAN_ID>",
    "status": "recognized",
    "billed": true,
    "confidence": "high",
    "created_at": "<ISO_TIMESTAMP>",
    "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>" }
}

什么都没读出来时,document 和 holder 为 null。mrz.status 的取值是 passed、failed 或 absent,passed 表示校验位一致。

接收响应的 record

JSON 使用 snake_case。.NET 8 自带 JsonNamingPolicy.SnakeCaseLower,所以 record 可以保留常规的 C# 命名。未知属性默认会被忽略,因此只需声明你用到的部分。

using System.Text.Json;

public static class Api
{
    public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    };
}

public sealed record Scan(Meta Meta, DocumentInfo? Document, Holder? Holder, Mrz Mrz);

public sealed record Meta(
    string Id, string Status, bool Billed, string Confidence,
    string CreatedAt, string? Reference);

public sealed record DocumentInfo(
    string? Kind, string? Country, string? Number,
    string? IssueDate, string? ExpiryDate, bool? IsExpired, int? DaysRemaining);

public sealed record Holder(
    string? GivenNames, string? Surname, string? BirthDate,
    string? Sex, string? Nationality);

public sealed record Mrz(string Status, string? Reason, IReadOnlyList<string>? Lines, string? Text);

public sealed record ApiError(string Code, string Message, string? DocsUrl, string? RequestId);
public sealed record ErrorEnvelope(ApiError Error);

这里有意把日期保留为字符串。在需要的地方用 DateOnly.TryParse 解析,这样证件上某个值格式古怪时,也不会在反序列化器里抛出异常。

模糊的照片不是异常

返回 200 并不总是代表读出了证件。请根据 meta.status 分支处理:

meta.status 含义 怎么做
recognized 已确定证件类型,并从中提取出数据 使用 document、holder、mrz
no_document_found 画面中没有类似证件的东西 请用户重新拍照
unreadable 有证件,但没有读出可用的文字 请用户拍得更清晰些
unsupported_document 找到了证件,但不是已知的类型 告知用户
rejected 服务端识别失败 记录 meta.id,稍后重试

谁来付费:billed 标志

sandbox 密钥从不消耗额度。使用正式密钥时,只有可计费的扫描才会扣费:证件类型已确定,并且至少读出了以下之一:校验位通过的 MRZ、印刷区的五个或更多字段,或一个成功解码的条形码。否则,预留的额度会被释放。 使用 sandbox 密钥时,meta.billed 仍会表明扫描是否满足这一规则,但不会动用任何余额。

meta.billed 会告诉你结果是哪一种。把它和你自己的订单或用户记录存在一起,就不必靠猜测来对账。正式账号每月还有 100 次免费识别。

不会重复扣费的重试

你这边超时,并不代表我们这边没有执行扫描。请发送 Idempotency-Key 请求头(1 到 255 个字符),同一份证件的每次重试都复用它。使用正式密钥时,服务器会把这个键和请求体的指纹一起匹配:

  • 同一个键、同样的请求体:重放第一次的结果,不会再扣第二次额度。
  • 同一个键、不同的请求体:409 idempotency_conflict。请换一个新键。
  • 第一次调用仍在处理:第二次调用会短暂等待,然后收到 409 idempotency_in_progress。保留这个键,再重试。
  • 第一次的结果已不再保存:409 idempotency_replay_unavailable。请换一个新键。

如果额度预留之后识别引擎失败,预留会被释放,这个键也会空出来,供正常重试使用。详情见用幂等性安全重试指南。

完整的客户端

这是值得保留的版本。它只重试值得重试的错误码,遵守最长 60 秒的 Retry-After,所有尝试都使用同一个键,其余错误一律转换成带错误码的异常。

using System.Net.Http.Json;

public sealed class ScanFailed(int status, ApiError error)
    : Exception($"{status} {error.Code}: {error.Message}")
{
    public int Status { get; } = status;
    public ApiError Error { get; } = error;
}

public sealed class PassportClient(HttpClient http)
{
    static readonly HashSet<string> Retryable =
    [
        "rate_limited", "document_repeated", "internal_error", "engine_unavailable",
        "service_unavailable", "maintenance", "idempotency_in_progress",
    ];

    public async Task<Scan> ReadAsync(
        byte[] imageBytes, string idempotencyKey, string? reference = null,
        CancellationToken ct = default)
    {
        var body = new
        {
            image = Convert.ToBase64String(imageBytes),
            reference,
            options = new { return_portrait = false },
        };

        for (var attempt = 0; ; attempt++)
        {
            using var request = new HttpRequestMessage(HttpMethod.Post, "scans")
            {
                Content = JsonContent.Create(body),
            };
            request.Headers.Add("Idempotency-Key", idempotencyKey);

            using var response = await http.SendAsync(request, ct);
            if (response.IsSuccessStatusCode)
                return (await response.Content.ReadFromJsonAsync<Scan>(Api.Json, ct))!;

            var envelope = await response.Content.ReadFromJsonAsync<ErrorEnvelope>(Api.Json, ct);
            var error = envelope?.Error
                ?? new ApiError("unknown", response.ReasonPhrase ?? "", null, null);

            if (!Retryable.Contains(error.Code) || attempt >= 3)
                throw new ScanFailed((int)response.StatusCode, error);

            var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1 << attempt);
            await Task.Delay(wait > TimeSpan.FromSeconds(60) ? TimeSpan.FromSeconds(60) : wait, ct);
        }
    }
}

键应该来自你自己的系统,比如上传 ID 或订单 ID,而不是在方法里调用 Guid.NewGuid()。来自你数据的键在进程重启后依然有效;临时生成的 GUID 则不然。

所有错误的结构都一样:code、message、docs_url 和 request_id。请按 code 分支,绝不要按 message;联系技术支持时,把 request_id 记进日志。完整列表见错误处理指南。你很早就会遇到的三个:

  • 402 insufficient_credits – 正式余额已用完。不可重试。
  • 413 payload_too_large – 请求体超出上限。请缩小照片。
  • 401 unauthorized – 缺少密钥或密钥未被接受。不可重试。

用 IHttpClientFactory 实现类型化客户端

不要为每个请求新建一个 HttpClient。在 ASP.NET Core 中,只注册一次客户端,让工厂来管理处理程序:

builder.Services.AddHttpClient<PassportClient>(http =>
{
    http.BaseAddress = new Uri("https://api.doc.cheap/v1/");
    http.Timeout = TimeSpan.FromSeconds(60);
    http.DefaultRequestHeaders.Authorization = new("Bearer",
        builder.Configuration["DocCheap:ApiKey"]);
});

把密钥放在配置或密钥存储中,绝不要写进源代码。正式密钥以 sk_live_ 开头,sandbox 密钥以 sk_sandbox_ 开头。如果你从 Microsoft.Extensions.Http.Resilience 添加了弹性处理程序,只让其中一层负责重试;两层都重试,尝试次数会成倍增加。

少保存数据

调用结束后,不要自己保存图片。从结果里读出需要的内容,然后丢弃字节。上面的客户端已经发送了 return_portrait: false,所以人像裁剪图不会出现在响应中。

使用正式密钥时,结果会在账号的历史保留期内保存,默认一年。如果某次调用什么都不想保留,就在 options 中发送 retain_hours: 0:不写入任何记录,也不生成缩略图。代价是这次调用的重试无法重放结果,会返回 idempotency_replay_unavailable。如果两者都想要,就选一个大于零的较短保留期。护照识别指南涵盖了所有选项。

切换到正式环境

把 sk_sandbox_public 换成你的 sk_live_ 密钥,其他都不用改。注册密钥的上限是每分钟 60 个请求。如果你还在挑选服务商,护照 OCR API 对比列出了付费替代方案及其公开价格。

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