Compare commits
2 Commits
93d4a0d506
...
b0fa4279b0
| Author | SHA1 | Date | |
|---|---|---|---|
| b0fa4279b0 | |||
| fdc159de7e |
@@ -111,9 +111,10 @@ export function useUpload() {
|
|||||||
// 开发环境:使用 /oss 代理避免CORS问题
|
// 开发环境:使用 /oss 代理避免CORS问题
|
||||||
// 生产环境:使用自定义域名(签名基于路径,域名可替换)
|
// 生产环境:使用自定义域名(签名基于路径,域名可替换)
|
||||||
const uploadUrl = isDev()
|
const uploadUrl = isDev()
|
||||||
? presignedData.presignedUrl.replace(OSS_ORIGINAL, '/oss')
|
? presignedData.presignedUrl.replace(OSS_DOMAIN, '/oss')
|
||||||
: presignedData.presignedUrl.replace(OSS_ORIGINAL, OSS_DOMAIN)
|
: presignedData.presignedUrl
|
||||||
xhr.open('PUT', uploadUrl)
|
xhr.open('PUT', uploadUrl)
|
||||||
|
// 设置 Content-Type header(必须与签名时一致)
|
||||||
if (presignedData.headers && presignedData.headers['Content-Type']) {
|
if (presignedData.headers && presignedData.headers['Content-Type']) {
|
||||||
xhr.setRequestHeader('Content-Type', presignedData.headers['Content-Type'])
|
xhr.setRequestHeader('Content-Type', presignedData.headers['Content-Type'])
|
||||||
}
|
}
|
||||||
@@ -169,12 +170,13 @@ export function useUpload() {
|
|||||||
duration = await getVideoDuration(file)
|
duration = await getVideoDuration(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 第一步:获取预签名URL
|
// 第一步:获取预签名URL(传递文件的 MIME 类型)
|
||||||
const presignedData = await MaterialService.getPresignedUrl({
|
const presignedData = await MaterialService.getPresignedUrl({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileCategory,
|
fileCategory,
|
||||||
groupId,
|
groupId,
|
||||||
fileSize: file.size
|
fileSize: file.size,
|
||||||
|
contentType: file.type || undefined // 传递文件的 MIME 类型
|
||||||
})
|
})
|
||||||
|
|
||||||
// 第二步:直传文件到OSS
|
// 第二步:直传文件到OSS
|
||||||
|
|||||||
@@ -109,6 +109,12 @@
|
|||||||
<groupId>software.amazon.awssdk</groupId>
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
<artifactId>s3</artifactId>
|
<artifactId>s3</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- 阿里云 OSS SDK:用于生成阿里云签名 URL -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.aliyun.oss</groupId>
|
||||||
|
<artifactId>aliyun-sdk-oss</artifactId>
|
||||||
|
<version>3.18.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.tika</groupId>
|
<groupId>org.apache.tika</groupId>
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ public interface FileClient {
|
|||||||
throw new UnsupportedOperationException("不支持的操作");
|
throw new UnsupportedOperationException("不支持的操作");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获得文件预签名地址,用于上传(包含 Content-Type 签名)
|
||||||
|
*
|
||||||
|
* @param path 相对路径
|
||||||
|
* @param contentType 文件的 Content-Type
|
||||||
|
* @return 文件预签名地址
|
||||||
|
*/
|
||||||
|
default String presignPutUrl(String path, String contentType) {
|
||||||
|
throw new UnsupportedOperationException("不支持的操作");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成文件预签名地址,用于读取
|
* 生成文件预签名地址,用于读取
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import cn.hutool.core.util.URLUtil;
|
|||||||
import cn.hutool.http.HttpUtil;
|
import cn.hutool.http.HttpUtil;
|
||||||
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
|
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
|
||||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
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.AwsBasicCredentials;
|
||||||
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
|
||||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
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.S3Configuration;
|
||||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
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.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.GetObjectPresignRequest;
|
||||||
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
|
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.time.Duration;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
import org.springframework.web.util.UriUtils;
|
import org.springframework.web.util.UriUtils;
|
||||||
|
|
||||||
@@ -36,9 +42,15 @@ import org.springframework.web.util.UriUtils;
|
|||||||
public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
||||||
|
|
||||||
private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24);
|
private static final Duration EXPIRATION_DEFAULT = Duration.ofHours(24);
|
||||||
|
private static final String DEFAULT_REGION = "us-east-1";
|
||||||
|
|
||||||
private S3Client client;
|
/** S3 客户端(非阿里云使用) */
|
||||||
private S3Presigner presigner;
|
private S3Client s3Client;
|
||||||
|
private S3Presigner s3Presigner;
|
||||||
|
/** 阿里云 OSS 客户端(阿里云专用) */
|
||||||
|
private OSS aliyunOssClient;
|
||||||
|
/** 是否为阿里云 */
|
||||||
|
private boolean isAliyun;
|
||||||
|
|
||||||
public S3FileClient(Long id, S3FileClientConfig config) {
|
public S3FileClient(Long id, S3FileClientConfig config) {
|
||||||
super(id, config);
|
super(id, config);
|
||||||
@@ -50,22 +62,47 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
|||||||
if (StrUtil.isEmpty(config.getDomain())) {
|
if (StrUtil.isEmpty(config.getDomain())) {
|
||||||
config.setDomain(buildDomain());
|
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(
|
AwsCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(
|
||||||
AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret()));
|
AwsBasicCredentials.create(config.getAccessKey(), config.getAccessSecret()));
|
||||||
URI endpoint = URI.create(buildEndpoint());
|
URI endpoint = URI.create(buildEndpoint());
|
||||||
S3Configuration serviceConfiguration = S3Configuration.builder() // Path-style 访问
|
S3Configuration serviceConfiguration = S3Configuration.builder()
|
||||||
.pathStyleAccessEnabled(Boolean.TRUE.equals(config.getEnablePathStyleAccess()))
|
.pathStyleAccessEnabled(Boolean.TRUE.equals(config.getEnablePathStyleAccess()))
|
||||||
.chunkedEncodingEnabled(false) // 禁用分块编码,参见 https://t.zsxq.com/kBy57
|
.chunkedEncodingEnabled(false)
|
||||||
.build();
|
.build();
|
||||||
client = S3Client.builder()
|
s3Client = S3Client.builder()
|
||||||
.credentialsProvider(credentialsProvider)
|
.credentialsProvider(credentialsProvider)
|
||||||
.region(region)
|
.region(region)
|
||||||
.endpointOverride(endpoint)
|
.endpointOverride(endpoint)
|
||||||
.serviceConfiguration(serviceConfiguration)
|
.serviceConfiguration(serviceConfiguration)
|
||||||
.build();
|
.build();
|
||||||
presigner = S3Presigner.builder()
|
s3Presigner = S3Presigner.builder()
|
||||||
.credentialsProvider(credentialsProvider)
|
.credentialsProvider(credentialsProvider)
|
||||||
.region(region)
|
.region(region)
|
||||||
.endpointOverride(endpoint)
|
.endpointOverride(endpoint)
|
||||||
@@ -75,125 +112,196 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String upload(byte[] content, String path, String type) {
|
public String upload(byte[] content, String path, String type) {
|
||||||
// 构造 PutObjectRequest
|
if (isAliyun) {
|
||||||
PutObjectRequest putRequest = PutObjectRequest.builder()
|
ObjectMetadata metadata = new ObjectMetadata();
|
||||||
.bucket(config.getBucket())
|
metadata.setContentType(type);
|
||||||
.key(path)
|
metadata.setContentLength(content.length);
|
||||||
.contentType(type)
|
com.aliyun.oss.model.PutObjectRequest putRequest = new com.aliyun.oss.model.PutObjectRequest(
|
||||||
.contentLength((long) content.length)
|
config.getBucket(), path, new ByteArrayInputStream(content), metadata);
|
||||||
.build();
|
aliyunOssClient.putObject(putRequest);
|
||||||
// 上传文件
|
} else {
|
||||||
client.putObject(putRequest, RequestBody.fromBytes(content));
|
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);
|
return presignGetUrl(path, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(String path) {
|
public void delete(String path) {
|
||||||
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
|
if (isAliyun) {
|
||||||
.bucket(config.getBucket())
|
aliyunOssClient.deleteObject(config.getBucket(), path);
|
||||||
.key(path)
|
} else {
|
||||||
.build();
|
DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder()
|
||||||
client.deleteObject(deleteRequest);
|
.bucket(config.getBucket())
|
||||||
|
.key(path)
|
||||||
|
.build();
|
||||||
|
s3Client.deleteObject(deleteRequest);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] getContent(String path) {
|
public byte[] getContent(String path) {
|
||||||
GetObjectRequest getRequest = GetObjectRequest.builder()
|
if (isAliyun) {
|
||||||
.bucket(config.getBucket())
|
OSSObject ossObject = aliyunOssClient.getObject(config.getBucket(), path);
|
||||||
.key(path)
|
return IoUtil.readBytes(ossObject.getObjectContent());
|
||||||
.build();
|
} else {
|
||||||
return IoUtil.readBytes(client.getObject(getRequest));
|
GetObjectRequest getRequest = GetObjectRequest.builder()
|
||||||
|
.bucket(config.getBucket())
|
||||||
|
.key(path)
|
||||||
|
.build();
|
||||||
|
return IoUtil.readBytes(s3Client.getObject(getRequest));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String presignPutUrl(String path) {
|
public String presignPutUrl(String path) {
|
||||||
return presigner.presignPutObject(PutObjectPresignRequest.builder()
|
return presignPutUrl(path, null);
|
||||||
.signatureDuration(EXPIRATION_DEFAULT)
|
}
|
||||||
.putObjectRequest(b -> b.bucket(config.getBucket()).key(path)).build())
|
|
||||||
.url().toString();
|
@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
|
@Override
|
||||||
public String presignGetUrl(String url, Integer expirationSeconds) {
|
public String presignGetUrl(String url, Integer expirationSeconds) {
|
||||||
// 1. 将 url 转换为 path(支持多种URL格式)
|
// 1. 将 url 转换为 path
|
||||||
String path = extractPathFromUrl(url);
|
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
|
||||||
path = HttpUtils.removeUrlQuery(path);
|
path = HttpUtils.removeUrlQuery(path);
|
||||||
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
|
String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
// 2.1 情况一:公开访问:无需签名
|
// 2. 公开访问:无需签名
|
||||||
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
|
|
||||||
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
|
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
|
||||||
String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8);
|
String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8);
|
||||||
return config.getDomain() + "/" + encodedPath;
|
return config.getDomain() + "/" + encodedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2.2 情况二:私有访问:生成 GET 预签名 URL
|
// 3. 私有访问:生成预签名 URL
|
||||||
String finalPath = decodedPath;
|
int expiration = expirationSeconds != null ? expirationSeconds : (int) EXPIRATION_DEFAULT.getSeconds();
|
||||||
Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT;
|
|
||||||
URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder()
|
String signedUrl;
|
||||||
.signatureDuration(expiration)
|
if (isAliyun) {
|
||||||
.getObjectRequest(b -> b.bucket(config.getBucket()).key(finalPath)).build())
|
Date expirationDate = new Date(System.currentTimeMillis() + expiration * 1000L);
|
||||||
.url();
|
signedUrl = aliyunOssClient.generatePresignedUrl(config.getBucket(), decodedPath, expirationDate).toString();
|
||||||
return signedUrl.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 的 host 部分为目标域名
|
||||||
* 支持多种URL格式:
|
*
|
||||||
* 1. CDN域名:https://oss.muyetools.cn/path/to/file
|
* @param url 原始 URL
|
||||||
* 2. OSS原始域名:https://bucket.oss-region.aliyuncs.com/path/to/file
|
* @param targetDomain 目标域名(含协议)
|
||||||
* 3. 相对路径:path/to/file
|
* @return 替换后的 URL
|
||||||
*/
|
*/
|
||||||
private String extractPathFromUrl(String url) {
|
private String replaceUrlHost(String url, String targetDomain) {
|
||||||
if (StrUtil.isEmpty(url)) {
|
try {
|
||||||
return url;
|
URI uri = new URI(url);
|
||||||
}
|
String originalHost = uri.getScheme() + "://" + uri.getHost();
|
||||||
|
if (uri.getPort() > 0) {
|
||||||
// 1. 尝试移除配置的域名前缀
|
originalHost += ":" + uri.getPort();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
return StrUtil.replace(url, originalHost, targetDomain);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 降级处理:使用 buildDomain()
|
||||||
|
return StrUtil.replace(url, buildDomain(), targetDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 已经是相对路径,直接返回
|
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基于 bucket + endpoint 构建访问的 Domain 地址
|
* 基于 bucket + endpoint 构建访问的 Domain 地址
|
||||||
*
|
|
||||||
* @return Domain 地址
|
|
||||||
*/
|
*/
|
||||||
private String buildDomain() {
|
private String buildDomain() {
|
||||||
// 如果已经是 http 或者 https,则不进行拼接.主要适配 MinIO
|
|
||||||
if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
|
if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
|
||||||
return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket());
|
return StrUtil.format("{}/{}", config.getEndpoint(), config.getBucket());
|
||||||
}
|
}
|
||||||
// 阿里云、腾讯云、华为云都适合。七牛云比较特殊,必须有自定义域名
|
|
||||||
return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint());
|
return StrUtil.format("https://{}.{}", config.getBucket(), config.getEndpoint());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点地址补全协议头
|
* 节点地址补全协议头
|
||||||
*
|
|
||||||
* @return 节点地址
|
|
||||||
*/
|
*/
|
||||||
private String buildEndpoint() {
|
private String buildEndpoint() {
|
||||||
// 如果已经是 http 或者 https,则不进行拼接
|
|
||||||
if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
|
if (HttpUtil.isHttp(config.getEndpoint()) || HttpUtil.isHttps(config.getEndpoint())) {
|
||||||
return config.getEndpoint();
|
return config.getEndpoint();
|
||||||
}
|
}
|
||||||
return StrUtil.format("https://{}", 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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,12 +100,14 @@ public class AppTikUserFileController {
|
|||||||
@Parameter(name = "fileCategory", description = "文件分类(video/audio/image)", required = true, example = "video")
|
@Parameter(name = "fileCategory", description = "文件分类(video/audio/image)", required = true, example = "video")
|
||||||
@Parameter(name = "groupId", description = "分组编号(可选)")
|
@Parameter(name = "groupId", description = "分组编号(可选)")
|
||||||
@Parameter(name = "fileSize", description = "文件大小(字节)")
|
@Parameter(name = "fileSize", description = "文件大小(字节)")
|
||||||
|
@Parameter(name = "contentType", description = "文件 MIME 类型(可选,如 video/mp4)")
|
||||||
public CommonResult<Object> getPresignedUrl(
|
public CommonResult<Object> getPresignedUrl(
|
||||||
@RequestParam("fileName") String fileName,
|
@RequestParam("fileName") String fileName,
|
||||||
@RequestParam("fileCategory") String fileCategory,
|
@RequestParam("fileCategory") String fileCategory,
|
||||||
@RequestParam(value = "groupId", required = false) Long groupId,
|
@RequestParam(value = "groupId", required = false) Long groupId,
|
||||||
@RequestParam(value = "fileSize", required = false) Long fileSize) {
|
@RequestParam(value = "fileSize", required = false) Long fileSize,
|
||||||
return success(userFileService.getPresignedUrl(fileName, fileCategory, groupId, fileSize));
|
@RequestParam(value = "contentType", required = false) String contentType) {
|
||||||
|
return success(userFileService.getPresignedUrl(fileName, fileCategory, groupId, fileSize, contentType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/complete-upload")
|
@PostMapping("/complete-upload")
|
||||||
|
|||||||
@@ -82,9 +82,10 @@ public interface TikUserFileService {
|
|||||||
* @param fileCategory 文件分类
|
* @param fileCategory 文件分类
|
||||||
* @param groupId 分组编号(可选)
|
* @param groupId 分组编号(可选)
|
||||||
* @param fileSize 文件大小(可选)
|
* @param fileSize 文件大小(可选)
|
||||||
|
* @param contentType 文件 MIME 类型(可选)
|
||||||
* @return 预签名URL信息
|
* @return 预签名URL信息
|
||||||
*/
|
*/
|
||||||
Object getPresignedUrl(String fileName, String fileCategory, Long groupId, Long fileSize);
|
Object getPresignedUrl(String fileName, String fileCategory, Long groupId, Long fileSize, String contentType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确认上传(直传模式)
|
* 确认上传(直传模式)
|
||||||
|
|||||||
@@ -53,24 +53,8 @@ import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.*;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class TikUserFileServiceImpl implements TikUserFileService {
|
public class TikUserFileServiceImpl implements TikUserFileService {
|
||||||
|
|
||||||
/** 预签名URL过期时间(24小时,单位:秒) */
|
|
||||||
private static final int PRESIGN_URL_EXPIRATION_SECONDS = 24 * 60 * 60;
|
private static final int PRESIGN_URL_EXPIRATION_SECONDS = 24 * 60 * 60;
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传文件的前缀,是否包含日期
|
|
||||||
*
|
|
||||||
* 目的:按照日期,进行分目录
|
|
||||||
*/
|
|
||||||
private static boolean PATH_PREFIX_DATE_ENABLE = true;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传文件的后缀,是否包含时间戳
|
|
||||||
*
|
|
||||||
* 目的:保证文件的唯一性,避免覆盖
|
|
||||||
* 定制:可按需调整成 UUID、或者其他方式
|
|
||||||
*/
|
|
||||||
private static boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true;
|
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private TikUserFileMapper userFileMapper;
|
private TikUserFileMapper userFileMapper;
|
||||||
|
|
||||||
@@ -93,25 +77,18 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64, Integer duration, Long groupId) {
|
public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64, Integer duration, Long groupId) {
|
||||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
|
||||||
// ========== 第一阶段:校验和准备 ==========
|
// 校验文件分类和配额
|
||||||
// 1. 校验文件分类和配额
|
|
||||||
validateUploadRequest(fileCategory, file.getSize());
|
validateUploadRequest(fileCategory, file.getSize());
|
||||||
|
|
||||||
// 2. 获取OSS基础目录路径
|
// 获取OSS基础目录路径
|
||||||
String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory);
|
String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory);
|
||||||
|
|
||||||
// ========== 第二阶段:处理文件和上传 ==========
|
// 处理文件和上传
|
||||||
UploadFileContext context = processFileAndUpload(file, baseDirectory);
|
UploadFileContext context = processFileAndUpload(file, baseDirectory);
|
||||||
|
|
||||||
// ========== 第三阶段:保存数据库 ==========
|
// 保存数据库
|
||||||
try {
|
return saveFileRecord(userId, file, fileCategory, context.fileUrl, context.filePath,
|
||||||
return saveFileRecord(userId, file, fileCategory, context.fileUrl, context.filePath,
|
coverBase64, baseDirectory, context.infraFileId, duration, groupId);
|
||||||
coverBase64, baseDirectory, context.infraFileId, duration, groupId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[uploadFile][保存数据库失败]", e);
|
|
||||||
deleteOssFile(context.infraFileId, context.filePath, context.fileUrl);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -143,12 +120,11 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一处理文件和上传逻辑
|
* 处理文件和上传逻辑
|
||||||
*/
|
*/
|
||||||
private UploadFileContext processFileAndUpload(MultipartFile file, String baseDirectory) {
|
private UploadFileContext processFileAndUpload(MultipartFile file, String baseDirectory) {
|
||||||
UploadFileContext context = new UploadFileContext();
|
UploadFileContext context = new UploadFileContext();
|
||||||
|
|
||||||
// 1. 读取文件内容
|
|
||||||
byte[] fileContent;
|
byte[] fileContent;
|
||||||
try {
|
try {
|
||||||
fileContent = IoUtil.readBytes(file.getInputStream());
|
fileContent = IoUtil.readBytes(file.getInputStream());
|
||||||
@@ -158,12 +134,10 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. 处理文件名和类型
|
|
||||||
String fileName = file.getOriginalFilename();
|
String fileName = file.getOriginalFilename();
|
||||||
String fileType = file.getContentType();
|
String fileType = StrUtil.isEmpty(file.getContentType())
|
||||||
if (StrUtil.isEmpty(fileType)) {
|
? FileTypeUtils.getMineType(fileContent, fileName)
|
||||||
fileType = FileTypeUtils.getMineType(fileContent, fileName);
|
: file.getContentType();
|
||||||
}
|
|
||||||
if (StrUtil.isEmpty(fileName)) {
|
if (StrUtil.isEmpty(fileName)) {
|
||||||
fileName = DigestUtil.sha256Hex(fileContent);
|
fileName = DigestUtil.sha256Hex(fileContent);
|
||||||
}
|
}
|
||||||
@@ -174,15 +148,12 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 生成上传路径
|
|
||||||
String filePath = generateUploadPath(fileName, baseDirectory);
|
String filePath = generateUploadPath(fileName, baseDirectory);
|
||||||
|
|
||||||
// 4. 上传到OSS
|
|
||||||
FileClient client = fileConfigService.getMasterFileClient();
|
FileClient client = fileConfigService.getMasterFileClient();
|
||||||
Assert.notNull(client, "客户端(master) 不能为空");
|
Assert.notNull(client, "客户端(master) 不能为空");
|
||||||
String presignedUrl = client.upload(fileContent, filePath, fileType);
|
String presignedUrl = client.upload(fileContent, filePath, fileType);
|
||||||
|
|
||||||
// 5. 保存到 infra_file 表
|
|
||||||
String fileUrl = HttpUtils.removeUrlQuery(presignedUrl);
|
String fileUrl = HttpUtils.removeUrlQuery(presignedUrl);
|
||||||
FileDO infraFile = new FileDO()
|
FileDO infraFile = new FileDO()
|
||||||
.setConfigId(client.getId())
|
.setConfigId(client.getId())
|
||||||
@@ -193,7 +164,6 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
.setSize((int) file.getSize());
|
.setSize((int) file.getSize());
|
||||||
fileMapper.insert(infraFile);
|
fileMapper.insert(infraFile);
|
||||||
|
|
||||||
// 6. 设置上下文
|
|
||||||
context.fileUrl = fileUrl;
|
context.fileUrl = fileUrl;
|
||||||
context.filePath = filePath;
|
context.filePath = filePath;
|
||||||
context.infraFileId = infraFile.getId();
|
context.infraFileId = infraFile.getId();
|
||||||
@@ -210,13 +180,12 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一处理视频封面上传
|
* 处理视频封面上传
|
||||||
*/
|
*/
|
||||||
private String handleCoverUpload(String coverBase64, String fileName, String fileType, String baseDirectory) {
|
private String handleCoverUpload(String coverBase64, String fileName, String fileType, String baseDirectory) {
|
||||||
if (StrUtil.isBlank(coverBase64) || !StrUtil.containsIgnoreCase(fileType, "video")) {
|
if (StrUtil.isBlank(coverBase64) || !StrUtil.containsIgnoreCase(fileType, "video")) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String base64Data = coverBase64.contains(",")
|
String base64Data = coverBase64.contains(",")
|
||||||
? coverBase64.substring(coverBase64.indexOf(",") + 1)
|
? coverBase64.substring(coverBase64.indexOf(",") + 1)
|
||||||
@@ -234,102 +203,62 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存文件记录到数据库(在事务中执行)
|
* 保存文件记录到数据库
|
||||||
*/
|
*/
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory,
|
public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory,
|
||||||
String fileUrl, String filePath, String coverBase64, String baseDirectory, Long infraFileId, Integer duration, Long groupId) {
|
String fileUrl, String filePath, String coverBase64,
|
||||||
// 验证 infraFileId 不为空
|
String baseDirectory, Long infraFileId, Integer duration, Long groupId) {
|
||||||
if (infraFileId == null) {
|
if (infraFileId == null) {
|
||||||
log.error("[saveFileRecord][infra_file.id 为空,无法保存文件记录,用户({}),URL({})]", userId, fileUrl);
|
log.error("[saveFileRecord][infra_file.id 为空,用户({}),URL({})]", userId, fileUrl);
|
||||||
throw exception(FILE_NOT_EXISTS, "文件记录保存失败:无法获取文件ID");
|
throw exception(FILE_NOT_EXISTS, "文件记录保存失败:无法获取文件ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理视频封面和文件名
|
|
||||||
String fileName = file.getOriginalFilename();
|
String fileName = file.getOriginalFilename();
|
||||||
String fileType = file.getContentType();
|
String fileType = file.getContentType();
|
||||||
String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory);
|
String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory);
|
||||||
// 处理显示名称(去除文件后缀)
|
|
||||||
String displayName = FileUtil.mainName(fileName);
|
String displayName = FileUtil.mainName(fileName);
|
||||||
|
|
||||||
// 9. 创建文件记录(保存完整路径,包含封面URL和Base64)
|
|
||||||
TikUserFileDO userFile = new TikUserFileDO()
|
TikUserFileDO userFile = new TikUserFileDO()
|
||||||
.setUserId(userId)
|
.setUserId(userId)
|
||||||
.setFileId(infraFileId) // 关联infra_file表,用于后续通过FileService管理文件
|
.setFileId(infraFileId)
|
||||||
.setFileName(fileName) // 保留原始文件名(系统标识)
|
.setFileName(fileName)
|
||||||
.setDisplayName(displayName) // 设置显示名称(无后缀,用户可编辑)
|
.setDisplayName(displayName)
|
||||||
.setFileType(fileType)
|
.setFileType(fileType)
|
||||||
.setFileCategory(fileCategory)
|
.setFileCategory(fileCategory)
|
||||||
.setFileSize(file.getSize())
|
.setFileSize(file.getSize())
|
||||||
.setFileUrl(fileUrl)
|
.setFileUrl(fileUrl)
|
||||||
.setFilePath(filePath) // 保存完整的OSS路径(由FileService生成)
|
.setFilePath(filePath)
|
||||||
.setCoverUrl(coverUrl) // 设置封面URL(如果有)
|
.setCoverUrl(coverUrl)
|
||||||
.setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null) // 保存原始base64数据(如果有)
|
.setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null)
|
||||||
.setDuration(duration) // 设置视频时长(如果有)
|
.setDuration(duration)
|
||||||
.setGroupId(groupId); // 设置分组编号(如果有)
|
.setGroupId(groupId);
|
||||||
|
|
||||||
userFileMapper.insert(userFile);
|
userFileMapper.insert(userFile);
|
||||||
|
|
||||||
// 10. 更新配额
|
|
||||||
memberUserProfileService.increaseUsedStorage(String.valueOf(userId), file.getSize());
|
memberUserProfileService.increaseUsedStorage(String.valueOf(userId), file.getSize());
|
||||||
|
|
||||||
log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({}),infra文件编号({})]",
|
log.info("[saveFileRecord][用户({})保存文件成功,userFileId({}),infraFileId({})]",
|
||||||
userId, userFile.getId(), infraFileId);
|
userId, userFile.getId(), infraFileId);
|
||||||
// 返回 infra_file.id,保持与现有配音功能的兼容性
|
|
||||||
return infraFileId;
|
return infraFileId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除OSS文件(当数据库保存失败时调用)
|
* 生成上传路径
|
||||||
*/
|
|
||||||
private void deleteOssFile(Long infraFileId, String filePath, String fileUrl) {
|
|
||||||
try {
|
|
||||||
if (infraFileId != null) {
|
|
||||||
// 优先通过 infra_file.id 删除(更准确)
|
|
||||||
// 注意:这里需要调用 FileService.deleteFile,但 FileApi 没有提供删除方法
|
|
||||||
// 暂时通过 filePath 删除
|
|
||||||
log.warn("[deleteOssFile][通过 infra_file.id 删除文件,但 FileApi 未提供删除方法,id({})]", infraFileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通过 filePath 删除(需要获取 FileClient)
|
|
||||||
// TODO: 实现通过 filePath 删除OSS文件的逻辑
|
|
||||||
// 可以通过 FileService 或 FileClient 删除
|
|
||||||
log.warn("[deleteOssFile][准备删除OSS文件,路径({}),URL({})]", filePath, fileUrl);
|
|
||||||
} catch (Exception e) {
|
|
||||||
// 删除OSS文件失败不影响主流程,只记录日志
|
|
||||||
log.error("[deleteOssFile][删除OSS文件失败,路径({}),URL({})]", filePath, fileUrl, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成上传路径(与 FileService 保持一致)
|
|
||||||
* 格式:{directory}/{yyyyMMdd}/{filename}_{timestamp}.ext
|
* 格式:{directory}/{yyyyMMdd}/{filename}_{timestamp}.ext
|
||||||
*/
|
*/
|
||||||
private String generateUploadPath(String name, String directory) {
|
private String generateUploadPath(String name, String directory) {
|
||||||
// 1. 生成前缀、后缀
|
String prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN);
|
||||||
String prefix = null;
|
String suffix = String.valueOf(System.currentTimeMillis());
|
||||||
if (PATH_PREFIX_DATE_ENABLE) {
|
|
||||||
prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN);
|
|
||||||
}
|
|
||||||
String suffix = null;
|
|
||||||
if (PATH_SUFFIX_TIMESTAMP_ENABLE) {
|
|
||||||
suffix = String.valueOf(System.currentTimeMillis());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.1 先拼接 suffix 后缀
|
// 拼接后缀
|
||||||
if (StrUtil.isNotEmpty(suffix)) {
|
String ext = FileUtil.extName(name);
|
||||||
String ext = FileUtil.extName(name);
|
if (StrUtil.isNotEmpty(ext)) {
|
||||||
if (StrUtil.isNotEmpty(ext)) {
|
name = FileUtil.mainName(name) + "_" + suffix + "." + ext;
|
||||||
name = FileUtil.mainName(name) + "_" + suffix + "." + ext;
|
} else {
|
||||||
} else {
|
name = name + "_" + suffix;
|
||||||
name = name + "_" + suffix;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 2.2 再拼接 prefix 前缀
|
// 拼接前缀和目录
|
||||||
if (StrUtil.isNotEmpty(prefix)) {
|
name = prefix + "/" + name;
|
||||||
name = prefix + "/" + name;
|
|
||||||
}
|
|
||||||
// 2.3 最后拼接 directory 目录
|
|
||||||
if (StrUtil.isNotEmpty(directory)) {
|
if (StrUtil.isNotEmpty(directory)) {
|
||||||
name = directory + "/" + name;
|
name = directory + "/" + name;
|
||||||
}
|
}
|
||||||
@@ -338,51 +267,36 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<AppTikUserFileRespVO> getFilePage(AppTikUserFilePageReqVO pageReqVO) {
|
public PageResult<AppTikUserFileRespVO> getFilePage(AppTikUserFilePageReqVO pageReqVO) {
|
||||||
// 自动填充当前登录用户ID
|
pageReqVO.setUserId(SecurityFrameworkUtils.getLoginUserId());
|
||||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
|
||||||
pageReqVO.setUserId(userId);
|
|
||||||
|
|
||||||
// 查询文件列表
|
|
||||||
PageResult<TikUserFileDO> pageResult = userFileMapper.selectPage(pageReqVO);
|
PageResult<TikUserFileDO> pageResult = userFileMapper.selectPage(pageReqVO);
|
||||||
|
|
||||||
// 转换为VO并生成预览URL
|
|
||||||
return CollectionUtils.convertPage(pageResult, file -> {
|
return CollectionUtils.convertPage(pageResult, file -> {
|
||||||
AppTikUserFileRespVO vo = BeanUtils.toBean(file, AppTikUserFileRespVO.class);
|
AppTikUserFileRespVO vo = BeanUtils.toBean(file, AppTikUserFileRespVO.class);
|
||||||
|
|
||||||
// 判断文件类型
|
|
||||||
boolean isVideo = StrUtil.containsIgnoreCase(file.getFileType(), "video");
|
boolean isVideo = StrUtil.containsIgnoreCase(file.getFileType(), "video");
|
||||||
boolean isImage = FileTypeUtils.isImage(file.getFileType());
|
boolean isImage = FileTypeUtils.isImage(file.getFileType());
|
||||||
vo.setIsVideo(isVideo);
|
vo.setIsVideo(isVideo);
|
||||||
vo.setIsImage(isImage);
|
vo.setIsImage(isImage);
|
||||||
|
|
||||||
// 视频文件不使用任何OSS预签名URL(包括coverUrl和previewUrl)
|
// 视频文件不生成OSS预签名URL(前端使用coverBase64缓存)
|
||||||
// 只返回coverBase64(如果有),前端从本地缓存获取
|
if (isVideo) {
|
||||||
String coverUrlPresigned = null;
|
vo.setCoverUrl(null);
|
||||||
String thumbnailUrlPresigned = null;
|
vo.setThumbnailUrl(null);
|
||||||
String previewUrl = null;
|
vo.setPreviewUrl(null);
|
||||||
|
return vo;
|
||||||
// 非视频文件才生成预签名URL
|
|
||||||
if (!isVideo) {
|
|
||||||
// 图片文件:生成缩略图URL
|
|
||||||
thumbnailUrlPresigned = StrUtil.isNotBlank(file.getThumbnailUrl())
|
|
||||||
? getCachedPresignUrl(file.getThumbnailUrl(), PRESIGN_URL_EXPIRATION_SECONDS)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// 生成预览URL
|
|
||||||
if (isImage) {
|
|
||||||
// 图片:优先使用缩略图,没有缩略图时使用原图
|
|
||||||
previewUrl = thumbnailUrlPresigned != null
|
|
||||||
? thumbnailUrlPresigned
|
|
||||||
: getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置封面和缩略图URL
|
// 非视频文件生成预签名URL
|
||||||
vo.setCoverUrl(coverUrlPresigned);
|
String thumbnailUrl = StrUtil.isNotBlank(file.getThumbnailUrl())
|
||||||
vo.setThumbnailUrl(thumbnailUrlPresigned);
|
? getCachedPresignUrl(file.getThumbnailUrl(), PRESIGN_URL_EXPIRATION_SECONDS)
|
||||||
|
: null;
|
||||||
|
vo.setCoverUrl(null);
|
||||||
|
vo.setThumbnailUrl(thumbnailUrl);
|
||||||
|
|
||||||
// 生成预览URL
|
// 图片预览URL:优先缩略图,否则原图
|
||||||
vo.setPreviewUrl(previewUrl);
|
vo.setPreviewUrl(isImage
|
||||||
|
? (thumbnailUrl != null ? thumbnailUrl : getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS))
|
||||||
|
: null);
|
||||||
|
|
||||||
return vo;
|
return vo;
|
||||||
});
|
});
|
||||||
@@ -396,43 +310,15 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
}
|
}
|
||||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
|
||||||
// 查询文件列表
|
|
||||||
List<TikUserFileDO> files = userFileMapper.selectBatchIds(fileIds);
|
List<TikUserFileDO> files = userFileMapper.selectBatchIds(fileIds);
|
||||||
if (files.isEmpty()) {
|
if (files.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验文件属于当前用户
|
// 校验文件属于当前用户
|
||||||
for (TikUserFileDO file : files) {
|
boolean allOwnedByUser = files.stream().allMatch(file -> file.getUserId().equals(userId));
|
||||||
if (!file.getUserId().equals(userId)) {
|
if (!allOwnedByUser) {
|
||||||
throw exception(FILE_NOT_EXISTS, "文件不属于当前用户");
|
throw exception(FILE_NOT_EXISTS, "文件不属于当前用户");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 物理删除OSS文件(使用 file_path,如果为空则使用 file_url)
|
|
||||||
for (TikUserFileDO file : files) {
|
|
||||||
try {
|
|
||||||
// 优先使用 file_path,如果没有则从 file_url 提取路径
|
|
||||||
String pathToDelete = file.getFilePath();
|
|
||||||
if (StrUtil.isBlank(pathToDelete)) {
|
|
||||||
// 如果 file_path 为空,尝试从 file_url 提取路径
|
|
||||||
// file_url 格式可能是:https://domain.com/path/to/file 或 /path/to/file
|
|
||||||
pathToDelete = extractPathFromUrl(file.getFileUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (StrUtil.isNotBlank(pathToDelete)) {
|
|
||||||
// 通过 FileApi 删除文件(需要先获取 FileDO,然后删除)
|
|
||||||
// 注意:这里需要关联 infra_file 表获取 configId 和 path
|
|
||||||
// 如果 file_id 存在,可以通过 FileService 删除
|
|
||||||
// 否则需要直接调用 FileClient 删除
|
|
||||||
// TODO: 实现物理删除OSS文件的逻辑
|
|
||||||
log.debug("[deleteFiles][准备删除OSS文件,路径({})]", pathToDelete);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
// 删除OSS文件失败不影响逻辑删除,只记录日志
|
|
||||||
log.warn("[deleteFiles][删除OSS文件失败,文件编号({}),路径({})]",
|
|
||||||
file.getId(), file.getFilePath(), e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 逻辑删除文件
|
// 逻辑删除文件
|
||||||
@@ -442,7 +328,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
long totalSize = files.stream().mapToLong(TikUserFileDO::getFileSize).sum();
|
long totalSize = files.stream().mapToLong(TikUserFileDO::getFileSize).sum();
|
||||||
memberUserProfileService.decreaseUsedStorage(String.valueOf(userId), totalSize);
|
memberUserProfileService.decreaseUsedStorage(String.valueOf(userId), totalSize);
|
||||||
|
|
||||||
log.info("[deleteFiles][用户({})删除文件成功,文件数量({})]", userId, fileIds.size());
|
log.info("[deleteFiles][用户({})删除文件成功,数量({})]", userId, fileIds.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -514,37 +400,25 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 确定预览URL
|
* 确定预览URL
|
||||||
*
|
|
||||||
* @param file 文件对象
|
|
||||||
* @param type 预览类型(cover/thumbnail,可选)
|
|
||||||
* @return 预览URL
|
|
||||||
*/
|
*/
|
||||||
private String determinePreviewUrl(TikUserFileDO file, String type) {
|
private String determinePreviewUrl(TikUserFileDO file, String type) {
|
||||||
// 视频文件:不返回任何OSS URL(前端使用coverBase64缓存)
|
// 视频文件:不返回任何OSS URL(前端使用coverBase64缓存)
|
||||||
boolean isVideo = StrUtil.containsIgnoreCase(file.getFileType(), "video");
|
if (StrUtil.containsIgnoreCase(file.getFileType(), "video")) {
|
||||||
if (isVideo) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 明确指定封面类型(非视频文件)
|
// 明确指定类型
|
||||||
if (StrUtil.equals(type, "cover") && StrUtil.isNotBlank(file.getCoverUrl())) {
|
if (StrUtil.equals(type, "cover") && StrUtil.isNotBlank(file.getCoverUrl())) {
|
||||||
return file.getCoverUrl();
|
return file.getCoverUrl();
|
||||||
}
|
}
|
||||||
// 明确指定缩略图类型
|
|
||||||
if (StrUtil.equals(type, "thumbnail") && StrUtil.isNotBlank(file.getThumbnailUrl())) {
|
if (StrUtil.equals(type, "thumbnail") && StrUtil.isNotBlank(file.getThumbnailUrl())) {
|
||||||
return file.getThumbnailUrl();
|
return file.getThumbnailUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据文件类型自动选择
|
// 根据文件类型自动选择
|
||||||
boolean isImage = FileTypeUtils.isImage(file.getFileType());
|
if (FileTypeUtils.isImage(file.getFileType())) {
|
||||||
|
return StrUtil.isNotBlank(file.getThumbnailUrl()) ? file.getThumbnailUrl() : file.getFileUrl();
|
||||||
if (isImage) {
|
|
||||||
return StrUtil.isNotBlank(file.getThumbnailUrl())
|
|
||||||
? file.getThumbnailUrl()
|
|
||||||
: file.getFileUrl();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认返回原文件URL
|
|
||||||
return file.getFileUrl();
|
return file.getFileUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,92 +432,41 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
return fileApi.presignGetUrl(url, expirationSeconds != null ? expirationSeconds : 86400);
|
return fileApi.presignGetUrl(url, expirationSeconds != null ? expirationSeconds : 86400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从URL中提取文件路径
|
|
||||||
*
|
|
||||||
* @param url 文件URL
|
|
||||||
* @return 文件路径
|
|
||||||
*/
|
|
||||||
private String extractPathFromUrl(String url) {
|
|
||||||
if (StrUtil.isBlank(url)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 移除URL中的查询参数(签名参数等)
|
|
||||||
String cleanUrl = url;
|
|
||||||
if (url.contains("?")) {
|
|
||||||
cleanUrl = url.substring(0, url.indexOf("?"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果URL包含域名,提取路径部分
|
|
||||||
if (cleanUrl.contains("://")) {
|
|
||||||
int pathStart = cleanUrl.indexOf("/", cleanUrl.indexOf("://") + 3);
|
|
||||||
if (pathStart > 0) {
|
|
||||||
String fullPath = cleanUrl.substring(pathStart);
|
|
||||||
// 路径可能包含 bucket 名称,需要提取实际的文件路径
|
|
||||||
// 例如:/bucket-name/user-id/tenant-id/voice/20251117/file.wav
|
|
||||||
// 实际 path 可能是:user-id/tenant-id/voice/20251117/file.wav
|
|
||||||
// 但数据库中的 path 格式是:voice/20251117/file_timestamp.wav
|
|
||||||
// 所以我们需要找到包含日期格式的部分(yyyyMMdd)
|
|
||||||
return fullPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 如果已经是路径格式,直接返回(去除查询参数)
|
|
||||||
if (cleanUrl.startsWith("/")) {
|
|
||||||
return cleanUrl;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[extractPathFromUrl][从URL提取路径失败,URL({})]", url, e);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Object getPresignedUrl(String fileName, String fileCategory, Long groupId, Long fileSize) {
|
public Object getPresignedUrl(String fileName, String fileCategory, Long groupId, Long fileSize, String contentType) {
|
||||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
|
||||||
// 1. 校验请求
|
|
||||||
validateUploadRequest(fileCategory, fileSize);
|
validateUploadRequest(fileCategory, fileSize);
|
||||||
|
|
||||||
// 2. 获取OSS基础目录路径
|
|
||||||
String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory);
|
String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory);
|
||||||
|
|
||||||
// 3. 生成文件路径
|
|
||||||
String filePath = generateUploadPath(fileName, baseDirectory);
|
String filePath = generateUploadPath(fileName, baseDirectory);
|
||||||
|
|
||||||
// 4. 生成预签名URL
|
|
||||||
FileClient client = fileConfigService.getMasterFileClient();
|
FileClient client = fileConfigService.getMasterFileClient();
|
||||||
Assert.notNull(client, "客户端(master) 不能为空");
|
Assert.notNull(client, "客户端(master) 不能为空");
|
||||||
|
|
||||||
String presignedUrl = client.presignPutUrl(filePath);
|
String effectiveContentType = StrUtil.isNotBlank(contentType)
|
||||||
|
? contentType
|
||||||
|
: FileTypeUtils.getMineType(fileName);
|
||||||
|
|
||||||
|
String presignedUrl = client.presignPutUrl(filePath, effectiveContentType);
|
||||||
String visitUrl = client.presignGetUrl(filePath, null);
|
String visitUrl = client.presignGetUrl(filePath, null);
|
||||||
|
|
||||||
// 5. 替换为自定义域名
|
|
||||||
String domain = getFileClientDomain(client.getId());
|
String domain = getFileClientDomain(client.getId());
|
||||||
if (StrUtil.isNotBlank(domain)) {
|
if (StrUtil.isNotBlank(domain)) {
|
||||||
presignedUrl = replaceUrlDomain(presignedUrl, domain);
|
presignedUrl = replaceUrlDomain(presignedUrl, domain);
|
||||||
visitUrl = replaceUrlDomain(visitUrl, domain);
|
visitUrl = replaceUrlDomain(visitUrl, domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 构建返回结果
|
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"presignedUrl", presignedUrl,
|
"presignedUrl", presignedUrl,
|
||||||
"uploadUrl", HttpUtils.removeUrlQuery(visitUrl),
|
"uploadUrl", HttpUtils.removeUrlQuery(visitUrl),
|
||||||
"fileKey", filePath,
|
"fileKey", filePath,
|
||||||
"expiresIn", PRESIGN_URL_EXPIRATION_SECONDS,
|
"expiresIn", PRESIGN_URL_EXPIRATION_SECONDS,
|
||||||
"method", "PUT",
|
"method", "PUT",
|
||||||
"headers", Map.of(
|
"headers", Map.of("Content-Type", effectiveContentType)
|
||||||
"Content-Type", FileTypeUtils.getMineType(fileName)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取文件客户端的自定义域名
|
|
||||||
* @param configId 配置编号
|
|
||||||
* @return 自定义域名,如果没有配置则返回 null
|
|
||||||
*/
|
|
||||||
private String getFileClientDomain(Long configId) {
|
private String getFileClientDomain(Long configId) {
|
||||||
if (configId == null) {
|
if (configId == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -661,26 +484,18 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 替换URL的域名部分
|
* 替换URL的域名部分
|
||||||
* @param url 原始URL
|
|
||||||
* @param newDomain 新域名
|
|
||||||
* @return 替换后的URL
|
|
||||||
*/
|
*/
|
||||||
private String replaceUrlDomain(String url, String newDomain) {
|
private String replaceUrlDomain(String url, String newDomain) {
|
||||||
if (StrUtil.isBlank(url) || StrUtil.isBlank(newDomain)) {
|
if (StrUtil.isBlank(url) || StrUtil.isBlank(newDomain)) {
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
try {
|
int schemeEnd = url.indexOf("://");
|
||||||
// 提取原始URL的协议和主机名
|
if (schemeEnd <= 0) {
|
||||||
int schemeEnd = url.indexOf("://");
|
return url;
|
||||||
if (schemeEnd > 0) {
|
|
||||||
int pathStart = url.indexOf("/", schemeEnd + 3);
|
|
||||||
String pathAndQuery = pathStart > 0 ? url.substring(pathStart) : "";
|
|
||||||
return newDomain + pathAndQuery;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[replaceUrlDomain][替换域名失败,url({})]", url, e);
|
|
||||||
}
|
}
|
||||||
return url;
|
int pathStart = url.indexOf("/", schemeEnd + 3);
|
||||||
|
String pathAndQuery = pathStart > 0 ? url.substring(pathStart) : "";
|
||||||
|
return newDomain + pathAndQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -689,7 +504,6 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
public Object completeUpload(Object request) {
|
public Object completeUpload(Object request) {
|
||||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||||
|
|
||||||
// 解析请求参数
|
|
||||||
Map<String, Object> params = (Map<String, Object>) request;
|
Map<String, Object> params = (Map<String, Object>) request;
|
||||||
String fileKey = (String) params.get("fileKey");
|
String fileKey = (String) params.get("fileKey");
|
||||||
String fileName = (String) params.get("fileName");
|
String fileName = (String) params.get("fileName");
|
||||||
@@ -700,16 +514,12 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
String coverBase64 = (String) params.get("coverBase64");
|
String coverBase64 = (String) params.get("coverBase64");
|
||||||
Integer duration = params.get("duration") != null ? ((Number) params.get("duration")).intValue() : null;
|
Integer duration = params.get("duration") != null ? ((Number) params.get("duration")).intValue() : null;
|
||||||
|
|
||||||
// 1. 校验请求
|
|
||||||
validateUploadRequest(fileCategory, fileSize);
|
validateUploadRequest(fileCategory, fileSize);
|
||||||
|
|
||||||
// 2. 获取文件访问URL
|
|
||||||
FileClient client = fileConfigService.getMasterFileClient();
|
FileClient client = fileConfigService.getMasterFileClient();
|
||||||
Assert.notNull(client, "客户端(master) 不能为空");
|
Assert.notNull(client, "客户端(master) 不能为空");
|
||||||
String fileUrl = client.presignGetUrl(fileKey, null);
|
String fileUrl = HttpUtils.removeUrlQuery(client.presignGetUrl(fileKey, null));
|
||||||
fileUrl = HttpUtils.removeUrlQuery(fileUrl);
|
|
||||||
|
|
||||||
// 3. 保存到 infra_file 表
|
|
||||||
FileDO infraFile = new FileDO()
|
FileDO infraFile = new FileDO()
|
||||||
.setConfigId(client.getId())
|
.setConfigId(client.getId())
|
||||||
.setName(fileName)
|
.setName(fileName)
|
||||||
@@ -720,18 +530,15 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
fileMapper.insert(infraFile);
|
fileMapper.insert(infraFile);
|
||||||
Long infraFileId = infraFile.getId();
|
Long infraFileId = infraFile.getId();
|
||||||
|
|
||||||
// 4. 处理视频封面
|
|
||||||
String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory);
|
String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory);
|
||||||
String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory);
|
String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory);
|
||||||
// 处理显示名称(去除文件后缀)
|
|
||||||
String displayName = FileUtil.mainName(fileName);
|
String displayName = FileUtil.mainName(fileName);
|
||||||
|
|
||||||
// 5. 保存到 tik_user_file 表
|
|
||||||
TikUserFileDO userFile = new TikUserFileDO()
|
TikUserFileDO userFile = new TikUserFileDO()
|
||||||
.setUserId(userId)
|
.setUserId(userId)
|
||||||
.setFileId(infraFileId)
|
.setFileId(infraFileId)
|
||||||
.setFileName(fileName) // 保留原始文件名(系统标识)
|
.setFileName(fileName)
|
||||||
.setDisplayName(displayName) // 设置显示名称(无后缀,用户可编辑)
|
.setDisplayName(displayName)
|
||||||
.setFileType(fileType)
|
.setFileType(fileType)
|
||||||
.setFileCategory(fileCategory)
|
.setFileCategory(fileCategory)
|
||||||
.setFileSize(fileSize)
|
.setFileSize(fileSize)
|
||||||
@@ -743,13 +550,11 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
|||||||
.setGroupId(groupId);
|
.setGroupId(groupId);
|
||||||
userFileMapper.insert(userFile);
|
userFileMapper.insert(userFile);
|
||||||
|
|
||||||
// 6. 更新配额
|
|
||||||
memberUserProfileService.increaseUsedStorage(String.valueOf(userId), fileSize);
|
memberUserProfileService.increaseUsedStorage(String.valueOf(userId), fileSize);
|
||||||
|
|
||||||
log.info("[completeUpload][用户({})直传上传完成,infraFileId({}),userFileId({})]",
|
log.info("[completeUpload][用户({})直传上传完成,infraFileId({}),userFileId({})]",
|
||||||
userId, infraFileId, userFile.getId());
|
userId, infraFileId, userFile.getId());
|
||||||
|
|
||||||
// 7. 返回结果
|
|
||||||
return Map.of(
|
return Map.of(
|
||||||
"infraFileId", infraFileId,
|
"infraFileId", infraFileId,
|
||||||
"userFileId", userFile.getId(),
|
"userFileId", userFile.getId(),
|
||||||
|
|||||||
Reference in New Issue
Block a user