feat(ops,iot): 保洁前端 API 层和区域管理新增

新增保洁业务前端 API 接口层(工牌、工单、仪表盘)和运营区域管理完整功能,包含 Service/Controller/Test 三层结构。

主要功能:

1. IoT 设备查询 API(RPC 接口)
   - IotDeviceQueryApi: 提供设备简化信息查询
   - IotDeviceSimpleRespDTO: 设备简化 DTO

2. 保洁工牌管理
   - CleanBadgeService/Impl: 工牌通知、优先级调整、手动完成
   - BadgeNotifyReqDTO/UpgradePriorityReqDTO/ManualCompleteOrderReqDTO

3. 保洁工单管理
   - CleanWorkOrderService/Impl: 工单时间线查询

4. 保洁仪表盘
   - CleanDashboardService/Impl: 快速统计(待处理/进行中/已完成/在线工牌数)
   - QuickStatsRespDTO: 快速统计 DTO

5. 运营区域管理(Ops Biz)
   - OpsBusAreaService/Impl: 区域 CRUD(支持树形结构、分页查询)
   - AreaDeviceRelationService/Impl: 区域设备关联管理(绑定/解绑/批量更新)
   - OpsBusAreaMapper/AreaDeviceRelationMapper: 扩展 MyBatis 批量方法
   - 7 个 VO 类:CreateReqVO/UpdateReqVO/PageReqVO/RespVO/BindReqVO/RelationRespVO/DeviceUpdateReqVO

6. 前端 Controller(Ops Server)
   - OpsBusAreaController: 区域管理 REST API(11 个接口)
   - AreaDeviceRelationController: 设备关联 REST API(8 个接口)
   - CleanBadgeController: 工牌管理 REST API(5 个接口)
   - CleanDashboardController: 仪表盘 REST API(1 个接口)
   - CleanDeviceController: 设备管理 REST API(2 个接口)
   - CleanWorkOrderController: 工单管理 REST API(2 个接口)

7. 测试覆盖
   - OpsBusAreaServiceTest: 区域服务测试(284 行)
   - AreaDeviceRelationServiceTest: 设备关联测试(240 行)
   - OpsBusAreaControllerTest: 区域 Controller 测试(186 行)
   - AreaDeviceRelationControllerTest: 设备关联 Controller 测试(182 行)

8. API 层扩展
   - ErrorCodeConstants: 错误码常量(区域、设备关联)
   - NotifyTypeEnum: 通知类型枚举(语音、文本、震动)
   - 4 个 Badge/Order DTO: BadgeStatusRespDTO/BadgeRealtimeStatusRespDTO/OrderTimelineRespDTO

9. RPC 配置
   - RpcConfiguration: 注入 IotDeviceQueryApi

影响模块:Ops API、Ops Biz、Ops Server、Ops Environment Biz、IoT API、IoT Server

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-02-02 22:42:45 +08:00
parent bdf5b640b0
commit 955c825e2c
43 changed files with 3312 additions and 1 deletions

View File

@@ -0,0 +1,41 @@
package com.viewsh.module.iot.api.device;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO;
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;
import java.util.List;
/**
* IoT 设备查询 API
* <p>
* 提供 RPC 接口供其他模块(如 Ops 模块)查询设备基本信息
*
* @author lzh
*/
@FeignClient(name = ApiConstants.NAME)
@Tag(name = "RPC 服务 - IoT 设备查询")
public interface IotDeviceQueryApi {
String PREFIX = ApiConstants.PREFIX + "/device";
@GetMapping(PREFIX + "/simple-list")
@Operation(summary = "获取设备精简列表(按类型/产品筛选)")
@Parameter(name = "deviceType", description = "设备类型")
@Parameter(name = "productId", description = "产品ID")
CommonResult<List<IotDeviceSimpleRespDTO>> getDeviceSimpleList(
@RequestParam(value = "deviceType", required = false) Integer deviceType,
@RequestParam(value = "productId", required = false) Long productId);
@GetMapping(PREFIX + "/get")
@Operation(summary = "获取设备详情")
@Parameter(name = "id", description = "设备ID", required = true)
CommonResult<IotDeviceSimpleRespDTO> getDevice(@RequestParam("id") Long id);
}

View File

