电子遗嘱存证这块业务,这两年最大的压力其实不在用户量,而在合规。一边是公证处和司法平台对“人证一致、本人自愿、精神状况正常”的认定要求越来越明确,一边是用户手机摄像头里的光线、角度、反光、口罩遮挡各种不受控。活体识别在这个场景里不是锦上添花的功能,而是整个电子存证证据链的第一环,跑不通后面全是白搭。
我们团队最近在做的事情,就是用 Python 把这一环完整地接到电子遗嘱存证平台里。具体来说,是把海宇活体识别V 的检测能力,结合数据工程里那套清洗、打标、归档的做法,做成一条既满足合规要求、又不会让用户觉得“被盘问”的流程。这个项目我把它拆成了三个阶段,当前这篇讲的就是步骤1——主链路打通与证据留存基础版。如果你也在做身份核验、司法存证、金融双录这类系统,或者只是对“Python 怎么接一个第三方活体识别服务”感兴趣,这篇东西应该能帮你少踩不少坑。
1. 项目背景与整体设计思路
1.1 电子遗嘱存证的合规痛点到底在哪
一般电商、社交App做实名认证,活体识别挂了就挂了,顶多被风控拦一下。但电子遗嘱不一样,它的产出物是“未来可能被法庭调用的电子证据”。最高人民法院对电子数据真实性的审查要求里,强调了三件事:生成过程是否真实、保存过程是否完整、关联主体是否明确。对应到遗嘱场景,就是“操作遗嘱的这个人,是不是遗嘱人本人”“他/她做这个操作的时候是不是清醒的、自愿的”“这个操作行为能不能完整回溯”。
所以一套合格的流程里,必须有这几样东西:身份凭证(比如身份证OCR和人脸比对)、活体检测(证明你不是用照片、视频或面具在骗系统)、行为记录(签署意愿的表达过程)、时间戳(把行为钉死在时间线上)。这里面最容易搞砸的就是活体检测——它既是技术门槛,也是体验门槛。检测太松,合规不认;检测太严,用户一小时内流失率能涨到三成以上。
1.2 为什么选海宇活体识别V加 Python 这套组合
选型的时候我们其实对比过市场上好几家活体识别服务。最后留下海宇活体识别V,主要是看中它的接口返回非常“工程化”:除了活体分数,还会返回 verify_id、现场人脸截图帧、风险标签(例如“照片翻拍”“屏幕翻拍”),以及原始响应 JSON。这些字段对存证来说不是鸡毛蒜皮,而是编制证据链的原材料。它支持静默活体和动作活体两种模式,静默模式下用户不需要做眨眼、张嘴、摇头,通过率会明显高一些,合规端也能接受。
Python 在这一环的价值不在“识别能力”本身,而在数据工程侧的粘合能力。海宇的接口本质上就是 HTTPS + JSON,而 Python 做 HTTP 封装、结构化校验、数据清洗、异步归档这一套生态特别成熟。requests 发请求,pydantic 做响应模型校验,numpy 处理图像帧,Redis 队列做异步归档。这些库都是现成的,代码量小,出问题也好排查。相比之下,如果我拿 Java 去写,也不是不行,但同样的活要多写不少样板代码。
1.3 项目整体架构是怎么设计的
整个步骤1的链路是这样的:用户在遗嘱产品页面点击“开始订立”后,前端通过摄像头采集一段短时视频或一帧高清照片,传给后端 Python 服务;后端把图片转成 base64,带上签名参数请求海宇活体识别V;海宇返回活体结果后,Python 服务先做响应清洗和分数策略判断,然后把“请求ID、海宇verify_id、分数、风险标签、人脸帧、原始JSON、操作时间戳”打包成一条结构化证据记录,存入 MySQL 和对象存储;最后异步通知存证服务,生成存证编号,前端轮询拿到结果展示给用户。
这里有个关键取舍:识别主链路用同步的方式,用户点完按钮要在几秒内看到结果,拖太久体验就完了。但“证据打包归档”这个动作放到异步队列里做,因为归档涉及哈希计算、文件上传、数据库写入,容易慢,而且没必要让用户等。同步 + 异步混合,是这类业务最常见也最稳的架构。
2. 环境准备与核心依赖安装
2.1 Python 版本与虚拟环境选择
我们统一用的 Python 3.8。不是因为它最新,而是因为项目里几个关键依赖在 3.8 上的兼容性最稳:opencv-python 的预编译包、pydantic 的 v1 版本、还有跑异步任务的 Redis 客户端,在 3.8 上都是久经考验的组合。团队里有同学图新鲜装了 Python 3.12,结果 opencv 某个版本在 wheel 层出问题,折腾了半天,后来统一回退。
我建议不管你是 Windows、macOS 还是 Linux,都先建虚拟环境再装依赖,千万别直接用全局环境。特别是 Linux 服务器上,系统自带的 Python 往往被系统工具依赖,你直接 pip install 容易把系统环境搞乱。我一般这样建:
python3.8 -m venv venv source venv/bin/activate pip install --upgrade pipWindows 上就是venv\Scripts\activate。这一步干完,后面所有 pip 安装都只在项目环境里生效。
依赖清单我用 requirements.txt 管理,核心几个是这样的:
requests>=2.28.0 opencv-python>=4.5.0 numpy>=1.21.0 pydantic>=1.10.0 python-dotenv>=0.20.0 redis>=4.5.0 SQLAlchemy>=1.4.0 pytest>=7.0.02.2 opencv 和 numpy 那个经典坑
网上搜 python 下载 cv2、python 安装 numpy 库的人特别多,说明这两个库看着简单,装起来就是容易出问题。最有名的一个坑是 opencv-python 和 numpy 的版本互相打架,常见的现象是import cv2的时候报DLL load failed,或者导入后一运行崩掉。这通常是因为 numpy 版本太新,和 opencv 预编译时依赖的 numpy ABI 对不上。
解决办法很简单:先固定 numpy 版本再装 opencv,或者干脆用我上面那份 requirements.txt 的顺序。还有一个更隐蔽的坑是机器上之前装过另一个叫 opencv-contrib-python 的包,和 opencv-python 冲突。排查时用pip list | grep opencv看一眼,如果有两个,先全部卸载再重装一个。
另外一个跟业务有关的坑:opencv 默认读图片是 BGR 通道,不是 RGB。如果你要把海宇返回的 face_frame 图片保存到对象存储,再在前端回显,转了 base64 发现颜色发蓝,八成就是通道顺序没转:
import cv2 def bgr_to_rgb(bytes_data: bytes) -> bytes: arr = cv2.imdecode(np.frombuffer(bytes_data, np.uint8), cv2.IMREAD_COLOR) rgb = cv2.cvtColor(arr, cv2.COLOR_BGR2RGB) ok, buf = cv2.imencode(".jpg", rgb) return buf.tobytes()2.3 海宇活体识别V 接口接入前的配置准备
接入任何第三方服务,我的习惯是先把环境信息和密钥单独放到 .env 文件里,绝不让密钥出现在代码里。比如这样:
HAIYU_APP_ID=your_app_id HAIYU_SECRET_KEY=your_secret_key HAIYU_BASE_URL=https://api.haiyu.example.com/v1 HAIYU_LIVENESS_API=/liveness/detect REDIS_URL=redis://localhost:6379/0然后 Python 侧用 python-dotenv 加载。海宇的接口要求每次请求带签名,一般是时间戳、随机串、AppID 加盐做 HMAC-SHA256。官网文档会写清楚签名算法,但要注意两点:一是服务器时间必须准,我们踩过签名过期的问题,最后发现是测试服务器没开 NTP,时间慢了两分钟;二是请求里的时间戳要用 UTC,签名里用哪个时间戳,请求头里就传哪个,两边不一致必报错。
在写 Python 代码之前,先把接口用 curl 调通一次,确认密钥和签名姿势正确,这一步能省后面至少半小时的排查时间。
3. 活体识别服务封装实现
3.1 为什么必须单独写一个 service 层
很多新手写第三方接口对接,喜欢直接在业务函数里 requests.post。业务里面逻辑多了之后,你会发现活体识别的代码散落在各处:这里调一次、那里调一次,海宇万一哪天下线了或者换成别家服务,你得全项目搜着改。
所以我第一件事就是单独建一个liveness.py服务模块,把海宇的对接全部收敛在里面。这样做有几个实际好处:第一,业务代码只依赖我们自己定义的LivenessResult对象,不直接依赖海宇的字段,后续换服务商只改这一个文件;第二,统一在这里做请求超时、重试、日志记录,审计过关;第三,单元测试好写,mock 这个文件就行。
项目结构大致是:
will-evidence-platform/ ├── app/ │ ├── main.py │ ├── services/ │ │ └── liveness.py │ ├── data/ │ │ └── evidence_store.py │ ├── schemas/ │ │ ├── liveness.py │ │ └── common.py │ └── utils/ │ └── sign.py ├── config/ │ └── settings.py ├── tests/ ├── requirements.txt └── .env3.2 核心代码:签名、请求与响应封装
下面这一段是步骤1里最核心的代码,我把签名逻辑、网络请求、超时重试都放进了一个LivenessClient类里。你会注意到我没有把重试逻辑做成简单的 while 循环,而是用tenacity库来管理,这样退避策略、重试次数、可观测性都更干净。实际项目中我们用的是tenacity,你如果不想引入额外依赖,用 for 循环加time.sleep也能做,但不推荐。
import hashlib import hmac import json import time import uuid import requests from tenacity import retry, stop_after_attempt, wait_exponential from app.schemas.liveness import LivenessResult, LivenessRiskTag class LivenessClient: def __init__(self, app_id: str, secret_key: str, base_url: str): self.app_id = app_id self.secret_key = secret_key self.base_url = base_url def _generate_sign(self, timestamp: str, nonce: str) -> str: message = f"{self.app_id}{timestamp}{nonce}".encode("utf-8") sign = hmac.new( self.secret_key.encode("utf-8"), message, hashlib.sha256 ).hexdigest() return sign def _build_headers(self) -> dict: timestamp = str(int(time.time())) nonce = uuid.uuid4().hex return { "Content-Type": "application/json", "X-App-Id": self.app_id, "X-Timestamp": timestamp, "X-Nonce": nonce, "X-Sign": self._generate_sign(timestamp, nonce), } @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=8), reraise=True, ) def detect_from_base64(self, image_base64: str) -> LivenessResult: payload = { "image_base64": image_base64, "scene": "WILL_DEPOSIT", "mode": "silent", "need_face_frame": True, } resp = requests.post( self.base_url + "/liveness/detect", json=payload, headers=self._build_headers(), timeout=(3.05, 10), ) resp.raise_for_status() raw = resp.json() # 结构化校验放在 schema 层,这里只做边界判断 if raw.get("code") != 0: raise RuntimeError(f"haiyu liveness error: {raw.get('msg')}") return LivenessResult(**raw["data"])这里有两个容易被忽略的点。第一,timeout=(3.05, 10)的第一个值是连接超时,第二个值是读取超时,不要只传一个数。我们线上遇到过连接一直挂住但迟迟不返回的情况,不设连接超时的话,线程会被拖死。第二,重试只应当对网络异常和 5xx 做,海宇返回业务错误码(比如图片模糊、人像太小)不要重试,重试多少次都一样,还浪费时间。
3.3 响应解析与数据清洗:用 pydantic 而不是手写判断
海宇返回的 JSON 结构比较复杂,里面有十几层嵌套字段。如果用raw["data"]["face_frame"]这种写法,一旦某个字段缺失,线上就是 KeyError 崩溃。步骤1里我坚持用 pydantic 定义响应模型,靠它做类型校验和默认值兜底:
from pydantic import BaseModel, Field class LivenessResult(BaseModel): verify_id: str liveness_score: float = Field(ge=0.0, le=100.0) risk_tags: list[str] = [] face_frame_base64: str = "" device_info: str = "" request_time: int raw_json: dict = {} @property def normalized_score(self) -> float: """把 0-100 的分数归一化成 0-1,方便下游策略使用。""" return round(self.liveness_score / 100.0, 4)数据清洗在这里主要干几件事:把字符串数字转成 float、缺失字段给默认值、空数组归一化、把海宇返回的时间戳转成我们统一的 UTC 格式。这一步看起来很碎,但对于存证平台来说,统一的数据格式就是证据可解释性的基础。你总不能哪天做统计报表的时候,发现一分数据里 score 是 0-100、另一分数据里是 0-1,那报表就没法做了。
还要做一个动作:把海宇返回的原始 JSON 原封不动保留到raw_json字段里。清洗后的结构化字段给人看、给策略用,原始 JSON 给审计留底。万一双方对结果有争议,原始接口响应就是最直接的证据。
4. 存证链路中的数据工程处理
4.1 把一次活体识别变成一份可用证据
合规平台的一个核心思想是:不管前端怎么交互,后端必须能证明每一次关键操作“是谁、在什么时候、通过什么设备、拿到了什么结果、依据是什么”。所以步骤1里我会把活体检测的每个关键输出字段,都映射成一条证据记录。下面是证据表的核心字段设计,实际字段会更多,但骨架是这套:
| 字段 | 含义 | 来源 | 用途 |
|---|---|---|---|
| transaction_id | 业务流程唯一ID | 前端生成,后端全程透传 | 串起遗嘱流程与检测记录 |
| verify_id | 海宇本次检测唯一ID | 海宇响应 | 与海宇侧核对订单 |
| liveness_score | 活体分数 0-100 | 海宇响应 | 通过/复核/拒绝判定 |
| risk_tags | 风险标签数组 | 海宇响应 | 人工复核优先级 |
| face_frame_base64 | 现场人脸截图 | 海宇响应 | 证据材料 |
| raw_json | 海宇原始响应 | 海宇响应 | 审计留底 |
| server_timestamp | 后端收到响应的UTC时间 | 本地生成 | 防时间篡改 |
| data_hash | 上述字段的SHA-256 | 本地计算 | 数据完整性校验 |
很多团队会忽略server_timestamp和data_hash这两个字段。但做过司法存证的朋友应该知道,证据的“生成时间”不能只采信第三方返回的request_time,因为那只是第三方服务器的时间,你没法证明你的服务器是什么时候收到并记录的。本地时间戳 + 数据哈希,才是在纠纷发生时能拿得出手的“过程记录”。
计算哈希很简单:
import hashlib import json def calc_evidence_hash(core_fields: dict) -> str: canonical = json.dumps(core_fields, sort_keys=True, ensure_ascii=False) return hashlib.sha256(canonical.encode("utf-8")).hexdigest()注意sort_keys=True,因为 JSON 字典序不同会得到不同的哈希,取证时必须用确定性序列化。
4.2 数据打标与流水对齐,让整个链路“能解释”
做过数据工程的都知道,最怕的不是数据量大,而是数据对不上。活体识别结果、存证编号、遗嘱签署记录如果各自为政,审计时根本没法串起来。所以步骤1里我规定了一个铁律:transaction_id从用户点击“开始订立”那一刻由前端生成,后面所有环节全都在日志、数据库、外部接口参数里透传这个ID。
实现上,我让前端生成transaction_id后随活体检测请求一起传给后端,后端在调用海宇接口时把这个 ID 也放在scene字段里,比如scene="WILL_DEPOSIT|txn_20240512_xxxx"。这样即使不去查数据库,光看海宇侧的单据都能反查出是哪个用户的哪次操作。
日志也要结构化。我们统一把所有业务日志写成 JSON 行,每行包含ts、level、txn_id、event、detail。线上排查的时候直接按txn_id过滤,一条遗嘱从进来到归档,几毫秒内能拉出全链路时间线。这一点做得好,验收时法务和技术对账会省非常多力气。
4.3 异步归档:用 Redis 队列把慢操作挪出主链路
用户等不了证据归档,但证据必须归档。所以步骤1的架构里,主链路拿到海宇结果后立刻返回给前端展示,同时把完整证据记录丢进 Redis 队列,由消费者异步写入数据库和对象存储。这里有个坑:异步任务丢失怎么办?我们的做法是消费者处理成功后写入一张已完成表,并定期做“对账任务”,把 Redis 里已消费但证据表没记录的数据捞出来补写。
用 Python 写消费者时,我选择了 Redis 的 BRPOP 命令配合线程池。为什么不用消息中间件?因为项目早期没那么大流量,一台 Redis 足够,Kafka 进来反而增加运维成本。代码逻辑大致是这样:
import json import threading import redis class EvidenceConsumer: def __init__(self, redis_url: str, store): self.r = redis.from_url(redis_url) self.store = store def worker(self): while True: _, payload = self.r.brpop("evidence_queue", timeout=5) if not payload: continue record = json.loads(payload) try: self.store.save_evidence(record) except Exception: # 写失败重新放回队列,重试三次后进入死信集合 self.r.lpush("evidence_queue_dead", payload) def start(self, workers: int = 3): for _ in range(workers): t = threading.Thread(target=self.worker, daemon=True) t.start()这个实现对单机消费来说够用,而且因为brpop是原子弹出的,多个 worker 不会拿到同一条数据,天然解决了重复消费问题。后面流量上来了再平滑迁移到消费组模型,不用大改业务代码。
5. 合规体验优化:合规和用户感受并不是二选一
5.1 交互时序怎么排,才不让用户觉得“被审问”
最开始的产品方案,把活体识别放在用户填完遗嘱基本信息、即将签署前弹出来。实测用户流失率特别高,因为在那个节点用户心理预期是“我要签字了”,结果突然被要求“把脸放进框里转一下头”,很多人直接退出。
后来我们改了时序:用户进到产品首页、点击“开始订立”之后,第一步就做身份核验。这个节点用户是带着“办正事”的心理进来的,被要求人脸识别反而觉得是正常的。海宇活体识别V 的静默模式在这里也贡献很大——用户只需要对着镜头一两秒,不用做动作,通过率比动作模式高一大截。前端引导文案也刻意简化,就一句话:“请正对屏幕,保持光线充足,几秒即可完成验证。”
5.2 分数阈值策略怎么写,既守住底线又不误伤
海宇返回的活体分数是0-100,我们策略上做了三档。80分及以上直接通过;60到79分进入人工复核队列;60分以下直接拒绝,并提示“请重新尝试”。这个阈值不是拍脑袋定的,是用线下3000条样本算出来的。我们拿历史通过数据、人工复核结论、后续纠纷样本做了交叉分析,发现80分以上的样本里后续被人工否定的人数极少,而把阈值降到75会让风险上升不少。
线上策略不是死的,代码里把阈值放进配置:
SCORE_PASS = int(os.getenv("LIVENESS_SCORE_PASS", "80")) SCORE_REVIEW = int(os.getenv("LIVENESS_SCORE_REVIEW", "60"))这样改策略不用发版,运维改个环境变量就行。对合规平台这个很重要,因为监管要求随时可能调整。
5.3 异常降级:服务不可用时不能一刀切
第三方服务总会有抖动。我们的底线是:绝对不允许因为活体服务不可用就跳过检测,那样整个平台的合规基础就崩了。但也不能让用户干等。
步骤1里的降级策略是“延迟核验 + 人工兜底”:海宇接口超时或报错时,先放用户进入“待复核”状态,遗嘱流程可以继续填,但最终生效前必须有人工审核确认身份。系统会在后台自动重试三次,重试仍失败就把记录标记为PENDING_REVIEW,推送给人审团队。用户端看到的提示是“身份核验暂时繁忙,我们会在24小时内完成复核”,而不是冷冰冰的“系统错误”。
这种降级方案既保住了用户体验,又没有突破合规底线。关键实现点在于,所有降级记录也会进入证据表,并标注verify_status=DELAYED_REVIEW,确保流程可追踪、可解释。
6. 常见问题与排查技巧实录
这里把我实际遇到、或者帮同事排查过的高频问题整理成了一份速查表,大家以后对接活体识别时可以直接照着查。
| 现象 | 常见原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| 接口报签名过期 | 服务器本地时间不准 | 用date -u看服务器UTC时间,和本机对比 | 安装NTP并开启自动同步,重启服务 |
import cv2报DLL加载失败 | numpy版本与opencv不匹配 | pip show numpy opencv-python查看版本 | 固定numpy为1.21.x,重装opencv |
| 图片上传413 | base64体积超过海宇单张5MB限制 | 查看上传图片原始大小 | 前端用canvas压缩到1280px以内再传 |
| 活体分数普遍偏低 | 光线不足/手机老旧 | 看海宇返回的风险标签和设备信息 | 引导用户到光线充足环境,或切换动作模式 |
| 异步归档偶尔丢数据 | Redis队列消费异常 | 检查evidence_queue_dead长度 | 加死信队列 + 定时对账脚本 |
| 前端回显人脸帧发蓝 | opencv BGR与RGB未转换 | 保存前打印像素通道均值 | 用cv2.cvtColor转通道 |
| 请求量上来后响应变慢 | 同步调用没有连接池 | 查看requests是否有大量TIME_WAIT | 用requests.Session()复用连接 |
再分享一个独家经验:调试第三方接口时,一定要把完整请求头和响应体打出来。很多人总觉得打印日志会拖慢性能,但系统上线初期,定位问题最快的方式就是看原始报文。我们的做法是在测试环境用中间件打印完整请求响应,生产只打印摘要。有一次海宇侧反馈某个请求带的人脸帧不是现场帧,我们靠原始日志里的request_time和face_frame的EXIF时间戳对比,一下就发现是前端传错了图片,不是海宇的问题。
还有一个小技巧:测试环境调用海宇之前,先用一张真人自拍和一张打印照片各跑一遍,确认接口确实能识别“活体对抗”。这样你在自测阶段就能发现分数阈值设置是否合理,不用等线上用户替你试错。
最后再补充一点个人体会。活体识别这种第三方服务,难的不是把接口调通,而是把它嵌进你自己的业务和数据架构里。接口调通只要半天,把证据链路、异常兜底、数据对账全部理顺,才是真正花时间的地方。步骤1我们一共用了两个星期,其中真正写代码的时间不到三天,剩下的时间全在讨论字段规范、阈值策略和对账机制。但恰恰是这些看起来不酷的工作,决定了一个存证平台在关键时候能不能拿出站得住脚的证据。
如果你也在做类似的对接,我真心建议你在一开始就把证据表结构、日志规范、降级策略想清楚,哪怕前期多花几天,后面维护的成本会省出一个量级。