基于JavaWeb的人脸识别与GPS定位考勤系统防代打卡实践

发布时间:2026/10/24 1:30:25
基于JavaWeb的人脸识别与GPS定位考勤系统防代打卡实践

月底处理考勤数据的时候,最常见的场景是这样:一张表拉出来,同一个门店三个人在 08:29:47 到 08:29:52 之间全部打了卡,位置坐标一模一样,只差小数点后四位。行政第一反应是"系统坏了",技术第一反应是"这仨人是把手机递给同一个同事按了一遍"。单靠一个密码、一个 Wi-Fi 名称、一个位置点,考勤系统在真实办公场景里基本等于没有防护。我这次复盘的是基于 JavaWeb 技术栈搭的一套考勤系统,核心思路是把人脸识别和GPS 定位这两道校验叠在一起:人要对、地方也要对,缺一样都不给过。这套东西适合正在做企业内部管理系统、门店巡店打卡、外勤人员管理的开发者参考,也适合被代打卡折磨过的行政或 HR 拿去和技术团队对话——读完之后你至少能判断,对方给的方案到底防住了什么、漏掉了什么。

1. 代打卡这事的真实成本:先想清楚两道校验各挡谁

动手写代码之前最该做的一件事,是把"敌人"列清楚。很多考勤系统上线三个月后形同虚设,不是因为技术不行,是因为设计时压根没定义过威胁模型,开发者只想着"能把打卡记录写进数据库就行"。等你被业务方问一句"员工把手机给同事带去公司代打怎么办",才发现整个链路只有一个登录态在撑着。

1.1 单点校验的三类典型漏洞

把常见的作弊路径拆开看,其实就三类。第一类是身份冒用,账号密码外借,或者干脆在工位电脑上保持登录状态让同事点一下。第二类是位置伪造,早年间是直接在系统里改代码注释掉定位判断,后来是安装模拟定位工具把坐标改到公司门口,再后来更省事——把打卡用的手机留在公司,人回家。第三类是时间作弊,改客户端系统时间、拦截请求重放昨天的打卡包。

这三类漏洞有个共同点:它们攻击的都是"单一维度的信任"。密码只证明"知道密码的人",位置只证明"手机在哪",两者都不证明"操作的人此刻在现场"。所以双因子的价值不在于两个校验各有多强,而在于它们必须被同一个主体在同一时刻同时满足——人脸属于生物特征,天然跟人绑定;GPS 属于空间约束,天然跟地点绑定。想同时伪造这两样,成本就从"借个手机"跳到"做个能骗过活体检测的假脸,还得跑一趟现场"。

提示:别指望任何单点方案做到绝对安全。目标是让作弊成本高于迟到扣的钱,不是造一个攻不破的堡垒。这个判断标准会直接影响你后面每一个技术选型的取舍。

1.2 人脸加位置到底锁死了哪些作弊路径

我做过一张对照表,上线评审时直接甩给业务方看,比讲技术原理有效得多:

作弊手段只有密码密码 + 位置密码 + 位置 + 活体人脸
账号外借,同事代点可以可以不可行
手机留在公司,人不在可以可以不可行
虚拟定位改坐标可以部分可破需配合假脸
翻拍照片过认证不涉及不涉及取决于活体强度
拦截请求重放可以可以不可行(一次性挑战)
提前录好视频播放不涉及不涉及取决于活体强度

从表里能看出来,位置这一层的实际防护力比想象中弱——模拟定位工具在开发模式下是合法能力,普通员工学习成本不到十分钟。所以真正扛住主力的其实是人脸那一道,位置更多是在做"外勤/多点位"场景下的业务约束,比如门店督导必须到店打卡、外勤人员必须到客户地址附近。这一点想清楚了,你在人脸模块上的投入比例就不会失衡。

2. 人脸模块的选型:本地模型和云服务,谁能扛住你的业务

这部分是最容易纠结的。我在项目里试过三条路,结论可能跟很多教程说的不太一样:决定选型的不是精度,是并发量、单次成本和底库规模这三者的组合。

2.1 三条技术路线的实测对照

第一条是自己跑本地模型。Java 侧通过 OpenCV 的 DNN 模块加载人脸检测和特征提取网络,或者用 JNI 调底层视觉库。优点是数据不出内网,没有按次计费,适合对隐私敏感的场景。缺点也很实在:部署包体积大,服务器得有点算力,第一版调参能耗掉你两周。

