diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java
new file mode 100644
index 0000000..1a5025d
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java
@@ -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
+ *
+ * 提供 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> 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 getDevice(@RequestParam("id") Long id);
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java
new file mode 100644
index 0000000..a130329
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java
@@ -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
+ *
+ * 用于 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;
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java
new file mode 100644
index 0000000..fed268f
--- /dev/null
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java
@@ -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 实现类
+ *
+ * 提供 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> getDeviceSimpleList(
+ @RequestParam(value = "deviceType", required = false) Integer deviceType,
+ @RequestParam(value = "productId", required = false) Long productId) {
+ try {
+ List list = deviceService.getDeviceListByCondition(deviceType, productId);
+ List 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 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);
+ }
+ }
+
+}
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/BadgeNotifyReqDTO.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/BadgeNotifyReqDTO.java
new file mode 100644
index 0000000..eb45094
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/BadgeNotifyReqDTO.java
@@ -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
+ *
+ * 用于发送工牌设备通知(语音、震动)
+ *
+ * @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;
+}
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/ManualCompleteOrderReqDTO.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/ManualCompleteOrderReqDTO.java
new file mode 100644
index 0000000..fecca9e
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/ManualCompleteOrderReqDTO.java
@@ -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
+ *
+ * 管理员手动完成工单的兜底操作
+ *
+ * @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;
+}
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/UpgradePriorityReqDTO.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/UpgradePriorityReqDTO.java
new file mode 100644
index 0000000..7a5e628
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/UpgradePriorityReqDTO.java
@@ -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
+ *
+ * 用于将普通工单升级为 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;
+}
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeService.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeService.java
new file mode 100644
index 0000000..aa241eb
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeService.java
@@ -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;
+
+/**
+ * 保洁工牌服务接口
+ *
+ * 职责:
+ * 1. 查询工牌实时状态列表
+ * 2. 查询工牌实时状态详情
+ * 3. 发送工牌通知(语音/震动)
+ *
+ * 设计说明:
+ * - 工牌状态来源于 Redis(通过 BadgeDeviceStatusService)
+ * - 设备基本信息来源于 iot_device 表
+ * - 区域关联来源于 ops_area_device_relation 表
+ *
+ * @author lzh
+ */
+public interface CleanBadgeService {
+
+ /**
+ * 获取工牌实时状态列表
+ *
+ * 支持按区域和状态筛选
+ *
+ * @param areaId 区域ID(可选)
+ * @param status 状态筛选(可选):IDLE/BUSY/OFFLINE/PAUSED
+ * @return 工牌状态列表
+ */
+ List getBadgeStatusList(Long areaId, String status);
+
+ /**
+ * 获取工牌实时状态详情
+ *
+ * @param badgeId 工牌设备ID
+ * @return 工牌实时状态详情
+ */
+ BadgeRealtimeStatusRespDTO getBadgeRealtimeStatus(Long badgeId);
+
+ /**
+ * 发送工牌通知
+ *
+ * 支持语音和震动通知
+ *
+ * @param req 通知请求
+ */
+ void sendBadgeNotify(BadgeNotifyReqDTO req);
+
+ /**
+ * 获取工牌今日完成工单数
+ *
+ * @param deviceId 设备ID(通过设备ID查找关联的用户)
+ * @return 今日完成工单数
+ */
+ Integer getTodayCompletedCount(Long deviceId);
+
+ /**
+ * 获取工牌今日工作时长(分钟)
+ *
+ * @param deviceId 设备ID
+ * @return 今日工作时长(分钟)
+ */
+ Integer getTodayWorkMinutes(Long deviceId);
+}
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java
new file mode 100644
index 0000000..a2a6a48
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java
@@ -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;
+
+/**
+ * 保洁工牌服务实现
+ *
+ * 工牌状态数据来源: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 getBadgeStatusList(Long areaId, String status) {
+ try {
+ // 1. 获取设备ID列表
+ List deviceIds;
+ if (areaId != null) {
+ deviceIds = areaDeviceService.getDeviceIdsByAreaAndType(areaId, "BADGE");
+ } else {
+ // 不指定区域时,从所有活跃工牌获取
+ List allActiveBadges = badgeDeviceStatusService.listActiveBadges();
+ deviceIds = allActiveBadges.stream()
+ .map(BadgeDeviceStatusDTO::getDeviceId)
+ .collect(Collectors.toList());
+ }
+
+ if (deviceIds.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ // 2. 批量获取工牌状态
+ List statusList = badgeDeviceStatusService.batchGetBadgeStatus(deviceIds);
+
+ // 3. 按状态筛选(如果指定)
+ List 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();
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderService.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderService.java
new file mode 100644
index 0000000..8d32271
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderService.java
@@ -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;
+
+/**
+ * 保洁工单服务接口
+ *
+ * 职责:
+ * 1. 查询工单时间轴
+ * 2. 手动完成工单(兜底操作)
+ * 3. 升级工单优先级(P0插队)
+ *
+ * 设计说明:
+ * - 工单时间轴数据来源于 ops_order_event 表
+ * - 工单操作复用现有的 OpsOrderService
+ *
+ * @author lzh
+ */
+public interface CleanWorkOrderService {
+
+ /**
+ * 获取工单时间轴
+ *
+ * 从 ops_order_event 表查询该工单的所有事件记录
+ *
+ * @param orderId 工单ID
+ * @return 工单时间轴
+ */
+ OrderTimelineRespDTO getOrderTimeline(Long orderId);
+
+ /**
+ * 手动完成工单
+ *
+ * 管理员手动完成工单的兜底操作
+ *
+ * @param req 手动完成请求
+ */
+ void manualCompleteOrder(ManualCompleteOrderReqDTO req);
+
+ /**
+ * 升级工单优先级
+ *
+ * 将普通工单升级为 P0 紧急工单
+ *
+ * @param req 升级优先级请求
+ */
+ void upgradePriority(UpgradePriorityReqDTO req);
+}
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java
new file mode 100644
index 0000000..593eb23
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java
@@ -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 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 events = opsOrderEventMapper.selectList(
+ new LambdaQueryWrapperX()
+ .eq(OpsOrderEventDO::getOpsOrderId, orderId)
+ .orderByAsc(OpsOrderEventDO::getEventTime)
+ );
+
+ // 3. 转换为时间轴节点
+ List 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 buildExtraInfo(OpsOrderEventDO event) {
+ Map 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;
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardService.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardService.java
new file mode 100644
index 0000000..b3aa3e1
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardService.java
@@ -0,0 +1,23 @@
+package com.viewsh.module.ops.environment.service.dashboard;
+
+import com.viewsh.module.ops.api.clean.QuickStatsRespDTO;
+
+/**
+ * 保洁仪表盘服务接口
+ *
+ * 职责:
+ * 1. 提供快速统计数据
+ *
+ * @author lzh
+ */
+public interface CleanDashboardService {
+
+ /**
+ * 获取快速统计
+ *
+ * 包括:待分配数量、进行中数量、今日完成数量、在线工���数量
+ *
+ * @return 快速统计响应
+ */
+ QuickStatsRespDTO getQuickStats();
+}
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardServiceImpl.java
new file mode 100644
index 0000000..b5a6a6b
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardServiceImpl.java
@@ -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()
+ .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()
+ .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()
+ .eq(OpsOrderDO::getStatus, "COMPLETED")
+ .eq(OpsOrderDO::getOrderType, "CLEAN")
+ .between(OpsOrderDO::getEndTime, todayStart, todayEnd)
+ ));
+
+ // 4. 统计在线工牌数量(从 Redis)
+ List 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();
+ }
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java
index 0397abf..b71080c 100644
--- a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java
+++ b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java
@@ -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));
}
// ==========================================
diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java
new file mode 100644
index 0000000..ecdf804
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java
@@ -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
+ *
+ * 用于工牌详情页展示
+ *
+ * @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;
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java
new file mode 100644
index 0000000..d488452
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java
@@ -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
+ *
+ * 用于工牌列表展示
+ *
+ * @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;
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/OrderTimelineRespDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/OrderTimelineRespDTO.java
new file mode 100644
index 0000000..69a83eb
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/OrderTimelineRespDTO.java
@@ -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
+ *
+ * 用于工单详情页时间轴展示
+ *
+ * @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 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 extra;
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/QuickStatsRespDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/QuickStatsRespDTO.java
new file mode 100644
index 0000000..2f9b345
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/QuickStatsRespDTO.java
@@ -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
+ *
+ * 用于工单中心仪表盘快速统计展示
+ *
+ * @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;
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java
new file mode 100644
index 0000000..6d60cce
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java
@@ -0,0 +1,25 @@
+package com.viewsh.module.ops.enums;
+
+import com.viewsh.framework.common.exception.ErrorCode;
+
+/**
+ * ops 错误码枚举类
+ *
+ * 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, "设备关联关系不存在");
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/NotifyTypeEnum.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/NotifyTypeEnum.java
new file mode 100644
index 0000000..a83159f
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/NotifyTypeEnum.java
@@ -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;
+
+/**
+ * 工牌通知类型枚举
+ *
+ * 用于工牌设备通知(语音、震动等)
+ *
+ * @author lzh
+ */
+@AllArgsConstructor
+@Getter
+public enum NotifyTypeEnum implements ArrayValuable {
+
+ 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);
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceBindReqVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceBindReqVO.java
new file mode 100644
index 0000000..054ccd0
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceBindReqVO.java
@@ -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 configData;
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java
new file mode 100644
index 0000000..1f58fe3
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java
@@ -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 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;
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceUpdateReqVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceUpdateReqVO.java
new file mode 100644
index 0000000..b902d52
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceUpdateReqVO.java
@@ -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 configData;
+
+ @Schema(description = "是否启用", example = "true")
+ private Boolean enabled;
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaCreateReqVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaCreateReqVO.java
new file mode 100644
index 0000000..e610670
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaCreateReqVO.java
@@ -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;
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaPageReqVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaPageReqVO.java
new file mode 100644
index 0000000..4fc3b59
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaPageReqVO.java
@@ -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;
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaRespVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaRespVO.java
new file mode 100644
index 0000000..77873b7
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaRespVO.java
@@ -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 children;
+
+ @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
+ private LocalDateTime createTime;
+
+ @Schema(description = "更新时间")
+ private LocalDateTime updateTime;
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaUpdateReqVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaUpdateReqVO.java
new file mode 100644
index 0000000..b100b5c
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaUpdateReqVO.java
@@ -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;
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsAreaDeviceRelationMapper.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsAreaDeviceRelationMapper.java
index f0c6c23..02a5960 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsAreaDeviceRelationMapper.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsAreaDeviceRelationMapper.java
@@ -72,4 +72,47 @@ public interface OpsAreaDeviceRelationMapper extends BaseMapperX()
+ .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()
+ .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()
+ .eq(OpsAreaDeviceRelationDO::getAreaId, areaId)
+ .eq(OpsAreaDeviceRelationDO::getEnabled, true));
+ }
+
}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsBusAreaMapper.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsBusAreaMapper.java
index 87dcdcb..6f026c5 100644
--- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsBusAreaMapper.java
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsBusAreaMapper.java
@@ -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 {
return selectOne(OpsBusAreaDO::getAreaCode, areaCode);
}
+ /**
+ * 根据分页查询条件查询列表
+ *
+ * @param reqVO 查询条件
+ * @return 区域列表
+ */
+ default List selectListByPageReq(OpsBusAreaPageReqVO reqVO) {
+ LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX();
+ 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);
+ }
+
}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationService.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationService.java
new file mode 100644
index 0000000..e93216f
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationService.java
@@ -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 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);
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java
new file mode 100644
index 0000000..7c12bbc
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java
@@ -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 listByAreaId(Long areaId) {
+ List 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 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 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;
+ }
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaService.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaService.java
new file mode 100644
index 0000000..2e39462
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaService.java
@@ -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 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);
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceImpl.java
new file mode 100644
index 0000000..43ec57f
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceImpl.java
@@ -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 getAreaTree(OpsBusAreaPageReqVO reqVO) {
+ List 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);
+ }
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java
new file mode 100644
index 0000000..f1ce657
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java
@@ -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 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 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 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());
+ }
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceTest.java
new file mode 100644
index 0000000..42099d1
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceTest.java
@@ -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;
+
+/**
+ * 业务区域管理服务测试
+ *
+ * 测试内容:
+ * 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());
+ }
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationController.java
new file mode 100644
index 0000000..87f7711
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationController.java
@@ -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> listByAreaId(@RequestParam("areaId") Long areaId) {
+ List list = areaDeviceRelationService.listByAreaId(areaId);
+ return success(list);
+ }
+
+ @PostMapping("/bind")
+ @Operation(summary = "绑定设备到区域")
+ // @PreAuthorize("@ss.hasPermission('ops:area:bind-device')")
+ @ApiAccessLog(operateType = CREATE)
+ public CommonResult 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 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 unbindDevice(@RequestParam("id") Long id) {
+ Boolean result = areaDeviceRelationService.unbindDevice(id);
+ return success(result);
+ }
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaController.java
new file mode 100644
index 0000000..91b718b
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaController.java
@@ -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> getAreaTree(OpsBusAreaPageReqVO reqVO) {
+ List 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 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 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 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 deleteArea(@RequestParam("id") Long id) {
+ Boolean result = opsBusAreaService.deleteArea(id);
+ return success(result);
+ }
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanBadgeController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanBadgeController.java
new file mode 100644
index 0000000..f6d5a3e
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanBadgeController.java
@@ -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> getBadgeList(
+ @RequestParam(value = "areaId", required = false) Long areaId,
+ @RequestParam(value = "status", required = false) String status) {
+
+ List 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 getBadgeRealtimeStatus(
+ @PathVariable("badgeId") Long badgeId) {
+
+ BadgeRealtimeStatusRespDTO result = cleanBadgeService.getBadgeRealtimeStatus(badgeId);
+ return success(result);
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDashboardController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDashboardController.java
new file mode 100644
index 0000000..43c12be
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDashboardController.java
@@ -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 getQuickStats() {
+ QuickStatsRespDTO result = cleanDashboardService.getQuickStats();
+ return success(result);
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDeviceController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDeviceController.java
new file mode 100644
index 0000000..a92ee4d
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDeviceController.java
@@ -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 sendBadgeNotify(
+ @Valid @RequestBody BadgeNotifyReqDTO req) {
+
+ cleanBadgeService.sendBadgeNotify(req);
+ return success(true);
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanWorkOrderController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanWorkOrderController.java
new file mode 100644
index 0000000..438d53e
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanWorkOrderController.java
@@ -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 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 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 upgradePriority(
+ @Valid @RequestBody UpgradePriorityReqDTO req) {
+
+ cleanWorkOrderService.upgradePriority(req);
+ return success(true);
+ }
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java
index e2c5a92..d10bcb9 100644
--- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java
+++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java
@@ -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 {
diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationControllerTest.java b/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationControllerTest.java
new file mode 100644
index 0000000..6f01a65
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationControllerTest.java
@@ -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 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 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);
+ }
+
+}
diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaControllerTest.java b/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaControllerTest.java
new file mode 100644
index 0000000..0a68145
--- /dev/null
+++ b/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaControllerTest.java
@@ -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);
+ }
+
+}