
很多考勤系统一开始只做“打卡点”上线后才发现外勤场景真正麻烦的不是员工有没有点按钮而是后续争议无法还原客户现场到底有没有到、巡检路线是否覆盖、某天为什么没有轨迹、定位断点是手机问题还是系统问题。如果只有一条打卡记录管理者能看到的只是某个时间点如果有完整轨迹系统才能把“过程”变成可复盘的数据。这篇继续拆智慧考勤项目里的轨迹链路。代码来自 D:\workspace\ls_work\zhkq-project核心涉及 uni-app 移动端、Spring Boot 后端、RabbitMQ、InfluxDB、XXL-Job 和 PC 端高德地图回放。为了安全文中只放结构和关键逻辑不展示真实员工、真实坐标、生产配置和地图 Key。一、轨迹数据不能直接塞进打卡表打卡记录是事件数据描述“某人某时某地完成了什么动作”。轨迹记录是过程数据描述“某人在一段时间内走过哪些点、经过哪些区域、有没有明显断点”。两类数据的查询频率、存储粒度、页面交互完全不同。本项目把轨迹拆成三层层级作用典型数据原始点位还原移动过程经度、纬度、定位时间、地址、设备来源轨迹记录支撑一次手动或自动轨迹开始时间、结束时间、持续时长、点位数日汇总支撑列表、导出、异常筛选日期、人员、里程、是否外勤、区域名称这样做的好处很直接原始点位适合放到时序库轨迹记录和日汇总适合放到 MySQL。列表页查日汇总不需要每次扫大量定位点详情页要回放时再按人员和时间去 InfluxDB 拉点位。二、移动端先缓存再批量上传移动端位置点产生频率高如果每获取一个点就立刻请求后端会带来三个问题耗电、弱网失败率高、服务端写入压力不稳定。项目里采用的是本地缓存池加批量上传的设计。移动端接口定义在// zhkq-uniapp/src/common/http/api.js export default { uploadAddressInfo: /zhkq-api/app/kqLocationInfoReocord/uploadAddressInfo, kqTrajectoryRecordList: /zhkq-api/app/kqTrajectoryRecord/list, kqTrajectoryRecordAdd: /zhkq-api/app/kqTrajectoryRecord/add, kqTrajectoryRecordQueryById: /zhkq-api/app/kqTrajectoryRecord/queryById }位置缓存和上传逻辑集中在zhkq-uniapp/src/common/store/location.js这里不只是简单上传点位还承担了几个移动端必须考虑的职责判断是否开启自动轨迹、缓存办公轨迹点位、定时获取定位、定时上传点位、网络异常时保留本地缓存。移动端还在 App.vue 里处理后台持续定位并提示用户关闭电池优化避免系统杀后台导致轨迹中断。后端上传接口限制一次最多上传 100 条避免一个异常客户端一次性把队列打爆/** * 用户上传实时位置信息 */ PostMapping(value /uploadAddressInfo) public Result? uploadAddressInfo(RequestBody ListAppKqLocationInfoReocordIn inList) { LoginUser sysUser (LoginUser) SecurityUtils.getSubject().getPrincipal(); LoginUserVo userVo orgService.getUserVo(sysUser.getId()); if (inList.size() 0) { AppKqLocationInfoReocordIn reocordIn new AppKqLocationInfoReocordIn(); if (null ! redisUtil.get(ConstUtils.LOCATION sysUser.getId())) { Object object redisUtil.get(ConstUtils.LOCATION sysUser.getId()); reocordIn JSON.parseObject(JSON.toJSONString(object), AppKqLocationInfoReocordIn.class); } ListKqLocationInfoReocord list new ArrayList(); for (AppKqLocationInfoReocordIn in : inList) { if (null reocordIn.getOrientationTime() || in.getOrientationTime().compareTo(reocordIn.getOrientationTime()) 1) { KqLocationInfoReocord reocord new KqLocationInfoReocord(); BeanUtils.copyProperties(in, reocord); reocord.setPersonId(sysUser.getId()); reocord.setPersonName(sysUser.getRealname()); reocord.setPersonPhone(sysUser.getPhone()); reocord.setUnitId(userVo.getOrgId()); reocord.setUnitName(userVo.getOrgShortName()); list.add(reocord); } } if (list.size() 0) { rabbitTemplate.convertAndSend(RabbitMqConfig.ADDRESS_INFO_QUEUE, JSON.toJSONString(list)); } redisUtil.set(ConstUtils.LOCATION sysUser.getId(), inList.get(inList.size() - 1)); } return Result.OK(操作成功); }这段代码里有两个关键点。第一只保存比上一条定位时间更新的点避免弱网重传造成旧点覆盖新点。第二接口不直接写时序库而是先进入 RabbitMQ。轨迹点属于高频写入削峰比同步写入更稳。三、InfluxDB 适合存细粒度点位原始轨迹点写入 InfluxDB核心类是zhkq-api/jeecg-module-zhkq/src/main/java/org/jeecg/modules/biz/influxdb/InfluxDBUtils.java写入时使用 kq_location_log 作为 measurement保留人员、单位、设备、定位时间和经纬度等字段public void addPositionInfo(KqLocationInfoReocord reocord) { InfluxDBClient client InfluxDBClientFactory.create(url, token.toCharArray()); Point point Point .measurement(kq_location_log) .addTag(host, location) .addField(id, IdUtil.simpleUUID()) .addField(unitId, reocord.getUnitId()) .addField(personId, reocord.getPersonId()) .addField(personName, reocord.getPersonName()) .addField(phone, reocord.getPersonPhone()) .addField(deviceSource, reocord.getDeviceSource()) .addField(orientationTime, DateUtil.formatDateTime(reocord.getOrientationTime())) .addField(receiveTime, DateUtil.formatDateTime(new Date())) .addField(longitude, reocord.getLongitude()) .addField(latitude, reocord.getLatitude()) .addField(speed, reocord.getSpeed()) .addField(precision, reocord.getPrecision()) .addField(address, reocord.getAddress()) .time(reocord.getOrientationTime().toInstant(), WritePrecision.NS); WriteApiBlocking writeApi client.getWriteApiBlocking(); writeApi.writePoint(bucket, org, point); client.close(); }实际项目里还有一个值得优化的点每次写入都创建和关闭 InfluxDBClient简单但开销偏高。如果轨迹量上来可以把客户端改造成单例 Bean统一生命周期管理再配合批量写入策略。但现阶段文章只分析设计不直接改源码。查询轨迹时后端按人员和时间范围拉取点位并把它转换成前端地图需要的轻量对象public ListKqLocationInVo getPositionInfo(String personId, Date startDate, Date endDate) { return get2ReduceVo(getPositionInfoWHere(personId, startDate, endDate, null)); } public ListKqLocationInVo get2ReduceVo(ListKqLocationInfoReocordVo vos) { ListKqLocationInVo newList new ArrayList(); for (KqLocationInfoReocordVo vo : vos) { KqLocationInVo n new KqLocationInVo(); n.setLatitude(vo.getLatitude()); n.setLongitude(vo.getLongitude()); n.setTime(vo.getOrientationTime()); newList.add(n); } return newList; }这里有一个工程判断地图回放不需要把所有字段都返回给前端。姓名、手机号、地址、设备信息等都属于敏感或非必要字段详情回放只需要经纬度和时间即可。越少的数据出现在前端权限和隐私风险越小。四、轨迹日汇总用 XXL-Job 兜底只存原始点位还不够。管理端要的是列表、筛选、导出和异常定位所以需要把每天的轨迹算成日汇总。项目里有一个定时任务XxlJob(value trajectoryDayJob) public void execute() { log.info(----------定时任务:计算每天轨迹距离----------); try { Date today new Date(new DateTime().offset(DateField.DAY_OF_YEAR, -1).getTime()); String day DateUtil.formatDateTime(today).substring(0, 10); ikqTrajectoryDayService.trajectoryDayJob(day, day); } catch (Exception e) { log.info(----------定时任务:计算每天轨迹距离----------); e.printStackTrace(); log.info(e.getMessage()); } }它每天计算昨天的轨迹距离。服务层还支持按日期范围重算这对运维很重要因为定位数据可能补传某天队列也可能延迟消费。public void trajectoryDayJob(String start, String end) { if (threadPool.getActiveCount() 0) { throw new JeecgBootException(已在生成中请稍后再试……); } ListString dateList DateUtils.getBetweenDate(start, end); ListDictModel needTrajectoryCompanyList sysDictService.getDictItems(NEED_TRAJECTORY_COMPANY); ListDictModel czCountyList sysDictService.getDictItems(CZ_COUNTY); ListString czCountyListStr czCountyList.stream().map(DictModel::getText).collect(Collectors.toList()); for (String date : dateList) { for (DictModel dictModel : needTrajectoryCompanyList) { ListSysUserSysDepartModel userList sysUserService.queryUserByOrgCode(dictModel.getValue(), null); for (SysUserSysDepartModel user : userList) { threadPool.exec(new ThreadTrajectoryDay(user, date, czCountyListStr)); } } } }这里通过 threadPool.getActiveCount() 做了一个粗粒度并发保护避免重复生成。它不是最完美的分布式锁但至少能挡住同一实例里的重复点击。真正多实例部署时可以把这块升级成 Redis 分布式锁或任务表状态锁。五、按人员和日期计算距离轨迹计算线程按人员、日期从 InfluxDB 取点位再计算距离和外勤区域public void run() { try { InfluxDBUtils dbUtils SpringContextUtils.getBean(InfluxDBUtils.class); String influxQL SELECT \longitude\, \latitude\, \address\ FROM \ dbUtils.getBucket() \.\autogen\.\kq_location_log\ WHERE time DateUtil.toISO8601UTC(DateUtil.getDayStart(day)) and time DateUtil.toISO8601UTC(DateUtil.getDayEnd(day)) ; influxQL and personId user.getId() ; influxQL order by time asc; ListKqLocationInfoReocordVo locationList dbUtils.queryBySql(influxQL); double distance GeoUtils.distanceList(locationList); SpringContextUtils.getBean(IkqTrajectoryDayService.class) .saveTrajectoryDay(user, distance, day, GeoUtils.getCountyName(locationList, czCountyListStr), locationList.size()); } catch (Exception e) { log.error(计算失败数据: \n{}, e); } }这段逻辑体现了轨迹系统最常见的处理顺序1. 按天取点避免一次查询跨太大时间范围。2. 按时间正序排序因为距离计算依赖点位顺序。3. 用 GeoUtils.distanceList 计算累计距离。4. 用区域字典判断是否外勤。5. 将结果写入日汇总和轨迹记录。保存日汇总时系统把米转换为公里并保留两位小数只保存外勤轨迹到 kq_trajectory_day同时写入一条自动轨迹记录BigDecimal bd new BigDecimal(distance / 1000); bd bd.setScale(2, RoundingMode.HALF_UP); day.setInspectionDistance(bd.toString()); if (distance 0 day.getIsOut().intValue() 1) { KqTrajectoryDay db this.getTrajectoryDay(user.getId(), d); if (null db) { this.save(day); } else { day.setId(db.getId()); this.updateById(day); } } KqTrajectoryRecord record new KqTrajectoryRecord(); BeanUtil.copyProperties(day, record); record.setStartTime(DateUtils.parseDatetime(DateUtil.getDayStart(d))); record.setEndTime(DateUtils.parseDatetime(DateUtil.getDayEnd(d))); record.setTrajectoryType(2); record.setTrajectoryTypeName(自动); record.setContinueTime(24:00:00); record.setRecordPoint(count); record.setTrajectoryName(d);这个设计把“自动轨迹”和“手动轨迹”区分开来。移动端手动创建的轨迹记录类型是 1日任务生成的是 2。后面做列表筛选、异常分析、权限控制时这个字段很有用。六、PC 管理端只查外勤日汇总PC 端接口定义在// zhkq-web/src/views/attendanceRecord/trackView/track.view.api.ts enum Api { list /biz/kqTrajectoryDay/list, handleDetail /biz/kqTrajectoryRecord/queryById, dwInfo /biz/kqTrajectoryDay/getInfo, reloadGps /biz/kqTrajectoryDay/reload/gps }列表页走 kq_trajectory_day详情回放再查点位。这样的体验会比每次打开列表都查 InfluxDB 好很多。尤其当一个单位有几百名外勤人员、每天几万到几十万个定位点时列表页必须轻。后端管理接口里还有一个“重新加载 GPS”的能力GET /biz/kqTrajectoryDay/reload/gps它适合处理补传、异常恢复和历史重算。比如员工反馈“昨天有外勤但列表没有出来”管理员不应该直接改数据库而应该通过重算入口重新生成轨迹汇总保持原始点位、轨迹记录和日汇总的一致性。七、高德地图回放的关键是点位数组前端地图回放在zhkq-web/src/views/attendanceRecord/trackView/SelectMapPolygonModal.vue接口返回点位后页面把经纬度转成高德地图需要的数组let list []; let res await dwDetail({ informantId: informantId.value, ...info }); res.forEach((item) { if (item.longitude item.latitude) { list.push([parseFloat(item.longitude), parseFloat(item.latitude)]); } }); lineArr.value list; echart(list);地图上用 AMap.Polyline 画完整轨迹用另一个 passedPolyline 表示已经回放过的路线let polyline (polylines.value new AMap.Polyline({ map: map.value, path: [], showDir: true, strokeColor: #28F, strokeWeight: 6, lineJoin: round, lineCap: round, })); polyline.setPath(list); let passedPolyline (passedPolylines.value new AMap.Polyline({ map: map.value, strokeColor: #AF5, strokeWeight: 6, })); marker.on(moving, function (e) { passedPolylines.value.setPath(e.passedPath); maps.setCenter(e.target.getPosition(), true); });这套交互适合做“过程还原”一条蓝色线表示完整轨迹一条绿色线表示已经播放的位置Marker 跟着轨迹移动。管理者看一眼就能判断路线是否连贯、是否存在长时间断点、是否绕路。八、轨迹功能最容易忽略的是数据边界轨迹是敏感数据。技术上能记录不代表业务上可以无限制使用。真正上线时我建议至少做四个边界1. 查询权限按组织和岗位控制普通员工只能看自己的轨迹主管只能看授权范围。2. 列表默认只展示汇总不在非必要页面展示完整地址和完整轨迹。3. 原始点位设置保留周期超过周期只保留汇总结果。4. 导出行为记录审计日志防止批量导出敏感轨迹。从代码看当前项目已经有数据权限过滤、单位部门维度、轨迹类型区分和日汇总列表。这些都是正确方向。后续如果继续增强可以补充轨迹脱敏、点位抽稀、异常断点识别、重算任务审计、导出水印等能力。九、这套设计可以复用到哪些系统智慧考勤里的轨迹链路不只适合考勤也适合巡检、拜访、配送、维保、督导等场景。抽象成通用能力后大概是这样移动端定位采集 - 本地缓存和批量上传 - 后端校验和消息队列削峰 - 时序库存储原始点位 - 定时任务生成日汇总 - PC 列表筛选和地图回放 - 异常申诉、导出和审计这也是我认为轨迹模块必须独立设计的原因。它不是打卡表的一个字段而是一条完整的数据链。打卡负责结论轨迹负责证据打卡解决“有没有做”轨迹解决“过程能不能解释”。当系统做到这一步外勤考勤才不会只剩下口头争论。十、落地建议如果你也在做类似系统可以按下面顺序实现1. 先做移动端缓存和批量上传保证弱网不丢点。2. 再做 RabbitMQ 削峰避免高频定位请求直接压数据库。3. 原始点位放时序库日汇总放 MySQL。4. 地图回放只返回必要字段不把敏感信息全部丢给前端。5. 日任务支持重算方便处理补传和异常恢复。6. 权限、保留周期、导出审计要和功能一起设计不要等上线后再补。轨迹系统的价值不是“监控员工在哪里”而是让外勤过程有边界、有证据、可复盘。工程上把采集、存储、汇总、回放、重算和权限分开后续无论是扩展到巡检还是扩展到客户拜访都会轻很多。