第二条是接云厂商的人脸比对接口。上传一张现场照和目标照,或者上传现场照在底库里做 1:N 搜索,返回相似度和候选。优点是接入快、模型效果好、活体检测通常也一起给了。缺点是按调用量计费、有网络依赖、人脸图片要出内网——这一条在很多公司的合规评审里是硬门槛。

第三条是用开源的轻量人脸特征库,比如基于经典人脸识别网络输出的定长特征向量,自己在服务端做向量比对。这条路其实是我最后采用的,因为它把"模型能力"和"业务逻辑"解耦得最干净:模型只负责把一张脸变成一串数字,比对、阈值、底库管理全在你自己的 Java 代码里。

维度本地深度学习模型云厂商接口开源特征库自建
首次接入耗时长(1~2 周)短(1~2 天)中(3~5 天)
单次成本服务器折旧按次计费服务器折旧
底库规模上限取决于内存与算法受接口限制几千到几万可控
数据是否出内网否是否
活体检测需自己做通常自带需自己做
离线可用是否是

几百人规模的公司,底库几千条特征向量,用开源方案完全够用,比对耗时可压到毫秒级。上万人、多门店、要求高并发秒开的场景,云接口省下来的运维成本往往比调用费更值钱。

2.2 特征向量存哪、怎么比、阈值定多少

人脸特征本质上是一个定长浮点数组,常见的是 128 维或 512 维。存储上我最终选了 MySQL 的BLOB字段存序列化后的 float 数组,同时在 Redis 里放一份热底库——因为打卡是典型的高峰集中型流量,早上八点半那十分钟能占全天请求量的六成,每次都去查库序列化,数据库会被打穿。

比对用余弦相似度,公式不复杂:

// 余弦相似度:值越接近 1 越像 public static double cosine(float[] a, float[] b) { double dot = 0.0, normA = 0.0, normB = 0.0; for (int i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } if (normA == 0 || normB == 0) return 0.0; return dot / (Math.sqrt(normA) * Math.sqrt(normB)); }

阈值是这套系统里最需要拿真实数据调的一个参数。文献里常见的 0.6 欧氏距离对应到余弦相似度大约在 0.8 上下,但那是标准测试集上的数字。我实际的做法是:收集本单位 200 个人、每人 5 张不同光照和角度的照片做一轮内部评估,把阈值从 0.75 试到 0.9,看误拒率(本人在正常工位环境下被拒绝)和误识率(不同人被认为是同一人)的曲线拐点。最终落在0.82,误拒率约 3%,误识率接近零。这个数字每家都得自己测,直接抄别人的阈值基本一定出问题。

注意:不要把阈值写死在代码里。做成配置项,上线后根据被拒日志持续微调。人脸识别的环境变量太多——早上逆光、戴帽子、口罩、刚理完发——写死的阈值遇到换季就会有一批人打不上卡,投诉能把你淹没。

底库比对有个容易忽略的性能细节:N 个候选人做暴力比对,N=3000、维度 512 时,单次大约几毫秒,看似没问题,但早高峰 200 QPS 叠加起来就是上千毫秒的 P99。解决办法是先按部门或门店做一次粗筛(只在同门店的底库里搜),把 N 从 3000 降到 80,耗时就掉到可以忽略。这个优化不需要任何复杂算法,纯业务分片。

2.3 活体检测这道关必须自己补

我踩得最狠的一个坑在这。第一版上线只做了 1:1 比对,觉得"必须上传本人的脸才能过"已经很安全。结果测试时同事拿手机翻拍了一张工牌上的证件照,正对着摄像头,一次就过了。原因很简单:2D 人脸比对模型判别的是"这张图像里的面部特征和底库像不像",它不判断"这张脸是不是活的"。

活体方案按成本从低到高有三种。动作配合式要求用户眨眼、转头、张嘴,实现简单,用关键点位移做判定,缺点是体验一般,弱光下容易失败。静默活体靠模型判断屏幕反光、纹理、摩尔纹,体验最好,但需要专门的模型文件,自建成本高。硬件方案用双目红外摄像头,最稳,但需要指定设备,移动端打卡场景直接出局。

我最终用的是动作配合式的轻量版本:服务端下发一个随机的动作指令(比如"请向左转头"),客户端录制几帧上传,服务端校验关键点在时间序列上的变化方向和幅度。加这一步之后,翻拍照片的攻击基本归零,代价是单人打卡从 1 秒变成 2.5 秒左右。这个取舍我认为值得——早高峰排队的问题可以用多开一台打卡机解决,代打卡的窟窿补起来可就难了。

