1. 重构附件下载与预览,使用 minio 的临时签名 url 进行,预览水印与 url 过期时间可通过配置方式自定义

This commit is contained in:
chenbowen
2025-07-28 09:38:36 +08:00
parent 296cb6d59d
commit 4479c3c0b7
11 changed files with 80 additions and 178 deletions

View File

@@ -1,33 +1,42 @@
package cn.iocoder.yudao.module.infra.controller.admin.file.vo.file; package cn.iocoder.yudao.module.infra.controller.admin.file.vo.file;
import cn.iocoder.yudao.framework.common.util.spring.SpringUtils; import cn.iocoder.yudao.framework.common.util.spring.SpringUtils;
import cn.iocoder.yudao.module.infra.service.shortlink.ShortLinkService; import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClient;
import cn.iocoder.yudao.module.infra.service.file.FileConfigService;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import lombok.experimental.Accessors;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Base64; import java.util.Base64;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
/** /**
* @author chenbowen * @author chenbowen
*/ */
@Schema(description = "管理后台 - 文件 Response VO,不返回 content 字段,太大") @Schema(description = "管理后台 - 文件 Response VO,不返回 content 字段,太大")
@Data @Data
@Accessors(chain = true)
public class FileRespVO { public class FileRespVO {
public String getUrl() { public String getUrl() {
if (this.url == null || this.url.isEmpty()) return null; // 加密附件不返回 url
Long fileId = this.id; if (Boolean.TRUE.equals(this.isEncrypted)) {
Long userId = null; return null;
try { }
userId = getLoginUserId(); // 如果 url 已经是临时下载地址(如预签名 URL直接返回
} catch (Exception ignored) {} if (url != null && (url.contains("X-Amz-Signature") || url.contains("?sign="))) {
ShortLinkService shortLinkService = SpringUtils.getBean(ShortLinkService.class); return url;
String serverPrefix = SpringUtils.getProperty("yudao.shortlink.server"); }
String key = (userId != null ? userId : "anonymous") + "_" + (fileId != null ? fileId : "nofile"); FileConfigService fileConfigService = SpringUtils.getBean(FileConfigService.class);
return serverPrefix + shortLinkService.generateShortLink(key, this.url); FileClient fileClient = fileConfigService.getMasterFileClient();
if (fileClient instanceof S3FileClient s3FileClient) {
String presignedDownloadUrl = s3FileClient.getPresignedDownloadUrl(this.path, null);
if (presignedDownloadUrl != null && !presignedDownloadUrl.isEmpty()) {
return presignedDownloadUrl;
}
}
return url;
} }
@Schema(description = "文件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") @Schema(description = "文件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@@ -50,6 +59,10 @@ public class FileRespVO {
private String previewUrl; private String previewUrl;
public String getPreviewUrl() { public String getPreviewUrl() {
// 加密附件不返回 previewUrl
if (Boolean.TRUE.equals(this.isEncrypted)) {
return null;
}
// 仅当 url 不为空时生成 // 仅当 url 不为空时生成
if (this.url == null || this.url.isEmpty()) { if (this.url == null || this.url.isEmpty()) {
return null; return null;
@@ -59,15 +72,18 @@ public class FileRespVO {
if (onlinePreview == null || onlinePreview.isEmpty()) { if (onlinePreview == null || onlinePreview.isEmpty()) {
return null; return null;
} }
String shortUrl = this.getUrl(); String presignedUrl = this.getUrl();
if (shortUrl == null || shortUrl.isEmpty()) { if (presignedUrl == null || presignedUrl.isEmpty()) {
return null; return null;
} }
String base64ShortUrl = Base64.getUrlEncoder().encodeToString(shortUrl.getBytes(StandardCharsets.UTF_8)); String base64PresignedUrl = Base64.getUrlEncoder().encodeToString(presignedUrl.getBytes(StandardCharsets.UTF_8));
return onlinePreview + base64ShortUrl String timestamp = String.valueOf(System.currentTimeMillis());
// + "&fileName=" + URLEncoder.encode(this.getName(), StandardCharsets.UTF_8) + "&watermarkTxt=中国铜业" String watermark = SpringUtils.getProperty("aj.captcha.water-mark", "中国铜业");
; return onlinePreview + base64PresignedUrl + "&t=" + timestamp + "&watermarkTxt=" + watermark;
} }
@Schema(description = "是否加密", example = "false")
private Boolean isEncrypted;
@Schema(description = "文件MIME类型", example = "application/octet-stream") @Schema(description = "文件MIME类型", example = "application/octet-stream")
private String type; private String type;

View File

@@ -1,77 +0,0 @@
package cn.iocoder.yudao.module.infra.controller.admin.shortlink;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import cn.iocoder.yudao.module.infra.service.shortlink.ShortLinkService;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.security.PermitAll;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
/**
* @author chenbowen
*/
@Tag(name = "管理后台 - 文件存储")
@Validated
@Slf4j
@RestController
@RequestMapping("/infra/shortlink")
public class ShortLinkController {
@Autowired
private ShortLinkService shortLinkService;
/**
* 生成短链接
*/
@PostMapping("/generate")
public CommonResult<String> generateShortLink(@RequestParam("key") String key, @RequestParam("url") String url) {
String shortLink = "/infra/shortlink/" + shortLinkService.generateShortLink(key, url);
return CommonResult.success(shortLink);
}
/**
* 解析短链接并重定向
*/
@GetMapping("/{shortKey}")
@PermitAll
@TenantIgnore
public ResponseEntity<byte[]> parseShortLink(@PathVariable("shortKey") String shortKey) {
String realUrl = shortLinkService.parseShortLink(shortKey);
if (realUrl != null) {
RestTemplate restTemplate = new RestTemplate();
try {
ResponseEntity<byte[]> entity = restTemplate.exchange(realUrl, HttpMethod.GET, HttpEntity.EMPTY, byte[].class);
HttpHeaders headers = new HttpHeaders();
// 只透传部分常用header可根据需要扩展
if (entity.getHeaders().getContentType() != null) {
headers.setContentType(entity.getHeaders().getContentType());
}
if (entity.getHeaders().getContentLength() > 0) {
headers.setContentLength(entity.getHeaders().getContentLength());
}
// 透传 disposition/encoding 等
String disposition = entity.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
if (disposition != null) {
headers.set(HttpHeaders.CONTENT_DISPOSITION, disposition);
}
String encoding = entity.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING);
if (encoding != null) {
headers.set(HttpHeaders.CONTENT_ENCODING, encoding);
}
return new ResponseEntity<>(entity.getBody(), headers, entity.getStatusCode());
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(("目标链接内容获取异常: " + e.getMessage()).getBytes());
}
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("当前文件请求链接已失效,请从系统重新进行请求操作".getBytes());
}
}
}

View File

@@ -29,8 +29,7 @@ public interface FileMapper extends BaseMapperX<FileDO> {
* @return 文件DO若不存在返回null * @return 文件DO若不存在返回null
*/ */
default FileDO selectByHash(String hash) { default FileDO selectByHash(String hash) {
return selectOne(new LambdaQueryWrapperX<FileDO>() return selectFirstOne(FileDO::getHash, hash);
.eq(FileDO::getHash, hash));
} }
} }

View File

@@ -2,6 +2,8 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client;
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO;
import java.time.Duration;
/** /**
* 文件客户端 * 文件客户端
* *
@@ -52,4 +54,8 @@ public interface FileClient {
throw new UnsupportedOperationException("不支持的操作"); throw new UnsupportedOperationException("不支持的操作");
} }
default String getPresignedDownloadUrl(String path, Duration expiration){
throw new UnsupportedOperationException("不支持的操作");
}
} }

View File

@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.s3;
import cn.hutool.core.io.IoUtil; import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil; import cn.hutool.http.HttpUtil;
import cn.iocoder.yudao.framework.common.util.spring.SpringUtils;
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
@@ -15,6 +16,7 @@ import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import java.net.URI; import java.net.URI;
@@ -26,6 +28,36 @@ import java.time.Duration;
* @author 芋道源码 * @author 芋道源码
*/ */
public class S3FileClient extends AbstractFileClient<S3FileClientConfig> { public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
/**
* 生成临时下载地址(预签名下载 URL
* @param path 文件路径
* @param expiration 过期时间(可为 nullnull 时自动从配置获取)
* @return 下载 URL
*/
@Override
public String getPresignedDownloadUrl(String path, Duration expiration) {
Duration realExpiration = expiration;
if (realExpiration == null) {
long expireSeconds = 30; // 默认 30 秒
try {
String val = SpringUtils.getProperty("yudao.file.download-expire-seconds");
if (val != null && !val.isEmpty()) {
expireSeconds = Long.parseLong(val);
}
} catch (Exception ignored) {}
realExpiration = Duration.ofSeconds(expireSeconds);
}
// 使用 S3 官方支持的 responseCacheControl 参数,强制浏览器和代理不缓存
GetObjectRequest.Builder getObjectRequestBuilder = GetObjectRequest.builder()
.bucket(config.getBucket())
.key(path)
.responseCacheControl("no-cache, no-store, must-revalidate");
GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder()
.signatureDuration(realExpiration)
.getObjectRequest(getObjectRequestBuilder.build())
.build();
return presigner.presignGetObject(getObjectPresignRequest).url().toString();
}
private S3Client client; private S3Client client;
private S3Presigner presigner; private S3Presigner presigner;

View File

@@ -33,9 +33,8 @@ public class DatabaseTableServiceImpl implements DatabaseTableService {
@Override @Override
public List<TableInfo> getTableList(Long dataSourceConfigId, String nameLike, String commentLike) { public List<TableInfo> getTableList(Long dataSourceConfigId, String nameLike, String commentLike) {
List<TableInfo> tables = getTableList0(dataSourceConfigId, null); List<TableInfo> tables = getTableList0(dataSourceConfigId, null);
return tables.stream().filter(tableInfo -> return tables.stream().filter(tableInfo -> (StrUtil.isEmpty(nameLike) || tableInfo.getName().contains(nameLike))
(StrUtil.isEmpty(nameLike) || tableInfo.getName().toLowerCase().contains(nameLike.toLowerCase())) && (StrUtil.isEmpty(commentLike) || tableInfo.getComment().contains(commentLike)))
&& (StrUtil.isEmpty(commentLike) || tableInfo.getComment().contains(commentLike)))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

View File

@@ -131,7 +131,8 @@ public class FileServiceImpl implements FileService {
.setUrl(entity.getUrl()) .setUrl(entity.getUrl())
.setType(entity.getType()) .setType(entity.getType())
.setSize(entity.getSize()) .setSize(entity.getSize())
.setConfigId(entity.getConfigId()); .setConfigId(entity.getConfigId())
.setIsEncrypted(entity.getAesIv() != null && !entity.getAesIv().isEmpty());
} }
private FileDO uploadFile(byte[] content, String name, String directory, String type, Boolean encrypt) throws Exception { private FileDO uploadFile(byte[] content, String name, String directory, String type, Boolean encrypt) throws Exception {

View File

@@ -1,13 +0,0 @@
package cn.iocoder.yudao.module.infra.service.shortlink;
public interface ShortLinkService {
/**
* 生成短链接
*/
String generateShortLink(String key, String url);
/**
* 解析短链接
*/
String parseShortLink(String shortKey);
}

View File

@@ -1,58 +0,0 @@
package cn.iocoder.yudao.module.infra.service.shortlink;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.TimeUnit;
@Service
public class ShortLinkServiceImpl implements ShortLinkService {
private final StringRedisTemplate redisTemplate;
@Value("${yudao.shortlink.expire-seconds:30}")
private long expireSeconds;
public ShortLinkServiceImpl(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 生成短链接
*/
@Override
public String generateShortLink(String key, String url) {
// 使用url生成hash作为短链key
String hashKey = hashUrl(url);
// 存入Redis并设置过期时间
redisTemplate.opsForValue().set(hashKey, url, expireSeconds, TimeUnit.SECONDS);
return hashKey;
}
private String hashUrl(String url) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(url.getBytes());
StringBuilder sb = new StringBuilder();
// 只取前8字节够短链用
for (int i = 0; i < 8 && i < hash.length; i++) {
sb.append(String.format("%02x", hash[i]));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("Hash算法异常", e);
}
}
/**
* 解析短链接
*/
@Override
public String parseShortLink(String shortKey) {
// 直接从Redis获取
return redisTemplate.opsForValue().get(shortKey);
}
}

View File

@@ -114,8 +114,5 @@ spring:
context-path: /admin # 配置 Spring context-path: /admin # 配置 Spring
--- #################### 芋道相关配置 #################### --- #################### 芋道相关配置 ####################
yudao:
shortlink:
server: "http://172.16.46.63:48080/admin-api/infra/shortlink/"

View File

@@ -140,5 +140,5 @@ yudao:
enable: true enable: true
# 固定验证码 # 固定验证码
verify-code: 666666 verify-code: 666666
shortlink: file:
server: "http://localhost:48080/admin-api/infra/shortlink/" download-expire-seconds: 20