From b0fa4279b0138a6fc40b1a93218cc9dc7a22f393 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Tue, 3 Mar 2026 04:16:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/web-gold/src/composables/useUpload.js | 6 +- yudao-module-infra/pom.xml | 6 + .../file/core/client/FileClient.java | 11 + .../file/core/client/s3/S3FileClient.java | 264 +++++++++---- .../controller/AppTikUserFileController.java | 6 +- .../tik/file/service/TikUserFileService.java | 3 +- .../file/service/TikUserFileServiceImpl.java | 347 ++++-------------- 7 files changed, 289 insertions(+), 354 deletions(-) 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 getPresignedUrl( @RequestParam("fileName") String fileName, @RequestParam("fileCategory") String fileCategory, @RequestParam(value = "groupId", required = false) Long groupId, - @RequestParam(value = "fileSize", required = false) Long fileSize) { - return success(userFileService.getPresignedUrl(fileName, fileCategory, groupId, fileSize)); + @RequestParam(value = "fileSize", required = false) Long fileSize, + @RequestParam(value = "contentType", required = false) String contentType) { + return success(userFileService.getPresignedUrl(fileName, fileCategory, groupId, fileSize, contentType)); } @PostMapping("/complete-upload") diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java index e66a2dd57e..c8c61d8784 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java @@ -82,9 +82,10 @@ public interface TikUserFileService { * @param fileCategory 文件分类 * @param groupId 分组编号(可选) * @param fileSize 文件大小(可选) + * @param contentType 文件 MIME 类型(可选) * @return 预签名URL信息 */ - Object getPresignedUrl(String fileName, String fileCategory, Long groupId, Long fileSize); + Object getPresignedUrl(String fileName, String fileCategory, Long groupId, Long fileSize, String contentType); /** * 确认上传(直传模式) diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java index 147e1f2ee8..c84c35d86f 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java @@ -53,24 +53,8 @@ import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.*; @Slf4j public class TikUserFileServiceImpl implements TikUserFileService { - /** 预签名URL过期时间(24小时,单位:秒) */ 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 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) { Long userId = SecurityFrameworkUtils.getLoginUserId(); - // ========== 第一阶段:校验和准备 ========== - // 1. 校验文件分类和配额 + // 校验文件分类和配额 validateUploadRequest(fileCategory, file.getSize()); - // 2. 获取OSS基础目录路径 + // 获取OSS基础目录路径 String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory); - // ========== 第二阶段:处理文件和上传 ========== + // 处理文件和上传 UploadFileContext context = processFileAndUpload(file, baseDirectory); - // ========== 第三阶段:保存数据库 ========== - try { - return saveFileRecord(userId, file, fileCategory, context.fileUrl, context.filePath, - coverBase64, baseDirectory, context.infraFileId, duration, groupId); - } catch (Exception e) { - log.error("[uploadFile][保存数据库失败]", e); - deleteOssFile(context.infraFileId, context.filePath, context.fileUrl); - throw e; - } + // 保存数据库 + return saveFileRecord(userId, file, fileCategory, context.fileUrl, context.filePath, + coverBase64, baseDirectory, context.infraFileId, duration, groupId); } /** @@ -143,12 +120,11 @@ public class TikUserFileServiceImpl implements TikUserFileService { } /** - * 统一处理文件和上传逻辑 + * 处理文件和上传逻辑 */ private UploadFileContext processFileAndUpload(MultipartFile file, String baseDirectory) { UploadFileContext context = new UploadFileContext(); - // 1. 读取文件内容 byte[] fileContent; try { fileContent = IoUtil.readBytes(file.getInputStream()); @@ -158,12 +134,10 @@ public class TikUserFileServiceImpl implements TikUserFileService { } try { - // 2. 处理文件名和类型 String fileName = file.getOriginalFilename(); - String fileType = file.getContentType(); - if (StrUtil.isEmpty(fileType)) { - fileType = FileTypeUtils.getMineType(fileContent, fileName); - } + String fileType = StrUtil.isEmpty(file.getContentType()) + ? FileTypeUtils.getMineType(fileContent, fileName) + : file.getContentType(); if (StrUtil.isEmpty(fileName)) { fileName = DigestUtil.sha256Hex(fileContent); } @@ -174,15 +148,12 @@ public class TikUserFileServiceImpl implements TikUserFileService { } } - // 3. 生成上传路径 String filePath = generateUploadPath(fileName, baseDirectory); - // 4. 上传到OSS FileClient client = fileConfigService.getMasterFileClient(); Assert.notNull(client, "客户端(master) 不能为空"); String presignedUrl = client.upload(fileContent, filePath, fileType); - // 5. 保存到 infra_file 表 String fileUrl = HttpUtils.removeUrlQuery(presignedUrl); FileDO infraFile = new FileDO() .setConfigId(client.getId()) @@ -193,7 +164,6 @@ public class TikUserFileServiceImpl implements TikUserFileService { .setSize((int) file.getSize()); fileMapper.insert(infraFile); - // 6. 设置上下文 context.fileUrl = fileUrl; context.filePath = filePath; context.infraFileId = infraFile.getId(); @@ -210,13 +180,12 @@ public class TikUserFileServiceImpl implements TikUserFileService { } /** - * 统一处理视频封面上传 + * 处理视频封面上传 */ private String handleCoverUpload(String coverBase64, String fileName, String fileType, String baseDirectory) { if (StrUtil.isBlank(coverBase64) || !StrUtil.containsIgnoreCase(fileType, "video")) { return null; } - try { String base64Data = coverBase64.contains(",") ? coverBase64.substring(coverBase64.indexOf(",") + 1) @@ -234,102 +203,62 @@ public class TikUserFileServiceImpl implements TikUserFileService { } /** - * 保存文件记录到数据库(在事务中执行) + * 保存文件记录到数据库 */ @Transactional(rollbackFor = Exception.class) public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory, - String fileUrl, String filePath, String coverBase64, String baseDirectory, Long infraFileId, Integer duration, Long groupId) { - // 验证 infraFileId 不为空 + String fileUrl, String filePath, String coverBase64, + String baseDirectory, Long infraFileId, Integer duration, Long groupId) { 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"); } - // 处理视频封面和文件名 String fileName = file.getOriginalFilename(); String fileType = file.getContentType(); String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory); - // 处理显示名称(去除文件后缀) String displayName = FileUtil.mainName(fileName); - // 9. 创建文件记录(保存完整路径,包含封面URL和Base64) TikUserFileDO userFile = new TikUserFileDO() .setUserId(userId) - .setFileId(infraFileId) // 关联infra_file表,用于后续通过FileService管理文件 - .setFileName(fileName) // 保留原始文件名(系统标识) - .setDisplayName(displayName) // 设置显示名称(无后缀,用户可编辑) + .setFileId(infraFileId) + .setFileName(fileName) + .setDisplayName(displayName) .setFileType(fileType) .setFileCategory(fileCategory) .setFileSize(file.getSize()) .setFileUrl(fileUrl) - .setFilePath(filePath) // 保存完整的OSS路径(由FileService生成) - .setCoverUrl(coverUrl) // 设置封面URL(如果有) - .setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null) // 保存原始base64数据(如果有) - .setDuration(duration) // 设置视频时长(如果有) - .setGroupId(groupId); // 设置分组编号(如果有) + .setFilePath(filePath) + .setCoverUrl(coverUrl) + .setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null) + .setDuration(duration) + .setGroupId(groupId); userFileMapper.insert(userFile); - - // 10. 更新配额 memberUserProfileService.increaseUsedStorage(String.valueOf(userId), file.getSize()); - log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({}),infra文件编号({})]", + log.info("[saveFileRecord][用户({})保存文件成功,userFileId({}),infraFileId({})]", userId, userFile.getId(), infraFileId); - // 返回 infra_file.id,保持与现有配音功能的兼容性 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 */ private String generateUploadPath(String name, String directory) { - // 1. 生成前缀、后缀 - String prefix = null; - 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()); - } + String prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN); + String suffix = String.valueOf(System.currentTimeMillis()); - // 2.1 先拼接 suffix 后缀 - if (StrUtil.isNotEmpty(suffix)) { - String ext = FileUtil.extName(name); - if (StrUtil.isNotEmpty(ext)) { - name = FileUtil.mainName(name) + "_" + suffix + "." + ext; - } else { - name = name + "_" + suffix; - } + // 拼接后缀 + String ext = FileUtil.extName(name); + if (StrUtil.isNotEmpty(ext)) { + name = FileUtil.mainName(name) + "_" + suffix + "." + ext; + } else { + name = name + "_" + suffix; } - // 2.2 再拼接 prefix 前缀 - if (StrUtil.isNotEmpty(prefix)) { - name = prefix + "/" + name; - } - // 2.3 最后拼接 directory 目录 + // 拼接前缀和目录 + name = prefix + "/" + name; if (StrUtil.isNotEmpty(directory)) { name = directory + "/" + name; } @@ -338,51 +267,36 @@ public class TikUserFileServiceImpl implements TikUserFileService { @Override public PageResult getFilePage(AppTikUserFilePageReqVO pageReqVO) { - // 自动填充当前登录用户ID - Long userId = SecurityFrameworkUtils.getLoginUserId(); - pageReqVO.setUserId(userId); + pageReqVO.setUserId(SecurityFrameworkUtils.getLoginUserId()); - // 查询文件列表 PageResult pageResult = userFileMapper.selectPage(pageReqVO); - // 转换为VO并生成预览URL return CollectionUtils.convertPage(pageResult, file -> { AppTikUserFileRespVO vo = BeanUtils.toBean(file, AppTikUserFileRespVO.class); - - // 判断文件类型 boolean isVideo = StrUtil.containsIgnoreCase(file.getFileType(), "video"); boolean isImage = FileTypeUtils.isImage(file.getFileType()); vo.setIsVideo(isVideo); vo.setIsImage(isImage); - // 视频文件不使用任何OSS预签名URL(包括coverUrl和previewUrl) - // 只返回coverBase64(如果有),前端从本地缓存获取 - String coverUrlPresigned = null; - String thumbnailUrlPresigned = null; - String previewUrl = null; - - // 非视频文件才生成预签名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); - } + // 视频文件不生成OSS预签名URL(前端使用coverBase64缓存) + if (isVideo) { + vo.setCoverUrl(null); + vo.setThumbnailUrl(null); + vo.setPreviewUrl(null); + return vo; } - // 设置封面和缩略图URL - vo.setCoverUrl(coverUrlPresigned); - vo.setThumbnailUrl(thumbnailUrlPresigned); + // 非视频文件生成预签名URL + String thumbnailUrl = StrUtil.isNotBlank(file.getThumbnailUrl()) + ? getCachedPresignUrl(file.getThumbnailUrl(), PRESIGN_URL_EXPIRATION_SECONDS) + : null; + vo.setCoverUrl(null); + vo.setThumbnailUrl(thumbnailUrl); - // 生成预览URL - vo.setPreviewUrl(previewUrl); + // 图片预览URL:优先缩略图,否则原图 + vo.setPreviewUrl(isImage + ? (thumbnailUrl != null ? thumbnailUrl : getCachedPresignUrl(file.getFileUrl(), PRESIGN_URL_EXPIRATION_SECONDS)) + : null); return vo; }); @@ -396,43 +310,15 @@ public class TikUserFileServiceImpl implements TikUserFileService { } Long userId = SecurityFrameworkUtils.getLoginUserId(); - // 查询文件列表 List files = userFileMapper.selectBatchIds(fileIds); if (files.isEmpty()) { return; } // 校验文件属于当前用户 - for (TikUserFileDO file : files) { - if (!file.getUserId().equals(userId)) { - 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); - } + boolean allOwnedByUser = files.stream().allMatch(file -> file.getUserId().equals(userId)); + if (!allOwnedByUser) { + throw exception(FILE_NOT_EXISTS, "文件不属于当前用户"); } // 逻辑删除文件 @@ -442,7 +328,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { long totalSize = files.stream().mapToLong(TikUserFileDO::getFileSize).sum(); memberUserProfileService.decreaseUsedStorage(String.valueOf(userId), totalSize); - log.info("[deleteFiles][用户({})删除文件成功,文件数量({})]", userId, fileIds.size()); + log.info("[deleteFiles][用户({})删除文件成功,数量({})]", userId, fileIds.size()); } @Override @@ -514,37 +400,25 @@ public class TikUserFileServiceImpl implements TikUserFileService { /** * 确定预览URL - * - * @param file 文件对象 - * @param type 预览类型(cover/thumbnail,可选) - * @return 预览URL */ private String determinePreviewUrl(TikUserFileDO file, String type) { // 视频文件:不返回任何OSS URL(前端使用coverBase64缓存) - boolean isVideo = StrUtil.containsIgnoreCase(file.getFileType(), "video"); - if (isVideo) { + if (StrUtil.containsIgnoreCase(file.getFileType(), "video")) { return null; } - // 明确指定封面类型(非视频文件) + // 明确指定类型 if (StrUtil.equals(type, "cover") && StrUtil.isNotBlank(file.getCoverUrl())) { return file.getCoverUrl(); } - // 明确指定缩略图类型 if (StrUtil.equals(type, "thumbnail") && StrUtil.isNotBlank(file.getThumbnailUrl())) { return file.getThumbnailUrl(); } // 根据文件类型自动选择 - boolean isImage = FileTypeUtils.isImage(file.getFileType()); - - if (isImage) { - return StrUtil.isNotBlank(file.getThumbnailUrl()) - ? file.getThumbnailUrl() - : file.getFileUrl(); + if (FileTypeUtils.isImage(file.getFileType())) { + return StrUtil.isNotBlank(file.getThumbnailUrl()) ? file.getThumbnailUrl() : file.getFileUrl(); } - - // 默认返回原文件URL return file.getFileUrl(); } @@ -558,92 +432,41 @@ public class TikUserFileServiceImpl implements TikUserFileService { 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 - 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(); - // 1. 校验请求 validateUploadRequest(fileCategory, fileSize); - // 2. 获取OSS基础目录路径 String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory); - - // 3. 生成文件路径 String filePath = generateUploadPath(fileName, baseDirectory); - // 4. 生成预签名URL FileClient client = fileConfigService.getMasterFileClient(); 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); - // 5. 替换为自定义域名 String domain = getFileClientDomain(client.getId()); if (StrUtil.isNotBlank(domain)) { presignedUrl = replaceUrlDomain(presignedUrl, domain); visitUrl = replaceUrlDomain(visitUrl, domain); } - // 6. 构建返回结果 return Map.of( "presignedUrl", presignedUrl, "uploadUrl", HttpUtils.removeUrlQuery(visitUrl), "fileKey", filePath, "expiresIn", PRESIGN_URL_EXPIRATION_SECONDS, "method", "PUT", - "headers", Map.of( - "Content-Type", FileTypeUtils.getMineType(fileName) - ) + "headers", Map.of("Content-Type", effectiveContentType) ); } - /** - * 获取文件客户端的自定义域名 - * @param configId 配置编号 - * @return 自定义域名,如果没有配置则返回 null - */ private String getFileClientDomain(Long configId) { if (configId == null) { return null; @@ -661,26 +484,18 @@ public class TikUserFileServiceImpl implements TikUserFileService { /** * 替换URL的域名部分 - * @param url 原始URL - * @param newDomain 新域名 - * @return 替换后的URL */ private String replaceUrlDomain(String url, String newDomain) { if (StrUtil.isBlank(url) || StrUtil.isBlank(newDomain)) { return url; } - try { - // 提取原始URL的协议和主机名 - int schemeEnd = url.indexOf("://"); - 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); + int schemeEnd = url.indexOf("://"); + if (schemeEnd <= 0) { + return url; } - return url; + int pathStart = url.indexOf("/", schemeEnd + 3); + String pathAndQuery = pathStart > 0 ? url.substring(pathStart) : ""; + return newDomain + pathAndQuery; } @Override @@ -689,7 +504,6 @@ public class TikUserFileServiceImpl implements TikUserFileService { public Object completeUpload(Object request) { Long userId = SecurityFrameworkUtils.getLoginUserId(); - // 解析请求参数 Map params = (Map) request; String fileKey = (String) params.get("fileKey"); String fileName = (String) params.get("fileName"); @@ -700,16 +514,12 @@ public class TikUserFileServiceImpl implements TikUserFileService { String coverBase64 = (String) params.get("coverBase64"); Integer duration = params.get("duration") != null ? ((Number) params.get("duration")).intValue() : null; - // 1. 校验请求 validateUploadRequest(fileCategory, fileSize); - // 2. 获取文件访问URL FileClient client = fileConfigService.getMasterFileClient(); Assert.notNull(client, "客户端(master) 不能为空"); - String fileUrl = client.presignGetUrl(fileKey, null); - fileUrl = HttpUtils.removeUrlQuery(fileUrl); + String fileUrl = HttpUtils.removeUrlQuery(client.presignGetUrl(fileKey, null)); - // 3. 保存到 infra_file 表 FileDO infraFile = new FileDO() .setConfigId(client.getId()) .setName(fileName) @@ -720,18 +530,15 @@ public class TikUserFileServiceImpl implements TikUserFileService { fileMapper.insert(infraFile); Long infraFileId = infraFile.getId(); - // 4. 处理视频封面 String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory); String coverUrl = handleCoverUpload(coverBase64, fileName, fileType, baseDirectory); - // 处理显示名称(去除文件后缀) String displayName = FileUtil.mainName(fileName); - // 5. 保存到 tik_user_file 表 TikUserFileDO userFile = new TikUserFileDO() .setUserId(userId) .setFileId(infraFileId) - .setFileName(fileName) // 保留原始文件名(系统标识) - .setDisplayName(displayName) // 设置显示名称(无后缀,用户可编辑) + .setFileName(fileName) + .setDisplayName(displayName) .setFileType(fileType) .setFileCategory(fileCategory) .setFileSize(fileSize) @@ -743,13 +550,11 @@ public class TikUserFileServiceImpl implements TikUserFileService { .setGroupId(groupId); userFileMapper.insert(userFile); - // 6. 更新配额 memberUserProfileService.increaseUsedStorage(String.valueOf(userId), fileSize); log.info("[completeUpload][用户({})直传上传完成,infraFileId({}),userFileId({})]", userId, infraFileId, userFile.getId()); - // 7. 返回结果 return Map.of( "infraFileId", infraFileId, "userFileId", userFile.getId(),