feat(framework): 新增 @OssPresignUrl 注解与 ResponseBodyAdvice 自动预签名框架

基于 ResponseBodyAdvice 拦截 CommonResult 响应体,通过反射递归扫描
VO 中标注 @OssPresignUrl 的 String 字段,去重后批量调用
OssPresignUrlApi 一次性完成预签名,再回填到对应字段。

核心设计:
- supports() 阶段通过泛型静态分析判断 VO 是否含注解字段,
  无注解的接口零开销跳过(类似字典翻译注解思路)
- 三级缓存:FIELD_CACHE / ALL_FIELDS_CACHE / HAS_PRESIGN_CACHE
- 递归深度限制 MAX_SCAN_DEPTH=10 防止 StackOverflow
- 仅扫描 com.viewsh.* 包,规避 Java 17 模块系统限制
- 异常静默降级,保留原始 URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-18 15:05:42 +08:00
parent f792ee1678
commit f3299bd655
4 changed files with 393 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
package com.viewsh.framework.common.biz.infra.file;
import java.util.List;
/**
* OSS 预签名 URL 通用接口
* <p>
* 由 infra 模块提供实现,供 {@code OssPresignResponseBodyAdvice} 等框架组件使用
*/
public interface OssPresignUrlApi {
/**
* 批量生成文件预签名地址
*
* @param urls 原始 URL 列表
* @return 签名后的 URL 列表(与入参顺序一致)
*/
List<String> presignGetUrls(List<String> urls);
}

View File

