
员工站在公司楼下,手机却提示"不在考勤范围";主管把定位半径从 100 米改到 500 米,投诉少了,但隔壁园区也能打卡;HR 到月底看异常记录,只能在员工、主管和系统之间来回确认。定位打卡最容易翻车的地方,不是"有没有调用定位接口",而是考勤范围到底怎么画、怎么保存、怎么绑定规则、怎么在移动端解释失败原因。
很多系统第一版会用"中心点 + 半径"做电子围栏。这种方案适合门店很小、边界近似圆形的场景,但企业园区、医院、养老院、仓库、工地、写字楼往往不是圆形。一栋楼可能贴着马路,后门可能紧邻另一家公司,厂区边界可能是 L 形或多栋楼组成的区域。半径一放大,误判会变少,但考勤可信度会下降;半径一缩小,误判会变多,员工体验会变差。
智慧考勤项目里没有只把围栏做成一个经纬度半径,而是把考勤区域抽成 `kq_af_area`,用地图绘制得到的多边形点位保存到 `areaJson`,再通过 `kq_rule_area` 把区域和考勤规则绑定。移动端拿到规则后,把 `areaJson` 解析成 `areaCoordinates`,用 `checkPointInPolygon(longitude, latitude, coordinates)` 判断当前位置是否落在多边形内。这个设计的重点不是算法多复杂,而是让"围栏配置、审批生效、移动端判断、异常提示、后续统计"能连成闭环。
本文基于智慧考勤项目真实代码做脱敏整理,主要参考这些文件:
- 后端区域实体:`zhkq-api/jeecg-module-zhkq/src/main/java/org/jeecg/modules/biz/entity/KqAfArea.java`
- 后端规则区域关系:`KqRuleArea.java`
- 后端区域控制器:`KqAfAreaController.java`
- 后端区域服务:`KqAfAreaServiceImpl.java`
- PC 区域维护页:`zhkq-web/src/views/attendance/area/AreaModal.vue`
- PC 地图绘制弹窗:`SelectMapPolygonModal.vue`
- PC 接口封装:`area.api.ts`
- PC 表单 Schema:`area.data.ts`
- 一码多端移动端规则缓存:`src/common/store/clockInfo.js`
- 移动端多边形判断:`checkPointInPolygon.js`
- 移动端定位封装:`getLocation.js`

