ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SSM框架+微信小程序课堂签到系统:地图定位与人脸识别防代签

SSM框架+微信小程序课堂签到系统:地图定位与人脸识别防代签 简介面向高校课堂考勤场景基于SSM框架与微信小程序技术栈打造的课堂签到小程序源码集成Face人脸识别与地图定位签到为教师端与学生端提供完整业务闭环适合Java开发者、小程序学习者及毕业设计参考。资源包共339个文件、44.11MB涵盖87个Java核心业务代码、72个JAR依赖包、46个编译后CLASS文件、27个PNG界面素材、21个XML配置及MyBatis映射文件同时包含15个JS/JSON前端逻辑、14个wxss样式与13个wxml页面模板前后端结构清晰便于按模块研究。已有508人学习浏览适用于需要快速理解SSM整合微信小程序、人脸识别及地图API调用流程的开发者。在此基础上可复现签到、请假、统计等核心功能还可结合项目中的数据库脚本与接口设计延伸出考勤报表、课程管理等扩展是一套兼顾教学演示与实际落地的完整源码。1. 课堂签到别只看定位人脸识别才是防代签的底牌课堂签到类小程序在各类源码站里数量不少但多数实现停在“定位打卡”或“扫码签到”这一层学生把小程序转发到宿舍群人在床上课却显示“已到”。真正能扛住日常使用的课堂签到系统是用 SSM 框架Spring SpringMVC MyBatis做后端服务微信小程序做前端载体把地图签到与人脸识别串进同一个流程——先判断人在不在教室再判断人是不是本人两道校验都通过才落签到记录。这篇按从业者从零搭这套系统时的常见做法展开架构怎么分、表怎么建、定位半径怎么算、人脸比对怎么做、真机上有哪些坑。适合正在做相关毕业设计或者在业务系统里需要一套轻量考勤能力的一线开发。2. SSM 后端与微信小程序前端的架构分层、数据库设计与请求封装2.1 为什么课堂签到场景仍选 SSM 框架现在新项目默认 Spring Boot但 SSM 框架在课堂签到这类教学型、轻量级业务里仍常见原因是它的分层足够直白Spring 管 Bean 和事务SpringMVC 管路由和参数绑定MyBatis 管 SQL。把三层拆开写课程管理、签到记录、人脸特征这三个模块的职责边界比 Spring Boot 全家桶更清晰遇到问题也更容易定位是 Controller 层参数错误还是 Mapper 层 SQL 写错。我一般会把后端按这样的包结构组织源码拿到手后优先看这四个包com.classsign ├── controller # 接收小程序请求返回 JSON │ ├── CourseController.java │ ├── SignRecordController.java │ └── FaceAuthController.java ├── service # 业务逻辑距离判定、签到状态机 │ ├── SignService.java │ └── FaceAuthService.java ├── dao # MyBatis Mapper 接口 │ ├── StudentMapper.java │ ├── CourseMapper.java │ └── SignRecordMapper.java └── model # 实体类与 DTO ├── Student.java ├── Course.java └── SignRecord.javacontroller层只做参数接收和结果包装不写业务判断service层承载签到核心逻辑比如距离计算、人脸分数阈值判断dao层对数据库表的增删改查保持单一。这套结构的好处是替换实现成本低比如想从 MySQL 换到 PostgreSQL只需要动dao层的 SQLservice层完全不用碰。2.2 签到系统核心数据表这样设计不返工课堂签到的数据模型不大但容易漏字段。我见过不少源码把签到记录的经纬度只存成varchar导致后端没法做距离计算只能传给前端让前端算这是错误设计。定位和识别产生的数据都必须在服务端落库至少要包含下面三张核心表。第一张是学生表直接对接小程序的wx.login拿到的openidCREATE TABLE student ( id INT NOT NULL AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL COMMENT 学号, name VARCHAR(50) NOT NULL COMMENT 姓名, openid VARCHAR(64) NOT NULL COMMENT 微信openid, face_image TEXT COMMENT 人脸基准照片base64, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;第二张是签到记录表每次签到生成一条数据。这张表把地点、时间、人脸分数都冗余进来方便后面做考勤统计。CREATE TABLE sign_record ( id BIGINT NOT NULL AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, sign_time DATETIME NOT NULL COMMENT 签到时间, latitude DECIMAL(10,6) NOT NULL COMMENT 签到纬度, longitude DECIMAL(10,6) NOT NULL COMMENT 签到经度, distance DOUBLE DEFAULT NULL COMMENT 与教室距离单位米, face_score DOUBLE DEFAULT NULL COMMENT 人脸比对分数, status TINYINT DEFAULT 1 COMMENT 1正常 0异常, PRIMARY KEY (id), KEY idx_course_time (course_id, sign_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意latitude和longitude用DECIMAL(10,6)能精确到约 0.1 米同时索引开销比浮点类型可控。face_score和distance必须有否则事后没法审计某次签到到底是定位超了还是人脸没过。第三张表存课程与签到配置包括课程名、教室经纬度和签到有效半径CREATE TABLE course ( id INT NOT NULL AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, teacher_id INT NOT NULL, classroom_lat DECIMAL(10,6) NOT NULL COMMENT 教室纬度, classroom_lng DECIMAL(10,6) NOT NULL COMMENT 教室经度, sign_radius INT DEFAULT 150 COMMENT 签到半径单位米, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;sign_radius单独做成课程字段而不是写死在代码里是为了应对不同教室大小。阶梯教室可以放 200 米普通小教室 80 米就够后端只认这张表的配置。2.3 小程序端请求封装与后端对接小程序端不要在每个页面直接调wx.request否则后端接口地址改一个域名要全局替换。统一封装request.js把 baseURL、token 注入、错误码拦截都收敛到一处// utils/request.js const BASE_URL https://your-api.example.com function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: BASE_URL path, method: method, data: data, header: { Content-Type: application/json, Authorization: wx.getStorageSync(token) || }, success: (res) { if (res.data.code 200) { resolve(res.data.data) } else { wx.showToast({ title: res.data.msg, icon: none }) reject(res.data) } }, fail: (err) reject(err) }) }) } module.exports { request }这里Authorization头里带的 token 来自小程序端wx.login后换取的自定义登录态。后端在 SpringMVC 拦截器里校验 token 对应的openid再通过ThreadLocal传给 Service 层使用避免每个方法都传学生 ID。实际源码里如果看到 Controller 方法第一行都在查学生信息说明它的登录态设计有问题可以直接改成拦截器方案。3. 地图签到wx.getLocation 经纬度上报与后端半径判定3.1 小程序端定位获取与坐标系坑微信小程序里拿定位最直接的方式是wx.getLocation但这里有一个不绕开的坑返回的坐标系是gcj02国测局坐标不是 GPS 原始坐标也不等于高德/腾讯地图页面上的坐标。小程序内部已经做过一次偏移后端如果拿这份坐标和某张地图手动拾取的坐标做距离计算结果通常会偏几十到几百米。// pages/sign/sign.js 定位核心代码 wx.getLocation({ type: gcj02, isHighAccuracy: true, highAccuracyExpireTime: 4000, success: (res) { const latitude res.latitude const longitude res.longitude // 上报后端做距离判定 wx.request({ url: ${BASE_URL}/sign/check, method: POST, data: { latitude, longitude, courseId: this.data.courseId }, success: (res) { if (res.data.withinRange) { this.startFaceAuth() } } }) }, fail: (err) { if (err.errMsg.includes(auth deny)) { wx.showModal({ title: 需要定位权限, content: 请在设置中开启位置权限后再签到 }) } } })isHighAccuracy: true在真机上会调用 GPS 和网络混合定位定位精度能到 10 米以内但耗电明显签到是低频操作可以接受。fail回调里要区分两种失败用户拒绝授权和系统定位失败。授权失败引导去wx.openSetting定位失败则提示稍后重试不要把这两类混成同一个 toast。3.2 后端球面距离计算与签到半径判定拿到经纬度后不能直接用平面坐标距离公式sqrt((lat1-lat2)^2 (lng1-lng2)^2)算这个公式在 100 公里内误差尚可但课堂签到要求半径 100 米级别的判定纬度方向 0.0001 度约等于 11 米直接套平面公式在高纬度地区会有明显偏差。用 Haversine 公式算球面距离Java 实现如下// service/SignService.java private static final double EARTH_RADIUS 6371000; public double calculateDistance(double lat1, double lng1, double lat2, double lng2) { double radLat1 Math.toRadians(lat1); double radLat2 Math.toRadians(lat2); double a radLat1 - radLat2; double b Math.toRadians(lng1) - Math.toRadians(lng2); double s 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2))); return s * EARTH_RADIUS; }这个方法返回的是米。签到判定只需要一行if (distance course.getSignRadius())。注意course.getSignRadius()必须从数据库读课程配置不能在后端代码里写final int radius 100这种硬编码。我在实际项目中见过因为半径写死在代码里换教室后全体学生签不了到的情况。3.3 定位防作弊与异常签到兜底地图签到的天然缺陷是可以通过模拟定位作弊。安卓手机上开启开发者模式的模拟位置、iOS 上使用第三方定位修改工具都能让wx.getLocation返回任意坐标。常见做法是在后端加一道“时间与速度”约束正常两笔签到记录的物理距离不可能在几秒内跨越几十公里所以后端把最近一次签到的位置和时间缓存到 Redis新签到与之比较如果平均移动速度超过某个阈值就标记异常。另一个兜底是签到时间窗口。课程表里有start_time和end_time后端在check接口里先判断当前时间是否在窗口内提前签或延后签直接拒绝。很多源码把这层判断放在小程序端改一下系统时间就能绕过必须移到后端。// controller/SignRecordController.java PostMapping(/check) public MapString, Object checkSign(RequestBody SignCheckDTO dto) { Course course courseMapper.findById(dto.getCourseId()); Date now new Date(); if (now.before(course.getStartTime()) || now.after(course.getEndTime())) { return result(400, 不在签到时间范围内, null); } double distance calculateDistance( dto.getLatitude(), dto.getLongitude(), course.getClassroomLat(), course.getClassroomLng()); boolean within distance course.getSignRadius(); return result(200, within ? 定位有效 : 距离过远, within); }接口返回的distance建议同时回传给前端展示比如提示“您距教室 45 米”学生知道自己离教室多远才合理。这是体验细节但能明显减少“我明明在教室为什么签不了”的投诉。4. 人脸识别签到摄像头采集、特征比对与结果落库4.1 人脸识别方案选型云 API 还是本地模型课堂签到的人脸识别规模不大一个班几十人但并发集中在开课前五分钟可能出现几十个学生同时提交识别请求。备选方案里百度 AI 开放平台的人脸搜索、腾讯云人脸识别、本地 OpenCV Dlib 是三条主要路径对比如下方案接入成本精度成本适用场景百度 AI 人脸搜索低有 Java SDK高自带活体检测按调用量计费有免费额度中小规模追求省事腾讯云人脸识别低有 Java SDK高按调用量计费与腾讯云服务同栈OpenCV Dlib 自建高需自己训练/调参中光照敏感仅服务器成本离线环境、数据敏感课堂签到这类场景我一般推荐云 API。原因是需要维护人脸底库、处理活体检测防止拿照片冒充、处理光线变化这些本地模型要做到靠谱需要大量调优毕业设计和中小型系统的时间成本不划算。云 API 用法上大同小异下面以百度 AI 为例。4.2 小程序端人脸图像采集与压缩上传小程序端通过wx.createCameraContext调用摄像头采集到照片后不能直接把原始 base64 扔给后端。原生相机照片动辄几百 KB 到几 MB上传慢且浪费流量而且人脸识别接口对图片大小有限制通常要求在 10MB 以内但越小的图传输越快、识别越快。// pages/face/face.js 人脸采集核心代码 const ctx wx.createCameraContext() ctx.takePhoto({ quality: high, success: (res) { const tempFilePath res.tempImagePath // 先压缩再转base64 wx.compressImage({ src: tempFilePath, quality: 80, success: (compressRes) { wx.getFileSystemManager().readFile({ filePath: compressRes.tempFilePath, encoding: base64, success: (readRes) { const base64Data readRes.data this.uploadFace(base64Data) } }) } }) } })quality: high保证人脸细节不糊识别阶段不要用low会大幅降低比对分数。wx.compressImage的质量参数 80 是折中值压到 20 图片体积很小但五官细节丢失分数可能从 90 掉到 70。压缩后如果 base64 仍然超过 2MB建议直接用 canvas 裁剪人脸区域再转码减少背景干扰。采集完成后前端提醒学生正对摄像头、摘掉口罩和帽子然后调用后端的上传接口把 base64 图片传到服务端服务端再转发给人脸识别服务。4.3 后端人脸搜索接口与签到记录合并后端拿到 base64 图片后调用人脸识别服务的搜索接口在指定的学生人脸库中查找最高匹配者。以百度 AI 为例Java 侧用官方 SDK 的写法如下// service/FaceAuthService.java import com.baidu.aip.face.AipFace; import com.baidu.aip.face.FaceSearchResponse; import org.json.JSONObject; public FaceAuthResult faceSearch(String base64Image, String groupId) { AipFace client new AipFace(APP_ID, API_KEY, SECRET_KEY); JSONObject params new JSONObject(); params.put(image_type, BASE64); params.put(group_id_list, groupId); params.put(quality_control, HIGH); params.put(liveness_control, NORMAL); FaceSearchResponse res client.faceSearch(base64Image, params); if (res.getResult() null || res.getResult().getUserList().isEmpty()) { return FaceAuthResult.fail(未匹配到人脸); } double score res.getResult().getUserList().get(0).getScore(); String studentId res.getResult().getUserList().get(0).getUserId(); return new FaceAuthResult(studentId, score); }quality_control参数设为HIGH时会拒绝模糊、光照过暗的图片但同时会提高误拒率建议初期先用NORMAL跑通流程观察学生集中在哪些光线环境后再调成LOW保证通过率。liveness_control是活体检测必须开启否则拿一张打印照片就能冒充。识别成功拿到分数后不要直接落库还需要和工作流结合// 识别 定位 时间窗三重校验通过才落库 double threshold 80.0; if (faceAuthResult.getScore() threshold withinRange) { SignRecord record new SignRecord(); record.setStudentId(faceAuthResult.getStudentId()); record.setCourseId(dto.getCourseId()); record.setLatitude(dto.getLatitude()); record.setLongitude(dto.getLongitude()); record.setFaceScore(faceAuthResult.getScore()); signRecordMapper.insert(record); }分数阈值 80 是一个经验值。云人脸识别服务返回的 score 范围通常是 0-10080 以上基本可以确认是本人60-80 之间可能是本人但光线不佳也可能不是。阈值调低会放进来代签调高会导致部分学生反复刷脸失败。我一般建议先跑一周真实数据把分数分布拉出来取 10% 分位数作为阈值。5. 源码跑通后的三项真机实测加载页面、顶部导航与人脸阈值调整5.1 修改刚进入的加载页面不少拿到的源码默认第一个页面是首页或者课程列表但课堂签到的使用路径通常要求学生直接看到当日课程把启动页面改成签到页能减少一次点击。小程序启动页面由app.json里pages数组的第一项决定同时可以显式声明entryPagePath{ pages: [ pages/course/course, pages/sign/sign, pages/face/face ], entryPagePath: pages/course/course }注意只有tabBar里的页面可以直接冷启动进入非 tabBar 页面从entryPagePath进入后没有返回按钮。如果课程页已经在 tabBar 中直接调整pages顺序即可。改完重新编译模拟器里可能看不出区别因为工具会缓存建议清缓存后重新编译验证。5.2 真机定位不返回结果的处理顺序模拟器上wx.getLocation基本秒回但真机上经常出现一直在 loading 的情况。按这个顺序排查第一步确认小程序后台的“位置接口”权限已申请也就是要调用wx.authorize({ scope: scope.userLocation })第二步检查真机上是否开启了系统定位服务iOS 还需要确认隐私设置里给微信开放了位置权限第三步确认没有把type配成wgs84却又在代码里按gcj02做偏移坐标类型不一致会让距离判定结果飘忽不定。定位接口返回后建议在页面上实时显示“当前定位精度xx米”wx.getLocation的返回值里有accuracy字段。定位精度超过 100 米时提示学生走到窗边或开阔处再试与其让后端静默拒绝不如把原因直接展示给用户。5.3 人脸阈值与活体检测的真机边界人脸识别链路的最后一步是在真机上验证阈值和活体策略。测试时要覆盖三种典型场景教室前排正常光照、靠窗逆光、口罩半摘状态。逆光时人脸区域过暗云 API 的quality_control会提示图片质量不达标这不是阈值问题需要在前端提示学生调整方向而不是调低识别分数。活体检测在廉价安卓机上偶尔会卡顿两三秒交互上要做成“识别中请保持不动”的状态避免学生误以为程序死机反复退出重进。另外注意人脸识别服务和后端服务器如果不在同一地域图片传输耗时可能达到 1 秒以上建议把签到接口的超时时间设置为 10 秒以上并把connection超时与read超时分开设置。把识别结果打印到后端日志里每一次请求记录耗时、分数、命中的学生 ID后续调阈值和排查问题都靠这份日志。本文还有配套的精品资源点击获取
返回列表