@@ -1,12 +1,14 @@
package com.viewsh.framework.web.config; package com.viewsh.framework.web.config;
import cn.hutool.core.util.StrUtil; 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.biz.infra.logger.ApiErrorLogCommonApi;
import com.viewsh.framework.common.enums.WebFilterOrderEnum; import com.viewsh.framework.common.enums.WebFilterOrderEnum;
import com.viewsh.framework.web.core.filter.CacheRequestBodyFilter; import com.viewsh.framework.web.core.filter.CacheRequestBodyFilter;
import com.viewsh.framework.web.core.filter.DemoFilter; import com.viewsh.framework.web.core.filter.DemoFilter;
import com.viewsh.framework.web.core.handler.GlobalExceptionHandler; import com.viewsh.framework.web.core.handler.GlobalExceptionHandler;
import com.viewsh.framework.web.core.handler.GlobalResponseBodyHandler; 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.viewsh.framework.web.core.util.WebFrameworkUtils;
import com.google.common.collect.Maps; import com.google.common.collect.Maps;
import jakarta.servlet.Filter; 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.client.RestTemplateAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations; import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations;
import org.springframework.boot.context.properties.EnableConfigurationProperties; 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.client.RestTemplateBuilder;
import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.cloud.client.loadbalancer.LoadBalanced; 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.Map;
import java.util.function.Predicate; import java.util.function.Predicate;
// ⚠ 此类被 OssPresignUrlApiAutoConfiguration 通过 beforeName 字符串引用,
// 重命名/移动时须同步修改。搜索关键字PRESIGN_AUTO_CONFIG_ORDERING
@AutoConfiguration(beforeName = { @AutoConfiguration(beforeName = {
"com.fhs.trans.config.TransServiceConfig" // cloud 独有避免一键改包后RestTemplate 初始化的冲突。可见 https://t.zsxq.com/T4yj7 帖子 "com.fhs.trans.config.TransServiceConfig" // cloud 独有避免一键改包后RestTemplate 初始化的冲突。可见 https://t.zsxq.com/T4yj7 帖子
}) })
@@ -96,6 +101,12 @@ public class ViewshWebAutoConfiguration {
return new GlobalResponseBodyHandler(); return new GlobalResponseBodyHandler();
} }
@Bean
@ConditionalOnBean(OssPresignUrlApi.class)
public OssPresignResponseBodyAdvice ossPresignResponseBodyAdvice(OssPresignUrlApi ossPresignUrlApi) {
return new OssPresignResponseBodyAdvice(ossPresignUrlApi);
}
@Bean @Bean
@SuppressWarnings("InstantiationOfUtilityClass") @SuppressWarnings("InstantiationOfUtilityClass")
public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) { public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) {

View File

@@ -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 字段
* <p>
* 使用方式:在 VO 的 String 类型字段上添加此注解,
* {@code OssPresignResponseBodyAdvice} 会在响应写出前批量替换为签名后的 URL。
*/
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface OssPresignUrl {
}

View File

@@ -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} 注解的字段进行批量预签名
* <p>
* 流程:
* <ol>
* <li>反射递归扫描返回值中的 {@code @OssPresignUrl} 字段,收集原始 URL</li>
* <li>去重后调用 {@link OssPresignUrlApi#presignGetUrls} — 1 次批量 RPC</li>
* <li>将签名结果回填到对应字段</li>
* </ol>
* <p>
* 只扫描项目自身包({@code com.viewsh.})下的类,避免反射 JDK / 第三方库内部类导致
* Java 17 模块系统 {@link InaccessibleObjectException}。
*/
@Slf4j
@ControllerAdvice
public class OssPresignResponseBodyAdvice implements ResponseBodyAdvice<Object> {
/** 项目包前缀,只有此前缀下的类才会被反射扫描 */
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<Class<?>, Set<Field>> FIELD_CACHE = new ConcurrentHashMap<>();
/** 类 -> 所有字段(含父类)缓存 */
private static final Map<Class<?>, List<Field>> ALL_FIELDS_CACHE = new ConcurrentHashMap<>();
/** 空集合标记,避免对无注解字段的类反复扫描 */
private static final Set<Field> EMPTY_FIELDS = Collections.emptySet();
/** 类 -> 是否包含(直接或嵌套)@OssPresignUrl 字段的缓存,用于 supports() 快速判断 */
private static final Map<Class<?>, Boolean> HAS_PRESIGN_CACHE = new ConcurrentHashMap<>();
public OssPresignResponseBodyAdvice(OssPresignUrlApi ossPresignUrlApi) {
this.ossPresignUrlApi = ossPresignUrlApi;
}
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> 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<? extends HttpMessageConverter<?>> 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<FieldEntry> entries = new ArrayList<>();
collectUrls(data, entries, new IdentityHashMap<>(), 0);
if (entries.isEmpty()) {
return;
}
// 2. 去重后批量签名
LinkedHashSet<String> uniqueUrls = new LinkedHashSet<>();
for (FieldEntry entry : entries) {
uniqueUrls.add(entry.url);
}
List<String> urlList = new ArrayList<>(uniqueUrls);
List<String> 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<String, String> 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<FieldEntry> entries, IdentityHashMap<Object, Boolean> 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<Field> 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<Field> getAnnotatedFields(Class<?> clazz) {
return FIELD_CACHE.computeIfAbsent(clazz, c -> {
Set<Field> 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<Field> getAllFields(Class<?> clazz) {
return ALL_FIELDS_CACHE.computeIfAbsent(clazz, c -> {
List<Field> 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> 的泛型参数中解析出 T 的实际 Class
* <p>
* 支持CommonResult&lt;UserRespVO&gt;、CommonResult&lt;PageResult&lt;UserRespVO&gt;&gt;、
* CommonResult&lt;List&lt;UserRespVO&gt;&gt;
*/
private Class<?> resolveDataClass(MethodParameter returnType) {
Type genericType = returnType.getGenericParameterType();
// CommonResult<T>
Class<?> dataClass = extractFirstTypeArg(genericType);
if (dataClass == null) {
return null;
}
// 如果是 PageResult<T> / Collection<T>,继续剥一层
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<Class<?>> 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<T> 类型的字段,检查泛型参数
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) {}
}