uni-app + Spring Boot 实现 WiFi/蓝牙双因子考勤:设备配置、距离校验和打卡证据链
很多考勤系统一开始只做 GPS 定位,结果上线后争议反而变多:员工明明在公司,系统提示“不在范围”;办公楼里定位漂移,主管说不清到底是不是误判;门店和仓库只有几十米距离,地图点位一偏就跨区。
这类问题不能只靠“把定位半径调大”。半径一大,误判少了,但考勤可信度也下降。更稳的设计是把现场证据拆成多层:GPS 解决“在哪个区域”,WiFi 解决“连的是不是这个场所的网络”,蓝牙解决“是不是靠近这个工位或设备点”。三类证据合在一起,才更像一条能解释的考勤证据链。
这篇文章基于智慧考勤项目真实代码做一次脱敏整理,重点看 WiFi 和蓝牙设备如何参与打卡判断。真实项目素材主要来自:
- 后端 WiFi 实体:`zhkq-api/jeecg-module-zhkq/src/main/java/org/jeecg/modules/biz/entity/KqWifiConfig.java`
- 后端蓝牙实体:`KqBluetoothDevice.java`
- 打卡记录实体:`KqAttendanceRecord.java`
- WiFi 控制器:`KqWifiConfigController`
- 蓝牙控制器:`KqBluetoothDeviceController`
- App 打卡接口:`AppKqAttendanceRecordController`
- 移动端 WiFi 能力:`zhkq-uniapp/src/common/uniApi/wifi.js`
- 移动端蓝牙能力:`zhkq-uniapp/src/common/uniApi/bluetooth.js`
- PC 后台 API:`zhkq-web/src/views/attendance/wifi/wifi.api.ts`、`attendance/bluetooth/blue.api.ts`
下面的 Demo 不是内部工程原文件全文复制,而是按真实字段、接口风格和业务边界重新整理的一条最小闭环。它适合迁移到门店签到、仓库巡检、设备保养、项目现场、工单到场等系统。
一、为什么 WiFi 和蓝牙要一起用
真实项目里,WiFi 配置表 `kq_wifi_config` 至少包含这些字段:
| 字段 | 含义 | 业务价值 |
| `deviceName` | 设备名称 | 给后台和员工提示用,避免只看到一串 MAC |
| `deviceMac` | BSSID/MAC | 用于识别真实接入点,比 WiFi 名称更稳定 |
| `longitude`、`latitude` | 设备所在经纬度 | 辅助判断设备位置是否和考勤点一致 |
| `address` | 位置描述 | 便于后台维护和导出排查 |
| `sysOrgCode` | 所属组织 | 做数据权限和多部门隔离 |
蓝牙设备表 `kq_bluetooth_device` 多了几个近场识别字段:
| 字段 | 含义 | 业务价值 |
| `deviceUuid` | 蓝牙 UUID | 识别同一类 Beacon 或设备 |
| `deviceCode` | 设备唯一识别号 | 用于去重和设备定位 |
| `maiorNo`、`minorNo` | major/minor 编号 | 用于区分楼层、门店、工位或点位 |
| `longitude`、`latitude` | 部署位置 | 辅助地图展示和距离判断 |
| `deviceAddress` | 设备位置描述 | 解释“这个蓝牙点在哪里” |
WiFi 的特点是覆盖范围大,适合判断“是否在公司、门店或厂区”。蓝牙的特点是覆盖范围小,适合判断“是否靠近入口、考勤机、仓库门、设备点位”。两者组合后,系统可以返回更清楚的失败原因:不是简单说“打卡失败”,而是告诉用户“未连接指定 WiFi”“未扫描到附近蓝牙设备”“定位距离偏差过大”。
二、真实工程里已经做了哪些关键处理
项目里的 WiFi 和蓝牙控制器不是简单 CRUD,有两个值得保留的工程点。
第一,后台列表做了组织权限过滤。`KqWifiConfigController` 和 `KqBluetoothDeviceController` 都会通过当前用户能管理的部门列表过滤 `sysOrgCode`。这说明设备不是全局可见的,同一个集团下,不同门店、不同部门只能维护自己范围内的设备。
第二,新增和编辑时做了重复校验。WiFi 通过设备名称或 BSSID 去重,蓝牙通过设备名称或 UUID 去重,导入 Excel 时也会逐行校验必填字段和重复数据。这个细节很重要,因为现场设备一旦重复,后续打卡证据就会变得不可解释。
移动端也不是直接读一个值就完事。`wifi.js` 里会先 `uni.startWifi`,再 `uni.getConnectedWifi` 或 `uni.getWifiList`;失败时会把 `12005`、`12010` 这类 WiFi 未开启错误转成可读提示。`bluetooth.js` 里会先打开蓝牙适配器,再启动扫描,最后读取设备列表,并在 3 分钟后关闭扫描,避免一直占用手机资源。
这些都可以沉淀成一个公开 Demo。
三、完整源码 Demo:双因子打卡证据链
1. Entity:WiFi 配置
package com.demo.attendance.entity; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; /** * WiFi 考勤设备配置。 * 业务边界:只维护可用于现场识别的 WiFi 接入点,不负责计算考勤结果。 */ @Data @TableName("demo_kq_wifi_config") public class WifiConfig { /** 主键 */ private String id; /** 后台可读设备名称 */ private String deviceName; /** WiFi BSSID/MAC,实际匹配时优先使用 */ private String deviceMac; /** 部署经度 */ private String longitude; /** 部署纬度 */ private String latitude; /** 部署位置描述 */ private String address; /** 组织编码,用于数据权限隔离 */ private String sysOrgCode; }2. Entity:蓝牙设备
package com.demo.attendance.entity; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; /** * 蓝牙考勤设备配置。 * 业务边界:描述一个可被移动端扫描到的近场设备,用于增强现场证据。 */ @Data @TableName("demo_kq_bluetooth_device") public class BluetoothDevice { private String id; private String deviceName; private String deviceUuid; private String deviceCode; private String majorNo; private String minorNo; private String longitude; private String latitude; private String deviceAddress; private String sysOrgCode; }3. DTO:移动端提交的现场证据
package com.demo.attendance.dto; import lombok.Data; import javax.validation.constraints.NotBlank; import java.time.LocalDateTime; import java.util.List; /** * 移动端打卡证据。 * 只承载手机采集到的现场数据,最终是否有效由后端统一判断。 */ @Data public class ClockEvidenceDTO { @NotBlank(message = "打卡类型不能为空") private String clockType; @NotBlank(message = "上下班类型不能为空") private String upDownWorkClock; private LocalDateTime clockTime; private String longitude; private String latitude; private String address; private String connectedWifiBssid; private String connectedWifiName; private List<ScannedBluetoothDTO> bluetoothDevices; private String clockImg; }package com.demo.attendance.dto; import lombok.Data; /** * 移动端扫描到的蓝牙设备。 */ @Data public class ScannedBluetoothDTO { private String uuid; private String deviceId; private String major; private String minor; private Integer rssi; }4. VO:返回给移动端的判断结果
package com.demo.attendance.vo; import lombok.Data; /** * 打卡判断结果。 * 给移动端明确原因,避免只返回“失败”导致员工和主管继续扯皮。 */ @Data public class ClockEvidenceResultVO { private Boolean passed; private String message; private Boolean gpsPassed; private Boolean wifiPassed; private Boolean bluetoothPassed; private String matchedWifiName; private String matchedBluetoothName; }5. Mapper
package com.demo.attendance.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.demo.attendance.entity.WifiConfig; public interface WifiConfigMapper extends BaseMapper<WifiConfig> { }package com.demo.attendance.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.demo.attendance.entity.BluetoothDevice; public interface BluetoothDeviceMapper extends BaseMapper<BluetoothDevice> { }6. Service
package com.demo.attendance.service; import com.demo.attendance.dto.ClockEvidenceDTO; import com.demo.attendance.vo.ClockEvidenceResultVO; /** * 考勤证据校验服务。 */ public interface ClockEvidenceService { /** * 校验移动端提交的 GPS、WiFi、蓝牙证据,并返回可解释结果。 */ ClockEvidenceResultVO verify(ClockEvidenceDTO dto, String userId, String orgCode); }7. ServiceImpl:核心判断
package com.demo.attendance.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.demo.attendance.dto.ClockEvidenceDTO; import com.demo.attendance.dto.ScannedBluetoothDTO; import com.demo.attendance.entity.BluetoothDevice; import com.demo.attendance.entity.WifiConfig; import com.demo.attendance.mapper.BluetoothDeviceMapper; import com.demo.attendance.mapper.WifiConfigMapper; import com.demo.attendance.service.ClockEvidenceService; import com.demo.attendance.vo.ClockEvidenceResultVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.List; import java.util.Objects; /** * WiFi/蓝牙双因子打卡证据校验。 * 这里不直接写死考勤状态,只判断现场证据是否可信,后续再交给考勤规则服务计算迟到、早退、缺卡等状态。 */ @Service @RequiredArgsConstructor public class ClockEvidenceServiceImpl implements ClockEvidenceService { private final WifiConfigMapper wifiConfigMapper; private final BluetoothDeviceMapper bluetoothDeviceMapper; @Override public ClockEvidenceResultVO verify(ClockEvidenceDTO dto, String userId, String orgCode) { ClockEvidenceResultVO result = new ClockEvidenceResultVO(); result.setGpsPassed(StringUtils.hasText(dto.getLongitude()) && StringUtils.hasText(dto.getLatitude())); WifiConfig wifi = matchWifi(dto.getConnectedWifiBssid(), orgCode); result.setWifiPassed(wifi != null); result.setMatchedWifiName(wifi == null ? null : wifi.getDeviceName()); BluetoothDevice bluetooth = matchBluetooth(dto.getBluetoothDevices(), orgCode); result.setBluetoothPassed(bluetooth != null); result.setMatchedBluetoothName(bluetooth == null ? null : bluetooth.getDeviceName()); boolean passed = Boolean.TRUE.equals(result.getGpsPassed()) && (Boolean.TRUE.equals(result.getWifiPassed()) || Boolean.TRUE.equals(result.getBluetoothPassed())); result.setPassed(passed); result.setMessage(buildMessage(result)); return result; } private WifiConfig matchWifi(String bssid, String orgCode) { if (!StringUtils.hasText(bssid)) { return null; } return wifiConfigMapper.selectOne(new LambdaQueryWrapper<WifiConfig>() .eq(WifiConfig::getDeviceMac, bssid) .eq(WifiConfig::getSysOrgCode, orgCode) .last("limit 1")); } private BluetoothDevice matchBluetooth(List<ScannedBluetoothDTO> devices, String orgCode) { if (CollectionUtils.isEmpty(devices)) { return null; } for (ScannedBluetoothDTO item : devices) { BluetoothDevice device = bluetoothDeviceMapper.selectOne(new LambdaQueryWrapper<BluetoothDevice>() .eq(StringUtils.hasText(item.getUuid()), BluetoothDevice::getDeviceUuid, item.getUuid()) .eq(StringUtils.hasText(item.getDeviceId()), BluetoothDevice::getDeviceCode, item.getDeviceId()) .eq(StringUtils.hasText(item.getMajor()), BluetoothDevice::getMajorNo, item.getMajor()) .eq(StringUtils.hasText(item.getMinor()), BluetoothDevice::getMinorNo, item.getMinor()) .eq(BluetoothDevice::getSysOrgCode, orgCode) .last("limit 1")); if (Objects.nonNull(device)) { return device; } } return null; } private String buildMessage(ClockEvidenceResultVO result) { if (!Boolean.TRUE.equals(result.getGpsPassed())) { return "未获取到定位信息,请检查定位权限"; } if (Boolean.TRUE.equals(result.getWifiPassed()) || Boolean.TRUE.equals(result.getBluetoothPassed())) { return "现场证据校验通过"; } return "未匹配到允许打卡的 WiFi 或蓝牙设备"; } }8. Controller:移动端提交入口
package com.demo.attendance.controller; import com.demo.attendance.dto.ClockEvidenceDTO; import com.demo.attendance.service.ClockEvidenceService; import com.demo.attendance.vo.ClockEvidenceResultVO; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; /** * 移动端打卡证据接口。 */ @RestController @RequiredArgsConstructor @RequestMapping("/app/clockEvidence") public class ClockEvidenceController { private final ClockEvidenceService clockEvidenceService; @PostMapping("/verify") public ClockEvidenceResultVO verify(@RequestBody @Valid ClockEvidenceDTO dto) { String userId = "demo-user"; String orgCode = "demo-org"; return clockEvidenceService.verify(dto, userId, orgCode); } }9. uni-app:采集 WiFi 和蓝牙
import { uniGetConnectedWifi } from '@/common/uniApi/wifi.js' import { uniGetBluetoothDevices } from '@/common/uniApi/bluetooth.js' import http from '@/common/http' export async function submitClockEvidence(baseForm) { let wifi = null let bluetooth = [] try { const wifiResult = await uniGetConnectedWifi() wifi = wifiResult && wifiResult.wifi } catch (e) { console.log('WiFi 采集失败', e.message || e) } try { const blueResult = await uniGetBluetoothDevices() bluetooth = blueResult.devices || [] } catch (e) { console.log('蓝牙采集失败', e.message || e) } return http.post('/app/clockEvidence/verify', { ...baseForm, connectedWifiBssid: wifi && wifi.BSSID, connectedWifiName: wifi && wifi.SSID, bluetoothDevices: bluetooth.map(item => ({ uuid: item.advertisServiceUUIDs && item.advertisServiceUUIDs[0], deviceId: item.deviceId, major: item.major, minor: item.minor, rssi: item.RSSI })) }) }四、这套设计真正解决了什么
第一,它让失败原因可解释。员工打卡失败时,系统可以告诉他是定位权限没开,还是没有连接指定 WiFi,还是没有扫描到蓝牙设备。问题越具体,人工沟通成本越低。
第二,它把移动端从“裁判”降级为“采集器”。手机端只负责采集现场证据,后端负责校验证据和生成结果。这一点非常关键,因为手机时间、网络状态、系统权限都不完全可信。
第三,它天然支持组织隔离。真实项目里设备配置带 `sysOrgCode`,后台查询也会按当前用户可管理部门过滤。这样集团型企业、连锁门店、多项目现场都可以共用一套设备模型。
第四,它能保留审计空间。最终落到 `KqAttendanceRecord` 时,仍然保留 `clockTime`、`longitude`、`latitude`、`clockAddress`、`clockImg`、`clockStatus`、`afStatus` 等字段。后续员工申诉、主管审批、HR 统计,都有基础数据可查。
五、落地时最容易踩的坑
1. 不要只用 WiFi 名称判断。SSID 可以重复,BSSID/MAC 更适合作为识别字段。
2. 不要一直扫描蓝牙。移动端要像项目里的 `bluetooth.js` 一样,在限定时间内开启和关闭扫描,减少耗电和系统占用。
3. 不要把定位失败直接算旷工。应该先返回明确失败原因,让员工重新授权或补充说明。
4. 不要让所有部门共享设备。设备必须绑定组织或门店,否则跨部门考勤会乱。
5. 不要忽略导入校验。批量导入设备时必须校验设备名称、MAC、UUID、唯一识别号,否则后面排查成本非常高。
6. 不要让移动端直接决定考勤状态。迟到、早退、缺卡应该由后端结合规则统一计算。
六、生产级改造建议
如果把上面的 Demo 用到真实生产环境,还要补三类能力。
第一类是索引和唯一约束。WiFi 配置表建议至少给 `device_mac`、`sys_org_code` 建联合索引,蓝牙设备表建议给 `device_uuid`、`device_code`、`sys_org_code` 建索引。设备导入时虽然可以在 Java 层做重复校验,但数据库层仍然要保底,否则并发导入或多人同时维护设备时,可能出现重复设备。
第二类是打卡幂等。移动端弱网时经常会重复提交,尤其是员工看到页面没反应,会连续点几次。后端可以用 `personId + clockType + upDownWorkClock + 时间窗口` 做幂等判断,也可以让移动端生成一次打卡请求号。真实项目里的 `AppKqAttendanceRecordController` 已经能处理缓存打卡上传,这类场景更需要把“重复提交”和“补传”区分开。
第三类是审计和解释。现场证据最好不要只保存最终结果,还要保存当时采集到的关键摘要:匹配到哪个 WiFi、匹配到哪个蓝牙、定位是否成功、失败原因是什么。否则员工后续申诉时,系统只能看到“失败”两个字,仍然无法解释为什么失败。
下面是一个简化的索引建议:
CREATE UNIQUE INDEX uk_wifi_org_mac ON demo_kq_wifi_config(sys_org_code, device_mac); CREATE UNIQUE INDEX uk_bluetooth_org_code ON demo_kq_bluetooth_device(sys_org_code, device_code); CREATE INDEX idx_clock_person_time ON demo_attendance_record(person_id, clock_time);再补一个幂等判断示例:
/** * 判断员工在短时间窗口内是否已经提交过同类型打卡。 */ public boolean existsRecentClock(String personId, String clockType, String upDownWorkClock, LocalDateTime clockTime) { LocalDateTime start = clockTime.minusMinutes(3); LocalDateTime end = clockTime.plusMinutes(3); Long count = attendanceRecordMapper.selectCount(new LambdaQueryWrapper<AttendanceRecord>() .eq(AttendanceRecord::getPersonId, personId) .eq(AttendanceRecord::getClockType, clockType) .eq(AttendanceRecord::getUpDownWorkClock, upDownWorkClock) .between(AttendanceRecord::getClockTime, start, end)); return count != null && count > 0; }这个判断不复杂,但非常实用。没有幂等,弱网补传会变成重复打卡;没有审计,异常申诉会变成聊天解释;没有索引,设备一多,现场校验会拖慢移动端体验。
七、可复用总结
这套 WiFi + 蓝牙证据链,不只适合考勤。
门店巡检可以用它判断员工是否真的到店;仓库作业可以用它判断人员是否到达指定库区;设备保养可以用蓝牙点位确认是否靠近设备;物业巡更可以用 WiFi 和蓝牙减少虚假签到;上门服务可以用多证据减少客户和服务人员之间的争议。
企业系统真正有价值的地方,不是多收集几个字段,而是让每条记录未来都能解释。WiFi、蓝牙、GPS、照片、时间、组织权限、异常申诉连成一条链,考勤记录才不只是“打了一次卡”,而是一条能被员工、主管、HR 和老板共同理解的业务证据。