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-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 4ccbf08..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,12 +1,14 @@ 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; @@ -17,6 +19,7 @@ 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; @@ -34,6 +37,8 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandl 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 帖子 }) @@ -96,6 +101,12 @@ public class ViewshWebAutoConfiguration { return new GlobalResponseBodyHandler(); } + @Bean + @ConditionalOnBean(OssPresignUrlApi.class) + public OssPresignResponseBodyAdvice ossPresignResponseBodyAdvice(OssPresignUrlApi ossPresignUrlApi) { + return new OssPresignResponseBodyAdvice(ossPresignUrlApi); + } + @Bean @SuppressWarnings("InstantiationOfUtilityClass") public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) { 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} 注解的字段进行批量预签名 + *

+ * 流程: + *

    + *
  1. 反射递归扫描返回值中的 {@code @OssPresignUrl} 字段,收集原始 URL
  2. + *
  3. 去重后调用 {@link OssPresignUrlApi#presignGetUrls} — 1 次批量 RPC
  4. + *
  5. 将签名结果回填到对应字段
  6. + *
+ *

+ * 只扫描项目自身包({@code com.viewsh.})下的类,避免反射 JDK / 第三方库内部类导致 + * Java 17 模块系统 {@link InaccessibleObjectException}。 + */ +@Slf4j +@ControllerAdvice +public class OssPresignResponseBodyAdvice implements ResponseBodyAdvice { + + /** 项目包前缀,只有此前缀下的类才会被反射扫描 */ + private static final String BASE_PACKAGE = "com.viewsh."; + + /** 递归扫描最大深度,防止深层嵌套导致 StackOverflow */ + private static final int MAX_SCAN_DEPTH = 10; + + private final OssPresignUrlApi ossPresignUrlApi; + + /** 类 -> 带 @OssPresignUrl 注解的字段集合 缓存 */ + private static final Map, Set> FIELD_CACHE = new ConcurrentHashMap<>(); + + /** 类 -> 所有字段(含父类)缓存 */ + private static final Map, List> ALL_FIELDS_CACHE = new ConcurrentHashMap<>(); + + /** 空集合标记,避免对无注解字段的类反复扫描 */ + private static final Set EMPTY_FIELDS = Collections.emptySet(); + + /** 类 -> 是否包含(直接或嵌套)@OssPresignUrl 字段的缓存,用于 supports() 快速判断 */ + private static final Map, Boolean> HAS_PRESIGN_CACHE = new ConcurrentHashMap<>(); + + public OssPresignResponseBodyAdvice(OssPresignUrlApi ossPresignUrlApi) { + this.ossPresignUrlApi = ossPresignUrlApi; + } + + @Override + public boolean supports(MethodParameter returnType, Class> converterType) { + // 1. 必须是 CommonResult 返回类型 + if (!CommonResult.class.isAssignableFrom(returnType.getParameterType())) { + return false; + } + // 2. 通过泛型参数静态分析 VO 类是否包含 @OssPresignUrl 字段,无注解的接口直接跳过 + Class dataClass = resolveDataClass(returnType); + if (dataClass == null || !isProjectClass(dataClass)) { + // 无法解析泛型或非项目类,保守放行,交给 beforeBodyWrite 做运行时判断 + return true; + } + return hasPresignFields(dataClass, new HashSet<>(), 0); + } + + @Override + public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, + Class> selectedConverterType, + ServerHttpRequest request, ServerHttpResponse response) { + if (!(body instanceof CommonResult commonResult) || commonResult.getData() == null) { + return body; + } + try { + processData(commonResult.getData()); + } catch (Exception e) { + log.warn("[OssPresignResponseBodyAdvice] 预签名处理异常,保留原始 URL", e); + } + return body; + } + + // ===== 核心处理逻辑 ===== + + private void processData(Object data) { + // 1. 收集所有需要签名的 (对象, 字段, 原始URL) 三元组 + List entries = new ArrayList<>(); + collectUrls(data, entries, new IdentityHashMap<>(), 0); + if (entries.isEmpty()) { + return; + } + + // 2. 去重后批量签名 + LinkedHashSet uniqueUrls = new LinkedHashSet<>(); + for (FieldEntry entry : entries) { + uniqueUrls.add(entry.url); + } + List urlList = new ArrayList<>(uniqueUrls); + List signedList; + try { + signedList = ossPresignUrlApi.presignGetUrls(urlList); + } catch (Exception e) { + log.warn("[OssPresignResponseBodyAdvice] 批量签名 RPC 失败,保留原始 URL", e); + return; + } + + // 3. 构建映射并回填 + if (signedList.size() != urlList.size()) { + log.warn("[OssPresignResponseBodyAdvice] 签名结果数量({})与请求数量({})不一致,保留原始 URL", + signedList.size(), urlList.size()); + return; + } + Map signedMap = new HashMap<>(urlList.size()); + for (int i = 0; i < urlList.size(); i++) { + signedMap.put(urlList.get(i), signedList.get(i)); + } + for (FieldEntry entry : entries) { + String signed = signedMap.get(entry.url); + if (signed != null) { + try { + entry.field.set(entry.target, signed); + } catch (IllegalAccessException e) { + log.warn("[OssPresignResponseBodyAdvice] 回填签名 URL 失败: field={}", entry.field.getName(), e); + } + } + } + } + + /** + * 递归收集带 @OssPresignUrl 注解的字段值 + * + * @param obj 当前扫描的对象 + * @param entries 收集结果 + * @param visited 已访问对象(防止循环引用) + * @param depth 当前递归深度 + */ + private void collectUrls(Object obj, List entries, IdentityHashMap visited, int depth) { + if (obj == null || visited.containsKey(obj)) { + return; + } + if (depth > MAX_SCAN_DEPTH) { + log.debug("[OssPresignResponseBodyAdvice] 递归深度超过 {},停止扫描: class={}", MAX_SCAN_DEPTH, obj.getClass().getName()); + return; + } + visited.put(obj, Boolean.TRUE); + + // PageResult:遍历 list + if (obj instanceof PageResult pageResult) { + if (pageResult.getList() != null) { + for (Object item : pageResult.getList()) { + collectUrls(item, entries, visited, depth + 1); + } + } + return; + } + + // Collection:遍历元素 + if (obj instanceof Collection collection) { + for (Object item : collection) { + collectUrls(item, entries, visited, depth + 1); + } + return; + } + + // 只扫描项目自身包下的类 + Class clazz = obj.getClass(); + if (!isProjectClass(clazz)) { + return; + } + + // 获取带注解的字段 + Set annotatedFields = getAnnotatedFields(clazz); + + // 扫描所有字段 + for (Field field : getAllFields(clazz)) { + try { + Object value = field.get(obj); + if (value == null) { + continue; + } + if (annotatedFields.contains(field)) { + // 带注解的 String 字段:收集 URL + if (value instanceof String url && StrUtil.isNotEmpty(url)) { + entries.add(new FieldEntry(obj, field, url)); + } + } else { + // 非简单类型字段:递归(递归入口会再次判断 isProjectClass) + collectUrls(value, entries, visited, depth + 1); + } + } catch (IllegalAccessException e) { + log.debug("[OssPresignResponseBodyAdvice] 字段不可访问: class={}, field={}", clazz.getSimpleName(), field.getName()); + } + } + } + + // ===== 反射工具方法 ===== + + /** + * 判断是否为项目自身的类,只有项目类才需要反射扫描 @OssPresignUrl + */ + private boolean isProjectClass(Class clazz) { + return clazz.getName().startsWith(BASE_PACKAGE); + } + + /** + * 获取类中带 @OssPresignUrl 注解的字段集合(带缓存) + */ + private Set getAnnotatedFields(Class clazz) { + return FIELD_CACHE.computeIfAbsent(clazz, c -> { + Set result = new HashSet<>(); + for (Field field : getAllFields(c)) { + if (field.isAnnotationPresent(OssPresignUrl.class) && field.getType() == String.class) { + result.add(field); + } + } + return result.isEmpty() ? EMPTY_FIELDS : result; + }); + } + + /** + * 获取类的所有字段(含父类,止于非项目类),带缓存 + */ + private List getAllFields(Class clazz) { + return ALL_FIELDS_CACHE.computeIfAbsent(clazz, c -> { + List fields = new ArrayList<>(); + Class current = c; + while (current != null && isProjectClass(current)) { + for (Field field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + field.setAccessible(true); + fields.add(field); + } + current = current.getSuperclass(); + } + return fields; + }); + } + + // ===== supports() 静态类型分析 ===== + + /** + * 从 CommonResult 的泛型参数中解析出 T 的实际 Class + *

+ * 支持:CommonResult<UserRespVO>、CommonResult<PageResult<UserRespVO>>、 + * CommonResult<List<UserRespVO>> + */ + private Class resolveDataClass(MethodParameter returnType) { + Type genericType = returnType.getGenericParameterType(); + // CommonResult + Class dataClass = extractFirstTypeArg(genericType); + if (dataClass == null) { + return null; + } + // 如果是 PageResult / Collection,继续剥一层 + if (PageResult.class.isAssignableFrom(dataClass) || Collection.class.isAssignableFrom(dataClass)) { + Type inner = genericType instanceof ParameterizedType pt ? pt.getActualTypeArguments()[0] : null; + Class innerClass = extractFirstTypeArg(inner); + return innerClass != null ? innerClass : dataClass; + } + return dataClass; + } + + /** + * 提取 ParameterizedType 的第一个类型参数的原始类 + */ + private Class extractFirstTypeArg(Type type) { + if (type instanceof ParameterizedType pt) { + Type[] args = pt.getActualTypeArguments(); + if (args.length > 0) { + Type arg = args[0]; + if (arg instanceof Class clazz) { + return clazz; + } + if (arg instanceof ParameterizedType argPt && argPt.getRawType() instanceof Class rawClass) { + return rawClass; + } + } + } + return null; + } + + /** + * 递归检查类(及其嵌套项目类字段)是否包含 @OssPresignUrl 注解(带缓存) + */ + private boolean hasPresignFields(Class clazz, Set> visiting, int depth) { + if (clazz == null || !isProjectClass(clazz) || depth > MAX_SCAN_DEPTH) { + return false; + } + // 缓存命中 + Boolean cached = HAS_PRESIGN_CACHE.get(clazz); + if (cached != null) { + return cached; + } + // 防止循环引用导致无限递归 + if (!visiting.add(clazz)) { + return false; + } + try { + // 直接有注解字段 + if (getAnnotatedFields(clazz) != EMPTY_FIELDS) { + HAS_PRESIGN_CACHE.put(clazz, true); + return true; + } + // 递归检查嵌套的项目类字段 + for (Field field : getAllFields(clazz)) { + Class fieldType = field.getType(); + if (isProjectClass(fieldType) && hasPresignFields(fieldType, visiting, depth + 1)) { + HAS_PRESIGN_CACHE.put(clazz, true); + return true; + } + // Collection 类型的字段,检查泛型参数 + if (Collection.class.isAssignableFrom(fieldType)) { + Type genericFieldType = field.getGenericType(); + Class elementClass = extractFirstTypeArg(genericFieldType); + if (elementClass != null && isProjectClass(elementClass) + && hasPresignFields(elementClass, visiting, depth + 1)) { + HAS_PRESIGN_CACHE.put(clazz, true); + return true; + } + } + } + HAS_PRESIGN_CACHE.put(clazz, false); + return false; + } finally { + visiting.remove(clazz); + } + } + + private record FieldEntry(Object target, Field field, String url) {} + +}