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:
@@ -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);
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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<UserRespVO>、CommonResult<PageResult<UserRespVO>>、
|
||||
* CommonResult<List<UserRespVO>>
|
||||
*/
|
||||
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) {}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user