@@ -0,0 +1,32 @@
package com.viewsh.module.iot.api.device.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* IoT 设备精简响应 DTO
* <p>
* 用于 RPC 调用,只包含核心字段
*
* @author lzh
*/
@Schema(description = "RPC 服务 - IoT 设备精简 Response DTO")
@Data
public class IotDeviceSimpleRespDTO {
@Schema(description = "设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "50001")
private Long id;
@Schema(description = "设备名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客流计数器001")
private String deviceName;
@Schema(description = "产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
private Long productId;
@Schema(description = "产品标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "traffic_counter_v1")
private String productKey;
@Schema(description = "产品名称", example = "客流计数器")
private String productName;
}

View File

@@ -0,0 +1,90 @@
package com.viewsh.module.iot.api.device;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO;
import com.viewsh.module.iot.controller.admin.device.vo.device.IotDeviceRespVO;
import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO;
import com.viewsh.module.iot.service.device.IotDeviceService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.stream.Collectors;
import static com.viewsh.framework.common.pojo.CommonResult.success;
import static com.viewsh.module.iot.api.device.IotDeviceQueryApi.PREFIX;
/**
* IoT 设备查询 API 实现类
* <p>
* 提供 RPC 接口供其他模块调用 IoT 设备查询服务
*
* @author lzh
*/
@RestController
@Validated
@Primary
@Slf4j
public class IotDeviceQueryApiImpl implements IotDeviceQueryApi {
@Resource
private IotDeviceService deviceService;
@Override
@GetMapping(PREFIX + "/simple-list")
@Operation(summary = "获取设备精简列表(按类型/产品筛选)")
public CommonResult<List<IotDeviceSimpleRespDTO>> getDeviceSimpleList(
@RequestParam(value = "deviceType", required = false) Integer deviceType,
@RequestParam(value = "productId", required = false) Long productId) {
try {
List<IotDeviceDO> list = deviceService.getDeviceListByCondition(deviceType, productId);
List<IotDeviceSimpleRespDTO> result = list.stream()
.map(device -> {
IotDeviceSimpleRespDTO dto = new IotDeviceSimpleRespDTO();
dto.setId(device.getId());
dto.setDeviceName(device.getDeviceName());
dto.setProductId(device.getProductId());
dto.setProductKey(device.getProductKey());
// TODO: 从产品服务获取产品名称
dto.setProductName("产品_" + device.getProductKey());
return dto;
})
.collect(Collectors.toList());
return success(result);
} catch (Exception e) {
log.error("[getDeviceSimpleList] 查询设备列表失败: deviceType={}, productId={}", deviceType, productId, e);
return success(List.of());
}
}
@Override
@GetMapping(PREFIX + "/get")
@Operation(summary = "获取设备详情")
public CommonResult<IotDeviceSimpleRespDTO> getDevice(@RequestParam("id") Long id) {
try {
IotDeviceDO device = deviceService.getDevice(id);
if (device == null) {
return success(null);
}
IotDeviceSimpleRespDTO dto = new IotDeviceSimpleRespDTO();
dto.setId(device.getId());
dto.setDeviceName(device.getDeviceName());
dto.setProductId(device.getProductId());
dto.setProductKey(device.getProductKey());
dto.setProductName("产品_" + device.getProductKey());
return success(dto);
} catch (Exception e) {
log.error("[getDevice] 查询设备详情失败: id={}", id, e);
return success(null);
}
}
}

View File

@@ -0,0 +1,29 @@
package com.viewsh.module.ops.environment.dal.dataobject;
import com.viewsh.module.ops.enums.NotifyTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 工牌通知请求 DTO
* <p>
* 用于发送工牌设备通知(语音、震动)
*
* @author lzh
*/
@Schema(description = "管理后台 - 工牌通知 Request DTO")
@Data
public class BadgeNotifyReqDTO {
@Schema(description = "工牌设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "3001")
@NotNull(message = "工牌设备ID不能为空")
private Long badgeId;
@Schema(description = "通知类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "VOICE")
@NotNull(message = "通知类型不能为空")
private NotifyTypeEnum type;
@Schema(description = "语音内容(仅语音通知需要)", example = "请注意,您有待处理工单")
private String content;
}

View File

@@ -0,0 +1,24 @@
package com.viewsh.module.ops.environment.dal.dataobject;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 手动完成工单请求 DTO
* <p>
* 管理员手动完成工单的兜底操作
*
* @author lzh
*/
@Schema(description = "管理后台 - 手动完成工单 Request DTO")
@Data
public class ManualCompleteOrderReqDTO {
@Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
@NotNull(message = "工单ID不能为空")
private Long orderId;
@Schema(description = "备注", example = "管理员手动完成")
private String remark;
}

View File

@@ -0,0 +1,26 @@
package com.viewsh.module.ops.environment.dal.dataobject;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 升级工单优先级请求 DTO
* <p>
* 用于将普通工单升级为 P0 紧急工单
*
* @author lzh
*/
@Schema(description = "管理后台 - 升级工单优先级 Request DTO")
@Data
public class UpgradePriorityReqDTO {
@Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
@NotNull(message = "工单ID不能为空")
private Long orderId;
@Schema(description = "升级原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户投诉急需处理")
@NotBlank(message = "升级原因不能为空")
private String reason;
}

View File

@@ -0,0 +1,70 @@
package com.viewsh.module.ops.environment.service.badge;
import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO;
import com.viewsh.module.ops.api.clean.BadgeRealtimeStatusRespDTO;
import com.viewsh.module.ops.api.clean.BadgeStatusRespDTO;
import com.viewsh.module.ops.environment.dal.dataobject.BadgeNotifyReqDTO;
import java.util.List;
/**
* 保洁工牌服务接口
* <p>
* 职责:
* 1. 查询工牌实时状态列表
* 2. 查询工牌实时状态详情
* 3. 发送工牌通知(语音/震动)
* <p>
* 设计说明:
* - 工牌状态来源于 Redis通过 BadgeDeviceStatusService
* - 设备基本信息来源于 iot_device 表
* - 区域关联来源于 ops_area_device_relation 表
*
* @author lzh
*/
public interface CleanBadgeService {
/**
* 获取工牌实时状态列表
* <p>
* 支持按区域和状态筛选
*
* @param areaId 区域ID可选
* @param status 状态筛选可选IDLE/BUSY/OFFLINE/PAUSED
* @return 工牌状态列表
*/
List<BadgeStatusRespDTO> getBadgeStatusList(Long areaId, String status);
/**
* 获取工牌实时状态详情
*
* @param badgeId 工牌设备ID
* @return 工牌实时状态详情
*/
BadgeRealtimeStatusRespDTO getBadgeRealtimeStatus(Long badgeId);
/**
* 发送工牌通知
* <p>
* 支持语音和震动通知
*
* @param req 通知请求
*/
void sendBadgeNotify(BadgeNotifyReqDTO req);
/**
* 获取工牌今日完成工单数
*
* @param deviceId 设备ID通过设备ID查找关联的用户
* @return 今日完成工单数
*/
Integer getTodayCompletedCount(Long deviceId);
/**
* 获取工牌今日工作时长(分钟)
*
* @param deviceId 设备ID
* @return 今日工作时长(分钟)
*/
Integer getTodayWorkMinutes(Long deviceId);
}

View File

@@ -0,0 +1,199 @@
package com.viewsh.module.ops.environment.service.badge;
import com.viewsh.module.iot.api.device.IotDeviceControlApi;
import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO;
import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO;
import com.viewsh.module.ops.api.clean.BadgeRealtimeStatusRespDTO;
import com.viewsh.module.ops.api.clean.BadgeStatusRespDTO;
import com.viewsh.module.ops.environment.dal.dataobject.BadgeNotifyReqDTO;
import com.viewsh.module.ops.service.area.AreaDeviceService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
/**
* 保洁工牌服务实现
* <p>
* 工牌状态数据来源Redis (ops:badge:status:{deviceId})
* 设备基本信息来源iot_device 表
* 区域关联来源ops_area_device_relation 表
*
* @author lzh
*/
@Slf4j
@Service
public class CleanBadgeServiceImpl implements CleanBadgeService {
@Resource
private BadgeDeviceStatusService badgeDeviceStatusService;
@Resource
private AreaDeviceService areaDeviceService;
@Resource
private IotDeviceControlApi iotDeviceControlApi;
private static final String NOTIFY_IDENTIFIER = "NOTIFY";
@Override
public List<BadgeStatusRespDTO> getBadgeStatusList(Long areaId, String status) {
try {
// 1. 获取设备ID列表
List<Long> deviceIds;
if (areaId != null) {
deviceIds = areaDeviceService.getDeviceIdsByAreaAndType(areaId, "BADGE");
} else {
// 不指定区域时,从所有活跃工牌获取
List<BadgeDeviceStatusDTO> allActiveBadges = badgeDeviceStatusService.listActiveBadges();
deviceIds = allActiveBadges.stream()
.map(BadgeDeviceStatusDTO::getDeviceId)
.collect(Collectors.toList());
}
if (deviceIds.isEmpty()) {
return Collections.emptyList();
}
// 2. 批量获取工牌状态
List<BadgeDeviceStatusDTO> statusList = badgeDeviceStatusService.batchGetBadgeStatus(deviceIds);
// 3. 按状态筛选(如果指定)
List<BadgeDeviceStatusDTO> filteredList = statusList;
if (status != null && !status.isEmpty()) {
filteredList = statusList.stream()
.filter(dto -> dto.getStatus() != null)
.filter(dto -> dto.getStatus().getCode().equalsIgnoreCase(status))
.collect(Collectors.toList());
}
// 4. 转换为响应DTO
return filteredList.stream()
.map(this::convertToBadgeStatusRespDTO)
.collect(Collectors.toList());
} catch (Exception e) {
log.error("[getBadgeStatusList] 查询工牌状态列表失败: areaId={}, status={}", areaId, status, e);
return Collections.emptyList();
}
}
@Override
public BadgeRealtimeStatusRespDTO getBadgeRealtimeStatus(Long badgeId) {
try {
// 1. 获取工牌状态
BadgeDeviceStatusDTO status = badgeDeviceStatusService.getBadgeStatus(badgeId);
if (status == null) {
log.warn("[getBadgeRealtimeStatus] 工牌状态不存在: badgeId={}", badgeId);
return null;
}
// 2. 构建响应
return BadgeRealtimeStatusRespDTO.builder()
.deviceId(status.getDeviceId())
.deviceKey(status.getDeviceCode())
.status(status.getStatusCode())
.batteryLevel(status.getBatteryLevel())
.lastHeartbeatTime(formatTimestamp(status.getLastHeartbeatTime()))
.rssi(null) // RSSI 需要从 IoT 模块获取,暂不实现
.isInArea(status.getCurrentAreaId() != null)
.areaId(status.getCurrentAreaId())
.areaName(status.getCurrentAreaName())
.build();
} catch (Exception e) {
log.error("[getBadgeRealtimeStatus] 查询工牌实时状态失败: badgeId={}", badgeId, e);
return null;
}
}
@Override
public void sendBadgeNotify(BadgeNotifyReqDTO req) {
try {
Long deviceId = req.getBadgeId();
if (req.getType() == com.viewsh.module.ops.enums.NotifyTypeEnum.VOICE) {
// 语音通知 - 使用 TTS 服务
if (req.getContent() == null || req.getContent().isEmpty()) {
log.warn("[sendBadgeNotify] 语音通知内容为空: deviceId={}", deviceId);
return;
}
IotDeviceServiceInvokeReqDTO reqDTO = new IotDeviceServiceInvokeReqDTO();
reqDTO.setDeviceId(deviceId);
reqDTO.setIdentifier("TTS");
reqDTO.setParams(Map.of(
"tts_text", req.getContent(),
"tts_flag", 0x08 // 普通通知
));
iotDeviceControlApi.invokeService(reqDTO);
log.info("[sendBadgeNotify] 语音通知发送成功: deviceId={}, content={}", deviceId, req.getContent());
} else if (req.getType() == com.viewsh.module.ops.enums.NotifyTypeEnum.VIBRATE) {
// 震动通知
IotDeviceServiceInvokeReqDTO reqDTO = new IotDeviceServiceInvokeReqDTO();
reqDTO.setDeviceId(deviceId);
reqDTO.setIdentifier(NOTIFY_IDENTIFIER);
reqDTO.setParams(Map.of(
"notify_type", "VIBRATE",
"duration", 1000 // 震动1秒
));
iotDeviceControlApi.invokeService(reqDTO);
log.info("[sendBadgeNotify] 震动通知发送成功: deviceId={}", deviceId);
}
} catch (Exception e) {
log.error("[sendBadgeNotify] 发送工牌通知失败: badgeId={}, type={}",
req.getBadgeId(), req.getType(), e);
}
}
@Override
public Integer getTodayCompletedCount(Long deviceId) {
// 暂不实现,工牌不关联用户,无法统计工牌完成的工单数
return 0;
}
@Override
public Integer getTodayWorkMinutes(Long deviceId) {
// 暂不实现,工牌不关联用户,无法统计工牌工作时长
return 0;
}
// ==================== 私有方法 ====================
/**
* 转换为工牌状态响应DTO
*/
private BadgeStatusRespDTO convertToBadgeStatusRespDTO(BadgeDeviceStatusDTO status) {
return BadgeStatusRespDTO.builder()
.deviceId(status.getDeviceId())
.deviceKey(status.getDeviceCode())
.status(status.getStatusCode())
.batteryLevel(status.getBatteryLevel())
.lastHeartbeatTime(formatTimestamp(status.getLastHeartbeatTime()))
.currentAreaId(status.getCurrentAreaId())
.currentAreaName(status.getCurrentAreaName())
.todayCompletedCount(0)
.todayWorkMinutes(0)
.build();
}
/**
* 格式化时间戳
*/
private String formatTimestamp(Long timestamp) {
if (timestamp == null) {
return null;
}
return LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault())
.toString();
}
}

View File

@@ -0,0 +1,50 @@
package com.viewsh.module.ops.environment.service.cleanorder;
import com.viewsh.module.ops.api.clean.OrderTimelineRespDTO;
import com.viewsh.module.ops.environment.dal.dataobject.ManualCompleteOrderReqDTO;
import com.viewsh.module.ops.environment.dal.dataobject.UpgradePriorityReqDTO;
/**
* 保洁工单服务接口
* <p>
* 职责:
* 1. 查询工单时间轴
* 2. 手动完成工单(兜底操作)
* 3. 升级工单优先级P0插队
* <p>
* 设计说明:
* - 工单时间轴数据来源于 ops_order_event 表
* - 工单操作复用现有的 OpsOrderService
*
* @author lzh
*/
public interface CleanWorkOrderService {
/**
* 获取工单时间轴
* <p>
* 从 ops_order_event 表查询该工单的所有事件记录
*
* @param orderId 工单ID
* @return 工单时间轴
*/
OrderTimelineRespDTO getOrderTimeline(Long orderId);
/**
* 手动完成工单
* <p>
* 管理员手动完成工单的兜底操作
*
* @param req 手动完成请求
*/
void manualCompleteOrder(ManualCompleteOrderReqDTO req);
/**
* 升级工单优先级
* <p>
* 将普通工单升级为 P0 紧急工单
*
* @param req 升级优先级请求
*/
void upgradePriority(UpgradePriorityReqDTO req);
}

View File

@@ -0,0 +1,230 @@
package com.viewsh.module.ops.environment.service.cleanorder;
import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.viewsh.module.ops.api.clean.OrderTimelineRespDTO;
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderEventDO;
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderEventMapper;
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
import com.viewsh.module.ops.environment.dal.dataobject.ManualCompleteOrderReqDTO;
import com.viewsh.module.ops.environment.dal.dataobject.UpgradePriorityReqDTO;
import com.viewsh.module.ops.service.event.OpsOrderEventService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 保洁工单服务实现
*
* @author lzh
*/
@Slf4j
@Service
public class CleanWorkOrderServiceImpl implements CleanWorkOrderService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Resource
private OpsOrderMapper opsOrderMapper;
@Resource
private OpsOrderEventMapper opsOrderEventMapper;
@Resource
private OpsOrderEventService opsOrderEventService;
/**
* 事件类型名称映射
*/
private static final Map<String, String> EVENT_TYPE_NAMES = new HashMap<>();
static {
EVENT_TYPE_NAMES.put("CREATE", "工单创建");
EVENT_TYPE_NAMES.put("ASSIGN", "工单分配");
EVENT_TYPE_NAMES.put("ACCEPT", "接单确认");
EVENT_TYPE_NAMES.put("ARRIVE", "到岗确认");
EVENT_TYPE_NAMES.put("PAUSE", "工单暂停");
EVENT_TYPE_NAMES.put("RESUME", "恢复作业");
EVENT_TYPE_NAMES.put("COMPLETE", "工单完成");
EVENT_TYPE_NAMES.put("CANCEL", "工单取消");
EVENT_TYPE_NAMES.put("UPGRADE_PRIORITY", "优先级升级");
}
@Override
public OrderTimelineRespDTO getOrderTimeline(Long orderId) {
try {
// 1. 查询工单获取当前状态
OpsOrderDO order = opsOrderMapper.selectById(orderId);
if (order == null) {
log.warn("[getOrderTimeline] 工单不存在: orderId={}", orderId);
return null;
}
// 2. 查询工单事件
List<OpsOrderEventDO> events = opsOrderEventMapper.selectList(
new LambdaQueryWrapperX<OpsOrderEventDO>()
.eq(OpsOrderEventDO::getOpsOrderId, orderId)
.orderByAsc(OpsOrderEventDO::getEventTime)
);
// 3. 转换为时间轴节点
List<OrderTimelineRespDTO.TimelineItemDTO> timeline = events.stream()
.map(this::convertToTimelineItem)
.toList();
// 4. 构建响应
return OrderTimelineRespDTO.builder()
.orderId(orderId)
.currentStatus(order.getStatus())
.timeline(timeline)
.build();
} catch (Exception e) {
log.error("[getOrderTimeline] 查询工单时间轴失败: orderId={}", orderId, e);
return null;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void manualCompleteOrder(ManualCompleteOrderReqDTO req) {
try {
Long orderId = req.getOrderId();
// 1. 查询工单
OpsOrderDO order = opsOrderMapper.selectById(orderId);
if (order == null) {
log.warn("[manualCompleteOrder] 工单不存在: orderId={}", orderId);
return;
}
String fromStatus = order.getStatus();
// 2. 更新工单状态为已完成
OpsOrderDO updateDO = new OpsOrderDO();
updateDO.setId(orderId);
updateDO.setStatus("COMPLETED");
updateDO.setEndTime(LocalDateTime.now());
opsOrderMapper.updateById(updateDO);
// 3. 记录事件
opsOrderEventService.recordEvent(
orderId,
fromStatus,
"COMPLETED",
"COMPLETE",
"ADMIN",
null, // 管理员操作当前未传递具体ID
req.getRemark() != null ? req.getRemark() : "管理员手动完成"
);
log.info("[manualCompleteOrder] 手动完成工单成功: orderId={}, remark={}", orderId, req.getRemark());
} catch (Exception e) {
log.error("[manualCompleteOrder] 手动完成工单失败: orderId={}", req.getOrderId(), e);
throw e;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void upgradePriority(UpgradePriorityReqDTO req) {
try {
Long orderId = req.getOrderId();
// 1. 查询工单
OpsOrderDO order = opsOrderMapper.selectById(orderId);
if (order == null) {
log.warn("[upgradePriority] 工单不存在: orderId={}", orderId);
return;
}
Integer oldPriority = order.getPriority();
// 2. 更新工单优先级为 P0
OpsOrderDO updateDO = new OpsOrderDO();
updateDO.setId(orderId);
updateDO.setPriority(0); // P0
opsOrderMapper.updateById(updateDO);
// 3. 记录事件
opsOrderEventService.recordEvent(
orderId,
order.getStatus(),
order.getStatus(),
"UPGRADE_PRIORITY",
"ADMIN",
null, // 管理员操作
String.format("优先级从 P%d 升级为 P0: %s",
oldPriority != null ? oldPriority : 1, req.getReason())
);
log.info("[upgradePriority] 升级工单优先级成功: orderId={}, oldPriority={}, reason={}",
orderId, oldPriority, req.getReason());
} catch (Exception e) {
log.error("[upgradePriority] 升级工单优先级失败: orderId={}", req.getOrderId(), e);
throw e;
}
}
// ==================== 私有方法 ====================
/**
* 转换为时间轴节点
*/
private OrderTimelineRespDTO.TimelineItemDTO convertToTimelineItem(OpsOrderEventDO event) {
String statusName = EVENT_TYPE_NAMES.getOrDefault(
event.getEventType(),
event.getEventType()
);
String operator = event.getOperatorName();
if (operator == null || operator.isEmpty()) {
if ("SYSTEM".equals(event.getOperatorType())) {
operator = "系统";
} else {
operator = "操作员";
}
}
return OrderTimelineRespDTO.TimelineItemDTO.builder()
.status(event.getToStatus())
.statusName(statusName)
.time(formatDateTime(event.getEventTime()))
.operator(operator)
.description(event.getRemark())
.extra(buildExtraInfo(event))
.build();
}
/**
* 格式化日期时间
*/
private String formatDateTime(LocalDateTime dateTime) {
if (dateTime == null) {
return null;
}
return dateTime.format(DATE_TIME_FORMATTER);
}
/**
* 构建额外信息
*/
private Map<String, Object> buildExtraInfo(OpsOrderEventDO event) {
Map<String, Object> extra = new HashMap<>();
extra.put("event_type", event.getEventType());
extra.put("operator_type", event.getOperatorType());
if (event.getOperatorId() != null) {
extra.put("operator_id", event.getOperatorId());
}
return extra;
}
}

View File

@@ -0,0 +1,23 @@
package com.viewsh.module.ops.environment.service.dashboard;
import com.viewsh.module.ops.api.clean.QuickStatsRespDTO;
/**
* 保洁仪表盘服务接口
* <p>
* 职责:
* 1. 提供快速统计数据
*
* @author lzh
*/
public interface CleanDashboardService {
/**
* 获取快速统计
* <p>
* 包括待分配数量、进行中数量、今日完成数量、在线工<E7BABF><E5B7A5><EFBFBD>数量
*
* @return 快速统计响应
*/
QuickStatsRespDTO getQuickStats();
}

View File

@@ -0,0 +1,81 @@
package com.viewsh.module.ops.environment.service.dashboard;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO;
import com.viewsh.module.ops.api.clean.QuickStatsRespDTO;
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
import com.viewsh.module.ops.environment.service.badge.BadgeDeviceStatusService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
/**
* 保洁仪表盘服务实现
*
* @author lzh
*/
@Slf4j
@Service
public class CleanDashboardServiceImpl implements CleanDashboardService {
@Resource
private OpsOrderMapper opsOrderMapper;
@Resource
private BadgeDeviceStatusService badgeDeviceStatusService;
@Override
public QuickStatsRespDTO getQuickStats() {
try {
// 1. 统计待分配数量 (status='PENDING' AND order_type='CLEAN')
Integer pendingCount = Math.toIntExact(opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getStatus, "PENDING")
.eq(OpsOrderDO::getOrderType, "CLEAN")
));
// 2. 统计进行中数量 (status IN ('DISPATCHED','CONFIRMED','ARRIVED') AND order_type='CLEAN')
Integer inProgressCount = Math.toIntExact(opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.in(OpsOrderDO::getStatus, List.of("DISPATCHED", "CONFIRMED", "ARRIVED"))
.eq(OpsOrderDO::getOrderType, "CLEAN")
));
// 3. 统计今日完成数量
LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0);
LocalDateTime todayEnd = todayStart.plusDays(1);
Integer completedTodayCount = Math.toIntExact(opsOrderMapper.selectCount(
new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getStatus, "COMPLETED")
.eq(OpsOrderDO::getOrderType, "CLEAN")
.between(OpsOrderDO::getEndTime, todayStart, todayEnd)
));
// 4. 统计在线工牌数量(从 Redis
List<BadgeDeviceStatusDTO> activeBadges = badgeDeviceStatusService.listActiveBadges();
Integer onlineBadgeCount = activeBadges.size();
return QuickStatsRespDTO.builder()
.pendingCount(pendingCount)
.inProgressCount(inProgressCount)
.completedTodayCount(completedTodayCount)
.onlineBadgeCount(onlineBadgeCount)
.build();
} catch (Exception e) {
log.error("[getQuickStats] 获取快速统计失败", e);
return QuickStatsRespDTO.builder()
.pendingCount(0)
.inProgressCount(0)
.completedTodayCount(0)
.onlineBadgeCount(0)
.build();
}
}
}

View File

@@ -338,7 +338,7 @@ public class CleanOrderEndToEndTest {
verify(eventLogRecorder).record(any());
// 2. TTS sent (orderId can be null for TTS_REQUEST events)
verify(voiceBroadcastService).broadcast(eq(5001L), contains("请回到作业区域"), isNull());
verify(voiceBroadcastService).broadcast(eq(5001L), contains("请回到作业区域"), eq((Long) null));
}
// ==========================================

View File

@@ -0,0 +1,49 @@
package com.viewsh.module.ops.api.clean;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 工牌实时状态详情响应 DTO
* <p>
* 用于工牌详情页展示
*
* @author lzh
*/
@Schema(description = "管理后台 - 工牌实时状态详情 Response DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BadgeRealtimeStatusRespDTO {
@Schema(description = "设备ID", example = "3001")
private Long deviceId;
@Schema(description = "设备编码", example = "badge_001")
private String deviceKey;
@Schema(description = "设备状态", example = "BUSY")
private String status;
@Schema(description = "电量0-100", example = "72")
private Integer batteryLevel;
@Schema(description = "最后心跳时间", example = "2026-01-23 15:00:30")
private String lastHeartbeatTime;
@Schema(description = "信号强度dBm", example = "-42")
private Integer rssi;
@Schema(description = "是否在区域内", example = "true")
private Boolean isInArea;
@Schema(description = "当前区域ID", example = "101")
private Long areaId;
@Schema(description = "当前区域名称", example = "A区洗手间")
private String areaName;
}

View File

@@ -0,0 +1,49 @@
package com.viewsh.module.ops.api.clean;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 工牌状态响应 DTO
* <p>
* 用于工牌列表展示
*
* @author lzh
*/
@Schema(description = "管理后台 - 工牌状态 Response DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BadgeStatusRespDTO {
@Schema(description = "设备ID", example = "3001")
private Long deviceId;
@Schema(description = "设备编码", example = "badge_001")
private String deviceKey;
@Schema(description = "状态IDLE/BUSY/OFFLINE/PAUSED", example = "IDLE")
private String status;
@Schema(description = "电量0-100", example = "85")
private Integer batteryLevel;
@Schema(description = "最后心跳时间", example = "2026-01-23 14:30:25")
private String lastHeartbeatTime;
@Schema(description = "当前所在区域ID", example = "100")
private Long currentAreaId;
@Schema(description = "当前所在区域名称", example = "A区洗手间")
private String currentAreaName;
@Schema(description = "今日完成工单数", example = "5")
private Integer todayCompletedCount;
@Schema(description = "今日工作时长(分钟)", example = "180")
private Integer todayWorkMinutes;
}

View File

@@ -0,0 +1,63 @@
package com.viewsh.module.ops.api.clean;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 工单时间轴响应 DTO
* <p>
* 用于工单详情页时间轴展示
*
* @author lzh
*/
@Schema(description = "管理后台 - 工单时间轴 Response DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderTimelineRespDTO {
@Schema(description = "工单ID", example = "1001")
private Long orderId;
@Schema(description = "当前状态", example = "ARRIVED")
private String currentStatus;
@Schema(description = "时间轴节点列表")
private List<TimelineItemDTO> timeline;
/**
* 时间轴节点 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "时间轴节点")
public static class TimelineItemDTO {
@Schema(description = "状态", example = "PENDING")
private String status;
@Schema(description = "状态名称", example = "工单创建")
private String statusName;
@Schema(description = "时间", example = "2026-01-23 14:30:25")
private String time;
@Schema(description = "操作人", example = "系统")
private String operator;
@Schema(description = "描述", example = "蓝牙信标触发自动创建")
private String description;
@Schema(description = "额外信息如RSSI值、信标ID等")
private Map<String, Object> extra;
}
}

View File

@@ -0,0 +1,34 @@
package com.viewsh.module.ops.api.clean;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 快速统计响应 DTO
* <p>
* 用于工单中心仪表盘快速统计展示
*
* @author lzh
*/
@Schema(description = "管理后台 - 快速统计 Response DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QuickStatsRespDTO {
@Schema(description = "待分配数量", example = "8")
private Integer pendingCount;
@Schema(description = "进行中数量", example = "15")
private Integer inProgressCount;
@Schema(description = "今日完成数量", example = "42")
private Integer completedTodayCount;
@Schema(description = "在线工牌数量", example = "12")
private Integer onlineBadgeCount;
}

View File

@@ -0,0 +1,25 @@
package com.viewsh.module.ops.enums;
import com.viewsh.framework.common.exception.ErrorCode;
/**
* ops 错误码枚举类
* <p>
* ops 系统,使用 1-020-000-000 段
*/
public interface ErrorCodeConstants {
// ========== 业务区域 1-020-001-000 ============
ErrorCode AREA_NOT_FOUND = new ErrorCode(1_020_001_000, "区域不存在");
ErrorCode AREA_HAS_CHILDREN = new ErrorCode(1_020_001_001, "该区域下存在子区域,请先处理子区域");
ErrorCode AREA_HAS_DEVICES = new ErrorCode(1_020_001_002, "该区域已绑定设备,请先解除绑定");
ErrorCode AREA_PARENT_LOOP = new ErrorCode(1_020_001_003, "不能将父级设置为自己或子孙节点");
ErrorCode AREA_CODE_EXISTS = new ErrorCode(1_020_001_004, "区域编码已存在");
// ========== 区域设备关联 1-020-002-000 ============
ErrorCode DEVICE_NOT_FOUND = new ErrorCode(1_020_002_000, "设备不存在");
ErrorCode DEVICE_ALREADY_BOUND = new ErrorCode(1_020_002_001, "该工牌已绑定至此区域");
ErrorCode DEVICE_TYPE_ALREADY_BOUND = new ErrorCode(1_020_002_002, "该区域已绑定{},一个区域只能绑定一个");
ErrorCode DEVICE_RELATION_NOT_FOUND = new ErrorCode(1_020_002_003, "设备关联关系不存在");
}

View File

@@ -0,0 +1,55 @@
package com.viewsh.module.ops.enums;
import com.viewsh.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 工牌通知类型枚举
* <p>
* 用于工牌设备通知(语音、震动等)
*
* @author lzh
*/
@AllArgsConstructor
@Getter
public enum NotifyTypeEnum implements ArrayValuable<String> {
VOICE("VOICE", "语音通知"),
VIBRATE("VIBRATE", "震动通知");
public static final String[] ARRAYS = Arrays.stream(values()).map(NotifyTypeEnum::getType).toArray(String[]::new);
/**
* 类型
*/
private final String type;
/**
* 描述
*/
private final String description;
@Override
public String[] array() {
return ARRAYS;
}
/**
* 根据类型获取枚举
*
* @param type 类型
* @return 枚举实例,未找到返回 null
*/
public static NotifyTypeEnum fromType(String type) {
if (type == null) {
return null;
}
return Arrays.stream(values())
.filter(e -> e.getType().equals(type))
.findFirst()
.orElse(null);
}
}

View File

@@ -0,0 +1,34 @@
package com.viewsh.module.ops.dal.dataobject.vo.area;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Map;
/**
* 区域设备绑定请求 VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 区域设备绑定 Request VO")
@Data
public class AreaDeviceBindReqVO {
@Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
@NotNull(message = "区域ID不能为空")
private Long areaId;
@Schema(description = "设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "50001")
@NotNull(message = "设备ID不能为空")
private Long deviceId;
@Schema(description = "关联类型TRAFFIC_COUNTER=客流计数器/BEACON=信标/BADGE=工牌)", requiredMode = Schema.RequiredMode.REQUIRED, example = "TRAFFIC_COUNTER")
@NotBlank(message = "关联类型不能为空")
private String relationType;
@Schema(description = "配置数据JSON格式", example = "{\"threshold\":100}")
private Map<String, Object> configData;
}

View File

@@ -0,0 +1,60 @@
package com.viewsh.module.ops.dal.dataobject.vo.area;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 区域设备关联响应 VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 区域设备关联 Response VO")
@Data
public class AreaDeviceRelationRespVO {
@Schema(description = "关联ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long id;
@Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
private Long areaId;
@Schema(description = "区域名称", example = "A栋3层电梯厅")
private String areaName;
@Schema(description = "设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "50001")
private Long deviceId;
@Schema(description = "设备Key", example = "TRAFFIC_COUNTER_001")
private String deviceKey;
@Schema(description = "设备名称", example = "客流计数器001")
private String deviceName;
@Schema(description = "产品ID", example = "10")
private Long productId;
@Schema(description = "产品Key", example = "traffic_counter_v1")
private String productKey;
@Schema(description = "产品名称", example = "客流计数器")
private String productName;
@Schema(description = "关联类型TRAFFIC_COUNTER=客流计数器/BEACON=信标/BADGE=工牌)", requiredMode = Schema.RequiredMode.REQUIRED, example = "TRAFFIC_COUNTER")
private String relationType;
@Schema(description = "配置数据JSON格式", example = "{\"threshold\":100}")
private Map<String, Object> configData;
@Schema(description = "是否启用", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
private Boolean enabled;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,28 @@
package com.viewsh.module.ops.dal.dataobject.vo.area;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Map;
/**
* 区域设备关联更新请求 VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 区域设备关联更新 Request VO")
@Data
public class AreaDeviceUpdateReqVO {
@Schema(description = "关联ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "关联ID不能为空")
private Long id;
@Schema(description = "配置数据JSON格式完全替换", example = "{\"threshold\":150}")
private Map<String, Object> configData;
@Schema(description = "是否启用", example = "true")
private Boolean enabled;
}

View File

@@ -0,0 +1,50 @@
package com.viewsh.module.ops.dal.dataobject.vo.area;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 业务区域新增请求 VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 业务区域新增 Request VO")
@Data
public class OpsBusAreaCreateReqVO {
@Schema(description = "区域名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "A栋3层")
@NotBlank(message = "区域名称不能为空")
private String areaName;
@Schema(description = "父级区域ID", example = "1")
private Long parentId;
@Schema(description = "区域业务编码(可选,全局唯一)", example = "BUILD_A_F3")
private String areaCode;
@Schema(description = "区域层级类型PARK=园区/BUILDING=楼栋/FLOOR=楼层/FUNCTION=功能区域)", example = "FLOOR")
private String areaType;
@Schema(description = "功能类型MALE_TOILET=男卫/FEMALE_TOILET=女卫/PUBLIC=公共区/ELEVATOR=电梯厅等)", example = "ELEVATOR")
private String functionType;
@Schema(description = "楼层号2、-1", example = "3")
private Integer floorNo;
@Schema(description = "保洁频率(次/天)", example = "3")
private Integer cleaningFrequency;
@Schema(description = "标准保洁时长(分钟)", example = "30")
private Integer standardDuration;
@Schema(description = "区域等级HIGH=高优先级/MEDIUM=中/LOW=低)", example = "MEDIUM")
private String areaLevel;
@Schema(description = "是否启用", example = "true")
private Boolean isActive;
@Schema(description = "显示排序", example = "1")
private Integer sort;
}

View File

@@ -0,0 +1,27 @@
package com.viewsh.module.ops.dal.dataobject.vo.area;
import com.viewsh.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 业务区域分页查询请求 VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 业务区域分页查询 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class OpsBusAreaPageReqVO extends PageParam {
@Schema(description = "区域类型PARK=园区/BUILDING=楼栋/FLOOR=楼层/FUNCTION=功能区域)", example = "PARK")
private String areaType;
@Schema(description = "是否启用", example = "true")
private Boolean isActive;
@Schema(description = "区域名称或编码(模糊搜索)", example = "A栋")
private String name;
}

View File

@@ -0,0 +1,66 @@
package com.viewsh.module.ops.dal.dataobject.vo.area;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 业务区域响应 VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 业务区域 Response VO")
@Data
public class OpsBusAreaRespVO {
@Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
private Long id;
@Schema(description = "父级区域ID", example = "1")
private Long parentId;
@Schema(description = "父级路径1/2/3便于查询所有子区域", example = "/1/2")
private String parentPath;
@Schema(description = "区域名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "A栋3层")
private String areaName;
@Schema(description = "区域业务编码(可选,全局唯一)", example = "BUILD_A_F3")
private String areaCode;
@Schema(description = "区域层级类型PARK=园区/BUILDING=楼栋/FLOOR=楼层/FUNCTION=功能区域)", example = "FLOOR")
private String areaType;
@Schema(description = "功能类型MALE_TOILET=男卫/FEMALE_TOILET=女卫/PUBLIC=公共区/ELEVATOR=电梯厅等)", example = "ELEVATOR")
private String functionType;
@Schema(description = "楼层号2、-1", example = "3")
private Integer floorNo;
@Schema(description = "保洁频率(次/天)", example = "3")
private Integer cleaningFrequency;
@Schema(description = "标准保洁时长(分钟)", example = "30")
private Integer standardDuration;
@Schema(description = "区域等级HIGH=高优先级/MEDIUM=中/LOW=低)", example = "MEDIUM")
private String areaLevel;
@Schema(description = "是否启用", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
private Boolean isActive;
@Schema(description = "显示排序", example = "1")
private Integer sort;
@Schema(description = "子区域列表(前端自行组装树形结构)", example = "[]")
private List<OpsBusAreaRespVO> children;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,53 @@
package com.viewsh.module.ops.dal.dataobject.vo.area;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 业务区域更新请求 VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 业务区域更新 Request VO")
@Data
public class OpsBusAreaUpdateReqVO {
@Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
@NotNull(message = "区域ID不能为空")
private Long id;
@Schema(description = "区域名称", example = "A栋3层")
private String areaName;
@Schema(description = "父级区域ID", example = "1")
private Long parentId;
@Schema(description = "区域业务编码(可选,全局唯一)", example = "BUILD_A_F3")
private String areaCode;
@Schema(description = "区域层级类型PARK=园区/BUILDING=楼栋/FLOOR=楼层/FUNCTION=功能区域)", example = "FLOOR")
private String areaType;
@Schema(description = "功能类型MALE_TOILET=男卫/FEMALE_TOILET=女卫/PUBLIC=公共区/ELEVATOR=电梯厅等)", example = "ELEVATOR")
private String functionType;
@Schema(description = "楼层号2、-1", example = "3")
private Integer floorNo;
@Schema(description = "保洁频率(次/天)", example = "3")
private Integer cleaningFrequency;
@Schema(description = "标准保洁时长(分钟)", example = "30")
private Integer standardDuration;
@Schema(description = "区域等级HIGH=高优先级/MEDIUM=中/LOW=低)", example = "MEDIUM")
private String areaLevel;
@Schema(description = "是否启用", example = "true")
private Boolean isActive;
@Schema(description = "显示排序", example = "1")
private Integer sort;
}

View File

@@ -72,4 +72,47 @@ public interface OpsAreaDeviceRelationMapper extends BaseMapperX<OpsAreaDeviceRe
.eq(OpsAreaDeviceRelationDO::getProductKey, productKey)
.eq(OpsAreaDeviceRelationDO::getEnabled, true));
}
/**
* 统计区域某类型设备的数量
*
* @param areaId 区域ID
* @param relationType 关联类型
* @return 数量
*/
default Long countByAreaIdAndType(Long areaId, String relationType) {
return selectCount(new LambdaQueryWrapperX<OpsAreaDeviceRelationDO>()
.eq(OpsAreaDeviceRelationDO::getAreaId, areaId)
.eq(OpsAreaDeviceRelationDO::getRelationType, relationType)
.eq(OpsAreaDeviceRelationDO::getEnabled, true));
}
/**
* 查询区域某类型设备的关联记录
*
* @param areaId 区域ID
* @param deviceId 设备ID
* @param relationType 关联类型
* @return 关联关系
*/
default OpsAreaDeviceRelationDO selectByAreaIdAndDeviceIdAndType(Long areaId, Long deviceId, String relationType) {
return selectOne(new LambdaQueryWrapperX<OpsAreaDeviceRelationDO>()
.eq(OpsAreaDeviceRelationDO::getAreaId, areaId)
.eq(OpsAreaDeviceRelationDO::getDeviceId, deviceId)
.eq(OpsAreaDeviceRelationDO::getRelationType, relationType)
.eq(OpsAreaDeviceRelationDO::getEnabled, true));
}
/**
* 统计区域的设备关联数量
*
* @param areaId 区域ID
* @return 数量
*/
default Long selectCountByAreaId(Long areaId) {
return selectCount(new LambdaQueryWrapperX<OpsAreaDeviceRelationDO>()
.eq(OpsAreaDeviceRelationDO::getAreaId, areaId)
.eq(OpsAreaDeviceRelationDO::getEnabled, true));
}
}

View File

@@ -3,6 +3,7 @@ package com.viewsh.module.ops.dal.mysql.area;
import com.viewsh.framework.mybatis.core.mapper.BaseMapperX;
import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@@ -67,4 +68,39 @@ public interface OpsBusAreaMapper extends BaseMapperX<OpsBusAreaDO> {
return selectOne(OpsBusAreaDO::getAreaCode, areaCode);
}
/**
* 根据分页查询条件查询列表
*
* @param reqVO 查询条件
* @return 区域列表
*/
default List<OpsBusAreaDO> selectListByPageReq(OpsBusAreaPageReqVO reqVO) {
LambdaQueryWrapperX<OpsBusAreaDO> wrapper = new LambdaQueryWrapperX<OpsBusAreaDO>();
wrapper.orderByAsc(OpsBusAreaDO::getSort);
if (reqVO.getAreaType() != null) {
wrapper.eq(OpsBusAreaDO::getAreaType, reqVO.getAreaType());
}
if (reqVO.getIsActive() != null) {
wrapper.eq(OpsBusAreaDO::getIsActive, reqVO.getIsActive());
}
if (reqVO.getName() != null) {
wrapper.and(w -> w.like(OpsBusAreaDO::getAreaName, reqVO.getName())
.or()
.like(OpsBusAreaDO::getAreaCode, reqVO.getName()));
}
return selectList(wrapper);
}
/**
* 统计子区域数量
*
* @param parentId 父级区域ID
* @return 子区域数量
*/
default Long selectCountByParentId(Long parentId) {
return selectCount(OpsBusAreaDO::getParentId, parentId);
}
}

View File

@@ -0,0 +1,47 @@
package com.viewsh.module.ops.service.area;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO;
import java.util.List;
/**
* 区域设备关联管理服务
*
* @author lzh
*/
public interface AreaDeviceRelationService {
/**
* 查询区域设备列表
*
* @param areaId 区域ID
* @return 设备关联列表
*/
List<AreaDeviceRelationRespVO> listByAreaId(Long areaId);
/**
* 绑定设备到区域
*
* @param bindReq 绑定请求
* @return 关联ID
*/
Long bindDevice(AreaDeviceBindReqVO bindReq);
/**
* 更新设备关联
*
* @param updateReq 更新请求
*/
void updateRelation(AreaDeviceUpdateReqVO updateReq);
/**
* 解除设备绑定
*
* @param id 关联ID
* @return 是否解除成功
*/
Boolean unbindDevice(Long id);
}

View File

@@ -0,0 +1,186 @@
package com.viewsh.module.ops.service.area;
import cn.hutool.core.bean.BeanUtil;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.framework.common.exception.util.ServiceExceptionUtil;
import com.viewsh.module.iot.api.device.IotDeviceQueryApi;
import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO;
import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO;
import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper;
import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO;
import com.viewsh.module.ops.enums.ErrorCodeConstants;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.viewsh.module.ops.enums.AreaTypeEnum.*;
/**
* 区域设备关联管理服务实现
*
* @author lzh
*/
@Service
@Validated
@Slf4j
public class AreaDeviceRelationServiceImpl implements AreaDeviceRelationService {
@Resource
private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper;
@Resource
private OpsBusAreaMapper opsBusAreaMapper;
@Resource
private IotDeviceQueryApi iotDeviceQueryApi;
private static final String TYPE_TRAFFIC_COUNTER = "TRAFFIC_COUNTER";
private static final String TYPE_BEACON = "BEACON";
private static final String TYPE_BADGE = "BADGE";
@Override
public List<AreaDeviceRelationRespVO> listByAreaId(Long areaId) {
List<OpsAreaDeviceRelationDO> list = opsAreaDeviceRelationMapper.selectListByAreaId(areaId);
return list.stream()
.map(this::convertToRespVO)
.collect(java.util.stream.Collectors.toList());
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long bindDevice(AreaDeviceBindReqVO bindReq) {
// 校验区域存在
if (opsBusAreaMapper.selectById(bindReq.getAreaId()) == null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_NOT_FOUND);
}
// 根据关联类型进行唯一性校验
String relationType = bindReq.getRelationType();
// 1对1 约束TRAFFIC_COUNTER 和 BEACON
if (TYPE_TRAFFIC_COUNTER.equals(relationType) || TYPE_BEACON.equals(relationType)) {
Long count = opsAreaDeviceRelationMapper.countByAreaIdAndType(bindReq.getAreaId(), relationType);
if (count > 0) {
String typeName = TYPE_TRAFFIC_COUNTER.equals(relationType) ? "客流计数器" : "信标";
throw ServiceExceptionUtil.exception(ErrorCodeConstants.DEVICE_TYPE_ALREADY_BOUND, typeName);
}
}
// N对1 约束BADGE - 同一设备只能绑定一次到同一区域
if (TYPE_BADGE.equals(relationType)) {
OpsAreaDeviceRelationDO existing = opsAreaDeviceRelationMapper.selectByAreaIdAndDeviceIdAndType(
bindReq.getAreaId(), bindReq.getDeviceId(), relationType);
if (existing != null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.DEVICE_ALREADY_BOUND);
}
}
// 调用 IoT 接口获取设备信息
String deviceKey = "DEVICE_" + bindReq.getDeviceId();
Long productId = 0L;
String productKey = "PRODUCT_DEFAULT";
try {
CommonResult<IotDeviceSimpleRespDTO> result = iotDeviceQueryApi.getDevice(bindReq.getDeviceId());
if (result != null && result.getData() != null) {
IotDeviceSimpleRespDTO device = result.getData();
deviceKey = device.getDeviceName() != null ? device.getDeviceName() : deviceKey;
productId = device.getProductId() != null ? device.getProductId() : 0L;
productKey = device.getProductKey() != null ? device.getProductKey() : productKey;
}
} catch (Exception e) {
log.warn("[bindDevice] 调用 IoT 接口获取设备信息失败: deviceId={}, error={}",
bindReq.getDeviceId(), e.getMessage());
// 降级:使用默认值
}
OpsAreaDeviceRelationDO relation = OpsAreaDeviceRelationDO.builder()
.areaId(bindReq.getAreaId())
.deviceId(bindReq.getDeviceId())
.deviceKey(deviceKey)
.productId(productId)
.productKey(productKey)
.relationType(bindReq.getRelationType())
.configData(bindReq.getConfigData() != null ? bindReq.getConfigData() : new HashMap<>())
.enabled(true)
.build();
opsAreaDeviceRelationMapper.insert(relation);
return relation.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateRelation(AreaDeviceUpdateReqVO updateReq) {
OpsAreaDeviceRelationDO existing = opsAreaDeviceRelationMapper.selectById(updateReq.getId());
if (existing == null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.DEVICE_RELATION_NOT_FOUND);
}
OpsAreaDeviceRelationDO relation = new OpsAreaDeviceRelationDO();
relation.setId(updateReq.getId());
// configData 完全替换
if (updateReq.getConfigData() != null) {
relation.setConfigData(updateReq.getConfigData());
}
// enabled 更新
if (updateReq.getEnabled() != null) {
relation.setEnabled(updateReq.getEnabled());
}
opsAreaDeviceRelationMapper.updateById(relation);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean unbindDevice(Long id) {
OpsAreaDeviceRelationDO existing = opsAreaDeviceRelationMapper.selectById(id);
if (existing == null) {
return false;
}
return opsAreaDeviceRelationMapper.deleteById(id) > 0;
}
/**
* 转换为响应 VO
* 从 IoT 模块获取设备名称和产品名称(带降级)
*/
private AreaDeviceRelationRespVO convertToRespVO(OpsAreaDeviceRelationDO relation) {
AreaDeviceRelationRespVO respVO = BeanUtil.copyProperties(relation, AreaDeviceRelationRespVO.class);
// 从 IoT 模块获取设备信息
try {
CommonResult<IotDeviceSimpleRespDTO> result = iotDeviceQueryApi.getDevice(relation.getDeviceId());
if (result != null && result.getData() != null) {
IotDeviceSimpleRespDTO device = result.getData();
respVO.setDeviceName(device.getDeviceName());
respVO.setProductName(device.getProductName());
} else {
// 降级:使用默认值
respVO.setDeviceName("设备_" + relation.getDeviceId());
respVO.setProductName("产品_" + relation.getProductKey());
}
} catch (Exception e) {
log.warn("[convertToRespVO] 调用 IoT 接口获取设备信息失败: deviceId={}, error={}",
relation.getDeviceId(), e.getMessage());
// 降级:使用默认值
respVO.setDeviceName("设备_" + relation.getDeviceId());
respVO.setProductName("产品_" + relation.getProductKey());
}
return respVO;
}
}

View File

@@ -0,0 +1,57 @@
package com.viewsh.module.ops.service.area;
import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaCreateReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaUpdateReqVO;
import java.util.List;
/**
* 业务区域管理服务
*
* @author lzh
*/
public interface OpsBusAreaService {
/**
* 获取区域列表(平铺,由前端自行组装树形结构)
*
* @param reqVO 查询条件
* @return 区域列表
*/
List<OpsBusAreaRespVO> getAreaTree(OpsBusAreaPageReqVO reqVO);
/**
* 获取区域详情
*
* @param id 区域ID
* @return 区域详情
*/
OpsBusAreaRespVO getArea(Long id);
/**
* 新增区域
*
* @param createReq 创建请求
* @return 区域ID
*/
Long createArea(OpsBusAreaCreateReqVO createReq);
/**
* 更新区域
*
* @param updateReq 更新请求
*/
void updateArea(OpsBusAreaUpdateReqVO updateReq);
/**
* 删除区域
*
* @param id 区域ID
* @return 是否删除成功
*/
Boolean deleteArea(Long id);
}

View File

@@ -0,0 +1,195 @@
package com.viewsh.module.ops.service.area;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import com.viewsh.framework.common.exception.util.ServiceExceptionUtil;
import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO;
import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper;
import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaCreateReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaUpdateReqVO;
import com.viewsh.module.ops.enums.ErrorCodeConstants;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.List;
import java.util.stream.Collectors;
/**
* 业务区域管理服务实现
*
* @author lzh
*/
@Service
@Validated
public class OpsBusAreaServiceImpl implements OpsBusAreaService {
@Resource
private OpsBusAreaMapper opsBusAreaMapper;
@Resource
private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper;
@Override
public List<OpsBusAreaRespVO> getAreaTree(OpsBusAreaPageReqVO reqVO) {
List<OpsBusAreaDO> list = opsBusAreaMapper.selectListByPageReq(reqVO);
return BeanUtil.copyToList(list, OpsBusAreaRespVO.class);
}
@Override
public OpsBusAreaRespVO getArea(Long id) {
OpsBusAreaDO area = opsBusAreaMapper.selectById(id);
if (area == null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_NOT_FOUND);
}
return BeanUtil.copyProperties(area, OpsBusAreaRespVO.class);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long createArea(OpsBusAreaCreateReqVO createReq) {
// 校验区域编码唯一性
if (createReq.getAreaCode() != null) {
OpsBusAreaDO existing = opsBusAreaMapper.selectByAreaCode(createReq.getAreaCode());
if (existing != null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_CODE_EXISTS);
}
}
// 校验父级区域存在
if (createReq.getParentId() != null) {
OpsBusAreaDO parent = opsBusAreaMapper.selectById(createReq.getParentId());
if (parent == null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_NOT_FOUND);
}
}
OpsBusAreaDO area = BeanUtil.copyProperties(createReq, OpsBusAreaDO.class);
// 计算 parentPath
area.setParentPath(buildParentPath(createReq.getParentId()));
// 设置默认值
if (area.getIsActive() == null) {
area.setIsActive(true);
}
if (area.getSort() == null) {
area.setSort(0);
}
opsBusAreaMapper.insert(area);
return area.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateArea(OpsBusAreaUpdateReqVO updateReq) {
// 校验区域存在
OpsBusAreaDO existing = opsBusAreaMapper.selectById(updateReq.getId());
if (existing == null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_NOT_FOUND);
}
// 如果修改父级,校验父级存在且不能设置为自己或子孙节点
if (updateReq.getParentId() != null && !updateReq.getParentId().equals(existing.getParentId())) {
// 校验不能将父级设置为自己
if (updateReq.getId().equals(updateReq.getParentId())) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_PARENT_LOOP);
}
// 校验父级存在
OpsBusAreaDO parent = opsBusAreaMapper.selectById(updateReq.getParentId());
if (parent == null) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_NOT_FOUND);
}
// 校验不能将父级设置为子孙节点
if (isDescendant(updateReq.getId(), updateReq.getParentId())) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_PARENT_LOOP);
}
// 更新 parentPath
String newParentPath = buildParentPath(updateReq.getParentId());
updateReq.setParentId(updateReq.getParentId());
// 注意:这里需要在 DO 中设置 parentPath但由于 UpdateReqVO 没有 parentPath 字段
// 我们需要在后续更新时处理
}
OpsBusAreaDO area = BeanUtil.copyProperties(updateReq, OpsBusAreaDO.class);
// 重新计算 parentPath
if (updateReq.getParentId() != null) {
area.setParentPath(buildParentPath(updateReq.getParentId()));
} else {
area.setParentPath(null);
}
opsBusAreaMapper.updateById(area);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean deleteArea(Long id) {
// 校验区域存在
OpsBusAreaDO existing = opsBusAreaMapper.selectById(id);
if (existing == null) {
return false;
}
// 校验是否有子区域
Long childCount = opsBusAreaMapper.selectCountByParentId(id);
if (childCount > 0) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_HAS_CHILDREN);
}
// 校验是否有关联设备
Long deviceCount = opsAreaDeviceRelationMapper.selectCountByAreaId(id);
if (deviceCount > 0) {
throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_HAS_DEVICES);
}
return opsBusAreaMapper.deleteById(id) > 0;
}
/**
* 构建 parentPath
*
* @param parentId 父级ID
* @return parentPath
*/
private String buildParentPath(Long parentId) {
if (parentId == null) {
return null;
}
OpsBusAreaDO parent = opsBusAreaMapper.selectById(parentId);
if (parent == null) {
return null;
}
if (parent.getParentPath() == null || parent.getParentPath().isEmpty()) {
return String.valueOf(parentId);
}
return parent.getParentPath() + "/" + parentId;
}
/**
* 判断 targetId 是否是 sourceId 的子孙节点
*
* @param sourceId 源节点ID
* @param targetId 目标节点ID
* @return 是否是子孙节点
*/
private boolean isDescendant(Long sourceId, Long targetId) {
OpsBusAreaDO target = opsBusAreaMapper.selectById(targetId);
if (target == null || target.getParentPath() == null) {
return false;
}
return target.getParentPath().contains("/" + sourceId + "/")
|| target.getParentPath().startsWith(sourceId + "/")
|| target.getParentPath().endsWith("/" + sourceId);
}
}

