diff --git a/frontend/app/web-gold/src/composables/useUpload.js b/frontend/app/web-gold/src/composables/useUpload.js
index 3a9f8ae2a7..7a0f0c9e64 100644
--- a/frontend/app/web-gold/src/composables/useUpload.js
+++ b/frontend/app/web-gold/src/composables/useUpload.js
@@ -114,6 +114,7 @@ export function useUpload() {
? presignedData.presignedUrl.replace(OSS_DOMAIN, '/oss')
: presignedData.presignedUrl
xhr.open('PUT', uploadUrl)
+ // 设置 Content-Type header(必须与签名时一致)
if (presignedData.headers && presignedData.headers['Content-Type']) {
xhr.setRequestHeader('Content-Type', presignedData.headers['Content-Type'])
}
@@ -169,12 +170,13 @@ export function useUpload() {
duration = await getVideoDuration(file)
}
- // 第一步:获取预签名URL
+ // 第一步:获取预签名URL(传递文件的 MIME 类型)
const presignedData = await MaterialService.getPresignedUrl({
fileName: file.name,
fileCategory,
groupId,
- fileSize: file.size
+ fileSize: file.size,
+ contentType: file.type || undefined // 传递文件的 MIME 类型
})
// 第二步:直传文件到OSS
diff --git a/yudao-module-infra/pom.xml b/yudao-module-infra/pom.xml
index bd0e6c7408..9dc030cd2e 100644
--- a/yudao-module-infra/pom.xml
+++ b/yudao-module-infra/pom.xml
@@ -109,6 +109,12 @@
software.amazon.awssdk
s3
+
+
+ com.aliyun.oss
+ aliyun-sdk-oss
+ 3.18.1
+
org.apache.tika
diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java
index cf1cd620ae..8120fd69e3 100644
--- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java
+++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java
@@ -52,6 +52,17 @@ public interface FileClient {
throw new UnsupportedOperationException("不支持的操作");
}
+ /**
+ * 获得文件预签名地址,用于上传(包含 Content-Type 签名)
+ *
+ * @param path 相对路径
+ * @param contentType 文件的 Content-Type
+ * @return 文件预签名地址
+ */
+ default String presignPutUrl(String path, String contentType) {
+ throw new UnsupportedOperationException("不支持的操作");
+ }
+
/**
* 生成文件预签名地址,用于读取
*
diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java
index 3c36413ae5..663ff97523 100644
--- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java
+++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java
@@ -7,6 +7,11 @@ import cn.hutool.core.util.URLUtil;
import cn.hutool.http.HttpUtil;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
+import com.aliyun.oss.OSS;
+import com.aliyun.oss.OSSClient;
+import com.aliyun.oss.HttpMethod;
+import com.aliyun.oss.model.OSSObject;
+import com.aliyun.oss.model.ObjectMetadata;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
@@ -16,15 +21,16 @@ import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
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.io.ByteArrayInputStream;
import java.net.URI;
import java.net.URL;
-import java.time.Duration;
import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Date;
import org.springframework.web.util.UriUtils;
@@ -36,9 +42,15 @@ import org.springframework.web.util.UriUtils;
public class S3FileClient extends AbstractFileClient {
private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24);
+ private static final String DEFAULT_REGION = "us-east-1";
- private S3Client client;
- private S3Presigner presigner;
+ /** S3 客户端(非阿里云使用) */
+ private S3Client s3Client;
+ private S3Presigner s3Presigner;
+ /** 阿里云 OSS 客户端(阿里云专用) */
+ private OSS aliyunOssClient;
+ /** 是否为阿里云 */
+ private boolean isAliyun;
public S3FileClient(Long id, S3FileClientConfig config) {
super(id, config);
@@ -50,22 +62,47 @@ public class S3FileClient extends AbstractFileClient {
if (StrUtil.isEmpty(config.getDomain())) {
config.setDomain(buildDomain());
}
- // 初始化 S3 客户端
- Region region = Region.of("us-east-1"); // 必须填,但填什么都行,常见的值有 "us-east-1",不填会报错
+
+ // 判断是否为阿里云
+ isAliyun = StrUtil.contains(config.getEndpoint(), S3FileClientConfig.ENDPOINT_ALIYUN);
+
+ if (isAliyun) {
+ // 阿里云:使用阿里云 OSS SDK
+ initAliyunClient();
+ } else {
+ // 其他云厂商:使用 AWS S3 SDK
+ initS3Client();
+ }
+ }
+
+ /**
+ * 初始化阿里云 OSS 客户端
+ */
+ private void initAliyunClient() {
+ String region = extractRegionFromEndpoint(config.getEndpoint());
+ String endpoint = "https://oss-" + region + ".aliyuncs.com";
+ aliyunOssClient = new OSSClient(endpoint, config.getAccessKey(), config.getAccessSecret());
+ }
+
+ /**
+ * 初始化 AWS S3 客户端
+ */
+ private void initS3Client() {
+ Region region = Region.of(extractRegionFromEndpoint(config.getEndpoint()));
AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(
AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret()));
URI endpoint = URI.create(buildEndpoint());
- S3Configuration serviceConfiguration = S3Configuration.builder() // Path-style 访问
+ S3Configuration serviceConfiguration = S3Configuration.builder()
.pathStyleAccessEnabled(Boolean.TRUE.equals(config.getEnablePathStyleAccess()))
- .chunkedEncodingEnabled(false) // 禁用分块编码,参见 https://t.zsxq.com/kBy57
+ .chunkedEncodingEnabled(false)
.build();
- client = S3Client.builder()
+ s3Client = S3Client.builder()
.credentialsProvider(credentialsProvider)
.region(region)
.endpointOverride(endpoint)
.serviceConfiguration(serviceConfiguration)
.build();
- presigner = S3Presigner.builder()
+ s3Presigner = S3Presigner.builder()
.credentialsProvider(credentialsProvider)
.region(region)
.endpointOverride(endpoint)
@@ -75,125 +112,196 @@ public class S3FileClient extends AbstractFileClient {
@Override
public String upload(byte[] content, String path, String type) {
- // 构造 PutObjectRequest
- PutObjectRequest putRequest = PutObjectRequest.builder()
- .bucket(config.getBucket())
- .key(path)
- .contentType(type)
- .contentLength((long) content.length)
- .build();
- // 上传文件
- client.putObject(putRequest, RequestBody.fromBytes(content));
- // 拼接返回路径
+ if (isAliyun) {
+ ObjectMetadata metadata = new ObjectMetadata();
+ metadata.setContentType(type);
+ metadata.setContentLength(content.length);
+ com.aliyun.oss.model.PutObjectRequest putRequest = new com.aliyun.oss.model.PutObjectRequest(
+ config.getBucket(), path, new ByteArrayInputStream(content), metadata);
+ aliyunOssClient.putObject(putRequest);
+ } else {
+ software.amazon.awssdk.services.s3.model.PutObjectRequest putRequest = software.amazon.awssdk.services.s3.model.PutObjectRequest.builder()
+ .bucket(config.getBucket())
+ .key(path)
+ .contentType(type)
+ .contentLength((long) content.length)
+ .build();
+ s3Client.putObject(putRequest, RequestBody.fromBytes(content));
+ }
return presignGetUrl(path, null);
}
@Override
public void delete(String path) {
- DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
- .bucket(config.getBucket())
- .key(path)
- .build();
- client.deleteObject(deleteRequest);
+ if (isAliyun) {
+ aliyunOssClient.deleteObject(config.getBucket(), path);
+ } else {
+ DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
+ .bucket(config.getBucket())
+ .key(path)
+ .build();
+ s3Client.deleteObject(deleteRequest);
+ }
}
@Override
public byte[] getContent(String path) {
- GetObjectRequest getRequest = GetObjectRequest.builder()
- .bucket(config.getBucket())
- .key(path)
- .build();
- return IoUtil.readBytes(client.getObject(getRequest));
+ if (isAliyun) {
+ OSSObject ossObject = aliyunOssClient.getObject(config.getBucket(), path);
+ return IoUtil.readBytes(ossObject.getObjectContent());
+ } else {
+ GetObjectRequest getRequest = GetObjectRequest.builder()
+ .bucket(config.getBucket())
+ .key(path)
+ .build();
+ return IoUtil.readBytes(s3Client.getObject(getRequest));
+ }
}
@Override
public String presignPutUrl(String path) {
- return presigner.presignPutObject(PutObjectPresignRequest.builder()
- .signatureDuration(EXPIRATION_DEFAULT)
- .putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build())
- .url().toString();
+ return presignPutUrl(path, null);
+ }
+
+ @Override
+ public String presignPutUrl(String path, String contentType) {
+ String signedUrl;
+ if (isAliyun) {
+ Date expirationDate = new Date(System.currentTimeMillis() + EXPIRATION_DEFAULT.toMillis());
+ // 使用 GeneratePresignedUrlRequest 以支持设置 Content-Type
+ com.aliyun.oss.model.GeneratePresignedUrlRequest request =
+ new com.aliyun.oss.model.GeneratePresignedUrlRequest(config.getBucket(), path, HttpMethod.PUT);
+ request.setExpiration(expirationDate);
+ // 如果指定了 Content-Type,则包含在签名中
+ if (StrUtil.isNotBlank(contentType)) {
+ request.setContentType(contentType);
+ }
+ URL url = aliyunOssClient.generatePresignedUrl(request);
+ signedUrl = url.toString();
+ } else {
+ software.amazon.awssdk.services.s3.model.PutObjectRequest.Builder putRequestBuilder =
+ software.amazon.awssdk.services.s3.model.PutObjectRequest.builder()
+ .bucket(config.getBucket())
+ .key(path);
+ // 如果指定了 Content-Type,则包含在签名中
+ if (StrUtil.isNotBlank(contentType)) {
+ putRequestBuilder.contentType(contentType);
+ }
+ signedUrl = s3Presigner.presignPutObject(PutObjectPresignRequest.builder()
+ .signatureDuration(EXPIRATION_DEFAULT)
+ .putObjectRequest(putRequestBuilder.build()).build())
+ .url().toString();
+ }
+ // 直传使用 OSS 原始域名,不替换为 CDN(CDN 可能不支持签名直传)
+ return signedUrl;
}
@Override
public String presignGetUrl(String url, Integer expirationSeconds) {
- // 1. 将 url 转换为 path(支持多种URL格式)
- String path = extractPathFromUrl(url);
+ // 1. 将 url 转换为 path
+ String path = StrUtil.removePrefix(url, config.getDomain() + "/");
path = HttpUtils.removeUrlQuery(path);
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
- // 2.1 情况一:公开访问:无需签名
- // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
+ // 2. 公开访问:无需签名
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8);
return config.getDomain() + "/" + encodedPath;
}
- // 2.2 情况二:私有访问:生成 GET 预签名 URL
- String finalPath = decodedPath;
- Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT;
- URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder()
- .signatureDuration(expiration)
- .getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build())
- .url();
- return signedUrl.toString();
+ // 3. 私有访问:生成预签名 URL
+ int expiration = expirationSeconds != null ? expirationSeconds : (int) EXPIRATION_DEFAULT.getSeconds();
+
+ String signedUrl;
+ if (isAliyun) {
+ Date expirationDate = new Date(System.currentTimeMillis() + expiration * 1000L);
+ signedUrl = aliyunOssClient.generatePresignedUrl(config.getBucket(), decodedPath, expirationDate).toString();
+ } else {
+ Duration duration = Duration.ofSeconds(expiration);
+ signedUrl = s3Presigner.presignGetObject(GetObjectPresignRequest.builder()
+ .signatureDuration(duration)
+ .getObjectRequest(b -> b.bucket(config.getBucket()).key(decodedPath)).build())
+ .url().toString();
+ }
+
+ // 4. 替换为 CDN 域名
+ if (StrUtil.isNotEmpty(config.getDomain())) {
+ return replaceUrlHost(signedUrl, config.getDomain());
+ }
+ return signedUrl;
}
/**
- * 从URL中提取相对路径
- * 支持多种URL格式:
- * 1. CDN域名:https://oss.muyetools.cn/path/to/file
- * 2. OSS原始域名:https://bucket.oss-region.aliyuncs.com/path/to/file
- * 3. 相对路径:path/to/file
+ * 替换 URL 的 host 部分为目标域名
+ *
+ * @param url 原始 URL
+ * @param targetDomain 目标域名(含协议)
+ * @return 替换后的 URL
*/
- private String extractPathFromUrl(String url) {
- if (StrUtil.isEmpty(url)) {
- return url;
- }
-
- // 1. 尝试移除配置的域名前缀
- String path = StrUtil.removePrefix(url, config.getDomain() + "/");
- if (!StrUtil.equals(url, path)) {
- return path;
- }
-
- // 2. 如果不是以配置域名开头,检查是否是完整URL
- if (url.contains("://")) {
- // 提取域名后的路径
- int pathStart = url.indexOf("/", url.indexOf("://") + 3);
- if (pathStart > 0) {
- return url.substring(pathStart + 1);
+ private String replaceUrlHost(String url, String targetDomain) {
+ try {
+ URI uri = new URI(url);
+ String originalHost = uri.getScheme() + "://" + uri.getHost();
+ if (uri.getPort() > 0) {
+ originalHost += ":" + uri.getPort();
}
+ return StrUtil.replace(url, originalHost, targetDomain);
+ } catch (Exception e) {
+ // 降级处理:使用 buildDomain()
+ return StrUtil.replace(url, buildDomain(), targetDomain);
}
-
- // 3. 已经是相对路径,直接返回
- return url;
}
/**
* 基于 bucket + endpoint 构建访问的 Domain 地址
- *
- * @return Domain 地址
*/
private String buildDomain() {
- // 如果已经是 http 或者 https,则不进行拼接.主要适配 MinIO
if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket());
}
- // 阿里云、腾讯云、华为云都适合。七牛云比较特殊,必须有自定义域名
return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint());
}
/**
* 节点地址补全协议头
- *
- * @return 节点地址
*/
private String buildEndpoint() {
- // 如果已经是 http 或者 https,则不进行拼接
if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
return config.getEndpoint();
}
return StrUtil.format("https://{}", config.getEndpoint());
}
+ /**
+ * 从 endpoint 中提取 region
+ */
+ private String extractRegionFromEndpoint(String endpoint) {
+ if (StrUtil.isEmpty(endpoint)) {
+ return DEFAULT_REGION;
+ }
+
+ // 移除协议前缀和 bucket 前缀
+ String host = StrUtil.removePrefixIgnoreCase(endpoint, "https://");
+ host = StrUtil.removePrefixIgnoreCase(host, "http://");
+ host = StrUtil.removePrefix(host, config.getBucket() + ".");
+
+ // 阿里云 OSS
+ if (host.contains(S3FileClientConfig.ENDPOINT_ALIYUN)) {
+ String prefix = StrUtil.subBefore(host, "." + S3FileClientConfig.ENDPOINT_ALIYUN, false);
+ if (StrUtil.isNotEmpty(prefix)) {
+ return StrUtil.removePrefix(prefix, "oss-");
+ }
+ }
+
+ // 腾讯云 COS
+ if (host.contains(S3FileClientConfig.ENDPOINT_TENCENT)) {
+ String region = StrUtil.subBefore(host, "." + S3FileClientConfig.ENDPOINT_TENCENT, false);
+ if (StrUtil.isNotEmpty(region) && region.startsWith("cos.")) {
+ return region.substring(4);
+ }
+ }
+
+ return DEFAULT_REGION;
+ }
+
}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java
index 04e1346a0b..670a1141e4 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java
@@ -100,12 +100,14 @@ public class AppTikUserFileController {
@Parameter(name = "fileCategory", description = "文件分类(video/audio/image)", required = true, example = "video")
@Parameter(name = "groupId", description = "分组编号(可选)")
@Parameter(name = "fileSize", description = "文件大小(字节)")
+ @Parameter(name = "contentType", description = "文件 MIME 类型(可选,如 video/mp4)")
public CommonResult