
uni-app 做 App最容易在“认证”这一步卡住。表单能写、接口能调、页面能画可一旦业务方说“要确认镜头前是个活人”纯前端那套东西就立刻不够用了。我这两年接过的几个项目最后都落到同一个组合上uniapp 开发的 App 利用百度人脸实现认证功能。这套方案说白了就是把“取景、拍照、活体判断、人脸比对”四件事拆到 App 端、自己的服务端、以及百度人脸服务三处去完成App 端只负责最轻的活——把画面拍清楚、传上去、把结果展示出来。这篇内容适合三类人看第一类是已经在用 uniapp 做 App、被实名认证需求卡住的开发者第二类是刚接触人脸识别接口、不确定该走 H5 方案还是原生采集的同学第三类是要给项目做技术选型、需要评估成本和坑位的负责人。我不会只给你贴一段接口调用代码而是把每一个参数为什么这么取、阈值为什么这么定、权限为什么这么申请都拆开讲清楚。看完全篇你应该能自己从零把这套认证链路搭起来并且知道哪里最容易翻车。1. 先把问题定义清楚这套认证到底在认证什么1.1 认证功能背后其实是三个独立的问题很多人一上来就说“我要做人脸认证”但“认证”这个词其实很含糊。拆开看它至少包含三个彼此独立、可以分别实现的子问题。第一个是活体判断镜头前的是真人还是一张打印的照片、一段录屏、一个手机里的翻拍这个问题解决的是“防作弊”。第二个是身份比对拍到的人脸和系统里已经存档的那张脸是不是同一个人这个问题解决的是“是不是本人”。第三个是证件核验如果业务要求更高还需要把活体人脸和证件信息姓名、证件号做交叉校验确认“这个人和这份证件对得上”。这三件事的技术门槛完全不同。活体判断最依赖摄像头质量和算法身份比对最依赖底库质量和阈值设定证件核验则更多依赖权威数据源。我在做方案设计时习惯先把这三件事列出来然后问业务方一句“你到底要防的是哪一类风险”如果只是防止员工代打卡活体 1:1 比对就够了如果是要跑金融级的开户流程那三个都得做而且阈值要往上抬。这个问题想不清楚后面全是返工。我见过一个项目需求方只说了“要人脸登录”开发同学直接上了 1:N 人脸库搜索结果底库里有几万人搜索耗时高、误识率还高最后发现其实只需要把当前登录账号的存档照片调出来做 1:1 比对难度直接降了一个数量级。1.2 为什么是百度人脸而不是别的方案人脸识别这个赛道可选项不少百度人脸的优势在我的实际体感里主要落在三点上。第一是文档和错误码足够细。这一点在做线上问题排查时太重要了。接口返回一个“未检测到人脸”如果平台只给你这一句你只能猜百度这边会区分“未检测到人脸”“检测到多张人脸”“人脸质量不达标”你在 App 端就能给出精准的用户提示而不是干巴巴一句“认证失败请重试”。第二是活体检测可以直接在服务端做。这一点对 uniapp 项目特别关键。uniapp 打包出来的 App 底层是原生渲染层加 JS 逻辑你要在端上集成一整套原生人脸 SDK需要走离线打包和原生插件接入成本不低。而百度提供了纯接口形态的在线活体检测App 端只负责拍照并把图片传给你的服务端服务端调接口拿到活体分再决定是否继续比对。整个链路里App 端不需要任何原生人脸 SDK。第三是人脸库能力开箱即用。注册、更新、删除、分组、搜索这些接口都是现成的你不需要自己维护特征向量也不需要自己写相似度计算底库管理直接用平台能力就行。当然代价也是有的。所有图片都要上传到云端处理意味着网络质量直接决定认证成功率也意味着你在做隐私合规说明时要把“人脸图像会传输到第三方服务”这件事写清楚。这一点后面第 5 章会展开。1.3 链路分层别把所有逻辑塞进 App这是我踩过坑之后最想强调的一条设计原则App 端只做采集和展示所有敏感调用都放服务端。原因很直接。调用百度人脸接口需要一个凭据无论你是用 AK/SK 换 Access Token还是用更复杂的签名方式这个凭据一旦被打包进 App就等于公开了。现在网上有大量自动化工具能直接从 apk 里把字符串扒出来你的额度会在几天内被刷干净。我见过最惨的一个案例AK/SK 硬编码在前端一周时间跑掉了几十万次调用。所以正确的分层是这样的App 端uniapp申请相机权限 → 打开取景框 → 引导用户正脸入框 → 拍照 → 压缩 → 把图片 base64 传给自己的服务端 → 展示结果。服务端持有 AK/SK → 换取并缓存 Access Token → 接收 App 传来的图片 → 调活体检测接口 → 调比对或搜索接口 → 返回结构化结果 → 记录流水日志。百度人脸服务完成活体判断、质量检查、特征提取与比对。这个分层还有一个隐性好处服务端可以做二次风控。比如同一个用户十分钟内请求了二十次认证服务端可以直接拦截App 端不用管这些逻辑。App 端越“薄”后续升级越轻松——你改服务端的阈值策略不需要重新发版。2. 动手前的准备账号开通、方案配置与工程配置2.1 百度智能云控制台要建什么正式写代码之前控制台里有几样东西必须先建好顺序错了会来回折腾。第一步是创建应用。在人脸识别或人脸实名认证产品下新建一个应用你会拿到三个关键值API Key简称 AK、Secret Key简称 SK以及应用名称。AK/SK 只在创建时完整展示一次记得当场保存到你的密码管理工具里别只截个图丢在相册。第二步是创建人脸库分组。人脸库接口里的group_id就是分组的标识。我的习惯是按业务线划分比如staff_verify、user_realname而不是所有业务共用一个默认分组。分开的好处是搜索范围小、响应快而且某个业务要清库的时候不会误伤别人。分组名一旦定下来就别轻易改改一次要迁移所有底库数据。第三步是配置认证方案如果你走 H5 活体方案的话。人脸实名认证类产品通常需要你新建一个方案在方案里勾选是否做活体、活体等级、是否需要证件信息核验、比对源是什么。方案配好之后会生成一个方案 ID服务端拿这个 ID 去换取前端可以打开的认证页地址。注意方案里的“活体等级”和后面接口里的liveness_control是两套不同的开关。前者影响 H5 页面的交互流程后者影响接口侧的判定强度。别把两者混为一谈也不要两边都拉到最高否则用户通过率会难看到你想哭。2.2 鉴权AK/SK、Access Token 与签名百度人脸接口的鉴权走的是标准的 OAuth 2.0 客户端模式流程分两步。第一步用 AK 和 SK 换 Access Token。请求方式是向鉴权地址发一个 POST带上grant_typeclient_credentials、client_id你的 AK、client_secret你的 SK。返回里会有access_token和expires_in后者的单位是秒通常是三十天。第二步把access_token拼在业务接口的 URL 查询参数上比如.../rest/2.0/face/v3/detect?access_tokenxxx请求体里放业务参数。这里有两个非常容易踩的点。第一Access Token 有获取频次限制。如果你每次用户请求都去换一次 Token很快就会触发限流接口开始报错。正确做法是在服务端做全局缓存拿到 Token 之后记下过期时间在过期前五到十分钟再刷新。整个服务实例共用一份用 Redis 存也行用进程内存 定时刷新也行只要别每个请求都换。第二Token 失效的报错要单独处理。常见的错误码是 110Token 无效和 111Token 过期。虽然我们做了缓存但线上总会出现各种意外——比如服务重启丢了内存缓存、比如多实例部署时某台机器的缓存时间算错了。所以业务接口的封装层里要写一个拦截逻辑一旦收到这两个错误码就强制刷新一次 Token 并重试一次原请求。这个重试必须是幂等的别再嵌套重试。至于更复杂的签名鉴权方式一般是给有特殊安全要求的场景准备的普通业务用 Access Token 就够了没必要给自己加复杂度。2.3 manifest.json 与权限声明uniapp 项目里manifest.json是打包配置的核心。人脸认证这块需要重点确认这几项。Android 侧在app-plus.distribute.android.permissions里加上相机和网络权限。以下是我常用的一个最小集合app-plus: { distribute: { android: { permissions: [ uses-permission android:name\android.permission.CAMERA\/, uses-permission android:name\android.permission.INTERNET\/, uses-permission android:name\android.permission.ACCESS_NETWORK_STATE\/, uses-permission android:name\android.permission.ACCESS_WIFI_STATE\/, uses-permission android:name\android.permission.WRITE_EXTERNAL_STORAGE\/, uses-permission android:name\android.permission.READ_EXTERNAL_STORAGE\/ ] } } }存储权限在 Android 10 之后其实已经弱化了很多机型不给也能通过缓存目录读写临时图片但为了兼容老设备我一般还是留着。如果你的 App 还用到麦克风比如视频活体引导语音记得把RECORD_AUDIO也加上漏掉的话表现就是“相机正常、一说话就崩”很难查。iOS 侧要在app-plus.distribute.ios.privacyDescription里补上用途说明ios: { privacyDescription: { NSCameraUsageDescription: 用于采集人脸照片完成身份认证, NSPhotoLibraryUsageDescription: 用于选择已有照片进行人脸认证 } }iOS 这块有个硬性规则凡是调用了相关权限 API就必须在 plist 里申明用途且文案要具体。写“需要相机权限”这种含糊描述审核阶段有被拒的风险改成“用于采集人脸照片完成身份认证”这种说明实际用途的表述更稳。提示manifest.json里的权限只是“声明”不代表用户会同意。Android 6.0 以后相机属于危险权限必须在运行时再次申请且用户可以选择拒绝。这一块在第 5 章详细讲。2.4 要不要上 uts 原生插件uniapp 从 3.x 开始支持 uts 插件可以直接用 TypeScript 语法调用原生能力打包时编译成原生代码。很多人一听到“人脸”两个字第一反应就是“是不是得写原生插件”。我的判断标准很简单看你走的是接口方案还是 SDK 方案。如果你走的是本文这套接口方案——App 端只拍照、传图片、等结果——那完全不需要 uts 插件用 uniapp 自带的相机组件加上uni.request就够了。这也是我推荐这条路线的主要原因接入成本低、发版灵活、不依赖原生编译环境。如果你要集成原生的离线人脸 SDK比如对联网有硬性限制的场景那就必须走 uts 插件或原生插件 离线打包的路子。这条路的工作量大概是接口方案的三到五倍你要写插件、调原生 API、处理生命周期、适配不同机型的前后置摄像头、还要在每次 uniapp 升级时验证插件兼容性。所以我的建议是能用接口方案就用接口方案除非业务明确要求离线。很多需求方说“要离线”其实只是担心网络不稳定这种情况下做好重试和超时兜底比上原生插件划算得多。3. 核心原理从一张照片到一条认证结论3.1 活体检测怎么判断镜头前是不是真人活体检测是这个链路里最“玄学”的一环也是最容易被误解的。很多同学以为活体检测是在判断“这张脸长得像不像人”其实不是它判断的是这张图像在采集过程中有没有携带“真实三维人脸”才有的物理特征。常见的攻击手段有三种一是打印照片拿一张纸质照片怼在镜头前二是屏幕翻拍用手机或平板播放一段人脸视频三是面具或三维头模。这三种攻击方式在成像上会留下不同痕迹。照片攻击的典型破绽是缺乏微动。真人的面部即使在静止状态也会有呼吸带来的细微起伏、眼球的微动、皮肤局部的微小形变。这些变化在连续多帧里会体现为有规律的微小差异而一张静止的照片无论怎么拍帧与帧之间的高频细节都是死的除非拍摄者手在抖但那种抖动是整体的、刚性的。屏幕翻拍的破绽主要在光学特性上。屏幕是自发光的它的亮度分布、色域表现、摩尔纹、以及屏幕玻璃表面的反射都和真实人脸在自然光或室内灯光下的漫反射不一样。算法会提取这些光影特征来判断。理解了原理你就能明白为什么活体检测对光照和环境这么敏感。逆光环境下人脸变成剪影皮肤细节全丢算法拿不到微动特征误判概率就会上升。同理在极暗环境里靠手机屏幕补光人脸被一块小小的屏幕照着反而更像“翻拍”。这也是为什么我在 App 端做交互设计时一定会加一句引导文案“请到光线充足的地方正脸对准取景框。”这句话不是客套它能实打实地把通过率往上抬十几个百分点。3.2 质量分被大多数人忽略的一道门槛接口调用里有个参数叫quality_control取值通常是 NONE、LOW、NORMAL、HIGH 四档。很多示例代码直接写 NORMAL然后就不管了。但这个参数背后的逻辑值得说一下。质量分评估的是这张照片“适合不适合做比对”。它综合考虑了这些因素人脸在画面中的占比是否足够大、是否在画面中央、是否模糊、光照是否均匀、是否被遮挡口罩、墨镜、刘海、姿态偏转角是否过大。质量分不达标的图片即使活体通过了比对结果也可能不可靠。所以正确的处理顺序是先卡质量再判活体最后做比对。三道关卡依次收紧能显著降低误识率。这里有个经验值可以参考。质量分门槛设得太低等于没卡设得太高用户反复重拍体验崩掉。我的做法是分场景设置场景quality_controlliveness_control说明注册/建档HIGHNORMAL底库照片质量必须高否则后面全是坑日常登录NORMALNORMAL平衡通过率和安全性高风险操作HIGHHIGH转账、改密等场景宁可多试几次建档那一步用 HIGH 是我的强烈建议。底库照片一旦质量不高后面每一次比对都会受影响而且你很难判断问题出在底库还是出在当次采集。花三秒钟让用户重拍一张清晰的比后面花三天排查误识强太多。3.3 1:1 比对与 1:N 搜索的区别与取舍这两个词经常被混用但它们完全是两回事。1:1 比对match是把两张图片放一起算一个相似度分数。它的输入是“本次采集的人脸”和“系统里已知的某张人脸”输出是一个 0 到 100 的分数。整个过程中你不需要人脸库只需要事先存好这个人的一张参考照片。适合登录、二次验证这类“我知道你是谁验证一下是不是本人”的场景。1:N 搜索search是把一张图片丢进人脸库里让系统告诉你库里最像的几个是谁。它的输入是“本次采集的人脸”加一个分组 ID 列表输出是若干候选用户及其分数。适合“我不知道你是谁你来认领身份”的场景比如门禁、考勤。1:N 的复杂度远高于 1:1。底库越大搜索耗时越长误识的概率也越高。假设单次比对的误识率是万分之一那么在一万人的底库里做搜索出现“认错人”的概率就上升到接近百分之几。这个数量级的变化在设计方案时必须考虑进去。如果两个场景都需要我的建议是分开做先让人输入手机号或工号定位到具体账号再做 1:1 比对只有在没有账号信息的前提下才走 1:N 搜索并且把分数阈值调高。3.4 阈值怎么定把误识率和通过率放在一起看这是整套方案里最需要“拍板”的一个参数也是最容易出问题的地方。比对接口返回的分数通常建议 80 分作为“同一人”的参考线但这只是一个非常粗略的经验值实际要看文档给出的各场景推荐区间。搜索接口支持传match_threshold参数用来过滤候选结果。阈值调高误识率下降但通过率也下降本人被拒的概率上升阈值调低通过率上升但风险也上升。这两个指标是一对矛盾没有“最优点”只有“平衡点”。我的做法是按业务风险等级分档低风险比如普通 App 内部打卡阈值取推荐区间的下限让本人尽量一次通过。中风险比如账号登录、信息查看取推荐区间的中位。高风险比如资金相关操作、关键资料修改取推荐区间上限甚至在上限基础上再抬几个点。同时一定要设计兜底路径。人脸认证永远会有一小部分真实用户无法通过——可能是光线太差、可能是面部有临时变化受伤、过敏、可能是手机摄像头故障。如果业务是“认证失败就无法使用”那这部分用户会直接流失。所以必须准备人工审核、短信验证、证件上传等备选通道。注意不要把阈值写死在前端。所有阈值都放在服务端配置里这样业务方想调整你改个配置就能生效不用重新打包发版。4. 实操从取流、压缩、调用到落库4.1 服务端Access Token 的获取与缓存先解决服务端的基础设施。下面这段是 Node.js 环境下的实现思路换成 Java、PHP、Python 都一样。const AK process.env.BAIDU_FACE_AK; const SK process.env.BAIDU_FACE_SK; let tokenCache { value: , expireAt: 0 }; let refreshing null; async function getAccessToken() { const now Date.now(); // 提前 10 分钟刷新避免边界时间失效 if (tokenCache.value tokenCache.expireAt - now 10 * 60 * 1000) { return tokenCache.value; } // 并发请求时只放一个请求出去其余的复用同一个 Promise if (refreshing) return refreshing; refreshing (async () { const url https://aip.baidubce.com/oauth/2.0/token ?grant_typeclient_credentialsclient_id${AK}client_secret${SK}; const resp await fetch(url, { method: POST }); const data await resp.json(); if (!data.access_token) { refreshing null; throw new Error(token 获取失败: JSON.stringify(data)); } tokenCache.value data.access_token; tokenCache.expireAt now data.expires_in * 1000; refreshing null; return tokenCache.value; })(); return refreshing; }这里有两个设计点值得说明。第一个是提前十分钟刷新。Token 的有效期是三十天但服务端和百度的时间可能有秒级偏差卡在过期瞬间刷新容易出问题提前一点更保险。第二个是并发去重。用refreshing变量把并发的刷新请求合并成一个避免服务重启后的瞬间流量把刷新接口打爆——这个坑我在一次大促前的压测里真实遇到过重启后三百个请求同时去换 Token结果触发了频次限制。AK/SK 一定要从环境变量或配置中心读取绝对不要写在代码里提交到仓库。这条不是洁癖是保命线。4.2 uniapp 端相机取景与拍照uniapp 提供了camera组件在 App 端vue 页面和小程序端都能用。下面是一个可用的取景页结构。template view classverify-page camera classcam device-positionfront flashoff resolutionhigh erroronCameraError cover-view classtip请正脸对准圆形区域保持光线充足/cover-view cover-view classoval/cover-view /camera view classfooter button classbtn typeprimary clickstartVerify 开始认证 /button text classsub认证过程约 3 秒请保持不动/text /view /view /template几个关键点。device-positionfront用前置摄像头人脸认证场景几乎都用前置。resolutionhigh建议开着虽然图片更大但清晰度直接影响质量分。cover-view是用来在相机上层绘制遮罩的内置组件注意它只支持有限的样式圆角、定位这些可以复杂的效果做不了。拍照逻辑methods: { startVerify() { const ctx uni.createCameraContext(); // 给用户一点时间摆正姿势 uni.showLoading({ title: 采集中 }); setTimeout(() { ctx.takePhoto({ quality: high, success: (res) { uni.hideLoading(); this.handlePhoto(res.tempImagePath); }, fail: (err) { uni.hideLoading(); console.error(拍照失败, err); uni.showToast({ title: 拍摄失败请重试, icon: none }); } }); }, 1200); }, onCameraError(e) { console.error(相机异常, e.detail); uni.showModal({ title: 无法使用相机, content: 请检查相机权限是否开启, showCancel: false }); } }那个 1200 毫秒的延时是有意为之。用户点下按钮的瞬间往往还在调整姿势直接拍大概率会拍到模糊或半张脸。给一个短暂缓冲通过率能明显提升。这个数值可以根据你的用户反馈调整一千到一千五之间是我试出来比较舒服的区间。如果camera组件在你的目标平台上表现异常比如某些 Android 定制系统上预览黑屏备选方案是用 5 API 的plus.camera.captureImage它调起的是系统相机兼容性更好但缺点是无法自定义取景框和引导层体验会差一些。我的做法是优先用 camera 组件在检测到异常机型时降级到系统相机。4.3 图片压缩与 base64 处理拍照拿到的临时图片动辄两三兆直接转 base64 上传体积会膨胀到四兆以上。百度人脸接口对图片有明确限制base64 编码后通常不能超过 2M而且上传时间越长弱网下的失败率越高。所以压缩是必须的一步。function compressImage(srcPath) { return new Promise((resolve) { // #ifdef APP-PLUS plus.zip.compressImage({ src: srcPath, dst: srcPath.replace(/(\.\w)$/, _c.jpg), quality: 70, // 压缩质量0-100 width: 720px, // 限制宽度高度按比例 overwrite: true, success: (e) resolve(e.target), fail: (e) { console.warn(压缩失败使用原图, e); resolve(srcPath); } }); // #endif // #ifdef H5 || MP-WEIXIN resolve(srcPath); // #endif }); }压缩参数的取值逻辑说一下。quality: 70是在清晰度和体积之间比较平衡的点我实测 1080P 的原图压到这个质量通常落在 200 到 400KB转成 base64 后大约 300 到 550KB安全落在接口限制以内。宽度限制到 720px 是因为人脸比对并不需要超高分辨率——接口文档要求的最短边通常在 150px 以上、建议 300px 以上720px 的宽度足够算法提取特征了再高就是浪费带宽。转 base64 的时候有一个必踩的坑uni.getFileSystemManager或者plus.io读出来的 base64很多情况下会带上data:image/jpeg;base64,这样的前缀。直接把这个字符串传给接口会得到一个“图片格式错误”的报错而且提示信息不会告诉你是因为前缀。一定要剥掉function readBase64(filePath) { return new Promise((resolve, reject) { plus.io.resolveLocalFileSystemURL(filePath, (entry) { entry.file((file) { const reader new plus.io.FileReader(); reader.onloadend (e) { // 关键剥掉 data URI 前缀 const raw e.target.result.split(,)[1] || e.target.result; resolve(raw); }; reader.onerror reject; reader.readAsDataURL(file); }, reject); }, reject); }); }另外提醒一点读文件和压缩都属于耗时操作最好放在setTimeout或独立的异步流程里别阻塞 UI。我在一个低端机上测过一张三兆的图片读 base64 加上压缩前后要接近一秒钟如果不给 loading 提示用户会以为按钮没反应然后疯狂点击。4.4 在线活体检测接口调用服务端拿到 base64 之后第一件事是调活体检测。这个接口的作用是判断图片里是不是真人同时给出人脸质量、位置等信息。async function faceVerify(base64Image) { const token await getAccessToken(); const url https://aip.baidubce.com/rest/2.0/face/v3/faceverify?access_token${token}; const body { image: base64Image, image_type: BASE64, face_field: quality,spoofing,age,gender,face_type, option: COMMON }; const resp await fetch(url, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body) }); return await resp.json(); }返回结构里最重要的是这几个字段。error_code为 0 表示调用成功非 0 就要走错误分支。result.face_liveness是活体分数通常会附带一个thresholds数组里面给出不同误识率档位对应的阈值。我的做法是不写死阈值而是从返回值里的阈值列表按业务等级挑一个低风险业务挑宽松档高风险业务挑严格档。这样即使平台调整了算法你的逻辑也不需要改。face_field里加quality是为了拿到质量分。如果你发现质量分很低直接返回让用户重拍比继续往下走更划算——因为质量差的图做比对结果不可信。提示如果业务量比较大建议在调用前先在服务端做一次粗略的图片校验base64 长度是否在合理区间、解码后的头部字节是不是 JPEG 或 PNG。这类本地能拦的检查放在前面可以省下不少无效调用。4.5 人脸注册与搜索比对活体通过之后就进入业务逻辑分支。场景一建档注册。把这次采集的人脸写进人脸库作为该用户的底库照片。async function addFaceToLibrary(base64Image, userId) { const token await getAccessToken(); const url https://aip.baidubce.com/rest/2.0/face/v3/faceset/user/add?access_token${token}; const body { image: base64Image, image_type: BASE64, group_id: user_realname, user_id: userId, user_info: 业务侧标识不要放敏感明文, quality_control: HIGH, liveness_control: NORMAL, action_type: REPLACE }; const resp await fetch(url, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body) }); return await resp.json(); }action_type有两个值APPEND是追加一个人可以有多张脸REPLACE是覆盖。实名认证场景我一般用REPLACE保证一个人只保留一张最新的底库照片避免多张照片之间互相干扰。user_id用你自己系统的用户主键别用手机号或证件号这种敏感信息因为它在返回日志里可能会被打印出来。场景二1:1 比对。用户已经登录把本次采集的人脸与他档案里的那张照片做比对。async function faceMatch(base64Image, registeredBase64) { const token await getAccessToken(); const url https://aip.baidubce.com/rest/2.0/face/v3/match?access_token${token}; const body [ { image: base64Image, image_type: BASE64, face_type: LIVE, quality_control: NORMAL, liveness_control: NORMAL }, { image: registeredBase64, image_type: BASE64, face_type: IDCARD, quality_control: NORMAL, liveness_control: NONE } ]; const resp await fetch(url, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(body) }); return await resp.json(); }注意这里face_type的设置。现场采集的图是活体拍出来的标LIVE底库里那张如果是证件照或档案照标IDCARD不确定就标COMMON。liveness_control只在第一张上设NORMAL第二张设NONE——因为底库照片本来就是从证件或档案里来的对它做活体检测没有意义反而可能因为它是翻拍件而判定失败。返回里的score就是相似度分数。业务侧拿这个分数跟你配置的阈值比较大于等于就判定通过。这里一定要把分数也记录下来不要只记“通过/不通过”。线上出问题时分数分布是排查的第一手材料如果一批用户的分数集中在 60 到 75 之间说明可能是阈值定高了也可能是采集环境出了问题。场景三1:N 搜索。没有账号信息时的兜底方案。const body { image: base64Image, image_type: BASE64, group_id_list: staff_verify, quality_control: NORMAL, liveness_control: NORMAL, match_threshold: 85, // 调高阈值降低误识 max_user_num: 3 // 最多返回 3 个候选 };搜索接口的地址是/rest/2.0/face/v3/search。返回的user_list是按分数降序排列的候选。match_threshold这个参数很重要它的作用是先把低于阈值的候选过滤掉。如果不传接口会用默认值通常比较宽松你会拿到一堆“其实不太像”的结果业务侧还得再筛一遍。直接传一个偏高的值让接口帮你筛省事又省流。4.6 前端交互与异常兜底接口跑通只是开始真正决定用户体验的是异常处理。人脸认证有个特点失败是常态不是例外。光线不对、姿势不对、镜头脏了、网络抖了任何一项都能让认证失败。所以前端要把“失败”设计成流程的一部分而不是意外。我把失败分成三类分别给不同的提示和动作第一类是可自助修复的。比如“未检测到人脸”“检测到多张人脸”“人脸模糊”“活体未通过”。这类提示要具体并且给一个明确的动作指引请正脸对准圆形区域、请确保画面中只有你一人、请到光线更亮的地方、请保持面部稳定。然后直接允许重试不要让用户返回上一页重新进入。第二类是需要切换方式的。比如连续三次活体失败、或者相机权限被拒绝。这时候要给出备选入口换个环境再试、使用其他验证方式、联系客服人工审核。第三类是系统级异常。比如接口返回未知错误、网络超时。这类不要暴露原始错误信息给用户统一提示“服务暂时不可用请稍后重试”同时在后台记录完整错误码和请求 ID方便排查。还有一个细节我很在意重试次数要有上限。我一般设三次。超过三次就锁定一段时间比如十分钟避免被自动化脚本反复试探。这个限制要放在服务端做前端只负责展示剩余次数。async function submitFace(base64) { try { const res await uni.request({ url: ${BASE_URL}/api/face/verify, method: POST, data: { image: base64 }, timeout: 15000, header: { content-type: application/json } }); const data res.data || {}; if (data.code 0) { return { ok: true, data: data.result }; } return { ok: false, code: data.code, msg: data.message }; } catch (e) { return { ok: false, code: NETWORK, msg: 网络异常请检查网络后重试 }; } }timeout: 15000这个值是我反复调整后定下来的。设得太短比如 5 秒弱网下大量请求被掐断设得太长比如 30 秒用户干等着更难受。15 秒是个折中的数字既给了上传和处理的余量又不至于让人失去耐心。5. 踩坑记录权限、机型与网络5.1 相机权限的申请、监听与兜底权限这块是最容易出线上事故的地方因为它在开发机上几乎不会出问题——开发时你早就同意了权限用户不会。Android 6.0 以后相机是危险权限需要运行时申请。uniapp 打包的 App 在启动相机组件时会自动触发系统权限弹窗但这个行为是隐式的你控制不了时机也拿不到明确的拒绝回调。所以我更倾向于在进入认证页之前主动申请一次function requestCameraPermission() { return new Promise((resolve) { // #ifdef APP-PLUS if (plus.os.name ! Android) { return resolve(true); // iOS 由系统在首次调用时弹窗 } plus.android.requestPermissions( [android.permission.CAMERA], (result) { const denied result.deniedAlways || []; const deniedPresent result.deniedPresent || []; if (denied.length 0) { // 用户勾选了“不再询问”必须去设置页手动开 uni.showModal({ title: 需要相机权限, content: 人脸认证需要访问相机请在系统设置中开启权限, confirmText: 去设置, success: (r) { if (r.confirm) { plus.runtime.openURL(app-settings:); // iOS // Android 可用下面的方式打开应用详情页 } } }); resolve(false); } else if (deniedPresent.length 0) { resolve(false); // 本次拒绝下次还能再弹 } else { resolve(true); } }, (err) { console.error(权限申请失败, err); resolve(false); } ); // #endif // #ifndef APP-PLUS resolve(true); // #endif }); }这里有个很多人问过的问题uniapp 能不能实时监听系统权限弹窗的出现和消失答案是App 端拿不到系统弹窗的生命周期事件。你能依赖的只有两个东西——requestPermissions的回调结果以及 App 的onShow生命周期。所以可行的做法是弹窗返回后如果结果是拒绝就把 App 切到后台再切回前台的行为当作“用户可能去过设置页了”在onShow里重新检查一次权限状态。这不是精确监听但能覆盖大部分用户行为路径。iOS 侧没有这么复杂系统会在首次调用相机时自动弹窗用户拒绝后后续调用会直接失败。所以 iOS 的处理重点是在相机报错回调里判断是否是权限问题如果是就引导用户去设置页。plus.runtime.openURL(app-settings:)在 iOS 上能打开本应用的设置页。Android 各家系统不一样通用的做法是打开应用详情页const main plus.android.runtimeMainActivity(); const Intent plus.android.importClass(android.content.Intent); const Uri plus.android.importClass(android.net.Uri); const intent new Intent(android.settings.APPLICATION_DETAILS_SETTINGS); intent.setData(Uri.fromParts(package, main.getPackageName(), null)); main.startActivity(intent);注意权限引导文案不要写得太生硬也不要一上来就引导去设置页。先在页面上用友好的方式说明“为什么需要相机”再弹系统权限用户同意的概率会高很多。我做过对比加了说明文案之后权限同意率大概能提升两成左右。5.2 机型和系统差异带来的兼容问题这是 uniapp 做原生能力时绕不开的一块人脸认证尤其明显因为它重度依赖相机。问题一部分机型的相机预览画面被拉伸或变形。这通常是因为camera组件的宽高比和摄像头传感器的输出比例不匹配。解决办法是给容器设一个固定的宽高比常见的是 3:4 或 9:16让 container 去适配而不是让摄像头去适配容器。如果还是不对就需要考虑降级到系统相机。问题二前置摄像头拍出来的图片是镜像的。前置摄像头默认输出镜像图像这本身不是问题人照镜子也是镜像的但如果你把这张镜像图和底库里的非镜像图做比对分数可能会略有下降。大部分算法对镜像是有一定容忍度的但如果你的通过率异常低可以检查一下是不是这个原因。部分平台支持通过接口参数控制是否镜像具体看文档。问题三低端机上camera组件打开慢。我测过几台千元机从进入页面到画面出现要两三秒。这段时间如果不给提示用户会以为页面卡住了。所以进入认证页后立刻显示一个“正在启动相机”的 loading画面 ready 之后再隐藏。uniapp 的camera组件没有直接的 ready 事件可以用一个短延时加首帧检测的方式近似实现。问题四刘海屏、挖孔屏的遮挡。取景框顶部容易被状态栏或刘海挡住导致用户的脸被切掉一部分。解决办法是取景区域整体下移或者用safe-area-inset-top这类安全区适配。这个细节不做质量分会莫名其妙地低。5.3 弱网下的超时与重试设计人脸认证涉及一次图片上传加两次接口调用活体 比对总耗时由网络决定。在弱网环境比如地下车库、电梯口、老小区下超时是家常便饭。我的处理策略分三层。第一层是压缩前置。前面说过把图片压到 300 到 500KB传输时间能砍掉一大半。这一步投入产出比最高。第二层是分段超时。不要给整个请求设一个笼统的超时而是给上传和处理分别设。上传设 10 秒服务端调用百度接口设 5 秒。这样如果上传本身就卡住了能立刻失败不用等到 15 秒。第三层是自动重试但要有条件。只对“网络类错误”自动重试一次且重试必须有幂等保护。我一般会在请求里带一个客户端生成的requestId服务端遇到相同requestId就直接返回上次的结果避免重复注册或重复扣费。async function submitWithRetry(base64, retry 1) { const requestId ${Date.now()}_${Math.random().toString(36).slice(2)}; const result await submitFace(base64, requestId); if (!result.ok result.code NETWORK retry 0) { await new Promise((r) setTimeout(r, 800)); return submitWithRetry(base64, retry - 1); } return result; }那个 800 毫秒的等待也是有讲究的。网络抖动通常是瞬时的立刻重试大概率还是失败等一下再试成功率更高。但也不能等太久用户会觉得卡死。6. 常见报错速查与几条压箱底的经验6.1 错误码速查表下面这张表是我这两年排查问题时攒下来的覆盖了绝大部分线上遇到的情况。具体含义请以平台最新文档为准这里给的是我实际遇到过的对应关系和处理方式。错误码含义常见原因处理方式110Access Token 无效缓存失效、手动改过配置强制刷新 Token 后重试一次111Access Token 过期缓存过期时间计算错误同上并检查缓存逻辑100参数错误参数名拼错、类型不对对照文档逐项核对请求体216101缺少必要参数body 字段漏传检查 image、image_type 等必填项216201图片格式错误base64 带了 data URI 前缀剥掉data:image/...;base64,216202图片体积超限未压缩或压缩不够压到 500KB 以内再传216630识别错误图片内容异常记录日志让用户重拍216631未检测到人脸人脸不在框内、光线太暗提示对准取景框、改善光照216632检测到多张人脸背景有人、海报上有人脸提示确保画面中只有一人216634人脸质量不达标模糊、遮挡、角度偏提示保持稳定并正对镜头222207未找到匹配的用户底库中无该用户转入建档流程216401用户已存在重复调用注册接口改用 REPLACE 或 update 接口223120活体检测未通过疑似照片或翻拍提示真人操作并限制重试次数17 / 18请求量限流并发过高或未做缓存加缓存、加队列、必要时提额216500未知错误服务端异常记录完整返回体上报排查排查经验上我建议在服务端把完整的原始返回值落一份日志包括请求参数图片只记长度和哈希不要记内容、返回码、错误信息、耗时。线上出问题时光看一个错误码是不够的你需要看到完整的上下文。但同时要注意日志里绝对不能明文存人脸图片和敏感信息只记哈希值就够了。6.2 文档里不会写的经验经验一底库照片的质量决定了整个系统的天花板。这句话我反复强调。很多项目初期为了快速上线建档时用quality_control: NORMAL甚至LOW结果后面每次比对都要跟一张糊图比通过率怎么调都上不去。建档这一步就该用 HIGH宁可让用户多拍两次也别把垃圾照片存进底库。经验二活体检测要跟重试策略绑定。活体失败如果允许无限重试攻击者可以不停地试探总有蒙对的时候。我在服务端做的策略是同一用户同一设备十分钟内活体失败超过五次就锁定需要走人工通道解封。这个逻辑放在服务端前端拦不住绕过。经验三单帧活体不如多帧。接口方案默认是单张图片做活体判断安全性天然弱于视频活体。如果你的业务风险较高比如涉及资金建议走 H5 视频活体方案让用户在页面上做一个随机动作眨眼、转头连续采样多帧防翻拍能力强得多。代价是接入多一个 H5 页面用web-view承载交互体验会稍微割裂一些。经验四用 web-view 承载 H5 认证页时返回逻辑要特别处理。这是我最近才踩的坑。web-view页面的返回行为和普通页面不一样用户在认证页按物理返回键可能直接退出了整个流程也可能什么都没发生。稳妥的做法是在web-view外层包一个自己的页面监听onBackPress在返回前先跟 H5 页面通信确认当前状态如果认证正在进行中就提示“认证尚未完成确认退出吗”如果已经完成就直接跳转到结果页。这部分需要 H5 页面配合抛出状态消息双方约定好消息格式。经验五把“失败原因”翻译成人话。接口返回的 216634用户看不懂客服也看不懂。所以我在服务端做了一层映射把错误码转成用户能理解的中文提示同时给客服侧输出一份更详细的说明文档。这个映射表是要持续维护的每次遇到新错误码就补一条。看起来是小事但能省掉大量客服工单。经验六留一个“降级开关”。再稳定的第三方服务也可能出现波动。我在配置里留了一个开关一旦人脸服务整体不可用就自动切换到备选验证方式短信验证码或人工审核保证业务不中断。这个开关平时是关着的但真出事的时候能救命。经验七定期核对调用量和账单。按调用次数计费的接口最怕的是被刷。建议做一个简单的告警当日调用量超过往日均值的三倍就发通知。前面提到的 AK/SK 泄露问题如果有这个告警损失能控制在很小范围内。我个人在实际项目里最大的体会是人脸认证这件事技术难度不高工程难度不低。接口文档看半天就能调通但要让它在几百种机型、各种网络环境、各种用户行为下都稳定工作需要的是持续打磨权限引导加一句文案、压缩参数调一档、重试逻辑加一个幂等键、错误提示换一个说法。这些细节堆起来才是决定这套认证功能好不好用的东西。另外一个建议是从第一天就把日志和指标埋好。每次认证记录设备型号、系统版本、采集耗时、接口耗时、活体分、质量分、比对分、最终结果。等你的通过率从 70% 提到 90% 的时候你会发现这些数据比任何经验判断都可靠——你能清楚地看到是哪一类机型拖了后腿是哪一档分数区间的用户被误拒了。这套数据体系建起来之后后续任何调整都是有据可依的而不是拍脑袋改阈值。