3. 定位数据从浏览器到服务端:坐标系是最大的坑

位置这块看起来最简单——调个接口拿经纬度,算下距离不就完了。实际做下来,坐标系能把人坑到怀疑人生,而且这类错误非常隐蔽:距离算出来差几百米,你以为是围栏半径设小了,其实是坐标系差了整整一个量级。

3.1 Geolocation 拿到的经纬度到底是哪一套

网页端调navigator.geolocation.getCurrentPosition,返回的经纬度理论上基于 WGS84 这个全球通用的大地坐标系。问题在于,国内的地图服务和部分浏览器/容器环境会做坐标偏移处理,把 WGS84 转成一种加密后的坐标系(俗称火星坐标),而在国内地图上标注点位时用的又是另一套。这三套坐标系之间相差几百米,在楼宇密集区足够让你的围栏判定完全失效——员工明明站在公司楼下,系统显示距离 480 米。

判断当前环境返回的是哪一套,最土也最可靠的办法是现场标定:站在一个已知坐标的点位上,取一次值,跟官方地图上标注的点做差。差在几百米量级就说明被转过,差在几十米内就是原始坐标。

// 前端取坐标的正确姿势 navigator.geolocation.getCurrentPosition( (pos) => { const { latitude, longitude, accuracy, timestamp } = pos.coords; // accuracy 是精度半径(米),必须一起上报,服务端据此判断可信度 submitCheckIn({ lat: latitude, lng: longitude, acc: accuracy, ts: timestamp }); }, (err) => { /* 超时、拒绝授权、定位不可用,要分开提示 */ }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 } );

enableHighAccuracy: true会强制走 GPS 而不是基站/WiFi 粗略定位,代价是首次定位慢、耗电高。maximumAge一定要设 0,否则浏览器可能直接返回上一次缓存的坐标——员工在家打开页面,缓存里还是昨天的公司坐标,这种情况我真实遇到过。

3.2 坐标转换与围栏判定

如果确认拿到的是偏移后的坐标,需要转到统一的基准上再做距离计算。转换算法是公开的固定公式,写成工具类即可:

private static final double PI = 3.1415926535897932384626; private static final double A = 6378245.0; // 长半轴 private static final double EE = 0.00669342162296594323; // 偏心率平方 // 判断是否在偏移范围之外(境外坐标不做转换) public static boolean outOfChina(double lat, double lng) { return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271; } // WGS84 转偏移坐标系 public static double[] wgs84ToGcj02(double lat, double lng) { if (outOfChina(lat, lng)) return new double[]{lat, lng}; double dLat = transformLat(lng - 105.0, lat - 35.0); double dLng = transformLng(lng - 105.0, lat - 35.0); double radLat = lat / 180.0 * PI; double magic = Math.sin(radLat); magic = 1 - EE * magic * magic; double sqrtMagic = Math.sqrt(magic); dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); dLng = (dLng * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI); return new double[]{lat + dLat, lng + dLng}; }

转换完再做距离判定。圆形围栏用 Haversine 公式:

public static double distanceMeters(double lat1, double lng1, double lat2, double lng2) { double r = 6371000; // 地球半径,米 double dLat = Math.toRadians(lat2 - lat1); double dLng = Math.toRadians(lng2 - lng1); double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2); return 2 * r * Math.asin(Math.sqrt(a)); }

这里有个非常重要但常被忽略的点:判定时不能直接拿"距离 <= 半径"就放行,必须把定位精度 accuracy 算进去。手机上报的坐标永远带误差,室内靠 WiFi 定位误差可能到 200 米。正确的判定是:如果distance + accuracy <= radius,认为是确定在范围内;如果distance - accuracy > radius,确定在范围外;中间那段模糊区间,要么放宽,要么提示用户到开阔处重试。

对于园区、厂区这种不规则边界,圆形围栏不够用,得上多边形。多边形判定用射线法,把围栏点按顺序存成 JSON 数组,判断坐标点是否在多边形内部。射线法的经典实现二十行左右,难点不在算法而在数据维护——围栏顶点是谁来画、改了点位之后历史打卡怎么算,这些得在管理后台里给出明确的操作入口。

4. 一次打卡请求在服务端要走完哪些工序

从后端视角看,一次打卡远不止"插一条记录"。我把整条链路拆成了六步,每一步都有可能成为事故点。

4.1 请求结构与防重放

请求体我设计成这样:userId(从登录态取,绝不信任前端传的)、faceImage(Base64 或文件流)、lat/lng/acc、clientTs、nonce、sign。nonce是一次性随机串,服务端在 Redis 里存 5 分钟,重复直接拒绝。sign用 userId + clientTs + nonce + 一个服务端下发的临时 token 做哈希,防止参数被中途篡改。

提示:前端传过来的clientTs只能用于辅助判断(比如识别出设备时间明显异常),绝对不能作为打卡时间的最终依据。所有入库时间一律取服务端System.currentTimeMillis()。我见过有系统用客户端时间,员工把手机时间往前调十分钟,全勤奖就稳了。

4.2 服务端处理链路与事务边界

处理顺序我固定成这样,顺序本身有讲究:

  1. 参数校验与防重:校验必填、验签、查 nonce、分布式锁挡住同一个人 30 秒内的重复提交(用 Redis 的setIfAbsent加过期时间)。
  2. 时间窗口校验:判断当前时间是否落在允许打卡的班次区间内,超出范围直接拒绝,避免半夜三更冒出一条记录。
  3. 定位校验:坐标转换、精度过滤、围栏判定,任一项不通过就落到"异常打卡"分支,而不是直接拒绝——这点下面细说。
  4. 人脸检测:先用检测器找到图里有没有并且只有一张人脸,裁剪对齐,再提取特征向量。
  5. 比对:在限定范围内搜底库,得分过阈值则通过,同时把得分一并入库,方便后续追查。
  6. 落库:打卡记录、考勤明细、异常日志三张表。

事务边界只应该包住第 6 步。人脸比对、坐标计算这些可能耗时几百毫秒的操作不要放在事务里,否则高峰期数据库连接池会被长时间占用,连锁反应比你想的严重。

@Transactional(rollbackFor = Exception.class) public CheckInResult persist(Long userId, CheckInContext ctx) { AttendanceRecord record = new AttendanceRecord(); record.setUserId(userId); record.setCheckTime(ctx.getServerTime()); record.setLat(ctx.getLat()); record.setLng(ctx.getLng()); record.setAccuracy(ctx.getAccuracy()); record.setSimilarity(ctx.getSimilarity()); record.setStatus(ctx.isAllPassed() ? STATUS_NORMAL : STATUS_ABNORMAL); recordMapper.insert(record); attendanceDetailService.refresh(userId, ctx.getServerTime().toLocalDate()); return CheckInResult.of(record); }

4.3 异常打卡不要直接拒绝

这是我自己踩过的一个设计坑。第一版是人脸不过或位置不在范围内,接口直接返回失败,前端弹个红字。结果上线第一周投诉爆炸:有人在地下车库定位失败、有人早上光线太暗人脸不过,反复重试最后迟到。

改完之后逻辑变成:校验失败也允许提交,但标记为"异常打卡",附上失败原因和当时的图片。这样员工能证明自己确实来了,主管在后台看一眼照片和坐标就能人工确认。系统的目的从来不是把员工卡死,是让作弊变难、让正常人的意外有据可查。这个改动上线后投诉量直接掉到接近零。

5. 抗攻击:把"能打卡"变成"打不了假卡"

攻防这块,我按攻击者能拿到的资源分了层:普通员工会用现成工具,技术型员工会看请求,专业攻击者才会去研究模型。绝大多数系统的实际威胁只来自前两层。

5.1 图片攻击的几种变体与对应处理

翻拍照片是最基础的一种,动作活体可以挡。更麻烦的是屏幕翻拍——把照片放在另一台手机上显示,摄像头拍另一台手机的屏幕,会有摩尔纹和反光,但有些角度下不明显。还有视频重放,提前录好一段本人眨眼的视频对着摄像头播,纯动作检测就失效了。

我的组合拳是这样的:动作指令随机化(每次不同,且有时间窗,超过 10 秒作废),检出的人脸面积要达到阈值(照片贴在远处屏幕上人脸会很小),对上传图像做一次纹理和反射特征粗筛。不做太复杂,因为再往上一层的攻击(3D 面具、深度伪造)已经不是考勤系统的防御范畴了,性价比极低。

另外还有一个几乎零成本但很有效的措施:打卡时同时拍一张带场景的图,不做人脸分析,只作为留证。这条对心理威慑的作用远大于技术作用——知道每次都留了现场照片,想作弊的人自己会打退堂鼓。

5.2 定位伪造与设备环境识别

模拟定位在安卓开发模式下是合法功能,普通应用层很难彻底识别。能做的是提高成本:检测开发者选项状态、检测是否存在常见的模拟定位应用包名、对比 GPS 与基站/WiFi 定位结果的差异(模拟定位通常只改 GPS 层,两者会明显矛盾)。这些信号不必作为硬性拒绝条件,但可以累计成风险分。

还有一种更朴素但更难防的作弊:人机分离。人把手机交给同事带进公司,同事用他的账号登录,人脸过不去——除非同事拿着他的照片。这时候活体就起作用了。你看,所有防线最后都收敛到人脸这一关,这也是为什么前面说投入重心要放在这里。

5.3 限流与异常留痕

按 userId 做滑动窗口限流,一分钟最多 5 次,超过锁定 10 分钟。异地登录立刻使旧 token 失效。所有失败记录——人脸得分低的、位置超范围的、活体失败的——全部落到异常日志表,保留 90 天。

这张日志表的价值远超预期。它有几个用途:调阈值时有真实数据、被投诉时有据可查、还能反过来发现异常模式。我们上线两个月后从日志里发现某个账号连续两周都在 09:15 打卡且定位精度都是"精确到 3 米"这种异常理想的值,一查确实是外挂。正常手机在楼里的定位精度是波动的,稳定得不像话反而不正常。这种洞察光靠看代码是想不出来的。

6. 考勤规则引擎与统计口径:真正难的在出报表

打卡只是采集数据,考勤系统的复杂度八成都堆在规则计算上。这部分做得糙,月底出报表就是一场灾难。

6.1 班次模型的抽象方式

不要一上来就写"迟到判断"的逻辑,先把班次抽象出来。我用的模型是:班次(上班时间、下班时间、是否跨天、弹性时长)→排班(某人某天属于哪个班次)→考勤组(一批人共享的规则集合)。

跨天班是最容易被漏掉的情况。夜班 22:00 到次日 06:00,如果按自然日切分,凌晨两点那次打卡会被算到第二天,整个报表全错。处理办法是引入"考勤日"的概念,跟自然日解耦——一条打卡记录归属哪个考勤日,由它所属班次的开始时间决定,而不是日期本身。

弹性工时也要单独处理。允许 09:00 ± 30 分钟到岗,那就得按实际到岗时间往后退算下班时间,而不是固定 18:00。这中间的边界条件很多,比如弹性时长用不完能不能累积,这些必须和业务方一条条确认清楚再写代码,否则返工成本极高。

6.2 迟到、早退、漏卡的判定顺序

判定顺序有讲究,写反了会出荒谬结果。我的顺序是:先定归属考勤日 → 找出该日该班次的所有打卡记录 → 按时间排序 → 取最早的一条作为上班卡、最晚的一条作为下班卡 → 再判迟到早退。

中间有个细节:一天可能打四次卡(午休外出)。简单取最早最晚会把午休那些记录全部忽略,这通常是对的,但如果公司要求午休也打卡,就得按班次定义的时间段分别匹配。所以我在班次里加了个"打卡段"列表,每段有自己的时间范围,匹配时按段处理。

漏卡判定要设置容错。早上只打了一次卡、下班忘了打,这是最常见的场景。系统不能一刀切判"缺勤",而应该区分"缺卡"和"缺勤"——缺卡可以补卡,走审批流程;缺勤才扣钱。这一层区分在数据模型上体现为状态字段的多值枚举,别偷懒用布尔值。

6.3 报表聚合的 SQL 与性能

月度统计我最初写了一个看起来没问题的 SQL,两百人跑 4 秒,五百人跑 40 秒。问题出在逐行调用函数算迟到分钟数。

-- 优化前:每行调用一次函数,性能随数据量平方级恶化 SELECT u.id, u.name, fn_late_minutes(r.check_time, s.start_time) AS late FROM attendance_record r JOIN sys_user u ON u.id = r.user_id JOIN shift s ON s.id = r.shift_id WHERE r.check_date BETWEEN #{start} AND #{end}; -- 优化后:纯集合运算,延迟计算下推到 JOIN 条件里 SELECT u.id, u.name, SUM(GREATEST(TIMESTAMPDIFF(MINUTE, s.start_time, r.check_time), 0)) AS late_minutes, COUNT(CASE WHEN r.check_time > s.start_time THEN 1 END) AS late_times FROM attendance_record r JOIN sys_user u ON u.id = r.user_id JOIN shift s ON s.id = r.shift_id WHERE r.check_date BETWEEN #{start} AND #{end} GROUP BY u.id, u.name;

改完之后五百人一个月的数据跑 300 毫秒以内。核心思路是:不要在行级调用自定义函数,把能下推的计算全部下推,聚合放在数据库里做,Java 只负责组装。

再补一层缓存:日报按天预计算,存到一张汇总表,月报基于日报汇总。用户点开报表是查汇总表,不是实时扫原始明细。这一层加完,前端加载时间从十几秒掉到一秒内。

7. 部署形态、隐私边界与实测数据

7.1 人脸数据属于敏感个人信息,处理方式必须提前定

人脸信息在个人信息保护相关要求里属于敏感类别,处理时至少要满足几条:单独告知并取得同意、加密存储、最小化留存、支持删除。落到技术实现上有几个具体动作:

  • 原始照片默认不长期保存,比对完成后只留特征向量,图片在临时目录存几小时用于异常复核,之后定时清理。
  • 特征向量入库前做加密,密钥独立管理,不跟数据库放一起。
  • 员工离职流程里加一步:特征向量与历史照片全部物理删除,留删除日志。
  • 管理后台查看打卡照片要有权限控制和操作审计,谁看的、什么时候看的都记下来。

这些工作不产生任何业务功能,但一旦缺失,后续返工成本极高——数据已经存进去了,再想改存储策略,得做数据迁移和清理。

7.2 部署结构与实测数据

部署形态我最后落成三台机器:应用服务器跑 Spring Boot 打好的 jar,数据库单独一台,另有一台跑人脸特征提取服务(用 HTTP 内网调用,方便独立扩缩容)。图片和日志走本地磁盘按日期分目录,量不大就不上对象存储,省得引入额外依赖。

实测数据供参考:单张人脸检测加特征提取在 4 核 8G 的机器上约380 毫秒(含解码),比对 80 条底库约4 毫秒,全链路一次打卡接口平均620 毫秒,P99 在 1.4 秒左右。早高峰 200 并发下单机 CPU 跑到 70%,够用但不宽裕,扩展方式就是加人脸服务的实例,前面挂个简单的轮询。

数据库表结构上,我最终保留了这几张核心表:sys_user、face_feature(用户特征,含版本号和状态)、attendance_record(原始打卡流水,只增不改)、attendance_detail(日汇总)、attendance_summary(月汇总)、shift与schedule、checkin_abnormal_log(异常日志)。原始流水和汇总表分开是关键设计——流水永不修改,所有计算都可以从中重放,出问题了重算一遍就行,不用人工纠数据。

8. 几个我踩过之后才明白的点

最后分享几条纯粹来自实操的经验,文档里基本不会写。

人脸模型别急着换新的。中途我一度想升级特征提取模型提升精度,结果新旧模型的特征向量维度不同,整个底库得重新采集。员工要重新录一遍脸,那场面非常难看。所以模型版本号必须从第一天就写进表结构,并且支持多版本共存,新老用户过渡期各用各的模型比对。这个设计提前花十分钟,能省掉后面一场大折腾。

打卡成功的响应要慢一点给。听起来反直觉,但确实是我们的实测结论:接口如果 200 毫秒就返回成功,用户会怀疑"是不是没真的识别",反复点。加上 300 毫秒的处理感,配合"正在比对"的进度提示,重复提交率下降了七成。人是需要一点反馈延迟来建立信任的。

阈值和围栏半径一定要做成运营可调的。我最初把它们写死在配置文件里,每次调整都要重新发包、走一遍上线流程。后来挪到管理后台的配置表,支持按部门设置不同的围栏半径(外勤部门范围和总部完全不是一回事),运营自己就能改,技术侧彻底解放。

留一条"手动补卡"的兜底通道,并且严格审计。任何自动化系统都会有失效的时候——手机丢了、摄像头坏了、系统故障。没有兜底通道,业务会直接停摆。但补卡必须走审批、留痕、限次数(比如每月不超过 2 次),否则它就成了新的作弊入口,前面所有防线的努力都白费。

这套系统到现在跑了小半年,代打卡现象基本绝迹,行政月底对账的时间从两天压缩到两个小时。但更让我有成就感的其实是另一件事:因为异常打卡会保留现场照片和原因,那些偶尔因为堵车、下雨、车库没信号而迟到的人,第一次有了说清楚的机会。技术做对了,往往是让老实人不吃亏。