@@ -42,6 +42,9 @@ import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.*;
@Slf4j
public class TikUserFileServiceImpl implements TikUserFileService {
/** 预签名URL过期时间( 1小时, 单位: 秒) */
private static final int PRESIGN_URL_EXPIRATION_SECONDS = 3600 ;
@Resource
private TikUserFileMapper userFileMapper ;
@@ -58,7 +61,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
private FileMapper fileMapper ;
@Override
public Long uploadFile ( MultipartFile file , String fileCategory ) {
public Long uploadFile ( MultipartFile file , String fileCategory , String coverBase64 ) {
Long userId = SecurityFrameworkUtils . getLoginUserId ( ) ;
Long tenantId = TenantContextHolder . getTenantId ( ) ;
@@ -112,7 +115,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
// ========== 第三阶段: 保存数据库( 在事务中, 如果失败则删除OSS文件) ==========
try {
return saveFileRecord ( userId , file , fileCategory , fileUrl , filePath ) ;
return saveFileRecord ( userId , file , fileCategory , fileUrl , filePath , coverBase64 , baseDirectory );
} catch ( Exception e ) {
// 数据库保存失败, 删除已上传的OSS文件
log . error ( " [uploadFile][保存数据库失败, 准备删除OSS文件, URL({})] " , fileUrl , e ) ;
@@ -126,11 +129,55 @@ public class TikUserFileServiceImpl implements TikUserFileService {
*/
@Transactional ( rollbackFor = Exception . class )
public Long saveFileRecord ( Long userId , MultipartFile file , String fileCategory ,
String fileUrl , String filePath ) {
// 7. 获取OSS根路径
String ossRootPath = ossInitService . getOssRootPath ( userId ) ;
String fileUrl , String filePath , String coverBase64 , String baseDirectory ) {
// 7. 处理视频封面(如果有前端传递的 base64 封面,先处理封面再插入主记录)
String coverUrl = null ;
if ( StrUtil . isNotBlank ( coverBase64 ) & & StrUtil . containsIgnoreCase ( file . getContentType ( ) , " video " ) ) {
try {
// 解析 base64( 格式: data:image/jpeg;base64,/9j/4AAQ...)
String base64Data = coverBase64 ;
if ( base64Data . contains ( " , " ) ) {
base64Data = base64Data . substring ( base64Data . indexOf ( " , " ) + 1 ) ;
}
// 验证 base64 数据不为空
if ( StrUtil . isBlank ( base64Data ) ) {
log . warn ( " [saveFileRecord][视频封面 base64 数据为空,跳过封面处理] " ) ;
} else {
// 解码 base64
byte [ ] coverBytes = java . util . Base64 . getDecoder ( ) . decode ( base64Data ) ;
// 验证解码后的数据不为空
if ( coverBytes = = null | | coverBytes . length = = 0 ) {
log . warn ( " [saveFileRecord][视频封面 base64 解码后数据为空,跳过封面处理] " ) ;
} else {
// 生成封面文件名( 原文件名_cover.jpg)
String originalName = file . getOriginalFilename ( ) ;
String coverFileName = originalName ! = null & & originalName . contains ( " . " )
? originalName . replaceFirst ( " \\ .[^.]+$ " , " _cover.jpg " )
: " cover.jpg " ;
// 上传封面到 OSS( 使用相同的目录结构)
String uploadedUrl = fileApi . createFile ( coverBytes , coverFileName , baseDirectory , " image/jpeg " ) ;
// 严格验证:确保返回的是有效的 URL, 而不是 base64 字符串
if ( StrUtil . isNotBlank ( uploadedUrl ) & & ! uploadedUrl . equals ( coverBase64 ) & & ! uploadedUrl . contains ( " data:image " ) ) {
coverUrl = uploadedUrl ;
log . info ( " [saveFileRecord][视频封面上传成功, 封面URL({})] " , coverUrl ) ;
} else {
log . error ( " [saveFileRecord][视频封面上传返回无效URL, 跳过保存封面。返回URL: {} " , uploadedUrl ) ;
}
}
}
} catch ( IllegalArgumentException e ) {
log . warn ( " [saveFileRecord][视频封面 base64 解析失败,格式错误: {}] " , e . getMessage ( ) ) ;
} catch ( Exception e ) {
log . error ( " [saveFileRecord][视频封面上传失败,错误信息: {}] " , e . getMessage ( ) , e ) ;
// 封面处理失败不影响主流程,继续保存文件记录
}
}
// 8. 创建文件记录(保存完整路径)
// 8. 创建文件记录(保存完整路径, 包含封面URL和Base64)
TikUserFileDO userFile = new TikUserFileDO ( )
. setUserId ( userId )
. setFileId ( null ) // 显式设置为null, file_id是可选的, 用于关联infra_file表
@@ -140,20 +187,11 @@ public class TikUserFileServiceImpl implements TikUserFileService {
. setFileSize ( file . getSize ( ) )
. setFileUrl ( fileUrl )
. setFilePath ( filePath ) // 保存完整的OSS路径( 由FileService生成)
. setOssRootPath ( ossRootPath ) ;
. setCoverUrl ( coverUrl ) // 设置封面URL( 如果有)
. setCoverBase64 ( StrUtil . isNotBlank ( coverBase64 ) ? coverBase64 : null ) ; // 保存原始base64数据( 如果有)
userFileMapper . insert ( userFile ) ;
// 9. 异步生成预览图(视频封面或图片缩略图)
// TODO: 后续实现视频封面和图片缩略图生成
// if (StrUtil.containsIgnoreCase(file.getContentType(), "video")) {
// generateVideoCoverAsync(userFile.getId(), fileContent, file.getOriginalFilename(),
// file.getContentType(), baseDirectory);
// } else if (FileTypeUtils.isImage(file.getContentType())) {
// generateImageThumbnailAsync(userFile.getId(), fileContent, file.getOriginalFilename(),
// file.getContentType(), baseDirectory);
// }
// 10. 更新配额
// 9. 更新配额
quotaService . increaseUsedStorage ( userId , file . getSize ( ) ) ;
log . info ( " [saveFileRecord][用户({})保存文件记录成功,文件编号({})] " , userId , userFile . getId ( ) ) ;
@@ -229,15 +267,30 @@ public class TikUserFileServiceImpl implements TikUserFileService {
vo . setIsVideo ( isVideo ) ;
vo . setIsImage ( isImage ) ;
// 生成封面和缩略图的预签名URL( 避免重复调用)
String coverUrlPresigned = StrUtil . isNotBlank ( file . getCoverUrl ( ) )
? getCachedPresignUrl ( file . getCoverUrl ( ) , PRESIGN_URL_EXPIRATION_SECONDS )
: null ;
String thumbnailUrlPresigned = StrUtil . isNotBlank ( file . getThumbnailUrl ( ) )
? getCachedPresignUrl ( file . getThumbnailUrl ( ) , PRESIGN_URL_EXPIRATION_SECONDS )
: null ;
// 设置封面和缩略图URL
vo . setCoverUrl ( coverUrlPresigned ) ;
vo . setThumbnailUrl ( thumbnailUrlPresigned ) ;
// 生成预览URL( 优先使用封面/缩略图, 否则使用原文件URL)
String previewUrl = null ;
if ( isVideo & & StrUtil . isNotBlank ( file . getCoverUrl ( ) ) ) {
previewUrl = getCachedPresignUrl ( file . getCoverUrl ( ) , 3600 ) ;
} else if ( isImage & & StrUtil . isNotBlank ( file . getThumbnailUrl ( ) ) ) {
previewUrl = getCachedPresignUrl ( file . getThumbnailUrl ( ) , 3600 ) ;
if ( isVideo ) {
// 视频: 优先使用封面, 没有封面时使用原视频URL
previewUrl = coverUrlPresigned ! = null
? coverUrlPresigned
: getCachedPresignUrl ( file . getFileUrl ( ) , PRESIGN_URL_EXPIRATION_SECONDS ) ;
} else if ( isImage ) {
// 图片没有缩略图时, 使用原图
previewUrl = getCachedPresignUrl ( file . getFileUrl ( ) , 3600 ) ;
// 图片:优先使用缩略图, 没有缩略图时使用原图
previewUrl = thumbnailUrlPresigned ! = null
? thumbnailUrlPresigned
: getCachedPresignUrl ( file . getFileUrl ( ) , PRESIGN_URL_EXPIRATION_SECONDS ) ;
}
vo . setPreviewUrl ( previewUrl ) ;
@@ -318,7 +371,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
}
// 生成预签名URL( 1小时有效期)
return getCachedPresignUrl ( file . getFileUrl ( ) , 3600 ) ;
return getCachedPresignUrl ( file . getFileUrl ( ) , PRESIGN_URL_EXPIRATION_SECONDS ) ;
}
@Override
@@ -337,7 +390,7 @@ public class TikUserFileServiceImpl implements TikUserFileService {
}
// 生成预签名URL( 1小时有效期)
return getCachedPresignUrl ( file . getFileUrl ( ) , 3600 ) ;
return getCachedPresignUrl ( file . getFileUrl ( ) , PRESIGN_URL_EXPIRATION_SECONDS ) ;
}
@Override
@@ -351,26 +404,44 @@ public class TikUserFileServiceImpl implements TikUserFileService {
}
// 根据类型返回预览URL
String previewUrl = null ;
if ( StrUtil . equals ( type , " cover " ) & & StrUtil . isNotBlank ( file . getCoverUrl ( ) ) ) {
// 视频封面
previewUrl = file . getCoverUrl ( ) ;
} else if ( StrUtil . equals ( type , " thumbnail " ) & & StrUtil . isNotBlank ( file . getThumbnailUrl ( ) ) ) {
// 图片缩略图
previewUrl = file . getThumbnailUrl ( ) ;
} else if ( FileTypeUtils . isImage ( file . getFileType ( ) ) ) {
// 图片没有缩略图时,使用原图
previewUrl = file . getFileUrl ( ) ;
} else if ( StrUtil . containsIgnoreCase ( file . getFileType ( ) , " video " ) & & StrUtil . isNotBlank ( file . getCoverUrl ( ) ) ) {
// 视频使用封面
previewUrl = file . getCoverUrl ( ) ;
} else {
// 其他情况返回原文件URL
previewUrl = file . getFileUrl ( ) ;
}
String previewUrl = determinePreviewUrl ( file , type ) ;
// 生成预签名URL( 1小时有效期)
return getCachedPresignUrl ( previewUrl , 3600 ) ;
return getCachedPresignUrl ( previewUrl , PRESIGN_URL_EXPIRATION_SECONDS ) ;
}
/**
* 确定预览URL
*
* @param file 文件对象
* @param type 预览类型( cover/thumbnail, 可选)
* @return 预览URL
*/
private String determinePreviewUrl ( TikUserFileDO file , String type ) {
// 明确指定封面类型
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 isVideo = StrUtil . containsIgnoreCase ( file . getFileType ( ) , " video " ) ;
boolean isImage = FileTypeUtils . isImage ( file . getFileType ( ) ) ;
if ( isVideo & & StrUtil . isNotBlank ( file . getCoverUrl ( ) ) ) {
return file . getCoverUrl ( ) ;
}
if ( isImage ) {
return StrUtil . isNotBlank ( file . getThumbnailUrl ( ) )
? file . getThumbnailUrl ( )
: file . getFileUrl ( ) ;
}
// 默认返回原文件URL
return file . getFileUrl ( ) ;
}
@Override