ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java+SSM+UniApp双定位考勤系统:WIFI指纹与GPS防代签方案

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. WIFI+GPS双定位课程考勤系统:用java+SSM+UniApp把代签堵死在门口

每个学期末,总有那么几个在宿舍就能把自己的考勤“满勤”交上去的同学。纸质签到可以代签,蓝牙打卡可以被中继,纯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 Map<String, Object> check(@RequestBody CheckInParam param) { Map<String, 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桥接到原生WifiManager

WIFI信息里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.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/attendance_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456

serverTimezone=Asia/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 双端联调:从第一个登录请求到第一条考勤记录落库

后端和前端都启动后,按顺序走一遍完整流程:

  1. 打开安卓端App,先在登录页用预置的测试账号登录;
  2. 进入课程列表页,能看到当前考勤时间内的课程;
  3. 点击“签到”按钮,前端会依次调用getWifiInfo和getGpsLocation;
  4. 后端接口返回code: 200,前端跳转签到成功页;
  5. 打开数据库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连接串上加serverTimezone=Asia/Shanghai(前面已提到),同时在Tomcat启动参数里加-Duser.timezone=Asia/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_type=2(双通道命中);然后关闭WIFI再用GPS签一次,展示check_type=1。最后一句话可以这样收:“双定位不是把两个定位方式相加,而是让两个独立信号源互相校验,任何一条通道异常时,另一条还能兜底。”

从那以后我每次帮人调这个考勤项目,都会先检查三件事:权限是否申请、时区是否统一、热点MAC是否录全。这三步走完,大部分签到问题都能在十分钟内定位。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表