1. 重构附件下载与预览,使用 minio 的临时签名 url 进行,预览水印与 url 过期时间可通过配置方式自定义
This commit is contained in:
@@ -1,33 +1,42 @@
|
||||
package cn.iocoder.yudao.module.infra.controller.admin.file.vo.file;
|
||||
|
||||
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 lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
|
||||
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
|
||||
/**
|
||||
* @author chenbowen
|
||||
*/
|
||||
@Schema(description = "管理后台 - 文件 Response VO,不返回 content 字段,太大")
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class FileRespVO {
|
||||
public String getUrl() {
|
||||
if (this.url == null || this.url.isEmpty()) return null;
|
||||
Long fileId = this.id;
|
||||
Long userId = null;
|
||||
try {
|
||||
userId = getLoginUserId();
|
||||
} catch (Exception ignored) {}
|
||||
ShortLinkService shortLinkService = SpringUtils.getBean(ShortLinkService.class);
|
||||
String serverPrefix = SpringUtils.getProperty("yudao.shortlink.server");
|
||||
String key = (userId != null ? userId : "anonymous") + "_" + (fileId != null ? fileId : "nofile");
|
||||
return serverPrefix + shortLinkService.generateShortLink(key, this.url);
|
||||
// 加密附件不返回 url
|
||||
if (Boolean.TRUE.equals(this.isEncrypted)) {
|
||||
return null;
|
||||
}
|
||||
// 如果 url 已经是临时下载地址(如预签名 URL),直接返回
|
||||
if (url != null && (url.contains("X-Amz-Signature") || url.contains("?sign="))) {
|
||||
return url;
|
||||
}
|
||||
FileConfigService fileConfigService = SpringUtils.getBean(FileConfigService.class);
|
||||
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")
|
||||
@@ -50,6 +59,10 @@ public class FileRespVO {
|
||||
private String previewUrl;
|
||||
|
||||
public String getPreviewUrl() {
|
||||
// 加密附件不返回 previewUrl
|
||||
if (Boolean.TRUE.equals(this.isEncrypted)) {
|
||||
return null;
|
||||
}
|
||||
// 仅当 url 不为空时生成
|
||||
if (this.url == null || this.url.isEmpty()) {
|
||||
return null;
|
||||
@@ -59,15 +72,18 @@ public class FileRespVO {
|
||||
if (onlinePreview == null || onlinePreview.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String shortUrl = this.getUrl();
|
||||
if (shortUrl == null || shortUrl.isEmpty()) {
|
||||
String presignedUrl = this.getUrl();
|
||||
if (presignedUrl == null || presignedUrl.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String base64ShortUrl = Base64.getUrlEncoder().encodeToString(shortUrl.getBytes(StandardCharsets.UTF_8));
|
||||
return onlinePreview + base64ShortUrl
|
||||
// + "&fileName=" + URLEncoder.encode(this.getName(), StandardCharsets.UTF_8) + "&watermarkTxt=中国铜业"
|
||||
;
|
||||
String base64PresignedUrl = Base64.getUrlEncoder().encodeToString(presignedUrl.getBytes(StandardCharsets.UTF_8));
|
||||
String timestamp = String.valueOf(System.currentTimeMillis());
|
||||
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")
|
||||
private String type;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,7 @@ public interface FileMapper extends BaseMapperX<FileDO> {
|
||||
* @return 文件DO,若不存在返回null
|
||||
*/
|
||||
default FileDO selectByHash(String hash) {
|
||||
return selectOne(new LambdaQueryWrapperX<FileDO>()
|
||||
.eq(FileDO::getHash, hash));
|
||||
return selectFirstOne(FileDO::getHash, hash);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 java.time.Duration;
|
||||
|
||||
/**
|
||||
* 文件客户端
|
||||
*
|
||||
@@ -52,4 +54,8 @@ public interface FileClient {
|
||||
throw new UnsupportedOperationException("不支持的操作");
|
||||
}
|
||||
|
||||
default String getPresignedDownloadUrl(String path, Duration expiration){
|
||||
throw new UnsupportedOperationException("不支持的操作");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.util.StrUtil;
|
||||
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 software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
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.PutObjectRequest;
|
||||
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 java.net.URI;
|
||||
@@ -26,6 +28,36 @@ import java.time.Duration;
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||
/**
|
||||
* 生成临时下载地址(预签名下载 URL)
|
||||
* @param path 文件路径
|
||||
* @param expiration 过期时间(可为 null,null 时自动从配置获取)
|
||||
* @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 S3Presigner presigner;
|
||||
|
||||
@@ -33,9 +33,8 @@ public class DatabaseTableServiceImpl implements DatabaseTableService {
|
||||
@Override
|
||||
public List<TableInfo> getTableList(Long dataSourceConfigId, String nameLike, String commentLike) {
|
||||
List<TableInfo> tables = getTableList0(dataSourceConfigId, null);
|
||||
return tables.stream().filter(tableInfo ->
|
||||
(StrUtil.isEmpty(nameLike) || tableInfo.getName().toLowerCase().contains(nameLike.toLowerCase()))
|
||||
&& (StrUtil.isEmpty(commentLike) || tableInfo.getComment().contains(commentLike)))
|
||||
return tables.stream().filter(tableInfo -> (StrUtil.isEmpty(nameLike) || tableInfo.getName().contains(nameLike))
|
||||
&& (StrUtil.isEmpty(commentLike) || tableInfo.getComment().contains(commentLike)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,8 @@ public class FileServiceImpl implements FileService {
|
||||
.setUrl(entity.getUrl())
|
||||
.setType(entity.getType())
|
||||
.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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -114,8 +114,5 @@ spring:
|
||||
context-path: /admin # 配置 Spring
|
||||
|
||||
--- #################### 芋道相关配置 ####################
|
||||
yudao:
|
||||
shortlink:
|
||||
server: "http://172.16.46.63:48080/admin-api/infra/shortlink/"
|
||||
|
||||
|
||||
|
||||
@@ -140,5 +140,5 @@ yudao:
|
||||
enable: true
|
||||
# 固定验证码
|
||||
verify-code: 666666
|
||||
shortlink:
|
||||
server: "http://localhost:48080/admin-api/infra/shortlink/"
|
||||
file:
|
||||
download-expire-seconds: 20
|
||||
Reference in New Issue
Block a user