一、什么是地理围栏?
地理围栏是一种基于位置的服务技术,通过在真实世界的地理区域周围建立虚拟边界("围栏"),当移动设备进入或离开该区域时,触发预设的响应动作。
二、核心工作原理
┌─────────────────────────────────────────┐
│ 地理围栏系统架构 │
├─────────────────────────────────────────┤
│ 1. 定义围栏 → 在地图上划定虚拟边界区域 │
│ 2. 位置监测 → GPS/WiFi/基站定位追踪 │
│ 3. 触发判断 → 检测进入/离开/停留事件 │
│ 4. 执行动作 → 推送通知/记录日志/联动控制 │
└─────────────────────────────────────────┘
三、主要应用场景
| 领域 | 具体应用 |
|---|---|
| 智能家居 | 离家自动关闭空调/灯光,回家提前开启暖气 |
| 儿童/老人安全 | 设置安全区域,离开范围自动告警通知家长 |
| 企业管理 | 外勤人员考勤打卡、车辆轨迹监控 |
| 物流配送 | 货物到达特定区域自动通知收件人 |
| 零售营销 | 顾客进入商场附近推送优惠券 |
| 宠物追踪 | 宠物跑出设定范围立即提醒主人 |
| 游戏娱乐 | 如 Pokémon GO 的地理位置游戏机制 |
四、技术实现要点
定位技术
- GPS(室外高精度)
- WiFi/蓝牙(室内定位补充)
- 基站定位(低功耗备用)
触发类型
- 进入围栏 (Enter)
- 离开围栏 (Exit)
- 在围栏内停留 (Dwell)
平台支持
- iOS: Core Location 框架(
CLCircularRegion) - Android: Geofencing API(
Geofence类) - 高德/百度/腾讯地图 SDK 均提供封装接口
五、技术架构概览
┌─────────────────────────────────────────────────────────────────┐
│ 微信小程序端 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ 地图展示组件 │ │ 实时位置采集 │ │ 订阅消息/推送通知 │ │
│ │ (Map组件) │ │ (wx.getLocation)│ │ (wx.requestSubscribe) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ 通信层 │
│ HTTPS REST API + WebSocket (可选实时推送) │
├─────────────────────────────────────────────────────────────────┤
│ Java Spring Boot后端 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ 围栏管理API │ │ 位置计算引擎 │ │ 规则触发系统 │ │
│ │ (CRUD操作) │ │ (空间算法) │ │ (事件处理+消息推送) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ 数据存储层 │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ MySQL 8.0+ │ │ Redis │ │
│ │ (围栏定义+历史记录)│ │ (实时位置缓存) │ │
│ │ SPATIAL INDEX │ │ Geo数据结构 │ │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
六、数据库设计(完整版)
-- ==================== 核心表:地理围栏 ====================
CREATE TABLE geofence (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '围栏ID',
name VARCHAR(100) NOT NULL COMMENT '围栏名称(如:家、公司)',
description VARCHAR(500) COMMENT '描述',
-- 几何形状定义
shape_type ENUM('CIRCLE', 'POLYGON', 'RECTANGLE') NOT NULL DEFAULT 'CIRCLE',
center_lat DECIMAL(10,8) COMMENT '圆心/中心点纬度',
center_lng DECIMAL(11,8) COMMENT '圆心/中心点经度',
radius INT COMMENT '半径(米), 圆形专用',
coordinates JSON COMMENT '多边形顶点坐标数组 [{"lat":x,"lng":y},...]',
-- 触发规则配置
monitor_types SET('ENTER','EXIT','DWELL') DEFAULT 'ENTER,EXIT'
COMMENT '监听事件类型',
dwell_time INT DEFAULT 0 COMMENT '停留触发时间(分钟), DWELL专用',
cooldown_minutes INT DEFAULT 5 COMMENT '事件冷却时间(防重复触发)',
-- 业务属性
fence_type ENUM('SAFETY','ATTENDANCE','MARKETING','CUSTOM')
DEFAULT 'CUSTOM' COMMENT '围栏业务类型',
create_by BIGINT NOT NULL COMMENT '创建者用户ID',
status TINYINT DEFAULT 1 COMMENT '状态:0禁用 1启用 2删除',
-- 时间戳
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 空间索引优化查询
SPATIAL INDEX idx_location (center_lat, center_lng),
INDEX idx_user_status (create_by, status)
) ENGINE=InnoDB COMMENT='地理围栏定义表';
-- ==================== 用户位置历史记录 ====================
CREATE TABLE user_location_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
geofence_id BIGINT COMMENT '当前所在围栏ID(可为空)',
-- 位置信息
lat DECIMAL(10,8) NOT NULL COMMENT '纬度',
lng DECIMAL(11,8) NOT NULL COMMENT '经度',
accuracy FLOAT COMMENT '定位精度(米)',
altitude FLOAT COMMENT '海拔',
speed FLOAT COMMENT '速度(m/s)',
-- 设备信息
device_type VARCHAR(20) COMMENT '设备类型:ios/android',
wx_version VARCHAR(20) COMMENT '微信版本',
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_time (user_id, recorded_at),
INDEX idx_location (lat, lng)
) ENGINE=InnoDB COMMENT='用户位置历史';
-- ==================== 围栏触发事件日志 ====================
CREATE TABLE geofence_event_log (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
geofence_id BIGINT NOT NULL,
event_type ENUM('ENTER','EXIT','DWELL') NOT NULL,
-- 触发时位置
trigger_lat DECIMAL(10,8) NOT NULL,
trigger_lng DECIMAL(11,8) NOT NULL,
-- 事件处理状态
process_status TINYINT DEFAULT 0
COMMENT '0待处理 1已通知 2处理成功 3处理失败',
notify_method ENUM('WX_MSG','SMS','PHONE','APP_PUSH')
DEFAULT 'WX_MSG',
notify_result VARCHAR(500) COMMENT '通知结果/失败原因',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_fence (user_id, geofence_id),
INDEX idx_time (created_at)
) ENGINE=InnoDB COMMENT='围栏事件日志';
-- ==================== 用户围栏状态缓存表(持久化备份) ====================
CREATE TABLE user_geofence_state (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL UNIQUE,
current_fence_id BIGINT COMMENT '当前所在围栏',
enter_time TIMESTAMP COMMENT '进入时间',
last_exit_time TIMESTAMP COMMENT '最后离开时间',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB COMMENT='用户围栏状态(Redis故障时备用)';
七、Java核心代码实现
7.1 实体类层 (Entity/VO/DTO)
// ==================== 地理围栏实体 ====================
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Geofence implements Serializable {
private Long id;
private String name;
private String description;
private ShapeType shapeType;
private BigDecimal centerLat;
private BigDecimal centerLng;
private Integer radius;
private List<Coordinate> coordinates;
private Set<MonitorType> monitorTypes;
private Integer dwellTime;
private Integer cooldownMinutes;
private FenceType fenceType;
private Long createBy;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 判断点是否在围栏内
*/
public boolean contains(BigDecimal lat, BigDecimal lng) {
switch (shapeType) {
case CIRCLE:
return GeoCalculator.isInCircle(lat, lng, centerLat, centerLng, radius);
case POLYGON:
case RECTANGLE:
return GeoCalculator.isInPolygon(lat, lng, coordinates);
default:
return false;
}
}
/**
* 是否需要检查停留事件
*/
public boolean needCheckDwell() {
return monitorTypes.contains(MonitorType.DWELL) && dwellTime > 0;
}
}
// ==================== 坐标点 ====================
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Coordinate {
private BigDecimal lat;
private BigDecimal lng;
}
// ==================== 位置上报DTO ====================
@Data
public class LocationReportDTO {
@NotNull(message = "用户ID不能为空")
private Long userId;
@NotNull(message = "纬度不能为空")
@DecimalMin(value = "-90.0", message = "纬度范围错误")
@DecimalMax(value = "90.0", message = "纬度范围错误")
private BigDecimal lat;
@NotNull(message = "经度不能为空")
@DecimalMin(value = "-180.0", message = "经度范围错误")
@DecimalMax(value = "180.0", message = "经度范围错误")
private BigDecimal lng;
private Float accuracy;
private Float altitude;
private Float speed;
private String deviceType;
private Long timestamp;
}
// ==================== 围栏创建DTO ====================
@Data
public class GeofenceCreateDTO {
@NotBlank(message = "围栏名称不能为空")
@Size(max = 100, message = "名称长度不能超过100")
private String name;
private String description;
@NotNull(message = "形状类型不能为空")
private ShapeType shapeType;
// 圆形必填
private BigDecimal centerLat;
private BigDecimal centerLng;
private Integer radius;
// 多边形必填
private List<Coordinate> coordinates;
@NotEmpty(message = "至少选择一种监控类型")
private Set<MonitorType> monitorTypes;
private Integer dwellTime;
private FenceType fenceType;
}
// ==================== 枚举定义 ====================
public enum ShapeType {
CIRCLE, // 圆形
POLYGON, // 多边形
RECTANGLE // 矩形
}
public enum MonitorType {
ENTER, // 进入
EXIT, // 离开
DWELL // 停留
}
public enum FenceType {
SAFETY, // 安全围栏(儿童/老人)
ATTENDANCE, // 考勤打卡
MARKETING, // 营销推送
CUSTOM // 自定义
}
public enum EventType {
ENTER, EXIT, DWELL
}
7.2 地理计算工具类 (核心算法)
@Component
@Slf4j
public class GeoCalculator {
private static final double EARTH_RADIUS = 6371000; // 地球半径(米)
private static final MathContext MC = new MathContext(10, RoundingMode.HALF_UP);
/**
* Haversine公式计算两点间距离(米)
*/
public static double distance(BigDecimal lat1, BigDecimal lng1,
BigDecimal lat2, BigDecimal lng2) {
double dLat = Math.toRadians(lat2.subtract(lat1).doubleValue());
double dLng = Math.toRadians(lng2.subtract(lng1).doubleValue());
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1.doubleValue()))
* Math.cos(Math.toRadians(lat2.doubleValue()))
* Math.sin(dLng / 2) * Math.sin(dLng / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS * c;
}
/**
* 判断点是否在圆形围栏内
*/
public static boolean isInCircle(BigDecimal pointLat, BigDecimal pointLng,
BigDecimal centerLat, BigDecimal centerLng,
Integer radius) {
if (centerLat == null || centerLng == null || radius == null) {
return false;
}
double dist = distance(pointLat, pointLng, centerLat, centerLng);
return dist <= radius;
}
/**
* 射线法(Ray Casting)判断点是否在多边形内
*/
public static boolean isInPolygon(BigDecimal lat, BigDecimal lng,
List<Coordinate> polygon) {
if (polygon == null || polygon.size() < 3) {
return false;
}
boolean inside = false;
int n = polygon.size();
double x = lng.doubleValue(), y = lat.doubleValue();
for (int i = 0, j = n - 1; i < n; j = i++) {
double xi = polygon.get(i).getLng().doubleValue();
double yi = polygon.get(i).getLat().doubleValue();
double xj = polygon.get(j).getLng().doubleValue();
double yj = polygon.get(j).getLat().doubleValue();
// 检查点是否在边的两端点之间(y坐标范围内)
boolean intersect = ((yi > y) != (yj > y))
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) {
inside = !inside;
}
}
return inside;
}
/**
* 计算GeoHash (用于空间索引优化)
*/
public static String geoHash(BigDecimal lat, BigDecimal lng, int precision) {
// 使用h3或geohash-java库实现
// 这里简化示意
return lat.setScale(precision, RoundingMode.HALF_UP) + ","
+ lng.setScale(precision, RoundingMode.HALF_UP);
}
}
7.3 核心业务服务层
@Service
@Slf4j
public class GeofenceService {
@Autowired private GeofenceMapper geofenceMapper;
@Autowired private UserLocationMapper locationMapper;
@Autowired private EventLogMapper eventLogMapper;
@Autowired private StringRedisTemplate redisTemplate;
@Autowired private ApplicationEventPublisher eventPublisher;
// Redis Key常量
private static final String USER_CURRENT_FENCE = "geofence:user:%d:current";
private static final String USER_ENTER_TIME = "geofence:user:%d:fence:%d:enter";
private static final String USER_DWELL_TRIGGERED = "geofence:user:%d:fence:%d:dwell";
private static final String EVENT_COOLDOWN = "geofence:event:cooldown:%d:%d:%s";
// ==================== 围栏管理 ====================
@Transactional
public Long createFence(GeofenceCreateDTO dto, Long userId) {
// 参数校验
validateFenceParams(dto);
Geofence fence = Geofence.builder()
.name(dto.getName())
.description(dto.getDescription())
.shapeType(dto.getShapeType())
.centerLat(dto.getCenterLat())
.centerLng(dto.getCenterLng())
.radius(dto.getRadius())
.coordinates(dto.getCoordinates())
.monitorTypes(dto.getMonitorTypes())
.dwellTime(dto.getDwellTime())
.fenceType(dto.getFenceType())
.createBy(userId)
.status(1)
.build();
geofenceMapper.insert(fence);
log.info("用户{}创建围栏: {}", userId, fence.getName());
return fence.getId();
}
public List<Geofence> listUserFences(Long userId) {
return geofenceMapper.selectByUserId(userId);
}
// ==================== 位置处理核心逻辑 ====================
@Transactional
public void processLocationReport(LocationReportDTO report) {
Long userId = report.getUserId();
// 1. 保存位置历史
saveLocationHistory(report);
// 2. 获取用户相关的有效围栏
List<Geofence> fences = getRelevantFences(userId, report);
// 3. 检查每个围栏的触发条件
for (Geofence fence : fences) {
try {
checkFenceTrigger(userId, report, fence);
} catch (Exception e) {
log.error("检查围栏触发失败: user={}, fence={}, error={}",
userId, fence.getId(), e.getMessage());
}
}
// 4. 更新实时位置到Redis (用于快速查询)
updateRealtimeLocation(userId, report);
}
/**
* 检查单个围栏的触发条件
*/
private void checkFenceTrigger(Long userId, LocationReportDTO report, Geofence fence) {
boolean currentlyInside = fence.contains(report.getLat(), report.getLng());
Long fenceId = fence.getId();
// 获取之前的状态
String currentFenceKey = String.format(USER_CURRENT_FENCE, userId);
String previousFenceData = redisTemplate.opsForValue().get(currentFenceKey);
Long previousFenceId = previousFenceData != null ? Long.valueOf(previousFenceData) : null;
boolean wasInside = fenceId.equals(previousFenceId);
// 冷却检查: 防止重复触发
if (isInCooldown(userId, fenceId, currentlyInside ? EventType.ENTER : EventType.EXIT)) {
return;
}
// ===== ENTER 事件 =====
if (fence.getMonitorTypes().contains(MonitorType.ENTER)
&& !wasInside && currentlyInside) {
handleEnterEvent(userId, fence, report);
}
// ===== EXIT 事件 =====
else if (fence.getMonitorTypes().contains(MonitorType.EXIT)
&& wasInside && !currentlyInside) {
handleExitEvent(userId, fence, report);
}
// ===== DWELL 停留事件 =====
else if (fence.needCheckDwell() && currentlyInside && wasInside) {
handleDwellEvent(userId, fence, report);
}
}
private void handleEnterEvent(Long userId, Geofence fence, LocationReportDTO report) {
Long fenceId = fence.getId();
// 更新Redis状态
String currentFenceKey = String.format(USER_CURRENT_FENCE, userId);
String enterTimeKey = String.format(USER_ENTER_TIME, userId, fenceId);
redisTemplate.opsForValue().set(currentFenceKey, String.valueOf(fenceId),
Duration.ofHours(24));
redisTemplate.opsForValue().set(enterTimeKey, String.valueOf(System.currentTimeMillis()),
Duration.ofHours(24));
// 清除停留触发标记(重新计时)
String dwellKey = String.format(USER_DWELL_TRIGGERED, userId, fenceId);
redisTemplate.delete(dwellKey);
// 设置冷却
setCooldown(userId, fenceId, EventType.ENTER, fence.getCooldownMinutes());
// 记录事件并发布
GeofenceEvent event = GeofenceEvent.builder()
.userId(userId)
.geofenceId(fenceId)
.eventType(EventType.ENTER)
.lat(report.getLat())
.lng(report.getLng())
.fenceName(fence.getName())
.fenceType(fence.getFenceType())
.timestamp(LocalDateTime.now())
.build();
saveEventLog(event);
eventPublisher.publishEvent(event);
log.info("用户{} 进入围栏: {}", userId, fence.getName());
}
private void handleExitEvent(Long userId, Geofence fence, LocationReportDTO report) {
Long fenceId = fence.getId();
// 清除Redis状态
String currentFenceKey = String.format(USER_CURRENT_FENCE, userId);
String enterTimeKey = String.format(USER_ENTER_TIME, userId, fenceId);
String dwellKey = String.format(USER_DWELL_TRIGGERED, userId, fenceId);
redisTemplate.delete(currentFenceKey);
redisTemplate.delete(enterTimeKey);
redisTemplate.delete(dwellKey);
// 设置冷却
setCooldown(userId, fenceId, EventType.EXIT, fence.getCooldownMinutes());
// 发布事件
GeofenceEvent event = GeofenceEvent.builder()
.userId(userId)
.geofenceId(fenceId)
.eventType(EventType.EXIT)
.lat(report.getLat())
.lng(report.getLng())
.fenceName(fence.getName())
.fenceType(fence.getFenceType())
.timestamp(LocalDateTime.now())
.build();
saveEventLog(event);
eventPublisher.publishEvent(event);
log.info("用户{} 离开围栏: {}", userId, fence.getName());
}
private void handleDwellEvent(Long userId, Geofence fence, LocationReportDTO report) {
Long fenceId = fence.getId();
String enterTimeKey = String.format(USER_ENTER_TIME, userId, fenceId);
String dwellKey = String.format(USER_DWELL_TRIGGERED, userId, fenceId);
// 检查是否已触发过
if (Boolean.TRUE.equals(redisTemplate.hasKey(dwellKey))) {
return;
}
// 计算停留时间
String enterTimeStr = redisTemplate.opsForValue().get(enterTimeKey);
if (enterTimeStr == null) return;
long enterTime = Long.parseLong(enterTimeStr);
long dwellMinutes = (System.currentTimeMillis() - enterTime) / 60000;
if (dwellMinutes >= fence.getDwellTime()) {
// 标记已触发
redisTemplate.opsForValue().set(dwellKey, "1", Duration.ofHours(12));
// 发布停留事件
GeofenceEvent event = GeofenceEvent.builder()
.userId(userId)
.geofenceId(fenceId)
.eventType(EventType.DWELL)
.lat(report.getLat())
.lng(report.getLng())
.fenceName(fence.getName())
.fenceType(fence.getFenceType())
.dwellMinutes((int) dwellMinutes)
.timestamp(LocalDateTime.now())
.build();
saveEventLog(event);
eventPublisher.publishEvent(event);
log.info("用户{} 在围栏{} 停留{}分钟", userId, fence.getName(), dwellMinutes);
}
}
// ==================== 辅助方法 ====================
private void validateFenceParams(GeofenceCreateDTO dto) {
if (dto.getShapeType() == ShapeType.CIRCLE) {
if (dto.getCenterLat() == null || dto.getCenterLng() == null || dto.getRadius() == null) {
throw new BusinessException("圆形围栏必须设置圆心和半径");
}
if (dto.getRadius() <= 0 || dto.getRadius() > 10000) {
throw new BusinessException("半径范围1-10000米");
}
} else if (dto.getCoordinates() == null || dto.getCoordinates().size() < 3) {
throw new BusinessException("多边形围栏至少需要3个顶点");
}
}
private List<Geofence> getRelevantFences(Long userId, LocationReportDTO report) {
// 优化: 先通过GeoHash或距离粗略筛选附近围栏,再精确计算
// 这里简化查询用户创建的所有有效围栏
return geofenceMapper.selectActiveByUserId(userId);
}
private void saveLocationHistory(LocationReportDTO report) {
UserLocationHistory history = new UserLocationHistory();
BeanUtils.copyProperties(report, history);
history.setRecordedAt(LocalDateTime.now());
locationMapper.insert(history);
}
private void updateRealtimeLocation(Long userId, LocationReportDTO report) {
String key = "user:location:realtime:" + userId;
String value = String.format("%s,%s,%d", report.getLat(), report.getLng(),
System.currentTimeMillis());
redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(10));
}
private void saveEventLog(GeofenceEvent event) {
EventLog logEntry = new EventLog();
logEntry.setUserId(event.getUserId());
logEntry.setGeofenceId(event.getGeofenceId());
logEntry.setEventType(event.getEventType());
logEntry.setTriggerLat(event.getLat());
logEntry.setTriggerLng(event.getLng());
logEntry.setProcessStatus(0);
eventLogMapper.insert(logEntry);
}
private boolean isInCooldown(Long userId, Long fenceId, EventType type) {
String key = String.format(EVENT_COOLDOWN, userId, fenceId, type.name());
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
private void setCooldown(Long userId, Long fenceId, EventType type, int minutes) {
if (minutes <= 0) minutes = 5;
String key = String.format(EVENT_COOLDOWN, userId, fenceId, type.name());
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(minutes));
}
}
7.4 事件监听与消息推送
// ==================== 事件定义 ====================
@Data
@Builder
public class GeofenceEvent {
private Long userId;
private Long geofenceId;
private EventType eventType;
private BigDecimal lat;
private BigDecimal lng;
private String fenceName;
private FenceType fenceType;
private Integer dwellMinutes;
private LocalDateTime timestamp;
}
// ==================== 事件监听器 ====================
@Component
@Slf4j
public class GeofenceEventHandler {
@Autowired private WxMaService wxMaService;
@Autowired private SmsService smsService;
@Autowired private UserService userService;
@Autowired private EventLogMapper eventLogMapper;
/**
* 处理围栏触发事件
*/
@EventListener
@Async("taskExecutor")
public void onGeofenceTriggered(GeofenceEvent event) {
log.info("处理围栏事件: user={}, fence={}, type={}",
event.getUserId(), event.getFenceName(), event.getEventType());
try {
switch (event.getFenceType()) {
case SAFETY:
handleSafetyEvent(event);
break;
case ATTENDANCE:
handleAttendanceEvent(event);
break;
case MARKETING:
handleMarketingEvent(event);
break;
default:
handleDefaultEvent(event);
}
} catch (Exception e) {
log.error("处理事件失败", e);
updateEventStatus(event, 3, e.getMessage());
}
}
/**
* 安全围栏处理 (儿童/老人监护)
*/
private void handleSafetyEvent(GeofenceEvent event) {
User user = userService.getById(event.getUserId());
if (event.getEventType() == EventType.EXIT) {
// 离开安全区域 - 紧急通知
String msg = String.format("【安全告警】%s 已离开安全区域 %s,当前位置: %s, %s",
user.getName(), event.getFenceName(), event.getLat(), event.getLng());
// 1. 推送微信订阅消息
sendWxSubscribeMsg(event.getUserId(), "安全区域提醒", msg);
// 2. 短信通知紧急联系人
if (user.getEmergencyContact() != null) {
smsService.send(user.getEmergencyContact(), msg);
}
// 3. 记录为紧急事件
updateEventStatus(event, 2, "已发送安全告警");
} else if (event.getEventType() == EventType.ENTER) {
sendWxSubscribeMsg(event.getUserId(), "安全区域提醒",
String.format("%s 已进入安全区域 %s", user.getName(), event.getFenceName()));
updateEventStatus(event, 2, "已进入安全区域");
}
}
/**
* 考勤围栏处理
*/
private void handleAttendanceEvent(GeofenceEvent event) {
if (event.getEventType() == EventType.ENTER) {
// 上班打卡
AttendanceRecord record = new AttendanceRecord();
record.setUserId(event.getUserId());
record.setCheckInTime(LocalDateTime.now());
record.setLocation(event.getLat() + "," + event.getLng());
record.setFenceId(event.getGeofenceId());
// 保存考勤记录...
sendWxSubscribeMsg(event.getUserId(), "考勤打卡",
String.format("上班打卡成功\n时间: %s\n地点: %s",
LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm")),
event.getFenceName()));
} else if (event.getEventType() == EventType.EXIT) {
// 下班打卡
sendWxSubscribeMsg(event.getUserId(), "考勤打卡",
String.format("下班打卡成功\n时间: %s",
LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm"))));
}
updateEventStatus(event, 2, "考勤记录已保存");
}
/**
* 营销围栏处理
*/
private void handleMarketingEvent(GeofenceEvent event) {
if (event.getEventType() == EventType.ENTER) {
// 推送优惠券
Coupon coupon = generateNearbyCoupon(event.getGeofenceId());
sendWxSubscribeMsg(event.getUserId(), "附近优惠",
String.format("您附近有新优惠!\n%s\n点击查看详情", coupon.getTitle()));
}
updateEventStatus(event, 2, "营销推送已发送");
}
/**
* 默认处理
*/
private void handleDefaultEvent(GeofenceEvent event) {
String action = event.getEventType() == EventType.ENTER ? "进入" :
event.getEventType() == EventType.EXIT ? "离开" : "停留";
sendWxSubscribeMsg(event.getUserId(), "位置提醒",
String.format("您已%s %s", action, event.getFenceName()));
updateEventStatus(event, 2, "通知已发送");
}
/**
* 发送微信小程序订阅消息
*/
private void sendWxSubscribeMsg(Long userId, String title, String content) {
try {
// 获取用户openid
String openid = userService.getOpenidByUserId(userId);
// 构造订阅消息
WxMaSubscribeMessage message = WxMaSubscribeMessage.builder()
.toUser(openid)
.templateId("YOUR_TEMPLATE_ID") // 微信小程序订阅消息模板ID
.data(Arrays.asList(
new WxMaSubscribeMessage.Data("thing1", title),
new WxMaSubscribeMessage.Data("thing2", content),
new WxMaSubscribeMessage.Data("time3",
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")))
))
.build();
wxMaService.getMsgService().sendSubscribeMsg(message);
log.info("订阅消息发送成功: user={}", userId);
} catch (Exception e) {
log.error("发送订阅消息失败", e);
throw e;
}
}
private void updateEventStatus(GeofenceEvent event, int status, String result) {
// 更新事件处理状态
EventLog update = new EventLog();
update.setProcessStatus(status);
update.setNotifyResult(result);
// 根据userId和时间查询更新...
}
}
7.5 REST API 控制器
@RestController
@RequestMapping("/api/geofence")
@Validated
@Slf4j
public class GeofenceController {
@Autowired private GeofenceService geofenceService;
@Autowired private GeofenceQueryService queryService;
// ==================== 围栏管理接口 ====================
@PostMapping("/create")
public Result<Long> createFence(@RequestBody @Valid GeofenceCreateDTO dto,
@RequestAttribute("userId") Long userId) {
Long fenceId = geofenceService.createFence(dto, userId);
return Result.success(fenceId);
}
@PutMapping("/{id}")
public Result<Void> updateFence(@PathVariable Long id,
@RequestBody GeofenceCreateDTO dto,
@RequestAttribute("userId") Long userId) {
geofenceService.updateFence(id, dto, userId);
return Result.success();
}
@DeleteMapping("/{id}")
public Result<Void> deleteFence(@PathVariable Long id,
@RequestAttribute("userId") Long userId) {
geofenceService.deleteFence(id, userId);
return Result.success();
}
@GetMapping("/list")
public Result<List<GeofenceVO>> listFences(@RequestAttribute("userId") Long userId) {
List<Geofence> list = geofenceService.listUserFences(userId);
List<GeofenceVO> voList = list.stream().map(this::convertToVO).collect(Collectors.toList());
return Result.success(voList);
}
@GetMapping("/{id}")
public Result<GeofenceVO> getFenceDetail(@PathVariable Long id) {
Geofence fence = geofenceService.getById(id);
return Result.success(convertToVO(fence));
}
// ==================== 位置上报接口 ====================
@PostMapping("/location/report")
public Result<LocationProcessResult> reportLocation(@RequestBody @Valid LocationReportDTO dto) {
// 异步处理位置上报
geofenceService.processLocationReport(dto);
// 返回当前状态
LocationProcessResult result = queryService.getCurrentStatus(dto.getUserId());
return Result.success(result);
}
@PostMapping("/location/batch")
public Result<Void> batchReportLocation(@RequestBody List<LocationReportDTO> reports) {
// 批量上报,用于弱网环境下缓存后批量上传
for (LocationReportDTO report : reports) {
geofenceService.processLocationReport(report);
}
return Result.success();
}
// ==================== 查询接口 ====================
@GetMapping("/check")
public Result<List<Long>> checkLocation(@RequestParam BigDecimal lat,
@RequestParam BigDecimal lng,
@RequestAttribute("userId") Long userId) {
// 检查当前位置在哪些围栏内
List<Long> fenceIds = queryService.checkPointInFences(userId, lat, lng);
return Result.success(fenceIds);
}
@GetMapping("/nearby")
public Result<List<GeofenceVO>> getNearbyFences(@RequestParam BigDecimal lat,
@RequestParam BigDecimal lng,
@RequestParam(defaultValue = "1000") Integer radius,
@RequestAttribute("userId") Long userId) {
// 获取附近的围栏
List<GeofenceVO> list = queryService.getNearbyFences(userId, lat, lng, radius);
return Result.success(list);
}
@GetMapping("/history")
public Result<PageResult<UserLocationVO>> getLocationHistory(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size,
@RequestAttribute("userId") Long userId) {
PageResult<UserLocationVO> result = queryService.getLocationHistory(userId, start, end, page, size);
return Result.success(result);
}
@GetMapping("/events")
public Result<PageResult<GeofenceEventVO>> getEventLogs(
@RequestParam(required = false) Long fenceId,
@RequestParam(required = false) EventType eventType,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size,
@RequestAttribute("userId") Long userId) {
PageResult<GeofenceEventVO> result = queryService.getEventLogs(userId, fenceId, eventType, page, size);
return Result.success(result);
}
// ==================== 辅助方法 ====================
private GeofenceVO convertToVO(Geofence fence) {
GeofenceVO vo = new GeofenceVO();
BeanUtils.copyProperties(fence, vo);
return vo;
}
}
八、微信小程序端实现
8.1 页面结构 (WXML)
<!-- pages/geofence/geofence.wxml -->
<view class="container">
<!-- 地图组件 -->
<map
id="geofenceMap"
class="map"
latitude="{{latitude}}"
longitude="{{longitude}}"
scale="{{scale}}"
show-location
markers="{{markers}}"
circles="{{circles}}"
polygons="{{polygons}}"
bindmarkertap="onMarkerTap"
bindregionchange="onRegionChange">
</map>
<!-- 定位按钮 -->
<view class="location-btn" bindtap="moveToCurrentLocation">
<image src="/images/location.png" mode="aspectFit"/>
</view>
<!-- 围栏列表面板 -->
<view class="fence-panel {{panelExpanded ? 'expanded' : ''}}">
<view class="panel-header" bindtap="togglePanel">
<text class="title">我的围栏 ({{fences.length}})</text>
<image class="arrow" src="/images/arrow.png" animation="{{arrowAnimation}}"/>
</view>
<scroll-view class="fence-list" scroll-y>
<view wx:for="{{fences}}" wx:key="id" class="fence-item"
bindtap="focusOnFence" data-fence="{{item}}">
<view class="fence-info">
<text class="name">{{item.name}}</text>
<text class="type">{{item.shapeType === 'CIRCLE' ? '圆形' : '多边形'}}</text>
</view>
<view class="fence-status {{item.isInside ? 'inside' : ''}}">
{{item.isInside ? '范围内' : '范围外'}}
</view>
</view>
</scroll-view>
<button class="add-btn" bindtap="showAddModal">+ 新建围栏</button>
</view>
<!-- 创建围栏弹窗 -->
<view class="modal" wx:if="{{showAddModal}}">
<view class="modal-content">
<view class="modal-header">创建围栏</view>
<input placeholder="围栏名称" bindinput="onNameInput"/>
<picker mode="selector" range="{{shapeTypes}}" bindchange="onShapeChange">
<view class="picker">形状: {{selectedShape}}</view>
</picker>
<input type="digit" placeholder="半径(米)" wx:if="{{selectedShape === '圆形'}}"
bindinput="onRadiusInput"/>
<view class="modal-btns">
<button size="mini" bindtap="hideAddModal">取消</button>
<button size="mini" type="primary" bindtap="confirmAdd">确定</button>
</view>
</view>
</view>
</view>
8.2 页面逻辑 (JS)
// pages/geofence/geofence.js
const app = getApp();
const API_BASE = 'https://your-api-domain.com/api';
Page({
data: {
latitude: 39.9042, // 默认北京
longitude: 116.4074,
scale: 14,
fences: [],
markers: [],
circles: [],
polygons: [],
isTracking: false,
panelExpanded: false,
showAddModal: false,
selectedShape: '圆形',
shapeTypes: ['圆形', '多边形'],
newFenceRadius: 500
},
mapContext: null,
locationWatcher: null,
reportingInterval: null,
onLoad() {
this.mapContext = wx.createMapContext('geofenceMap');
this.checkPermissionAndInit();
},
onShow() {
if (!this.data.isTracking) {
this.startLocationTracking();
}
},
onHide() {
// 保持后台追踪,不停止
},
onUnload() {
this.stopLocationTracking();
},
// ==================== 权限检查与初始化 ====================
async checkPermissionAndInit() {
try {
// 检查位置权限
const setting = await wx.getSetting();
if (!setting.authSetting['scope.userLocation']) {
await wx.authorize({ scope: 'scope.userLocation' });
}
// 申请后台定位权限 (需先在app.json声明)
if (!setting.authSetting['scope.userLocationBackground']) {
const res = await wx.showModal({
title: '需要后台定位',
content: '地理围栏功能需要在后台持续定位,是否允许?'
});
if (res.confirm) {
await wx.authorize({ scope: 'scope.userLocationBackground' });
}
}
// 获取当前位置并加载数据
await this.getCurrentLocation();
await this.loadFences();
this.startLocationTracking();
} catch (err) {
wx.showToast({ title: '权限获取失败', icon: 'none' });
console.error('权限错误:', err);
}
},
// ==================== 位置追踪核心 ====================
async startLocationTracking() {
try {
// 开启后台定位
await wx.startLocationUpdateBackground();
this.setData({ isTracking: true });
// 监听位置变化
wx.onLocationChange(this.handleLocationChange.bind(this));
// 定时上报 (备用,防止onLocationChange不稳定)
this.reportingInterval = setInterval(() => {
this.reportCachedLocation();
}, 10000);
wx.showToast({ title: '定位已开启', icon: 'success' });
} catch (err) {
console.error('启动定位失败:', err);
// 降级为前台定位
this.startForegroundLocation();
}
},
startForegroundLocation() {
wx.startLocationUpdate();
wx.onLocationChange(this.handleLocationChange.bind(this));
this.setData({ isTracking: true });
wx.showToast({ title: '已切换前台定位', icon: 'none' });
},
stopLocationTracking() {
wx.stopLocationUpdate();
wx.offLocationChange();
clearInterval(this.reportingInterval);
this.setData({ isTracking: false });
},
// 位置变化处理
handleLocationChange(res) {
const { latitude, longitude, accuracy, speed } = res;
// 精度过滤:误差大于100米的位置丢弃
if (accuracy > 100) {
console.log('精度不足,丢弃:', accuracy);
return;
}
// 缓存最新位置
this.lastLocation = {
lat: latitude,
lng: longitude,
accuracy,
speed,
timestamp: Date.now()
};
// 节流上报:每5秒上报一次
if (!this.lastReportTime || Date.now() - this.lastReportTime > 5000) {
this.reportLocation(this.lastLocation);
}
// 更新地图中心(仅在跟随模式)
if (this.data.followLocation) {
this.setData({ latitude, longitude });
}
},
// 上报位置到服务器
async reportLocation(location) {
this.lastReportTime = Date.now();
try {
const res = await wx.request({
url: `${API_BASE}/geofence/location/report`,
method: 'POST',
data: {
userId: app.globalData.userId,
lat: location.lat,
lng: location.lng,
accuracy: location.accuracy,
speed: location.speed,
deviceType: wx.getSystemInfoSync().platform,
timestamp: location.timestamp
}
});
// 处理服务器返回的围栏状态
if (res.data && res.data.data) {
this.updateFenceStatus(res.data.data.currentFences);
}
} catch (err) {
console.error('位置上报失败:', err);
// 缓存失败位置,稍后批量上报
this.cacheFailedReport(location);
}
},
// 缓存失败的位置上报
cacheFailedReport(location) {
let failed = wx.getStorageSync('failed_locations') || [];
failed.push(location);
if (failed.length > 50) failed = failed.slice(-50); // 限制缓存数量
wx.setStorageSync('failed_locations', failed);
},
// 批量上报缓存的位置
async reportCachedLocation() {
const failed = wx.getStorageSync('failed_locations');
if (!failed || failed.length === 0) return;
try {
await wx.request({
url: `${API_BASE}/geofence/location/batch`,
method: 'POST',
data: failed.map(loc => ({
userId: app.globalData.userId,
...loc
}))
});
wx.removeStorageSync('failed_locations');
} catch (err) {
console.error('批量上报失败:', err);
}
},
// ==================== 围栏数据管理 ====================
async loadFences() {
try {
const res = await wx.request({
url: `${API_BASE}/geofence/list`,
data: { userId: app.globalData.userId }
});
const fences = res.data.data || [];
this.setData({ fences });
this.renderFencesOnMap(fences);
} catch (err) {
wx.showToast({ title: '加载围栏失败', icon: 'none' });
}
},
// 在地图上渲染围栏
renderFencesOnMap(fences) {
const markers = [];
const circles = [];
const polygons = [];
fences.forEach(fence => {
// 标记点
markers.push({
id: fence.id,
latitude: fence.centerLat,
longitude: fence.centerLng,
title: fence.name,
iconPath: '/images/fence-marker.png',
width: 32,
height: 32,
callout: {
content: fence.name,
color: '#000',
fontSize: 14,
borderRadius: 4,
padding: 8,
display: 'BYCLICK'
}
});
if (fence.shapeType === 'CIRCLE') {
circles.push({
latitude: fence.centerLat,
longitude: fence.centerLng,
radius: fence.radius,
fillColor: '#1AAD1980', // 半透明绿色
strokeColor: '#1AAD19',
strokeWidth: 2
});
} else {
// 多边形
polygons.push({
points: fence.coordinates.map(c => ({
latitude: c.lat,
longitude: c.lng
})),
fillColor: '#1AAD1980',
strokeColor: '#1AAD19',
strokeWidth: 2
});
}
});
this.setData({ markers, circles, polygons });
},
// 更新围栏状态(高亮当前在范围内的)
updateFenceStatus(currentFenceIds) {
const fences = this.data.fences.map(f => ({
...f,
isInside: currentFenceIds.includes(f.id)
}));
this.setData({ fences });
},
// ==================== 交互操作 ====================
moveToCurrentLocation() {
this.mapContext.moveToLocation({
longitude: this.data.longitude,
latitude: this.data.latitude
});
this.setData({ followLocation: true });
},
focusOnFence(e) {
const fence = e.currentTarget.dataset.fence;
this.setData({
latitude: fence.centerLat,
longitude: fence.centerLng,
scale: fence.shapeType === 'CIRCLE' ? this.getScaleByRadius(fence.radius) : 16,
followLocation: false
});
this.togglePanel();
},
getScaleByRadius(radius) {
// 根据半径计算合适的缩放级别
if (radius < 100) return 18;
if (radius < 500) return 16;
if (radius < 1000) return 15;
if (radius < 5000) return 13;
return 12;
},
togglePanel() {
this.setData({ panelExpanded: !this.data.panelExpanded });
},
// ==================== 创建围栏 ====================
showAddModal() {
// 获取地图中心作为默认位置
this.mapContext.getCenterLocation({
success: (res) => {
this.newFenceCenter = {
lat: res.latitude,
lng: res.longitude
};
this.setData({ showAddModal: true });
}
});
},
hideAddModal() {
this.setData({ showAddModal: false });
},
onNameInput(e) {
this.newFenceName = e.detail.value;
},
onShapeChange(e) {
const shapes = ['CIRCLE', 'POLYGON'];
this.setData({
selectedShape: this.data.shapeTypes[e.detail.value],
selectedShapeType: shapes[e.detail.value]
});
},
onRadiusInput(e) {
this.setData({ newFenceRadius: parseInt(e.detail.value) || 500 });
},
async confirmAdd() {
if (!this.newFenceName) {
wx.showToast({ title: '请输入名称', icon: 'none' });
return;
}
try {
await wx.request({
url: `${API_BASE}/geofence/create`,
method: 'POST',
data: {
name: this.newFenceName,
shapeType: this.data.selectedShapeType || 'CIRCLE',
centerLat: this.newFenceCenter.lat,
centerLng: this.newFenceCenter.lng,
radius: this.data.newFenceRadius,
monitorTypes: ['ENTER', 'EXIT'],
fenceType: 'CUSTOM'
}
});
wx.showToast({ title: '创建成功', icon: 'success' });
this.hideAddModal();
this.loadFences(); // 刷新列表
} catch (err) {
wx.showToast({ title: '创建失败', icon: 'none' });
}
}
});
8.3 样式 (WXSS)
/* pages/geofence/geofence.wxss */
.container {
position: relative;
width: 100vw;
height: 100vh;
}
.map {
width: 100%;
height: 100%;
}
.location-btn {
position: absolute;
right: 20rpx;
bottom: 300rpx;
width: 80rpx;
height: 80rpx;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.1);
}
.location-btn image {
width: 40rpx;
height: 40rpx;
}
.fence-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: white;
border-radius: 20rpx 20rpx 0 0;
box-shadow: 0 -2rpx 20rpx rgba(0,0,0,0.1);
transition: transform 0.3s;
transform: translateY(calc(100% - 100rpx));
max-height: 60vh;
}
.fence-panel.expanded {
transform: translateY(0);
}
.panel-header {
height: 100rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30rpx;
border-bottom: 1rpx solid #eee;
}
.title {
font-size: 32rpx;
font-weight: bold;
}
.arrow {
width: 32rpx;
height: 32rpx;
transition: transform 0.3s;
}
.fence-panel.expanded .arrow {
transform: rotate(180deg);
}
.fence-list {
max-height: calc(60vh - 200rpx);
padding: 20rpx 0;
}
.fence-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.fence-info {
display: flex;
flex-direction: column;
}
.name {
font-size: 30rpx;
color: #333;
}
.type {
font-size: 24rpx;
color: #999;
margin-top: 6rpx;
}
.fence-status {
padding: 6rpx 16rpx;
border-radius: 20rpx;
font-size: 24rpx;
color: #999;
background: #f5f5f5;
}
.fence-status.inside {
color: #1AAD19;
background: #E8F5E9;
}
.add-btn {
margin: 20rpx 30rpx;
background: #1AAD19;
color: white;
border-radius: 40rpx;
}
/* 弹窗样式 */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 80%;
background: white;
border-radius: 16rpx;
padding: 40rpx;
}
.modal-header {
font-size: 36rpx;
font-weight: bold;
text-align: center;
margin-bottom: 30rpx;
}
.modal input, .picker {
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 20rpx;
margin-bottom: 20rpx;
font-size: 28rpx;
}
.modal-btns {
display: flex;
justify-content: space-between;
margin-top: 30rpx;
}
.modal-btns button {
width: 45%;
}
九、配置文件
9.1 小程序 app.json
{
"pages": [
"pages/geofence/geofence",
"pages/index/index"
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#fff",
"navigationBarTitleText": "地理围栏",
"navigationBarTextStyle": "black"
},
"permission": {
"scope.userLocation": {
"desc": "你的位置信息将用于地理围栏功能"
}
},
"requiredBackgroundModes": ["location"],
"requiredPrivateInfos": [
"getLocation",
"startLocationUpdate",
"startLocationUpdateBackground"
]
}
9.2 Spring Boot 配置 (application.yml)
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/geofence_db?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
# 微信小程序配置
wx:
miniapp:
appid: your_appid
secret: your_secret
# 线程池配置
task:
executor:
core-pool-size: 10
max-pool-size: 20
queue-capacity: 100
# 日志
logging:
level:
com.yourpackage.geofence: DEBUG
十、Maven依赖 (pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.14</version>
</parent>
<groupId>com.example</groupId>
<artifactId>geofence-service</artifactId>
<version>1.0.0</version>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MySQL & MyBatis -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
<!-- 微信小程序SDK -->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-miniapp</artifactId>
<version>4.5.0</version>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 空间计算库 (可选,复杂几何计算) -->
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>jts-core</artifactId>
<version>1.19.0</version>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
十一、性能优化建议
| 优化点 | 具体方案 |
|---|---|
| 空间索引 | MySQL 8.0+ 使用 SPATIAL INDEX,或引入 PostGIS |
| GeoHash分片 | 使用 Redis Geo 命令 GEORADIUS 快速筛选附近围栏 |
| 缓存策略 | 用户围栏列表本地缓存,减少DB查询 |
| 批量处理 | 位置上报先入消息队列,异步批量消费 |
| 计算优化 | 先进行矩形包围盒粗筛,再精确计算 |
| 降级方案 | Redis故障时自动切换到DB查询 |
注意事项
- 电量消耗 --- GPS持续定位较耗电,建议结合运动传感器优化
- 精度问题 --- 城市峡谷、室内环境可能影响定位准确性
- 隐私合规 --- 需用户明确授权位置权限,符合 GDPR/个人信息保护法
- 围栏数量限制 --- iOS 通常限制同时监测约 20 个围栏