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..5dd86e2
--- /dev/null
+++ b/sql/mysql/ops_order_security_ext.sql
@@ -0,0 +1,42 @@
+-- ----------------------------
+-- 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数组',
+ `false_alarm` tinyint(1) DEFAULT NULL COMMENT '是否误报: 1=误报',
+
+ -- 关键时间点
+ `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/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`;
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 签名配置属性
+ *
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-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());
+ }
+
+}
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 前缀
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/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-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");
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-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);
}
}
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-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)
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-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/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);
+ }
}
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);
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/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..d908323
--- /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,101 @@
+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.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;
+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())
+ .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);
+ }
+
+ @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/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..d0a1b96
--- /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,51 @@
+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 = "关联告警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/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/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..aa20670
--- /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,106 @@
+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;
+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())
+ .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);
+ }
+
+ @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);
+ }
+
+}
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();
}
};
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
+
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..426eeef
--- /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,104 @@
+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;
+ /**
+ * 是否误报(true=误报)
+ */
+ private Boolean falseAlarm;
+
+ // ==================== 关键时间点 ====================
+
+ /**
+ * 派单时间
+ */
+ 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);
+ }
+ }
+
+}
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);
+ }
+ }
+
+}
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..3fce009
--- /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,36 @@
+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 层填充,open-api 场景可为 null,Service 层会自动取已分配人员)
+ */
+ 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..fae0fd5
--- /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,45 @@
+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 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..b77cb47
--- /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,63 @@
+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);
+
+ /**
+ * 误报标记(将工单标记为误报并完成)
+ *
+ * @param orderId 工单ID
+ * @param operatorId 操作人ID(可为 null,为 null 时取已分配人员)
+ */
+ void falseAlarmOrder(Long orderId, Long operatorId);
+
+ /**
+ * 根据工单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..8f453c9
--- /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,258 @@
+package com.viewsh.module.ops.security.service.securityorder;
+
+import cn.hutool.core.util.StrUtil;
+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;
+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.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;
+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;
+
+import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static com.viewsh.module.ops.enums.ErrorCodeConstants.*;
+
+/**
+ * 安保工单服务实现
+ *
+ * @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 AreaPathBuilder areaPathBuilder;
+
+ @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. 校验区域是否存在
+ OpsBusAreaDO area = opsBusAreaMapper.selectById(createReq.getAreaId());
+ if (area == 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. 构建主表记录(location 由 areaId 自动拼接)
+ 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(areaPathBuilder.buildPath(area))
+ .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);
+
+ // 如果 userId 为 null(open-api 调用),取已分配人员
+ Long effectiveUserId = resolveOperatorId(orderId, userId);
+
+ // 状态转换:DISPATCHED → CONFIRMED(扩展表时间 + 业务日志由 EventListener 统一记录)
+ orderStateMachine.transition(order, WorkOrderStatusEnum.CONFIRMED,
+ OperatorTypeEnum.SECURITY_GUARD, effectiveUserId, "安保人员确认接单");
+
+ log.info("安保工单确认: orderId={}, userId={}", orderId, effectiveUserId);
+ }
+
+ @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);
+
+ // 如果 operatorId 为 null(open-api 调用),取已分配人员
+ Long effectiveOperatorId = resolveOperatorId(req.getOrderId(), req.getOperatorId());
+
+ // 状态转换 → COMPLETED(扩展表 completedTime + 业务日志由 EventListener 统一记录,主表 endTime 由状态机统一设置)
+ orderStateMachine.transition(order, WorkOrderStatusEnum.COMPLETED,
+ OperatorTypeEnum.SECURITY_GUARD, effectiveOperatorId, "安保人员提交处理结果");
+
+ // 更新扩展表:结果 + 图片
+ 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
+ @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);
+ }
+
+ // ==================== 内部方法 ====================
+
+ 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);
+ }
+ }
+
+ /**
+ * 解析操作人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;
+ }
+
+}
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();
+ }
+}