uni-app + Spring Boot + InfluxDB 实现外勤轨迹:批量上传、日汇总和高德地图回放

很多考勤系统一开始只做"打卡点",上线后才发现外勤场景真正麻烦的不是员工有没有点按钮,而是后续争议无法还原:客户现场到底有没有到、巡检路线是否覆盖、某天为什么没有轨迹、定位断点是手机问题还是系统问题。如果只有一条打卡记录,管理者能看到的只是某个时间点;如果有完整轨迹,系统才能把"过程"变成可复盘的数据。

这篇继续拆智慧考勤项目里的轨迹链路。代码来自 `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);

    }

}

这段逻辑体现了轨迹系统最常见的处理顺序:

  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. 权限、保留周期、导出审计要和功能一起设计,不要等上线后再补。

轨迹系统的价值,不是"监控员工在哪里",而是让外勤过程有边界、有证据、可复盘。工程上把采集、存储、汇总、回放、重算和权限分开,后续无论是扩展到巡检,还是扩展到客户拜访,都会轻很多。