View File

@@ -0,0 +1,240 @@
package com.viewsh.module.ops.service.area;
import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO;
import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO;
import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper;
import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO;
import com.viewsh.module.ops.enums.ErrorCodeConstants;
import com.viewsh.framework.common.exception.ServiceException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 区域设备关联管理服务测试
*
* @author lzh
*/
@ExtendWith(MockitoExtension.class)
class AreaDeviceRelationServiceTest {
@Mock
private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper;
@Mock
private OpsBusAreaMapper opsBusAreaMapper;
@InjectMocks
private AreaDeviceRelationServiceImpl areaDeviceRelationService;
private OpsBusAreaDO testArea;
private OpsAreaDeviceRelationDO testRelation;
@BeforeEach
void setUp() {
testArea = OpsBusAreaDO.builder()
.id(100L)
.areaName("A栋3层电梯厅")
.build();
testRelation = OpsAreaDeviceRelationDO.builder()
.id(1L)
.areaId(100L)
.deviceId(50001L)
.deviceKey("TRAFFIC_COUNTER_001")
.productId(10L)
.productKey("traffic_counter_v1")
.relationType("TRAFFIC_COUNTER")
.configData(new HashMap<>())
.enabled(true)
.build();
}
@Test
void testListByAreaId_Success() {
// Given
when(opsAreaDeviceRelationMapper.selectListByAreaId(100L))
.thenReturn(Arrays.asList(testRelation));
// When
List<AreaDeviceRelationRespVO> result = areaDeviceRelationService.listByAreaId(100L);
// Then
assertNotNull(result);
assertEquals(1, result.size());
assertEquals(50001L, result.get(0).getDeviceId());
verify(opsAreaDeviceRelationMapper, times(1)).selectListByAreaId(100L);
}
@Test
void testListByAreaId_Empty_Success() {
// Given
when(opsAreaDeviceRelationMapper.selectListByAreaId(100L))
.thenReturn(Collections.emptyList());
// When
List<AreaDeviceRelationRespVO> result = areaDeviceRelationService.listByAreaId(100L);
// Then
assertNotNull(result);
assertTrue(result.isEmpty());
}
@Test
void testBindDevice_TrafficCounter_Success() {
// Given: 1对1约束但区域尚无该类型设备
AreaDeviceBindReqVO bindReq = new AreaDeviceBindReqVO();
bindReq.setAreaId(100L);
bindReq.setDeviceId(50001L);
bindReq.setRelationType("TRAFFIC_COUNTER");
when(opsBusAreaMapper.selectById(100L)).thenReturn(testArea);
when(opsAreaDeviceRelationMapper.countByAreaIdAndType(100L, "TRAFFIC_COUNTER")).thenReturn(0L);
when(opsAreaDeviceRelationMapper.insert(any(OpsAreaDeviceRelationDO.class))).thenAnswer(invocation -> {
OpsAreaDeviceRelationDO relation = invocation.getArgument(0);
relation.setId(1L);
return 1;
});
// When
Long relationId = areaDeviceRelationService.bindDevice(bindReq);
// Then
assertNotNull(relationId);
verify(opsAreaDeviceRelationMapper, times(1)).insert(any(OpsAreaDeviceRelationDO.class));
}
@Test
void testBindDevice_TrafficCounter_AlreadyBound_ThrowsException() {
// Given: PBT - 1对1约束区域已有客流计数器
AreaDeviceBindReqVO bindReq = new AreaDeviceBindReqVO();
bindReq.setAreaId(100L);
bindReq.setDeviceId(50002L);
bindReq.setRelationType("TRAFFIC_COUNTER");
when(opsBusAreaMapper.selectById(100L)).thenReturn(testArea);
when(opsAreaDeviceRelationMapper.countByAreaIdAndType(100L, "TRAFFIC_COUNTER")).thenReturn(1L);
// When & Then
ServiceException exception = assertThrows(ServiceException.class,
() -> areaDeviceRelationService.bindDevice(bindReq));
assertEquals(ErrorCodeConstants.DEVICE_TYPE_ALREADY_BOUND.getCode(), exception.getCode());
}
@Test
void testBindDevice_Badge_AlreadyBound_ThrowsException() {
// Given: PBT - N对1约束同一设备已绑定该区域
AreaDeviceBindReqVO bindReq = new AreaDeviceBindReqVO();
bindReq.setAreaId(100L);
bindReq.setDeviceId(30001L);
bindReq.setRelationType("BADGE");
when(opsBusAreaMapper.selectById(100L)).thenReturn(testArea);
when(opsAreaDeviceRelationMapper.selectByAreaIdAndDeviceIdAndType(100L, 30001L, "BADGE"))
.thenReturn(testRelation);
// When & Then
ServiceException exception = assertThrows(ServiceException.class,
() -> areaDeviceRelationService.bindDevice(bindReq));
assertEquals(ErrorCodeConstants.DEVICE_ALREADY_BOUND.getCode(), exception.getCode());
}
@Test
void testBindDevice_AreaNotFound_ThrowsException() {
// Given
AreaDeviceBindReqVO bindReq = new AreaDeviceBindReqVO();
bindReq.setAreaId(100L);
bindReq.setDeviceId(50001L);
bindReq.setRelationType("TRAFFIC_COUNTER");
when(opsBusAreaMapper.selectById(100L)).thenReturn(null);
// When & Then
ServiceException exception = assertThrows(ServiceException.class,
() -> areaDeviceRelationService.bindDevice(bindReq));
assertEquals(ErrorCodeConstants.AREA_NOT_FOUND.getCode(), exception.getCode());
}
@Test
void testUpdateRelation_Success() {
// Given
AreaDeviceUpdateReqVO updateReq = new AreaDeviceUpdateReqVO();
updateReq.setId(1L);
Map<String, Object> newConfig = new HashMap<>();
newConfig.put("threshold", 150);
when(opsAreaDeviceRelationMapper.selectById(1L)).thenReturn(testRelation);
when(opsAreaDeviceRelationMapper.updateById(any(OpsAreaDeviceRelationDO.class))).thenReturn(1);
// When
assertDoesNotThrow(() -> areaDeviceRelationService.updateRelation(updateReq));
// Then
verify(opsAreaDeviceRelationMapper, times(1)).updateById(any(OpsAreaDeviceRelationDO.class));
}
@Test
void testUpdateRelation_NotFound_ThrowsException() {
// Given
AreaDeviceUpdateReqVO updateReq = new AreaDeviceUpdateReqVO();
updateReq.setId(999L);
when(opsAreaDeviceRelationMapper.selectById(999L)).thenReturn(null);
// When & Then
ServiceException exception = assertThrows(ServiceException.class,
() -> areaDeviceRelationService.updateRelation(updateReq));
assertEquals(ErrorCodeConstants.DEVICE_RELATION_NOT_FOUND.getCode(), exception.getCode());
}
@Test
void testUnbindDevice_Success() {
// Given
when(opsAreaDeviceRelationMapper.selectById(1L)).thenReturn(testRelation);
when(opsAreaDeviceRelationMapper.deleteById(1L)).thenReturn(1);
// When
Boolean result = areaDeviceRelationService.unbindDevice(1L);
// Then
assertTrue(result);
verify(opsAreaDeviceRelationMapper, times(1)).deleteById(1L);
}
@Test
void testUnbindDevice_Idempotency() {
// Given: PBT - 幂等性,解除不存在的关联返回 false
when(opsAreaDeviceRelationMapper.selectById(1L)).thenReturn(null);
// When
Boolean result = areaDeviceRelationService.unbindDevice(1L);
// Then
assertFalse(result); // 第一次就返回false
verify(opsAreaDeviceRelationMapper, never()).deleteById(anyLong());
}
}

