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-common/src/main/java/com/viewsh/framework/common/biz/infra/file/OssPresignUrlApi.java b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/infra/file/OssPresignUrlApi.java
new file mode 100644
index 0000000..b6c0f10
--- /dev/null
+++ b/viewsh-framework/viewsh-common/src/main/java/com/viewsh/framework/common/biz/infra/file/OssPresignUrlApi.java
@@ -0,0 +1,20 @@
+package com.viewsh.framework.common.biz.infra.file;
+
+import java.util.List;
+
+/**
+ * OSS 预签名 URL 通用接口
+ *
+ * 由 infra 模块提供实现,供 {@code OssPresignResponseBodyAdvice} 等框架组件使用
+ */
+public interface OssPresignUrlApi {
+
+ /**
+ * 批量生成文件预签名地址
+ *
+ * @param urls 原始 URL 列表
+ * @return 签名后的 URL 列表(与入参顺序一致)
+ */
+ List presignGetUrls(List 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 签名配置属性
+ *
+ * 支持在 application.yaml 中配置 appId/appSecret,应用启动时自动加载到 Redis。
+ *
+ *
+ * viewsh:
+ * signature:
+ * apps:
+ * alarm-system: "your-app-secret"
+ * third-party: "another-secret"
+ *
+ *
+ * @author lzh
+ */
+@ConfigurationProperties(prefix = "viewsh.signature")
+@Data
+public class ApiSignatureProperties {
+
+ /**
+ * 签名应用列表:appId → appSecret
+ */
+ private Map apps;
+
+}
diff --git a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java
index 9aa434c..9f1d066 100644
--- a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java
+++ b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/config/ViewshApiSignatureAutoConfiguration.java
@@ -1,28 +1,34 @@
-package com.viewsh.framework.signature.config;
-
-import com.viewsh.framework.redis.config.ViewshRedisAutoConfiguration;
-import com.viewsh.framework.signature.core.aop.ApiSignatureAspect;
-import com.viewsh.framework.signature.core.redis.ApiSignatureRedisDAO;
-import org.springframework.boot.autoconfigure.AutoConfiguration;
-import org.springframework.context.annotation.Bean;
-import org.springframework.data.redis.core.StringRedisTemplate;
-
-/**
- * HTTP API 签名的自动配置类
- *
- * @author Zhougang
- */
-@AutoConfiguration(after = ViewshRedisAutoConfiguration.class)
-public class ViewshApiSignatureAutoConfiguration {
-
- @Bean
- public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
- return new ApiSignatureAspect(signatureRedisDAO);
- }
-
- @Bean
- public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
- return new ApiSignatureRedisDAO(stringRedisTemplate);
- }
-
-}
+package com.viewsh.framework.signature.config;
+
+import com.viewsh.framework.redis.config.ViewshRedisAutoConfiguration;
+import com.viewsh.framework.signature.core.aop.ApiSignatureAspect;
+import com.viewsh.framework.signature.core.redis.ApiSignatureRedisDAO;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+/**
+ * HTTP API 签名的自动配置类
+ *
+ * @author Zhougang
+ */
+@AutoConfiguration(after = ViewshRedisAutoConfiguration.class)
+@EnableConfigurationProperties(ApiSignatureProperties.class)
+public class ViewshApiSignatureAutoConfiguration {
+
+ @Bean
+ public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
+ return new ApiSignatureAspect(signatureRedisDAO);
+ }
+
+ @Bean
+ public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate,
+ ApiSignatureProperties properties) {
+ ApiSignatureRedisDAO dao = new ApiSignatureRedisDAO(stringRedisTemplate);
+ // 启动时将配置文件中的 appId/appSecret 同步到 Redis
+ dao.initApps(properties.getApps());
+ return dao;
+ }
+
+}
diff --git a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java
index f228d7d..8450d50 100644
--- a/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java
+++ b/viewsh-framework/viewsh-spring-boot-starter-protection/src/main/java/com/viewsh/framework/signature/core/redis/ApiSignatureRedisDAO.java
@@ -1,57 +1,78 @@
-package com.viewsh.framework.signature.core.redis;
-
-import lombok.AllArgsConstructor;
-import org.springframework.data.redis.core.StringRedisTemplate;
-
-import java.util.concurrent.TimeUnit;
-
-/**
- * HTTP API 签名 Redis DAO
- *
- * @author Zhougang
- */
-@AllArgsConstructor
-public class ApiSignatureRedisDAO {
-
- private final StringRedisTemplate stringRedisTemplate;
-
- /**
- * 验签随机数
- *
- * KEY 格式:signature_nonce:%s // 参数为 随机数
- * VALUE 格式:String
- * 过期时间:不固定
- */
- private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s";
-
- /**
- * 签名密钥
- *
- * HASH 结构
- * KEY 格式:%s // 参数为 appid
- * VALUE 格式:String
- * 过期时间:永不过期(预加载到 Redis)
- */
- private static final String SIGNATURE_APPID = "api_signature_app";
-
- // ========== 验签随机数 ==========
-
- public String getNonce(String appId, String nonce) {
- return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce));
- }
-
- public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) {
- return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit);
- }
-
- private static String formatNonceKey(String appId, String nonce) {
- return String.format(SIGNATURE_NONCE, appId, nonce);
- }
-
- // ========== 签名密钥 ==========
-
- public String getAppSecret(String appId) {
- return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId);
- }
-
-}
+package com.viewsh.framework.signature.core.redis;
+
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * HTTP API 签名 Redis DAO
+ *
+ * @author Zhougang
+ */
+@AllArgsConstructor
+@Slf4j
+public class ApiSignatureRedisDAO {
+
+ private final StringRedisTemplate stringRedisTemplate;
+
+ /**
+ * 验签随机数
+ *
+ * KEY 格式:signature_nonce:%s // 参数为 随机数
+ * VALUE 格式:String
+ * 过期时间:不固定
+ */
+ private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s";
+
+ /**
+ * 签名密钥
+ *
+ * HASH 结构
+ * KEY 格式:%s // 参数为 appid
+ * VALUE 格式:String
+ * 过期时间:永不过期(预加载到 Redis)
+ */
+ private static final String SIGNATURE_APPID = "api_signature_app";
+
+ // ========== 验签随机数 ==========
+
+ public String getNonce(String appId, String nonce) {
+ return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce));
+ }
+
+ public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) {
+ return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit);
+ }
+
+ private static String formatNonceKey(String appId, String nonce) {
+ return String.format(SIGNATURE_NONCE, appId, nonce);
+ }
+
+ // ========== 签名密钥 ==========
+
+ public String getAppSecret(String appId) {
+ return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId);
+ }
+
+ /**
+ * 从配置文件加载 appId/appSecret 到 Redis
+ *
+ * 先删除整个 Hash Key 再写入,确保 YAML 中移除的应用不会残留在 Redis 中。
+ *
+ * @param apps appId → appSecret 映射
+ */
+ public void initApps(Map apps) {
+ if (apps == null || apps.isEmpty()) {
+ stringRedisTemplate.delete(SIGNATURE_APPID);
+ log.info("[initApps][配置为空,已清除 Redis 中的签名应用]");
+ return;
+ }
+ stringRedisTemplate.delete(SIGNATURE_APPID);
+ stringRedisTemplate.opsForHash().putAll(SIGNATURE_APPID, apps);
+ log.info("[initApps][从配置文件加载 {} 个签名应用到 Redis: {}]", apps.size(), apps.keySet());
+ }
+
+}
diff --git a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java
index af00f3e..e21b328 100644
--- a/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java
+++ b/viewsh-framework/viewsh-spring-boot-starter-security/src/main/java/com/viewsh/framework/security/config/AuthorizeRequestsCustomizer.java
@@ -1,35 +1,39 @@
-package com.viewsh.framework.security.config;
-
-import com.viewsh.framework.web.config.WebProperties;
-import jakarta.annotation.Resource;
-import org.springframework.core.Ordered;
-import org.springframework.security.config.Customizer;
-import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
-
-/**
- * 自定义的 URL 的安全配置
- * 目的:每个 Maven Module 可以自定义规则!
- *
- * @author 芋道源码
- */
-public abstract class AuthorizeRequestsCustomizer
- implements Customizer.AuthorizationManagerRequestMatcherRegistry>, Ordered {
-
- @Resource
- private WebProperties webProperties;
-
- protected String buildAdminApi(String url) {
- return webProperties.getAdminApi().getPrefix() + url;
- }
-
- protected String buildAppApi(String url) {
- return webProperties.getAppApi().getPrefix() + url;
- }
-
- @Override
- public int getOrder() {
- return 0;
- }
-
-}
+package com.viewsh.framework.security.config;
+
+import com.viewsh.framework.web.config.WebProperties;
+import jakarta.annotation.Resource;
+import org.springframework.core.Ordered;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
+
+/**
+ * 自定义的 URL 的安全配置
+ * 目的:每个 Maven Module 可以自定义规则!
+ *
+ * @author 芋道源码
+ */
+public abstract class AuthorizeRequestsCustomizer
+ implements Customizer.AuthorizationManagerRequestMatcherRegistry>, Ordered {
+
+ @Resource
+ private WebProperties webProperties;
+
+ protected String buildAdminApi(String url) {
+ return webProperties.getAdminApi().getPrefix() + url;
+ }
+
+ protected String buildAppApi(String url) {
+ return webProperties.getAppApi().getPrefix() + url;
+ }
+
+ protected String buildOpenApi(String url) {
+ return webProperties.getOpenApi().getPrefix() + url;
+ }
+
+ @Override
+ public int getOrder() {
+ return 0;
+ }
+
+}
diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/config/ViewshWebAutoConfiguration.java
index 569ae64..3b36aaf 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,183 @@
-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.file.OssPresignUrlApi;
+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.presign.core.OssPresignResponseBodyAdvice;
+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.autoconfigure.condition.ConditionalOnBean;
+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;
+
+// ⚠ 此类被 OssPresignUrlApiAutoConfiguration 通过 beforeName 字符串引用,
+// 重命名/移动时须同步修改。搜索关键字:PRESIGN_AUTO_CONFIG_ORDERING
+@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
+ @ConditionalOnBean(OssPresignUrlApi.class)
+ public OssPresignResponseBodyAdvice ossPresignResponseBodyAdvice(OssPresignUrlApi ossPresignUrlApi) {
+ return new OssPresignResponseBodyAdvice(ossPresignUrlApi);
+ }
+
+ @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-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/annotation/OssPresignUrl.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/annotation/OssPresignUrl.java
new file mode 100644
index 0000000..08efbb6
--- /dev/null
+++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/annotation/OssPresignUrl.java
@@ -0,0 +1,17 @@
+package com.viewsh.framework.web.core.presign.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 标记需要自动预签名的 OSS URL 字段
+ *
+ * 使用方式:在 VO 的 String 类型字段上添加此注解,
+ * {@code OssPresignResponseBodyAdvice} 会在响应写出前批量替换为签名后的 URL。
+ */
+@Target({ElementType.FIELD, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface OssPresignUrl {
+}
diff --git a/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/core/OssPresignResponseBodyAdvice.java b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/core/OssPresignResponseBodyAdvice.java
new file mode 100644
index 0000000..4d94d95
--- /dev/null
+++ b/viewsh-framework/viewsh-spring-boot-starter-web/src/main/java/com/viewsh/framework/web/core/presign/core/OssPresignResponseBodyAdvice.java
@@ -0,0 +1,345 @@
+package com.viewsh.framework.web.core.presign.core;
+
+import cn.hutool.core.util.StrUtil;
+import com.viewsh.framework.common.biz.infra.file.OssPresignUrlApi;
+import com.viewsh.framework.common.pojo.CommonResult;
+import com.viewsh.framework.common.pojo.PageResult;
+import com.viewsh.framework.web.core.presign.annotation.OssPresignUrl;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.MethodParameter;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.server.ServerHttpRequest;
+import org.springframework.http.server.ServerHttpResponse;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 自动对 {@link CommonResult} 响应体中带 {@link OssPresignUrl} 注解的字段进行批量预签名
+ *
+ * 流程:
+ *
+ * - 反射递归扫描返回值中的 {@code @OssPresignUrl} 字段,收集原始 URL
+ * - 去重后调用 {@link OssPresignUrlApi#presignGetUrls} — 1 次批量 RPC
+ * - 将签名结果回填到对应字段
+ *
+ *
+ * 只扫描项目自身包({@code com.viewsh.})下的类,避免反射 JDK / 第三方库内部类导致
+ * Java 17 模块系统 {@link InaccessibleObjectException}。
+ */
+@Slf4j
+@ControllerAdvice
+public class OssPresignResponseBodyAdvice implements ResponseBodyAdvice