你手上有一张护照照片,还有一个 .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,还是交给服务来处理?欢迎在下方评论区告诉我们。