下面的代码不是内部工程原文件全文复制,而是按真实字段、接口风格和业务边界重新整理的一条公开 Demo。它适合迁移到门店签到、工地到场、巡检点位、仓库进出、养老院护理到岗等系统。
一、电子围栏不能只看定位点
定位打卡至少要处理四个问题:
|---------|--------------------|----------------------|
| 问题 | 只做定位接口的后果 | 更稳的设计 |
| 区域不是圆形 | 半径放大后相邻区域也能打卡 | 支持多边形围栏 |
| 规则会变更 | 今天改了区域,明天说不清什么时候生效 | 区域配置走草稿、审核、生效 |
| 人员权限不同 | 一个员工可能绑定不同单位或规则 | 区域与规则单独建关系表 |
| 失败原因不清楚 | 员工只看到"打卡失败" | 返回定位失败、未进围栏、规则未配置等原因 |
真实项目里的 `KqAfArea` 不只是保存点位,它还有 `unitId`、`unitName`、`areaName`、`areaJson`、`areaText`、`afStatus`、`copyId` 等字段。`afStatus` 让区域配置进入流程状态,`copyId` 让已生效区域再次编辑时生成副本,审核通过后再覆盖原记录。这一点非常关键:考勤规则一旦影响工资和管理记录,就不能随便改完立即生效。
`KqRuleArea` 则把规则和区域拆开。一个规则可以绑定多个区域,一个区域也可能被不同规则复用。这样设计比把区域 JSON 直接塞进规则表更稳,因为区域有自己的维护、审批、导入导出和权限过滤。
二、核心字段设计
1. 考勤区域表
|----------------|------------|-----------------------|
| 字段 | 说明 | 为什么需要 |
| `id` | 区域主键 | 规则绑定、审批副本、查询详情都依赖它 |
| `unitId` | 考勤单位 ID | 用于单位维度的数据隔离 |
| `unitName` | 考勤单位名称 | 导入导出和页面展示更友好 |
| `areaName` | 区域名称 | 移动端命中围栏后可显示"总部办公区"等提示 |
| `areaJson` | 多边形坐标 JSON | 保存地图绘制后的边界点 |
| `areaText` | 区域描述 | 给运维和 HR 解释这个区域覆盖哪里 |
| `afStatus` | 审核状态 | 草稿、审核中、完成、驳回 |
| `copyId` | 被编辑原记录 ID | 已生效区域再次编辑时生成副本 |
| `sysOrgCode` | 所属组织 | 做组织权限过滤 |
2. 规则区域关系表
|----------------|---------|--------------|
| 字段 | 说明 | 为什么需要 |
| `ruleId` | 考勤规则 ID | 把区域绑定到具体规则 |
| `areaId` | 考勤区域 ID | 支持一个规则绑定多个区域 |
| `sysOrgCode` | 所属组织 | 避免跨组织误绑定 |
真实项目里,PC 后台的 `AreaModal.vue` 通过"绘制"按钮打开 `SelectMapPolygonModal.vue`。用户在高德地图上新建多边形,结束编辑后把点位数组回传给表单,最终保存为:
[
[114.421832, 30.486012],
[114.424368, 30.486155],
[114.424021, 30.483902],
[114.421517, 30.483766]
]
移动端判断时要注意顺序:多边形点位是 `lng, lat`,定位返回一般是 `longitude`、`latitude`。真实代码里调用方式是:
checkPointInPolygon([lnglat.longitude, lnglat.latitude], item.coordinates)
如果把经纬度顺序写反,系统不会报错,但判断结果会完全不可信,这是定位类系统非常隐蔽的坑。
三、完整源码 Demo:多边形电子围栏闭环
1. Entity:考勤区域
package com.demo.attendance.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 考勤区域配置。
* 业务边界:负责保存电子围栏基础信息和审批状态,不直接计算员工考勤结果。
*/
@Data
@TableName("demo_kq_af_area")
public class AttendanceArea {
/** 主键 */
private String id;
/** 考勤单位 ID */
private String unitId;
/** 考勤单位名称 */
private String unitName;
/** 区域名称,例如总部办公区、仓库东门 */
private String areaName;
/** 多边形点位 JSON,格式为 [[lng,lat],[lng,lat]] */
private String areaJson;
/** 区域描述,用于解释覆盖范围 */
private String areaText;
/** 审核状态:-1 草稿,1 审核中,2 已完成,3 驳回 */
private Integer afStatus;
/** 已生效区域编辑时生成副本,记录原区域 ID */
private String copyId;
/** 所属组织编码 */
private String sysOrgCode;
}
2. Entity:规则区域关系
package com.demo.attendance.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 考勤规则与考勤区域关系。
* 业务边界:只维护规则和区域的绑定关系,不保存区域坐标详情。
*/
@Data
@TableName("demo_kq_rule_area")
public class AttendanceRuleArea {
/** 主键 */
private String id;
/** 考勤规则 ID */
private String ruleId;
/** 考勤区域 ID */
private String areaId;
/** 所属组织编码 */
private String sysOrgCode;
}
3. DTO:区域保存请求
package com.demo.attendance.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* 考勤区域保存请求。
* 业务边界:用于后台新增或编辑电子围栏配置。
*/
@Data
public class AttendanceAreaSaveDTO {
/** 编辑时传入区域 ID */
private String id;
/** 考勤单位 ID */
@NotBlank(message = "考勤单位不能为空")
private String unitId;
/** 考勤单位名称 */
@NotBlank(message = "考勤单位名称不能为空")
private String unitName;
/** 区域名称 */
@NotBlank(message = "区域名称不能为空")
@Size(max = 60, message = "区域名称不能超过 60 个字符")
private String areaName;
/** 多边形坐标 JSON */
@NotBlank(message = "请绘制考勤区域")
@Size(max = 2000, message = "区域坐标过多,请简化边界点")
private String areaJson;
/** 区域描述 */
@NotBlank(message = "区域描述不能为空")
@Size(max = 600, message = "区域描述不能超过 600 个字符")
private String areaText;
}
4. VO:移动端可用区域
package com.demo.attendance.vo;
import lombok.Data;
/**
* 移动端考勤区域视图。
* 业务边界:只返回打卡判断需要的信息,避免把审批字段暴露给移动端。
*/
@Data
public class AppAttendanceAreaVO {
/** 区域 ID */
private String id;
/** 区域名称 */
private String areaName;
/** 多边形坐标 JSON */
private String areaJson;
}
5. Mapper
package com.demo.attendance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.demo.attendance.entity.AttendanceArea;
import com.demo.attendance.vo.AppAttendanceAreaVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 考勤区域 Mapper。
* 业务边界:提供后台区域维护查询和移动端规则区域查询。
*/
@Mapper
public interface AttendanceAreaMapper extends BaseMapper<AttendanceArea> {
/**
* 查询某个考勤规则下已生效的电子围栏。
*
* @param ruleId 考勤规则 ID
* @return 移动端可用于打卡判断的区域列表
*/
@Select("select a.id, a.area_name as areaName, a.area_json as areaJson " +
"from demo_kq_af_area a " +
"inner join demo_kq_rule_area ra on ra.area_id = a.id " +
"where ra.rule_id = #{ruleId} and a.af_status = 2")
List<AppAttendanceAreaVO> selectAppAreasByRuleId(@Param("ruleId") String ruleId);
}
6. Service
package com.demo.attendance.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.demo.attendance.dto.AttendanceAreaSaveDTO;
import com.demo.attendance.entity.AttendanceArea;
import com.demo.attendance.vo.AppAttendanceAreaVO;
import java.util.List;
/**
* 考勤区域服务。
* 业务边界:负责区域保存、编辑副本、规则区域查询,不负责移动端定位采集。
*/
public interface AttendanceAreaService extends IService<AttendanceArea> {
/**
* 新增考勤区域,默认进入草稿状态。
*
* @param dto 区域保存请求
*/
void addArea(AttendanceAreaSaveDTO dto);
/**
* 编辑考勤区域。已审核通过的区域不直接覆盖,而是生成待审核副本。
*
* @param dto 区域保存请求
* @return 编辑结果说明
*/
String editArea(AttendanceAreaSaveDTO dto);
/**
* 查询规则下可用于移动端打卡判断的围栏。
*
* @param ruleId 考勤规则 ID
* @return 区域列表
*/
List<AppAttendanceAreaVO> listAppAreas(String ruleId);
}
7. ServiceImpl:副本编辑和名称去重
package com.demo.attendance.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.demo.attendance.dto.AttendanceAreaSaveDTO;
import com.demo.attendance.entity.AttendanceArea;
import com.demo.attendance.mapper.AttendanceAreaMapper;
import com.demo.attendance.service.AttendanceAreaService;
import com.demo.attendance.vo.AppAttendanceAreaVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 考勤区域服务实现。
* 业务边界:复刻真实项目中的草稿、已完成、副本编辑思路,保证区域变更可审计。
*/
@Slf4j
@Service
@Transactional(rollbackFor = Exception.class)
public class AttendanceAreaServiceImpl
extends ServiceImpl<AttendanceAreaMapper, AttendanceArea>
implements AttendanceAreaService {
private static final int DRAFT = -1;
private static final int IN_SERVICE = 1;
private static final int COMPLETED = 2;
/**
* 新增考勤区域。
* 先做同单位同名校验,再保存草稿,避免移动端立即使用未审核区域。
*/
@Override
public void addArea(AttendanceAreaSaveDTO dto) {
long count = lambdaQuery()
.eq(AttendanceArea::getUnitId, dto.getUnitId())
.eq(AttendanceArea::getAreaName, dto.getAreaName())
.count();
if (count > 0) {
throw new IllegalArgumentException("考勤区域名称已存在");
}
AttendanceArea area = BeanUtil.toBean(dto, AttendanceArea.class);
area.setAfStatus(DRAFT);
save(area);
log.info("新增考勤区域草稿,unitId={}, areaName={}", dto.getUnitId(), dto.getAreaName());
}
/**
* 编辑考勤区域。
* 已完成区域不直接覆盖,而是生成副本;审核通过后再覆盖原记录,避免线上规则突然变化。
*/
@Override
public String editArea(AttendanceAreaSaveDTO dto) {
AttendanceArea old = getById(dto.getId());
if (old == null) {
throw new IllegalArgumentException("未查询到考勤区域");
}
if (IN_SERVICE == old.getAfStatus()) {
throw new IllegalStateException("正在审核中,不能编辑");
}
if (COMPLETED == old.getAfStatus()) {
AttendanceArea existsCopy = lambdaQuery()
.eq(AttendanceArea::getCopyId, old.getId())
.one();
if (existsCopy != null) {
throw new IllegalStateException("当前区域已有待审核副本,不能重复编辑");
}
AttendanceArea copy = BeanUtil.toBean(dto, AttendanceArea.class);
copy.setId(null);
copy.setCopyId(old.getId());
copy.setAfStatus(DRAFT);
save(copy);
return "已生成待审核副本,审核通过后覆盖原区域";
}
AttendanceArea update = BeanUtil.toBean(dto, AttendanceArea.class);
updateById(update);
return "编辑成功";
}
/**
* 查询移动端打卡所需区域。
* 只返回已审核通过区域,避免草稿或审核中数据影响打卡判断。
*/
@Override
public List<AppAttendanceAreaVO> listAppAreas(String ruleId) {
return baseMapper.selectAppAreasByRuleId(ruleId);
}
}
8. Controller
package com.demo.attendance.controller;
import com.demo.attendance.dto.AttendanceAreaSaveDTO;
import com.demo.attendance.service.AttendanceAreaService;
import com.demo.attendance.vo.AppAttendanceAreaVO;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
/**
* 考勤区域接口。
* 业务边界:后台维护电子围栏,移动端查询已生效围栏。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/demo/attendance/area")
public class AttendanceAreaController {
private final AttendanceAreaService attendanceAreaService;
/**
* 新增电子围栏。
*
* @param dto 保存请求
* @return 处理结果
*/
@PostMapping("/add")
public String add(@RequestBody @Valid AttendanceAreaSaveDTO dto) {
attendanceAreaService.addArea(dto);
return "添加成功";
}
/**
* 编辑电子围栏。
*
* @param dto 保存请求
* @return 处理结果
*/
@PostMapping("/edit")
public String edit(@RequestBody @Valid AttendanceAreaSaveDTO dto) {
return attendanceAreaService.editArea(dto);
}
/**
* 移动端查询当前规则可用电子围栏。
*
* @param ruleId 考勤规则 ID
* @return 围栏列表
*/
@GetMapping("/app/list")
public List<AppAttendanceAreaVO> appList(@RequestParam String ruleId) {
return attendanceAreaService.listAppAreas(ruleId);
}
}
9. 混合框架移动端:多边形命中判断
// common/utils/checkPointInPolygon.js
export function checkPointInPolygon(point, polygon) {
let intersectCount = 0;
for (let i = 0, len = polygon.length; i < len; i++) {
const p1 = polygon[i];
const p2 = polygon[(i + 1) % len];
if (point[1] < Math.min(p1[1], p2[1]) || point[1] > Math.max(p1[1], p2[1])) {
continue;
}
if (point[1] === p1[1] && point[1] === p2[1]) {
continue;
}
const x = (point[1] - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1]) + p1[0];
if (x > point[0]) {
intersectCount++;
}
}
return intersectCount % 2 === 1;
}
真实项目里的移动端规则缓存大致是这个思路:
async function buildAreaCoordinates(ruleId) {
const areas = await http.get('/demo/attendance/area/app/list', { ruleId });
return areas
.filter(item => item.areaJson)
.map(item => ({
areaId: item.id,
areaName: item.areaName,
coordinates: JSON.parse(item.areaJson)
}));
}
async function checkLocationInArea(ruleId) {
const areaCoordinates = await buildAreaCoordinates(ruleId);
const lnglat = await getCurrentLocation();
const matched = areaCoordinates.find(item => {
return checkPointInPolygon([lnglat.longitude, lnglat.latitude], item.coordinates);
});
if (!matched) {
return Promise.reject({
message: '当前位置不在考勤区域内',
longitude: lnglat.longitude,
latitude: lnglat.latitude
});
}
return {
type: 'areaCoordinate',
areaId: matched.areaId,
address: matched.areaName,
longitude: lnglat.longitude,
latitude: lnglat.latitude
};
}
10. MySQL 表结构示例
CREATE TABLE `demo_kq_af_area` (
`id` varchar(32) NOT NULL DEFAULT '' COMMENT '主键',
`unit_id` varchar(32) NOT NULL DEFAULT '' COMMENT '考勤单位ID',
`unit_name` varchar(100) NOT NULL DEFAULT '' COMMENT '考勤单位名称',
`area_name` varchar(100) NOT NULL DEFAULT '' COMMENT '区域名称',
`area_json` varchar(2000) NOT NULL DEFAULT '' COMMENT '区域多边形JSON',
`area_text` varchar(600) NOT NULL DEFAULT '' COMMENT '区域描述',
`af_status` int(11) NOT NULL DEFAULT -1 COMMENT '审核状态:-1草稿,1审核中,2已完成,3驳回',
`copy_id` varchar(32) NOT NULL DEFAULT '' COMMENT '副本来源ID',
`sys_org_code` varchar(64) NOT NULL DEFAULT '' COMMENT '所属组织',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_unit_area` (`unit_id`, `area_name`),
KEY `idx_af_status` (`af_status`),
KEY `idx_copy_id` (`copy_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='考勤区域表';
CREATE TABLE `demo_kq_rule_area` (
`id` varchar(32) NOT NULL DEFAULT '' COMMENT '主键',
`rule_id` varchar(32) NOT NULL DEFAULT '' COMMENT '考勤规则ID',
`area_id` varchar(32) NOT NULL DEFAULT '' COMMENT '考勤区域ID',
`sys_org_code` varchar(64) NOT NULL DEFAULT '' COMMENT '所属组织',
PRIMARY KEY (`id`),
KEY `idx_rule_id` (`rule_id`),
KEY `idx_area_id` (`area_id`),
KEY `idx_rule_area` (`rule_id`, `area_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='考勤规则区域关系表';
四、关键代码为什么这样设计
1. 区域 JSON 不直接塞进规则表
如果把 `areaJson` 直接放进考勤规则表,维护起来最省事,但后续会遇到三个麻烦:
第一,多个规则复用同一个区域时会重复保存。第二,区域变更后无法知道哪些规则被影响。第三,区域审批、生效和导出会和规则表混在一起。真实项目把 `KqAfArea` 和 `KqRuleArea` 拆开,虽然表多了一张,但边界清楚:区域负责地图边界,规则负责什么时候、哪些人、哪些班次生效。
2. 已生效区域编辑要生成副本
`KqAfAreaServiceImpl.edit` 里有个很好的工程细节:如果老区域已经审核完成,编辑时不会直接覆盖,而是生成一条 `copyId=原区域ID` 的草稿记录。审核通过后再把副本数据覆盖回原区域,并删除副本。这种设计适合所有会影响薪资、绩效、审批结果的配置。
否则今天 10 点 HR 改了区域,员工 9 点的异常到底按旧区域还是新区域算?没有副本和审批记录,系统很难解释。
3. 移动端先做本地判断,但后端仍要留证据
移动端用 `checkPointInPolygon` 做快速判断,体验很好;但正式打卡记录里仍然应该保存 `longitude`、`latitude`、`areaName`、`locationType`、`address`、`clockTime` 等字段。原因是移动端判断只是"当下能不能提交",后续异常申诉、主管审核、报表统计需要能追溯当时的证据。
4. 多边形点位要限制复杂度
PC 后台表单里对 `areaJson` 做了长度限制。这个限制看似粗糙,但很实用。地图上如果画出过多点位,前端解析、移动端缓存、网络传输都会增加成本。企业考勤不是 GIS 专业系统,围栏点位要足够表达边界,但不能追求地图级精度。
五、容易踩坑的地方
|----------|-----------------|--------------------------|
| 坑 | 表现 | 建议 |
| 经纬度顺序写反 | 判断结果随机,看起来像定位漂移 | 统一约定点位为 `lng, lat` |
| 只做半径判断 | 办公楼、园区、仓库容易误判 | 复杂场景用多边形围栏 |
| 区域修改立即生效 | 历史异常无法解释 | 已生效配置走副本和审批 |
| 移动端只提示失败 | 员工不知道怎么处理 | 返回未进围栏、定位失败、规则未配置等原因 |
| 围栏点位过多 | 移动端缓存和判断变慢 | 限制点位数量或 JSON 长度 |
| 只相信前端判断 | 数据容易被篡改或无法追溯 | 打卡记录保存原始经纬度和命中类型 |
| 地图坐标系混用 | 高德、GPS 原始坐标偏移 | 统一使用 `gcj02` 或做坐标转换 |
六、这套设计可以怎么复用
电子围栏打卡不是考勤系统专属。只要业务里有"人、车、设备是否到达某个范围"的判断,都可以复用这套设计:
- 门店签到:店员是否在门店范围内开店、闭店。
- 仓库巡检:巡检人员是否到达库区或设备区。
- 工地到场:班组长是否到达施工区域。
- 养老院护理:护理员是否到达楼层或护理区。
- 外勤工单:维修人员是否到达客户现场。
可复用的不是 `checkPointInPolygon` 这个函数本身,而是整条链路:后台画围栏、配置走审批、规则绑定区域、移动端做命中判断、记录保存证据、异常可以申诉和追溯。
七、总结
定位打卡做不好,很多时候不是定位接口不准,而是系统没有把"范围"当成一个需要建模、审批和解释的业务对象。半径判断能解决第一版上线,但多部门、多区域、多规则、多角色协同时,必须把电子围栏单独抽出来。
一个相对可靠的方案应该至少做到:
-
PC 后台支持地图绘制多边形区域。
-
区域配置独立建表,并保留单位、名称、描述和审批状态。
-
考勤规则通过关系表绑定区域,不把区域 JSON 写死到规则里。
-
移动端统一用 `longitude, latitude` 判断多边形命中。
-
打卡记录保存原始经纬度、命中区域和失败原因。
-
已生效区域变更要生成副本并走审核。
这样做不是为了把考勤管得更死,而是让系统在出现争议时能说清楚:当时规则是什么、围栏在哪里、员工的位置是什么、为什么判定成功或失败。