
示例代码的目标运行环境为 Android 8.1 / Android 11(Java 8)与服务端 Spring Boot 3.2(Java 17),本地人脸库为 SQLite 3;离线 SDK 按公开的「初始化 / 注册 / 检索 / 删除」四类调用形态做接口抽象,不绑定具体厂商私有 API。示例代码为改造骨架,未做真机运行验证;文中不含实测性能数据,凡属工程判断的内容在正文中已做标注说明。
一、先说清楚:这次要改的不是算法,是入口
2026 年 9 月 17 日,近期公开通报了一起健身房刷脸系统合规整改案例。通报写得很具体:辖区 5 家健身机构、日常活跃用户约 1.3 万人,均使用第三方 SaaS 系统,被查出的问题是「强制刷脸入场、将人脸授权与办卡协议捆绑、未取得用户单独同意、过期人脸数据长期留存」四条。
整改之后做了什么,同样写在通报里,按技术动作拆开看:
| 整改动作(通报表述) | 对应到工程上的东西 |
|---|---|
| 增设扫码、人工核验等入场途径 | 入口层增加并行验证通道 |
| 前端页面对人脸信息脱敏展示 | 展示层去标识化 |
| 后端仅保存加密特征码、无法复原原始人脸信息 | 存储层只留特征值,不留原图 |
| 账号分级权限和操作日志留痕 | 访问控制 + 审计日志 |
| 开通免费撤回授权、删除数据渠道 | 同意状态可撤回,撤回即删 |
| 清理过期数据 1.9 万条 | retention 定时任务 |
| 建立全生命周期台账 | 数据流转记录 |
注意通报末尾那条责任划分:相关主管部门依据《个人信息保护法》指出,健身房作为个人信息处理者须履行法定义务,技术公司作为受托方须配合完成系统整改。这句话对做交付的团队意义很直接——甲方被查出问题,SDK 集成方不是旁观者。
对应的法规依据是《人脸识别技术应用安全管理办法》(国家互联网信息办公室、公安部令第 19 号,2025 年 3 月 21 日公布,2025 年 6 月 1 日施行)第十条:
实现相同目的或者达到同等业务要求,存在其他非人脸识别技术方式的,不得将人脸识别技术作为唯一验证方式。个人不同意通过人脸信息进行身份验证的,应当提供其他合理、便捷的方式。
这条里有三个被工程团队经常读漏的点:
- 触发条件是「存在其他非人脸识别技术方式」,不是「用户投诉了」。只要刷卡、扫码、密码、人工任何一种能达成同样目的,人脸就不能是独一份。
- 替代方式要「合理、便捷」。给个需要打三次电话才能开通的人工通道,不算便捷。
- 判断时点是「实现相同目的或者达到同等业务要求」。门口闸机的目的是「确认这是已付费会员」,那么会员码同样能达到这个目的,人脸就没有独占的理由。
二、先自查:你的系统算不算「唯一验证」
改造之前先定性。下面这张表是按第十条的构成要件拆的,只要第一列有任何一项命中「是」,系统就处在风险里。
| 自查项 | 判定为「是」的含义 | 涉及条文 |
|---|---|---|
| 用户拒绝刷脸后,是否无法完成业务 | 直接构成唯一验证 | 第十条 |
| 采集人脸是否与主服务协议打包勾选 | 未取得单独同意 | 第六条 |
| 是否存在「不录入人脸就不给开卡/不开通」 | 强制,且非必要 | 第四条、第十二条 |
| 用户要求删除人脸,是否有自助入口 | 缺少便捷撤回方式 | 第六条 |
| 原始人脸照片是否上传到了服务器 | 超出设备内存储要求 | 第八条 |
| 人脸数据是否有明确的过期清理机制 | 超出必要保存期限 | 第八条 |
| 公共区域设备是否设置了显著提示标识 | 缺少告知 | 第十三条 |
| 上线前是否做过个人信息保护影响评估并留档 | 缺少事前评估与记录 | 第九条 |
第八条在改造里常被忽略。原文是「除法律、行政法规另有规定或者取得个人单独同意外,人脸信息应当存储于人脸识别设备内,不得通过互联网对外传输」。很多系统的架构是设备端提特征、云端存特征并做比对——这在有单独同意的前提下可以走,但如果你连「单独同意」都没有拿到,这条就是硬伤。
三、改造目标:把「人脸」从入口降成通道之一
原始架构通常是这样:
闸机 / App → 人脸采集 → 特征提取 → 1:N 检索 → 开闸
人脸在这条链路里既是入口也是全部通路,任何一环断掉,业务就停。
改造后的目标架构:
┌─ 人脸通道(可关闭、可降级)
入口选择层 ───┼─ 二维码 / 会员码通道
├─ IC 卡 / 身份证通道
├─ 密码 / 短信码通道
└─ 人工核验通道
↓
统一验证结果 + 统一审计日志
替代通道怎么选,先看这张对比表。选的依据不是技术先进性,而是「在这个场景里能不能稳定达成同样的目的」:
| 通道 | 改造量 | 通行体验 | 主要局限 | 适用倾向 |
|---|---|---|---|---|
| 二维码 / 会员码 | 低,多数 SaaS 已带 | 快,需掏手机 | 可被转发(需做动态码) | 会员制场所、闸机 |
| IC 卡 / 门禁卡 | 中,需发卡与补卡流程 | 快 | 卡片丢失需挂失 | 园区、宿舍、固定人群 |
| 身份证读取 | 中,需读卡器 | 中 | 需携带证件 | 政务、酒店、实名核验 |
| 短信 / 动态码 | 低 | 慢,依赖手机信号 | 弱网环境不可用 | 自助终端、低频场景 |
| 密码 | 低 | 慢 | 泄露风险、易忘 | App 内二次确认 |
| 人工核验 | 低,但人力成本高 | 慢 | 需排班,夜间压力大 | 兜底通道,必须保留 |
其中二维码要做成动态码,静态会员码被截图转发的风险是实打实的;IC 卡要补齐挂失与重发流程,否则「卡丢了就进不去」和「脸过不了就进不去」在用户体验上是同一个问题。
关键的架构约束只有一条:人脸通道必须可以被单独关闭,且关闭后业务仍然完整可用。这条约束如果满足不了,改再多文案也仍然落回第十条的问题里。
四、代码:验证通道抽象层
先把所有验证方式收进同一个接口。这么做的好处是,人脸在代码里不再是一个特殊分支,而是 channels 列表里的一项。
public interface IdentityVerifier {
/** 通道标识,用于审计日志与灰度控制 */
String channel();
/** 通道是否可用:设备状态、授权状态、开关状态三者都要看 */
boolean isAvailable();
/** 执行一次验证,不允许抛业务异常,失败以结果对象返回 */
VerifyResult verify(VerifyRequest request);
}
请求与结果对象统一:
public final class VerifyRequest {
public final String scene; // 例如 "GATE_ENTRANCE" / "APP_LOGIN"
public final String userIdHint; // 可选,扫码/刷卡场景可携带
public final byte[] credential; // 各通道自定义:人脸帧、码值、卡号
}
public final class VerifyResult {
public final boolean passed;
public final String channel; // 实际生效的通道
public final String reasonCode; // 失败原因码,进日志
public final String matchedUserId;
}
人脸通道实现。注意 isAvailable() 里除了 SDK 状态,还要看业务开关:
public final class FaceVerifier implements IdentityVerifier {
private final FaceSdk sdk;
private final FeatureStore store;
private final FeatureSwitch featureSwitch;
@Override
public String channel() {
return "FACE";
}
@Override
public boolean isAvailable() {
if (!featureSwitch.isFaceEnabled()) {
return false; // 业务开关:整体停用
}
if (!sdk.isLicenseValid()) {
return false; // 授权失效,常见的高发故障
}
return sdk.isEngineReady(); // 引擎初始化状态
}
@Override
public VerifyResult verify(VerifyRequest request) {
if (!isAvailable()) {
return VerifyResult.fail("FACE", "FACE_CHANNEL_UNAVAILABLE");
}
FaceFeature f = sdk.extract(request.credential);
if (f == null) {
return VerifyResult.fail("FACE", "FACE_EXTRACT_FAILED");
}
Match m = store.searchTop1(f);
if (m == null || m.score < threshold()) {
return VerifyResult.fail("FACE", "FACE_NO_MATCH");
}
return VerifyResult.pass("FACE", m.userId);
}
}
五、代码:路由器与自动降级
路由器的职责是「用户没指定通道时,按可用性挑一个;指定了的就走指定的」,并且把降级过程记进日志——第十条要求的是提供替代方式,日志是事后证明你提供了替代方式的硬凭据。
public final class VerifyRouter {
private final List channels;
public VerifyResult verify(VerifyRequest request, String preferred) {
// 1) 用户显式选择的通道优先
if (preferred != null) {
IdentityVerifier v = find(preferred);
if (v != null && v.isAvailable()) {
return v.verify(request);
}
// 指定通道不可用:不阻断,继续走备选
Audit.log("CHANNEL_FALLBACK", preferred);
}
// 2) 按注册顺序挑第一个可用通道
for (IdentityVerifier v : channels) {
if (v.isAvailable()) {
return v.verify(request);
}
}
// 3) 全部不可用:必须落到人工,而不是直接拒绝
return ManualVerifier.enqueue(request);
}
}
第 3 步是改造的核心。原来「全部失败 = 拒绝通行」,现在「全部失败 = 转人工」——这一行改动,就是把系统从「人脸是独一份的方式」拉回到「人脸是方式之一」。
人工通道在代码里也要是正式的一等公民,不能是「记在纸上的流程」。它至少要有排队、留痕、超时提醒三件事:
public final class ManualVerifier implements IdentityVerifier {
private final TicketQueue queue;
private final AuditLog audit;
@Override
public String channel() {
return "MANUAL";
}
@Override
public boolean isAvailable() {
return queue.hasDutyStaff(); // 当前是否有人在岗
}
@Override
public VerifyResult verify(VerifyRequest request) {
String ticket = queue.enqueue(request.scene, System.currentTimeMillis());
audit.write("MANUAL_TICKET", ticket, request.scene);
// 人工核验是异步的,返回受理号而不是即时结果
return VerifyResult.pending("MANUAL", ticket);
}
}
hasDutyStaff() 这个判断要接值班表,而不是恒返回 true。该案例中整改的难点恰恰在这里——多家 24 小时健身房依赖刷脸闸机实现夜间无人值守,直接拆除设备将增加夜班人力成本或迫使关停夜间时段。通报给出的结论是整改不宜「一刀切」,通报记录的整改动作是在原有基础上增设扫码、人工核验等入场途径。至于夜间无人值守时段具体怎么配替代通道,通报没有涉及,需按各场所排班实际来定(工程判断,非通报原文)。
降级触发条件建议单独成表,方便上线后对照排查:
| 触发条件 | 检测位置 | 降级去向 |
|---|---|---|
| 人脸业务开关关闭 | FeatureSwitch | 扫码 / 刷卡 |
| 授权文件过期或校验失败 | sdk.isLicenseValid() | 扫码 / 刷卡 |
| 引擎初始化失败 | sdk.isEngineReady() | 扫码 / 刷卡 |
| 连续 N 次活体未通过 | 通道内计数 | 提示改用其他方式 |
| 摄像头硬件异常 | 设备自检 | 人工核验 |
| 断网且本地库不可用 | 本地库健康检查 | 人工核验 |
六、代码:单独同意与撤回后的清理
第六条除了单独同意,还有后半句:「个人有权撤回同意,个人信息处理者应当提供便捷的撤回同意的方式」。落到代码里,撤回必须是一个真实的数据删除动作,而不是把状态位改成 0。
public enum ConsentState { NONE, GRANTED, WITHDRAWN }
public final class ConsentService {
private final ConsentRepo repo;
private final FeatureStore store;
private final AuditLog audit;
/** 同意:必须独立记录来源与时间,不能挂在主协议上 */
public void grant(String userId, String source, long ts) {
repo.save(userId, ConsentState.GRANTED, source, ts);
audit.write("CONSENT_GRANT", userId, source);
}
/** 撤回:状态变更 + 特征值删除 + 缓存清理 + 日志,四步缺一不可 */
public void withdraw(String userId) {
repo.save(userId, ConsentState.WITHDRAWN, "USER_SELF_SERVICE", System.currentTimeMillis());
int deleted = store.deleteByUserId(userId); // 本地人脸库特征值
store.evictCache(userId); // 内存缓存
audit.write("CONSENT_WITHDRAW", userId, "deleted=" + deleted);
}
}
配套的存储侧约定,建议写进设计文档:
| 数据项 | 是否保留 | 位置 | 说明 |
|---|---|---|---|
| 原始人脸照片 / 视频帧 | 不保留 | — | 提取完特征即释放 |
| 人脸特征值 | 保留 | 设备本地库 | 第八条要求的默认位置 |
| 用户标识(脱敏) | 保留 | 设备本地库 | 用于检索结果回绑 |
| 同意记录 | 保留 | 服务端 | 评估与举证需要 |
| 审计日志 | 保留 | 服务端 | 第九条要求记录留档 |
| 验证通过记录 | 按业务期限 | 服务端 | 到期即清 |
第九条明确「个人信息保护影响评估报告和处理情况记录应当至少保存 3 年」,所以日志侧的保留期和特征值侧的保留期要分开配置,不能一刀切。
七、代码:过期清理与保存期限
第八条第二款:「人脸信息的保存期限不得超过实现处理目的所必需的最短时间」。这一条在代码上就是一个带参数的清理任务,但参数必须有业务依据,不能随手填 365。
@Component
public class RetentionJob {
// 30 是占位默认值,不是推荐值:需按业务回看周期确定,本文不给出通用取值
@Value("${face.retention.days:30}")
private int retentionDays;
@Scheduled(cron = "0 30 3 * * ?") // 每天凌晨 3:30
public void purge() {
long deadline = System.currentTimeMillis() - retentionDays * 86400000L;
int rows = verifyLogDao.deleteBefore(deadline);
int features = featureStore.deleteOrphan(); // 无对应同意记录的特征值
audit.write("RETENTION_PURGE", "logs=" + rows, "features=" + features);
}
}
retentionDays 的取值逻辑建议这样定:门禁通行场景,验证记录通常只需用于当日的纠纷追溯与月度对账,按「业务需要回看的周期上限 + 一个对账缓冲」来定;人脸特征值的有效期则跟着会员/员工的有效期走,状态失效即触发删除。这两个周期不必相等,分开配置反而更容易向甲方解释。
存量盘点要在改造前先做一次,用来确定工作量:
-- 只留了人脸、没有任何替代凭证的用户数
SELECT COUNT(*) FROM user u
WHERE u.face_feature_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM credential c WHERE c.user_id = u.id);
这个数直接决定第三节的入口改造要铺多少量——如果占比很高,说明还需要在首次进入时引导用户补录替代凭证。
八、代码:存量补签与影响评估留档
前面几节都是新系统的写法。已上线的系统还有一个更麻烦的问题:人脸早就采集好了,但当时拿的是打包勾选的同意。按第六条这不成立,需要补——对应通报里的「存量会员补签单独授权」。
补签迁移有一条容易踩的线:不能因为用户没补签就拒绝其入场,那样做又落回第十条了。正确做法是未补签只停用人脸通道,用户改用其他通道,业务不受影响。
@Component
public class ConsentBackfillJob {
private final ConsentRepo repo;
private final FeatureStore store;
private final NotifyService notify;
/** 每天扫描一次,推进补签状态机 */
@Scheduled(cron = "0 0 10 * * ?")
public void advance() {
// 历史采集、尚未发起补签
for (String uid : repo.findByState(ConsentState.LEGACY)) {
notify.sendConsentRequest(uid); // 站内 + 短信,文案独立成条
repo.moveTo(uid, ConsentState.PENDING);
}
// 超过补签期限未响应:停用人脸通道并删除特征值
for (String uid : repo.findTimeoutPending(backfillDeadlineDays)) {
store.deleteByUserId(uid);
repo.moveTo(uid, ConsentState.TIMEOUT);
Audit.write("CONSENT_BACKFILL_TIMEOUT", uid);
}
}
}
状态流转建议按下面这张表实现,尤其是 TIMEOUT 那一行的处理:
| 状态 | 含义 | 人脸通道 | 其他通道 |
|---|---|---|---|
| LEGACY | 历史采集,无单独同意 | 可用,但每次提示补签 | 可用 |
| PENDING | 已发出补签请求 | 可用 | 可用 |
| GRANTED | 已补签 | 可用 | 可用 |
| WITHDRAWN | 明确拒绝 | 停用并删除特征值 | 可用 |
| TIMEOUT | 超期未响应 | 停用并删除特征值 | 可用 |
TIMEOUT 到期视为未取得同意,特征值删除,但用户资格不受影响——这一条同时满足第六条的同意要求和第十条的不得阻断业务,是迁移方案里关键的一处。
第九条还要求事前做个人信息保护影响评估并留档,报告与处理情况记录至少保存 3 年。这个在代码上就是一个常驻的记录结构,建议直接把条文四项落成四组字段,避免评估做完了却和条文对不上:
public final class PiaRecord {
public String scenario; // 应用场景标识
public String purpose; // 处理目的
public String method; // 处理方式
// (一) 合法性、正当性、必要性结论
public String necessityConclusion;
// (二) 对个人权益的影响与降低影响的措施
public String impactAndMitigation;
// (三) 泄露、篡改、丢失、毁损或被非法获取的风险与危害
public String riskAndHarm;
// (四) 保护措施是否与风险程度相适应
public String measureAdequacy;
public String assessor;
public long assessAt;
public int retainYears = 3; // 第九条要求的下限
}
改造本身就是一次「目的、方式发生变化」,按第九条应当重新评估。所以这个记录不是上线前补一份材料,而是每次改通道、改存储位置、改保留期都要更新。
九、提示标识与无障碍通道
第十三条要求在公共场所安装人脸识别设备应当设置显著提示标识。这条常被做成「贴一张纸」,但贴完没有系统侧的任何对应物,事后既说不清贴没贴,也说不清采集区域划在哪。建议把它配置化:
public final class DeviceSignageConfig {
public String deviceId;
public boolean noticePosted; // 是否已设置显著提示
public String noticePhotoId; // 现场照片留档
public String captureZoneDesc; // 采集区域说明
public boolean privateSpace; // 是否涉私密空间,恒为 false
public long checkedAt;
}
privateSpace 这个字段看着多余,但它是给巡检用的:第十三条第二款禁止在宾馆客房、公共浴室、公共更衣室、公共卫生间等私密空间内部安装设备,留一个恒为 false 的显式字段,巡检表里就能有这一项,而不是靠人记。
第五条末尾还有一句容易被漏掉的:「处理残疾人、老年人人脸信息的,还应当符合国家有关无障碍环境建设的规定」。落到通道设计上,就是人工通道不能只是「有」,还得可用:
| 场景 | 无障碍要求 | 实现建议 |
|---|---|---|
| 视力障碍用户 | 无法自主对准摄像头 | 人工通道常驻,不依赖刷脸 |
| 老年用户 | 动作指令配合度低 | 活体动作放宽或提供非活体通道 |
| 面部损伤用户 | 特征值不稳定 | 刷卡/扫码为主通道 |
| 轮椅用户 | 摄像头高度不匹配 | 设备侧增加低位摄像头或人工核验 |
这一组要求在门禁场景尤其现实。摄像头按站立身高装的,坐轮椅的用户进不了采集区域——如果人脸是唯一入口,实际效果就是把这部分用户挡在门外。这类问题不在条文的直接规定范围内,但第五条要求处理残疾人、老年人人脸信息符合国家有关无障碍环境建设的规定,改造时一并考虑是稳妥做法。
十、改造里常漏的六件事
按实际交付中常见顺序排:
| 漏项 | 表现 | 后果 |
|---|---|---|
| 只加通道不加开关 | 人脸代码仍是主链路,关不掉 | 出事时无法快速止损 |
| 同意书塞进主协议 | 「勾选即同意」的旧文案未改 | 单独同意不成立 |
| 撤回只改状态 | 特征值还躺在本地库 | 撤回形同虚设 |
| 提示标识缺失 | 设备装了但没贴提示 | 第十三条不满足 |
| 人工通道要申请 | 用户拒绝刷脸后需走审批 | 不满足「便捷」 |
| 日志没留通道信息 | 只记通过/不通过 | 无法自证已提供替代方式 |
第六项值得单独说。事后要说明系统确实提供了替代方式,能拿出来的凭据只有日志;如果日志里只有 PASS / FAIL,就区分不出「用户刷脸失败被拒」和「用户改用了别的通道通过」。所以 VerifyResult.channel 这个字段必须落库,不能只在内存里用一下就丢。
十一、改造后的验收清单
上线前跑一遍,全部通过再切全量。
| 序号 | 验收项 | 判定方式 |
|---|---|---|
| 1 | 关闭人脸开关后业务仍完整可用 | 现场演练 |
| 2 | 每个入口至少两个可用通道 | 配置核对 |
| 3 | 拒绝刷脸的用户能自助完成验证 | 走查 |
| 4 | 同意为独立动作,非打包勾选 | UI 走查 |
| 5 | 撤回入口自助可达,无需人工审批 | 走查 |
| 6 | 撤回后特征值确实从本地库删除 | 查库确认 |
| 7 | 原始图像不在服务端留存 | 抓包 + 查库 |
| 8 | 人脸数据默认存于设备内 | 架构核对 |
| 9 | 过期清理任务已配置且有日志 | 查调度记录 |
| 10 | 公共场所设备有显著提示标识 | 现场核对 |
| 11 | 日志含通道字段,可导出 | 查日志样本 |
| 12 | 事前影响评估已做并留档 | 查档案 |
配套的灰度节奏建议按设备分批,先小范围验证降级链路真的能跑通:
| 批次 | 范围 | 观察重点 |
|---|---|---|
| 第一批 | 单台设备 / 单个网点 | 降级链路是否真的能走通 |
| 第二批 | 单一场景全量 | 替代通道的通行效率 |
| 第三批 | 全场景 | 人工通道压力、日志完整性 |
十二、写在末尾
该案例中值得记录的一句判断——「问题不在技术本身,而在于数据使用边界失守」,以及整改没有一刀切地拆设备,而是在原有基础上增设扫码、人工核验等入场途径。这个结果对做集成的团队是友好的:不需要推翻已交付的系统,需要的是把入口打开。
从工程角度看,这套改造真正有价值的地方不是合规本身,而是它顺带解决了一个长期存在的问题——原来人脸链路一断业务就停,现在授权过期、摄像头故障、引擎初始化失败这些高频故障,都只是降级到另一个通道而已。稳定性收益其实是白捡的。
不过这套改造在不同场景里卡住的地方差别很大,通用思路解决不了具体排期。可自查:正在跑的刷脸系统,人脸之外到底还有没有第二条能用的通道?
具体说,是这三种里的哪一种:
- 本来就有刷卡或扫码通道,只是没人维护、形同虚设;
- 人脸确实是独一份,这次要新增通道,卡在硬件采购还是接口改造;
- 通道好加,卡在存量那批数据——人脸早就采集完了,但当时拿的是打包勾选的同意,补签推不动。
可按「场景 + 现有通道 + 卡在哪一步」这三项梳理。另外有一类情况尚无标准方案:24 小时无人值守的时段,替代通道到底怎么配才不增加夜班人力——通报里没涉及,只能按各场所的排班实际来定,有实际处理过的可以补充做法。
相关文章专栏
专栏一: