feat(infra): 批量预签名 API 及单体/微服务双模式自动配置

新增 FileApi.presignGetUrls 批量签名接口(@NotEmpty + @Size(max=500)),
FileServiceImpl 实现带 null 守卫。

自动配置设计:
- 单体模式:ViewshFileAutoConfiguration 直连 FileService
- 微服务模式:OssPresignUrlApiAutoConfiguration 通过 Feign 代理
- 通过 @ConditionalOnMissingBean 互斥,保证同一 JVM 只有一个实现

新增 OssPresignHelper 工具类,供 Handler 层处理动态 Map 字段
(如 extInfo 中的图片 URL),提供静默降级的单个/批量/JSON数组签名方法。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-18 15:06:05 +08:00
parent f3299bd655
commit 807d44e398
8 changed files with 508 additions and 347 deletions

View File

@@ -7,12 +7,15 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@FeignClient(name = ApiConstants.NAME) // TODO 芋艿fallbackFactory =
@Tag(name = "RPC 服务 - 文件")
public interface FileApi {
@@ -70,4 +73,16 @@ public interface FileApi {
CommonResult<String> presignGetUrl(@NotEmpty(message = "URL 不能为空") @RequestParam("url") String url,
@RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds);
/**
* 批量生成文件预签名地址,用于读取
*
* @param urls 完整的文件访问地址列表
* @param expirationSeconds 访问有效期,单位秒
* @return 签名后的 URL 列表(与入参顺序一致)
*/
@PostMapping(PREFIX + "/presigned-urls")
@Operation(summary = "批量生成文件预签名地址,用于读取")
CommonResult<List<String>> presignGetUrls(@RequestBody @NotEmpty(message = "URL 列表不能为空") @Size(max = 500, message = "批量签名数量不能超过 500") List<String> urls,
@RequestParam(value = "expirationSeconds", required = false) Integer expirationSeconds);
}

View File

@@ -0,0 +1,80 @@
package com.viewsh.module.infra.api.file;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
import java.util.stream.Collectors;
/**
* OSS 预签名 URL 工具类
* <p>
* 供 Handler 层处理动态 Map 字段(如 extInfo中的文件 URL
*/
@Slf4j
public final class OssPresignHelper {
private OssPresignHelper() {}
/**
* 单个 URL 签名(静默降级:失败返回原始 URL
*/
public static String presignQuietly(FileApi fileApi, String url) {
if (StrUtil.isEmpty(url)) {
return url;
}
try {
return fileApi.presignGetUrl(url, null).getCheckedData();
} catch (Exception e) {
log.warn("[presignQuietly] URL 签名失败: {}", url, e);
return url;
}
}
/**
* JSON 数组 URL 签名:["url1","url2"] -> ["signed1","signed2"](静默降级)
*/
public static String presignJsonArrayQuietly(FileApi fileApi, String urlsJson) {
if (StrUtil.isEmpty(urlsJson)) {
return urlsJson;
}
try {
List<String> urls = JSONUtil.toList(urlsJson, String.class);
if (urls.isEmpty()) {
return urlsJson;
}
Map<String, String> signedMap = presignBatch(fileApi, urls);
List<String> signedUrls = urls.stream()
.map(u -> signedMap.getOrDefault(u, u))
.collect(Collectors.toList());
return JSONUtil.toJsonStr(signedUrls);
} catch (Exception e) {
log.warn("[presignJsonArrayQuietly] JSON 数组签名失败: {}", urlsJson, e);
return urlsJson;
}
}
/**
* 批量签名(一次 RPC返回 原始URL -> 签名URL 映射
*/
public static Map<String, String> presignBatch(FileApi fileApi, Collection<String> urls) {
if (urls == null || urls.isEmpty()) {
return Collections.emptyMap();
}
// 去重
List<String> uniqueUrls = new ArrayList<>(new LinkedHashSet<>(urls));
try {
List<String> signedUrls = fileApi.presignGetUrls(uniqueUrls, null).getCheckedData();
Map<String, String> map = new HashMap<>(uniqueUrls.size());
for (int i = 0; i < uniqueUrls.size(); i++) {
map.put(uniqueUrls.get(i), signedUrls.get(i));
}
return map;
} catch (Exception e) {
log.warn("[presignBatch] 批量签名失败", e);
return Collections.emptyMap();
}
}
}

View File

@@ -0,0 +1,31 @@
package com.viewsh.module.infra.api.file.config;
import com.viewsh.framework.common.biz.infra.file.OssPresignUrlApi;
import com.viewsh.module.infra.api.file.FileApi;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
/**
* 基于 {@link FileApi} Feign 客户端的 {@link OssPresignUrlApi} 自动配置
* <p>
* 微服务模式下,各服务通过 Feign 调用 infra-server 进行批量预签名。
* 单体模式下infra-server 中的 {@code ViewshFileAutoConfiguration} 会直连 FileService
* 通过 {@link ConditionalOnMissingBean} 保证不冲突。
*/
// ⚠ beforeName 引用了 ViewshWebAutoConfiguration 的全限定名(字符串),
// 因为 infra-api 不依赖 viewsh-spring-boot-starter-web无法使用 before = ViewshWebAutoConfiguration.class。
// 如果重命名/移动 ViewshWebAutoConfiguration必须同步修改此处字符串。
// grep 关键字以便重构时检索PRESIGN_AUTO_CONFIG_ORDERING
@AutoConfiguration(beforeName = "com.viewsh.framework.web.config.ViewshWebAutoConfiguration")
public class OssPresignUrlApiAutoConfiguration {
@Bean
@ConditionalOnBean(FileApi.class)
@ConditionalOnMissingBean(OssPresignUrlApi.class)
public OssPresignUrlApi ossPresignUrlApi(FileApi fileApi) {
return urls -> fileApi.presignGetUrls(urls, null).getCheckedData();
}
}

View File

@@ -0,0 +1 @@
com.viewsh.module.infra.api.file.config.OssPresignUrlApiAutoConfiguration