View File

@@ -0,0 +1,284 @@
package com.viewsh.module.ops.service.area;
import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO;
import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper;
import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaCreateReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaUpdateReqVO;
import com.viewsh.module.ops.enums.ErrorCodeConstants;
import com.viewsh.framework.common.exception.ServiceException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 业务区域管理服务测试
* <p>
* 测试内容:
* 1. CRUD 基础操作
* 2. parent_path 不变性
* 3. 删除校验(子区域、设备关联)
* 4. 父子循环检测
*
* @author lzh
*/
@ExtendWith(MockitoExtension.class)
class OpsBusAreaServiceTest {
@Mock
private OpsBusAreaMapper opsBusAreaMapper;
@Mock
private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper;
@InjectMocks
private OpsBusAreaServiceImpl opsBusAreaService;
private OpsBusAreaDO testArea;
@BeforeEach
void setUp() {
// 初始化测试区域
testArea = OpsBusAreaDO.builder()
.id(1L)
.parentId(null)
.parentPath(null)
.areaName("A园区")
.areaCode("PARK_A")
.areaType("PARK")
.isActive(true)
.sort(1)
.build();
}
@Test
void testGetAreaTree_Success() {
// Given
OpsBusAreaPageReqVO reqVO = new OpsBusAreaPageReqVO();
reqVO.setAreaType("PARK");
when(opsBusAreaMapper.selectListByPageReq(any())).thenReturn(Arrays.asList(testArea));
// When
var result = opsBusAreaService.getAreaTree(reqVO);
// Then
assertNotNull(result);
assertEquals(1, result.size());
assertEquals("A园区", result.get(0).getAreaName());
verify(opsBusAreaMapper, times(1)).selectListByPageReq(any());
}
@Test
void testGetArea_Success() {
// Given
when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea);
// When
var result = opsBusAreaService.getArea(1L);
// Then
assertNotNull(result);
assertEquals("A园区", result.getAreaName());
assertEquals(1L, result.getId());
}
@Test
void testGetArea_NotFound_ThrowsException() {
// Given
when(opsBusAreaMapper.selectById(999L)).thenReturn(null);
// When & Then
ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.getArea(999L));
assertEquals(ErrorCodeConstants.AREA_NOT_FOUND.getCode(), exception.getCode());
}
@Test
void testCreateArea_Root_Success() {
// Given: 根区域(无父级)
OpsBusAreaCreateReqVO createReq = new OpsBusAreaCreateReqVO();
createReq.setAreaName("A园区");
createReq.setAreaCode("PARK_A");
createReq.setAreaType("PARK");
when(opsBusAreaMapper.selectByAreaCode("PARK_A")).thenReturn(null);
when(opsBusAreaMapper.insert(any(OpsBusAreaDO.class))).thenAnswer(invocation -> {
OpsBusAreaDO area = invocation.getArgument(0);
area.setId(1L);
return 1;
});
// When
Long areaId = opsBusAreaService.createArea(createReq);
// Then
assertNotNull(areaId);
verify(opsBusAreaMapper, times(1)).insert(argThat((OpsBusAreaDO area) ->
"A园区".equals(area.getAreaName()) &&
area.getParentPath() == null &&
area.getIsActive() &&
area.getSort() == 0
));
}
@Test
void testCreateArea_WithParent_Success() {
// Given: 子区域
OpsBusAreaCreateReqVO createReq = new OpsBusAreaCreateReqVO();
createReq.setAreaName("A栋");
createReq.setParentId(1L);
createReq.setAreaType("BUILDING");
OpsBusAreaDO parentArea = OpsBusAreaDO.builder()
.id(1L)
.parentPath("10")
.build();
when(opsBusAreaMapper.selectById(1L)).thenReturn(parentArea);
when(opsBusAreaMapper.insert(any(OpsBusAreaDO.class))).thenAnswer(invocation -> {
OpsBusAreaDO area = invocation.getArgument(0);
area.setId(2L);
return 1;
});
// When
Long areaId = opsBusAreaService.createArea(createReq);
// Then
assertNotNull(areaId);
verify(opsBusAreaMapper, times(1)).insert(argThat((OpsBusAreaDO area) ->
"A栋".equals(area.getAreaName()) &&
"10/1".equals(area.getParentPath()) // PBT: parent_path 不变性
));
}
@Test
void testCreateArea_DuplicateCode_ThrowsException() {
// Given: 区域编码已存在
OpsBusAreaCreateReqVO createReq = new OpsBusAreaCreateReqVO();
createReq.setAreaName("A园区");
createReq.setAreaCode("PARK_A");
when(opsBusAreaMapper.selectByAreaCode("PARK_A")).thenReturn(testArea);
// When & Then
ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.createArea(createReq));
assertEquals(ErrorCodeConstants.AREA_CODE_EXISTS.getCode(), exception.getCode());
}
@Test
void testCreateArea_ParentNotFound_ThrowsException() {
// Given: 父级不存在
OpsBusAreaCreateReqVO createReq = new OpsBusAreaCreateReqVO();
createReq.setAreaName("A栋");
createReq.setParentId(999L);
when(opsBusAreaMapper.selectById(999L)).thenReturn(null);
// When & Then
ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.createArea(createReq));
assertEquals(ErrorCodeConstants.AREA_NOT_FOUND.getCode(), exception.getCode());
}
@Test
void testUpdateArea_Success() {
// Given
OpsBusAreaUpdateReqVO updateReq = new OpsBusAreaUpdateReqVO();
updateReq.setId(1L);
updateReq.setAreaName("A园区更新");
when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea);
when(opsBusAreaMapper.updateById(any(OpsBusAreaDO.class))).thenReturn(1);
// When
assertDoesNotThrow(() -> opsBusAreaService.updateArea(updateReq));
// Then
verify(opsBusAreaMapper, times(1)).updateById(any(OpsBusAreaDO.class));
}
@Test
void testUpdateArea_ParentLoop_Self_ThrowsException() {
// Given: 将父级设置为自己
OpsBusAreaUpdateReqVO updateReq = new OpsBusAreaUpdateReqVO();
updateReq.setId(1L);
updateReq.setParentId(1L);
when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea);
// When & Then
ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.updateArea(updateReq));
assertEquals(ErrorCodeConstants.AREA_PARENT_LOOP.getCode(), exception.getCode());
}
@Test
void testDeleteArea_Success() {
// Given
when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea);
when(opsBusAreaMapper.selectCountByParentId(1L)).thenReturn(0L);
when(opsAreaDeviceRelationMapper.selectCountByAreaId(1L)).thenReturn(0L);
when(opsBusAreaMapper.deleteById(1L)).thenReturn(1);
// When
Boolean result = opsBusAreaService.deleteArea(1L);
// Then
assertTrue(result);
verify(opsBusAreaMapper, times(1)).deleteById(1L);
}
@Test
void testDeleteArea_HasChildren_ThrowsException() {
// Given: 有子区域
when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea);
when(opsBusAreaMapper.selectCountByParentId(1L)).thenReturn(2L);
// When & Then
ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.deleteArea(1L));
assertEquals(ErrorCodeConstants.AREA_HAS_CHILDREN.getCode(), exception.getCode());
}
@Test
void testDeleteArea_HasDevices_ThrowsException() {
// Given: 有关联设备
when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea);
when(opsBusAreaMapper.selectCountByParentId(1L)).thenReturn(0L);
when(opsAreaDeviceRelationMapper.selectCountByAreaId(1L)).thenReturn(3L);
// When & Then
ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.deleteArea(1L));
assertEquals(ErrorCodeConstants.AREA_HAS_DEVICES.getCode(), exception.getCode());
}
@Test
void testDeleteArea_Idempotency() {
// Given: PBT - 幂等性,删除不存在的区域返回 false
when(opsBusAreaMapper.selectById(1L)).thenReturn(null);
// When
Boolean result = opsBusAreaService.deleteArea(1L);
// Then
assertFalse(result); // 第一次就返回 false
verify(opsBusAreaMapper, never()).deleteById(anyLong());
}
}

