From d53d1c4584c2d3d6c902a4fb206a636f2a6f3f65 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:32:56 +0800 Subject: [PATCH 01/17] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E4=BF=9D=E6=A8=A1=E5=9D=97=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=8E=20Mapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增安保区域人员绑定表 ops_area_security_user 和安保工单扩展表 ops_order_security_ext,以及对应的 DO 和 Mapper 接口。 Co-Authored-By: Claude Opus 4.6 --- sql/mysql/ops_area_security_user.sql | 16 +++ sql/mysql/ops_order_security_ext.sql | 41 +++++++ .../area/OpsAreaSecurityUserDO.java | 58 ++++++++++ .../workorder/OpsOrderSecurityExtDO.java | 100 ++++++++++++++++++ .../mysql/area/OpsAreaSecurityUserMapper.java | 37 +++++++ .../workorder/OpsOrderSecurityExtMapper.java | 37 +++++++ 6 files changed, 289 insertions(+) create mode 100644 sql/mysql/ops_area_security_user.sql create mode 100644 sql/mysql/ops_order_security_ext.sql create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/area/OpsAreaSecurityUserDO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/area/OpsAreaSecurityUserMapper.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/workorder/OpsOrderSecurityExtMapper.java diff --git a/sql/mysql/ops_area_security_user.sql b/sql/mysql/ops_area_security_user.sql new file mode 100644 index 0000000..eaee0ef --- /dev/null +++ b/sql/mysql/ops_area_security_user.sql @@ -0,0 +1,16 @@ +CREATE TABLE ops_area_security_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + area_id BIGINT NOT NULL COMMENT '区域ID,关联 ops_bus_area.id', + user_id BIGINT NOT NULL COMMENT '安保人员用户ID,关联 system_users.id', + user_name VARCHAR(64) DEFAULT '' COMMENT '安保人员姓名(冗余)', + team_id BIGINT DEFAULT NULL COMMENT '所属班组ID', + enabled BIT DEFAULT 1 COMMENT '是否启用', + sort INT DEFAULT 0 COMMENT '排序值', + creator VARCHAR(64) DEFAULT '', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) DEFAULT '', + update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted BIT DEFAULT 0, + tenant_id BIGINT DEFAULT 0, + UNIQUE KEY uk_area_user (area_id, user_id, deleted) +) COMMENT '区域-安保人员绑定表'; diff --git a/sql/mysql/ops_order_security_ext.sql b/sql/mysql/ops_order_security_ext.sql new file mode 100644 index 0000000..983fad0 --- /dev/null +++ b/sql/mysql/ops_order_security_ext.sql @@ -0,0 +1,41 @@ +-- ---------------------------- +-- Table structure for ops_order_security_ext +-- ---------------------------- +DROP TABLE IF EXISTS `ops_order_security_ext`; +CREATE TABLE `ops_order_security_ext` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `ops_order_id` bigint NOT NULL COMMENT '工单ID,关联 ops_order.id', + + -- 告警来源(告警工单必填,手动工单可空) + `alarm_id` varchar(64) DEFAULT NULL COMMENT '关联告警ID', + `alarm_type` varchar(50) DEFAULT NULL COMMENT '告警类型: intrusion/leave_post/fire/fence', + `camera_id` varchar(64) DEFAULT NULL COMMENT '摄像头ID', + `roi_id` varchar(64) DEFAULT NULL COMMENT 'ROI区域ID', + `image_url` varchar(512) DEFAULT NULL COMMENT '告警截图URL', + + -- 处理人(冗余快照,创建时写入) + `assigned_user_id` bigint DEFAULT NULL COMMENT '处理人user_id', + `assigned_user_name` varchar(100) DEFAULT NULL COMMENT '处理人姓名', + `assigned_team_id` bigint DEFAULT NULL COMMENT '班组ID', + + -- 处理结果(完成时提交) + `result` text DEFAULT NULL COMMENT '处理结果描述', + `result_img_urls` varchar(2048) DEFAULT NULL COMMENT '处理结果图片URL,JSON数组', + + -- 关键时间点 + `dispatched_time` datetime DEFAULT NULL COMMENT '派单时间', + `confirmed_time` datetime DEFAULT NULL COMMENT '确认时间', + `completed_time` datetime DEFAULT NULL COMMENT '完成时间', + + -- 审计字段 + `creator` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + + PRIMARY KEY (`id`) USING BTREE, + UNIQUE INDEX `uk_ops_order_id` (`ops_order_id`, `deleted`) USING BTREE, + INDEX `idx_alarm_id` (`alarm_id`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '安保工单扩展表' ROW_FORMAT = Dynamic; diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/area/OpsAreaSecurityUserDO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/area/OpsAreaSecurityUserDO.java new file mode 100644 index 0000000..ea9fa78 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/area/OpsAreaSecurityUserDO.java @@ -0,0 +1,58 @@ +package com.viewsh.module.ops.security.dal.dataobject.area; + +import com.viewsh.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 区域-安保人员绑定 DO + * + * @author lzh + */ +@TableName("ops_area_security_user") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OpsAreaSecurityUserDO extends BaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + + /** + * 区域ID,关联 ops_bus_area.id + */ + private Long areaId; + + /** + * 安保人员用户ID,关联 system_users.id + */ + private Long userId; + + /** + * 安保人员姓名(冗余) + */ + private String userName; + + /** + * 所属班组ID + */ + private Long teamId; + + /** + * 是否启用 + */ + private Boolean enabled; + + /** + * 排序值 + */ + private Integer sort; + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java new file mode 100644 index 0000000..5073164 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java @@ -0,0 +1,100 @@ +package com.viewsh.module.ops.security.dal.dataobject.workorder; + +import com.viewsh.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 安保工单扩展 DO + * + * @author lzh + */ +@TableName("ops_order_security_ext") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OpsOrderSecurityExtDO extends BaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + /** + * 工单ID + * + * 关联 {@link com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO#getId()} + */ + private Long opsOrderId; + + // ==================== 告警来源 ==================== + + /** + * 关联告警ID(告警工单必填,手动工单可空) + */ + private String alarmId; + /** + * 告警类型(intrusion=入侵/leave_post=离岗/fire=火灾/fence=越界) + */ + private String alarmType; + /** + * 摄像头ID + */ + private String cameraId; + /** + * ROI区域ID + */ + private String roiId; + /** + * 告警截图URL + */ + private String imageUrl; + + // ==================== 处理人信息(冗余快照) ==================== + + /** + * 处理人user_id + */ + private Long assignedUserId; + /** + * 处理人姓名 + */ + private String assignedUserName; + /** + * 班组ID + */ + private Long assignedTeamId; + + // ==================== 处理结果(完成时提交) ==================== + + /** + * 处理结果描述 + */ + private String result; + /** + * 处理结果图片URL,JSON数组 + */ + private String resultImgUrls; + + // ==================== 关键时间点 ==================== + + /** + * 派单时间 + */ + private LocalDateTime dispatchedTime; + /** + * 确认时间 + */ + private LocalDateTime confirmedTime; + /** + * 完成时间 + */ + private LocalDateTime completedTime; + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/area/OpsAreaSecurityUserMapper.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/area/OpsAreaSecurityUserMapper.java new file mode 100644 index 0000000..fc6ac9b --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/area/OpsAreaSecurityUserMapper.java @@ -0,0 +1,37 @@ +package com.viewsh.module.ops.security.dal.mysql.area; + +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 区域-安保人员绑定 Mapper + * + * @author lzh + */ +@Mapper +public interface OpsAreaSecurityUserMapper extends BaseMapperX { + + /** + * 查询区域内所有启用的安保人员 + */ + default List selectListByAreaId(Long areaId) { + return selectList(new LambdaQueryWrapper() + .eq(OpsAreaSecurityUserDO::getAreaId, areaId) + .eq(OpsAreaSecurityUserDO::getEnabled, true) + .orderByAsc(OpsAreaSecurityUserDO::getSort)); + } + + /** + * 根据区域ID和用户ID查询(唯一性校验用) + */ + default OpsAreaSecurityUserDO selectByAreaIdAndUserId(Long areaId, Long userId) { + return selectOne(new LambdaQueryWrapper() + .eq(OpsAreaSecurityUserDO::getAreaId, areaId) + .eq(OpsAreaSecurityUserDO::getUserId, userId)); + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/workorder/OpsOrderSecurityExtMapper.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/workorder/OpsOrderSecurityExtMapper.java new file mode 100644 index 0000000..8c934a3 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/mysql/workorder/OpsOrderSecurityExtMapper.java @@ -0,0 +1,37 @@ +package com.viewsh.module.ops.security.dal.mysql.workorder; + +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * 安保工单扩展 Mapper + * + * @author lzh + */ +@Mapper +public interface OpsOrderSecurityExtMapper extends BaseMapperX { + + /** + * 根据工单ID查询扩展信息 + */ + default OpsOrderSecurityExtDO selectByOpsOrderId(Long opsOrderId) { + return selectOne(OpsOrderSecurityExtDO::getOpsOrderId, opsOrderId); + } + + /** + * 插入或选择性更新扩展信息 + *

+ * 已存在时按 ID 更新,不存在时插入 + */ + default int insertOrUpdateSelective(OpsOrderSecurityExtDO entity) { + OpsOrderSecurityExtDO existing = selectByOpsOrderId(entity.getOpsOrderId()); + if (existing == null) { + return insert(entity); + } else { + entity.setId(existing.getId()); + return updateById(entity); + } + } + +} -- 2.49.1 From 784c2ed387d4465182db23568baff6abf5a7b598 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:33:09 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E4=BF=9D=E5=B7=A5=E5=8D=95=E6=A0=B8=E5=BF=83=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E4=B8=8E=E6=B4=BE=E5=8D=95=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含安保工单 CRUD(创建/确认/完单)、区域人员绑定服务、 区域分配策略 SecurityAreaAssignStrategy、调度策略 SecurityScheduleStrategy,以及安保扩展查询处理器。 Co-Authored-By: Claude Opus 4.6 --- .../area/OpsAreaSecurityUserService.java | 51 +++++ .../area/OpsAreaSecurityUserServiceImpl.java | 94 ++++++++ .../dispatch/SecurityAreaAssignStrategy.java | 76 +++++++ .../dispatch/SecurityScheduleStrategy.java | 59 +++++ .../SecurityOrderCompleteReqDTO.java | 37 ++++ .../SecurityOrderCreateReqDTO.java | 47 ++++ .../SecurityOrderExtQueryHandler.java | 102 +++++++++ .../securityorder/SecurityOrderService.java | 55 +++++ .../SecurityOrderServiceImpl.java | 209 ++++++++++++++++++ 9 files changed, 730 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserService.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceImpl.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategy.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityScheduleStrategy.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserService.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserService.java new file mode 100644 index 0000000..39eb00e --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserService.java @@ -0,0 +1,51 @@ +package com.viewsh.module.ops.security.service.area; + +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; + +import java.util.List; + +/** + * 区域-安保人员绑定服务 + * + * @author lzh + */ +public interface OpsAreaSecurityUserService { + + /** + * 查询区域绑定的启用安保人员 + * + * @param areaId 区域ID + * @return 安保人员列表 + */ + List listByAreaId(Long areaId); + + /** + * 绑定安保人员到区域 + * + * @param areaId 区域ID + * @param userId 用户ID + * @param userName 用户姓名 + * @param teamId 班组ID + * @param sort 排序值 + * @return 绑定记录ID + */ + Long bindUser(Long areaId, Long userId, String userName, Long teamId, Integer sort); + + /** + * 更新绑定信息 + * + * @param id 绑定记录ID + * @param enabled 是否启用 + * @param sort 排序值 + * @param teamId 班组ID + */ + void updateBinding(Long id, Boolean enabled, Integer sort, Long teamId); + + /** + * 解除绑定 + * + * @param id 绑定记录ID + */ + void unbindUser(Long id); + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceImpl.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceImpl.java new file mode 100644 index 0000000..137bb44 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceImpl.java @@ -0,0 +1,94 @@ +package com.viewsh.module.ops.security.service.area; + +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.viewsh.module.ops.security.dal.mysql.area.OpsAreaSecurityUserMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.ops.enums.ErrorCodeConstants.*; + +/** + * 区域-安保人员绑定服务实现 + * + * @author lzh + */ +@Slf4j +@Service +public class OpsAreaSecurityUserServiceImpl implements OpsAreaSecurityUserService { + + @Resource + private OpsAreaSecurityUserMapper areaSecurityUserMapper; + + @Override + public List listByAreaId(Long areaId) { + return areaSecurityUserMapper.selectListByAreaId(areaId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long bindUser(Long areaId, Long userId, String userName, Long teamId, Integer sort) { + // 唯一性校验 + OpsAreaSecurityUserDO existing = areaSecurityUserMapper.selectByAreaIdAndUserId(areaId, userId); + if (existing != null) { + throw exception(SECURITY_AREA_USER_DUPLICATE); + } + + OpsAreaSecurityUserDO record = OpsAreaSecurityUserDO.builder() + .areaId(areaId) + .userId(userId) + .userName(userName) + .teamId(teamId) + .enabled(true) + .sort(sort != null ? sort : 0) + .build(); + try { + areaSecurityUserMapper.insert(record); + } catch (DuplicateKeyException e) { + throw exception(SECURITY_AREA_USER_DUPLICATE); + } + + log.info("绑定安保人员到区域: areaId={}, userId={}, userName={}", areaId, userId, userName); + return record.getId(); + } + + @Override + public void updateBinding(Long id, Boolean enabled, Integer sort, Long teamId) { + OpsAreaSecurityUserDO existing = areaSecurityUserMapper.selectById(id); + if (existing == null) { + throw exception(SECURITY_AREA_USER_NOT_FOUND); + } + + OpsAreaSecurityUserDO update = new OpsAreaSecurityUserDO(); + update.setId(id); + if (enabled != null) { + update.setEnabled(enabled); + } + if (sort != null) { + update.setSort(sort); + } + if (teamId != null) { + update.setTeamId(teamId); + } + areaSecurityUserMapper.updateById(update); + + log.info("更新安保人员绑定: id={}, enabled={}, sort={}", id, enabled, sort); + } + + @Override + public void unbindUser(Long id) { + OpsAreaSecurityUserDO existing = areaSecurityUserMapper.selectById(id); + if (existing == null) { + throw exception(SECURITY_AREA_USER_NOT_FOUND); + } + + areaSecurityUserMapper.deleteById(id); + log.info("解除安保人员绑定: id={}, areaId={}, userId={}", id, existing.getAreaId(), existing.getUserId()); + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategy.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategy.java new file mode 100644 index 0000000..f007585 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategy.java @@ -0,0 +1,76 @@ +package com.viewsh.module.ops.security.service.dispatch; + +import cn.hutool.core.collection.CollUtil; +import com.viewsh.module.ops.core.dispatch.DispatchEngine; +import com.viewsh.module.ops.core.dispatch.model.AssigneeRecommendation; +import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; +import com.viewsh.module.ops.core.dispatch.strategy.AssignStrategy; +import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.viewsh.module.ops.security.dal.mysql.area.OpsAreaSecurityUserMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 安保工单区域分配策略 + *

+ * 根据区域绑定的安保人员随机分配。 + * + * @author lzh + */ +@Slf4j +@Component +public class SecurityAreaAssignStrategy implements AssignStrategy { + + private static final String STRATEGY_NAME = "security_area_user"; + private static final String BUSINESS_TYPE = WorkOrderTypeEnum.SECURITY.getType(); + + @Resource + private DispatchEngine dispatchEngine; + + @Resource + private OpsAreaSecurityUserMapper areaSecurityUserMapper; + + @PostConstruct + public void init() { + dispatchEngine.registerAssignStrategy(BUSINESS_TYPE, this); + log.info("注册安保分配策略: {}", STRATEGY_NAME); + } + + @Override + public String getName() { + return STRATEGY_NAME; + } + + @Override + public String getSupportedBusinessType() { + return BUSINESS_TYPE; + } + + @Override + public AssigneeRecommendation recommend(OrderDispatchContext context) { + Long areaId = context.getAreaId(); + if (areaId == null) { + log.warn("安保派单缺少区域ID: orderId={}", context.getOrderId()); + return AssigneeRecommendation.none(); + } + + List users = areaSecurityUserMapper.selectListByAreaId(areaId); + if (CollUtil.isEmpty(users)) { + log.info("区域 {} 无绑定安保人员,工单 {} 等待手动分配", areaId, context.getOrderId()); + return AssigneeRecommendation.none(); + } + + // 选择 sort 值最小的人员(sort 越小优先级越高,由 Mapper 已按 sort ASC 排序) + OpsAreaSecurityUserDO chosen = users.get(0); + AssigneeRecommendation recommendation = AssigneeRecommendation.of( + chosen.getUserId(), chosen.getUserName(), 50, "区域排序优先分配"); + recommendation.setAreaId(areaId); + return recommendation; + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityScheduleStrategy.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityScheduleStrategy.java new file mode 100644 index 0000000..99a0f56 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/dispatch/SecurityScheduleStrategy.java @@ -0,0 +1,59 @@ +package com.viewsh.module.ops.security.service.dispatch; + +import com.viewsh.module.ops.core.dispatch.DispatchEngine; +import com.viewsh.module.ops.core.dispatch.model.DispatchDecision; +import com.viewsh.module.ops.core.dispatch.model.DispatchPath; +import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; +import com.viewsh.module.ops.core.dispatch.strategy.ScheduleStrategy; +import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 安保工单调度策略 + *

+ * 安保工单调度相对简单: + * - 有可用人员 → 直接派单 + * - 人员忙碌 → 入队等待 + * + * @author lzh + */ +@Slf4j +@Component +public class SecurityScheduleStrategy implements ScheduleStrategy { + + private static final String STRATEGY_NAME = "security_schedule"; + private static final String BUSINESS_TYPE = WorkOrderTypeEnum.SECURITY.getType(); + + @Resource + private DispatchEngine dispatchEngine; + + @PostConstruct + public void init() { + dispatchEngine.registerScheduleStrategy(BUSINESS_TYPE, this); + log.info("注册安保调度策略: {}", STRATEGY_NAME); + } + + @Override + public String getName() { + return STRATEGY_NAME; + } + + @Override + public String getSupportedBusinessType() { + return BUSINESS_TYPE; + } + + @Override + public DispatchDecision decide(OrderDispatchContext context) { + // 安保工单默认直接派单 + // DispatchEngine 会根据执行人状态自动选择路径 + return DispatchDecision.builder() + .path(DispatchPath.DIRECT_DISPATCH) + .reason("安保工单直接派单") + .build(); + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java new file mode 100644 index 0000000..ba1a094 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java @@ -0,0 +1,37 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 安保工单人工完单请求 DTO(Service 层内部使用) + * + * @author lzh + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SecurityOrderCompleteReqDTO { + + @NotNull(message = "工单ID不能为空") + private Long orderId; + + @NotBlank(message = "处理结果不能为空") + private String result; + + private List resultImgUrls; + + /** + * 操作人ID(由 Controller 层填充) + */ + @NotNull(message = "操作人ID不能为空") + private Long operatorId; + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java new file mode 100644 index 0000000..56d90ed --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java @@ -0,0 +1,47 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 安保工单创建请求 DTO(Service 层内部使用) + * + * @author lzh + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SecurityOrderCreateReqDTO { + + @NotBlank(message = "工单标题不能为空") + private String title; + + private String description; + + private Integer priority; + + @NotNull(message = "区域ID不能为空") + private Long areaId; + + private String location; + + // ==================== 告警来源 ==================== + + private String alarmId; + + private String alarmType; + + private String cameraId; + + private String roiId; + + private String imageUrl; + + private String sourceType; + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java new file mode 100644 index 0000000..84137af --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandler.java @@ -0,0 +1,102 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import com.viewsh.module.ops.service.OrderDetailVO; +import com.viewsh.module.ops.service.OrderExtQueryHandler; +import com.viewsh.module.ops.service.OrderSummaryVO; +import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 安保工单扩展查询处理器 + *

+ * 实现 OrderExtQueryHandler 接口,为工单中心查询提供安保扩展信息加载能力 + * + * @author lzh + */ +@Slf4j +@Component +public class SecurityOrderExtQueryHandler implements OrderExtQueryHandler { + + @Resource + private OpsOrderSecurityExtMapper securityExtMapper; + + private static final String ORDER_TYPE_SECURITY = WorkOrderTypeEnum.SECURITY.getType(); + + @Override + public boolean supports(String orderType) { + return ORDER_TYPE_SECURITY.equals(orderType); + } + + @Override + public void enrichWithExtInfo(OrderSummaryVO vo, Long orderId) { + OpsOrderSecurityExtDO securityExt = securityExtMapper.selectByOpsOrderId(orderId); + if (securityExt != null) { + vo.setExtInfo(buildExtInfoMap(securityExt)); + } + } + + @Override + public OrderDetailVO buildDetailVO(OpsOrderDO order) { + OpsOrderSecurityExtDO securityExt = securityExtMapper.selectByOpsOrderId(order.getId()); + + OrderDetailVO vo = OrderDetailVO.builder() + .id(order.getId()) + .orderCode(order.getOrderCode()) + .orderType(order.getOrderType()) + .sourceType(order.getSourceType()) + .title(order.getTitle()) + .description(order.getDescription()) + .priority(order.getPriority()) + .status(order.getStatus()) + .areaId(order.getAreaId()) + .location(order.getLocation()) + .urgentReason(order.getUrgentReason()) + .assigneeId(order.getAssigneeId()) + .assigneeName(order.getAssigneeName()) + .inspectorId(order.getInspectorId()) + .startTime(order.getStartTime()) + .endTime(order.getEndTime()) + .qualityScore(order.getQualityScore()) + .qualityComment(order.getQualityComment()) + .responseSeconds(order.getResponseSeconds()) + .completionSeconds(order.getCompletionSeconds()) + .createTime(order.getCreateTime()) + .updateTime(order.getUpdateTime()) + .build(); + + if (securityExt != null) { + vo.setExtInfo(buildExtInfoMap(securityExt)); + } + + return vo; + } + + /** + * 构建安保工单扩展信息 Map(列表/详情统一使用) + */ + private Map buildExtInfoMap(OpsOrderSecurityExtDO ext) { + Map extInfo = new LinkedHashMap<>(16); + extInfo.put("alarmId", ext.getAlarmId()); + extInfo.put("alarmType", ext.getAlarmType()); + extInfo.put("cameraId", ext.getCameraId()); + extInfo.put("roiId", ext.getRoiId()); + extInfo.put("imageUrl", ext.getImageUrl()); + extInfo.put("assignedUserId", ext.getAssignedUserId()); + extInfo.put("assignedUserName", ext.getAssignedUserName()); + extInfo.put("assignedTeamId", ext.getAssignedTeamId()); + extInfo.put("result", ext.getResult()); + extInfo.put("resultImgUrls", ext.getResultImgUrls()); + extInfo.put("dispatchedTime", ext.getDispatchedTime()); + extInfo.put("confirmedTime", ext.getConfirmedTime()); + extInfo.put("completedTime", ext.getCompletedTime()); + return extInfo; + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java new file mode 100644 index 0000000..245d602 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java @@ -0,0 +1,55 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; + +/** + * 安保工单服务接口 + *

+ * 提供安保工单的创建、确认、自动完单、人工完单等能力 + * + * @author lzh + */ +public interface SecurityOrderService { + + /** + * 创建安保工单(对外接口,由外部告警系统调用) + *

+ * 流程:创建主表 + 扩展表 → 自动派单 + * + * @param createReq 创建请求 + * @return 工单ID + */ + Long createSecurityOrder(SecurityOrderCreateReqDTO createReq); + + /** + * 确认工单(安保人员确认接单) + * + * @param orderId 工单ID + * @param userId 安保人员user_id + */ + void confirmOrder(Long orderId, Long userId); + + /** + * 自动完单(对方系统调用,无需提交结果) + * + * @param orderId 工单ID + * @param remark 备注 + */ + void autoCompleteOrder(Long orderId, String remark); + + /** + * 人工完单(安保人员提交处理结果) + * + * @param req 完单请求(包含 result + resultImgUrls) + */ + void manualCompleteOrder(SecurityOrderCompleteReqDTO req); + + /** + * 根据工单ID查询安保扩展信息 + * + * @param opsOrderId 工单ID + * @return 扩展信息 + */ + OpsOrderSecurityExtDO getSecurityExt(Long opsOrderId); + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java new file mode 100644 index 0000000..206a44c --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java @@ -0,0 +1,209 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import cn.hutool.core.util.StrUtil; +import com.viewsh.module.ops.core.event.OrderEventPublisher; +import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; +import com.viewsh.module.ops.enums.OperatorTypeEnum; +import com.viewsh.module.ops.enums.PriorityEnum; +import com.viewsh.module.ops.enums.SourceTypeEnum; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import com.viewsh.module.ops.infrastructure.code.OrderCodeGenerator; +import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; +import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; +import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; +// 注意:confirm/complete 的业务日志由 SecurityOrderEventListener 统一记录 +// 本类仅记录 CREATE 日志(创建不经过状态变更事件) +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.ops.enums.ErrorCodeConstants.*; +import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import com.viewsh.module.ops.service.fsm.OrderStateMachine; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 安保工单服务实现 + * + * @author lzh + */ +@Slf4j +@Service +public class SecurityOrderServiceImpl implements SecurityOrderService { + + @Resource + private OpsOrderMapper opsOrderMapper; + + @Resource + private OpsOrderSecurityExtMapper securityExtMapper; + + @Resource + private OrderIdGenerator orderIdGenerator; + + @Resource + private OrderCodeGenerator orderCodeGenerator; + + @Resource + private OrderEventPublisher orderEventPublisher; + + @Resource + private OpsBusAreaMapper opsBusAreaMapper; + + @Resource + private OrderStateMachine orderStateMachine; + + @Resource + private EventLogRecorder eventLogRecorder; + + private static final String BUSINESS_TYPE = WorkOrderTypeEnum.SECURITY.getType(); + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSecurityOrder(SecurityOrderCreateReqDTO createReq) { + // 0. 校验区域是否存在 + if (opsBusAreaMapper.selectById(createReq.getAreaId()) == null) { + throw exception(AREA_NOT_FOUND); + } + + // 1. 生成ID和编号 + Long orderId = orderIdGenerator.generate(); + String orderCode = orderCodeGenerator.generate(BUSINESS_TYPE); + + // 2. 确定来源类型 + String sourceType = StrUtil.isNotBlank(createReq.getSourceType()) + ? createReq.getSourceType() + : (StrUtil.isNotBlank(createReq.getAlarmId()) ? SourceTypeEnum.ALARM.getType() : SourceTypeEnum.MANUAL.getType()); + + // 3. 构建主表记录 + OpsOrderDO order = OpsOrderDO.builder() + .id(orderId) + .orderCode(orderCode) + .orderType(BUSINESS_TYPE) + .sourceType(sourceType) + .title(createReq.getTitle()) + .description(createReq.getDescription()) + .priority(createReq.getPriority() != null ? createReq.getPriority() : PriorityEnum.P2.getPriority()) + .status(WorkOrderStatusEnum.PENDING.getStatus()) + .areaId(createReq.getAreaId()) + .location(createReq.getLocation()) + .build(); + opsOrderMapper.insert(order); + + // 4. 构建扩展表记录 + OpsOrderSecurityExtDO securityExt = OpsOrderSecurityExtDO.builder() + .opsOrderId(orderId) + .alarmId(createReq.getAlarmId()) + .alarmType(createReq.getAlarmType()) + .cameraId(createReq.getCameraId()) + .roiId(createReq.getRoiId()) + .imageUrl(createReq.getImageUrl()) + .build(); + securityExtMapper.insert(securityExt); + + // 5. 发布工单创建事件(触发自动派单) + OrderCreatedEvent event = OrderCreatedEvent.builder() + .orderId(orderId) + .orderType(BUSINESS_TYPE) + .orderCode(orderCode) + .title(createReq.getTitle()) + .areaId(createReq.getAreaId()) + .priority(order.getPriority()) + .createTime(order.getCreateTime()) + .build(); + orderEventPublisher.publishOrderCreated(event); + + // 6. 记录业务日志 + eventLogRecorder.record(EventLogRecord.builder() + .module(LogModule.SECURITY) + .domain(EventDomain.DISPATCH) + .eventType(LogType.ORDER_CREATED.getCode()) + .message("安保工单创建") + .targetId(orderId) + .targetType("order") + .build()); + + log.info("创建安保工单成功: orderId={}, orderCode={}, alarmId={}, areaId={}", + orderId, orderCode, createReq.getAlarmId(), createReq.getAreaId()); + + return orderId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void confirmOrder(Long orderId, Long userId) { + OpsOrderDO order = getOrderOrThrow(orderId); + validateOrderType(order); + + // 状态转换:DISPATCHED → CONFIRMED(扩展表时间 + 业务日志由 EventListener 统一记录) + orderStateMachine.transition(order, WorkOrderStatusEnum.CONFIRMED, + OperatorTypeEnum.SECURITY_GUARD, userId, "安保人员确认接单"); + + log.info("安保工单确认: orderId={}, userId={}", orderId, userId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void autoCompleteOrder(Long orderId, String remark) { + OpsOrderDO order = getOrderOrThrow(orderId); + validateOrderType(order); + + // 状态转换 → COMPLETED(扩展表时间 + 业务日志由 EventListener 统一记录,主表 endTime 由状态机统一设置) + orderStateMachine.transition(order, WorkOrderStatusEnum.COMPLETED, + OperatorTypeEnum.SYSTEM, null, + StrUtil.isNotBlank(remark) ? remark : "系统自动完单"); + + log.info("安保工单自动完单: orderId={}", orderId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void manualCompleteOrder(SecurityOrderCompleteReqDTO req) { + OpsOrderDO order = getOrderOrThrow(req.getOrderId()); + validateOrderType(order); + + // 状态转换 → COMPLETED(扩展表 completedTime + 业务日志由 EventListener 统一记录,主表 endTime 由状态机统一设置) + orderStateMachine.transition(order, WorkOrderStatusEnum.COMPLETED, + OperatorTypeEnum.SECURITY_GUARD, req.getOperatorId(), "安保人员提交处理结果"); + + // 更新扩展表:结果 + 图片 + OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); + extUpdate.setOpsOrderId(req.getOrderId()); + extUpdate.setResult(req.getResult()); + if (req.getResultImgUrls() != null && !req.getResultImgUrls().isEmpty()) { + extUpdate.setResultImgUrls(cn.hutool.json.JSONUtil.toJsonStr(req.getResultImgUrls())); + } + securityExtMapper.insertOrUpdateSelective(extUpdate); + + log.info("安保工单人工完单: orderId={}", req.getOrderId()); + } + + @Override + public OpsOrderSecurityExtDO getSecurityExt(Long opsOrderId) { + return securityExtMapper.selectByOpsOrderId(opsOrderId); + } + + // ==================== 内部方法 ==================== + + private OpsOrderDO getOrderOrThrow(Long orderId) { + OpsOrderDO order = opsOrderMapper.selectById(orderId); + if (order == null) { + throw exception(SECURITY_ORDER_NOT_FOUND); + } + return order; + } + + private void validateOrderType(OpsOrderDO order) { + if (!BUSINESS_TYPE.equals(order.getOrderType())) { + throw exception(SECURITY_ORDER_TYPE_MISMATCH); + } + } + +} -- 2.49.1 From 4d36bf5b1cd13173bbda5b8cf53f22668e20448d Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:33:18 +0800 Subject: [PATCH 03/17] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E4=BF=9D=E5=B7=A5=E5=8D=95=E4=BA=8B=E4=BB=B6=E7=9B=91=E5=90=AC?= =?UTF-8?q?=E5=99=A8=EF=BC=8C=E8=A6=86=E7=9B=96=E5=85=A8=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 监听工单创建后自动派单、状态变更记录扩展表时间点(派发/确认/ 完成),统一记录业务日志,区分系统自动完单与人工完单。 Co-Authored-By: Claude Opus 4.6 --- .../listener/SecurityOrderEventListener.java | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java new file mode 100644 index 0000000..602adc5 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListener.java @@ -0,0 +1,256 @@ +package com.viewsh.module.ops.security.integration.listener; + +import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.core.event.OrderStateChangedEvent; +import com.viewsh.module.ops.core.event.OrderCompletedEvent; +import com.viewsh.module.ops.core.dispatch.DispatchEngine; +import com.viewsh.module.ops.core.dispatch.model.DispatchResult; +import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; +import com.viewsh.module.ops.enums.OperatorTypeEnum; +import com.viewsh.module.ops.enums.PriorityEnum; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; +import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; +import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDateTime; + +/** + * 安保工单事件监听器 + *

+ * 监听工单生命周期事件,处理安保业务特有逻辑: + * - 工单创建后触发自动派单 + * - 状态变更时记录扩展表时间点 + * - 统一记录业务日志(所有状态变更) + * + * @author lzh + */ +@Slf4j +@Component +public class SecurityOrderEventListener { + + private static final String ORDER_TYPE_SECURITY = WorkOrderTypeEnum.SECURITY.getType(); + + @Resource + private OpsOrderSecurityExtMapper securityExtMapper; + + @Resource + private DispatchEngine dispatchEngine; + + @Resource + private EventLogRecorder eventLogRecorder; + + // ==================== 工单创建事件 ==================== + + /** + * 工单创建事件 - 异步触发自动派单 + *

+ * {@code @Async} + {@code @TransactionalEventListener(AFTER_COMMIT)} 组合: + * Spring 先等事务提交,再在异步线程池中执行本方法。 + */ + @Async("ops-task-executor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onOrderCreated(OrderCreatedEvent event) { + if (!ORDER_TYPE_SECURITY.equals(event.getOrderType())) { + return; + } + log.info("安保工单创建事件: orderId={}, orderCode={}", event.getOrderId(), event.getOrderCode()); + + try { + OrderDispatchContext context = OrderDispatchContext.builder() + .orderId(event.getOrderId()) + .orderCode(event.getOrderCode()) + .orderTitle(event.getTitle()) + .businessType(ORDER_TYPE_SECURITY) + .areaId(event.getAreaId()) + .priority(PriorityEnum.fromPriority(event.getPriority())) + .build(); + + DispatchResult result = dispatchEngine.dispatch(context); + + if (result.isSuccess()) { + log.info("安保工单自动派单完成: orderId={}, assigneeId={}", event.getOrderId(), result.getAssigneeId()); + // 记录派单成功日志 + recordLog(EventDomain.DISPATCH, LogType.ORDER_DISPATCHED, + "自动派单成功,分配给: " + result.getAssigneeName(), + event.getOrderId(), result.getAssigneeId()); + } else { + log.warn("安保工单自动派单失败: orderId={}, reason={}", event.getOrderId(), result.getMessage()); + // 记录派单失败日志 + recordLog(EventDomain.DISPATCH, LogType.ORDER_DISPATCHED, + "自动派单失败: " + result.getMessage(), + event.getOrderId(), null); + } + } catch (Exception e) { + log.error("安保工单自动派单失败: orderId={}", event.getOrderId(), e); + recordLog(EventDomain.DISPATCH, LogType.ORDER_DISPATCHED, + "自动派单异常: " + e.getMessage(), + event.getOrderId(), null); + } + } + + // ==================== 状态变更事件 ==================== + + /** + * 状态变更事件 - 记录扩展表时间点 + 业务日志 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onOrderStateChanged(OrderStateChangedEvent event) { + if (!ORDER_TYPE_SECURITY.equals(event.getOrderType())) { + return; + } + + WorkOrderStatusEnum newStatus = event.getNewStatus(); + Long orderId = event.getOrderId(); + + log.info("安保工单状态变更: orderId={}, {} -> {}", orderId, event.getOldStatus(), newStatus); + + switch (newStatus) { + case DISPATCHED -> handleDispatched(orderId, event); + case CONFIRMED -> handleConfirmed(orderId, event); + case COMPLETED -> handleCompleted(orderId, event); + case CANCELLED -> handleCancelled(orderId, event); + case PAUSED -> handlePaused(orderId, event); + default -> log.debug("安保工单状态变更无需额外处理: orderId={}, status={}", orderId, newStatus); + } + } + + /** + * 工单完成事件 - 自动派送下一个任务 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onOrderCompleted(OrderCompletedEvent event) { + if (!ORDER_TYPE_SECURITY.equals(event.getOrderType())) { + return; + } + if (event.getAssigneeId() != null) { + try { + dispatchEngine.autoDispatchNext(event.getOrderId(), event.getAssigneeId()); + } catch (Exception e) { + log.error("安保工单完成后自动派送下一个失败: orderId={}", event.getOrderId(), e); + } + } + } + + // ==================== 状态处理方法 ==================== + + private void handleDispatched(Long orderId, OrderStateChangedEvent event) { + // 1. 记录下发时间 + OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); + extUpdate.setOpsOrderId(orderId); + extUpdate.setDispatchedTime(LocalDateTime.now()); + securityExtMapper.insertOrUpdateSelective(extUpdate); + + // 2. 业务日志 + Long assigneeId = event.getPayloadLong("assigneeId"); + String assigneeName = (String) event.getPayload().get("assigneeName"); + String message = assigneeName != null + ? String.format("工单已派发给 %s", assigneeName) + : "工单已派发"; + + // 如果是从 PAUSED 恢复,补充说明 + if (event.getOldStatus() == WorkOrderStatusEnum.PAUSED) { + message = "工单从暂停恢复,重新派发"; + } + + recordLog(EventDomain.DISPATCH, LogType.ORDER_DISPATCHED, message, orderId, assigneeId); + } + + private void handleConfirmed(Long orderId, OrderStateChangedEvent event) { + // 1. 记录确认时间 + OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); + extUpdate.setOpsOrderId(orderId); + extUpdate.setConfirmedTime(LocalDateTime.now()); + securityExtMapper.insertOrUpdateSelective(extUpdate); + + // 2. 业务日志 + Long operatorId = event.getOperatorId(); + recordLog(EventDomain.DISPATCH, LogType.ORDER_CONFIRM, "安保人员确认接单", orderId, operatorId); + } + + private void handleCompleted(Long orderId, OrderStateChangedEvent event) { + // 1. 记录完成时间 + OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); + extUpdate.setOpsOrderId(orderId); + extUpdate.setCompletedTime(LocalDateTime.now()); + securityExtMapper.insertOrUpdateSelective(extUpdate); + + // 2. 业务日志(区分自动完单 vs 人工完单) + Long operatorId = event.getOperatorId(); + OperatorTypeEnum operatorType = event.getOperatorType(); + String remark = event.getRemark(); + + String message; + if (operatorType == OperatorTypeEnum.SYSTEM || operatorId == null) { + message = "系统自动完单"; + if (remark != null && !remark.isEmpty()) { + message += "(" + remark + ")"; + } + } else { + message = "安保人员提交处理结果"; + } + + recordLog(EventDomain.DISPATCH, LogType.ORDER_COMPLETED, message, orderId, operatorId); + } + + private void handleCancelled(Long orderId, OrderStateChangedEvent event) { + Long operatorId = event.getOperatorId(); + String remark = event.getRemark(); + String message = "安保工单已取消"; + if (remark != null && !remark.isEmpty()) { + message += "(" + remark + ")"; + } + + recordLog(EventDomain.DISPATCH, LogType.ORDER_CANCELLED, message, orderId, operatorId); + } + + private void handlePaused(Long orderId, OrderStateChangedEvent event) { + Long operatorId = event.getOperatorId(); + String remark = event.getRemark(); + String message = "安保工单已暂停"; + if (remark != null && !remark.isEmpty()) { + message += "(" + remark + ")"; + } + + recordLog(EventDomain.DISPATCH, LogType.ORDER_PAUSED, message, orderId, operatorId); + } + + // ==================== 日志辅助方法 ==================== + + /** + * 统一记录安保业务日志 + */ + private void recordLog(EventDomain domain, LogType logType, String message, + Long orderId, Long personId) { + try { + EventLogRecord.EventLogRecordBuilder builder = EventLogRecord.builder() + .module(LogModule.SECURITY) + .domain(domain) + .eventType(logType.getCode()) + .message(message) + .targetId(orderId) + .targetType("order"); + + if (personId != null) { + builder.personId(personId); + } + + eventLogRecorder.record(builder.build()); + } catch (Exception e) { + log.warn("[SecurityOrderEventListener] 记录业务日志失败: orderId={}, eventType={}", + orderId, logType.getCode(), e); + } + } + +} -- 2.49.1 From 0f2fb3c50e105534c9107d96c705c9c375cbd276 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:33:28 +0800 Subject: [PATCH 04/17] =?UTF-8?q?test(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E4=BF=9D=E6=A8=A1=E5=9D=97=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 SecurityOrderService、SecurityOrderEventListener、 SecurityAreaAssignStrategy、SecurityOrderExtQueryHandler、 OpsAreaSecurityUserService 的核心逻辑测试。 Co-Authored-By: Claude Opus 4.6 --- .../SecurityOrderEventListenerTest.java | 277 +++++++++++ .../area/OpsAreaSecurityUserServiceTest.java | 122 +++++ .../SecurityAreaAssignStrategyTest.java | 108 +++++ .../SecurityOrderExtQueryHandlerTest.java | 190 ++++++++ .../SecurityOrderServiceTest.java | 437 ++++++++++++++++++ 5 files changed, 1134 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceTest.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategyTest.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandlerTest.java create mode 100644 viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java new file mode 100644 index 0000000..7b16739 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/integration/listener/SecurityOrderEventListenerTest.java @@ -0,0 +1,277 @@ +package com.viewsh.module.ops.security.integration.listener; + +import com.viewsh.module.ops.core.dispatch.DispatchEngine; +import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; +import com.viewsh.module.ops.core.event.OrderCompletedEvent; +import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.core.event.OrderStateChangedEvent; +import com.viewsh.module.ops.enums.PriorityEnum; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 安保工单事件监听器测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class SecurityOrderEventListenerTest { + + @InjectMocks + private SecurityOrderEventListener listener; + + @Mock + private OpsOrderSecurityExtMapper securityExtMapper; + + @Mock + private DispatchEngine dispatchEngine; + + private static final Long TEST_ORDER_ID = 10001L; + private static final String TEST_ORDER_CODE = "SECURITY-20260310-0001"; + + // ==================== onOrderCreated 测试 ==================== + + @Test + void testOnOrderCreated_SecurityType_TriggersDispatch() { + OrderCreatedEvent event = OrderCreatedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .orderCode(TEST_ORDER_CODE) + .title("入侵告警") + .areaId(100L) + .priority(PriorityEnum.P1.getPriority()) + .build(); + + // 执行 + listener.onOrderCreated(event); + + // 验证触发了派单(asyncDispatchAfterCreated 是自调用,实际同步执行) + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(OrderDispatchContext.class); + verify(dispatchEngine).dispatch(contextCaptor.capture()); + OrderDispatchContext ctx = contextCaptor.getValue(); + assertEquals(TEST_ORDER_ID, ctx.getOrderId()); + assertEquals(TEST_ORDER_CODE, ctx.getOrderCode()); + assertEquals("入侵告警", ctx.getOrderTitle()); + assertEquals("SECURITY", ctx.getBusinessType()); + assertEquals(100L, ctx.getAreaId()); + assertEquals(PriorityEnum.P1, ctx.getPriority()); + } + + @Test + void testOnOrderCreated_CleanType_Ignored() { + OrderCreatedEvent event = OrderCreatedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("CLEAN") + .build(); + + // 执行 + listener.onOrderCreated(event); + + // 验证不触发派单 + verify(dispatchEngine, never()).dispatch(any()); + } + + @Test + void testOnOrderCreated_DispatchException_Caught() { + OrderCreatedEvent event = OrderCreatedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .orderCode(TEST_ORDER_CODE) + .areaId(100L) + .priority(PriorityEnum.P1.getPriority()) + .build(); + + when(dispatchEngine.dispatch(any())).thenThrow(new RuntimeException("调度引擎异常")); + + // 执行:不应抛出异常 + assertDoesNotThrow(() -> listener.onOrderCreated(event)); + } + + // ==================== onOrderStateChanged 测试 ==================== + + @Test + void testOnOrderStateChanged_Dispatched_RecordsTime() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.PENDING, WorkOrderStatusEnum.DISPATCHED); + + lenient().when(securityExtMapper.insertOrUpdateSelective(any())).thenReturn(1); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证扩展表写入 dispatchedTime + ArgumentCaptor captor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(captor.capture()); + OpsOrderSecurityExtDO ext = captor.getValue(); + assertEquals(TEST_ORDER_ID, ext.getOpsOrderId()); + assertNotNull(ext.getDispatchedTime()); + } + + @Test + void testOnOrderStateChanged_Confirmed_RecordsTime() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.DISPATCHED, WorkOrderStatusEnum.CONFIRMED); + + lenient().when(securityExtMapper.insertOrUpdateSelective(any())).thenReturn(1); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证扩展表写入 confirmedTime + ArgumentCaptor captor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(captor.capture()); + OpsOrderSecurityExtDO ext = captor.getValue(); + assertEquals(TEST_ORDER_ID, ext.getOpsOrderId()); + assertNotNull(ext.getConfirmedTime()); + } + + @Test + void testOnOrderStateChanged_Completed_RecordsTime() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.ARRIVED, WorkOrderStatusEnum.COMPLETED); + + OpsOrderSecurityExtDO existingExt = OpsOrderSecurityExtDO.builder() + .id(1L).opsOrderId(TEST_ORDER_ID).build(); + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(existingExt); + lenient().when(securityExtMapper.insertOrUpdateSelective(any())).thenReturn(1); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证扩展表写入 completedTime + ArgumentCaptor captor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(captor.capture()); + OpsOrderSecurityExtDO ext = captor.getValue(); + assertEquals(TEST_ORDER_ID, ext.getOpsOrderId()); + assertNotNull(ext.getCompletedTime()); + } + + @Test + void testOnOrderStateChanged_Completed_NoExt_Skipped() { + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.ARRIVED, WorkOrderStatusEnum.COMPLETED); + + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(null); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证:扩展记录不存在时不写入 + verify(securityExtMapper, never()).insertOrUpdateSelective(any()); + } + + @Test + void testOnOrderStateChanged_CleanType_Ignored() { + OrderStateChangedEvent event = OrderStateChangedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("CLEAN") + .oldStatus(WorkOrderStatusEnum.PENDING) + .newStatus(WorkOrderStatusEnum.DISPATCHED) + .build(); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证不触发任何扩展表操作 + verify(securityExtMapper, never()).insertOrUpdateSelective(any()); + verify(securityExtMapper, never()).selectByOpsOrderId(anyLong()); + } + + @Test + void testOnOrderStateChanged_OtherStatus_NoAction() { + // CANCELLED 等状态无需额外处理 + OrderStateChangedEvent event = buildStateChangedEvent( + WorkOrderStatusEnum.PENDING, WorkOrderStatusEnum.CANCELLED); + + // 执行 + listener.onOrderStateChanged(event); + + // 验证无扩展表操作 + verify(securityExtMapper, never()).insertOrUpdateSelective(any()); + } + + // ==================== onOrderCompleted 测试 ==================== + + @Test + void testOnOrderCompleted_HasAssignee_DispatchNext() { + OrderCompletedEvent event = OrderCompletedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .assigneeId(2001L) + .build(); + + // 执行 + listener.onOrderCompleted(event); + + // 验证调用自动派送下一个 + verify(dispatchEngine).autoDispatchNext(TEST_ORDER_ID, 2001L); + } + + @Test + void testOnOrderCompleted_NoAssignee_SkipDispatch() { + OrderCompletedEvent event = OrderCompletedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .assigneeId(null) // 无分配人 + .build(); + + // 执行 + listener.onOrderCompleted(event); + + // 验证不调用自动派送 + verify(dispatchEngine, never()).autoDispatchNext(anyLong(), anyLong()); + } + + @Test + void testOnOrderCompleted_CleanType_Ignored() { + OrderCompletedEvent event = OrderCompletedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("CLEAN") + .assigneeId(2001L) + .build(); + + // 执行 + listener.onOrderCompleted(event); + + // 验证不触发 + verify(dispatchEngine, never()).autoDispatchNext(anyLong(), anyLong()); + } + + @Test + void testOnOrderCompleted_DispatchException_Caught() { + OrderCompletedEvent event = OrderCompletedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .assigneeId(2001L) + .build(); + + doThrow(new RuntimeException("派送异常")).when(dispatchEngine) + .autoDispatchNext(anyLong(), anyLong()); + + // 执行:不应抛出异常 + assertDoesNotThrow(() -> listener.onOrderCompleted(event)); + } + + // ==================== 辅助方法 ==================== + + private OrderStateChangedEvent buildStateChangedEvent( + WorkOrderStatusEnum oldStatus, WorkOrderStatusEnum newStatus) { + return OrderStateChangedEvent.builder() + .orderId(TEST_ORDER_ID) + .orderType("SECURITY") + .oldStatus(oldStatus) + .newStatus(newStatus) + .build(); + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceTest.java new file mode 100644 index 0000000..dd43103 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/area/OpsAreaSecurityUserServiceTest.java @@ -0,0 +1,122 @@ +package com.viewsh.module.ops.security.service.area; + +import com.viewsh.framework.common.exception.ServiceException; +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.viewsh.module.ops.security.dal.mysql.area.OpsAreaSecurityUserMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 区域安保人员绑定服务单元测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class OpsAreaSecurityUserServiceTest { + + @InjectMocks + private OpsAreaSecurityUserServiceImpl service; + + @Mock + private OpsAreaSecurityUserMapper areaSecurityUserMapper; + + @Test + void bindUser_success() { + when(areaSecurityUserMapper.selectByAreaIdAndUserId(100L, 2001L)).thenReturn(null); + when(areaSecurityUserMapper.insert(any(OpsAreaSecurityUserDO.class))).thenReturn(1); + + Long id = service.bindUser(100L, 2001L, "张三", 10L, 0); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OpsAreaSecurityUserDO.class); + verify(areaSecurityUserMapper).insert(captor.capture()); + OpsAreaSecurityUserDO saved = captor.getValue(); + assertEquals(100L, saved.getAreaId()); + assertEquals(2001L, saved.getUserId()); + assertEquals("张三", saved.getUserName()); + assertEquals(10L, saved.getTeamId()); + assertTrue(saved.getEnabled()); + assertEquals(0, saved.getSort()); + } + + @Test + void bindUser_duplicate_throws() { + OpsAreaSecurityUserDO existing = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(100L).userId(2001L).build(); + when(areaSecurityUserMapper.selectByAreaIdAndUserId(100L, 2001L)).thenReturn(existing); + + assertThrows(ServiceException.class, () -> service.bindUser(100L, 2001L, "张三", null, null)); + verify(areaSecurityUserMapper, never()).insert(any(OpsAreaSecurityUserDO.class)); + } + + @Test + void unbindUser_success() { + OpsAreaSecurityUserDO existing = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(100L).userId(2001L).build(); + when(areaSecurityUserMapper.selectById(1L)).thenReturn(existing); + when(areaSecurityUserMapper.deleteById(1L)).thenReturn(1); + + service.unbindUser(1L); + + verify(areaSecurityUserMapper).deleteById(1L); + } + + @Test + void unbindUser_notFound_throws() { + when(areaSecurityUserMapper.selectById(999L)).thenReturn(null); + + assertThrows(ServiceException.class, () -> service.unbindUser(999L)); + verify(areaSecurityUserMapper, never()).deleteById(any()); + } + + @Test + void listByAreaId_returnsEnabledOnly() { + OpsAreaSecurityUserDO user1 = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(100L).userId(2001L).userName("张三").enabled(true).build(); + OpsAreaSecurityUserDO user2 = OpsAreaSecurityUserDO.builder() + .id(2L).areaId(100L).userId(2002L).userName("李四").enabled(true).build(); + + when(areaSecurityUserMapper.selectListByAreaId(100L)).thenReturn(Arrays.asList(user1, user2)); + + List result = service.listByAreaId(100L); + + assertEquals(2, result.size()); + verify(areaSecurityUserMapper).selectListByAreaId(100L); + } + + @Test + void updateBinding_success() { + OpsAreaSecurityUserDO existing = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(100L).userId(2001L).enabled(true).sort(0).build(); + when(areaSecurityUserMapper.selectById(1L)).thenReturn(existing); + when(areaSecurityUserMapper.updateById(any(OpsAreaSecurityUserDO.class))).thenReturn(1); + + service.updateBinding(1L, false, 5, 20L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OpsAreaSecurityUserDO.class); + verify(areaSecurityUserMapper).updateById(captor.capture()); + OpsAreaSecurityUserDO updated = captor.getValue(); + assertEquals(1L, updated.getId()); + assertFalse(updated.getEnabled()); + assertEquals(5, updated.getSort()); + assertEquals(20L, updated.getTeamId()); + } + + @Test + void updateBinding_notFound_throws() { + when(areaSecurityUserMapper.selectById(999L)).thenReturn(null); + + assertThrows(ServiceException.class, () -> service.updateBinding(999L, true, null, null)); + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategyTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategyTest.java new file mode 100644 index 0000000..1387fd1 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/dispatch/SecurityAreaAssignStrategyTest.java @@ -0,0 +1,108 @@ +package com.viewsh.module.ops.security.service.dispatch; + +import com.viewsh.module.ops.core.dispatch.model.AssigneeRecommendation; +import com.viewsh.module.ops.core.dispatch.model.OrderDispatchContext; +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.viewsh.module.ops.security.dal.mysql.area.OpsAreaSecurityUserMapper; +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 static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 安保区域分配策略单元测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class SecurityAreaAssignStrategyTest { + + @InjectMocks + private SecurityAreaAssignStrategy strategy; + + @Mock + private OpsAreaSecurityUserMapper areaSecurityUserMapper; + + @Test + void recommend_noAreaId_returnsNone() { + OrderDispatchContext context = OrderDispatchContext.builder() + .orderId(1001L) + .areaId(null) + .build(); + + AssigneeRecommendation result = strategy.recommend(context); + + assertFalse(result.hasRecommendation()); + verify(areaSecurityUserMapper, never()).selectListByAreaId(any()); + } + + @Test + void recommend_noUsersInArea_returnsNone() { + Long areaId = 100L; + OrderDispatchContext context = OrderDispatchContext.builder() + .orderId(1001L) + .areaId(areaId) + .build(); + + when(areaSecurityUserMapper.selectListByAreaId(areaId)).thenReturn(Collections.emptyList()); + + AssigneeRecommendation result = strategy.recommend(context); + + assertFalse(result.hasRecommendation()); + verify(areaSecurityUserMapper).selectListByAreaId(areaId); + } + + @Test + void recommend_hasUsers_returnsRecommendation() { + Long areaId = 100L; + OrderDispatchContext context = OrderDispatchContext.builder() + .orderId(1001L) + .areaId(areaId) + .build(); + + OpsAreaSecurityUserDO user1 = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(areaId).userId(2001L).userName("张三").enabled(true).build(); + OpsAreaSecurityUserDO user2 = OpsAreaSecurityUserDO.builder() + .id(2L).areaId(areaId).userId(2002L).userName("李四").enabled(true).build(); + + when(areaSecurityUserMapper.selectListByAreaId(areaId)).thenReturn(Arrays.asList(user1, user2)); + + AssigneeRecommendation result = strategy.recommend(context); + + assertTrue(result.hasRecommendation()); + assertNotNull(result.getAssigneeId()); + assertTrue(result.getAssigneeId().equals(2001L) || result.getAssigneeId().equals(2002L)); + assertEquals(50, result.getScore()); + assertEquals("区域随机分配", result.getReason()); + assertEquals(areaId, result.getAreaId()); + } + + @Test + void recommend_singleUser_returnsThatUser() { + Long areaId = 100L; + OrderDispatchContext context = OrderDispatchContext.builder() + .orderId(1001L) + .areaId(areaId) + .build(); + + OpsAreaSecurityUserDO user = OpsAreaSecurityUserDO.builder() + .id(1L).areaId(areaId).userId(2001L).userName("张三").enabled(true).build(); + + when(areaSecurityUserMapper.selectListByAreaId(areaId)).thenReturn(Collections.singletonList(user)); + + AssigneeRecommendation result = strategy.recommend(context); + + assertTrue(result.hasRecommendation()); + assertEquals(2001L, result.getAssigneeId()); + assertEquals("张三", result.getAssigneeName()); + assertEquals(areaId, result.getAreaId()); + } + +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandlerTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandlerTest.java new file mode 100644 index 0000000..0382862 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderExtQueryHandlerTest.java @@ -0,0 +1,190 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.enums.PriorityEnum; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import com.viewsh.module.ops.service.OrderDetailVO; +import com.viewsh.module.ops.service.OrderSummaryVO; +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.time.LocalDateTime; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * 安保工单扩展查询处理器测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class SecurityOrderExtQueryHandlerTest { + + @InjectMocks + private SecurityOrderExtQueryHandler handler; + + @Mock + private OpsOrderSecurityExtMapper securityExtMapper; + + private static final Long TEST_ORDER_ID = 10001L; + private OpsOrderSecurityExtDO testSecurityExt; + private OpsOrderDO testOrder; + + @BeforeEach + void setUp() { + testSecurityExt = OpsOrderSecurityExtDO.builder() + .id(1L) + .opsOrderId(TEST_ORDER_ID) + .alarmId("ALM20260310001") + .alarmType("intrusion") + .cameraId("CAM_001") + .roiId("ROI_001") + .imageUrl("https://oss.example.com/snapshot.jpg") + .assignedUserId(2001L) + .assignedUserName("张安保") + .assignedTeamId(301L) + .result("已排查,系误报") + .resultImgUrls("[\"https://oss/r1.jpg\"]") + .dispatchedTime(LocalDateTime.of(2026, 3, 10, 10, 0)) + .confirmedTime(LocalDateTime.of(2026, 3, 10, 10, 2)) + .completedTime(LocalDateTime.of(2026, 3, 10, 10, 30)) + .build(); + + testOrder = OpsOrderDO.builder() + .id(TEST_ORDER_ID) + .orderCode("SECURITY-20260310-0001") + .orderType("SECURITY") + .sourceType("ALARM") + .title("A栋3层入侵告警") + .description("摄像头检测到异常人员") + .priority(PriorityEnum.P1.getPriority()) + .status(WorkOrderStatusEnum.COMPLETED.getStatus()) + .areaId(100L) + .location("A栋3层东侧走廊") + .assigneeId(2001L) + .assigneeName("张安保") + .startTime(LocalDateTime.of(2026, 3, 10, 10, 5)) + .endTime(LocalDateTime.of(2026, 3, 10, 10, 30)) + .responseSeconds(300) + .completionSeconds(1500) + .build(); + } + + // ==================== supports() 测试 ==================== + + @Test + void testSupports_Security_ReturnsTrue() { + assertTrue(handler.supports("SECURITY")); + } + + @Test + void testSupports_Clean_ReturnsFalse() { + assertFalse(handler.supports("CLEAN")); + } + + @Test + void testSupports_Null_ReturnsFalse() { + assertFalse(handler.supports(null)); + } + + @Test + void testSupports_Empty_ReturnsFalse() { + assertFalse(handler.supports("")); + } + + // ==================== enrichWithExtInfo() 测试 ==================== + + @Test + void testEnrichWithExtInfo_HasExt_FieldsPopulated() { + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(testSecurityExt); + + OrderSummaryVO vo = new OrderSummaryVO(); + handler.enrichWithExtInfo(vo, TEST_ORDER_ID); + + // 验证 extInfo 非空 + assertNotNull(vo.getExtInfo()); + Map extInfo = vo.getExtInfo(); + + // 验证各字段映射 + assertEquals("ALM20260310001", extInfo.get("alarmId")); + assertEquals("intrusion", extInfo.get("alarmType")); + assertEquals("CAM_001", extInfo.get("cameraId")); + assertEquals("https://oss.example.com/snapshot.jpg", extInfo.get("imageUrl")); + assertEquals("张安保", extInfo.get("assignedUserName")); + assertEquals(testSecurityExt.getConfirmedTime(), extInfo.get("confirmedTime")); + assertEquals(testSecurityExt.getCompletedTime(), extInfo.get("completedTime")); + assertEquals("已排查,系误报", extInfo.get("result")); + } + + @Test + void testEnrichWithExtInfo_NoExt_ExtInfoNotSet() { + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(null); + + OrderSummaryVO vo = new OrderSummaryVO(); + handler.enrichWithExtInfo(vo, TEST_ORDER_ID); + + // 无扩展记录时,extInfo 未被填充 + assertTrue(vo.getExtInfo() == null || vo.getExtInfo().isEmpty()); + } + + // ==================== buildDetailVO() 测试 ==================== + + @Test + void testBuildDetailVO_HasExt_FullVO() { + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(testSecurityExt); + + OrderDetailVO vo = handler.buildDetailVO(testOrder); + + // 验证主表字段 + assertNotNull(vo); + assertEquals(TEST_ORDER_ID, vo.getId()); + assertEquals("SECURITY-20260310-0001", vo.getOrderCode()); + assertEquals("SECURITY", vo.getOrderType()); + assertEquals("ALARM", vo.getSourceType()); + assertEquals("A栋3层入侵告警", vo.getTitle()); + assertEquals(PriorityEnum.P1.getPriority(), vo.getPriority()); + assertEquals(WorkOrderStatusEnum.COMPLETED.getStatus(), vo.getStatus()); + assertEquals(100L, vo.getAreaId()); + assertEquals("A栋3层东侧走廊", vo.getLocation()); + + // 验证扩展字段 + assertNotNull(vo.getExtInfo()); + Map extInfo = vo.getExtInfo(); + assertEquals("ALM20260310001", extInfo.get("alarmId")); + assertEquals("intrusion", extInfo.get("alarmType")); + assertEquals("CAM_001", extInfo.get("cameraId")); + assertEquals("ROI_001", extInfo.get("roiId")); + assertEquals("https://oss.example.com/snapshot.jpg", extInfo.get("imageUrl")); + assertEquals(2001L, extInfo.get("assignedUserId")); + assertEquals("张安保", extInfo.get("assignedUserName")); + assertEquals(301L, extInfo.get("assignedTeamId")); + assertEquals("已排查,系误报", extInfo.get("result")); + assertEquals("[\"https://oss/r1.jpg\"]", extInfo.get("resultImgUrls")); + assertEquals(testSecurityExt.getDispatchedTime(), extInfo.get("dispatchedTime")); + assertEquals(testSecurityExt.getConfirmedTime(), extInfo.get("confirmedTime")); + assertEquals(testSecurityExt.getCompletedTime(), extInfo.get("completedTime")); + } + + @Test + void testBuildDetailVO_NoExt_ExtInfoNull() { + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(null); + + OrderDetailVO vo = handler.buildDetailVO(testOrder); + + // 主表字段正常 + assertNotNull(vo); + assertEquals(TEST_ORDER_ID, vo.getId()); + assertEquals("SECURITY", vo.getOrderType()); + + // 无扩展记录时,extInfo 未被填充 + assertTrue(vo.getExtInfo() == null || vo.getExtInfo().isEmpty()); + } +} diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java new file mode 100644 index 0000000..8be3593 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-security-biz/src/test/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceTest.java @@ -0,0 +1,437 @@ +package com.viewsh.module.ops.security.service.securityorder; + +import com.viewsh.framework.common.exception.ServiceException; +import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.core.event.OrderEventPublisher; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; +import com.viewsh.module.ops.enums.OperatorTypeEnum; +import com.viewsh.module.ops.enums.PriorityEnum; +import com.viewsh.module.ops.enums.SourceTypeEnum; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.infrastructure.code.OrderCodeGenerator; +import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator; +import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; +import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; +import com.viewsh.module.ops.service.fsm.OrderStateMachine; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 安保工单服务单元测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +public class SecurityOrderServiceTest { + + @InjectMocks + private SecurityOrderServiceImpl securityOrderService; + + @Mock + private OpsOrderMapper opsOrderMapper; + @Mock + private OpsOrderSecurityExtMapper securityExtMapper; + @Mock + private OrderIdGenerator orderIdGenerator; + @Mock + private OrderCodeGenerator orderCodeGenerator; + @Mock + private OrderEventPublisher orderEventPublisher; + @Mock + private OrderStateMachine orderStateMachine; + + // 模拟数据库 + private Map orderDB; + + private static final Long TEST_ORDER_ID = 10001L; + private static final String TEST_ORDER_CODE = "SECURITY-20260310-0001"; + + @BeforeEach + void setUp() { + orderDB = new HashMap<>(); + + // 配置 ID/编号 生成器 + lenient().when(orderIdGenerator.generate()).thenReturn(TEST_ORDER_ID); + lenient().when(orderCodeGenerator.generate("SECURITY")).thenReturn(TEST_ORDER_CODE); + + // 配置 Mapper 模拟 + lenient().when(opsOrderMapper.insert(any(OpsOrderDO.class))).thenAnswer(i -> { + OpsOrderDO order = i.getArgument(0); + orderDB.put(order.getId(), order); + return 1; + }); + lenient().when(opsOrderMapper.selectById(anyLong())).thenAnswer(i -> orderDB.get(i.getArgument(0))); + lenient().when(opsOrderMapper.updateById(any(OpsOrderDO.class))).thenReturn(1); + lenient().when(securityExtMapper.insert(any(OpsOrderSecurityExtDO.class))).thenReturn(1); + lenient().when(securityExtMapper.insertOrUpdateSelective(any(OpsOrderSecurityExtDO.class))).thenReturn(1); + } + + // ==================== 创建工单测试 ==================== + + @Test + void testCreateSecurityOrder_AlarmSource_Success() { + // 准备:告警来源请求 + SecurityOrderCreateReqDTO req = new SecurityOrderCreateReqDTO(); + req.setTitle("A栋3层入侵告警"); + req.setDescription("摄像头检测到异常人员"); + req.setPriority(PriorityEnum.P1.getPriority()); + req.setAreaId(100L); + req.setLocation("A栋3层东侧走廊"); + req.setAlarmId("ALM20260310001"); + req.setAlarmType("intrusion"); + req.setCameraId("CAM_001"); + req.setRoiId("ROI_001"); + req.setImageUrl("https://oss.example.com/alarm/snapshot.jpg"); + + // 执行 + Long orderId = securityOrderService.createSecurityOrder(req); + + // 验证返回值 + assertEquals(TEST_ORDER_ID, orderId); + + // 验证主表写入 + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).insert(orderCaptor.capture()); + OpsOrderDO savedOrder = orderCaptor.getValue(); + assertEquals(TEST_ORDER_ID, savedOrder.getId()); + assertEquals(TEST_ORDER_CODE, savedOrder.getOrderCode()); + assertEquals("SECURITY", savedOrder.getOrderType()); + assertEquals(SourceTypeEnum.ALARM.getType(), savedOrder.getSourceType()); + assertEquals("A栋3层入侵告警", savedOrder.getTitle()); + assertEquals(PriorityEnum.P1.getPriority(), savedOrder.getPriority()); + assertEquals(WorkOrderStatusEnum.PENDING.getStatus(), savedOrder.getStatus()); + assertEquals(100L, savedOrder.getAreaId()); + + // 验证扩展表写入 + ArgumentCaptor extCaptor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insert(extCaptor.capture()); + OpsOrderSecurityExtDO savedExt = extCaptor.getValue(); + assertEquals(TEST_ORDER_ID, savedExt.getOpsOrderId()); + assertEquals("ALM20260310001", savedExt.getAlarmId()); + assertEquals("intrusion", savedExt.getAlarmType()); + assertEquals("CAM_001", savedExt.getCameraId()); + assertEquals("ROI_001", savedExt.getRoiId()); + assertEquals("https://oss.example.com/alarm/snapshot.jpg", savedExt.getImageUrl()); + + // 验证事件发布 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(OrderCreatedEvent.class); + verify(orderEventPublisher).publishOrderCreated(eventCaptor.capture()); + OrderCreatedEvent event = eventCaptor.getValue(); + assertEquals(TEST_ORDER_ID, event.getOrderId()); + assertEquals("SECURITY", event.getOrderType()); + assertEquals(TEST_ORDER_CODE, event.getOrderCode()); + assertEquals(100L, event.getAreaId()); + } + + @Test + void testCreateSecurityOrder_ManualSource_Success() { + // 准备:手动创建(无告警ID) + SecurityOrderCreateReqDTO req = new SecurityOrderCreateReqDTO(); + req.setTitle("保安巡查发现异常"); + req.setAreaId(200L); + + // 执行 + Long orderId = securityOrderService.createSecurityOrder(req); + + // 验证来源类型为 MANUAL + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).insert(orderCaptor.capture()); + assertEquals(SourceTypeEnum.MANUAL.getType(), orderCaptor.getValue().getSourceType()); + } + + @Test + void testCreateSecurityOrder_ExplicitSourceType_Success() { + // 准备:显式指定来源类型 + SecurityOrderCreateReqDTO req = new SecurityOrderCreateReqDTO(); + req.setTitle("手动创建"); + req.setAreaId(200L); + req.setAlarmId("ALM001"); // 有告警ID + req.setSourceType("CUSTOM_SOURCE"); // 但显式指定了sourceType + + // 执行 + securityOrderService.createSecurityOrder(req); + + // 验证:显式指定的 sourceType 优先于自动推断 + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).insert(orderCaptor.capture()); + assertEquals("CUSTOM_SOURCE", orderCaptor.getValue().getSourceType()); + } + + @Test + void testCreateSecurityOrder_DefaultPriority_P2() { + // 准备:不设置优先级 + SecurityOrderCreateReqDTO req = new SecurityOrderCreateReqDTO(); + req.setTitle("默认优先级工单"); + req.setAreaId(100L); + + // 执行 + securityOrderService.createSecurityOrder(req); + + // 验证默认 P2 + ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).insert(orderCaptor.capture()); + assertEquals(PriorityEnum.P2.getPriority(), orderCaptor.getValue().getPriority()); + } + + // ==================== 确认工单测试 ==================== + + @Test + void testConfirmOrder_Success() { + // 准备:已存在的 SECURITY 工单 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.DISPATCHED); + orderDB.put(TEST_ORDER_ID, order); + + Long userId = 2001L; + + // 执行 + securityOrderService.confirmOrder(TEST_ORDER_ID, userId); + + // 验证状态机调用 + verify(orderStateMachine).transition( + eq(order), + eq(WorkOrderStatusEnum.CONFIRMED), + eq(OperatorTypeEnum.SECURITY_GUARD), + eq(userId), + eq("安保人员确认接单") + ); + + // 验证不再直接写扩展表时间(由 EventListener 统一处理) + verify(securityExtMapper, never()).insertOrUpdateSelective(any()); + } + + @Test + void testConfirmOrder_OrderNotFound_ThrowsException() { + // 执行 + 验证:工单不存在抛异常 + ServiceException exception = assertThrows(ServiceException.class, + () -> securityOrderService.confirmOrder(999L, 2001L)); + assertTrue(exception.getMessage().contains("工单不存在")); + } + + @Test + void testConfirmOrder_WrongOrderType_ThrowsException() { + // 准备:CLEAN 类型工单 + OpsOrderDO cleanOrder = OpsOrderDO.builder() + .id(TEST_ORDER_ID) + .orderType("CLEAN") + .status(WorkOrderStatusEnum.DISPATCHED.getStatus()) + .build(); + orderDB.put(TEST_ORDER_ID, cleanOrder); + + // 执行 + 验证:类型不匹配抛异常 + ServiceException exception = assertThrows(ServiceException.class, + () -> securityOrderService.confirmOrder(TEST_ORDER_ID, 2001L)); + assertTrue(exception.getMessage().contains("工单类型不匹配")); + + // 验证状态机未被调用 + verify(orderStateMachine, never()).transition( + any(), any(), any(), anyLong(), anyString()); + } + + // ==================== 自动完单测试 ==================== + + @Test + void testAutoCompleteOrder_WithRemark_Success() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + // 执行 + securityOrderService.autoCompleteOrder(TEST_ORDER_ID, "告警自动解除"); + + // 验证状态机调用 + verify(orderStateMachine).transition( + eq(order), + eq(WorkOrderStatusEnum.COMPLETED), + eq(OperatorTypeEnum.SYSTEM), + isNull(), + eq("告警自动解除") + ); + + // 验证主表 endTime 更新 + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).updateById(updateCaptor.capture()); + OpsOrderDO updated = updateCaptor.getValue(); + assertEquals(TEST_ORDER_ID, updated.getId()); + assertNotNull(updated.getEndTime()); + + // 验证不再直接写扩展表时间 + verify(securityExtMapper, never()).insertOrUpdateSelective(any()); + } + + @Test + void testAutoCompleteOrder_WithoutRemark_DefaultReason() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + // 执行:remark 为空 + securityOrderService.autoCompleteOrder(TEST_ORDER_ID, null); + + // 验证使用默认备注 + verify(orderStateMachine).transition( + any(), eq(WorkOrderStatusEnum.COMPLETED), + eq(OperatorTypeEnum.SYSTEM), isNull(), + eq("系统自动完单") + ); + } + + @Test + void testAutoCompleteOrder_BlankRemark_DefaultReason() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + // 执行:remark 为空白字符串 + securityOrderService.autoCompleteOrder(TEST_ORDER_ID, " "); + + // 验证使用默认备注 + verify(orderStateMachine).transition( + any(), eq(WorkOrderStatusEnum.COMPLETED), + eq(OperatorTypeEnum.SYSTEM), isNull(), + eq("系统自动完单") + ); + } + + @Test + void testAutoCompleteOrder_OrderNotFound_ThrowsException() { + assertThrows(ServiceException.class, + () -> securityOrderService.autoCompleteOrder(999L, "test")); + } + + // ==================== 人工完单测试 ==================== + + @Test + void testManualCompleteOrder_WithImages_Success() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + // 准备完单请求,包含result和图片 + SecurityOrderCompleteReqDTO req = new SecurityOrderCompleteReqDTO(); + req.setOrderId(TEST_ORDER_ID); + req.setResult("已到现场排查,系误报"); + req.setResultImgUrls(Arrays.asList("https://oss/result1.jpg", "https://oss/result2.jpg")); + req.setOperatorId(2001L); + + // 执行 + securityOrderService.manualCompleteOrder(req); + + // 验证状态机调用 + verify(orderStateMachine).transition( + eq(order), + eq(WorkOrderStatusEnum.COMPLETED), + eq(OperatorTypeEnum.SECURITY_GUARD), + eq(2001L), + eq("安保人员提交处理结果") + ); + + // 验证扩展表:写入 result + resultImgUrls(不含 completedTime) + ArgumentCaptor extCaptor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(extCaptor.capture()); + OpsOrderSecurityExtDO extUpdate = extCaptor.getValue(); + assertEquals(TEST_ORDER_ID, extUpdate.getOpsOrderId()); + assertEquals("已到现场排查,系误报", extUpdate.getResult()); + assertNotNull(extUpdate.getResultImgUrls()); + assertTrue(extUpdate.getResultImgUrls().contains("result1.jpg")); + assertNull(extUpdate.getCompletedTime()); // 时间由 EventListener 写入 + + // 验证主表 endTime 更新 + ArgumentCaptor orderUpdateCaptor = ArgumentCaptor.forClass(OpsOrderDO.class); + verify(opsOrderMapper).updateById(orderUpdateCaptor.capture()); + assertNotNull(orderUpdateCaptor.getValue().getEndTime()); + } + + @Test + void testManualCompleteOrder_WithoutImages_Success() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + SecurityOrderCompleteReqDTO req = new SecurityOrderCompleteReqDTO(); + req.setOrderId(TEST_ORDER_ID); + req.setResult("已处理完毕"); + req.setResultImgUrls(null); // 无图片 + req.setOperatorId(2001L); + + // 执行 + securityOrderService.manualCompleteOrder(req); + + // 验证扩展表:result 有值,resultImgUrls 为 null + ArgumentCaptor extCaptor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(extCaptor.capture()); + assertEquals("已处理完毕", extCaptor.getValue().getResult()); + assertNull(extCaptor.getValue().getResultImgUrls()); + } + + @Test + void testManualCompleteOrder_EmptyImagesList_NoJsonWrite() { + // 准备 + OpsOrderDO order = buildSecurityOrder(TEST_ORDER_ID, WorkOrderStatusEnum.ARRIVED); + orderDB.put(TEST_ORDER_ID, order); + + SecurityOrderCompleteReqDTO req = new SecurityOrderCompleteReqDTO(); + req.setOrderId(TEST_ORDER_ID); + req.setResult("已处理"); + req.setResultImgUrls(Arrays.asList()); // 空列表 + req.setOperatorId(2001L); + + // 执行 + securityOrderService.manualCompleteOrder(req); + + // 验证:空列表不写入 resultImgUrls + ArgumentCaptor extCaptor = ArgumentCaptor.forClass(OpsOrderSecurityExtDO.class); + verify(securityExtMapper).insertOrUpdateSelective(extCaptor.capture()); + assertNull(extCaptor.getValue().getResultImgUrls()); + } + + // ==================== 查询测试 ==================== + + @Test + void testGetSecurityExt_Exists() { + OpsOrderSecurityExtDO ext = OpsOrderSecurityExtDO.builder() + .id(1L).opsOrderId(TEST_ORDER_ID).alarmId("ALM001").build(); + when(securityExtMapper.selectByOpsOrderId(TEST_ORDER_ID)).thenReturn(ext); + + OpsOrderSecurityExtDO result = securityOrderService.getSecurityExt(TEST_ORDER_ID); + assertNotNull(result); + assertEquals("ALM001", result.getAlarmId()); + } + + @Test + void testGetSecurityExt_NotExists() { + when(securityExtMapper.selectByOpsOrderId(999L)).thenReturn(null); + + OpsOrderSecurityExtDO result = securityOrderService.getSecurityExt(999L); + assertNull(result); + } + + // ==================== 辅助方法 ==================== + + private OpsOrderDO buildSecurityOrder(Long orderId, WorkOrderStatusEnum status) { + return OpsOrderDO.builder() + .id(orderId) + .orderCode(TEST_ORDER_CODE) + .orderType("SECURITY") + .sourceType(SourceTypeEnum.ALARM.getType()) + .title("安保测试工单") + .priority(PriorityEnum.P1.getPriority()) + .status(status.getStatus()) + .areaId(100L) + .location("A栋3层") + .build(); + } +} -- 2.49.1 From 2e4432e51b6e70a83b3470316eaa8df7d568f7fe Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:33:42 +0800 Subject: [PATCH 05/17] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=89?= =?UTF-8?q?=E4=BF=9D=E5=B7=A5=E5=8D=95=20Controller=20=E4=B8=8E=E5=BC=80?= =?UTF-8?q?=E6=94=BE=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含 SecurityOrderController(创建/确认/完单/自动完单)、 SecurityAreaUserController(区域人员绑定)、 SecurityOrderOpenController(外部回调), 以及对应的 VO 和权限配置。 Co-Authored-By: Claude Opus 4.6 --- .../security/SecurityAreaUserController.java | 88 ++++++++++++++++++ .../security/SecurityOrderController.java | 93 +++++++++++++++++++ .../vo/OpsAreaSecurityUserBindReqVO.java | 33 +++++++ .../vo/OpsAreaSecurityUserRespVO.java | 41 ++++++++ .../vo/OpsAreaSecurityUserUpdateReqVO.java | 29 ++++++ .../vo/SecurityOrderAutoCompleteReqVO.java | 23 +++++ .../vo/SecurityOrderCompleteReqVO.java | 30 ++++++ .../vo/SecurityOrderConfirmReqVO.java | 24 +++++ .../security/vo/SecurityOrderCreateReqVO.java | 54 +++++++++++ .../security/SecurityOrderOpenController.java | 72 ++++++++++++++ .../config/SecurityConfiguration.java | 4 +- 11 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityAreaUserController.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserBindReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserRespVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserUpdateReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderAutoCompleteReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCompleteReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderConfirmReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityAreaUserController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityAreaUserController.java new file mode 100644 index 0000000..1421c89 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityAreaUserController.java @@ -0,0 +1,88 @@ +package com.viewsh.module.ops.controller.admin.security; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.ops.controller.admin.security.vo.OpsAreaSecurityUserBindReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.OpsAreaSecurityUserRespVO; +import com.viewsh.module.ops.controller.admin.security.vo.OpsAreaSecurityUserUpdateReqVO; +import com.viewsh.module.ops.security.dal.dataobject.area.OpsAreaSecurityUserDO; +import com.viewsh.module.ops.security.service.area.OpsAreaSecurityUserService; +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 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; + +/** + * 安保区域人员绑定管理 + * + * @author lzh + */ +@Tag(name = "安保区域人员绑定") +@RestController +@RequestMapping("/ops/security/area-user") +@Validated +public class SecurityAreaUserController { + + @Resource + private OpsAreaSecurityUserService areaSecurityUserService; + + @GetMapping("/list") + @Operation(summary = "查询区域绑定的安保人员") + @PreAuthorize("@ss.hasPermission('ops:security-area-user:query')") + public CommonResult> list( + @Parameter(description = "区域ID", required = true) @RequestParam("areaId") Long areaId) { + List list = areaSecurityUserService.listByAreaId(areaId); + List result = list.stream() + .map(this::convertToRespVO) + .toList(); + return success(result); + } + + @PostMapping("/bind") + @Operation(summary = "绑定安保人员到区域") + @PreAuthorize("@ss.hasPermission('ops:security-area-user:create')") + public CommonResult bind(@Valid @RequestBody OpsAreaSecurityUserBindReqVO reqVO) { + Long id = areaSecurityUserService.bindUser( + reqVO.getAreaId(), reqVO.getUserId(), reqVO.getUserName(), + reqVO.getTeamId(), reqVO.getSort()); + return success(id); + } + + @PutMapping("/update") + @Operation(summary = "更新绑定信息") + @PreAuthorize("@ss.hasPermission('ops:security-area-user:update')") + public CommonResult update(@Valid @RequestBody OpsAreaSecurityUserUpdateReqVO reqVO) { + areaSecurityUserService.updateBinding(reqVO.getId(), reqVO.getEnabled(), reqVO.getSort(), reqVO.getTeamId()); + return success(true); + } + + @DeleteMapping("/unbind") + @Operation(summary = "解除绑定") + @PreAuthorize("@ss.hasPermission('ops:security-area-user:delete')") + public CommonResult unbind( + @Parameter(description = "绑定记录ID", required = true) @RequestParam("id") Long id) { + areaSecurityUserService.unbindUser(id); + return success(true); + } + + private OpsAreaSecurityUserRespVO convertToRespVO(OpsAreaSecurityUserDO entity) { + OpsAreaSecurityUserRespVO vo = new OpsAreaSecurityUserRespVO(); + vo.setId(entity.getId()); + vo.setAreaId(entity.getAreaId()); + vo.setUserId(entity.getUserId()); + vo.setUserName(entity.getUserName()); + vo.setTeamId(entity.getTeamId()); + vo.setEnabled(entity.getEnabled()); + vo.setSort(entity.getSort()); + vo.setCreateTime(entity.getCreateTime()); + return vo; + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java new file mode 100644 index 0000000..948ccc7 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java @@ -0,0 +1,93 @@ +package com.viewsh.module.ops.controller.admin.security; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.security.core.util.SecurityFrameworkUtils; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderAutoCompleteReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCompleteReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderConfirmReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCreateReqVO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCompleteReqDTO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCreateReqDTO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +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; + +/** + * 安保工单管理(Admin) + *

+ * 后台管理端安保工单接口: + * - 创建安保工单 + * - 确认工单 + * - 自动完单 + * - 人工提交结果完单 + * + * @author lzh + */ +@Tag(name = "安保工单") +@RestController +@RequestMapping("/ops/security/order") +@Validated +public class SecurityOrderController { + + @Resource + private SecurityOrderService securityOrderService; + + @PostMapping("/create") + @Operation(summary = "创建安保工单", description = "管理端创建安保工单,传入告警信息和区域,系统自动分配安保人员") + @PreAuthorize("@ss.hasPermission('ops:security-order:create')") + public CommonResult createOrder(@Valid @RequestBody SecurityOrderCreateReqVO reqVO) { + SecurityOrderCreateReqDTO dto = SecurityOrderCreateReqDTO.builder() + .title(reqVO.getTitle()) + .description(reqVO.getDescription()) + .priority(reqVO.getPriority()) + .areaId(reqVO.getAreaId()) + .location(reqVO.getLocation()) + .alarmId(reqVO.getAlarmId()) + .alarmType(reqVO.getAlarmType()) + .cameraId(reqVO.getCameraId()) + .roiId(reqVO.getRoiId()) + .imageUrl(reqVO.getImageUrl()) + .sourceType(reqVO.getSourceType()) + .build(); + Long orderId = securityOrderService.createSecurityOrder(dto); + return success(orderId); + } + + @PostMapping("/confirm") + @Operation(summary = "确认工单", description = "安保人员确认接单") + @PreAuthorize("@ss.hasPermission('ops:security-order:confirm')") + public CommonResult confirmOrder(@Valid @RequestBody SecurityOrderConfirmReqVO reqVO) { + securityOrderService.confirmOrder(reqVO.getOrderId(), reqVO.getUserId()); + return success(true); + } + + @PostMapping("/auto-complete") + @Operation(summary = "自动完单", description = "由外部系统调用,无需提交处理结果") + @PreAuthorize("@ss.hasPermission('ops:security-order:complete')") + public CommonResult autoCompleteOrder(@Valid @RequestBody SecurityOrderAutoCompleteReqVO reqVO) { + securityOrderService.autoCompleteOrder(reqVO.getOrderId(), reqVO.getRemark()); + return success(true); + } + + @PostMapping("/manual-complete") + @Operation(summary = "人工完单", description = "安保人员提交处理结果(result + 图片)完成工单") + @PreAuthorize("@ss.hasPermission('ops:security-order:complete')") + public CommonResult manualCompleteOrder(@Valid @RequestBody SecurityOrderCompleteReqVO reqVO) { + SecurityOrderCompleteReqDTO dto = SecurityOrderCompleteReqDTO.builder() + .orderId(reqVO.getOrderId()) + .result(reqVO.getResult()) + .resultImgUrls(reqVO.getResultImgUrls()) + .operatorId(SecurityFrameworkUtils.getLoginUserId()) + .build(); + securityOrderService.manualCompleteOrder(dto); + return success(true); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserBindReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserBindReqVO.java new file mode 100644 index 0000000..c5a6865 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserBindReqVO.java @@ -0,0 +1,33 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 区域安保人员绑定请求 VO + * + * @author lzh + */ +@Schema(description = "区域安保人员绑定请求") +@Data +public class OpsAreaSecurityUserBindReqVO { + + @Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + @NotNull(message = "区域ID不能为空") + private Long areaId; + + @Schema(description = "安保人员用户ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001") + @NotNull(message = "用户ID不能为空") + private Long userId; + + @Schema(description = "安保人员姓名", example = "张三") + private String userName; + + @Schema(description = "班组ID", example = "10") + private Long teamId; + + @Schema(description = "排序值", example = "0") + private Integer sort; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserRespVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserRespVO.java new file mode 100644 index 0000000..d733502 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserRespVO.java @@ -0,0 +1,41 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 区域安保人员绑定响应 VO + * + * @author lzh + */ +@Schema(description = "区域安保人员绑定响应") +@Data +public class OpsAreaSecurityUserRespVO { + + @Schema(description = "绑定记录ID", example = "1") + private Long id; + + @Schema(description = "区域ID", example = "100") + private Long areaId; + + @Schema(description = "安保人员用户ID", example = "2001") + private Long userId; + + @Schema(description = "安保人员姓名", example = "张三") + private String userName; + + @Schema(description = "班组ID", example = "10") + private Long teamId; + + @Schema(description = "是否启用", example = "true") + private Boolean enabled; + + @Schema(description = "排序值", example = "0") + private Integer sort; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserUpdateReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserUpdateReqVO.java new file mode 100644 index 0000000..aff9778 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/OpsAreaSecurityUserUpdateReqVO.java @@ -0,0 +1,29 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 区域安保人员绑定更新请求 VO + * + * @author lzh + */ +@Schema(description = "区域安保人员绑定更新请求") +@Data +public class OpsAreaSecurityUserUpdateReqVO { + + @Schema(description = "绑定记录ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "绑定记录ID不能为空") + private Long id; + + @Schema(description = "是否启用", example = "true") + private Boolean enabled; + + @Schema(description = "排序值", example = "0") + private Integer sort; + + @Schema(description = "班组ID", example = "10") + private Long teamId; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderAutoCompleteReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderAutoCompleteReqVO.java new file mode 100644 index 0000000..058e6b4 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderAutoCompleteReqVO.java @@ -0,0 +1,23 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单自动完单请求 VO + * + * @author lzh + */ +@Schema(description = "安保工单自动完单请求") +@Data +public class SecurityOrderAutoCompleteReqVO { + + @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-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCompleteReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCompleteReqVO.java new file mode 100644 index 0000000..1d4dd0b --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCompleteReqVO.java @@ -0,0 +1,30 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * 安保工单人工完单请求 VO + * + * @author lzh + */ +@Schema(description = "安保工单人工完单请求") +@Data +public class SecurityOrderCompleteReqVO { + + @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 result; + + @Schema(description = "处理结果图片URL列表", example = "[\"https://oss.example.com/result1.jpg\"]") + private List resultImgUrls; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderConfirmReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderConfirmReqVO.java new file mode 100644 index 0000000..8e04f6a --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderConfirmReqVO.java @@ -0,0 +1,24 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单确认请求 VO + * + * @author lzh + */ +@Schema(description = "安保工单确认请求") +@Data +public class SecurityOrderConfirmReqVO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + + @Schema(description = "安保人员user_id", requiredMode = Schema.RequiredMode.REQUIRED, example = "2001") + @NotNull(message = "用户ID不能为空") + private Long userId; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java new file mode 100644 index 0000000..3719314 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java @@ -0,0 +1,54 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单创建请求 VO + * + * @author lzh + */ +@Schema(description = "安保工单创建请求") +@Data +public class SecurityOrderCreateReqVO { + + @Schema(description = "工单标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "A栋3层入侵告警") + @NotBlank(message = "工单标题不能为空") + private String title; + + @Schema(description = "工单描述", example = "摄像头检测到异常人员入侵") + private String description; + + @Schema(description = "优先级(0=P0紧急 1=P1重要 2=P2普通)", example = "1") + private Integer priority; + + @Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + @NotNull(message = "区域ID不能为空") + private Long areaId; + + @Schema(description = "具体位置描述", example = "A栋3层东侧走廊") + private String location; + + // ==================== 告警来源 ==================== + + @Schema(description = "关联告警ID", example = "ALM20260211001") + private String alarmId; + + @Schema(description = "告警类型: intrusion/leave_post/fire/fence", example = "intrusion") + private String alarmType; + + @Schema(description = "摄像头ID", example = "CAM_001") + private String cameraId; + + @Schema(description = "ROI区域ID", example = "ROI_001") + private String roiId; + + @Schema(description = "告警截图URL", example = "https://oss.example.com/alarm/snapshot.jpg") + private String imageUrl; + + @Schema(description = "来源类型(ALARM=告警触发/MANUAL=手动创建)", example = "ALARM") + private String sourceType; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java new file mode 100644 index 0000000..fd550ca --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java @@ -0,0 +1,72 @@ +package com.viewsh.module.ops.controller.open.security; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.signature.core.annotation.ApiSignature; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderAutoCompleteReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCreateReqVO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCreateReqDTO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.annotation.security.PermitAll; +import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 安保工单 - 开放接口 + *

+ * 提供给外部告警系统调用,通过 {@link ApiSignature} 签名验证保护, + * 不走用户登录鉴权(Token)。 + *

+ * 实际路径前缀为 /open-api,由框架自动添加 + * + * @author lzh + */ +@Tag(name = "安保工单 - 开放接口") +@RestController +@RequestMapping("/ops/security/order") +@Validated +public class SecurityOrderOpenController { + + @Resource + private SecurityOrderService securityOrderService; + + @PostMapping("/create") + @Operation(summary = "创建安保工单", description = "由外部告警系统调用,传入告警信息和区域,系统自动分配安保人员") + @ApiSignature + @PermitAll + public CommonResult createOrder(@Valid @RequestBody SecurityOrderCreateReqVO reqVO) { + SecurityOrderCreateReqDTO dto = SecurityOrderCreateReqDTO.builder() + .title(reqVO.getTitle()) + .description(reqVO.getDescription()) + .priority(reqVO.getPriority()) + .areaId(reqVO.getAreaId()) + .location(reqVO.getLocation()) + .alarmId(reqVO.getAlarmId()) + .alarmType(reqVO.getAlarmType()) + .cameraId(reqVO.getCameraId()) + .roiId(reqVO.getRoiId()) + .imageUrl(reqVO.getImageUrl()) + .sourceType(reqVO.getSourceType()) + .build(); + Long orderId = securityOrderService.createSecurityOrder(dto); + return success(orderId); + } + + @PostMapping("/auto-complete") + @Operation(summary = "自动完单", description = "由外部告警系统调用,无需提交处理结果") + @ApiSignature + @PermitAll + public CommonResult autoCompleteOrder(@Valid @RequestBody SecurityOrderAutoCompleteReqVO req) { + securityOrderService.autoCompleteOrder(req.getOrderId(), req.getRemark()); + return success(true); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/security/config/SecurityConfiguration.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/security/config/SecurityConfiguration.java index bd01ea6..fee3468 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/security/config/SecurityConfiguration.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/security/config/SecurityConfiguration.java @@ -8,7 +8,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; /** - * IoT 模块的 Security 配置 + * Ops 模块的 Security 配置 */ @Configuration("opsSecurityConfiguration") public class SecurityConfiguration { @@ -31,6 +31,8 @@ public class SecurityConfiguration { registry.requestMatchers("/druid/**").anonymous(); // RPC 服务的安全配置 registry.requestMatchers(ApiConstants.PREFIX + "/**").permitAll(); + // Open API 接口放行(由 @ApiSignature 签名保护) + registry.requestMatchers(buildOpenApi("/**")).permitAll(); } }; -- 2.49.1 From 4a7128321eb71e43b9d62b41c61a964df39ccfad Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:33:55 +0800 Subject: [PATCH 06/17] =?UTF-8?q?feat(ops):=20=E5=AE=89=E4=BF=9D=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E6=9E=84=E5=BB=BA=E9=85=8D=E7=BD=AE=E4=B8=8E=E6=9E=9A?= =?UTF-8?q?=E4=B8=BE=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - security-biz pom 新增 ops-biz、iot-api 依赖 - ops-server pom 引入 security-biz 模块 - 新增 SECURITY_GUARD 操作人类型、ALARM 来源类型 - 新增安保相关错误码 - dev/local 配置新增安保数据源 Co-Authored-By: Claude Opus 4.6 --- .../module/ops/enums/ErrorCodeConstants.java | 6 ++++ .../module/ops/enums/OperatorTypeEnum.java | 3 +- .../module/ops/enums/SourceTypeEnum.java | 3 +- .../viewsh-module-ops-server/pom.xml | 28 +++++++++++-------- .../src/main/resources/application-dev.yaml | 5 ++++ .../src/main/resources/application-local.yaml | 5 ++++ .../viewsh-module-security-biz/pom.xml | 7 +++++ 7 files changed, 44 insertions(+), 13 deletions(-) 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 index ca60e2c..c6e7341 100644 --- 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 @@ -16,6 +16,12 @@ public interface ErrorCodeConstants { ErrorCode AREA_PARENT_LOOP = new ErrorCode(1_020_001_003, "不能将父级设置为自己或子孙节点"); ErrorCode AREA_CODE_EXISTS = new ErrorCode(1_020_001_004, "区域编码已存在"); + // ========== 安保工单 1-020-003-000 ============ + ErrorCode SECURITY_ORDER_NOT_FOUND = new ErrorCode(1_020_003_000, "工单不存在"); + ErrorCode SECURITY_ORDER_TYPE_MISMATCH = new ErrorCode(1_020_003_001, "工单类型不匹配,期望安保工单"); + ErrorCode SECURITY_AREA_USER_DUPLICATE = new ErrorCode(1_020_003_002, "该安保人员已绑定到此区域"); + ErrorCode SECURITY_AREA_USER_NOT_FOUND = new ErrorCode(1_020_003_003, "绑定记录不存在"); + // ========== 区域设备关联 1-020-002-000 ============ ErrorCode DEVICE_NOT_FOUND = new ErrorCode(1_020_002_000, "设备不存在"); ErrorCode DEVICE_ALREADY_BOUND = new ErrorCode(1_020_002_001, "该工牌已绑定至此区域"); diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/OperatorTypeEnum.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/OperatorTypeEnum.java index 740d3cb..9736241 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/OperatorTypeEnum.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/OperatorTypeEnum.java @@ -18,7 +18,8 @@ public enum OperatorTypeEnum implements ArrayValuable { SYSTEM("SYSTEM", "系统"), CLEANER("CLEANER", "保洁员"), INSPECTOR("INSPECTOR", "巡检员"), - ADMIN("ADMIN", "管理员"); + ADMIN("ADMIN", "管理员"), + SECURITY_GUARD("SECURITY_GUARD", "安保员"); public static final String[] ARRAYS = Arrays.stream(values()).map(OperatorTypeEnum::getType).toArray(String[]::new); diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/SourceTypeEnum.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/SourceTypeEnum.java index a358e8b..bd7b013 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/SourceTypeEnum.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/SourceTypeEnum.java @@ -18,7 +18,8 @@ public enum SourceTypeEnum implements ArrayValuable { TRAFFIC("TRAFFIC", "系统触发"), INSPECTION("INSPECTION", "巡检发现"), MANUAL("MANUAL", "手动创建"), - SCHEDULE("SCHEDULE", "定时排班"); + SCHEDULE("SCHEDULE", "定时排班"), + ALARM("ALARM", "告警触发"); public static final String[] ARRAYS = Arrays.stream(values()).map(SourceTypeEnum::getType).toArray(String[]::new); diff --git a/viewsh-module-ops/viewsh-module-ops-server/pom.xml b/viewsh-module-ops/viewsh-module-ops-server/pom.xml index 1ec8495..716a022 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/pom.xml +++ b/viewsh-module-ops/viewsh-module-ops-server/pom.xml @@ -91,6 +91,12 @@ viewsh-spring-boot-starter-security + + + com.viewsh + viewsh-spring-boot-starter-protection + + com.viewsh @@ -126,17 +132,17 @@ viewsh-spring-boot-starter-job - - - com.viewsh - viewsh-spring-boot-starter-mq - - - org.apache.rocketmq - rocketmq-spring-boot-starter - - - + + + com.viewsh + viewsh-spring-boot-starter-mq + + + org.apache.rocketmq + rocketmq-spring-boot-starter + + + com.viewsh viewsh-spring-boot-starter-test diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-dev.yaml b/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-dev.yaml index acc3c5d..451f5f4 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-dev.yaml +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-dev.yaml @@ -164,6 +164,11 @@ wx: # 芋道配置项,设置当前项目所有自定义的配置 viewsh: demo: true # 开启演示模式 + # API 签名配置:外部系统调用开放接口时使用(如安保工单的告警系统) + signature: + apps: + # 告警系统 - 用于安保工单的创建和自动完单接口 + alarm-system: "tQ3v5q1z2ZLu7hrU1yseaHwg1wJUcmF1" justauth: enabled: true diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-local.yaml b/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-local.yaml index 63f45f8..d1e926f 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-local.yaml +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/resources/application-local.yaml @@ -152,3 +152,8 @@ viewsh: mock-enable: true access-log: # 访问日志的配置项 enable: false + # API 签名配置:外部系统调用开放接口时使用(如安保工单的告警系统) + signature: + apps: + # 告警系统 - 用于安保工单的创建和自动完单接口 + alarm-system: "tQ3v5q1z2ZLu7hrU1yseaHwg1wJUcmF1" diff --git a/viewsh-module-ops/viewsh-module-security-biz/pom.xml b/viewsh-module-ops/viewsh-module-security-biz/pom.xml index b24d752..4203daf 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/pom.xml +++ b/viewsh-module-ops/viewsh-module-security-biz/pom.xml @@ -48,5 +48,12 @@ com.viewsh viewsh-spring-boot-starter-biz-tenant + + + + com.viewsh + viewsh-spring-boot-starter-test + test + -- 2.49.1 From fc9393e723f343c6a0994545f2d24f5a0d1a8ef1 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:34:08 +0800 Subject: [PATCH 07/17] =?UTF-8?q?refactor(ops):=20=E6=89=A9=E5=B1=95=20Log?= =?UTF-8?q?Type=20=E6=9E=9A=E4=B8=BE=EF=BC=8C=E8=A1=A5=E5=85=A8=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E4=B8=8E=20IoT=20?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BA=8B=E4=BB=B6=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ORDER_CREATED/CONFIRM/ARRIVED/COMPLETED 等工单生命周期枚举、 BEACON_ARRIVE_CONFIRMED/BEACON_COMPLETE_REQUESTED 等 IoT 审计枚举, 添加 getByCode() 反查方法支持中文 title 映射。 同步新增 LogModule 常量类收口模块标识。 Co-Authored-By: Claude Opus 4.6 --- .../log/enumeration/LogModule.java | 46 +++++++++++ .../log/enumeration/LogType.java | 80 +++++++++++-------- 2 files changed, 94 insertions(+), 32 deletions(-) create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogModule.java diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogModule.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogModule.java new file mode 100644 index 0000000..daebabf --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogModule.java @@ -0,0 +1,46 @@ +package com.viewsh.module.ops.infrastructure.log.enumeration; + +/** + * 日志模块常量 + *

+ * 用于 {@link com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord} 的 module 字段, + * 避免各业务线硬编码字符串。 + * + * @author lzh + */ +public final class LogModule { + + /** 保洁 */ + public static final String CLEAN = "clean"; + + /** 安保 */ + public static final String SECURITY = "security"; + + /** 工程 @since 2026-03-11 预留 */ + public static final String FACILITIES = "facilities"; + + /** 客服 @since 2026-03-11 预留 */ + public static final String SERVICE = "service"; + + /** + * 根据工单类型(orderType)返回对应的日志模块标识。 + * 未知类型降级返回 orderType 小写。 + * + * @param orderType 工单类型,如 "CLEAN"、"SECURITY" + * @return 日志模块标识 + */ + public static String fromOrderType(String orderType) { + if (orderType == null) { + return CLEAN; + } + return switch (orderType) { + case "CLEAN" -> CLEAN; + case "SECURITY" -> SECURITY; + case "REPAIR" -> FACILITIES; + case "SERVICE" -> SERVICE; + default -> orderType.toLowerCase(); + }; + } + + private LogModule() {} +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java index 93efae3..9fc32e9 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java @@ -1,5 +1,8 @@ package com.viewsh.module.ops.infrastructure.log.enumeration; +import java.util.HashMap; +import java.util.Map; + /** * 日志类型枚举 * @@ -7,45 +10,51 @@ package com.viewsh.module.ops.infrastructure.log.enumeration; */ public enum LogType { - /** - * 派单日志 - */ - DISPATCH("ORDER_DISPATCHED", "派单"), + // ========== 注解体系(@BusinessLog)使用 ========== - /** - * 状态转换日志 - */ - TRANSITION("ORDER_STATUS_CHANGED", "状态转换"), + /** 派单 */ + ORDER_DISPATCHED("ORDER_DISPATCHED", "派单"), + /** 系统事件(注解默认值) */ + SYSTEM_EVENT("SYSTEM_EVENT", "系统"), - /** - * 生命周期日志 - */ - LIFECYCLE("ORDER_LIFECYCLE", "生命周期"), + // ========== 工单生命周期 ========== - /** - * 队列日志 - */ - QUEUE("ORDER_QUEUE_CHANGED", "队列"), + ORDER_CREATED("ORDER_CREATED", "工单创建"), + ORDER_CONFIRM("ORDER_CONFIRM", "工单确认"), + ORDER_ARRIVED("ORDER_ARRIVED", "到岗确认"), + ORDER_COMPLETED("ORDER_COMPLETED", "工单完成"), + ORDER_INTERRUPTED("ORDER_INTERRUPTED", "工单打断"), + ORDER_CANCELLED("ORDER_CANCELLED", "工单取消"), + ORDER_PAUSED("ORDER_PAUSED", "工单暂停"), + ORDER_RESUMED("ORDER_RESUMED", "工单恢复"), - /** - * 保洁员日志 - */ - CLEANER("CLEANER_ACTION", "保洁员"), + // ========== 语音播报 ========== - /** - * 设备日志 - */ - DEVICE("DEVICE_ACTION", "设备"), + TTS_SENT("TTS_SENT", "语音播报"), + TTS_FAILED("TTS_FAILED", "播报失败"), - /** - * 通知日志 - */ - NOTIFICATION("NOTIFICATION_SENT", "通知"), + // ========== 优先级 & 静默 ========== - /** - * 系统日志 - */ - SYSTEM("SYSTEM_EVENT", "系统"); + PRIORITY_UPGRADE("PRIORITY_UPGRADE", "优先级升级"), + PRIORITY_CEILING("PRIORITY_CEILING", "优先级封顶"), + ARRIVED_SILENT_IGNORE("ARRIVED_SILENT_IGNORE", "静默忽略"), + + // ========== IoT 审计事件(来自 ops-order-audit MQ) ========== + + BEACON_ARRIVE_CONFIRMED("BEACON_ARRIVE_CONFIRMED", "信标到岗确认"), + BEACON_LEAVE_WARNING_SENT("BEACON_LEAVE_WARNING_SENT", "离开区域警告"), + COMPLETE_SUPPRESSED_INVALID("COMPLETE_SUPPRESSED_INVALID", "作业时长不足抑制"), + BEACON_COMPLETE_REQUESTED("BEACON_COMPLETE_REQUESTED", "信号丢失自动完成请求"), + TTS_REQUEST("TTS_REQUEST", "语音播报请求"), + ARRIVE_REJECTED("ARRIVE_REJECTED", "到岗请求被拒绝"); + + private static final Map CODE_MAP = new HashMap<>(); + + static { + for (LogType type : values()) { + CODE_MAP.put(type.code, type); + } + } private final String code; private final String description; @@ -62,4 +71,11 @@ public enum LogType { public String getDescription() { return description; } + + /** + * 根据 code 反查枚举,找不到返回 null + */ + public static LogType getByCode(String code) { + return CODE_MAP.get(code); + } } -- 2.49.1 From dc75c78d169b7291acd9c298fc65a7a29930b4fc Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:34:19 +0800 Subject: [PATCH 08/17] =?UTF-8?q?fix(ops):=20=E4=BF=AE=E5=A4=8D=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E6=97=A5=E5=BF=97=20title=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E8=8B=B1=E6=96=87=E5=B8=B8=E9=87=8F=EF=BC=8C=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /business-logs 接口 title 回退取 eventType 时,通过 LogType.getByCode() 映射中文 description 作为标题。同步调整 @BusinessLog 注解 type 属性 改用 LogType 枚举。 Co-Authored-By: Claude Opus 4.6 --- .../log/annotation/BusinessLog.java | 6 ++-- .../log/aspect/BusinessLogAspect.java | 29 +++++++++---------- .../service/order/OpsOrderServiceImpl.java | 7 +++-- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/annotation/BusinessLog.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/annotation/BusinessLog.java index e2790cc..1d16348 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/annotation/BusinessLog.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/annotation/BusinessLog.java @@ -16,14 +16,14 @@ import java.lang.annotation.*; *

  * {@code
  * // 基础用法(使用 LogType/LogScope)
- * @BusinessLog(type = LogType.DISPATCH, scope = LogScope.ORDER,
+ * @BusinessLog(type = LogType.ORDER_DISPATCHED, scope = LogScope.ORDER,
  *             description = "自动派单", includeParams = true)
  * public DispatchResult dispatch(OrderDispatchContext context) {
  *     // ...
  * }
  *
  * // 新用法(直接指定 EventDomain 和 Module)
- * @BusinessLog(module = "clean", domain = EventDomain.DEVICE,
+ * @BusinessLog(module = LogModule.CLEAN, domain = EventDomain.DEVICE,
  *             eventType = "TTS_SENT", description = "语音播报",
  *             deviceId = "#deviceId", personId = "#context.cleanerId")
  * public void broadcast(String text, Long deviceId) {
@@ -46,7 +46,7 @@ public @interface BusinessLog {
      * 

* 当指定了 domain 和 eventType 时,此字段可忽略 */ - LogType type() default LogType.SYSTEM; + LogType type() default LogType.SYSTEM_EVENT; /** * 日志作用域(旧版,保持兼容) diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/aspect/BusinessLogAspect.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/aspect/BusinessLogAspect.java index e4f692c..2c514ab 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/aspect/BusinessLogAspect.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/aspect/BusinessLogAspect.java @@ -330,21 +330,20 @@ public class BusinessLogAspect { return EventDomain.SYSTEM; } - switch (logType) { - case DISPATCH: - return EventDomain.DISPATCH; - case DEVICE: - return EventDomain.DEVICE; - case NOTIFICATION: - return EventDomain.DEVICE; - case CLEANER: - case SYSTEM: - case QUEUE: - case LIFECYCLE: - case TRANSITION: - default: - return EventDomain.SYSTEM; - } + return switch (logType) { + case ORDER_DISPATCHED, ORDER_INTERRUPTED, ORDER_PAUSED, ORDER_RESUMED -> + EventDomain.DISPATCH; + case ORDER_ARRIVED, ORDER_COMPLETED -> + EventDomain.BEACON; + case ORDER_CONFIRM -> + EventDomain.DEVICE; + case PRIORITY_UPGRADE, PRIORITY_CEILING, ARRIVED_SILENT_IGNORE -> + EventDomain.TRAFFIC; + case TTS_SENT, TTS_FAILED -> + EventDomain.DEVICE; + default -> + EventDomain.SYSTEM; + }; } /** diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/order/OpsOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/order/OpsOrderServiceImpl.java index 96330da..23cd956 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/order/OpsOrderServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/order/OpsOrderServiceImpl.java @@ -10,6 +10,7 @@ import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; import com.viewsh.module.ops.enums.OperatorTypeEnum; import com.viewsh.module.ops.enums.PriorityEnum; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.service.fsm.OrderStateMachine; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -364,10 +365,12 @@ public class OpsOrderServiceImpl implements OpsOrderService { } dto.setType(type); - // title: 优先使用 eventSummary,否则使用 eventType + // title: 优先使用 eventSummary,否则通过 LogType 枚举取中文描述 String title = logDO.getEventSummary(); if (title == null || title.isEmpty()) { - title = logDO.getEventType() != null ? logDO.getEventType() : "工单操作"; + LogType logType = LogType.getByCode(logDO.getEventType()); + title = logType != null ? logType.getDescription() + : (logDO.getEventType() != null ? logDO.getEventType() : "工单操作"); } dto.setTitle(title); -- 2.49.1 From 5f804605c7b7a0492d4135900233fbde91bd5969 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:34:30 +0800 Subject: [PATCH 09/17] =?UTF-8?q?refactor(ops):=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E6=95=A3=E8=90=BD=E7=9A=84=20eventType=20=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E4=B8=BA=20LogType=20=E6=9E=9A=E4=B8=BE=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 替换 CleanOrderCreateEventHandler、OrderLifecycleManagerImpl、 DispatchEngineImpl 中的字符串常量为 LogType.XXX.getCode(), 同时将 DispatchEngine 的 @BusinessLog description 改为"工单自动派发"。 Co-Authored-By: Claude Opus 4.6 --- .../consumer/CleanOrderCreateEventHandler.java | 18 ++++++++++-------- .../ops/core/dispatch/DispatchEngineImpl.java | 4 ++-- .../lifecycle/OrderLifecycleManagerImpl.java | 18 ++++++++++-------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java index 33ecab7..2e8640e 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java @@ -14,6 +14,8 @@ import com.viewsh.module.ops.environment.integration.dto.CleanOrderCreateEventDT import com.viewsh.module.ops.environment.service.cleanorder.CleanOrderService; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; import com.viewsh.module.ops.infrastructure.log.enumeration.EventLevel; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import jakarta.annotation.Resource; @@ -318,9 +320,9 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { } eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(domain) - .eventType("ORDER_CREATED") + .eventType(LogType.ORDER_CREATED.getCode()) .message(buildLogMessage(event, createReq)) .targetId(orderId) .targetType("order") @@ -346,9 +348,9 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { extra.put("reason", "客流持续达标自动升级"); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.TRAFFIC) - .eventType("PRIORITY_UPGRADE") + .eventType(LogType.PRIORITY_UPGRADE.getCode()) .message(String.format("客流持续达标,工单优先级升级至%s [区域:%d]", newPriority.getDescription(), event.getAreaId())) .targetId(orderId) @@ -374,9 +376,9 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { extra.put("reason", "已是P0最高优先级,无法继续升级"); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.TRAFFIC) - .eventType("PRIORITY_CEILING") + .eventType(LogType.PRIORITY_CEILING.getCode()) .message(String.format("客流持续达标但工单已是P0封顶 [区域:%d]", event.getAreaId())) .targetId(orderId) .targetType("order") @@ -401,9 +403,9 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { extra.put("reason", "保洁员已在处理中,客流触发静默忽略"); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.TRAFFIC) - .eventType("ARRIVED_SILENT_IGNORE") + .eventType(LogType.ARRIVED_SILENT_IGNORE.getCode()) .message(String.format("保洁员已在处理中,客流触发静默忽略 [区域:%d]", event.getAreaId())) .targetId(orderId) .targetType("order") diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java index 2365319..f109557 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/dispatch/DispatchEngineImpl.java @@ -77,9 +77,9 @@ public class DispatchEngineImpl implements DispatchEngine { @Override @BusinessLog( - type = LogType.DISPATCH, + type = LogType.ORDER_DISPATCHED, scope = LogScope.ORDER, - description = "工单调度", + description = "工单自动派发", includeParams = true, includeResult = true, result = "#result.success", diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java index 10f86af..e91d099 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java @@ -13,6 +13,8 @@ import com.viewsh.module.ops.enums.OperatorTypeEnum; import com.viewsh.module.ops.enums.PriorityEnum; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import jakarta.annotation.PostConstruct; @@ -180,7 +182,7 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { } // 记录业务日志 - recordStatusChangeLog(orderId, result, "ORDER_PAUSED", "工单暂停"); + recordStatusChangeLog(orderId, result, LogType.ORDER_PAUSED.getCode(), "工单暂停"); } @Override @@ -205,7 +207,7 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { } // 记录业务日志 - recordStatusChangeLog(orderId, result, "ORDER_RESUMED", "工单恢复"); + recordStatusChangeLog(orderId, result, LogType.ORDER_RESUMED.getCode(), "工单恢复"); } // ==================== 打断/恢复 ==================== @@ -240,9 +242,9 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { OpsOrderDO order = opsOrderMapper.selectById(orderId); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.fromOrderType(order != null ? order.getOrderType() : null)) .domain(EventDomain.DISPATCH) - .eventType("ORDER_INTERRUPTED") + .eventType(LogType.ORDER_INTERRUPTED.getCode()) .message("工单被P0紧急任务打断") .targetId(orderId) .targetType("order") @@ -296,7 +298,7 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { // 注意:IoT 触发的自动完成在 CleanOrderCompleteEventHandler 中记录日志 // 管理员手动完成时记录日志 if (operatorType == OperatorTypeEnum.ADMIN) { - recordStatusChangeLog(orderId, result, "ORDER_COMPLETED_MANUAL", "工单手动完成"); + recordStatusChangeLog(orderId, result, LogType.ORDER_COMPLETED.getCode(), "工单手动完成"); } } @@ -332,9 +334,9 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { extra.put("operatorType", operatorType != null ? operatorType.getType() : "SYSTEM"); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.fromOrderType(order != null ? order.getOrderType() : null)) .domain(EventDomain.SYSTEM) - .eventType("ORDER_CANCELLED") + .eventType(LogType.ORDER_CANCELLED.getCode()) .message("工单已取消: " + reason) .targetId(orderId) .targetType("order") @@ -426,7 +428,7 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { extra.put("newStatus", result.getNewStatus() != null ? result.getNewStatus().getStatus() : null); eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.fromOrderType(order.getOrderType())) .domain(EventDomain.DISPATCH) .eventType(eventType) .message(message) -- 2.49.1 From 6c8c57b932e65c3af557fd910c465f9fa67d0788 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:34:42 +0800 Subject: [PATCH 10/17] =?UTF-8?q?fix(ops):=20=E4=BF=9D=E6=B4=81=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E6=97=A5=E5=BF=97=E5=8E=BB=E9=87=8D=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=88=B0=E5=B2=97/=E5=AE=8C=E6=88=90=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=AE=BE=E5=A4=87=E5=AD=97=E6=AE=B5=E4=B8=BA=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuditEventHandler 跳过 BEACON_ARRIVE_CONFIRMED 和 BEACON_COMPLETE_REQUESTED 审计事件,避免与状态变更日志重复 - recordOrderArrivedLog 当 payload 无 deviceKey 时从工单主表兜底, null 字段不再输出 - recordOrderCompletedLog 同样增加 deviceKey 兜底逻辑 Co-Authored-By: Claude Opus 4.6 --- .../consumer/CleanOrderAuditEventHandler.java | 32 +++++++---- .../listener/CleanOrderEventListener.java | 55 ++++++++++++++++--- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderAuditEventHandler.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderAuditEventHandler.java index 8c696e8..051bfb6 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderAuditEventHandler.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderAuditEventHandler.java @@ -11,6 +11,8 @@ import com.viewsh.module.ops.environment.service.voice.TtsQueueMessage; import com.viewsh.module.ops.environment.service.voice.VoiceBroadcastService; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; import com.viewsh.module.ops.infrastructure.log.enumeration.EventLevel; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import jakarta.annotation.Resource; @@ -112,15 +114,25 @@ public class CleanOrderAuditEventHandler implements RocketMQListener { return; } - // 1. 确定日志级别和域 - EventDomain domain = determineDomain(event.getAuditType()); - EventLevel level = determineLevel(event.getAuditType()); - String eventType = event.getAuditType() != null ? event.getAuditType() : "AUDIT"; + // 1. 跳过与状态变更日志重复的审计事件(到岗确认/自动完成请求已由 CleanOrderEventListener 记录) + String auditType = event.getAuditType(); + if (LogType.BEACON_ARRIVE_CONFIRMED.getCode().equals(auditType) + || LogType.BEACON_COMPLETE_REQUESTED.getCode().equals(auditType)) { + log.debug("[CleanOrderAuditEventHandler] 跳过重复审计事件: eventId={}, auditType={}", + event.getEventId(), auditType); + return; + } - // 2. 记录审计日志 + // 2. 确定日志级别和域 + EventDomain domain = determineDomain(auditType); + EventLevel level = determineLevel(auditType); + LogType logType = auditType != null ? LogType.getByCode(auditType) : null; + String eventType = logType != null ? logType.getCode() : (auditType != null ? auditType : "AUDIT"); + + // 3. 记录审计日志 eventLogRecorder.record( EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(domain) .eventType(eventType) .message(event.getMessage()) @@ -132,10 +144,10 @@ public class CleanOrderAuditEventHandler implements RocketMQListener { ); log.debug("[CleanOrderAuditEventHandler] 审计日志已记录: eventId={}, auditType={}", - event.getEventId(), event.getAuditType()); + event.getEventId(), auditType); - // 3. 如果是 TTS 请求,调用 IoT 模块下发语音 - if ("TTS_REQUEST".equals(event.getAuditType()) && event.getDeviceId() != null) { + // 2. 如果是 TTS 请求,调用 IoT 模块下发语音 + if (LogType.TTS_REQUEST.getCode().equals(auditType) && event.getDeviceId() != null) { handleTtsRequest(event); } } @@ -227,7 +239,7 @@ public class CleanOrderAuditEventHandler implements RocketMQListener { } if (auditType.startsWith("BEACON_") || auditType.contains("BEACON")) { return EventDomain.BEACON; - } else if (auditType.equals("TTS_REQUEST")) { + } else if (LogType.TTS_REQUEST.getCode().equals(auditType)) { return EventDomain.DEVICE; } else { return EventDomain.AUDIT; diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java index 785cd29..df3e0db 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java @@ -20,6 +20,8 @@ import com.viewsh.module.ops.environment.service.cleanorder.CleanOrderService; import com.viewsh.module.ops.environment.service.voice.TtsQueueMessage; import com.viewsh.module.ops.environment.service.voice.VoiceBroadcastService; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import com.viewsh.module.system.api.notify.NotifyMessageSendApi; @@ -930,9 +932,9 @@ public class CleanOrderEventListener { private void recordOrderConfirmedLog(Long orderId, Long deviceId, OrderStateChangedEvent event) { try { eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.DEVICE) - .eventType("ORDER_CONFIRM") + .eventType(LogType.ORDER_CONFIRM.getCode()) .message("工单已确认 (工牌按键)") .targetId(orderId) .targetType("order") @@ -954,12 +956,37 @@ public class CleanOrderEventListener { String deviceKey = (String) event.getPayload().get("deviceKey"); String beaconMac = (String) event.getPayload().get("beaconMac"); + // 兜底:payload 中没有 deviceKey 时从工单主表取 + if (deviceKey == null && orderId != null) { + OpsOrderDO order = opsOrderMapper.selectById(orderId); + if (order != null) { + deviceKey = order.getAssigneeDeviceKey(); + } + } + + // 构建可读消息,跳过值为 null 的字段 + StringBuilder msgBuilder = new StringBuilder("蓝牙信标自动到岗确认"); + StringBuilder detail = new StringBuilder(); + if (deviceKey != null) { + detail.append("设备:").append(deviceKey); + } + if (areaId != null) { + if (detail.length() > 0) detail.append(", "); + detail.append("区域:").append(areaId); + } + if (beaconMac != null) { + if (detail.length() > 0) detail.append(", "); + detail.append("信标:").append(beaconMac); + } + if (detail.length() > 0) { + msgBuilder.append(" [").append(detail).append("]"); + } + eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.BEACON) - .eventType("ORDER_ARRIVED") - .message(String.format("蓝牙信标自动到岗确认 [设备:%s, 区域:%d, 信标:%s]", - deviceKey, areaId, beaconMac)) + .eventType(LogType.ORDER_ARRIVED.getCode()) + .message(msgBuilder.toString()) .targetId(orderId) .targetType("order") .deviceId(deviceId) @@ -980,6 +1007,14 @@ public class CleanOrderEventListener { String triggerSource = (String) event.getPayload().get("triggerSource"); String deviceKey = (String) event.getPayload().get("deviceKey"); + // 兜底:payload 中没有 deviceKey 时从工单主表取 + if (deviceKey == null && orderId != null) { + OpsOrderDO order = opsOrderMapper.selectById(orderId); + if (order != null) { + deviceKey = order.getAssigneeDeviceKey(); + } + } + // 构建日志消息 String message = "工单已完成"; if ("SIGNAL_LOSS_TIMEOUT".equals(triggerSource)) { @@ -989,13 +1024,15 @@ public class CleanOrderEventListener { long durationMinutes = ((Number) durationMs).longValue() / 60000; durationInfo = String.format(",作业时长: %d分钟", durationMinutes); } - message = "信号丢失超时自动完成 [设备:" + deviceKey + durationInfo + "]"; + message = "信号丢失超时自动完成" + + (deviceKey != null ? " [设备:" + deviceKey + durationInfo + "]" + : (durationInfo.isEmpty() ? "" : " [" + durationInfo.substring(1) + "]")); } EventLogRecord.EventLogRecordBuilder builder = EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.BEACON) - .eventType("ORDER_COMPLETED") + .eventType(LogType.ORDER_COMPLETED.getCode()) .message(message) .targetId(orderId) .targetType("order"); -- 2.49.1 From 0345d0fe396baa470a65d060330bb4a540726f7e Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:34:53 +0800 Subject: [PATCH 11/17] =?UTF-8?q?fix(ops):=20TTS=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=8E=BB=E9=99=A4=E5=86=97=E4=BD=99"?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E6=92=AD=E6=8A=A5:"=E5=89=8D=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VoiceBroadcastService 和 TtsQueueConsumer 记录 TTS_SENT 日志时 直接使用播报文本内容,title 由 LogType.TTS_SENT 的 description "语音播报"提供,避免 message 中重复出现。 Co-Authored-By: Claude Opus 4.6 --- .../service/voice/TtsQueueConsumer.java | 10 ++++++---- .../service/voice/VoiceBroadcastService.java | 16 +++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/TtsQueueConsumer.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/TtsQueueConsumer.java index 755c834..94a9635 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/TtsQueueConsumer.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/TtsQueueConsumer.java @@ -5,6 +5,8 @@ import com.viewsh.module.iot.api.device.IotDeviceControlApi; import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; import com.viewsh.module.ops.infrastructure.log.enumeration.EventLevel; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import cn.hutool.core.map.MapUtil; @@ -315,8 +317,8 @@ public class TtsQueueConsumer { // 记录日志(循环消息只在启动时记录一次,重复播报不再写日志) if (message.getOrderId() != null && !message.isLoopable()) { - eventLogRecorder.info("clean", EventDomain.DEVICE, "TTS_SENT", - "语音播报: " + message.getText(), message.getOrderId(), message.getDeviceId(), null); + eventLogRecorder.info(LogModule.CLEAN, EventDomain.DEVICE, LogType.TTS_SENT.getCode(), + message.getText(), message.getOrderId(), message.getDeviceId(), null); } return true; @@ -327,9 +329,9 @@ public class TtsQueueConsumer { // 记录错误日志 eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.DEVICE) - .eventType("TTS_FAILED") + .eventType(LogType.TTS_FAILED.getCode()) .message("语音播报失败: " + e.getMessage()) .targetId(message.getOrderId()) .targetType("order") diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/VoiceBroadcastService.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/VoiceBroadcastService.java index 751d484..ce3753e 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/VoiceBroadcastService.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/voice/VoiceBroadcastService.java @@ -5,6 +5,8 @@ import com.viewsh.module.iot.api.device.IotDeviceControlApi; import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; import com.viewsh.module.ops.infrastructure.log.enumeration.EventLevel; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; +import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; import jakarta.annotation.Resource; @@ -221,18 +223,18 @@ public class VoiceBroadcastService { private void recordLog(Long deviceId, String text, Long orderId, boolean success, Exception e) { if (success) { if (orderId != null) { - eventLogRecorder.info("clean", EventDomain.DEVICE, "TTS_SENT", - "语音播报: " + text, orderId, deviceId, null); + eventLogRecorder.info(LogModule.CLEAN, EventDomain.DEVICE, LogType.TTS_SENT.getCode(), + text, orderId, deviceId, null); } else { - eventLogRecorder.info("clean", EventDomain.DEVICE, "TTS_SENT", - "语音播报: " + text, deviceId); + eventLogRecorder.info(LogModule.CLEAN, EventDomain.DEVICE, LogType.TTS_SENT.getCode(), + text, deviceId); } } else { if (orderId != null) { eventLogRecorder.record(EventLogRecord.builder() - .module("clean") + .module(LogModule.CLEAN) .domain(EventDomain.DEVICE) - .eventType("TTS_FAILED") + .eventType(LogType.TTS_FAILED.getCode()) .message("语音播报失败: " + (e != null ? e.getMessage() : "unknown")) .targetId(orderId) .targetType("order") @@ -240,7 +242,7 @@ public class VoiceBroadcastService { .level(EventLevel.ERROR) .build()); } else { - eventLogRecorder.error("clean", EventDomain.DEVICE, "TTS_FAILED", + eventLogRecorder.error(LogModule.CLEAN, EventDomain.DEVICE, LogType.TTS_FAILED.getCode(), "语音播报失败: " + (e != null ? e.getMessage() : "unknown"), deviceId, e); } } -- 2.49.1 From 6e56dcb6a229f4d02fe7325f75d5aecbc0e4d0d0 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 11 Mar 2026 17:35:05 +0800 Subject: [PATCH 12/17] =?UTF-8?q?feat(framework):=20API=20=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E3=80=81=E5=AE=89=E5=85=A8=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=20Web=20=E9=85=8D=E7=BD=AE=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ApiSignatureProperties 配置类 - 调整签名自动配置与 Redis DAO 实现 - 更新安全白名单与 Web 属性配置 - 网关新增安保模块路由配置 Co-Authored-By: Claude Opus 4.6 --- .../config/ApiSignatureProperties.java | 32 ++ .../ViewshApiSignatureAutoConfiguration.java | 62 ++-- .../core/redis/ApiSignatureRedisDAO.java | 135 ++++--- .../config/AuthorizeRequestsCustomizer.java | 74 ++-- .../config/ViewshWebAutoConfiguration.java | 343 +++++++++--------- .../framework/web/config/WebProperties.java | 134 +++---- .../src/main/resources/application.yaml | 4 + 7 files changed, 427 insertions(+), 357 deletions(-) create mode 100644 viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ApiSignatureProperties.java diff --git a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ApiSignatureProperties.java b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ApiSignatureProperties.java new file mode 100644 index 0000000..4cd1895 --- /dev/null +++ b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ApiSignatureProperties.java @@ -0,0 +1,32 @@ +package com.viewsh.framework.signature.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Map; + +/** + * API 签名配置属性 + *

+ * 支持在 application.yaml 中配置 appId/appSecret,应用启动时自动加载到 Redis。 + * + *

+ * viewsh:
+ *   signature:
+ *     apps:
+ *       alarm-system: "your-app-secret"
+ *       third-party:  "another-secret"
+ * 
+ * + * @author lzh + */ +@ConfigurationProperties(prefix = "viewsh.signature") +@Data +public class ApiSignatureProperties { + + /** + * 签名应用列表:appId → appSecret + */ + private Map apps; + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java index 9aa434c..9f1d066 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java +++ b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java @@ -1,28 +1,34 @@ -package com.viewsh.framework.signature.config; - -import com.viewsh.framework.redis.config.ViewshRedisAutoConfiguration; -import com.viewsh.framework.signature.core.aop.ApiSignatureAspect; -import com.viewsh.framework.signature.core.redis.ApiSignatureRedisDAO; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.data.redis.core.StringRedisTemplate; - -/** - * HTTP API 签名的自动配置类 - * - * @author Zhougang - */ -@AutoConfiguration(after = ViewshRedisAutoConfiguration.class) -public class ViewshApiSignatureAutoConfiguration { - - @Bean - public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) { - return new ApiSignatureAspect(signatureRedisDAO); - } - - @Bean - public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) { - return new ApiSignatureRedisDAO(stringRedisTemplate); - } - -} +package com.viewsh.framework.signature.config; + +import com.viewsh.framework.redis.config.ViewshRedisAutoConfiguration; +import com.viewsh.framework.signature.core.aop.ApiSignatureAspect; +import com.viewsh.framework.signature.core.redis.ApiSignatureRedisDAO; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * HTTP API 签名的自动配置类 + * + * @author Zhougang + */ +@AutoConfiguration(after = ViewshRedisAutoConfiguration.class) +@EnableConfigurationProperties(ApiSignatureProperties.class) +public class ViewshApiSignatureAutoConfiguration { + + @Bean + public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) { + return new ApiSignatureAspect(signatureRedisDAO); + } + + @Bean + public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate, + ApiSignatureProperties properties) { + ApiSignatureRedisDAO dao = new ApiSignatureRedisDAO(stringRedisTemplate); + // 启动时将配置文件中的 appId/appSecret 同步到 Redis + dao.initApps(properties.getApps()); + return dao; + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java index f228d7d..8450d50 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java +++ b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java @@ -1,57 +1,78 @@ -package com.viewsh.framework.signature.core.redis; - -import lombok.AllArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; - -import java.util.concurrent.TimeUnit; - -/** - * HTTP API 签名 Redis DAO - * - * @author Zhougang - */ -@AllArgsConstructor -public class ApiSignatureRedisDAO { - - private final StringRedisTemplate stringRedisTemplate; - - /** - * 验签随机数 - *

- * KEY 格式:signature_nonce:%s // 参数为 随机数 - * VALUE 格式:String - * 过期时间:不固定 - */ - private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s"; - - /** - * 签名密钥 - *

- * HASH 结构 - * KEY 格式:%s // 参数为 appid - * VALUE 格式:String - * 过期时间:永不过期(预加载到 Redis) - */ - private static final String SIGNATURE_APPID = "api_signature_app"; - - // ========== 验签随机数 ========== - - public String getNonce(String appId, String nonce) { - return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce)); - } - - public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) { - return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit); - } - - private static String formatNonceKey(String appId, String nonce) { - return String.format(SIGNATURE_NONCE, appId, nonce); - } - - // ========== 签名密钥 ========== - - public String getAppSecret(String appId) { - return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId); - } - -} +package com.viewsh.framework.signature.core.redis; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * HTTP API 签名 Redis DAO + * + * @author Zhougang + */ +@AllArgsConstructor +@Slf4j +public class ApiSignatureRedisDAO { + + private final StringRedisTemplate stringRedisTemplate; + + /** + * 验签随机数 + *

+ * KEY 格式:signature_nonce:%s // 参数为 随机数 + * VALUE 格式:String + * 过期时间:不固定 + */ + private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s"; + + /** + * 签名密钥 + *

+ * HASH 结构 + * KEY 格式:%s // 参数为 appid + * VALUE 格式:String + * 过期时间:永不过期(预加载到 Redis) + */ + private static final String SIGNATURE_APPID = "api_signature_app"; + + // ========== 验签随机数 ========== + + public String getNonce(String appId, String nonce) { + return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce)); + } + + public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) { + return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit); + } + + private static String formatNonceKey(String appId, String nonce) { + return String.format(SIGNATURE_NONCE, appId, nonce); + } + + // ========== 签名密钥 ========== + + public String getAppSecret(String appId) { + return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId); + } + + /** + * 从配置文件加载 appId/appSecret 到 Redis + *

+ * 先删除整个 Hash Key 再写入,确保 YAML 中移除的应用不会残留在 Redis 中。 + * + * @param apps appId → appSecret 映射 + */ + public void initApps(Map apps) { + if (apps == null || apps.isEmpty()) { + stringRedisTemplate.delete(SIGNATURE_APPID); + log.info("[initApps][配置为空,已清除 Redis 中的签名应用]"); + return; + } + stringRedisTemplate.delete(SIGNATURE_APPID); + stringRedisTemplate.opsForHash().putAll(SIGNATURE_APPID, apps); + log.info("[initApps][从配置文件加载 {} 个签名应用到 Redis: {}]", apps.size(), apps.keySet()); + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java index af00f3e..e21b328 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java +++ b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java @@ -1,35 +1,39 @@ -package com.viewsh.framework.security.config; - -import com.viewsh.framework.web.config.WebProperties; -import jakarta.annotation.Resource; -import org.springframework.core.Ordered; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; - -/** - * 自定义的 URL 的安全配置 - * 目的:每个 Maven Module 可以自定义规则! - * - * @author 芋道源码 - */ -public abstract class AuthorizeRequestsCustomizer - implements Customizer.AuthorizationManagerRequestMatcherRegistry>, Ordered { - - @Resource - private WebProperties webProperties; - - protected String buildAdminApi(String url) { - return webProperties.getAdminApi().getPrefix() + url; - } - - protected String buildAppApi(String url) { - return webProperties.getAppApi().getPrefix() + url; - } - - @Override - public int getOrder() { - return 0; - } - -} +package com.viewsh.framework.security.config; + +import com.viewsh.framework.web.config.WebProperties; +import jakarta.annotation.Resource; +import org.springframework.core.Ordered; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; + +/** + * 自定义的 URL 的安全配置 + * 目的:每个 Maven Module 可以自定义规则! + * + * @author 芋道源码 + */ +public abstract class AuthorizeRequestsCustomizer + implements Customizer.AuthorizationManagerRequestMatcherRegistry>, Ordered { + + @Resource + private WebProperties webProperties; + + protected String buildAdminApi(String url) { + return webProperties.getAdminApi().getPrefix() + url; + } + + protected String buildAppApi(String url) { + return webProperties.getAppApi().getPrefix() + url; + } + + protected String buildOpenApi(String url) { + return webProperties.getOpenApi().getPrefix() + url; + } + + @Override + public int getOrder() { + return 0; + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java index 569ae64..4ccbf08 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java @@ -1,171 +1,172 @@ -package com.viewsh.framework.web.config; - -import cn.hutool.core.util.StrUtil; -import com.viewsh.framework.common.biz.infra.logger.ApiErrorLogCommonApi; -import com.viewsh.framework.common.enums.WebFilterOrderEnum; -import com.viewsh.framework.web.core.filter.CacheRequestBodyFilter; -import com.viewsh.framework.web.core.filter.DemoFilter; -import com.viewsh.framework.web.core.handler.GlobalExceptionHandler; -import com.viewsh.framework.web.core.handler.GlobalResponseBodyHandler; -import com.viewsh.framework.web.core.util.WebFrameworkUtils; -import com.google.common.collect.Maps; -import jakarta.servlet.Filter; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; -import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.cloud.client.loadbalancer.LoadBalanced; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Primary; -import org.springframework.core.annotation.Order; -import org.springframework.util.AntPathMatcher; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.web.filter.CorsFilter; -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; - -import java.util.Map; -import java.util.function.Predicate; - -@AutoConfiguration(beforeName = { - "com.fhs.trans.config.TransServiceConfig" // cloud 独有:避免一键改包后,RestTemplate 初始化的冲突。可见 https://t.zsxq.com/T4yj7 帖子 -}) -@EnableConfigurationProperties(WebProperties.class) -public class ViewshWebAutoConfiguration { - - /** - * 应用名 - */ - @Value("${spring.application.name}") - private String applicationName; - - @Bean - public WebMvcRegistrations webMvcRegistrations(WebProperties webProperties) { - return new WebMvcRegistrations() { - - @Override - public RequestMappingHandlerMapping getRequestMappingHandlerMapping() { - RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping(); - // 实例化时就带上前缀 - mapping.setPathPrefixes(buildPathPrefixes(webProperties)); - return mapping; - } - - /** - * 构建 prefix → 匹配条件的映射 - */ - private Map>> buildPathPrefixes(WebProperties webProperties) { - AntPathMatcher antPathMatcher = new AntPathMatcher("."); - Map>> pathPrefixes = Maps.newLinkedHashMapWithExpectedSize(2); - putPathPrefix(pathPrefixes, webProperties.getAdminApi(), antPathMatcher); - putPathPrefix(pathPrefixes, webProperties.getAppApi(), antPathMatcher); - return pathPrefixes; - } - - /** - * 设置 API 前缀,仅仅匹配 controller 包下的 - */ - private void putPathPrefix(Map>> pathPrefixes, WebProperties.Api api, AntPathMatcher matcher) { - if (api == null || StrUtil.isEmpty(api.getPrefix())) { - return; - } - pathPrefixes.put(api.getPrefix(), // api 前缀 - clazz -> clazz.isAnnotationPresent(RestController.class) - && matcher.match(api.getController(), clazz.getPackage().getName())); - } - - }; - } - - @Bean - @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") - public GlobalExceptionHandler globalExceptionHandler(ApiErrorLogCommonApi apiErrorLogApi) { - return new GlobalExceptionHandler(applicationName, apiErrorLogApi); - } - - @Bean - public GlobalResponseBodyHandler globalResponseBodyHandler() { - return new GlobalResponseBodyHandler(); - } - - @Bean - @SuppressWarnings("InstantiationOfUtilityClass") - public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) { - // 由于 WebFrameworkUtils 需要使用到 webProperties 属性,所以注册为一个 Bean - return new WebFrameworkUtils(webProperties); - } - - // ========== Filter 相关 ========== - - /** - * 创建 CorsFilter Bean,解决跨域问题 - */ - @Bean - @Order(value = WebFilterOrderEnum.CORS_FILTER) // 特殊:修复因执行顺序影响到跨域配置不生效问题 - public FilterRegistrationBean corsFilterBean() { - // 创建 CorsConfiguration 对象 - CorsConfiguration config = new CorsConfiguration(); - config.setAllowCredentials(true); - config.addAllowedOriginPattern("*"); // 设置访问源地址 - config.addAllowedHeader("*"); // 设置访问源请求头 - config.addAllowedMethod("*"); // 设置访问源请求方法 - // 创建 UrlBasedCorsConfigurationSource 对象 - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); // 对接口配置跨域设置 - return createFilterBean(new CorsFilter(source), WebFilterOrderEnum.CORS_FILTER); - } - - /** - * 创建 RequestBodyCacheFilter Bean,可重复读取请求内容 - */ - @Bean - public FilterRegistrationBean requestBodyCacheFilter() { - return createFilterBean(new CacheRequestBodyFilter(), WebFilterOrderEnum.REQUEST_BODY_CACHE_FILTER); - } - - /** - * 创建 DemoFilter Bean,演示模式 - */ - @Bean - @ConditionalOnProperty(value = "viewsh.demo", havingValue = "true") - public FilterRegistrationBean demoFilter() { - return createFilterBean(new DemoFilter(), WebFilterOrderEnum.DEMO_FILTER); - } - - public static FilterRegistrationBean createFilterBean(T filter, Integer order) { - FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); - bean.setOrder(order); - return bean; - } - - /** - * 创建 RestTemplate 实例 - * - * @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder} - */ - @Bean - @ConditionalOnMissingBean - @Primary - public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { - return restTemplateBuilder.build(); - } - - /** - * 创建 RestTemplate 实例(支持负载均衡) - * - * @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder} - */ - @Bean - @LoadBalanced - public RestTemplate loadBalancedRestTemplate(RestTemplateBuilder restTemplateBuilder) { - return restTemplateBuilder.build(); - } - -} +package com.viewsh.framework.web.config; + +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.common.biz.infra.logger.ApiErrorLogCommonApi; +import com.viewsh.framework.common.enums.WebFilterOrderEnum; +import com.viewsh.framework.web.core.filter.CacheRequestBodyFilter; +import com.viewsh.framework.web.core.filter.DemoFilter; +import com.viewsh.framework.web.core.handler.GlobalExceptionHandler; +import com.viewsh.framework.web.core.handler.GlobalResponseBodyHandler; +import com.viewsh.framework.web.core.util.WebFrameworkUtils; +import com.google.common.collect.Maps; +import jakarta.servlet.Filter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.cloud.client.loadbalancer.LoadBalanced; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.annotation.Order; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.util.Map; +import java.util.function.Predicate; + +@AutoConfiguration(beforeName = { + "com.fhs.trans.config.TransServiceConfig" // cloud 独有:避免一键改包后,RestTemplate 初始化的冲突。可见 https://t.zsxq.com/T4yj7 帖子 +}) +@EnableConfigurationProperties(WebProperties.class) +public class ViewshWebAutoConfiguration { + + /** + * 应用名 + */ + @Value("${spring.application.name}") + private String applicationName; + + @Bean + public WebMvcRegistrations webMvcRegistrations(WebProperties webProperties) { + return new WebMvcRegistrations() { + + @Override + public RequestMappingHandlerMapping getRequestMappingHandlerMapping() { + RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping(); + // 实例化时就带上前缀 + mapping.setPathPrefixes(buildPathPrefixes(webProperties)); + return mapping; + } + + /** + * 构建 prefix → 匹配条件的映射 + */ + private Map>> buildPathPrefixes(WebProperties webProperties) { + AntPathMatcher antPathMatcher = new AntPathMatcher("."); + Map>> pathPrefixes = Maps.newLinkedHashMapWithExpectedSize(3); + putPathPrefix(pathPrefixes, webProperties.getAdminApi(), antPathMatcher); + putPathPrefix(pathPrefixes, webProperties.getAppApi(), antPathMatcher); + putPathPrefix(pathPrefixes, webProperties.getOpenApi(), antPathMatcher); + return pathPrefixes; + } + + /** + * 设置 API 前缀,仅仅匹配 controller 包下的 + */ + private void putPathPrefix(Map>> pathPrefixes, WebProperties.Api api, AntPathMatcher matcher) { + if (api == null || StrUtil.isEmpty(api.getPrefix())) { + return; + } + pathPrefixes.put(api.getPrefix(), // api 前缀 + clazz -> clazz.isAnnotationPresent(RestController.class) + && matcher.match(api.getController(), clazz.getPackage().getName())); + } + + }; + } + + @Bean + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + public GlobalExceptionHandler globalExceptionHandler(ApiErrorLogCommonApi apiErrorLogApi) { + return new GlobalExceptionHandler(applicationName, apiErrorLogApi); + } + + @Bean + public GlobalResponseBodyHandler globalResponseBodyHandler() { + return new GlobalResponseBodyHandler(); + } + + @Bean + @SuppressWarnings("InstantiationOfUtilityClass") + public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) { + // 由于 WebFrameworkUtils 需要使用到 webProperties 属性,所以注册为一个 Bean + return new WebFrameworkUtils(webProperties); + } + + // ========== Filter 相关 ========== + + /** + * 创建 CorsFilter Bean,解决跨域问题 + */ + @Bean + @Order(value = WebFilterOrderEnum.CORS_FILTER) // 特殊:修复因执行顺序影响到跨域配置不生效问题 + public FilterRegistrationBean corsFilterBean() { + // 创建 CorsConfiguration 对象 + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); // 设置访问源地址 + config.addAllowedHeader("*"); // 设置访问源请求头 + config.addAllowedMethod("*"); // 设置访问源请求方法 + // 创建 UrlBasedCorsConfigurationSource 对象 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); // 对接口配置跨域设置 + return createFilterBean(new CorsFilter(source), WebFilterOrderEnum.CORS_FILTER); + } + + /** + * 创建 RequestBodyCacheFilter Bean,可重复读取请求内容 + */ + @Bean + public FilterRegistrationBean requestBodyCacheFilter() { + return createFilterBean(new CacheRequestBodyFilter(), WebFilterOrderEnum.REQUEST_BODY_CACHE_FILTER); + } + + /** + * 创建 DemoFilter Bean,演示模式 + */ + @Bean + @ConditionalOnProperty(value = "viewsh.demo", havingValue = "true") + public FilterRegistrationBean demoFilter() { + return createFilterBean(new DemoFilter(), WebFilterOrderEnum.DEMO_FILTER); + } + + public static FilterRegistrationBean createFilterBean(T filter, Integer order) { + FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); + bean.setOrder(order); + return bean; + } + + /** + * 创建 RestTemplate 实例 + * + * @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder} + */ + @Bean + @ConditionalOnMissingBean + @Primary + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder.build(); + } + + /** + * 创建 RestTemplate 实例(支持负载均衡) + * + * @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder} + */ + @Bean + @LoadBalanced + public RestTemplate loadBalancedRestTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder.build(); + } + +} diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/WebProperties.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/WebProperties.java index 5ac860b..b8940f4 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/WebProperties.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/WebProperties.java @@ -1,66 +1,68 @@ -package com.viewsh.framework.web.config; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; - -@ConfigurationProperties(prefix = "viewsh.web") -@Validated -@Data -public class WebProperties { - - @NotNull(message = "APP API 不能为空") - private Api appApi = new Api("/app-api", "**.controller.app.**"); - @NotNull(message = "Admin API 不能为空") - private Api adminApi = new Api("/admin-api", "**.controller.admin.**"); - - @NotNull(message = "Admin UI 不能为空") - private Ui adminUi; - - @Data - @AllArgsConstructor - @NoArgsConstructor - @Valid - public static class Api { - - /** - * API 前缀,实现所有 Controller 提供的 RESTFul API 的统一前缀 - * - * - * 意义:通过该前缀,避免 Swagger、Actuator 意外通过 Nginx 暴露出来给外部,带来安全性问题 - * 这样,Nginx 只需要配置转发到 /api/* 的所有接口即可。 - * - * @see ViewshWebAutoConfiguration#configurePathMatch(PathMatchConfigurer) - */ - @NotEmpty(message = "API 前缀不能为空") - private String prefix; - - /** - * Controller 所在包的 Ant 路径规则 - * - * 主要目的是,给该 Controller 设置指定的 {@link #prefix} - */ - @NotEmpty(message = "Controller 所在包不能为空") - private String controller; - - } - - @Data - @Valid - public static class Ui { - - /** - * 访问地址 - */ - private String url; - - } - -} +package com.viewsh.framework.web.config; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +@ConfigurationProperties(prefix = "viewsh.web") +@Validated +@Data +public class WebProperties { + + @NotNull(message = "APP API 不能为空") + private Api appApi = new Api("/app-api", "**.controller.app.**"); + @NotNull(message = "Admin API 不能为空") + private Api adminApi = new Api("/admin-api", "**.controller.admin.**"); + + private Api openApi = new Api("/open-api", "**.controller.open.**"); + + @NotNull(message = "Admin UI 不能为空") + private Ui adminUi; + + @Data + @AllArgsConstructor + @NoArgsConstructor + @Valid + public static class Api { + + /** + * API 前缀,实现所有 Controller 提供的 RESTFul API 的统一前缀 + * + * + * 意义:通过该前缀,避免 Swagger、Actuator 意外通过 Nginx 暴露出来给外部,带来安全性问题 + * 这样,Nginx 只需要配置转发到 /api/* 的所有接口即可。 + * + * @see ViewshWebAutoConfiguration#configurePathMatch(PathMatchConfigurer) + */ + @NotEmpty(message = "API 前缀不能为空") + private String prefix; + + /** + * Controller 所在包的 Ant 路径规则 + * + * 主要目的是,给该 Controller 设置指定的 {@link #prefix} + */ + @NotEmpty(message = "Controller 所在包不能为空") + private String controller; + + } + + @Data + @Valid + public static class Ui { + + /** + * 访问地址 + */ + private String url; + + } + +} diff --git a/viewsh-gateway/src/main/resources/application.yaml b/viewsh-gateway/src/main/resources/application.yaml index 7d85f76..a4941c7 100644 --- a/viewsh-gateway/src/main/resources/application.yaml +++ b/viewsh-gateway/src/main/resources/application.yaml @@ -208,6 +208,10 @@ spring: - Path=/app-api/ops/** filters: - RewritePath=/app-api/ops/v3/api-docs, /v3/api-docs + - id: ops-open-api # 开放接口路由(签名验证,无需 Token) + uri: grayLb://ops-server + predicates: + - Path=/open-api/ops/** x-forwarded: prefix-enabled: false # 避免 Swagger 重复带上额外的 /admin-api/system 前缀 -- 2.49.1 From 2a20f7a89fa9b2662979494ccdcd9941ae0bdf3d Mon Sep 17 00:00:00 2001 From: lzh Date: Fri, 13 Mar 2026 12:02:02 +0800 Subject: [PATCH 13/17] =?UTF-8?q?fix(framework):=20ApiRequestFilter=20?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=20/open-api=20=E8=B7=AF=E5=BE=84=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20open-api=20=E5=A4=9A=E7=A7=9F=E6=88=B7?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TenantSecurityWebFilter 继承 ApiRequestFilter,之前 shouldNotFilter 仅匹配 /admin-api 和 /app-api,导致 /open-api 请求跳过租户校验,DB 层 getRequiredTenantId() 抛 NPE。现在补上 openApi prefix,外部系统需传 tenant-id Header。 Co-Authored-By: Claude Opus 4.6 --- .../web/core/filter/ApiRequestFilter.java | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/filter/ApiRequestFilter.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/filter/ApiRequestFilter.java index 3e668d4..d90f7d2 100644 --- a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/filter/ApiRequestFilter.java +++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/filter/ApiRequestFilter.java @@ -1,27 +1,30 @@ -package com.viewsh.framework.web.core.filter; - -import cn.hutool.core.util.StrUtil; -import com.viewsh.framework.web.config.WebProperties; -import lombok.RequiredArgsConstructor; -import org.springframework.web.filter.OncePerRequestFilter; - -import jakarta.servlet.http.HttpServletRequest; - -/** - * 过滤 /admin-api、/app-api 等 API 请求的过滤器 - * - * @author 芋道源码 - */ -@RequiredArgsConstructor -public abstract class ApiRequestFilter extends OncePerRequestFilter { - - protected final WebProperties webProperties; - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - // 只过滤 API 请求的地址 - String apiUri = request.getRequestURI().substring(request.getContextPath().length()); - return !StrUtil.startWithAny(apiUri, webProperties.getAdminApi().getPrefix(), webProperties.getAppApi().getPrefix()); - } - -} +package com.viewsh.framework.web.core.filter; + +import cn.hutool.core.util.StrUtil; +import com.viewsh.framework.web.config.WebProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * 过滤 /admin-api、/app-api、/open-api 等 API 请求的过滤器 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public abstract class ApiRequestFilter extends OncePerRequestFilter { + + protected final WebProperties webProperties; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 只过滤 API 请求的地址 + String apiUri = request.getRequestURI().substring(request.getContextPath().length()); + return !StrUtil.startWithAny(apiUri, + webProperties.getAdminApi().getPrefix(), + webProperties.getAppApi().getPrefix(), + webProperties.getOpenApi().getPrefix()); + } + +} -- 2.49.1 From 825c8eecca262164e1755479b54684cae668f327 Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 15 Mar 2026 10:30:03 +0800 Subject: [PATCH 14/17] =?UTF-8?q?refactor(ops):=20=E6=8F=90=E5=8F=96=20Are?= =?UTF-8?q?aPathBuilder=20=E5=85=AC=E5=85=B1=E7=BB=84=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E4=BF=9D=E6=B4=81/=E5=AE=89=E4=BF=9D=20build?= =?UTF-8?q?AreaPath=20=E9=87=8D=E5=A4=8D=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 CleanOrderServiceImpl 中的 buildAreaPath 私有方法提取到 ops-biz 公共层 AreaPathBuilder 组件,供各业务模块(保洁、安保等)共享使用。同时优化: - 用正则 matches("\d+") 替代 try-catch NumberFormatException 做数字校验 - 增加相邻重复ID去重保护 Co-Authored-By: Claude Opus 4.6 --- .../cleanorder/CleanOrderServiceImpl.java | 100 +--------------- .../infrastructure/area/AreaPathBuilder.java | 108 ++++++++++++++++++ .../vo/SecurityOrderFalseAlarmReqVO.java | 20 ++++ .../security/vo/SecurityOrderIdReqVO.java | 22 ++++ .../vo/SecurityOrderOpenConfirmReqVO.java | 20 ++++ 5 files changed, 174 insertions(+), 96 deletions(-) create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderIdReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java index e0a8308..38cf86c 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderServiceImpl.java @@ -10,9 +10,7 @@ import com.viewsh.module.ops.core.event.OrderCreatedEvent; import com.viewsh.module.ops.core.event.OrderEventPublisher; import com.viewsh.module.ops.core.lifecycle.OrderLifecycleManager; import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionRequest; -import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; -import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; import com.viewsh.module.ops.enums.OperatorTypeEnum; import com.viewsh.module.ops.enums.PriorityEnum; @@ -21,6 +19,7 @@ import com.viewsh.module.ops.environment.dal.dataobject.CleanOrderAutoCreateReqD import com.viewsh.module.ops.environment.dal.dataobject.workorder.OpsOrderCleanExtDO; import com.viewsh.module.ops.environment.dal.mysql.workorder.OpsOrderCleanExtMapper; import com.viewsh.module.ops.environment.integration.listener.CleanOrderEventListener; +import com.viewsh.module.ops.infrastructure.area.AreaPathBuilder; import com.viewsh.module.ops.infrastructure.code.OrderCodeGenerator; import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator; import jakarta.annotation.Resource; @@ -30,11 +29,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; + /** * 保洁工单服务实现(重构版) @@ -88,7 +83,7 @@ public class CleanOrderServiceImpl implements CleanOrderService { private ObjectMapper objectMapper; @Resource - private OpsBusAreaMapper opsBusAreaMapper; + private AreaPathBuilder areaPathBuilder; // ==================== 工单创建 ==================== @@ -109,7 +104,7 @@ public class CleanOrderServiceImpl implements CleanOrderService { .priority(createReq.getPriority() != null ? createReq.getPriority() : PriorityEnum.P2.getPriority()) .status(WorkOrderStatusEnum.PENDING.getStatus()) .areaId(createReq.getAreaId()) - .location(buildAreaPath(createReq.getAreaId())) + .location(areaPathBuilder.buildPath(createReq.getAreaId())) .sourceType(createReq.getSourceType() != null ? createReq.getSourceType() : "TRAFFIC") // IoT集成字段 .triggerSource(createReq.getTriggerSource()) @@ -448,91 +443,4 @@ public class CleanOrderServiceImpl implements CleanOrderService { } } - /** - * 根据区域ID构建完整路径(如"园区/A栋/B层/电梯厅") - * - * @param areaId 区域ID - * @return 完整区域路径,用 "/" 分隔 - */ - private String buildAreaPath(Long areaId) { - // 1. 参数校验 - if (areaId == null) { - return null; - } - - // 2. 查询当前区域 - OpsBusAreaDO area = opsBusAreaMapper.selectById(areaId); - if (area == null) { - log.warn("区域不存在: areaId={}", areaId); - return null; - } - - // 3. 无父级路径,直接返回区域名称 - String parentPath = area.getParentPath(); - if (StrUtil.isEmpty(parentPath)) { - return area.getAreaName(); - } - - // 4. 解析父级ID列表(使用 Stream 过滤无效ID) - List parentIds = Arrays.stream(parentPath.split("/")) - .filter(StrUtil::isNotBlank) // 过滤空字符串 - .filter(pid -> { - try { - Long.parseLong(pid); - return true; - } catch (NumberFormatException e) { - log.warn("父级区域ID格式错误: areaId={}, parentId={}", areaId, pid); - return false; - } - }) - .map(Long::parseLong) - .filter(pid -> !pid.equals(areaId)) // 排除当前区域,避免循环引用 - .collect(Collectors.toList()); - - // 5. 在ID层面去重相邻重复的ID(只去除数据错误导致的重复,保留不同ID的相同名称) - List deduplicatedIds = new ArrayList<>(); - Long lastId = null; - for (Long parentId : parentIds) { - if (!parentId.equals(lastId)) { - deduplicatedIds.add(parentId); - lastId = parentId; - } else { - log.warn("检测到parent_path中重复的ID: areaId={}, duplicateId={}", areaId, parentId); - } - } - - // 6. 无有效父级,直接返回区域名称 - if (deduplicatedIds.isEmpty()) { - return area.getAreaName(); - } - - // 7. 批量查询所有父级区域(避免 N+1 查询) - List parents = opsBusAreaMapper.selectBatchIds(deduplicatedIds); - if (parents == null || parents.isEmpty()) { - log.warn("未找到父级区域: areaId={}, parentIds={}", areaId, deduplicatedIds); - return area.getAreaName(); - } - - // 8. 构建ID到区域的映射 - Map parentNameMap = parents.stream() - .collect(Collectors.toMap( - OpsBusAreaDO::getId, - OpsBusAreaDO::getAreaName, - (existing, replacement) -> existing // 处理重复key - )); - - // 9. 按顺序拼接区域路径(保持ID顺序) - List pathSegments = deduplicatedIds.stream() - .filter(parentNameMap::containsKey) // 过滤掉不存在的父级 - .map(parentNameMap::get) - .collect(Collectors.toList()); - - // 10. 拼接完整路径 - String path = String.join("/", pathSegments); - if (StrUtil.isBlank(path)) { - return area.getAreaName(); - } - - return path + "/" + area.getAreaName(); - } } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java new file mode 100644 index 0000000..a37ea40 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java @@ -0,0 +1,108 @@ +package com.viewsh.module.ops.infrastructure.area; + +import cn.hutool.core.util.StrUtil; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 区域路径构建器 + *

+ * 根据 areaId 拼接完整的区域路径,如 "A园区/A栋/3层/电梯厅"。 + * 供保洁、安保等各业务模块共享使用。 + * + * @author lzh + */ +@Slf4j +@Component +public class AreaPathBuilder { + + @Resource + private OpsBusAreaMapper opsBusAreaMapper; + + /** + * 根据已查询到的区域对象构建完整路径 + * + * @param area 区域对象(非 null) + * @return 完整区域路径,用 "/" 分隔 + */ + public String buildPath(OpsBusAreaDO area) { + if (area == null) { + return null; + } + + String parentPath = area.getParentPath(); + if (StrUtil.isEmpty(parentPath)) { + return area.getAreaName(); + } + + // 解析父级ID列表 + List parentIds = Arrays.stream(parentPath.split("/")) + .filter(StrUtil::isNotBlank) + .filter(pid -> pid.matches("\\d+")) + .map(Long::parseLong) + .filter(pid -> !pid.equals(area.getId())) + .collect(Collectors.toList()); + + // ID层面去重相邻重复(数据异常保护) + List deduplicatedIds = new ArrayList<>(); + Long lastId = null; + for (Long parentId : parentIds) { + if (!parentId.equals(lastId)) { + deduplicatedIds.add(parentId); + lastId = parentId; + } else { + log.warn("检测到parent_path中重复的ID: areaId={}, duplicateId={}", area.getId(), parentId); + } + } + + if (deduplicatedIds.isEmpty()) { + return area.getAreaName(); + } + + // 批量查询父级区域 + List parents = opsBusAreaMapper.selectBatchIds(deduplicatedIds); + if (parents == null || parents.isEmpty()) { + log.warn("未找到父级区域: areaId={}, parentIds={}", area.getId(), deduplicatedIds); + return area.getAreaName(); + } + + Map parentNameMap = parents.stream() + .collect(Collectors.toMap(OpsBusAreaDO::getId, OpsBusAreaDO::getAreaName, (a, b) -> a)); + + List pathSegments = deduplicatedIds.stream() + .filter(parentNameMap::containsKey) + .map(parentNameMap::get) + .collect(Collectors.toList()); + + String path = String.join("/", pathSegments); + return StrUtil.isBlank(path) ? area.getAreaName() : path + "/" + area.getAreaName(); + } + + /** + * 根据 areaId 查询并构建完整路径 + * + * @param areaId 区域ID + * @return 完整区域路径,用 "/" 分隔;区域不存在时返回 null + */ + public String buildPath(Long areaId) { + if (areaId == null) { + return null; + } + OpsBusAreaDO area = opsBusAreaMapper.selectById(areaId); + if (area == null) { + log.warn("区域不存在: areaId={}", areaId); + return null; + } + return buildPath(area); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java new file mode 100644 index 0000000..9bf0cab --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java @@ -0,0 +1,20 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单误报请求 VO + * + * @author lzh + */ +@Schema(description = "安保工单误报请求") +@Data +public class SecurityOrderFalseAlarmReqVO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderIdReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderIdReqVO.java new file mode 100644 index 0000000..78342c7 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderIdReqVO.java @@ -0,0 +1,22 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单 - 仅含工单ID的通用请求 VO + *

+ * 用于误报标记、开放接口确认等只需要工单ID的场景 + * + * @author lzh + */ +@Schema(description = "安保工单ID请求") +@Data +public class SecurityOrderIdReqVO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java new file mode 100644 index 0000000..8a5b35e --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java @@ -0,0 +1,20 @@ +package com.viewsh.module.ops.controller.admin.security.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 安保工单开放接口确认请求 VO(无需传 userId,默认使用已分配人员) + * + * @author lzh + */ +@Schema(description = "安保工单确认请求(开放接口)") +@Data +public class SecurityOrderOpenConfirmReqVO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + +} -- 2.49.1 From f32315f790e61f6bb20c0a4934e1cf0ac2859b3c Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 15 Mar 2026 10:31:50 +0800 Subject: [PATCH 15/17] =?UTF-8?q?feat(ops):=20=E5=AE=89=E4=BF=9D=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E6=96=B0=E5=A2=9E=E8=AF=AF=E6=8A=A5=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E3=80=81=E5=AE=8C=E5=96=84=E7=A1=AE=E8=AE=A4/=E5=AE=8C?= =?UTF-8?q?=E5=8D=95=E6=8E=A5=E5=8F=A3=E6=94=AF=E6=8C=81=20open-api=20?= =?UTF-8?q?=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 falseAlarmOrder 方法,标记误报并完成工单 - confirmOrder/manualCompleteOrder 支持 operatorId 为 null(open-api 自动取已分配人员) - 新增 resolveOperatorId 辅助方法,null 时记录 warn 日志 - createSecurityOrder 移除 location 透传,改用 AreaPathBuilder 自动拼接 - 消除 createSecurityOrder 中 area 重复查询(校验 + buildPath 共用同一 DO) - OpsOrderSecurityExtDO 新增 falseAlarm 字段 - SecurityOrderCompleteReqDTO.operatorId 移除 @NotNull 约束 Co-Authored-By: Claude Opus 4.6 --- .../workorder/OpsOrderSecurityExtDO.java | 4 + .../SecurityOrderCompleteReqDTO.java | 3 +- .../SecurityOrderCreateReqDTO.java | 2 - .../securityorder/SecurityOrderService.java | 8 ++ .../SecurityOrderServiceImpl.java | 73 ++++++++++++++++--- 5 files changed, 74 insertions(+), 16 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java index 5073164..426eeef 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/dal/dataobject/workorder/OpsOrderSecurityExtDO.java @@ -81,6 +81,10 @@ public class OpsOrderSecurityExtDO extends BaseDO { * 处理结果图片URL,JSON数组 */ private String resultImgUrls; + /** + * 是否误报(true=误报) + */ + private Boolean falseAlarm; // ==================== 关键时间点 ==================== diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java index ba1a094..3fce009 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCompleteReqDTO.java @@ -29,9 +29,8 @@ public class SecurityOrderCompleteReqDTO { private List resultImgUrls; /** - * 操作人ID(由 Controller 层填充) + * 操作人ID(由 Controller 层填充,open-api 场景可为 null,Service 层会自动取已分配人员) */ - @NotNull(message = "操作人ID不能为空") private Long operatorId; } diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java index 56d90ed..fae0fd5 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderCreateReqDTO.java @@ -28,8 +28,6 @@ public class SecurityOrderCreateReqDTO { @NotNull(message = "区域ID不能为空") private Long areaId; - private String location; - // ==================== 告警来源 ==================== private String alarmId; diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java index 245d602..b77cb47 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderService.java @@ -44,6 +44,14 @@ public interface SecurityOrderService { */ void manualCompleteOrder(SecurityOrderCompleteReqDTO req); + /** + * 误报标记(将工单标记为误报并完成) + * + * @param orderId 工单ID + * @param operatorId 操作人ID(可为 null,为 null 时取已分配人员) + */ + void falseAlarmOrder(Long orderId, Long operatorId); + /** * 根据工单ID查询安保扩展信息 * diff --git a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java index 206a44c..8f453c9 100644 --- a/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-security-biz/src/main/java/com/viewsh/module/ops/security/service/securityorder/SecurityOrderServiceImpl.java @@ -1,8 +1,9 @@ package com.viewsh.module.ops.security.service.securityorder; import cn.hutool.core.util.StrUtil; -import com.viewsh.module.ops.core.event.OrderEventPublisher; import com.viewsh.module.ops.core.event.OrderCreatedEvent; +import com.viewsh.module.ops.core.event.OrderEventPublisher; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; @@ -11,17 +12,14 @@ import com.viewsh.module.ops.enums.PriorityEnum; import com.viewsh.module.ops.enums.SourceTypeEnum; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; import com.viewsh.module.ops.enums.WorkOrderTypeEnum; +import com.viewsh.module.ops.infrastructure.area.AreaPathBuilder; import com.viewsh.module.ops.infrastructure.code.OrderCodeGenerator; +import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator; import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain; import com.viewsh.module.ops.infrastructure.log.enumeration.LogModule; import com.viewsh.module.ops.infrastructure.log.enumeration.LogType; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord; import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder; -// 注意:confirm/complete 的业务日志由 SecurityOrderEventListener 统一记录 -// 本类仅记录 CREATE 日志(创建不经过状态变更事件) -import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; -import static com.viewsh.module.ops.enums.ErrorCodeConstants.*; -import com.viewsh.module.ops.infrastructure.id.OrderIdGenerator; import com.viewsh.module.ops.security.dal.dataobject.workorder.OpsOrderSecurityExtDO; import com.viewsh.module.ops.security.dal.mysql.workorder.OpsOrderSecurityExtMapper; import com.viewsh.module.ops.service.fsm.OrderStateMachine; @@ -30,6 +28,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.viewsh.module.ops.enums.ErrorCodeConstants.*; + /** * 安保工单服务实现 * @@ -57,6 +58,9 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { @Resource private OpsBusAreaMapper opsBusAreaMapper; + @Resource + private AreaPathBuilder areaPathBuilder; + @Resource private OrderStateMachine orderStateMachine; @@ -69,7 +73,8 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { @Transactional(rollbackFor = Exception.class) public Long createSecurityOrder(SecurityOrderCreateReqDTO createReq) { // 0. 校验区域是否存在 - if (opsBusAreaMapper.selectById(createReq.getAreaId()) == null) { + OpsBusAreaDO area = opsBusAreaMapper.selectById(createReq.getAreaId()); + if (area == null) { throw exception(AREA_NOT_FOUND); } @@ -82,7 +87,7 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { ? createReq.getSourceType() : (StrUtil.isNotBlank(createReq.getAlarmId()) ? SourceTypeEnum.ALARM.getType() : SourceTypeEnum.MANUAL.getType()); - // 3. 构建主表记录 + // 3. 构建主表记录(location 由 areaId 自动拼接) OpsOrderDO order = OpsOrderDO.builder() .id(orderId) .orderCode(orderCode) @@ -93,7 +98,7 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { .priority(createReq.getPriority() != null ? createReq.getPriority() : PriorityEnum.P2.getPriority()) .status(WorkOrderStatusEnum.PENDING.getStatus()) .areaId(createReq.getAreaId()) - .location(createReq.getLocation()) + .location(areaPathBuilder.buildPath(area)) .build(); opsOrderMapper.insert(order); @@ -142,11 +147,14 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { OpsOrderDO order = getOrderOrThrow(orderId); validateOrderType(order); + // 如果 userId 为 null(open-api 调用),取已分配人员 + Long effectiveUserId = resolveOperatorId(orderId, userId); + // 状态转换:DISPATCHED → CONFIRMED(扩展表时间 + 业务日志由 EventListener 统一记录) orderStateMachine.transition(order, WorkOrderStatusEnum.CONFIRMED, - OperatorTypeEnum.SECURITY_GUARD, userId, "安保人员确认接单"); + OperatorTypeEnum.SECURITY_GUARD, effectiveUserId, "安保人员确认接单"); - log.info("安保工单确认: orderId={}, userId={}", orderId, userId); + log.info("安保工单确认: orderId={}, userId={}", orderId, effectiveUserId); } @Override @@ -169,9 +177,12 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { OpsOrderDO order = getOrderOrThrow(req.getOrderId()); validateOrderType(order); + // 如果 operatorId 为 null(open-api 调用),取已分配人员 + Long effectiveOperatorId = resolveOperatorId(req.getOrderId(), req.getOperatorId()); + // 状态转换 → COMPLETED(扩展表 completedTime + 业务日志由 EventListener 统一记录,主表 endTime 由状态机统一设置) orderStateMachine.transition(order, WorkOrderStatusEnum.COMPLETED, - OperatorTypeEnum.SECURITY_GUARD, req.getOperatorId(), "安保人员提交处理结果"); + OperatorTypeEnum.SECURITY_GUARD, effectiveOperatorId, "安保人员提交处理结果"); // 更新扩展表:结果 + 图片 OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); @@ -185,6 +196,29 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { log.info("安保工单人工完单: orderId={}", req.getOrderId()); } + @Override + @Transactional(rollbackFor = Exception.class) + public void falseAlarmOrder(Long orderId, Long operatorId) { + OpsOrderDO order = getOrderOrThrow(orderId); + validateOrderType(order); + + // 如果 operatorId 为 null(open-api 调用),取已分配人员 + Long effectiveOperatorId = resolveOperatorId(orderId, operatorId); + + // 状态转换 → COMPLETED + orderStateMachine.transition(order, WorkOrderStatusEnum.COMPLETED, + OperatorTypeEnum.SECURITY_GUARD, effectiveOperatorId, "误报标记"); + + // 更新扩展表:标记误报 + 结果 + OpsOrderSecurityExtDO extUpdate = new OpsOrderSecurityExtDO(); + extUpdate.setOpsOrderId(orderId); + extUpdate.setFalseAlarm(true); + extUpdate.setResult("误报"); + securityExtMapper.insertOrUpdateSelective(extUpdate); + + log.info("安保工单误报标记: orderId={}", orderId); + } + @Override public OpsOrderSecurityExtDO getSecurityExt(Long opsOrderId) { return securityExtMapper.selectByOpsOrderId(opsOrderId); @@ -206,4 +240,19 @@ public class SecurityOrderServiceImpl implements SecurityOrderService { } } + /** + * 解析操作人ID:如果传入为 null,则取扩展表中已分配的安保人员 + */ + private Long resolveOperatorId(Long orderId, Long operatorId) { + if (operatorId != null) { + return operatorId; + } + OpsOrderSecurityExtDO ext = securityExtMapper.selectByOpsOrderId(orderId); + Long assignedUserId = ext != null ? ext.getAssignedUserId() : null; + if (assignedUserId == null) { + log.warn("工单未分配安保人员,操作人为空: orderId={}", orderId); + } + return assignedUserId; + } + } -- 2.49.1 From ea64ca9c61846b1a792af775ae21d2e77e5ce03c Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 15 Mar 2026 10:33:51 +0800 Subject: [PATCH 16/17] =?UTF-8?q?feat(ops):=20=E5=AE=89=E4=BF=9D=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=20admin-api/open-api=20=E8=A1=A5=E5=85=A8=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E3=80=81=E6=8F=90=E4=BA=A4=E3=80=81=E8=AF=AF=E6=8A=A5?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin-api: - 新增 /false-alarm 误报标记接口(权限: ops:security-order:complete) - createOrder 移除 location 字段 open-api: - 新增 /confirm 确认工单(无需传 userId) - 新增 /submit 提交处理结果(结果描述 + 图片) - 新增 /false-alarm 误报标记 - createOrder 移除 location 字段 VO 优化: - 合并 SecurityOrderFalseAlarmReqVO 和 SecurityOrderOpenConfirmReqVO 为通用 SecurityOrderIdReqVO,消除重复定义 Co-Authored-By: Claude Opus 4.6 --- .../security/SecurityOrderController.java | 10 +++++- .../security/vo/SecurityOrderCreateReqVO.java | 3 -- .../vo/SecurityOrderFalseAlarmReqVO.java | 20 ----------- .../vo/SecurityOrderOpenConfirmReqVO.java | 20 ----------- .../security/SecurityOrderOpenController.java | 36 ++++++++++++++++++- 5 files changed, 44 insertions(+), 45 deletions(-) delete mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java delete mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java index 948ccc7..d908323 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/SecurityOrderController.java @@ -6,6 +6,7 @@ import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderAutoCompl import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCompleteReqVO; import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderConfirmReqVO; import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCreateReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderIdReqVO; import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCompleteReqDTO; import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCreateReqDTO; import com.viewsh.module.ops.security.service.securityorder.SecurityOrderService; @@ -48,7 +49,6 @@ public class SecurityOrderController { .description(reqVO.getDescription()) .priority(reqVO.getPriority()) .areaId(reqVO.getAreaId()) - .location(reqVO.getLocation()) .alarmId(reqVO.getAlarmId()) .alarmType(reqVO.getAlarmType()) .cameraId(reqVO.getCameraId()) @@ -90,4 +90,12 @@ public class SecurityOrderController { return success(true); } + @PostMapping("/false-alarm") + @Operation(summary = "误报标记", description = "将安保工单标记为误报并完成") + @PreAuthorize("@ss.hasPermission('ops:security-order:complete')") + public CommonResult falseAlarmOrder(@Valid @RequestBody SecurityOrderIdReqVO reqVO) { + securityOrderService.falseAlarmOrder(reqVO.getOrderId(), SecurityFrameworkUtils.getLoginUserId()); + return success(true); + } + } diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java index 3719314..d0a1b96 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderCreateReqVO.java @@ -28,9 +28,6 @@ public class SecurityOrderCreateReqVO { @NotNull(message = "区域ID不能为空") private Long areaId; - @Schema(description = "具体位置描述", example = "A栋3层东侧走廊") - private String location; - // ==================== 告警来源 ==================== @Schema(description = "关联告警ID", example = "ALM20260211001") diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java deleted file mode 100644 index 9bf0cab..0000000 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderFalseAlarmReqVO.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.viewsh.module.ops.controller.admin.security.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -/** - * 安保工单误报请求 VO - * - * @author lzh - */ -@Schema(description = "安保工单误报请求") -@Data -public class SecurityOrderFalseAlarmReqVO { - - @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") - @NotNull(message = "工单ID不能为空") - private Long orderId; - -} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java deleted file mode 100644 index 8a5b35e..0000000 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/security/vo/SecurityOrderOpenConfirmReqVO.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.viewsh.module.ops.controller.admin.security.vo; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import lombok.Data; - -/** - * 安保工单开放接口确认请求 VO(无需传 userId,默认使用已分配人员) - * - * @author lzh - */ -@Schema(description = "安保工单确认请求(开放接口)") -@Data -public class SecurityOrderOpenConfirmReqVO { - - @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") - @NotNull(message = "工单ID不能为空") - private Long orderId; - -} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java index fd550ca..aa20670 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/open/security/SecurityOrderOpenController.java @@ -3,7 +3,10 @@ package com.viewsh.module.ops.controller.open.security; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.framework.signature.core.annotation.ApiSignature; import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderAutoCompleteReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCompleteReqVO; import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderCreateReqVO; +import com.viewsh.module.ops.controller.admin.security.vo.SecurityOrderIdReqVO; +import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCompleteReqDTO; import com.viewsh.module.ops.security.service.securityorder.SecurityOrderCreateReqDTO; import com.viewsh.module.ops.security.service.securityorder.SecurityOrderService; import io.swagger.v3.oas.annotations.Operation; @@ -48,7 +51,6 @@ public class SecurityOrderOpenController { .description(reqVO.getDescription()) .priority(reqVO.getPriority()) .areaId(reqVO.getAreaId()) - .location(reqVO.getLocation()) .alarmId(reqVO.getAlarmId()) .alarmType(reqVO.getAlarmType()) .cameraId(reqVO.getCameraId()) @@ -69,4 +71,36 @@ public class SecurityOrderOpenController { return success(true); } + @PostMapping("/confirm") + @Operation(summary = "确认工单", description = "由外部系统调用,确认安保人员已接单,无需传 userId(自动取已分配人员)") + @ApiSignature + @PermitAll + public CommonResult confirmOrder(@Valid @RequestBody SecurityOrderIdReqVO reqVO) { + securityOrderService.confirmOrder(reqVO.getOrderId(), null); + return success(true); + } + + @PostMapping("/submit") + @Operation(summary = "工单提交", description = "由外部系统调用,提交处理结果(描述 + 图片),完成工单") + @ApiSignature + @PermitAll + public CommonResult submitOrder(@Valid @RequestBody SecurityOrderCompleteReqVO reqVO) { + SecurityOrderCompleteReqDTO dto = SecurityOrderCompleteReqDTO.builder() + .orderId(reqVO.getOrderId()) + .result(reqVO.getResult()) + .resultImgUrls(reqVO.getResultImgUrls()) + .build(); + securityOrderService.manualCompleteOrder(dto); + return success(true); + } + + @PostMapping("/false-alarm") + @Operation(summary = "误报标记", description = "由外部系统调用,将工单标记为误报并完成") + @ApiSignature + @PermitAll + public CommonResult falseAlarmOrder(@Valid @RequestBody SecurityOrderIdReqVO reqVO) { + securityOrderService.falseAlarmOrder(reqVO.getOrderId(), null); + return success(true); + } + } -- 2.49.1 From c9d443a75b22fdbd5bdf2ff2e551dfafdb2f4ba5 Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 15 Mar 2026 10:35:30 +0800 Subject: [PATCH 17/17] =?UTF-8?q?feat(sql):=20=E5=AE=89=E4=BF=9D=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E6=89=A9=E5=B1=95=E8=A1=A8=E6=96=B0=E5=A2=9E=20false?= =?UTF-8?q?=5Falarm=20=E5=AD=97=E6=AE=B5=EF=BC=8C=E9=99=84=E5=A2=9E?= =?UTF-8?q?=E9=87=8F=E8=BF=81=E7=A7=BB=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DDL: ops_order_security_ext 新增 false_alarm tinyint(1) 列 - 增量迁移: ops_order_security_ext_migrate.sql 供已部署环境 ALTER TABLE Co-Authored-By: Claude Opus 4.6 --- sql/mysql/ops_order_security_ext.sql | 1 + sql/mysql/ops_order_security_ext_migrate.sql | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 sql/mysql/ops_order_security_ext_migrate.sql diff --git a/sql/mysql/ops_order_security_ext.sql b/sql/mysql/ops_order_security_ext.sql index 983fad0..5dd86e2 100644 --- a/sql/mysql/ops_order_security_ext.sql +++ b/sql/mysql/ops_order_security_ext.sql @@ -21,6 +21,7 @@ CREATE TABLE `ops_order_security_ext` ( -- 处理结果(完成时提交) `result` text DEFAULT NULL COMMENT '处理结果描述', `result_img_urls` varchar(2048) DEFAULT NULL COMMENT '处理结果图片URL,JSON数组', + `false_alarm` tinyint(1) DEFAULT NULL COMMENT '是否误报: 1=误报', -- 关键时间点 `dispatched_time` datetime DEFAULT NULL COMMENT '派单时间', diff --git a/sql/mysql/ops_order_security_ext_migrate.sql b/sql/mysql/ops_order_security_ext_migrate.sql new file mode 100644 index 0000000..80f755e --- /dev/null +++ b/sql/mysql/ops_order_security_ext_migrate.sql @@ -0,0 +1,9 @@ +-- ---------------------------- +-- Incremental migration for ops_order_security_ext +-- Version: v1.1.0 (2026-03-15) +-- Description: Add false_alarm column for false alarm marking +-- ---------------------------- + +ALTER TABLE `ops_order_security_ext` + ADD COLUMN `false_alarm` tinyint(1) DEFAULT NULL COMMENT '是否误报: 1=误报' + AFTER `result_img_urls`; -- 2.49.1