前一阵接了个需求,要在uniapp的H5端做“人脸识别认证 + 活体检测”,还要兼容微信公众号里的网页。App端做这种功能不难,但H5端一涉及到摄像头调用、模型加载、微信授权签名,坑一下就变多了。我把这次完整实现整理出来了:一条纯前端免费路线,用face-api.js做检测和活体判断;一条微信SDK人脸核身路线,后端签名给Spring Boot和ThinkPHP两版,方便你直接抄。
我猜你已经搜了不少资料,大部分教程要么只讲原理、不给可跑代码,要么只贴前端片段、后端签名逻辑一笔带过。这篇文章会把两条路线的整体设计、核心实现、常见坑一次性讲清楚,适合正在做uniapp H5项目、需要快速接入人脸核身能力的同学。
1. 项目整体设计与技术选型
1.1 需求核心点拆解
先别急着写代码,把这个需求拆开看。
标题里最核心的三个词是“uniapp”“H5”“人脸识别认证与活体检测”。实际落到业务上,它涉及这几件事:
- H5页面需要调用手机摄像头,获取实时视频帧,用于人脸检测。
- 需要有一套活体检测机制,防止用户拿张照片或一段录好的视频来冒充真人。
- 业务场景如果是在微信公众号里打开的H5,还需要考虑微信官方的人脸核身通道。
- 后端需要配合完成签名、票据获取,甚至最终的人脸比对结果查询。
我在做之前先问了自己一个问题:这个需求到底是“防机器人”还是“防冒充”?
如果是签到打卡、用户画像完善、会员实名登记这类轻业务,只需要确认“屏幕前是个活人”,纯前端方案足够。但如果是贷款、支付、身份绑定这种强实名场景,就必须走微信官方的人脸核身,因为纯前端方案无法真正做到金融级的人脸比对和证件一致性校验。
想清楚这一点,技术选型就顺理成章了。
1.2 两条技术路线的选型对比
我这次实际做了两套,方便不同项目复用。先给你一张对比表,心里有个底:
| 维度 | 纯前端免费方案 | 微信SDK人脸核身 |
|---|---|---|
| 成本 | 0,模型开源免费 | 需认证服务号,接口权限申请审核,费用视服务而定 |
| 前后端工作量 | 前端为主,基本不需要后端 | 前后端都要改,后端负责票据和签名 |
| 安全等级 | 基础活体,能防照片和简单视频 | 金融级,由微信完成人脸比对和活体检测 |
| 适用场景 | 签到、活动、CRM客户登记 | 支付、绑定、实名认证、贷款等强合规场景 |
| 微信内置浏览器兼容性 | 差,很多机型拿不到摄像头 | 好,官方通道 |
| 接入周期 | 快,1-2天能跑通 | 慢,需申请权限+前后端开发,至少一周 |
选型逻辑很简单:如果明确要做“微信里的H5”,而且业务敏感,直接上微信SDK;如果只是普通浏览器H5,或者内部工具、活动页,用纯前端免费方案性价比最高。
1.3 为什么用uniapp而不是单独写H5
这个问题其实不用纠结。uniapp对H5的支持已经比较成熟,同一套代码以后还能编译成小程序和App,条件编译可以精确控制不同端的行为。比如人脸检测的模型加载逻辑只写在H5端,后面如果发布小程序,可以直接走wx.startFacialRecognitionVerify原生接口,不需要重写业务代码。
但要注意一点:uniapp的H5本质还是Vue/HTML页面,涉及摄像头、WebAssembly、SDK调用时,不能指望它有App端的原生能力,还是得按浏览器的规则来写。这也是为什么很多人在uniapp里做人脸识别时卡住——他们想用uni的API去做,但实际上H5端需要直接操作DOM和浏览器的navigator对象。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 纯前端免费方案:基于face-api.js的完整实现
2.1 免费方案的技术底座
纯前端方案我选的是face-api.js。它基于TensorFlow.js,能把人脸检测、人脸关键点识别、表情识别全部跑在浏览器端,不需要后端参与,摄像头画面也不会离开用户设备,隐私上相对友好。
它有几个关键模型:
- tinyFaceDetector:轻量人脸检测器,速度快,适合移动端。
- faceLandmark68Net:68点人脸关键点模型,用于定位眼睛、嘴巴、鼻子等部位。
- faceExpressionNet:表情识别,可以用来辅助判断是否在做动作。
我实际用下来,tinyFaceDetector + faceLandmark68Net就够活体检测用了。表情识别模型在移动端稍微有点重,不是必须的。
模型文件需要部署到可访问的静态目录。你可以从face-api.js的GitHub仓库下载模型权重,然后放到uniapp项目的static/models/下。模型加载路径用loadFromUri指定,路径错误的话会直接静默失败,这是新手最容易踩的坑。
2.2 uniapp H5端如何集成
在uniapp里安装face-api.js:
bash复制npm install face-api.js
然后封装一个检测工具类,只在H5端执行,用条件编译隔离:
js复制// utils/faceDetect.js
// #ifdef H5
import * as faceapi from 'face-api.js'
// #endif
class FaceDetect {
constructor() {
this.isModelLoaded = false
}
async loadModels() {
// #ifdef H5
if (this.isModelLoaded) return true
const MODEL_URL = '/static/models'
await faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL)
await faceapi.nets.faceLandmark68Net.loadFromUri(MODEL_URL)
this.isModelLoaded = true
return true
// #endif
return false
}
async detectLandmarks(videoEl) {
// #ifdef H5
const options = new faceapi.TinyFaceDetectorOptions({
inputSize: 320,
scoreThreshold: 0.5
})
const result = await faceapi
.detectSingleFace(videoEl, options)
.withFaceLandmarks()
return result
// #endif
return null
}
}
export default new FaceDetect()
摄像头采集这块,uniapp的camera组件在H5端不是最优选择,我直接用了浏览器的navigator.mediaDevices.getUserMedia,然后绑定到原生video元素上。页面里用video标签,设置autoplay和muted,否则iOS上会出现画面卡在第一帧的问题。
js复制async function startCamera() {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: 640, height: 480 },
audio: false
})
videoEl.srcObject = stream
await videoEl.play()
}
这里有个容易被忽略的点:getUserMedia只有在安全上下文里才能调用,也就是说页面必须是HTTPS协议,或者访问的是localhost。我用本地IP调试时经常遇到这个限制,差点以为代码写错了。
2.3 活体检测判定原理
活体检测的核心是“让用户做一个指定的动作,检测动作是否真实发生”。我实现的是眨眼和张嘴两种动作,综合判断是不是活人。
关键点是人脸68个关键点中眼睛和嘴巴的位置。业界常用的算法是计算“眼睛纵横比”(EAR,Eye Aspect Ratio),通过眼睑关键点的距离变化判断眼睛闭合程度。
眼睛纵横比的计算公式是:
code复制EAR = (||p2 - p6|| + ||p3 - p5||) / (2 * ||p1 - p4||)
其中p1到p6是单只眼睛周围6个关键点。眼睛睁开时EAR约在0.25到0.35之间,闭合时会跌到0.15以下。嘴巴检测类似,计算上下嘴唇关键点的距离,张嘴时距离明显增大。
我把活体检测封装成一个独立的函数,不断采集视频帧并判断状态:
js复制// 追踪眼睛闭合状态和嘴巴张开状态
const EYE_CLOSE_THRESHOLD = 0.18
const MOUTH_OPEN_THRESHOLD = 0.35
function calcEAR(eye) {
const p1 = eye[0], p2 = eye[1], p3 = eye[2]
const p4 = eye[3], p5 = eye[4], p6 = eye[5]
const verticalA = Math.hypot(p2.x - p6.x, p2.y - p6.y)
const verticalB = Math.hypot(p3.x - p5.x, p3.y - p5.y)
const horizontal = Math.hypot(p1.x - p4.x, p1.y - p4.y)
return (verticalA + verticalB) / (2 * horizontal)
}
流程设计成随机指令模式:先要求用户“请眨眼”,检测到一次眼睛EAR从高到低再恢复,就认为眨眼动作完成;然后要求“请张嘴”,检测到嘴巴距离参数超过阈值,就认为张嘴动作完成。两步都通过,活体检测就算过了。
这个方案对付照片和屏幕录制视频是有效的:照片的脸永远不变,不会有EAR波动;屏幕上的录制视频因为帧率和不稳定因素,很难精确复制出眨眼和张嘴的动态时序,而且视频画面在摄像头里会产生摩尔纹和反光,肉眼都能看出来。
但它对3D面具或者AI换脸视频确实防不住,所以用到强实名场景时要换微信SDK方案。
2.4 核心页面完整示例
给你一个完整的Vue页面,可以直接复制过去跑:
vue复制<template>
<view class="face-page">
<video
id="localVideo"
autoplay
muted
playsinline
class="video-box"
></video>
<canvas id="overlayCanvas" class="overlay-box"></canvas>
<view class="tip-text">{{ tipText }}</view>
<button
v-if="!detecting"
class="start-btn"
@click="startDetect"
>
开始认证
</button>
<button
v-else
class="stop-btn"
@click="stopDetect"
>
结束认证
</button>
</view>
</template>
<script>
import faceDetect from '@/utils/faceDetect.js'
export default {
data() {
return {
tipText: '点击开始认证',
detecting: false,
blinkCount: 0,
mouthChecked: false
}
},
onReady() {
// #ifdef H5
this.videoEl = document.getElementById('localVideo')
this.canvasEl = document.getElementById('overlayCanvas')
// #endif
},
onUnload() {
this.stopDetect()
},
methods: {
async startDetect() {
// #ifdef H5
try {
this.tipText = '正在加载模型...'
await faceDetect.loadModels()
this.tipText = '正在打开摄像头...'
await this.initCamera()
this.detecting = true
this.bindTask = setInterval(() => {
this.runDetect()
}, 120)
} catch (err) {
this.tipText = '初始化失败,请检查浏览器权限'
console.error(err)
}
// #endif
},
async initCamera() {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: 640, height: 480 },
audio: false
})
this.videoEl.srcObject = stream
await this.videoEl.play()
},
async runDetect() {
const result = await faceDetect.detectLandmarks(this.videoEl)
if (!result) {
this.tipText = '未检测到人脸,请正对摄像头'
return
}
const landmarks = result.landmarks
this.drawOverlay(result)
const leftEye = landmarks.getLeftEye()
const rightEye = landmarks.getRightEye()
const mouth = landmarks.getMouth()
const ear = (calcEAR(leftEye) + calcEAR(rightEye)) / 2
const mouthOpen = calcMouthOpen(mouth)
if (!this.mouthChecked) {
if (ear < 0.18) {
this.blinkStart = true
}
if (this.blinkStart && ear > 0.25) {
this.blinkCount++
this.blinkStart = false
if (this.blinkCount >= 2) {
this.tipText = '眨眼通过,请张一下嘴'
this.mouthChecking = true
} else {
this.tipText = `请眨眼睛 (${this.blinkCount}/2)`
}
}
} else if (this.mouthChecking) {
if (mouthOpen && mouthOpen > 0.35) {
this.tipText = '张嘴通过,认证成功'
this.stopDetect()
}
}
},
drawOverlay(result) {
const box = result.detection.box
const canvas = this.canvasEl
const ctx = canvas.getContext('2d')
// 清空上次绘制
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 调整canvas尺寸保持与video一致
canvas.width = this.videoEl.videoWidth
canvas.height = this.videoEl.videoHeight
// 绘制检测框,框的颜色可以根据状态变化
ctx.strokeStyle = '#07c160'
ctx.lineWidth = 4
ctx.strokeRect(box.x, box.y, box.width, box.height)
},
stopDetect() {
if (this.bindTask) {
clearInterval(this.bindTask)
this.bindTask = null
}
// 释放摄像头
const stream = this.videoEl && this.videoEl.srcObject
if (stream) {
stream.getTracks().forEach((track) => track.stop())
}
this.videoEl && (this.videoEl.srcObject = null)
this.detecting = false
}
}
}
</script>
<style scoped>
.video-box {
width: 100%;
height: 480px;
object-fit: cover;
background: #000;
}
.overlay-box {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 480px;
pointer-events: none;
}
.tip-text {
text-align: center;
margin: 20px 0;
font-size: 28rpx;
color: #333;
}
</style>
注意:video的object-fit: cover会让画面裁剪,如果canvas的坐标和video的显示尺寸对不上,检测框会偏移。我建议把video固定宽高,canvas用同样的CSS尺寸,并且让videoWidth/videoHeight和CSS像素保持一致,最简单的方式是把video控件的宽高写死,或者用object-fit: fill,虽然会拉伸画面,但调试方便。
2.5 这套方案的性能和兼容性实测
我在几台设备上实测过:
- iOS Safari:整体流畅,EAR检测稳定,但需要保证
playsinline属性存在,否则iOS会强制全屏播放视频,导致检测中断。 - Android Chrome:大部分机型没问题,但部分低端机在加载TensorFlow.js模型时耗时较长,需要给用户一个明显的loading提示。模型加载一般需要1-3秒,视网络情况而定,最好是页面加载时就预先初始化模型,而不是等用户点击了才开始。
- 微信内置浏览器:兼容性很差,部分安卓手机能拿到摄像头,但iOS微信里基本拿不到,或经常黑屏。如果你的主场景是微信公众号网页,我不推荐纯前端方案。
如果你一定要在微信内置浏览器里跑纯前端方案,唯一能做的优化是提示用户使用右上角菜单里的“在浏览器打开”,但这非常影响体验。所以除非业务场景特殊,否则微信公众号内还是直接用微信SDK方案更靠谱。
3. 微信SDK人脸核身方案实现
3.1 微信人脸核身的整体流程与开通条件
微信官方的人脸核身能力,最终是调JS-SDK里的wx.startFacialRecognitionVerify。它在用户确认后,会拉起微信原生的人脸识别界面,由微众银行或微信侧完成活体检测和人脸比对,安全性非常高。
整个流程是这样的:
- 用户打开公众号网页,页面加载JS-SDK。
- 前端向后端发起请求,获取当前页面的签名信息(appId、timestamp、nonceStr、signature)。
- 前端用签名信息调用
wx.config完成JS-SDK鉴权。 wx.ready回调里,先调wx.checkIsSupportFacialRecognition检查当前环境是否支持人脸核身。- 调
wx.startFacialRecognitionVerify拉起核身界面。 - 核身结果通过
success回调返回,业务后端再根据verifyResult调用查询接口确认结果。
开通条件上,微信公众号必须是已认证的服务号,而且“人脸识别”接口权限需要单独申请。微信官方对开通行业有审核,金融、政务、电商、出行这类更容易过,纯社交或灰色行业基本没戏。申请入口在公众号后台的“接口权限”里,找不到的话可以找微信支付服务商或腾讯云人工客服确认。
3.2 uniapp H5端接入微信JS-SDK
uniapp H5端接入微信JS-SDK,有两种方式。一是直接在index.html里引入官方脚本:
html复制<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
二是在npm项目里用包:
bash复制npm install jweixin-module
js复制import wx from 'jweixin-module'
两种都可以,我习惯用npm包的方式,这样不用在html里挂全局变量。下面给出前端核心代码:
js复制// #ifdef H5
import wx from 'jweixin-module'
// #endif
export function initWechatSdk(config) {
return new Promise((resolve, reject) => {
wx.config({
debug: false,
appId: config.appId,
timestamp: config.timestamp,
nonceStr: config.nonceStr,
signature: config.signature,
jsApiList: [
'checkIsSupportFacialRecognition',
'startFacialRecognitionVerify'
]
})
wx.ready(() => {
resolve()
})
wx.error((err) => {
reject(err)
})
})
}
export function startFaceVerify(name, idCardNumber) {
return new Promise((resolve, reject) => {
wx.checkIsSupportFacialRecognition({
success: () => {
wx.startFacialRecognitionVerify({
name: name || '',
idCardNumber: idCardNumber || '',
success: (res) => {
// res.verifyResult 需要交给后端再次核实
resolve(res)
},
fail: (err) => {
reject(err)
}
})
},
fail: (err) => {
reject(err)
}
})
})
}
这里的name和idCardNumber是选填参数。如果传了,用户在核身界面可以少填一次身份证信息;如果不传,用户也能在拉起的人脸核身页面手动输入。我的建议是后端能拿到实名信息就传,不能就拿不到,保持流程简洁。
有一点特别提醒:wx.startFacialRecognitionVerify 的签名配置用的 ticket 不是普通的 jsapi_ticket,而是人脸核身专用票据,类型是h5_face_verify。后端如果拿错了,前端wx.config能成功,但调用核身接口时大概率提示签名错误。下面会讲后端怎么处理。
3.3 Spring Boot后端签名实现
后端这块,最关键的是三件事:获取access_token、获取h5_face_verify票据、生成前端需要的SHA1签名。
先封装一个获取access_token和票据的方法:
java复制@Service
public class WechatFaceVerifyService {
@Value("${wechat.appId}")
private String appId;
@Value("${wechat.appSecret}")
private String appSecret;
private String accessToken;
private String faceVerifyTicket;
private long tokenExpireTime = 0;
// 获取access_token,做本地缓存,避免每次请求微信接口
public String getAccessToken() throws Exception {
if (System.currentTimeMillis() < tokenExpireTime && accessToken != null) {
return accessToken;
}
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid="
+ appId + "&secret=" + appSecret;
String resp = HttpClientUtil.get(url);
JSONObject json = JSON.parseObject(resp);
accessToken = json.getString("access_token");
// 提前200秒过期,防止边界情况
tokenExpireTime = System.currentTimeMillis() + (json.getIntValue("expires_in") - 200) * 1000;
return accessToken;
}
// 获取H5人脸核身专用票据
public String getFaceVerifyTicket() throws Exception {
if (System.currentTimeMillis() < tokenExpireTime && faceVerifyTicket != null) {
return faceVerifyTicket;
}
String token = getAccessToken();
String url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token="
+ token + "&type=h5_face_verify";
String resp = HttpClientUtil.get(url);
JSONObject json = JSON.parseObject(resp);
if (!"0".equals(json.getString("errcode"))) {
throw new RuntimeException("获取ticket失败:" + json.toJSONString());
}
faceVerifyTicket = json.getString("ticket");
return faceVerifyTicket;
}
// 生成前端config需要的签名
public Map<String, String> getFaceVerifyConfig(String url) throws Exception {
String ticket = getFaceVerifyTicket();
String nonceStr = UUID.randomUUID().toString().replace("-", "");
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
String rawString = "jsapi_ticket=" + ticket
+ "&noncestr=" + nonceStr
+ "×tamp=" + timestamp
+ "&url=" + url;
String signature = Sha1Util.encode(rawString);
Map<String, String> config = new HashMap<>();
config.put("appId", appId);
config.put("timestamp", timestamp);
config.put("nonceStr", nonceStr);
config.put("signature", signature);
return config;
}
}
需要给前端吐一个接口:
java复制@RestController
@RequestMapping("/api/wechat")
public class WechatFaceVerifyController {
@Autowired
private WechatFaceVerifyService faceVerifyService;
@GetMapping("/face-verify-config")
public Result getFaceVerifyConfig(@RequestParam String url) {
try {
Map<String, String> config = faceVerifyService.getFaceVerifyConfig(url);
return Result.success(config);
} catch (Exception e) {
return Result.error(e.getMessage());
}
}
}
注意接口里的url参数必须由前端把“当前页面的完整URL(不包含#及其后面的部分)”传过来,签名校验是严格匹配的。前端在调用这个接口时应该使用location.href.split('#')[0],不要把hash带进去。
SHA1工具类很简单,直接用Java内置的MessageDigest就能实现,我就不贴完整代码了,核心方法:
java复制public static String encode(String str) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] bytes = digest.digest(str.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
3.4 ThinkPHP后端签名实现
如果你们后端是PHP,我最常用的是ThinkPHP框架。逻辑和Java版完全一样,核心代码:
php复制<?php
namespace app\api\controller;
use think\Controller;
use think\facade\Cache;
class WechatFaceVerify extends Controller
{
protected $appId = '你的appId';
protected $appSecret = '你的appSecret';
// 接口入口
public function getFaceVerifyConfig()
{
$url = input('get.url');
$ticket = $this->getFaceVerifyTicket();
$nonceStr = $this->createNonceStr();
$timestamp = time();
$string1 = "jsapi_ticket={$ticket}&noncestr={$nonceStr}×tamp={$timestamp}&url={$url}";
$signature = sha1($string1);
return json([
'code' => 0,
'data' => [
'appId' => $this->appId,
'timestamp' => $timestamp,
'nonceStr' => $nonceStr,
'signature' => $signature
]
]);
}
protected function getAccessToken()
{
// 用ThinkPHP缓存,避免重复请求微信接口
if (Cache::has('wechat_access_token')) {
return Cache::get('wechat_access_token');
}
$url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={$this->appId}&secret={$this->appSecret}";
$resp = $this->httpGet($url);
$data = json_decode($resp, true);
if (isset($data['access_token'])) {
Cache::set('wechat_access_token', $data['access_token'], $data['expires_in'] - 200);
return $data['access_token'];
}
throw new \Exception('获取access_token失败');
}
protected function getFaceVerifyTicket()
{
if (Cache::has('h5_face_verify_ticket')) {
return Cache::get('h5_face_verify_ticket');
}
$accessToken = $this->getAccessToken();
$url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token={$accessToken}&type=h5_face_verify";
$resp = $this->httpGet($url);
$data = json_decode($resp, true);
if (isset($data['ticket'])) {
Cache::set('h5_face_verify_ticket', $data['ticket'], $data['expires_in'] - 200);
return $data['ticket'];
}
throw new \Exception('获取h5_face_verify票据失败');
}
protected function createNonceStr($length = 16)
{
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$str = '';
for ($i = 0; $i < $length; $i++) {
$str .= $chars[mt_rand(0, strlen($chars) - 1)];
}
return $str;
}
protected function httpGet($url)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$res = curl_exec($ch);
curl_close($ch);
return $res;
}
}
PHP版的注意点和Java版一样:access_token和ticket都必须做缓存,因为微信接口有调用次数限制。ticket的有效期也是7200秒,但缓存时间建议设置成7000秒或直接减去200秒,避免在过期边缘被微信拒绝。
前端调用这个接口时,还是要强调url参数的准确性。后来我把这句代码写进文档里,大家遇到签名报错的第一反应就是检查它:
js复制const currentUrl = location.href.split('#')[0]
3.5 前端如何侧配合后端完成整个认证闭环
很多时候前端把wx.startFacialRecognitionVerify调起来、看到用户核身成功就以为完事了,其实业务还没结束。微信SDK返回的verifyResult是一段字符串,前端无法确认内容是否真实有效,必须把verifyResult交给后端,由后端调微信的核身结果查询接口做二次验证。
我实现时会在success回调里把结果post给后端:
js复制success: async (res) => {
// res.verifyResult 是核身结果的唯一标识
const checkResp = await uni.request({
url: '/api/wechat/check-face-result',
method: 'POST',
data: {
verifyResult: res.verifyResult,
userId: getApp().globalData.userId
}
})
if (checkResp.data.code === 0) {
uni.showToast({ title: '认证成功' })
} else {
uni.showToast({ title: '认证结果校验失败', icon: 'none' })
}
}
后端拿到verifyResult后,根据微信官方文档调用对应的查询接口,确认用户是否真的通过了核身。这个环节不能省,不然别人可以伪造一段假的verifyResult直接绕过认证。虽然少见,但安全逻辑不能留后门。
4. 常见问题与排查技巧实录
4.1 纯前端方案常见问题
getUserMedia黑屏或报错NotAllowedError
最常见的原因是页面不是HTTPS,或者浏览器权限被禁。排查时先看控制台报错,如果是NotAllowedError,去浏览器设置里把摄像头权限打开。另外,在uniapp的H5页面里,如果video是异步插入DOM的,可能导致浏览器认为没有用户手势,检测不到权限弹窗。解决方法是在用户点击按钮后,先同步调用一次getUserMedia,再后置检测逻辑。
iOS微信内置浏览器黑屏
这个前面也提到过,iOS微信里WebView对getUserMedia的支持不完整,经常返回MediaStream但画面是黑的。我踩了几次坑之后,直接在代码里做了UA判断,如果是微信内置浏览器且不是Android X5,就提示用户使用系统浏览器打开,或者直接切到微信SDK人脸核身方案。与其花时间调一个不可靠的环境,不如让用户走路子最快的通道。
模型加载慢或加载失败
模型文件如果放在static目录,发布后路径是/static/models,但要注意服务器是否配置了正确的MIME类型。.json和.bin文件有时候会被Nginx当成普通静态文件直接下载,导致浏览器解析失败。还要确认路径下所有模型文件都上传完整,缺一个都会初始化失败。
检测框偏移
主要是video的CSS尺寸和canvas的绘制尺寸不一致。我建议调试时在控制台打印video.videoWidth和video.clientWidth,如果两者差距过大,要么缩放到canvas,要么用CSS让video填满容器,再把canvas的显示区域设置成完全覆盖video。标准做法是用canvas画布作为背景,video隐藏或半透明,这样检测框和人脸画面可以完美重叠。
4.2 微信SDK方案常见问题
wx.config报错,errMsg是invalid signature
这个错误90%是URL问题。前端传给后端的URL必须和用户当前访问的URL完全一致,包括协议(http/https)、域名、端口、路径,但不包括#后面的hash。公众号里出现的URL可能带有from=singlemessage之类的参数,这些参数也必须原样传给后端。我在后端处理时还会做一遍URL decode,双保险。
wx.config成功了,但checkIsSupportFacialRecognition报不支持
先确认当前是不是在微信内置浏览器里。普通浏览器打开页面时,wx对象本身可能都拿不到。微信环境下如果checkIsSupportFacialRecognition返回fail,多半是AppID没开通人脸核身权限,或JS安全域名配置不对。这类接口通常不需要在公众号后台单独配置JS域名,但如果配置了错误的域名,签名会用错,也会导致不支持。
startFacialRecognitionVerify拉起页面后用户一直转圈
转圈一般是ticket失效或者name、idCardNumber传参格式不对。身份证号码如果有空格或中文空格,SDK底层解析会失败。前端在传参前先做trim,后端也要做一遍清洗。
核身在部分手机上没有声音提示
这个问题不确定,感觉是微信SDK版本问题。建议前端接入时直接使用官方最新的jweixin版本,不要用旧版。在index.html中引入的脚本要加v号参数避免缓存,例如:
html复制<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js?v=20240101"></script>
4.3 通用性能与体验优化
人脸识别这种交互对用户的心理预期是“秒过”,所以性能体验要重点打磨。
我建议在页面加载完成后就预加载模型(纯前端方案),不要等用户点击“开始认证”再加载。模型初始化完成后可以先唤醒摄像头,用一个半透明的取景框让用户调整位置,整个过程尽量不出现“加载中...”这种空状态。
失败重试也要设计好。纯前端方案里,检测不到人脸时提示“请正对摄像头”,检测到但动作失败时提示“请重新眨眼”。重试次数最好限制在3次以内,超过3次强制走人工审核或降级到短信验证,避免用户反复尝试造成流失。
还有一点是关于数据安全的。人脸信息属于敏感个人信息,不管用哪套方案,我建议后端都不要保存原始视频帧或用户自拍照。微信SDK方案里的verifyResult也不是长期有效的,后端只需要在核身成功后立即验证一次,然后只保存“已核验”这个结果和核验时间,不保存人脸底图。前端页面里也要有用户授权弹窗,明确告知“本次操作将采集人脸信息用于身份核验”,否则合规上容易出问题。
5. 上线部署与合规提醒
5.1 H5打包部署与公众号配置
uniapp H5端执行npm run build:h5,产物在dist/build/h5。把这个目录里的文件原样部署到Nginx即可。
公众号网页要正常走微信SDK,必须保证:
- 服务器域名和公众号后台配置的“JS接口安全域名”一致。
- 全站HTTPS,证书要有效,微信对证书链路校验比较严格。
- 页面URL不能有明显跳转,特别不要用302重定向,因为签名是基于用户最终访问的URL计算的,跳转后URL变了,签名就会失效。
我遇到过最典型的场景:用户从微信菜单点进来,公众号帮你加了一堆参数,然后又做了几次重定向,最终前端拿到的URL和后端配置的域名对不上,签名就挂了。解决方法是前端始终从window.location.href取当前URL传给后端,不要让后端预拼接URL。
5.2 隐私授权与敏感数据处理
现在用户对隐私泄露这事很敏感,人脸数据处理不好,产品分分钟被投诉下架。我从第一次接人脸识别项目起,就在页面里放了显眼的授权说明:
- 明确告知采集目的:仅用于本次身份核验。
- 不承诺“永久删除”这种空话,而是在后端设置定期清理任务,核验成功后立即删除本地缓存。
- 数据加密传输,日志里不要打印身份证号和人脸文件路径。
在uniapp里,静态资源目录一般也会被打包进产物,如果模型文件不需要被用户访问,可以放在不对外暴露的路径,或者通过Nginx加一层鉴权,避免模型文件被恶意爬取。
5.3 监控与应急方案
人脸核身功能不是调试完就能放着不管的,线上会冒出来各种奇怪问题。我建议后端对access_token获取失败、ticket获取失败、核身结果查询失败这三类关键指标做监控告警。前端在wx.error回调里也要上报日志,这样能第一时间发现签名配置被改动导致大面积失败。
另外,无论采用哪种方案,都要准备降级路径:微信核身失败时,可以引导用户使用人工审核或短信验证;纯前端检测失败时,可以让用户重新录制一段短视频提交人工复核。人脸识别再强也不能做到100%,给用户留条退路,运营压力会小很多。
做到这些,整个H5人脸识别认证功能才算是真正立得住。后面如果你们团队业务量起来了,还可以考虑把纯前端方案里的模型放到CDN加速,或者升级成云端人脸比对,但核心架构和踩坑经验都是通用的,换汤不换药。