View File

@@ -0,0 +1,76 @@
package com.viewsh.module.ops.controller.admin.area;
import com.viewsh.framework.apilog.core.annotation.ApiAccessLog;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO;
import com.viewsh.module.ops.service.area.AreaDeviceRelationService;
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.*;
import java.util.List;
import static com.viewsh.framework.apilog.core.enums.OperateTypeEnum.*;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* 管理后台 - 区域设备关联管理
*
* @author lzh
*/
@Tag(name = "管理后台 - 区域设备关联")
@Slf4j
@RestController
@RequestMapping("/admin-api/ops/area/device-relation")
@Validated
public class AreaDeviceRelationController {
@Resource
private AreaDeviceRelationService areaDeviceRelationService;
@GetMapping("/list")
@Operation(summary = "查询区域已绑定设备列表")
@Parameter(name = "areaId", description = "区域ID", required = true)
// @PreAuthorize("@ss.hasPermission('ops:area:query')")
public CommonResult<List<AreaDeviceRelationRespVO>> listByAreaId(@RequestParam("areaId") Long areaId) {
List<AreaDeviceRelationRespVO> list = areaDeviceRelationService.listByAreaId(areaId);
return success(list);
}
@PostMapping("/bind")
@Operation(summary = "绑定设备到区域")
// @PreAuthorize("@ss.hasPermission('ops:area:bind-device')")
@ApiAccessLog(operateType = CREATE)
public CommonResult<Long> bindDevice(@Valid @RequestBody AreaDeviceBindReqVO bindReq) {
Long relationId = areaDeviceRelationService.bindDevice(bindReq);
return success(relationId);
}
@PutMapping("/update")
@Operation(summary = "更新设备关联配置")
// @PreAuthorize("@ss.hasPermission('ops:area:config-device')")
@ApiAccessLog(operateType = UPDATE)
public CommonResult<Boolean> updateRelation(@Valid @RequestBody AreaDeviceUpdateReqVO updateReq) {
areaDeviceRelationService.updateRelation(updateReq);
return success(true);
}
@DeleteMapping("/remove")
@Operation(summary = "解除设备绑定")
@Parameter(name = "id", description = "关联ID", required = true)
// @PreAuthorize("@ss.hasPermission('ops:area:bind-device')")
@ApiAccessLog(operateType = DELETE)
public CommonResult<Boolean> unbindDevice(@RequestParam("id") Long id) {
Boolean result = areaDeviceRelationService.unbindDevice(id);
return success(result);
}
}

