简介:本资源是一套基于Java与安卓UniApp的WiFi和GPS双定位学生课程考勤管理系统毕业设计源码,面向计算机、软件工程、通信工程等专业的在校学生与教师,可用于毕业设计、课程设计、作业或项目立项演示。项目采用SSM后端与UniApp跨端前端,通过WiFi与GPS双重定位实现课堂考勤签到,解决传统点名效率低、代签难防的问题。压缩包共455个文件,以270个js与112个vue为核心业务代码,辅以scss、jsx、css等样式与组件文件,另有sql数据库脚本、md使用文档及少量图片与配置文件,整体约1019KB,结构清晰便于二次开发。已有98人学习关注,作者为qq_21531681。该资源已通过Mac与Windows10/11运行测试,答辩评审95分,读者可获取完整源码、数据库与使用文档,快速理解双定位考勤的实现思路,并在此基础上修改扩展功能,适合作为高分毕设参考与进阶学习素材。
1. 双定位考勤系统:从 WiFi 指纹到 GPS 围栏的完整落地
学生考勤这件事,看起来只是「点个到」,真做起来才知道坑有多深。纯 GPS 在室内直接飘到几百米外,纯 WiFi 又容易被热点名伪造,所以这套基于 Java + 安卓 uniapp 的课程考勤管理系统,走的是 WiFi 指纹 + GPS 地理围栏双通道校验的路子:服务端用 SSM 记录每个教室的 BSSID 白名单和经纬度半径,客户端在 uniapp 里同时采集两类信号,任一通道命中且时间窗口合法才判定有效签到。它解决的是「代签、异地签、室内定位不准」这三个毕业设计里最常被导师追问的痛点,适合做课设、毕设,也适合想学移动端定位落地的同学拿来拆。整套资源包含后端源码、安卓端、数据库脚本和使用文档,下面按「是什么 → 怎么跑 → 坑在哪」的顺序拆开讲。
2. 双定位校验的技术选型:为什么不是单 GPS 也不是单 WiFi
2.1 WiFi 指纹与 GPS 围栏各自的边界
先说清楚为什么非得两个一起上。GPS 的强项是室外开阔环境,民用模块水平误差常见在 5~15 米,但一进教学楼,钢筋混凝土一挡,误差能飙到 50 米以上,甚至直接返回上一次的缓存坐标,这就是热词里说的「gps误差」。WiFi 定位靠的是 BSSID(路由器 MAC 地址)唯一性,室内精度反而好,一个教室对应几个固定 AP,扫到的 BSSID 集合匹配上就算命中,但它怕两件事:一是热点名可以伪造,二是安卓从 6.0 起对 WiFi 扫描加了频率限制和定位权限绑定。
所以这套系统的判定逻辑是「或」关系而非「与」:GPS 在半径内,或者 WiFi 指纹匹配成功,任一成立即通过。这样室内靠 WiFi 兜底,室外靠 GPS 兜底,单点失效不会让整个签到瘫掉。选型上后端用 SSM(Spring + SpringMVC + MyBatis)而不是 SpringBoot,是因为毕设答辩时导师更认传统三层架构,配置显式、分层清晰,改起来也好定位。
2.2 数据库表结构与定位字段设计
定位相关的核心表就三张:教室表存基准坐标和半径,AP 表存 BSSID 白名单,签到记录表存每次采集的原始信号。字段设计直接决定后面判定好不好写。
-- 教室表:一个教室一个地理围栏 CREATE TABLE classroom ( id INT PRIMARY KEY AUTO_INCREMENT, room_name VARCHAR(50) NOT NULL, latitude DECIMAL(10,7) NOT NULL, -- 基准纬度,7位小数约1cm精度 longitude DECIMAL(10,7) NOT NULL, -- 基准经度 radius INT DEFAULT 80, -- 允许半径(米),室内建议50-100 status TINYINT DEFAULT 1 ); -- AP白名单表:一个教室可挂多个路由器 CREATE TABLE ap_whitelist ( id INT PRIMARY KEY AUTO_INCREMENT, classroom_id INT NOT NULL, bssid VARCHAR(17) NOT NULL, -- 格式 AA:BB:CC:DD:EE:FF ssid VARCHAR(64), FOREIGN KEY (classroom_id) REFERENCES classroom(id) ); -- 签到记录表:保留原始信号便于申诉复核 CREATE TABLE attendance_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, check_time DATETIME NOT NULL, latitude DECIMAL(10,7), longitude DECIMAL(10,7), matched_bssid VARCHAR(17), locate_type TINYINT, -- 1=GPS 2=WiFi 3=双命中 distance INT, -- 距基准点米数,便于排查 status TINYINT DEFAULT 1 );latitude/longitude用DECIMAL(10,7)而不是FLOAT,是因为浮点在距离计算里会累积误差,7 位小数对应约 1 厘米,足够围栏判定。radius默认 80 米是室内场景的经验值,太小会误杀站在教室门口的学生,太大又失去约束意义。attendance_record特意保留matched_bssid和distance,答辩时被问「凭什么判定他在教室」可以直接调记录自证。
2.3 后端判定接口的实现
判定逻辑集中在签到接口里,先算 GPS 距离,再查 WiFi 白名单,最后落库。
// AttendanceController.java 核心签到逻辑 @RequestMapping(value = "/checkIn", method = RequestMethod.POST) @ResponseBody public Map<String, Object> checkIn(@RequestBody CheckInDTO dto) { Map<String, Object> result = new HashMap<>(); Classroom room = classroomService.getById(dto.getClassroomId()); boolean gpsPass = false; boolean wifiPass = false; int distance = -1; // 1. GPS 围栏判定:Haversine 公式算球面距离 if (dto.getLatitude() != null && dto.getLongitude() != null) { distance = GeoUtil.getDistance( room.getLatitude(), room.getLongitude(), dto.getLatitude(), dto.getLongitude()); gpsPass = distance <= room.getRadius(); } // 2. WiFi 指纹判定:扫到的 BSSID 是否在白名单 if (dto.getBssidList() != null && !dto.getBssidList().isEmpty()) { List<String> white = apService.listBssidByRoom(dto.getClassroomId()); for (String bssid : dto.getBssidList()) { if (white.contains(bssid.toUpperCase())) { wifiPass = true; dto.setMatchedBssid(bssid); break; } } } // 3. 任一通过即有效,记录定位类型 if (gpsPass || wifiPass) { AttendanceRecord rec = new AttendanceRecord(); rec.setStudentId(dto.getStudentId()); rec.setCheckTime(new Date()); rec.setDistance(distance); rec.setMatchedBssid(dto.getMatchedBssid()); rec.setLocateType(gpsPass && wifiPass ? 3 : (gpsPass ? 1 : 2)); attendanceService.save(rec); result.put("code", 200); result.put("msg", "签到成功"); } else { result.put("code", 400); result.put("msg", "不在考勤范围,GPS距离" + distance + "米"); } return result; }GeoUtil.getDistance用的是 Haversine 公式,比直接算平面欧氏距离准,尤其在纬度较高地区。locateType字段区分三种命中方式,方便后期统计「到底有多少人靠 WiFi 兜底」。注意bssid.toUpperCase()这一步不能省,安卓不同厂商返回的大小写不一致,血泪经验,不统一大小写会出现「明明连上了却匹配不上」的玄学问题。
3. uniapp 安卓端定位采集:权限、扫描与上报
3.1 安卓定位权限的申请顺序
安卓端最容易被卡住的就是权限。从 Android 6.0 起定位是运行时权限,10 以后又拆出后台定位,12 之后 WiFi 扫描还必须开定位开关,否则getScanResults直接返回空列表。正确顺序是先申请ACCESS_FINE_LOCATION,再判断系统定位总开关是否打开,最后才发起扫描。
// uniapp 中申请定位权限并检查开关 async function ensureLocationReady() { // 1. 申请精确定位权限 const auth = await new Promise((resolve) => { plus.android.requestPermissions( ['android.permission.ACCESS_FINE_LOCATION', 'android.permission.ACCESS_COARSE_LOCATION'], (e) => resolve(e.granted), (e) => resolve([]) ); }); if (!auth || auth.length === 0) { uni.showToast({ title: '请授予定位权限', icon: 'none' }); return false; } // 2. 检查系统定位总开关(Android 12+ 必须) const main = plus.android.runtimeMainActivity(); const context = main.getApplicationContext(); const locationManager = context.getSystemService('location'); const enabled = locationManager.isLocationEnabled(); if (!enabled) { uni.showToast({ title: '请打开手机定位开关', icon: 'none' }); return false; } return true; }requestPermissions的回调里granted是已授权数组,长度为零说明被拒。第二步的isLocationEnabled是 Android 12 之后判断定位总开关的推荐方式,比读Settings.Secure.LOCATION_MODE更稳。很多同学只做了第一步,结果在 Android 12 手机上扫描永远为空,翻车就翻在这。
3.2 WiFi 扫描与 GPS 坐标采集
权限过了之后,WiFi 扫描和 GPS 采集可以并行发起,减少等待时间。
// 采集 WiFi 列表 + GPS 坐标,统一上报 async function collectAndReport(courseId, classroomId) { const payload = { courseId, classroomId, bssidList: [] }; // WiFi 扫描:注意 Android 9+ 有节流,两次扫描间隔需 > 30s const wifiList = await new Promise((resolve) => { const main = plus.android.runtimeMainActivity(); const ctx = main.getApplicationContext(); const wifiManager = ctx.getSystemService('wifi'); const list = wifiManager.getScanResults(); const arr = []; for (let i = 0; i < list.size(); i++) { const item = list.get(i); arr.push(item.BSSID); // 只取 MAC,不取 SSID 避免中文乱码 } resolve(arr); }); payload.bssidList = wifiList; // GPS 坐标:用 uni.getLocation,type 选 gcj02 与后端坐标系一致 const loc = await new Promise((resolve, reject) => { uni.getLocation({ type: 'gcj02', success: resolve, fail: reject }); }); payload.latitude = loc.latitude; payload.longitude = loc.longitude; // 上报后端 return uni.request({ url: BASE_URL + '/attendance/checkIn', method: 'POST', data: payload }); }getScanResults返回的是最近一次系统扫描的缓存,不是实时扫描,所以如果刚开机可能为空,常见做法是先调startScan()再延迟 1~2 秒取结果。type: 'gcj02'必须和后端存的坐标系一致,如果后端存的是 WGS84 而前端传 gcj02,会有几十到上百米的系统性偏移,这也是「gps误差」里最隐蔽的一种。只取 BSSID 不取 SSID,是因为部分安卓机 SSID 含中文会乱码,而判定根本用不到 SSID。
3.3 上报数据的字段对照
前端 payload 和后端 DTO 字段必须一一对应,错一个就 400。对照关系如下:
| 前端字段 | 后端 DTO 字段 | 类型 | 说明 |
|---|---|---|---|
| courseId | courseId | Integer | 课程 ID |
| classroomId | classroomId | Integer | 教室 ID |
| bssidList | bssidList | List<String> | 扫描到的 MAC 列表 |
| latitude | latitude | Double | gcj02 纬度 |
| longitude | longitude | Double | gcj02 经度 |
字段名大小写敏感,bssidList写成bssidlist后端接不到,这种低级错误在联调时最耗时间,建议前后端字段表先对齐再写代码。
4. 避坑与排查:双定位系统最常见的五个翻车点
4.1 现象:室内 GPS 飘到几百米外,签到被判无效
原因:安卓在室内拿不到卫星信号时,会返回上一次定位缓存或基站粗定位,精度字段accuracy可能高达几百米。解决:在uni.getLocation的 success 回调里读loc.accuracy,大于 100 米直接丢弃 GPS 结果,只走 WiFi 判定,别让脏数据污染围栏计算。
4.2 现象:WiFi 扫描列表为空,明明连着路由器
原因:Android 10 以后getScanResults需要定位权限且系统定位开关打开,缺一不可;另外部分机型两次扫描间隔小于 30 秒会返回空。解决:先跑 3.1 的ensureLocationReady,再在扫描前调startScan()并延迟取结果,别直接读缓存。
4.3 现象:BSSID 匹配不上,白名单里明明有
原因:安卓返回的 BSSID 大小写不统一,有的机型返回小写,数据库存的是大写。解决:后端比对前统一toUpperCase(),前端上报时也统一转大写,两头都做,别只做一头。
4.4 现象:GPS 坐标对不上,整体偏移几十米
原因:前端用 gcj02,后端存 WGS84,坐标系不一致。解决:全链路统一坐标系,要么都用 gcj02,要么后端入库前做一次转换,别混用。这个坑最隐蔽,因为偏移量不大,容易误判成「GPS 精度问题」。
4.5 现象:签到接口 400,后端收不到 bssidList
原因:uniapp 的uni.request默认content-type是application/json,但如果后端用@RequestParam接数组,格式对不上。解决:后端用@RequestBody接 DTO,前端保持 JSON 提交,两边对齐。联调时先看浏览器或抓包工具的原始请求体,比猜快得多。
5. 进阶技巧:把定位判定做成可配置的评分制
前面讲的「或」判定简单直接,但答辩时容易被追问「万一有人站在两个教室中间呢」。更稳的做法是把硬判定改成评分制:GPS 距离越近分越高,WiFi 命中数越多分越高,总分过阈值才算有效。这样既能处理边界情况,也显得系统有设计深度。
// 评分制判定:GPS 距离分 + WiFi 命中分 public int calcScore(Classroom room, CheckInDTO dto) { int score = 0; // GPS 分:半径内满分 60,超出按距离衰减 if (dto.getLatitude() != null) { int dist = GeoUtil.getDistance(room.getLatitude(), room.getLongitude(), dto.getLatitude(), dto.getLongitude()); if (dist <= room.getRadius()) { score += 60; } else if (dist <= room.getRadius() * 2) { score += 60 - (dist - room.getRadius()) / 2; // 每超1米扣0.5分 } } // WiFi 分:每命中一个白名单 AP 加 20,上限 40 if (dto.getBssidList() != null) { List<String> white = apService.listBssidByRoom(room.getId()); int hit = 0; for (String b : dto.getBssidList()) { if (white.contains(b.toUpperCase())) hit++; } score += Math.min(hit * 20, 40); } return score; }阈值建议设在 60 分:单靠 GPS 命中就是 60 分刚好过,单靠 WiFi 命中两个 AP 是 40 分不够、三个才 60 分,逼着室内场景多扫几个 AP,反而更可靠。这套评分逻辑改起来只动一个方法,不影响表结构,答辩演示时还能现场调阈值展示灵活性。
验证方法很简单:拿两台手机,一台站在教室中心,一台站在走廊尽头,分别签到看分数差异;再把其中一台的 GPS 关掉只留 WiFi,确认兜底通道生效。我一般会在attendance_record里加一列score存下每次得分,事后统计分布,如果大量记录卡在 55~65 分之间,说明阈值或半径需要调。
从那以后我每次做定位类项目,都强制先把坐标系和权限顺序在真机上跑通再写业务逻辑,不然联调阶段全是玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取