护照照片是大多数应用所收到的最敏感的文件。上面有人脸、全名、出生日期和证件号码,仅凭这些就足以在别处开立账户。然而在许多上传流程里,这张图片在有人问起它是否真有必要存在之前,就已经被复制了五六次。

本文从工程角度看待这个问题。文中引用 GDPR 关于数据保存的规定,逐一梳理护照图像悄无声息地堆积起来的地方,并介绍一种我们称为“读取、返回、遗忘”的模式:图像被读取,结果被返回,图片本身什么也不留下。最后讨论仍然属于你的那部分工作,因为有些企业必须保存一份副本,而从 2027 年 7 月起,欧盟将在一部新法律中明确规定这一点。

这是 doc.cheap 的博客。doc.cheap 是一款护照和身份证 OCR API,它读取身份证件并以 JSON 返回结果。阅读涉及产品的部分时,请记住这一点。

GDPR 实际要求什么

GDPR 并没有说“永远不要存储护照图像”。它说的是更有用的话:保留你需要的,在你需要的时间内保留,不多保留一天。Reg. (EU) 2016/679 第 5 条第 1 款规定了这些原则,其中两条决定了大部分设计。

原则 第 5 条第 1 款原文(英文版) 对图像意味着什么
数据最小化,(c) 项 "adequate, relevant and limited to what is necessary in relation to the purposes for which they are processed" 如果你的流程需要的是姓名、出生日期和证件号码,那么读取这些信息之后,图片本身可能就不再必要。
存储限制,(e) 项 "kept in a form which permits identification of data subjects for no longer than is necessary for the purposes for which the personal data are processed" 每份副本都需要一个截止日期,而“我们一直没顾上删除”不算截止日期。

第 5 条第 2 款增加了问责要求:你必须能够证明自己遵守了这些原则。第 25 条第 1 款,即通过设计保护数据,要求采取 "appropriate technical and organisational measures, such as pseudonymisation, which are designed to implement data-protection principles, such as data minimisation, in an effective manner",也就是采取适当的技术和组织措施,例如假名化,用来有效落实数据最小化等原则。

把这些条文放在一起读,法律问题就变成了工程问题。图像的副本越少,你需要描述、保护、备份并最终清空的地方就越少。从未被写入的副本,才是唯一不需要这些工作的副本。

护照图像最终会落在哪里

大多数团队会有意识地把图像存在一个地方。麻烦在于那些没人选择过的地方。下面这份清单,可以对照你自己的流程逐项检查。

位置 图像如何到达那里
上传存储桶 客户端先上传到对象存储,后端再从那里读取。对象的存在时间比请求更长。
请求日志 日志中间件会写下请求体,而 base64 编码的图像就是请求体。
错误报告 异常追踪工具会附上失败请求的载荷。
队列与重试 任务消息携带着图像,死信队列会把失败的消息保留数周。
备份与快照 当天拍下的数据库或磁盘快照,包含此前写入的每一张图像,即使那一行早已删除也依然存在。
支持工单 用户“因为上传没成功”又把照片通过邮件发了一遍。
分析与会话回放 工具会录下页面,包括所选文件的预览。
OCR 服务商 读取证件的服务会按照它自己的保存规则,保留它自己的副本。

最后一行是你最难控制的。你自己的日志可以修。供应商持有的副本受该供应商的设置约束,你必须去问它的设置是什么。

模式:读取、返回、遗忘

这个模式说起来很简单。图像只在一次请求期间存在于内存中。离开请求的是读取结果,也就是提取出的值。图片本身完全不会离开。

  1. 把图像直接发送去识别。 中间不经过上传存储桶。如果因为文件较大而无法避免使用存储桶,就把对象的生命周期设为几分钟,并在调用返回时删除它。
  2. 立即使用响应中你需要的内容。 持证人照片之类的裁剪图只存在于那次响应中。如果你的流程要把自拍与证件照比对,现在就做。
  3. 保留读取结果,而不是图片。 按照你自己的保存规则,存储流程所需的字段。出生日期和证件号码仍然是个人数据,所以它们同样需要截止日期。
  4. 让图像远离日志和错误报告。 在边界处的一个地方统一去除请求体,而不是指望每个调用方都记得这样做。
  5. 把结果记录下来。 针对上表中的每个位置,记下图像能否到达那里,以及为什么不能。这份记录正是第 5 条第 2 款所要求的问责。