View File

@@ -0,0 +1,85 @@
package com.viewsh.module.ops.controller.admin.area;
import com.viewsh.framework.apilog.core.annotation.ApiAccessLog;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaCreateReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaUpdateReqVO;
import com.viewsh.module.ops.service.area.OpsBusAreaService;
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.*;
import java.util.List;
import static com.viewsh.framework.apilog.core.enums.OperateTypeEnum.*;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* 管理后台 - 业务区域管理
*
* @author lzh
*/
@Tag(name = "管理后台 - 业务区域")
@Slf4j
@RestController
@RequestMapping("/admin-api/ops/area")
@Validated
public class OpsBusAreaController {
@Resource
private OpsBusAreaService opsBusAreaService;
@GetMapping("/tree")
@Operation(summary = "获取区域树(平铺列表,由前端自行组装)")
// @PreAuthorize("@ss.hasPermission('ops:area:query')")
public CommonResult<List<OpsBusAreaRespVO>> getAreaTree(OpsBusAreaPageReqVO reqVO) {
List<OpsBusAreaRespVO> list = opsBusAreaService.getAreaTree(reqVO);
return success(list);
}
@GetMapping("/get")
@Operation(summary = "获取区域详情")
@Parameter(name = "id", description = "区域ID", required = true)
// @PreAuthorize("@ss.hasPermission('ops:area:query')")
public CommonResult<OpsBusAreaRespVO> getArea(@RequestParam("id") Long id) {
OpsBusAreaRespVO area = opsBusAreaService.getArea(id);
return success(area);
}
@PostMapping("/create")
@Operation(summary = "新增区域")
// @PreAuthorize("@ss.hasPermission('ops:area:create')")
@ApiAccessLog(operateType = CREATE)
public CommonResult<Long> createArea(@Valid @RequestBody OpsBusAreaCreateReqVO createReq) {
Long areaId = opsBusAreaService.createArea(createReq);
return success(areaId);
}
@PutMapping("/update")
@Operation(summary = "更新区域")
// @PreAuthorize("@ss.hasPermission('ops:area:update')")
@ApiAccessLog(operateType = UPDATE)
public CommonResult<Boolean> updateArea(@Valid @RequestBody OpsBusAreaUpdateReqVO updateReq) {
opsBusAreaService.updateArea(updateReq);
return success(true);
}
@DeleteMapping("/delete")
@Operation(summary = "删除区域")
@Parameter(name = "id", description = "区域ID", required = true)
// @PreAuthorize("@ss.hasPermission('ops:area:delete')")
@ApiAccessLog(operateType = DELETE)
public CommonResult<Boolean> deleteArea(@RequestParam("id") Long id) {
Boolean result = opsBusAreaService.deleteArea(id);
return success(result);
}
}

