Java+SSM+UniApp双定位考勤系统:WIFI指纹与GPS防代签方案 简介一份基于Java SSM与Android Uniapp框架的学生课程考勤管理系统毕业设计源码核心通过WiFi与GPS双定位完成课堂签到与考勤统计适用于计算机、软件工程、自动化等专业学生的毕设或课设场景。压缩包共455个文件、约1019KB内容以270个js和112个vue构成的前端逻辑与页面为主配合15个scss及11个css负责样式2个sql脚本用于数据库初始化md与txt文档辅助快速部署和理解工程结构。系统已在mac及Windows 10/11环境测试运行通过答辩评审分达95分课程管理、位置核验、签到记录等模块实现完整可在此基础上二次开发或直接用于答辩演示。已有98人浏览学习对需要一套可运行考勤系统方案的人群具备较高参考价值。1. WIFIGPS双定位课程考勤系统用javaSSMUniApp把代签堵死在门口每个学期末总有那么几个在宿舍就能把自己的考勤“满勤”交上去的同学。纸质签到可以代签蓝牙打卡可以被中继纯GPS定位又常常被室内信号拖后腿——隔着一堵墙误差就能飙到几十米。这份毕业设计把方案换成了两层校验Java后端用SSM搭出考勤规则安卓端用UniApp同时采集WiFi热点指纹和GPS坐标两个信号源必须至少有一个命中教室范围内的考勤点才算一次有效签到。它面向的是做Java毕业设计、课程设计、二次开发练手的同学拿到手就能装库、编译、跑通签到闭环后端的判定逻辑和前端定位采集都留有清晰的改动点。2. SSM后端与数据库设计四张核心表把考勤闭环串起来2.1 SSM三层怎么分工Controller只做入口Service只做规则判定这个系统的后端是典型SSM组合Spring管对象、SpringMVC管请求路由、MyBatis管SQL。我拆过不少Java毕业设计源码最容易翻车的不是代码写不出来而是三层职责混在一起——判断逻辑写在Controller里、SQL拼在Service里后期改一个签到规则要动三个文件。这个项目里三层分得很干净Controller层只接收前端POST上来的签到参数调用Service把结果包成JSON返回Service层负责全部考勤业务查当前课程考勤规则、比对WIFI和GPS坐标、写入考勤记录Mapper层通过MyBatis操作数据库一个方法对应一条SQL不掺业务判断。这样的分层对于课程设计和毕业设计都有实际意义答辩老师问“如果我想加一个每日签到码改哪里”你可以直接回答“只动Service层新增一个校验Controller和Mapper不动”。这也是SSM项目在答辩环节的常规加分点代码结构本身就是可讲的设计亮点。2.2 数据库表设计考勤规则表与考勤记录表是理解全项目的钥匙数据库脚本在资源包的SQL文件夹里导入MySQL后能看到完整的库表结构。整个库核心表不多四张表把角色、课程、规则、记录串成一条完整的考勤链路。重点看下面两张表其余几张要么是基础信息表要么是中间关联表。表名关键字段设计意图attendance_rulecourse_id, latitude, longitude, radius, allow_bssids, start_time, end_time配置课程考勤点GPS经纬度、允许范围半径米、允许的热点MAC地址列表、签到时间窗口attendance_recordstudent_id, course_id, check_time, check_type, latitude, longitude, bssid, status记录每次签到结果定位来源类型、上报坐标、命中的热点MAC、考勤状态studentstudent_no, name, class_name学生基础信息考勤统计按班级分组时依赖这里coursecourse_id, course_name, teacher_id课程基础信息关联考勤规则allow_bssids的存储格式是逗号分隔的字符串一个教室如果有多台路由器就一次性把几个MAC地址都写进去。这个字段我建议实际使用时不要只存一个理由后面避坑章节会展开。2.3 签到判定接口实现Haversine距离与WIFI指纹双通道后端最核心的代码是签到判定逻辑入口在AttendanceController的/attend/check接口。下面这段代码可以直接对照资源包里的Controller看Controller RequestMapping(/attend) public class AttendanceController { Autowired private AttendanceService attendanceService; RequestMapping(value /check, method RequestMethod.POST) ResponseBody public MapString, Object check(RequestBody CheckInParam param) { MapString, Object result new HashMap(); // 1. 查询该课程当前生效的考勤规则 AttendanceRule rule attendanceService.getCurrentRule(param.getCourseId()); if (rule null) { result.put(code, 400); result.put(msg, 当前课程未开启考勤); return result; } // 2. 调用双通道定位判定 int type attendanceService.matchLocation(param, rule); if (type 0) { result.put(code, 401); result.put(msg, 不在考勤范围内); return result; } // 3. 判定通过写入考勤记录 attendanceService.saveRecord(param, rule, type); result.put(code, 200); result.put(type, type); return result; } }参数CheckInParam里包含courseId、studentId、bssid、latitude、longitude、timestamp。前端把WIFI信息和GPS坐标一起POST上来后端只认这一份数据再做交叉校验。Service层的matchLocation是双定位的核心判定逻辑只有二十多行public int matchLocation(CheckInParam param, AttendanceRule rule) { // 1. WIFI通道比对BSSID是否在规则的允许列表里 boolean wifiOk false; String bssid param.getBssid(); if (bssid ! null rule.getAllowBssids() ! null) { for (String allow : rule.getAllowBssids().split(,)) { if (allow.trim().equalsIgnoreCase(bssid)) { wifiOk true; break; } } } // 2. GPS通道计算与考勤点的实际距离 boolean gpsOk false; if (param.getLatitude() ! null param.getLongitude() ! null) { double distance haversine( param.getLatitude(), param.getLongitude(), rule.getLatitude(), rule.getLongitude() ); gpsOk distance rule.getRadius(); } // 3. 组合判定结果 if (wifiOk gpsOk) return 2; // 双通道都命中 if (wifiOk) return 0; // 仅WIFI命中 if (gpsOk) return 1; // 仅GPS命中 return -1; // 都不命中 }这里有个细节WIFI通道优先看BSSID而不是SSID。SSID是热点名称教学楼里可能出现十个同名热点BSSID是路由器物理地址教室和教室之间几乎不可能重复。所以规则表里存MAC地址是更可靠的匹配方式。距离计算用Haversine公式处理经纬度的球面距离。我见过有同学直接把经纬度当平面坐标算教室附近还能用换到高纬度地区误差就放大了。这个项目用的是规范球面距离算法private double haversine(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.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }radius在数据库里默认是100米。教室场景下我一般建议调成80到120米之间太小了GPS一漂移就误判太大了隔壁教室也能签进来。3. UniApp前端定位采集WIFI指纹和GPS坐标的合并上报3.1 为什么前端选UniApp而不是原生Android如果是纯安卓原生开发WIFI和定位的API都好拿但意味着要写Java或Kotlin的Activity、要自己处理Android版本适配。UniApp的优势是同一套Vue代码能编译到安卓App、iOS App、H5三端对毕业设计来说开发和演示都省事。代价是原生能力不能直接调用得靠plus对象桥接到系统API。这个项目里WIFI信息获取就是典型例子——UniApp没有封装直接拿BSSID的标准接口常见做法是引入原生插件或者用plus.android导入安卓类来拿。资源包里的前端代码走的是后者不依赖额外插件少一个需要配置的环节。3.2 WIFI指纹获取用plus.android桥接到原生WifiManagerWIFI信息里SSID相对好拿但BSSID需要从android.net.wifi.WifiManager的getConnectionInfo()里取。下面是资源包前端代码的核心函数function getWifiInfo() { if (typeof plus undefined) { return Promise.reject(new Error(仅在App环境支持WIFI采集)); } return new Promise((resolve, reject) { // 1. 导入Android原生类 const main plus.android.runtimeMainActivity(); const Context plus.android.importClass(android.content.Context); const WifiManager plus.android.importClass(android.net.wifi.WifiManager); // 2. 通过系统服务拿到WifiManager实例 const wifiManager main.getSystemService(Context.WIFI_SERVICE); // 3. 取当前连接热点的连接信息 const wifiInfo wifiManager.getConnectionInfo(); const bssid wifiInfo.getBSSID(); if (!bssid) { reject(new Error(未获取到BSSID请检查WIFI是否连接)); return; } resolve({ ssid: wifiInfo.getSSID(), bssid: bssid, rssi: wifiInfo.getRssi() }); }); }在plus.android.importClass这行上Context.WIFI_SERVICE是一个字符串常量原样传进getSystemService即可。getRssi()返回信号强度单位是dBm正常范围在-30到-90之间。后端判定时没有用RSSI作为依据只把BSSID当作身份标志这能让判定逻辑更稳定——同一台手机在教室不同位置RSSI波动很大但BSSID不会变。3.3 GPS坐标与上报参数组装坐标系统一是第一个坑GPS坐标用UniApp内置的uni.getLocation获取常规写法是这样function getGpsLocation() { return new Promise((resolve, reject) { uni.getLocation({ type: gcj02, isHighAccuracy: true, highAccuracyExpireTime: 3000, success: (res) resolve({ latitude: res.latitude, longitude: res.longitude, accuracy: res.accuracy }), fail: (err) reject(err) }); }); }type: gcj02是国测局坐标国内地图和高德、腾讯都默认用这套。如果你的后端考勤规则表里录的坐标是直接从在线地图里点的那大概率也是gcj02保持一致就对了。如果坐标来源不统一比如前端拿WGS84、后端录GCJ02传上去的距离直接就偏出一大截。这个坑我在这里先标记一下后面避坑章节有完整记录。签到上报函数把两份定位数据合到一个请求里同时做了超时容错async function doCheckIn(courseId, studentId) { // 1. 先取WIFI再取GPS两个通道必须都尝试 const wifiInfo await getWifiInfo(); const location await getGpsLocation(); // 2. 组装后端需要的完整参数 const payload { courseId: courseId, studentId: studentId, bssid: wifiInfo.bssid, latitude: location.latitude, longitude: location.longitude, timestamp: Date.now() }; // 3. 发送考勤请求 return new Promise((resolve, reject) { uni.request({ url: APP_BASE /attend/check, method: POST, data: payload, header: { Content-Type: application/json }, success: (res) resolve(res.data), fail: (err) reject(err) }); }); }注意uni.request本身不返回Promise这里手动包了一层避免调用方出现难排查的undefined错误。APP_BASE是全局配置的接口前缀不同运行环境下取值不同下一章说怎么配置。4. 把毕业设计源码跑起来数据库导入、HBuilderX打包与双端联调4.1 后端启动MySQL建库与JDBC时区配置先把源码里的SQL脚本导入MySQL。资源包里数据库文件在sql目录下我用命令行导入的习惯是分两步走先建库再导表mysql -u root -p -e CREATE DATABASE attendance_system DEFAULT CHARACTER SET utf8mb4 mysql -u root -p attendance_system attendance_system.sql用utf8mb4而不是utf8是为了让签到备注里出现生僻字或表情时不报编码错误。这一步做完可以用Navicat或DataGrip连上看一眼表结构确认四张核心表都建出来了。后端是SSM项目JDBC连接配置在jdbc.properties里。需要改的是数据库的地址、账号、密码外加一个经常被忽略的时区参数jdbc.drivercom.mysql.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/attendance_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.password123456serverTimezoneAsia/Shanghai这个参数不加Java 8以上版本连MySQL 8时会出现时区报错或时间偏差8小时。我遇到不止一个人在这里卡住以为是代码问题调了一整天最后发现是时区参数少了。数据库导入后把项目导入IDEA或Eclipse配置Tomcat 8.5或9.0启动即可。后端默认跑在8080端口。4.2 前端运行HBuilderX导入和真机/模拟器切换前端是UniApp项目用HBuilderX打开即可。不需要用Android Studio编译HBuilderX自带真机运行和模拟器运行能力。第一次跑起来大概率遇到接口地址不通因为前端和后端不在同一个设备上。前端的接口前缀配置写在公共配置文件里常见做法是用编译环境区分地址const BASE_URL_MAP { H5: http://localhost:8080, // 浏览器里调试用 ANDROID: http://10.0.2.2:8080, // Android模拟器访问宿主机 APP: http://192.168.1.100:8080 // 真机运行时填电脑的局域网IP }; export const APP_BASE BASE_URL_MAP[process.env.UNI_PLATFORM] || BASE_URL_MAP.APP;这里有个很多新手栽过的坑如果是在Android模拟器里跑访问宿主机不能用localhost或127.0.0.1要写成10.0.2.2。如果是真机要填电脑的局域网IP并且手机和电脑连同一个WIFI。运行环境不同process.env.UNI_PLATFORM会自动切换不需要每次手动改。4.3 双端联调从第一个登录请求到第一条考勤记录落库后端和前端都启动后按顺序走一遍完整流程打开安卓端App先在登录页用预置的测试账号登录进入课程列表页能看到当前考勤时间内的课程点击“签到”按钮前端会依次调用getWifiInfo和getGpsLocation后端接口返回code: 200前端跳转签到成功页打开数据库attendance_record表确认出现一条记录。如果某一步没通过优先看后端控制台日志。签到接口抛异常时日志会直接打印异常栈排查效率最高。联调成功一个完整流程后再去做双通道测试关掉WIFI只留GPS或者关掉GPS只留WIFI各签一次看check_type字段分别记录为1和0。5. 避坑实录模拟器能跑、真机却签不了问题出在哪5.1 WIFI的BSSID在真机上总是null现象HBuilderX内置模拟器里能拿到BSSID安装到Android真机上后getWifiInfo返回的bssid一直是null签到永远失败。原因Android 6.0之后应用扫描WIFI需要动态申请定位权限Android 9之后还会要求精确位置权限。plus.android直接调用WifiManager.getConnectionInfo()时权限不到位拿到的就是空值。解决在App启动时用uni.authorize主动申请ACCESS_FINE_LOCATION权限再到系统设置里确认定位服务已开启。更稳的做法是在getWifiInfo里加一层权限检查没有权限时提示用户去设置页手动打开而不是静默返回null。5.2 GPS坐标在教室内外反复跳40米现象同一间教室、同一台手机、同一个座位上午签到距离考勤点18米下午签到变成57米有时候直接判定不在范围内。原因教室属于室内环境GPS信号受天花板和墙体遮挡严重冷启动和热启动的定位精度差异明显。另外如果前端取坐标用的坐标系和后端规则表录入用的坐标系不一致误差更大。解决半径阈值不要设成50米教室场景下常见做法是80到120米。同时统一坐标系——用同一台手机、同一个uni.getLocation接口去录制考勤点坐标而不是去在线地图手动点一个位置。自己录的坐标和自己的判定逻辑在同一个参考系里偏差才能控制住。5.3 换了路由器整个班的签到全部失效现象教学楼网络改造后原来能正常签到的同学全部返回“不在考勤范围内”。原因attendance_rule表里allow_bssids字段只存了旧路由器的MAC地址。手机连上新热点后BSSID变了WIFI通道必然失败。这个属于典型的静态配置数据没跟着硬件变更走。解决把这间教室可能用到的备用路由器的MAC一起录进allow_bssids用逗号分隔存储。后端解析时已经是遍历比对逻辑多存几个MAC不影响性能。另外我一般会在管理端留一个“重新采集当前教室WIFI”的调试接口方便现场快速更新规则而不是改数据库。5.4 学生明明在考勤时间内签到记录却显示缺勤现象学生上午8点05分签到成功后台考勤记录却是12点整被统计成了缺勤。原因MySQL的datetime字段存进去的时间正常但Java后端读取时默认用了JVM所在服务器的时区。如果服务器时区是GMT差8小时就出现了。解决JDBC连接串上加serverTimezoneAsia/Shanghai前面已提到同时在Tomcat启动参数里加-Duser.timezoneAsia/Shanghai双保险。代码里接收时间字段统一用LocalDateTime不要用Date裸接避免时区二次转换。5.5 同一学生一节课出现三条考勤记录现象签到高峰期某个学生在attendance_record表里反复出现多条记录出勤率统计时被重复计数。原因Controller里没有幂等判断。学生第一次点签到没看到页面跳转以为失败又点了一次后端每次请求都执行插入记录就重复了。解决给attendance_record表的(student_id, course_id, check_date)加唯一索引。签到接口里先按学号、课程、日期查一次已存在就直接返回“本次已签到”不重复写入。用数据库唯一约束兜底比代码里只做if判断更可靠。6. 二次开发与答辩验收把公共毕设改成自己的加分项6.1 给签到接口加一个动态签到码基础版系统是纯定位签到答辩时容易被问“如果两个学生故意交换手机怎么防”。一个低成本的改造是加每日动态签到码教师开课时生成4位随机码随考勤规则一起下发学生签到时必须回传。改动点只有两处attendance_rule表加一个daily_code字段Service层签到前比对传入的code。整个改造不超过半小时但答辩时展示的是一个有独立思路的防代签机制。6.2 答辩演示的验证脚本演示前先确认教室有WIFI热点并且这个热点已录入考勤规则表。演示顺序建议这样走打开学生端登录 → 选课程 → 点击签到 → 当场打开attendance_record表给评委看check_type2双通道命中然后关闭WIFI再用GPS签一次展示check_type1。最后一句话可以这样收“双定位不是把两个定位方式相加而是让两个独立信号源互相校验任何一条通道异常时另一条还能兜底。”从那以后我每次帮人调这个考勤项目都会先检查三件事权限是否申请、时区是否统一、热点MAC是否录全。这三步走完大部分签到问题都能在十分钟内定位。希望帮到你。本文还有配套的精品资源点击获取