
很多考勤系统一开始只做"打卡点",上线后才发现外勤场景真正麻烦的不是员工有没有点按钮,而是后续争议无法还原:客户现场到底有没有到、巡检路线是否覆盖、某天为什么没有轨迹、定位断点是手机问题还是系统问题。如果只有一条打卡记录,管理者能看到的只是某个时间点;如果有完整轨迹,系统才能把"过程"变成可复盘的数据。
这篇继续拆智慧考勤项目里的轨迹链路。代码来自 `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 List<AppKqLocationInfoReocordIn> 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);
}
List<KqLocationInfoReocord> 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 List<KqLocationInVo> getPositionInfo(String personId, Date startDate, Date endDate) {
return get2ReduceVo(getPositionInfoWHere(personId, startDate, endDate, null));
}
public List<KqLocationInVo> get2ReduceVo(List<KqLocationInfoReocordVo> vos) {
List<KqLocationInVo> 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("已在生成中,请稍后再试......");
}
List<String> dateList = DateUtils.getBetweenDate(start, end);
List<DictModel> needTrajectoryCompanyList = sysDictService.getDictItems(NEED_TRAJECTORY_COMPANY);
List<DictModel> czCountyList = sysDictService.getDictItems(CZ_COUNTY);
List<String> czCountyListStr = czCountyList.stream().map(DictModel::getText).collect(Collectors.toList());
for (String date : dateList) {
for (DictModel dictModel : needTrajectoryCompanyList) {
List<SysUserSysDepartModel> 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";
List<KqLocationInfoReocordVo> 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);
}
}
这段逻辑体现了轨迹系统最常见的处理顺序:
-
按天取点,避免一次查询跨太大时间范围。
-
按时间正序排序,因为距离计算依赖点位顺序。
-
用 `GeoUtils.distanceList` 计算累计距离。
-
用区域字典判断是否外勤。
-
将结果写入日汇总和轨迹记录。
保存日汇总时,系统把米转换为公里并保留两位小数,只保存外勤轨迹到 `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 跟着轨迹移动。管理者看一眼就能判断路线是否连贯、是否存在长时间断点、是否绕路。
八、轨迹功能最容易忽略的是数据边界
轨迹是敏感数据。技术上能记录,不代表业务上可以无限制使用。真正上线时,我建议至少做四个边界:
-
查询权限按组织和岗位控制,普通员工只能看自己的轨迹,主管只能看授权范围。
-
列表默认只展示汇总,不在非必要页面展示完整地址和完整轨迹。
-
原始点位设置保留周期,超过周期只保留汇总结果。
-
导出行为记录审计日志,防止批量导出敏感轨迹。
从代码看,当前项目已经有数据权限过滤、单位部门维度、轨迹类型区分和日汇总列表。这些都是正确方向。后续如果继续增强,可以补充轨迹脱敏、点位抽稀、异常断点识别、重算任务审计、导出水印等能力。
九、这套设计可以复用到哪些系统
智慧考勤里的轨迹链路,不只适合考勤,也适合巡检、拜访、配送、维保、督导等场景。抽象成通用能力后,大概是这样:
移动端定位采集
-> 本地缓存和批量上传
-> 后端校验和消息队列削峰
-> 时序库存储原始点位
-> 定时任务生成日汇总
-> PC 列表筛选和地图回放
-> 异常申诉、导出和审计
这也是我认为轨迹模块必须独立设计的原因。它不是打卡表的一个字段,而是一条完整的数据链。打卡负责结论,轨迹负责证据;打卡解决"有没有做",轨迹解决"过程能不能解释"。当系统做到这一步,外勤考勤才不会只剩下口头争论。
十、落地建议
如果你也在做类似系统,可以按下面顺序实现:
-
先做移动端缓存和批量上传,保证弱网不丢点。
-
再做 RabbitMQ 削峰,避免高频定位请求直接压数据库。
-
原始点位放时序库,日汇总放 MySQL。
-
地图回放只返回必要字段,不把敏感信息全部丢给前端。
-
日任务支持重算,方便处理补传和异常恢复。
-
权限、保留周期、导出审计要和功能一起设计,不要等上线后再补。
轨迹系统的价值,不是"监控员工在哪里",而是让外勤过程有边界、有证据、可复盘。工程上把采集、存储、汇总、回放、重算和权限分开,后续无论是扩展到巡检,还是扩展到客户拜访,都会轻很多。