View File

@@ -0,0 +1,60 @@
package com.viewsh.module.ops.controller.admin.clean;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.ops.api.clean.BadgeRealtimeStatusRespDTO;
import com.viewsh.module.ops.api.clean.BadgeStatusRespDTO;
import com.viewsh.module.ops.environment.dal.dataobject.BadgeNotifyReqDTO;
import com.viewsh.module.ops.environment.service.badge.CleanBadgeService;
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.*;
import java.util.List;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* 管理后台 - 保洁工牌 Controller
*
* @author lzh
*/
@Tag(name = "管理后台 - 保洁工牌")
@Slf4j
@RestController
@RequestMapping("/ops/clean/badge")
@Validated
public class CleanBadgeController {
@Resource
private CleanBadgeService cleanBadgeService;
@GetMapping("/list")
@Operation(summary = "工牌实时状态列表")
@Parameter(name = "areaId", description = "区域ID", required = false)
@Parameter(name = "status", description = "状态筛选IDLE/BUSY/OFFLINE/PAUSED", required = false)
@PreAuthorize("@ss.hasPermission('ops:clean:badge:query')")
public CommonResult<List<BadgeStatusRespDTO>> getBadgeList(
@RequestParam(value = "areaId", required = false) Long areaId,
@RequestParam(value = "status", required = false) String status) {
List<BadgeStatusRespDTO> result = cleanBadgeService.getBadgeStatusList(areaId, status);
return success(result);
}
@GetMapping("/realtime/{badgeId}")
@Operation(summary = "工牌实时状态详情")
@Parameter(name = "badgeId", description = "工牌设备ID", required = true)
@PreAuthorize("@ss.hasPermission('ops:clean:badge:query')")
public CommonResult<BadgeRealtimeStatusRespDTO> getBadgeRealtimeStatus(
@PathVariable("badgeId") Long badgeId) {
BadgeRealtimeStatusRespDTO result = cleanBadgeService.getBadgeRealtimeStatus(badgeId);
return success(result);
}
}

View File