我们的 API 如何处理图像

下面是 doc.cheap 对同一问题的处理方式,内容依据其数据保留与隐私页面的说明。

  • 图像从不存储。 它在请求期间位于内存中,被交给识别引擎,并在响应写出后消失。没有任何磁盘、对象存储或日志会接收它。
  • 裁剪图也不存储。 证件裁剪图、持证人照片和签名,会在生成它们的那次调用的响应中返回。之后通过 GET /v1/scans/{id} 读回的扫描记录,所有图像字段都为 null。
  • 可以保留的是读取结果, 而且只在你要求的期限内保留。retain_hours 选项按请求设置这一期限,范围从 0 到 8760 小时(一年)。显式指定的值始终优先于账户设置。
  • retain_hours: 0 什么也不写入。 不是一条立即过期的记录,而是根本没有记录。没有需要清理的东西,备份里什么也没有,也没有可导出的内容。这次扫描仍然计为一次扫描。
  • 其余情况由账户默认值处理。 当请求没有指定期限时,适用账户自己的历史设置:24 小时、7 天、1 个月或 1 年。新账户从 1 年开始,以便控制台能显示历史记录。缩短该设置也会作用于已存储的记录,每条记录都从其自身的创建时间起算。
  • 保留的记录会留下一张小图: 一张最长边不超过 96 px、大小不超过 16 KiB 的缩略图,显示在控制台的操作日志中,便于识别某条记录。它无法通过 API 读取。缩略图随记录一起删除。
  • 单次扫描可以提前删除。 使用 live 密钥发送 DELETE /v1/scans/{id},即可删除结果、历史记录和缩略图。删除不可恢复。

控制历史记录保留期指南逐步介绍了这些设置,我们的数据处理方式页面则给出了摘要。

下面是一个使用 requests 的 Python 零保留调用。公开的沙盒密钥 sk_sandbox_public 印在文档中,无需注册:每个地址总共可免费识别 10 份证件,每小时最多 10 次请求。零保留是您自己账户的设置,因此需要您的正式(live)密钥。公开沙盒不是账户:它会把每次扫描连同其小图记录在服务自己的日志中,所以只向它发送测试图片,切勿发送真实证件。

import base64
import uuid

import requests

API = "https://api.doc.cheap/v1/scans"
KEY = "sk_sandbox_public"  # 生产环境中使用你自己的 live 密钥


def read_and_forget(path):
    with open(path, "rb") as f:
        image = base64.b64encode(f.read()).decode("ascii")
    response = requests.post(
        API,
        headers={
            "Authorization": f"Bearer {KEY}",
            "Idempotency-Key": str(uuid.uuid4()),
        },
        json={
            "image": image,
            # 0:使用 live 密钥时,API 端不会记录这次扫描的任何内容。
            # False:不返回证件照裁剪图,因为这个流程用不到。
            "options": {"retain_hours": 0, "return_portrait": False},
        },
        timeout=30,
    )
    response.raise_for_status()
    scan = response.json()
    del image  # 调用一返回就丢弃本地副本
    if scan["meta"]["status"] != "recognized":
        return None
    # 按照你自己的保存规则,保留流程所需的读取结果。
    return {
        "scan_id": scan["meta"]["id"],
        "document_number": scan["document"]["number"],
        "expiry_date": scan["document"]["expiry_date"],
        "birth_date": scan["holder"]["birth_date"],
        "mrz_status": scan["mrz"]["status"],
    }

