diff --git a/sql/mysql/ops_device_trajectory.sql b/sql/mysql/ops_device_trajectory.sql
new file mode 100644
index 00000000..b080b7ba
--- /dev/null
+++ b/sql/mysql/ops_device_trajectory.sql
@@ -0,0 +1,37 @@
+-- =============================================
+-- 设备轨迹记录表
+-- 记录工牌设备进出各区域的轨迹(进入时创建,离开时更新)
+-- =============================================
+
+CREATE TABLE IF NOT EXISTS `ops_device_trajectory` (
+ `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `device_id` BIGINT NOT NULL COMMENT '工牌设备ID',
+ `device_name` VARCHAR(64) DEFAULT NULL COMMENT '设备名称(冗余)',
+ `nickname` VARCHAR(64) DEFAULT NULL COMMENT '设备备注名称(冗余)',
+ `person_id` BIGINT DEFAULT NULL COMMENT '人员ID(预留)',
+ `person_name` VARCHAR(64) DEFAULT NULL COMMENT '人员名称(预留)',
+ `area_id` BIGINT NOT NULL COMMENT '区域ID',
+ `area_name` VARCHAR(128) DEFAULT NULL COMMENT '区域名称(冗余)',
+ `beacon_mac` VARCHAR(64) DEFAULT NULL COMMENT '匹配的Beacon MAC',
+ `enter_time` DATETIME NOT NULL COMMENT '进入时间',
+ `leave_time` DATETIME DEFAULT NULL COMMENT '离开时间',
+ `duration_seconds` INT DEFAULT NULL COMMENT '停留时长(秒)',
+ `leave_reason` VARCHAR(32) DEFAULT NULL COMMENT '离开原因: SIGNAL_LOSS/AREA_SWITCH/DEVICE_OFFLINE',
+ `enter_rssi` INT DEFAULT NULL COMMENT '进入时RSSI',
+ `tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
+ `creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
+ `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+ PRIMARY KEY (`id`),
+ INDEX `idx_device_enter` (`device_id`, `enter_time`),
+ INDEX `idx_area_enter` (`area_id`, `enter_time`),
+ INDEX `idx_device_area` (`device_id`, `area_id`),
+ INDEX `idx_tenant` (`tenant_id`),
+ INDEX `idx_device_area_leave` (`device_id`, `area_id`, `leave_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='设备轨迹记录';
+
+-- 优化:为 selectOpenRecord 查询(device_id + area_id + leave_time IS NULL)添加索引
+-- 注:已有数据的线上环境需单独执行以下 ALTER(如数据量大请在低峰期执行):
+-- ALTER TABLE `ops_device_trajectory` ADD INDEX `idx_device_area_leave` (`device_id`, `area_id`, `leave_time`);
diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/DeviceLocationDTO.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/DeviceLocationDTO.java
new file mode 100644
index 00000000..14ff1eba
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/DeviceLocationDTO.java
@@ -0,0 +1,36 @@
+package com.viewsh.module.iot.api.trajectory;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 设备实时位置 DTO
+ *
+ * @author lzh
+ */
+@Schema(description = "RPC - 设备实时位置")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DeviceLocationDTO {
+
+ @Schema(description = "设备ID", example = "31")
+ private Long deviceId;
+
+ @Schema(description = "当前所在区域ID", example = "1301")
+ private Long areaId;
+
+ @Schema(description = "进入时间(毫秒时间戳)", example = "1711872600000")
+ private Long enterTime;
+
+ @Schema(description = "匹配的Beacon MAC", example = "F0:C8:60:1D:10:BB")
+ private String beaconMac;
+
+ @Schema(description = "是否在某区域内")
+ private Boolean inArea;
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/TrajectoryStateApi.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/TrajectoryStateApi.java
new file mode 100644
index 00000000..40933e80
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/TrajectoryStateApi.java
@@ -0,0 +1,31 @@
+package com.viewsh.module.iot.api.trajectory;
+
+import com.viewsh.framework.common.pojo.CommonResult;
+import com.viewsh.module.iot.enums.ApiConstants;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+/**
+ * 轨迹实时状态 API
+ *
+ * 提供 RPC 接口供 Ops 模块查询设备当前位置
+ *
+ * @author lzh
+ */
+@FeignClient(name = ApiConstants.NAME)
+@Tag(name = "RPC 服务 - 轨迹实时状态")
+public interface TrajectoryStateApi {
+
+ String PREFIX = ApiConstants.PREFIX + "/trajectory";
+
+ @GetMapping(PREFIX + "/current-location")
+ @Operation(summary = "查询设备当前位置")
+ CommonResult getCurrentLocation(
+ @Parameter(description = "设备ID", required = true, example = "31")
+ @RequestParam("deviceId") Long deviceId);
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/constants/TrajectoryTopics.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/constants/TrajectoryTopics.java
new file mode 100644
index 00000000..2b849bb3
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/constants/TrajectoryTopics.java
@@ -0,0 +1,22 @@
+package com.viewsh.module.iot.core.integration.constants;
+
+/**
+ * 轨迹事件 Topic 常量
+ *
+ * 定义 IoT → Ops 的轨迹事件 Topic
+ *
+ * @author lzh
+ */
+public interface TrajectoryTopics {
+
+ /**
+ * 进入区域事件
+ */
+ String TRAJECTORY_ENTER = "trajectory-enter";
+
+ /**
+ * 离开区域事件
+ */
+ String TRAJECTORY_LEAVE = "trajectory-leave";
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryEnterEvent.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryEnterEvent.java
new file mode 100644
index 00000000..228baea4
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryEnterEvent.java
@@ -0,0 +1,72 @@
+package com.viewsh.module.iot.core.integration.event.trajectory;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * 轨迹进入区域事件
+ *
+ * 当工牌蓝牙信号满足强进条件确认进入某区域时发布
+ * Topic: trajectory-enter
+ *
+ * @author lzh
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TrajectoryEnterEvent {
+
+ /**
+ * 事件ID(唯一标识,用于幂等性处理)
+ */
+ @Builder.Default
+ private String eventId = UUID.randomUUID().toString();
+
+ /**
+ * 设备ID(工牌)
+ */
+ private Long deviceId;
+
+ /**
+ * 设备名称
+ */
+ private String deviceName;
+
+ /**
+ * 设备备注名称
+ */
+ private String nickname;
+
+ /**
+ * 区域ID
+ */
+ private Long areaId;
+
+ /**
+ * 匹配的 Beacon MAC 地址
+ */
+ private String beaconMac;
+
+ /**
+ * 进入时的 RSSI 值
+ */
+ private Integer enterRssi;
+
+ /**
+ * 事件时间(ISO-8601 字符串,跨模块序列化安全)
+ */
+ @Builder.Default
+ private String eventTime = LocalDateTime.now().toString();
+
+ /**
+ * 租户ID
+ */
+ private Long tenantId;
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryLeaveEvent.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryLeaveEvent.java
new file mode 100644
index 00000000..e5015617
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryLeaveEvent.java
@@ -0,0 +1,81 @@
+package com.viewsh.module.iot.core.integration.event.trajectory;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * 轨迹离开区域事件
+ *
+ * 当工牌蓝牙信号满足弱出条件确认离开某区域时发布
+ * Topic: trajectory-leave
+ *
+ * @author lzh
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TrajectoryLeaveEvent {
+
+ /**
+ * 事件ID(唯一标识,用于幂等性处理)
+ */
+ @Builder.Default
+ private String eventId = UUID.randomUUID().toString();
+
+ /**
+ * 设备ID(工牌)
+ */
+ private Long deviceId;
+
+ /**
+ * 设备名称
+ */
+ private String deviceName;
+
+ /**
+ * 设备备注名称
+ */
+ private String nickname;
+
+ /**
+ * 区域ID
+ */
+ private Long areaId;
+
+ /**
+ * 匹配的 Beacon MAC 地址
+ */
+ private String beaconMac;
+
+ /**
+ * 离开原因
+ *
+ * SIGNAL_LOSS - 信号丢失(弱出条件满足)
+ * AREA_SWITCH - 切换到其他区域
+ * DEVICE_OFFLINE - 设备离线
+ */
+ private String leaveReason;
+
+ /**
+ * 进入时间戳(毫秒),用于 Ops 端匹配记录
+ */
+ private Long enterTimestamp;
+
+ /**
+ * 事件时间(ISO-8601 字符串,跨模块序列化安全)
+ */
+ @Builder.Default
+ private String eventTime = LocalDateTime.now().toString();
+
+ /**
+ * 租户ID
+ */
+ private Long tenantId;
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/IotHenghuaD5Codec.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/IotHenghuaD5Codec.java
new file mode 100644
index 00000000..702f61bf
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/IotHenghuaD5Codec.java
@@ -0,0 +1,112 @@
+package com.viewsh.module.iot.gateway.codec.henghua;
+
+import cn.hutool.core.map.MapUtil;
+import com.viewsh.framework.common.util.json.JsonUtils;
+import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum;
+import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
+import com.viewsh.module.iot.gateway.codec.IotDeviceMessageCodec;
+import com.viewsh.module.iot.gateway.codec.henghua.dto.HenghuaD5DataReqVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 恒华 D5 客流摄像机 编解码器
+ */
+@Slf4j
+@Component
+public class IotHenghuaD5Codec implements IotDeviceMessageCodec {
+
+ public static final String TYPE = "HENGHUA_D5";
+
+ @Override
+ public String type() {
+ return TYPE;
+ }
+
+ @Override
+ public IotDeviceMessage decode(byte[] bytes) {
+ // 1. 将 byte[] 转为 UTF-8 字符串
+ String body = new String(bytes, StandardCharsets.UTF_8);
+
+ // 2. 按 & 拆分参数,构建 key-value Map(value 做 URL decode)
+ Map params = new HashMap<>();
+ for (String entry : body.split("&")) {
+ int idx = entry.indexOf('=');
+ if (idx > 0) {
+ String key = URLDecoder.decode(entry.substring(0, idx), StandardCharsets.UTF_8);
+ String value = URLDecoder.decode(entry.substring(idx + 1), StandardCharsets.UTF_8);
+ params.put(key, value);
+ }
+ }
+
+ // 3. 提取 status:等于 "0" 时为心跳,直接返回 null
+ String status = params.get("status");
+ if ("0".equals(status)) {
+ return null;
+ }
+
+ // 4. 提取 type:当前只对接 type=1
+ String type = params.get("type");
+ if (!"1".equals(type)) {
+ return null;
+ }
+
+ // 5. 提取 data 参数(已在步骤 2 中完成 URL decode)
+ String dataJson = params.get("data");
+ if (dataJson == null) {
+ log.warn("[decode][恒华D5 data 参数缺失]");
+ return null;
+ }
+
+ // 6. 将 JSON 字符串解析为 HenghuaD5DataReqVO
+ HenghuaD5DataReqVO reqVO;
+ try {
+ reqVO = JsonUtils.parseObject(dataJson, HenghuaD5DataReqVO.class);
+ } catch (Exception e) {
+ log.error("[decode][解析恒华D5 data JSON 失败]", e);
+ throw new IllegalArgumentException("data JSON 格式错误");
+ }
+ if (reqVO == null) {
+ log.warn("[decode][恒华D5 data 解析结果为空]");
+ return null;
+ }
+
+ // 7. 构建属性 Map
+ Map propertyParams = MapUtil.newHashMap();
+ propertyParams.put("people_in", reqVO.getInNum());
+ propertyParams.put("people_out", reqVO.getOutNum());
+
+ // 8. 时间校验:timeStamp 超过 24 小时的数据舍弃
+ LocalDateTime reportTime = LocalDateTime.now();
+ if (reqVO.getTimeStamp() != null) {
+ reportTime = LocalDateTime.ofInstant(
+ Instant.ofEpochSecond(reqVO.getTimeStamp()), ZoneId.systemDefault());
+ long hoursDiff = Math.abs(Duration.between(reportTime, LocalDateTime.now()).toHours());
+ if (hoursDiff > 24) {
+ log.warn("[decode][设备时间超过24小时,舍弃数据: timeStamp={}, 时间差: {}小时]",
+ reqVO.getTimeStamp(), hoursDiff);
+ return null;
+ }
+ }
+
+ // 9. 返回消息
+ return IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), propertyParams)
+ .setReportTime(reportTime);
+ }
+
+ @Override
+ public byte[] encode(IotDeviceMessage message) {
+ // 恒华D5 不需要下行指令
+ return new byte[0];
+ }
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/dto/HenghuaD5DataReqVO.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/dto/HenghuaD5DataReqVO.java
new file mode 100644
index 00000000..f93dbab1
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/dto/HenghuaD5DataReqVO.java
@@ -0,0 +1,41 @@
+package com.viewsh.module.iot.gateway.codec.henghua.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+/**
+ * 恒华 D5 客流摄像机 — type=1 拌线统计数据上报请求
+ */
+@Data
+public class HenghuaD5DataReqVO {
+
+ /**
+ * 时间日期字符串,格式 "YYYY-MM-DD HH:MM:SS"
+ */
+ @JsonProperty("DataDateTime")
+ private String dataDateTime;
+
+ /**
+ * 进入人数(累计值)
+ */
+ @JsonProperty("InNum")
+ private Integer inNum;
+
+ /**
+ * 离开人数(累计值)
+ */
+ @JsonProperty("OutNum")
+ private Integer outNum;
+
+ /**
+ * 经过人数(累计值)
+ */
+ @JsonProperty("PassNum")
+ private Integer passNum;
+
+ /**
+ * Unix 时间戳(秒)
+ */
+ private Long timeStamp;
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java
index 6d4a3b1f..85cdb299 100644
--- a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java
+++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java
@@ -8,6 +8,7 @@ import cn.hutool.extra.spring.SpringUtil;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.iot.core.biz.dto.IotDeviceRespDTO;
import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
+import com.viewsh.module.iot.gateway.codec.henghua.IotHenghuaD5Codec;
import com.viewsh.module.iot.gateway.codec.people.IotPeopleCounterCodec;
import com.viewsh.module.iot.gateway.codec.people.dto.PeopleCounterUploadResp;
import com.viewsh.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol;
@@ -49,8 +50,9 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler {
// 2. 获取设备信息(提前获取,避免重复调用)
IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName);
- boolean isPeopleCounter = device != null
- && StrUtil.equals(device.getCodecType(), IotPeopleCounterCodec.TYPE);
+ String codecType = device != null ? device.getCodecType() : null;
+ boolean isPeopleCounter = StrUtil.equals(codecType, IotPeopleCounterCodec.TYPE);
+ boolean isHenghuaD5 = StrUtil.equals(codecType, IotHenghuaD5Codec.TYPE);
// 3. 解码设备消息
byte[] bytes = context.body().buffer().getBytes();
@@ -62,6 +64,9 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler {
if (isPeopleCounter) {
return writePeopleCounterResponse(context);
}
+ if (isHenghuaD5) {
+ return writeHenghuaD5Response(context);
+ }
return CommonResult.error(400, "消息解码失败");
}
@@ -69,7 +74,7 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler {
// 5. 发送消息到消息队列
// 注意:HTTP 是短连接协议,响应直接通过 HTTP 返回,不需要记录回复消息
- // 所以 serverId 传 null,让系统不记录回复消��(参见 IotDeviceMessageServiceImpl.handleUpstreamDeviceMessage 第186行)
+ // 所以 serverId 传 null,让系统不记录回复消息
deviceMessageService.sendDeviceMessage(message,
productKey, deviceName, null);
@@ -77,6 +82,9 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler {
if (isPeopleCounter) {
return writePeopleCounterResponse(context);
}
+ if (isHenghuaD5) {
+ return writeHenghuaD5Response(context);
+ }
return CommonResult.success(MapUtil.of("messageId", message.getId()));
}
@@ -89,4 +97,12 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler {
return null; // 返回 null 表示响应已写入
}
+ /**
+ * 写入恒华D5客流摄像机响应
+ */
+ private CommonResult