diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/BadgeSimpleRespDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/BadgeSimpleRespDTO.java new file mode 100644 index 0000000..cc3be19 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/BadgeSimpleRespDTO.java @@ -0,0 +1,30 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 工牌设备精简 Response DTO(下拉列表用) + * + * @author lzh + */ +@Schema(description = "管理后台 - 工牌设备精简信息(下拉列表用)") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BadgeSimpleRespDTO { + + @Schema(description = "设备ID", example = "31") + private Long deviceId; + + @Schema(description = "设备Key", example = "09207455611") + private String deviceKey; + + @Schema(description = "设备备注名称", example = "1号工牌") + private String nickname; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/DeviceCurrentLocationDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/DeviceCurrentLocationDTO.java new file mode 100644 index 0000000..9881f6b --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/DeviceCurrentLocationDTO.java @@ -0,0 +1,39 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 设备实时位置 Response DTO + * + * @author lzh + */ +@Schema(description = "管理后台 - 设备实时位置 Response DTO") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeviceCurrentLocationDTO { + + @Schema(description = "设备ID", example = "31") + private Long deviceId; + + @Schema(description = "当前所在区域ID", example = "1301") + private Long areaId; + + @Schema(description = "区域名称", example = "A座2楼男卫") + private String areaName; + + @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-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryPageReqDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryPageReqDTO.java new file mode 100644 index 0000000..14e7758 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryPageReqDTO.java @@ -0,0 +1,35 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import com.viewsh.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static com.viewsh.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +/** + * 轨迹分页查询 Request DTO + * + * @author lzh + */ +@Schema(description = "管理后台 - 设备轨迹分页查询 Request DTO") +@Data +@EqualsAndHashCode(callSuper = true) +public class TrajectoryPageReqDTO extends PageParam { + + @Schema(description = "设备ID", example = "31") + private Long deviceId; + + @Schema(description = "区域ID", example = "1301") + private Long areaId; + + @Schema(description = "进入时间范围(开始时间, 结束时间)") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @Size(min = 2, max = 2, message = "进入时间范围必须包含开始时间和结束时间") + private LocalDateTime[] enterTime; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java new file mode 100644 index 0000000..07b4847 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java @@ -0,0 +1,59 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 轨迹记录 Response DTO + * + * @author lzh + */ +@Schema(description = "管理后台 - 设备轨迹记录 Response DTO") +@Data +public class TrajectoryRespDTO { + + @Schema(description = "记录ID", example = "1") + private Long id; + + @Schema(description = "设备ID", example = "31") + private Long deviceId; + + @Schema(description = "设备名称", example = "badge_001") + private String deviceName; + + @Schema(description = "设备备注名称", example = "1号工牌") + private String nickname; + + @Schema(description = "区域ID", example = "1301") + private Long areaId; + + @Schema(description = "区域名称", example = "A座2楼男卫") + private String areaName; + + @Schema(description = "楼栋名称", example = "A栋") + private String buildingName; + + @Schema(description = "楼层号", example = "2") + private Integer floorNo; + + @Schema(description = "Beacon MAC", example = "F0:C8:60:1D:10:BB") + private String beaconMac; + + @Schema(description = "进入时间") + private LocalDateTime enterTime; + + @Schema(description = "离开时间") + private LocalDateTime leaveTime; + + @Schema(description = "停留时长(秒)", example = "300") + private Integer durationSeconds; + + @Schema(description = "离开原因", example = "SIGNAL_LOSS") + private String leaveReason; + + @Schema(description = "进入时RSSI", example = "-65") + private Integer enterRssi; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java new file mode 100644 index 0000000..1f651fc --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java @@ -0,0 +1,41 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 轨迹统计摘要 Response DTO + *

+ * 用于 KPI 卡片展示 + * + * @author lzh + */ +@Schema(description = "管理后台 - 轨迹统计摘要 Response DTO") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrajectorySummaryDTO { + + @Schema(description = "总轨迹记录数(含未关闭)", example = "42") + private Long totalRecords; + + @Schema(description = "已完成记录数(有离开时间)", example = "38") + private Long completedRecords; + + @Schema(description = "覆盖区域数", example = "8") + private Long coveredAreaCount; + + @Schema(description = "总停留时长(秒)", example = "28800") + private Long totalDurationSeconds; + + @Schema(description = "平均停留时长(秒)", example = "685") + private Long avgDurationSeconds; + + @Schema(description = "最长单次停留(秒)", example = "3600") + private Long maxDurationSeconds; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java new file mode 100644 index 0000000..d76acad --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java @@ -0,0 +1,28 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +/** + * 轨迹时间线查询 Request DTO + * + * @author lzh + */ +@Schema(description = "管理后台 - 设备轨迹时间线查询 Request DTO") +@Data +public class TrajectoryTimelineReqDTO { + + @Schema(description = "设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "31") + @NotNull(message = "设备ID不能为空") + private Long deviceId; + + @Schema(description = "查询日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-03-30") + @NotNull(message = "查询日期不能为空") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private LocalDate date; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java new file mode 100644 index 0000000..28f15e1 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java @@ -0,0 +1,181 @@ +package com.viewsh.module.ops.controller.admin.trajectory; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.iot.api.device.IotDeviceQueryApi; +import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO; +import com.viewsh.module.iot.api.trajectory.DeviceLocationDTO; +import com.viewsh.module.iot.api.trajectory.TrajectoryStateApi; +import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO; +import com.viewsh.module.ops.environment.service.trajectory.DeviceTrajectoryService; +import com.viewsh.module.ops.service.area.AreaDeviceService; +import com.viewsh.module.ops.service.area.OpsBusAreaService; +import com.viewsh.module.ops.service.trajectory.dto.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 管理后台 - 设备轨迹 + * + * @author lzh + */ +@Tag(name = "管理后台 - 设备轨迹") +@Slf4j +@RestController +@RequestMapping("/ops/trajectory") +@Validated +public class TrajectoryController { + + @Resource + private DeviceTrajectoryService trajectoryService; + + @Resource + private TrajectoryStateApi trajectoryStateApi; + + @Resource + private AreaDeviceService areaDeviceService; + + @Resource + private OpsBusAreaService opsBusAreaService; + + @Resource + private IotDeviceQueryApi iotDeviceQueryApi; + + // ==================== 工牌设备下拉列表 ==================== + + @GetMapping("/badge-list") + @Operation(summary = "获取工牌设备下拉列表(轨迹页面用)") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult> getBadgeSimpleList() { + // 查询所有 relationType=BADGE 的设备关联 + List relations = areaDeviceService.listAllByType("BADGE"); + + // 按 deviceId 去重(同一设备可能绑定多个区域) + Map uniqueDevices = relations.stream() + .collect(Collectors.toMap( + OpsAreaDeviceRelationDO::getDeviceId, + Function.identity(), + (a, b) -> a, + LinkedHashMap::new)); + + // 批量查询设备信息以获取 nickname + Set deviceIds = uniqueDevices.keySet(); + Map deviceMap = Collections.emptyMap(); + if (!deviceIds.isEmpty()) { + try { + CommonResult> deviceResult = + iotDeviceQueryApi.batchGetDevices(deviceIds); + if (deviceResult != null && deviceResult.getData() != null) { + deviceMap = deviceResult.getData().stream() + .collect(Collectors.toMap(IotDeviceSimpleRespDTO::getId, Function.identity())); + } + } catch (Exception e) { + log.warn("[badge-list] 批量查询设备信息失败,降级返回无 nickname", e); + } + } + + Map finalDeviceMap = deviceMap; + List result = uniqueDevices.values().stream() + .map(r -> { + BadgeSimpleRespDTO dto = BadgeSimpleRespDTO.builder() + .deviceId(r.getDeviceId()) + .deviceKey(r.getDeviceKey()) + .build(); + IotDeviceSimpleRespDTO device = finalDeviceMap.get(r.getDeviceId()); + if (device != null) { + dto.setNickname(device.getNickname()); + } + return dto; + }) + .collect(Collectors.toList()); + return success(result); + } + + // ==================== 轨迹查询 ==================== + + @GetMapping("/page") + @Operation(summary = "获得设备轨迹分页") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult> getTrajectoryPage( + @Valid TrajectoryPageReqDTO pageReq) { + return success(trajectoryService.getTrajectoryPage(pageReq)); + } + + @GetMapping("/timeline") + @Operation(summary = "获得设备某天的轨迹时间线") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult> getTimeline(@Valid TrajectoryTimelineReqDTO req) { + return success(trajectoryService.getTimeline(req.getDeviceId(), req.getDate())); + } + + @GetMapping("/summary") + @Operation(summary = "获得设备某天的轨迹统计摘要") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult getSummary(@Valid TrajectoryTimelineReqDTO req) { + return success(trajectoryService.getSummary(req.getDeviceId(), req.getDate())); + } + + // ==================== 实时位置 ==================== + + @GetMapping("/current-location") + @Operation(summary = "获得设备当前位置(实时)") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult getCurrentLocation( + @Parameter(description = "设备ID", required = true, example = "31") + @RequestParam("deviceId") Long deviceId) { + // 调用 IoT RPC 查询设备实时位置 + CommonResult result; + try { + result = trajectoryStateApi.getCurrentLocation(deviceId); + } catch (Exception e) { + log.error("[current-location] IoT 服务调用失败:deviceId={}", deviceId, e); + throw new RuntimeException("IoT 服务不可用,无法查询设备实时位置", e); + } + + if (result == null || !result.isSuccess() || result.getData() == null + || !Boolean.TRUE.equals(result.getData().getInArea())) { + return success(DeviceCurrentLocationDTO.builder() + .deviceId(deviceId) + .inArea(false) + .build()); + } + + DeviceLocationDTO location = result.getData(); + // 通过 Service 查询区域名称(不直接注入 Mapper) + String areaName = null; + if (location.getAreaId() != null) { + try { + OpsBusAreaRespVO area = opsBusAreaService.getArea(location.getAreaId()); + areaName = area.getAreaName(); + } catch (Exception e) { + log.warn("[current-location] 查询区域名称失败:areaId={}", location.getAreaId(), e); + } + } + + return success(DeviceCurrentLocationDTO.builder() + .deviceId(location.getDeviceId()) + .areaId(location.getAreaId()) + .areaName(areaName) + .enterTime(location.getEnterTime()) + .beaconMac(location.getBeaconMac()) + .inArea(true) + .build()); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java index da66435..d9a1f7b 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java @@ -4,6 +4,7 @@ import com.viewsh.module.infra.api.file.FileApi; import com.viewsh.module.iot.api.device.IotDeviceControlApi; import com.viewsh.module.iot.api.device.IotDeviceQueryApi; import com.viewsh.module.iot.api.device.IotDeviceStatusQueryApi; +import com.viewsh.module.iot.api.trajectory.TrajectoryStateApi; import com.viewsh.module.system.api.notify.NotifyMessageSendApi; import com.viewsh.module.system.api.social.SocialUserApi; import com.viewsh.module.system.api.user.AdminUserApi; @@ -18,6 +19,7 @@ import org.springframework.context.annotation.Configuration; IotDeviceControlApi.class, IotDeviceQueryApi.class, IotDeviceStatusQueryApi.class, + TrajectoryStateApi.class, FileApi.class }) public class RpcConfiguration {