零保留有一个代价,最好在它让你措手不及之前了解。通常,Idempotency-Key 能让重试返回第一次的结果。使用 retain_hours: 0 时没有已存储的结果可返回,因此在 24 小时内,用同一个键发起的重试会被拒绝,返回 HTTP 409 和代码 idempotency_replay_unavailable,而不会被应答两次。请把这个响应理解为“第一次调用已经成功”,并使用你手头已有的结果。

读回扫描记录,能看到这一设计的另一面。沙盒密钥无论用什么 id 都读不回任何内容。我们在 2026 年 10 月 5 日用 sk_sandbox_public 发送了以下请求:

curl https://api.doc.cheap/v1/scans/<SCAN_ID> \
  -H "Authorization: Bearer sk_sandbox_public"

返回的是 HTTP 404(消息已截短):

{
  "error": {
    "code": "not_found",
    "message": "No scan with id …",
    "docs_url": "https://doc.cheap/docs/errors/not_found"
  }
}

对于用 retain_hours: 0 完成的扫描,以及任何已超过保留期的扫描,live 密钥也会得到同样的 404。如果你正在就这一点比较各家服务,可以从护照 OCR API 对比开始;不仅要问每家服务返回什么,还要问图像去了哪里。

仍然属于你的工作

“读取、返回、遗忘”消除了 API 端的副本,但它并不决定你的企业必须保留什么。对某些企业来说,答案是“一份副本”,而且法律就是这样规定的。

欧盟新的反洗钱法律 Reg. (EU) 2024/1624 自 2027 年 7 月 10 日起适用。其第 90 条规定:"It shall apply from 10 July 2027, except in relation to obliged entities referred to in Article 3, points (3)(n) and (o), to which it shall apply from 10 July 2029." 意思是:它自 2027 年 7 月 10 日起适用,只有第 3 条 (3)(n) 和 (o) 所列的义务实体自 2029 年 7 月 10 日起适用。其关于记录保存的第 77 条,要求银行和其他金融机构等义务实体保存:

"a copy of the documents and information obtained in the performance of customer due diligence pursuant to Chapter III, including information obtained through electronic identification means;"

换句话说,就是在客户尽职调查中获取的文件和信息的副本,包括通过电子身份识别手段获取的信息。

第 77 条第 3 款规定了期限:记录要 "retained for a period of 5 years commencing on the date of the termination of the business relationship",即自业务关系结束之日起保存 5 年;此后 "obliged entities shall delete personal data upon expiry of the five-year period",即五年期满后必须删除个人数据。第 77 条第 2 款允许在一定条件下以 "a retention of the references to such information" 代替副本,即只保存指向这些信息的引用。

所以,如果你是义务实体,API 端的零保留并不能免除你保存记录的义务。它改变的是记录存放的位置。你自己的存储成为唯一的副本,而上文提到的存储限制原则依然适用于它:业务关系结束五年后,它就要被删除。设计工作在于让这个存储成为有意为之的存储:一个位置、一个负责人、加密、访问控制和一个删除任务,而不是上表中那种无意间堆积起来的副本。

如果你不是义务实体,请先问一个朴素的问题:字段读取完成之后,你的流程中还有什么需要那张图片吗?诚实的答案往往是没有。

检查清单

  • 已对照你的流程,逐一检查“护照图像最终会落在哪里”表中的每个位置。
  • 图像直接送去识别,或经过一个生命周期以分钟计的存储桶。
  • 裁剪图在响应处理函数内使用,不写入任何地方。
  • OCR 调用有意识地设置保留期,无需读回时使用 retain_hours: 0。
  • 重试逻辑能处理 409 idempotency_replay_unavailable 响应。
  • 日志和错误报告在同一个边界处去除请求体。
  • 你保留的字段有截止日期,并且有机制负责删除它们。
  • 如果法律要求保留副本,它存放在一个有意设立的存储中,并有自己的删除日期。

本文是一份工程总结,不构成法律意见。如果你发现了本文遗漏的、图像可能泄漏的地方,请写信至 admin@doc.cheap。

想问问你: 你上一次发现一份没人打算保留的身份证件副本,是在什么地方?欢迎在下方评论区告诉我们。