Merge branch 'master' into feat/multi-tenant

# Conflicts:
#	viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/redis/TrafficActiveOrderRedisDAO.java
#	viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/BadgeDeviceStatusServiceImpl.java
#	viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/dispatch/UserDispatchStatusServiceImpl.java
This commit is contained in:
lzh
2026-04-13 14:35:27 +08:00
57 changed files with 3612 additions and 106 deletions

View File

@@ -0,0 +1,195 @@
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<List<BadgeSimpleRespDTO>> getBadgeSimpleList() {
// 查询所有 relationType=BADGE 的设备关联
List<OpsAreaDeviceRelationDO> relations = areaDeviceService.listAllByType("BADGE");
// 按 deviceId 去重(同一设备可能绑定多个区域)
Map<Long, OpsAreaDeviceRelationDO> uniqueDevices = relations.stream()
.collect(Collectors.toMap(
OpsAreaDeviceRelationDO::getDeviceId,
Function.identity(),
(a, b) -> a,
LinkedHashMap::new));
// 批量查询设备信息以获取 nickname
Set<Long> deviceIds = uniqueDevices.keySet();
Map<Long, IotDeviceSimpleRespDTO> deviceMap = Collections.emptyMap();
if (!deviceIds.isEmpty()) {
try {
CommonResult<List<IotDeviceSimpleRespDTO>> 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<Long, IotDeviceSimpleRespDTO> finalDeviceMap = deviceMap;
List<BadgeSimpleRespDTO> 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<PageResult<TrajectoryRespDTO>> getTrajectoryPage(
@Valid TrajectoryPageReqDTO pageReq) {
return success(trajectoryService.getTrajectoryPage(pageReq));
}
@GetMapping("/timeline")
@Operation(summary = "获得设备某天的轨迹时间线")
@PreAuthorize("@ss.hasPermission('ops:trajectory:query')")
public CommonResult<List<TrajectoryRespDTO>> getTimeline(@Valid TrajectoryTimelineReqDTO req) {
return success(trajectoryService.getTimeline(req.getDeviceId(), req.getDate()));
}
@GetMapping("/summary")
@Operation(summary = "获得轨迹统计摘要KPI 卡片)")
@PreAuthorize("@ss.hasPermission('ops:trajectory:query')")
public CommonResult<TrajectorySummaryDTO> getSummary(@Valid TrajectoryStatsReqDTO req) {
return success(trajectoryService.getSummary(req));
}
@GetMapping("/hourly-trend")
@Operation(summary = "获得时段出入趋势(按小时聚合)")
@PreAuthorize("@ss.hasPermission('ops:trajectory:query')")
public CommonResult<List<HourlyTrendDTO>> getHourlyTrend(@Valid TrajectoryStatsReqDTO req) {
return success(trajectoryService.getHourlyTrend(req));
}
@GetMapping("/area-stay-stats")
@Operation(summary = "获得区域停留分布")
@PreAuthorize("@ss.hasPermission('ops:trajectory:query')")
public CommonResult<List<AreaStayStatsDTO>> getAreaStayStats(@Valid TrajectoryStatsReqDTO req) {
return success(trajectoryService.getAreaStayStats(req));
}
// ==================== 实时位置 ====================
@GetMapping("/current-location")
@Operation(summary = "获得设备当前位置(实时)")
@PreAuthorize("@ss.hasPermission('ops:trajectory:query')")
public CommonResult<DeviceCurrentLocationDTO> getCurrentLocation(
@Parameter(description = "设备ID", required = true, example = "31")
@RequestParam("deviceId") Long deviceId) {
// 调用 IoT RPC 查询设备实时位置
CommonResult<DeviceLocationDTO> 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());
}
}

View File

@@ -37,6 +37,13 @@ public class AreaDeviceController {
@Resource
private AreaDeviceService areaDeviceService;
@GetMapping("/beacons/all")
@Operation(summary = "查询所有启用的Beacon设备轨迹检测用")
public CommonResult<List<AreaDeviceDTO>> getAllEnabledBeacons() {
List<OpsAreaDeviceRelationDO> relations = areaDeviceService.listAllEnabledBeacons();
return success(BeanUtils.toBean(relations, AreaDeviceDTO.class));
}
@GetMapping("/{areaId}/badges")
@Operation(summary = "查询区域的工牌设备列表")
public CommonResult<List<AreaDeviceDTO>> getBadgesByArea(
@@ -45,21 +52,21 @@ public class AreaDeviceController {
return success(BeanUtils.toBean(relations, AreaDeviceDTO.class));
}
@GetMapping("/{areaId}/devices")
@Operation(summary = "查询区域设备列表(按类型)")
public CommonResult<List<AreaDeviceDTO>> getDevicesByAreaAndType(
@PathVariable("areaId") Long areaId,
@RequestParam(value = "relationType", required = false) String relationType) {
List<OpsAreaDeviceRelationDO> relations;
if (relationType != null) {
relations = areaDeviceService.listByAreaIdAndType(areaId, relationType);
} else {
relations = areaDeviceService.listByAreaId(areaId);
}
return success(BeanUtils.toBean(relations, AreaDeviceDTO.class));
}
@GetMapping("/{areaId}/devices")
@Operation(summary = "查询区域设备列表(按类型)")
public CommonResult<List<AreaDeviceDTO>> getDevicesByAreaAndType(
@PathVariable("areaId") Long areaId,
@RequestParam(value = "relationType", required = false) String relationType) {
List<OpsAreaDeviceRelationDO> relations;
if (relationType != null) {
relations = areaDeviceService.listByAreaIdAndType(areaId, relationType);
} else {
relations = areaDeviceService.listByAreaId(areaId);
}
return success(BeanUtils.toBean(relations, AreaDeviceDTO.class));
}
@GetMapping("/device/{deviceId}/relation")
@Operation(summary = "查询设备的关联关系")

View File

@@ -0,0 +1,43 @@
package com.viewsh.module.ops.framework.job.dispatch;
import com.viewsh.framework.tenant.core.util.TenantUtils;
import com.viewsh.module.ops.service.dispatch.UserDispatchStatusService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* 人员调度状态 Redis 启动校准器
* <p>
* 职责:服务启动时,扫描 Redis 中所有人员调度状态 key
* 与 DB 中的实际活跃工单比对,修正 status / activeOrderCount / waitingTaskCount。
* <p>
* 解决场景:
* - 服务重启期间工单完成/取消事件丢失,导致人员状态卡在 BUSY
* - 计数器漂移increment/decrement 不对称)
*
* @author AI
*/
@Slf4j
@Component
public class UserDispatchStatusInitializer implements ApplicationRunner {
@Resource
private UserDispatchStatusService userDispatchStatusService;
@Override
public void run(ApplicationArguments args) {
log.info("[初始化] 开始校准人员调度状态...");
try {
TenantUtils.executeIgnore(() -> {
int calibrated = userDispatchStatusService.calibrateFromDb();
log.info("[初始化] 人员调度状态校准完成:修正 {} 个用户", calibrated);
});
} catch (Exception e) {
log.error("[初始化] 人员调度状态校准失败", e);
}
}
}

View File

@@ -0,0 +1,43 @@
package com.viewsh.module.ops.framework.job.queue;
import com.viewsh.framework.tenant.core.util.TenantUtils;
import com.viewsh.module.ops.service.queue.QueueSyncService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* 工单队列 Redis 启动恢复器
* <p>
* 职责:服务启动时,全量同步 MySQL 队列数据到 Redis
* 确保 Redis Sorted Set 与 MySQL 一致。
* <p>
* 解决场景:
* - 服务重启后 Redis 队列数据丢失或过期
* - 异步写入 Redis 失败导致的数据不一致
*
* @author AI
*/
@Slf4j
@Component
public class OrderQueueInitializer implements ApplicationRunner {
@Resource
private QueueSyncService queueSyncService;
@Override
public void run(ApplicationArguments args) {
log.info("[初始化] 开始全量同步工单队列到 Redis...");
try {
TenantUtils.executeIgnore(() -> {
int count = queueSyncService.forceSyncAll();
log.info("[初始化] 工单队列全量同步完成:同步 {} 条记录", count);
});
} catch (Exception e) {
log.error("[初始化] 工单队列全量同步失败", e);
}
}
}

View File

@@ -2,8 +2,10 @@ package com.viewsh.module.ops.framework.rpc.config;
import com.viewsh.module.infra.api.file.FileApi;
import com.viewsh.module.iot.api.device.IotDeviceControlApi;
import com.viewsh.module.iot.api.device.IotDevicePropertyQueryApi;
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;
@@ -16,8 +18,10 @@ import org.springframework.context.annotation.Configuration;
AdminUserApi.class,
SocialUserApi.class,
IotDeviceControlApi.class,
IotDevicePropertyQueryApi.class,
IotDeviceQueryApi.class,
IotDeviceStatusQueryApi.class,
TrajectoryStateApi.class,
FileApi.class
})
public class RpcConfiguration {