@@ -0,0 +1,38 @@
package com.viewsh.module.ops.controller.admin.clean;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.ops.api.clean.QuickStatsRespDTO;
import com.viewsh.module.ops.environment.service.dashboard.CleanDashboardService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* 管理后台 - 保洁仪表盘 Controller
*
* @author lzh
*/
@Tag(name = "管理后台 - 保洁仪表盘")
@Slf4j
@RestController
@RequestMapping("/ops/clean/dashboard")
@Validated
public class CleanDashboardController {
@Resource
private CleanDashboardService cleanDashboardService;
@GetMapping("/quick-stats")
@Operation(summary = "快速统计")
@PreAuthorize("@ss.hasPermission('ops:clean:dashboard:query')")
public CommonResult<QuickStatsRespDTO> getQuickStats() {
QuickStatsRespDTO result = cleanDashboardService.getQuickStats();
return success(result);
}
}

View File

@@ -0,0 +1,41 @@
package com.viewsh.module.ops.controller.admin.clean;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.ops.environment.dal.dataobject.BadgeNotifyReqDTO;
import com.viewsh.module.ops.environment.service.badge.CleanBadgeService;
import io.swagger.v3.oas.annotations.Operation;
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.*;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* 管后台 - 保洁设备 Controller
*
* @author lzh
*/
@Tag(name = "管理后台 - 保洁设备")
@Slf4j
@RestController
@RequestMapping("/ops/clean/device")
@Validated
public class CleanDeviceController {
@Resource
private CleanBadgeService cleanBadgeService;
@PostMapping("/notify")
@Operation(summary = "发送工牌通知(语音/震动)")
@PreAuthorize("@ss.hasPermission('ops:clean:device:notify')")
public CommonResult<Boolean> sendBadgeNotify(
@Valid @RequestBody BadgeNotifyReqDTO req) {
cleanBadgeService.sendBadgeNotify(req);
return success(true);
}
}

View File

@@ -0,0 +1,65 @@
package com.viewsh.module.ops.controller.admin.clean;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.ops.api.clean.OrderTimelineRespDTO;
import com.viewsh.module.ops.environment.dal.dataobject.ManualCompleteOrderReqDTO;
import com.viewsh.module.ops.environment.dal.dataobject.UpgradePriorityReqDTO;
import com.viewsh.module.ops.environment.service.cleanorder.CleanWorkOrderService;
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.*;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* 管理后台 - 保洁工单 Controller
*
* @author lzh
*/
@Tag(name = "管理后台 - 保洁工单")
@Slf4j
@RestController
@RequestMapping("/ops/clean/order")
@Validated
public class CleanWorkOrderController {
@Resource
private CleanWorkOrderService cleanWorkOrderService;
@GetMapping("/timeline/{orderId}")
@Operation(summary = "工单时间轴")
@Parameter(name = "orderId", description = "工单ID", required = true)
@PreAuthorize("@ss.hasPermission('ops:clean:order:query')")
public CommonResult<OrderTimelineRespDTO> getOrderTimeline(
@PathVariable("orderId") Long orderId) {
OrderTimelineRespDTO result = cleanWorkOrderService.getOrderTimeline(orderId);
return success(result);
}
@PostMapping("/manual-complete")
@Operation(summary = "手动完成工单")
@PreAuthorize("@ss.hasPermission('ops:clean:order:complete')")
public CommonResult<Boolean> manualCompleteOrder(
@Valid @RequestBody ManualCompleteOrderReqDTO req) {
cleanWorkOrderService.manualCompleteOrder(req);
return success(true);
}
@PostMapping("/upgrade-priority")
@Operation(summary = "升级工单优先级")
@PreAuthorize("@ss.hasPermission('ops:clean:order:upgrade')")
public CommonResult<Boolean> upgradePriority(
@Valid @RequestBody UpgradePriorityReqDTO req) {
cleanWorkOrderService.upgradePriority(req);
return success(true);
}
}

View File

@@ -1,6 +1,7 @@
package com.viewsh.module.ops.framework.rpc.config;
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.system.api.notify.NotifyMessageSendApi;
import org.springframework.cloud.openfeign.EnableFeignClients;
@@ -10,6 +11,7 @@ import org.springframework.context.annotation.Configuration;
@EnableFeignClients(clients = {
NotifyMessageSendApi.class,
IotDeviceControlApi.class,
IotDeviceQueryApi.class,
IotDeviceStatusQueryApi.class
})
public class RpcConfiguration {

View File

@@ -0,0 +1,182 @@
package com.viewsh.module.ops.controller.admin.area;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO;
import com.viewsh.module.ops.service.area.AreaDeviceRelationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 区域设备关联管理 Controller 测试
*
* @author lzh
*/
@ExtendWith(MockitoExtension.class)
class AreaDeviceRelationControllerTest {
@Mock
private AreaDeviceRelationService areaDeviceRelationService;
@InjectMocks
private AreaDeviceRelationController controller;
private MockMvc mockMvc;
private ObjectMapper objectMapper;
private AreaDeviceRelationRespVO testRelationVO;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
objectMapper = new ObjectMapper();
testRelationVO = new AreaDeviceRelationRespVO();
testRelationVO.setId(1L);
testRelationVO.setAreaId(100L);
testRelationVO.setAreaName("A栋3层电梯厅");
testRelationVO.setDeviceId(50001L);
testRelationVO.setDeviceKey("TRAFFIC_COUNTER_001");
testRelationVO.setDeviceName("客流计数器001");
testRelationVO.setProductId(10L);
testRelationVO.setProductKey("traffic_counter_v1");
testRelationVO.setProductName("客流计数器");
testRelationVO.setRelationType("TRAFFIC_COUNTER");
testRelationVO.setConfigData(new HashMap<>());
testRelationVO.setEnabled(true);
}
@Test
void testListByAreaId_Success() throws Exception {
// Given
when(areaDeviceRelationService.listByAreaId(100L))
.thenReturn(Arrays.asList(testRelationVO));
// When & Then
mockMvc.perform(get("/admin-api/ops/area/device-relation/list")
.param("areaId", "100")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data[0].deviceId").value(50001))
.andExpect(jsonPath("$.data[0].deviceName").value("客流计数器001"));
verify(areaDeviceRelationService).listByAreaId(100L);
}
@Test
void testListByAreaId_Empty_Success() throws Exception {
// Given
when(areaDeviceRelationService.listByAreaId(999L))
.thenReturn(Arrays.asList());
// When & Then
mockMvc.perform(get("/admin-api/ops/area/device-relation/list")
.param("areaId", "999")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data").isEmpty());
}
@Test
void testBindDevice_Success() throws Exception {
// Given
AreaDeviceBindReqVO bindReq = new AreaDeviceBindReqVO();
bindReq.setAreaId(100L);
bindReq.setDeviceId(50001L);
bindReq.setRelationType("TRAFFIC_COUNTER");
Map<String, Object> config = new HashMap<>();
config.put("threshold", 100);
bindReq.setConfigData(config);
when(areaDeviceRelationService.bindDevice(any(AreaDeviceBindReqVO.class)))
.thenReturn(1L);
// When & Then
mockMvc.perform(post("/admin-api/ops/area/device-relation/bind")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(bindReq)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(1));
verify(areaDeviceRelationService).bindDevice(any(AreaDeviceBindReqVO.class));
}
@Test
void testUpdateRelation_Success() throws Exception {
// Given
AreaDeviceUpdateReqVO updateReq = new AreaDeviceUpdateReqVO();
updateReq.setId(1L);
Map<String, Object> config = new HashMap<>();
config.put("threshold", 150);
updateReq.setConfigData(config);
// When & Then
mockMvc.perform(put("/admin-api/ops/area/device-relation/update")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateReq)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(areaDeviceRelationService).updateRelation(any(AreaDeviceUpdateReqVO.class));
}
@Test
void testUnbindDevice_Success() throws Exception {
// Given
when(areaDeviceRelationService.unbindDevice(1L)).thenReturn(true);
// When & Then
mockMvc.perform(delete("/admin-api/ops/area/device-relation/remove")
.param("id", "1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(true));
verify(areaDeviceRelationService).unbindDevice(1L);
}
@Test
void testUnbindDevice_NotFound() throws Exception {
// Given
when(areaDeviceRelationService.unbindDevice(999L)).thenReturn(false);
// When & Then
mockMvc.perform(delete("/admin-api/ops/area/device-relation/remove")
.param("id", "999")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(false));
verify(areaDeviceRelationService).unbindDevice(999L);
}
}

View File

@@ -0,0 +1,186 @@
package com.viewsh.module.ops.controller.admin.area;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaCreateReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO;
import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaUpdateReqVO;
import com.viewsh.module.ops.service.area.OpsBusAreaService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.Arrays;
import java.util.Collections;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 业务区域管理 Controller 测试
*
* @author lzh
*/
@ExtendWith(MockitoExtension.class)
class OpsBusAreaControllerTest {
@Mock
private OpsBusAreaService opsBusAreaService;
@InjectMocks
private OpsBusAreaController controller;
private MockMvc mockMvc;
private ObjectMapper objectMapper;
private OpsBusAreaRespVO testAreaResp;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
objectMapper = new ObjectMapper();
testAreaResp = new OpsBusAreaRespVO();
testAreaResp.setId(100L);
testAreaResp.setParentId(null);
testAreaResp.setParentPath(null);
testAreaResp.setAreaName("A园区");
testAreaResp.setAreaCode("PARK_A");
testAreaResp.setAreaType("PARK");
testAreaResp.setIsActive(true);
testAreaResp.setSort(1);
}
@Test
void testGetAreaTree_Success() throws Exception {
// Given
when(opsBusAreaService.getAreaTree(any(OpsBusAreaPageReqVO.class)))
.thenReturn(Arrays.asList(testAreaResp));
// When & Then
mockMvc.perform(get("/admin-api/ops/area/tree")
.param("areaType", "PARK")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data[0].areaName").value("A园区"));
verify(opsBusAreaService).getAreaTree(any(OpsBusAreaPageReqVO.class));
}
@Test
void testGetAreaTree_Empty_Success() throws Exception {
// Given
when(opsBusAreaService.getAreaTree(any(OpsBusAreaPageReqVO.class)))
.thenReturn(Collections.emptyList());
// When & Then
mockMvc.perform(get("/admin-api/ops/area/tree")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data").isEmpty());
}
@Test
void testGetArea_Success() throws Exception {
// Given
when(opsBusAreaService.getArea(100L)).thenReturn(testAreaResp);
// When & Then
mockMvc.perform(get("/admin-api/ops/area/get")
.param("id", "100")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(100))
.andExpect(jsonPath("$.data.areaName").value("A园区"));
verify(opsBusAreaService).getArea(100L);
}
@Test
void testCreateArea_Success() throws Exception {
// Given
OpsBusAreaCreateReqVO createReq = new OpsBusAreaCreateReqVO();
createReq.setAreaName("A园区");
createReq.setAreaCode("PARK_A");
createReq.setAreaType("PARK");
when(opsBusAreaService.createArea(any(OpsBusAreaCreateReqVO.class)))
.thenReturn(100L);
// When & Then
mockMvc.perform(post("/admin-api/ops/area/create")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createReq)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(100));
verify(opsBusAreaService).createArea(any(OpsBusAreaCreateReqVO.class));
}
@Test
void testUpdateArea_Success() throws Exception {
// Given
OpsBusAreaUpdateReqVO updateReq = new OpsBusAreaUpdateReqVO();
updateReq.setId(100L);
updateReq.setAreaName("A园区更新");
// When & Then
mockMvc.perform(put("/admin-api/ops/area/update")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateReq)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(opsBusAreaService).updateArea(any(OpsBusAreaUpdateReqVO.class));
}
@Test
void testDeleteArea_Success() throws Exception {
// Given
when(opsBusAreaService.deleteArea(100L)).thenReturn(true);
// When & Then
mockMvc.perform(delete("/admin-api/ops/area/delete")
.param("id", "100")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(true));
verify(opsBusAreaService).deleteArea(100L);
}
@Test
void testDeleteArea_NotFound() throws Exception {
// Given
when(opsBusAreaService.deleteArea(999L)).thenReturn(false);
// When & Then
mockMvc.perform(delete("/admin-api/ops/area/delete")
.param("id", "999")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(false));
verify(opsBusAreaService).deleteArea(999L);
}
}