功能优化
This commit is contained in:
@@ -6,10 +6,23 @@ import cn.iocoder.yudao.framework.common.exception.ErrorCode;
|
||||
* AI 错误码枚举类
|
||||
* <p>
|
||||
* ai 系统,使用 1-040-000-000 段
|
||||
* tik 文件管理,使用 1-030-000-000 段
|
||||
*/
|
||||
public interface ErrorCodeConstants {
|
||||
|
||||
|
||||
ErrorCode USER_PROMPT_NOT_EXISTS = new ErrorCode(1_040_010_002, "用户提示词不存在");
|
||||
|
||||
// ========== 文件管理 1-030-000-000 ==========
|
||||
ErrorCode FILE_NOT_EXISTS = new ErrorCode(1_030_000_001, "文件不存在");
|
||||
ErrorCode FILE_CATEGORY_INVALID = new ErrorCode(1_030_000_002, "文件分类无效");
|
||||
ErrorCode QUOTA_NOT_ENOUGH = new ErrorCode(1_030_000_003, "存储配额不足");
|
||||
ErrorCode QUOTA_NOT_EXISTS = new ErrorCode(1_030_000_004, "配额记录不存在");
|
||||
ErrorCode OSS_INIT_FAILED = new ErrorCode(1_030_000_005, "OSS目录初始化失败");
|
||||
ErrorCode VIDEO_COVER_GENERATE_FAILED = new ErrorCode(1_030_000_006, "视频封面生成失败");
|
||||
ErrorCode IMAGE_THUMBNAIL_GENERATE_FAILED = new ErrorCode(1_030_000_007, "图片缩略图生成失败");
|
||||
ErrorCode FILE_GROUP_NOT_EXISTS = new ErrorCode(1_030_000_011, "分组不存在");
|
||||
ErrorCode FILE_GROUP_NAME_DUPLICATE = new ErrorCode(1_030_000_012, "分组名称重复");
|
||||
ErrorCode FILE_GROUP_NOT_BELONG_TO_USER = new ErrorCode(1_030_000_013, "分组不属于当前用户");
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.iocoder.yudao.module.tik.file.controller;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.tik.file.service.TikFileGroupService;
|
||||
import cn.iocoder.yudao.module.tik.file.service.TikUserFileGroupService;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupAddFilesReqVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupCreateReqVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupRespVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupUpdateReqVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 用户 App - 文件分组 Controller
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Tag(name = "用户 App - 文件分组")
|
||||
@RestController
|
||||
@RequestMapping("/api/tik/file/group")
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class AppTikFileGroupController {
|
||||
|
||||
@Resource
|
||||
private TikFileGroupService fileGroupService;
|
||||
|
||||
@Resource
|
||||
private TikUserFileGroupService userFileGroupService;
|
||||
|
||||
@PostMapping("/create")
|
||||
@Operation(summary = "创建分组")
|
||||
public CommonResult<Long> createGroup(@Valid @RequestBody AppTikFileGroupCreateReqVO createReqVO) {
|
||||
return success(fileGroupService.createGroup(createReqVO));
|
||||
}
|
||||
|
||||
@PutMapping("/update")
|
||||
@Operation(summary = "更新分组")
|
||||
public CommonResult<Boolean> updateGroup(@Valid @RequestBody AppTikFileGroupUpdateReqVO updateReqVO) {
|
||||
fileGroupService.updateGroup(updateReqVO);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/delete")
|
||||
@Operation(summary = "删除分组")
|
||||
@Parameter(name = "id", description = "分组编号", required = true, example = "1")
|
||||
public CommonResult<Boolean> deleteGroup(@RequestParam("id") Long groupId) {
|
||||
fileGroupService.deleteGroup(groupId);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "查询分组列表")
|
||||
public CommonResult<List<AppTikFileGroupRespVO>> getGroupList() {
|
||||
return success(fileGroupService.getGroupList());
|
||||
}
|
||||
|
||||
@PostMapping("/add-files")
|
||||
@Operation(summary = "将文件添加到分组")
|
||||
public CommonResult<Boolean> addFilesToGroups(@Valid @RequestBody AppTikFileGroupAddFilesReqVO reqVO) {
|
||||
userFileGroupService.addFilesToGroups(reqVO.getFileIds(), reqVO.getGroupIds());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@PostMapping("/remove-files")
|
||||
@Operation(summary = "从分组移除文件")
|
||||
public CommonResult<Boolean> removeFilesFromGroups(@Valid @RequestBody AppTikFileGroupAddFilesReqVO reqVO) {
|
||||
userFileGroupService.removeFilesFromGroups(reqVO.getFileIds(), reqVO.getGroupIds());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package cn.iocoder.yudao.module.tik.file.controller;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserOssInitRespVO;
|
||||
import cn.iocoder.yudao.module.tik.quota.service.TikUserQuotaService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 用户 App - 测试 Controller
|
||||
* 用于测试升级会员和创建OSS目录
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Tag(name = "用户 App - 测试接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/tik/test")
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class AppTikTestController {
|
||||
|
||||
@Resource
|
||||
private TikOssInitService ossInitService;
|
||||
|
||||
@Resource
|
||||
private TikUserQuotaService quotaService;
|
||||
|
||||
@PostMapping("/upgrade-vip")
|
||||
@Operation(summary = "测试:升级会员", description = "升级当前用户的VIP等级和配额")
|
||||
@Parameter(name = "vipLevel", description = "VIP等级", example = "1")
|
||||
@Parameter(name = "totalStorage", description = "总存储空间(字节)", example = "10737418240")
|
||||
@Parameter(name = "totalQuota", description = "总配额(积分/额度)", example = "10000")
|
||||
public CommonResult<Boolean> upgradeVip(
|
||||
@RequestParam(value = "vipLevel", defaultValue = "1") Integer vipLevel,
|
||||
@RequestParam(value = "totalStorage", required = false) Long totalStorage,
|
||||
@RequestParam(value = "totalQuota", required = false) Long totalQuota) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 默认值:10GB存储空间,10000配额
|
||||
if (totalStorage == null) {
|
||||
totalStorage = 10L * 1024 * 1024 * 1024; // 10GB
|
||||
}
|
||||
if (totalQuota == null) {
|
||||
totalQuota = 10000L;
|
||||
}
|
||||
|
||||
quotaService.updateVipLevel(userId, vipLevel, totalStorage, totalQuota);
|
||||
log.info("[upgradeVip][用户({})升级VIP成功,等级({}),存储({}),配额({})]",
|
||||
userId, vipLevel, totalStorage, totalQuota);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@PostMapping("/init-oss")
|
||||
@Operation(summary = "测试:初始化OSS目录", description = "为当前用户创建OSS目录结构")
|
||||
public CommonResult<AppTikUserOssInitRespVO> initOss() {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
AppTikUserOssInitRespVO result = ossInitService.initOssDirectory(userId);
|
||||
log.info("[initOss][用户({})OSS目录初始化成功]", userId);
|
||||
return success(result);
|
||||
}
|
||||
|
||||
@PostMapping("/test-all")
|
||||
@Operation(summary = "测试:一键执行", description = "同时执行升级会员和创建OSS目录")
|
||||
@Parameter(name = "vipLevel", description = "VIP等级", example = "1")
|
||||
@Parameter(name = "totalStorage", description = "总存储空间(字节)", example = "10737418240")
|
||||
@Parameter(name = "totalQuota", description = "总配额(积分/额度)", example = "10000")
|
||||
public CommonResult<AppTikUserOssInitRespVO> testAll(
|
||||
@RequestParam(value = "vipLevel", defaultValue = "1") Integer vipLevel,
|
||||
@RequestParam(value = "totalStorage", required = false) Long totalStorage,
|
||||
@RequestParam(value = "totalQuota", required = false) Long totalQuota) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 1. 升级会员
|
||||
if (totalStorage == null) {
|
||||
totalStorage = 10L * 1024 * 1024 * 1024; // 10GB
|
||||
}
|
||||
if (totalQuota == null) {
|
||||
totalQuota = 10000L;
|
||||
}
|
||||
quotaService.updateVipLevel(userId, vipLevel, totalStorage, totalQuota);
|
||||
log.info("[testAll][用户({})升级VIP成功,等级({}),存储({}),配额({})]",
|
||||
userId, vipLevel, totalStorage, totalQuota);
|
||||
|
||||
// 2. 初始化OSS目录
|
||||
AppTikUserOssInitRespVO ossResult = ossInitService.initOssDirectory(userId);
|
||||
log.info("[testAll][用户({})OSS目录初始化成功]", userId);
|
||||
|
||||
return success(ossResult);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package cn.iocoder.yudao.module.tik.file.controller;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.tik.file.service.TikUserFileService;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFilePageReqVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFileRespVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 用户 App - 文件管理 Controller
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Tag(name = "用户 App - 文件管理")
|
||||
@RestController
|
||||
@RequestMapping("/api/tik/file")
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class AppTikUserFileController {
|
||||
|
||||
@Resource
|
||||
private TikUserFileService userFileService;
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(summary = "上传文件")
|
||||
public CommonResult<Long> uploadFile(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "文件分类(video/generate/audio/mix/voice)", required = true)
|
||||
@RequestParam("fileCategory") String fileCategory) {
|
||||
return success(userFileService.uploadFile(file, fileCategory));
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "分页查询文件列表")
|
||||
public CommonResult<PageResult<AppTikUserFileRespVO>> getFilePage(@Valid AppTikUserFilePageReqVO pageReqVO) {
|
||||
return success(userFileService.getFilePage(pageReqVO));
|
||||
}
|
||||
|
||||
@DeleteMapping("/delete-batch")
|
||||
@Operation(summary = "批量删除文件")
|
||||
@Parameter(name = "ids", description = "文件编号列表,逗号分隔", required = true, example = "1,2,3")
|
||||
public CommonResult<Boolean> deleteFiles(@RequestParam("ids") String ids) {
|
||||
List<Long> fileIds = Arrays.stream(ids.split(","))
|
||||
.map(String::trim)
|
||||
.map(Long::valueOf)
|
||||
.collect(Collectors.toList());
|
||||
userFileService.deleteFiles(fileIds);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/video/play-url")
|
||||
@Operation(summary = "获取视频播放URL")
|
||||
@Parameter(name = "id", description = "文件编号", required = true, example = "1")
|
||||
public CommonResult<String> getVideoPlayUrl(@RequestParam("id") Long fileId) {
|
||||
return success(userFileService.getVideoPlayUrl(fileId));
|
||||
}
|
||||
|
||||
@GetMapping("/audio/play-url")
|
||||
@Operation(summary = "获取音频播放URL")
|
||||
@Parameter(name = "id", description = "文件编号", required = true, example = "1")
|
||||
public CommonResult<String> getAudioPlayUrl(@RequestParam("id") Long fileId) {
|
||||
return success(userFileService.getAudioPlayUrl(fileId));
|
||||
}
|
||||
|
||||
@GetMapping("/preview-url")
|
||||
@Operation(summary = "获取预览URL")
|
||||
@Parameter(name = "id", description = "文件编号", required = true, example = "1")
|
||||
@Parameter(name = "type", description = "预览类型(thumbnail/cover)", example = "thumbnail")
|
||||
public CommonResult<String> getPreviewUrl(
|
||||
@RequestParam("id") Long fileId,
|
||||
@RequestParam(value = "type", required = false, defaultValue = "thumbnail") String type) {
|
||||
return success(userFileService.getPreviewUrl(fileId, type));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package cn.iocoder.yudao.module.tik.file.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 文件分组 DO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@TableName("tik_file_group")
|
||||
@KeySequence("tik_file_group_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TikFileGroupDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 分组编号
|
||||
*/
|
||||
@TableId
|
||||
private Long id;
|
||||
/**
|
||||
* 用户编号
|
||||
*/
|
||||
private Long userId;
|
||||
/**
|
||||
* 分组名称
|
||||
*/
|
||||
private String name;
|
||||
/**
|
||||
* 分组描述
|
||||
*/
|
||||
private String description;
|
||||
/**
|
||||
* 排序
|
||||
*/
|
||||
private Integer sort;
|
||||
/**
|
||||
* 分组图标
|
||||
*/
|
||||
private String icon;
|
||||
/**
|
||||
* 父分组编号(0表示根分组,支持层级分组)
|
||||
*/
|
||||
private Long parentId;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.iocoder.yudao.module.tik.file.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 用户文件 DO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@TableName("tik_user_file")
|
||||
@KeySequence("tik_user_file_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TikUserFileDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 文件编号
|
||||
*/
|
||||
@TableId
|
||||
private Long id;
|
||||
/**
|
||||
* 用户编号
|
||||
*/
|
||||
private Long userId;
|
||||
/**
|
||||
* 文件编号(关联infra_file.id,可选)
|
||||
* 注意:数据库字段已设置为 DEFAULT NULL,MyBatis Plus 会自动处理 null 值
|
||||
*/
|
||||
private Long fileId;
|
||||
/**
|
||||
* 文件名称
|
||||
*/
|
||||
private String fileName;
|
||||
/**
|
||||
* 文件类型(video/image/document等)
|
||||
*/
|
||||
private String fileType;
|
||||
/**
|
||||
* 文件分类(video/generate/audio/mix/voice)
|
||||
*/
|
||||
private String fileCategory;
|
||||
/**
|
||||
* 文件大小(字节)
|
||||
*/
|
||||
private Long fileSize;
|
||||
/**
|
||||
* 文件访问URL
|
||||
*/
|
||||
private String fileUrl;
|
||||
/**
|
||||
* 文件存储路径
|
||||
*/
|
||||
private String filePath;
|
||||
/**
|
||||
* OSS根路径(用户手机MD5)
|
||||
*/
|
||||
private String ossRootPath;
|
||||
/**
|
||||
* 封面图URL(视频文件的封面图)
|
||||
*/
|
||||
private String coverUrl;
|
||||
/**
|
||||
* 缩略图URL(图片文件的缩略图)
|
||||
*/
|
||||
private String thumbnailUrl;
|
||||
/**
|
||||
* 默认分组编号(关联tik_file_group.id,可选)
|
||||
*/
|
||||
private Long groupId;
|
||||
/**
|
||||
* 文件描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.iocoder.yudao.module.tik.file.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 文件分组关联 DO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@TableName("tik_user_file_group")
|
||||
@KeySequence("tik_user_file_group_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TikUserFileGroupDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 关联编号
|
||||
*/
|
||||
@TableId
|
||||
private Long id;
|
||||
/**
|
||||
* 文件编号(关联tik_user_file.id)
|
||||
*/
|
||||
private Long fileId;
|
||||
/**
|
||||
* 分组编号(关联tik_file_group.id)
|
||||
*/
|
||||
private Long groupId;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package cn.iocoder.yudao.module.tik.file.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 用户OSS目录初始化记录 DO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@TableName("tik_user_oss_init")
|
||||
@KeySequence("tik_user_oss_init_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TikUserOssInitDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 编号
|
||||
*/
|
||||
@TableId
|
||||
private Long id;
|
||||
/**
|
||||
* 用户编号
|
||||
*/
|
||||
private Long userId;
|
||||
/**
|
||||
* 手机号MD5值
|
||||
*/
|
||||
private String mobileMd5;
|
||||
/**
|
||||
* OSS根路径
|
||||
*/
|
||||
private String ossRootPath;
|
||||
/**
|
||||
* 视频集路径
|
||||
*/
|
||||
private String videoPath;
|
||||
/**
|
||||
* 生成集路径
|
||||
*/
|
||||
private String generatePath;
|
||||
/**
|
||||
* 配音集路径
|
||||
*/
|
||||
private String audioPath;
|
||||
/**
|
||||
* 混剪集路径
|
||||
*/
|
||||
private String mixPath;
|
||||
/**
|
||||
* 声音集路径(用户克隆的声音,用于语音生成)
|
||||
*/
|
||||
private String voicePath;
|
||||
/**
|
||||
* 初始化状态(0-未初始化,1-已初始化)
|
||||
*/
|
||||
private Integer initStatus;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.iocoder.yudao.module.tik.file.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikFileGroupDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件分组 Mapper
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Mapper
|
||||
public interface TikFileGroupMapper extends BaseMapperX<TikFileGroupDO> {
|
||||
|
||||
default List<TikFileGroupDO> selectListByUserId(Long userId) {
|
||||
return selectList(new LambdaQueryWrapperX<TikFileGroupDO>()
|
||||
.eq(TikFileGroupDO::getUserId, userId)
|
||||
.orderByAsc(TikFileGroupDO::getSort)
|
||||
.orderByDesc(TikFileGroupDO::getId));
|
||||
}
|
||||
|
||||
default TikFileGroupDO selectByUserIdAndName(Long userId, String name) {
|
||||
return selectOne(new LambdaQueryWrapperX<TikFileGroupDO>()
|
||||
.eq(TikFileGroupDO::getUserId, userId)
|
||||
.eq(TikFileGroupDO::getName, name));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package cn.iocoder.yudao.module.tik.file.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileGroupDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件分组关联 Mapper
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Mapper
|
||||
public interface TikUserFileGroupMapper extends BaseMapperX<TikUserFileGroupDO> {
|
||||
|
||||
default List<TikUserFileGroupDO> selectListByFileId(Long fileId) {
|
||||
return selectList(TikUserFileGroupDO::getFileId, fileId);
|
||||
}
|
||||
|
||||
default List<TikUserFileGroupDO> selectListByFileIds(Collection<Long> fileIds) {
|
||||
return selectList(TikUserFileGroupDO::getFileId, fileIds);
|
||||
}
|
||||
|
||||
default List<TikUserFileGroupDO> selectListByGroupId(Long groupId) {
|
||||
return selectList(TikUserFileGroupDO::getGroupId, groupId);
|
||||
}
|
||||
|
||||
default void deleteByFileIdAndGroupIds(Long fileId, Collection<Long> groupIds) {
|
||||
delete(new LambdaQueryWrapperX<TikUserFileGroupDO>()
|
||||
.eq(TikUserFileGroupDO::getFileId, fileId)
|
||||
.inIfPresent(TikUserFileGroupDO::getGroupId, groupIds));
|
||||
}
|
||||
|
||||
default void deleteByFileIds(Collection<Long> fileIds) {
|
||||
delete(TikUserFileGroupDO::getFileId, fileIds);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.iocoder.yudao.module.tik.file.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFilePageReqVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 用户文件 Mapper
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Mapper
|
||||
public interface TikUserFileMapper extends BaseMapperX<TikUserFileDO> {
|
||||
|
||||
default PageResult<TikUserFileDO> selectPage(AppTikUserFilePageReqVO reqVO) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<TikUserFileDO>()
|
||||
.eqIfPresent(TikUserFileDO::getUserId, reqVO.getUserId())
|
||||
.eqIfPresent(TikUserFileDO::getFileCategory, reqVO.getFileCategory())
|
||||
.likeIfPresent(TikUserFileDO::getFileName, reqVO.getFileName())
|
||||
.eqIfPresent(TikUserFileDO::getFileType, reqVO.getFileType())
|
||||
.eqIfPresent(TikUserFileDO::getGroupId, reqVO.getGroupId())
|
||||
.betweenIfPresent(TikUserFileDO::getCreateTime, reqVO.getCreateTime())
|
||||
.orderByDesc(TikUserFileDO::getId));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.iocoder.yudao.module.tik.file.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserOssInitDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 用户OSS目录初始化记录 Mapper
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Mapper
|
||||
public interface TikUserOssInitMapper extends BaseMapperX<TikUserOssInitDO> {
|
||||
|
||||
default TikUserOssInitDO selectByUserId(Long userId) {
|
||||
return selectOne(TikUserOssInitDO::getUserId, userId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.iocoder.yudao.module.tik.file.enums;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Tik文件分类枚举
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum TikFileCategoryEnum implements ArrayValuable<Integer> {
|
||||
|
||||
VIDEO(1, "video", "视频集"),
|
||||
GENERATE(2, "generate", "生成集"),
|
||||
AUDIO(3, "audio", "配音集"),
|
||||
MIX(4, "mix", "混剪集"),
|
||||
VOICE(5, "voice", "声音集");
|
||||
|
||||
public static final Integer[] ARRAYS = Arrays.stream(values()).map(TikFileCategoryEnum::getCode).toArray(Integer[]::new);
|
||||
|
||||
/**
|
||||
* 分类编码
|
||||
*/
|
||||
private final Integer code;
|
||||
/**
|
||||
* 分类值(数据库存储值)
|
||||
*/
|
||||
private final String value;
|
||||
/**
|
||||
* 分类名称
|
||||
*/
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 根据 value 值获取枚举
|
||||
*
|
||||
* @param value 分类值(video/generate/audio/mix/voice)
|
||||
* @return 枚举值,如果不存在返回 null
|
||||
*/
|
||||
public static TikFileCategoryEnum valueOfValue(String value) {
|
||||
return Arrays.stream(values())
|
||||
.filter(item -> item.getValue().equals(value))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer[] array() {
|
||||
return ARRAYS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.iocoder.yudao.module.tik.file.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupCreateReqVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupRespVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupUpdateReqVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件分组 Service 接口
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface TikFileGroupService {
|
||||
|
||||
/**
|
||||
* 创建分组
|
||||
*
|
||||
* @param createReqVO 创建请求
|
||||
* @return 分组编号
|
||||
*/
|
||||
Long createGroup(AppTikFileGroupCreateReqVO createReqVO);
|
||||
|
||||
/**
|
||||
* 更新分组
|
||||
*
|
||||
* @param updateReqVO 更新请求
|
||||
*/
|
||||
void updateGroup(AppTikFileGroupUpdateReqVO updateReqVO);
|
||||
|
||||
/**
|
||||
* 删除分组
|
||||
*
|
||||
* @param groupId 分组编号
|
||||
*/
|
||||
void deleteGroup(Long groupId);
|
||||
|
||||
/**
|
||||
* 查询分组列表
|
||||
*
|
||||
* @return 分组列表
|
||||
*/
|
||||
List<AppTikFileGroupRespVO> getGroupList();
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package cn.iocoder.yudao.module.tik.file.service;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikFileGroupDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileGroupDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikFileGroupMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileGroupMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupCreateReqVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupRespVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupUpdateReqVO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* 文件分组 Service 实现类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class TikFileGroupServiceImpl implements TikFileGroupService {
|
||||
|
||||
@Resource
|
||||
private TikFileGroupMapper fileGroupMapper;
|
||||
|
||||
@Resource
|
||||
private TikUserFileGroupMapper userFileGroupMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createGroup(AppTikFileGroupCreateReqVO createReqVO) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
|
||||
// 校验分组名称是否重复
|
||||
validateGroupNameUnique(userId, null, createReqVO.getName());
|
||||
|
||||
// 创建分组
|
||||
TikFileGroupDO group = BeanUtils.toBean(createReqVO, TikFileGroupDO.class)
|
||||
.setUserId(userId);
|
||||
if (group.getSort() == null) {
|
||||
group.setSort(0);
|
||||
}
|
||||
if (group.getParentId() == null) {
|
||||
group.setParentId(0L);
|
||||
}
|
||||
fileGroupMapper.insert(group);
|
||||
|
||||
log.info("[createGroup][用户({})创建分组成功,分组编号({})]", userId, group.getId());
|
||||
return group.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateGroup(AppTikFileGroupUpdateReqVO updateReqVO) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 校验分组存在且属于当前用户
|
||||
TikFileGroupDO group = validateGroupExists(updateReqVO.getId(), userId);
|
||||
|
||||
// 校验分组名称是否重复
|
||||
if (updateReqVO.getName() != null && !updateReqVO.getName().equals(group.getName())) {
|
||||
validateGroupNameUnique(userId, updateReqVO.getId(), updateReqVO.getName());
|
||||
}
|
||||
|
||||
// 更新分组
|
||||
BeanUtils.copyProperties(updateReqVO, group);
|
||||
fileGroupMapper.updateById(group);
|
||||
|
||||
log.info("[updateGroup][用户({})更新分组成功,分组编号({})]", userId, updateReqVO.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteGroup(Long groupId) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 校验分组存在且属于当前用户
|
||||
validateGroupExists(groupId, userId);
|
||||
|
||||
// 删除分组关联关系
|
||||
userFileGroupMapper.delete(new LambdaQueryWrapperX<TikUserFileGroupDO>()
|
||||
.eq(TikUserFileGroupDO::getGroupId, groupId));
|
||||
|
||||
// 逻辑删除分组
|
||||
fileGroupMapper.deleteById(groupId);
|
||||
|
||||
log.info("[deleteGroup][用户({})删除分组成功,分组编号({})]", userId, groupId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppTikFileGroupRespVO> getGroupList() {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
|
||||
List<TikFileGroupDO> groups = fileGroupMapper.selectList(
|
||||
new LambdaQueryWrapperX<TikFileGroupDO>()
|
||||
.eq(TikFileGroupDO::getUserId, userId)
|
||||
.eq(TikFileGroupDO::getTenantId, tenantId)
|
||||
.eq(TikFileGroupDO::getDeleted, false)
|
||||
.orderByAsc(TikFileGroupDO::getSort)
|
||||
.orderByDesc(TikFileGroupDO::getCreateTime)
|
||||
);
|
||||
|
||||
// 转换为VO并查询文件数量
|
||||
return CollectionUtils.convertList(groups, group -> {
|
||||
AppTikFileGroupRespVO vo = BeanUtils.toBean(group, AppTikFileGroupRespVO.class);
|
||||
// 查询分组下的文件数量
|
||||
Long fileCount = userFileGroupMapper.selectCount(
|
||||
new LambdaQueryWrapperX<TikUserFileGroupDO>()
|
||||
.eq(TikUserFileGroupDO::getGroupId, group.getId())
|
||||
);
|
||||
vo.setFileCount(fileCount != null ? fileCount : 0L);
|
||||
return vo;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验分组存在且属于当前用户
|
||||
*/
|
||||
private TikFileGroupDO validateGroupExists(Long groupId, Long userId) {
|
||||
TikFileGroupDO group = fileGroupMapper.selectById(groupId);
|
||||
if (group == null) {
|
||||
throw exception(FILE_GROUP_NOT_EXISTS);
|
||||
}
|
||||
if (!group.getUserId().equals(userId)) {
|
||||
throw exception(FILE_GROUP_NOT_BELONG_TO_USER);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验分组名称是否重复
|
||||
*/
|
||||
private void validateGroupNameUnique(Long userId, Long excludeId, String name) {
|
||||
TikFileGroupDO existing = fileGroupMapper.selectByUserIdAndName(userId, name);
|
||||
if (existing != null && (excludeId == null || !existing.getId().equals(excludeId))) {
|
||||
throw exception(FILE_GROUP_NAME_DUPLICATE);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.iocoder.yudao.module.tik.file.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserOssInitRespVO;
|
||||
|
||||
/**
|
||||
* OSS初始化 Service 接口
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface TikOssInitService {
|
||||
|
||||
/**
|
||||
* 初始化用户OSS目录
|
||||
* 懒加载策略:首次上传时自动初始化
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @return OSS初始化信息
|
||||
*/
|
||||
AppTikUserOssInitRespVO initOssDirectory(Long userId);
|
||||
|
||||
/**
|
||||
* 确保OSS已初始化(如果未初始化则自动初始化)
|
||||
*
|
||||
* @param userId 用户编号
|
||||
*/
|
||||
void ensureOssInitialized(Long userId);
|
||||
|
||||
/**
|
||||
* 获取OSS根路径
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @return OSS根路径(格式:{手机号MD5}/{租户ID})
|
||||
*/
|
||||
String getOssRootPath(Long userId);
|
||||
|
||||
/**
|
||||
* 根据文件分类获取对应的OSS目录路径(基础目录,不包含日期和文件名)
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param fileCategory 文件分类(video/generate/audio/mix/voice)
|
||||
* @return OSS目录路径
|
||||
*/
|
||||
String getOssDirectoryByCategory(Long userId, String fileCategory);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package cn.iocoder.yudao.module.tik.file.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.member.api.user.MemberUserApi;
|
||||
import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserOssInitDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserOssInitMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserOssInitRespVO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.OSS_INIT_FAILED;
|
||||
|
||||
/**
|
||||
* OSS初始化 Service 实现类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class TikOssInitServiceImpl implements TikOssInitService {
|
||||
|
||||
@Resource
|
||||
private TikUserOssInitMapper ossInitMapper;
|
||||
|
||||
@Resource
|
||||
private MemberUserApi memberUserApi;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AppTikUserOssInitRespVO initOssDirectory(Long userId) {
|
||||
// 检查是否已初始化
|
||||
TikUserOssInitDO existing = ossInitMapper.selectByUserId(userId);
|
||||
if (existing != null && existing.getInitStatus() == 1) {
|
||||
log.info("[initOssDirectory][用户({})OSS已初始化,跳过]", userId);
|
||||
return BeanUtils.toBean(existing, AppTikUserOssInitRespVO.class);
|
||||
}
|
||||
|
||||
// 获取用户信息(获取手机号)
|
||||
MemberUserRespDTO user = memberUserApi.getUser(userId);
|
||||
if (user == null || StrUtil.isBlank(user.getMobile())) {
|
||||
throw exception(OSS_INIT_FAILED, "用户手机号不存在");
|
||||
}
|
||||
|
||||
// 计算手机号MD5和OSS路径
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
String mobileMd5 = DigestUtil.md5Hex(user.getMobile());
|
||||
OssPathInfo pathInfo = buildOssPaths(mobileMd5, tenantId);
|
||||
|
||||
// 创建或更新OSS初始化记录
|
||||
// 注意:OSS中目录是虚拟的,不需要显式创建,直接上传文件时包含路径即可自动创建
|
||||
TikUserOssInitDO ossInit;
|
||||
if (existing != null) {
|
||||
// 更新现有记录(补充缺失的字段或重新初始化)
|
||||
ossInit = existing;
|
||||
updateOssInitFields(ossInit, mobileMd5, pathInfo);
|
||||
ossInitMapper.updateById(ossInit);
|
||||
} else {
|
||||
// 创建新记录
|
||||
ossInit = createOssInitDO(userId, mobileMd5, pathInfo);
|
||||
ossInitMapper.insert(ossInit);
|
||||
}
|
||||
|
||||
log.info("[initOssDirectory][用户({})OSS初始化成功,根路径({})]", userId, pathInfo.ossRootPath);
|
||||
return BeanUtils.toBean(ossInit, AppTikUserOssInitRespVO.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS路径信息
|
||||
*/
|
||||
private static class OssPathInfo {
|
||||
final String ossRootPath;
|
||||
final String videoPath;
|
||||
final String generatePath;
|
||||
final String audioPath;
|
||||
final String mixPath;
|
||||
final String voicePath;
|
||||
|
||||
OssPathInfo(String ossRootPath, String videoPath, String generatePath,
|
||||
String audioPath, String mixPath, String voicePath) {
|
||||
this.ossRootPath = ossRootPath;
|
||||
this.videoPath = videoPath;
|
||||
this.generatePath = generatePath;
|
||||
this.audioPath = audioPath;
|
||||
this.mixPath = mixPath;
|
||||
this.voicePath = voicePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建OSS路径信息
|
||||
*/
|
||||
private OssPathInfo buildOssPaths(String mobileMd5, Long tenantId) {
|
||||
String ossRootPath = mobileMd5 + "/" + tenantId;
|
||||
return new OssPathInfo(
|
||||
ossRootPath,
|
||||
ossRootPath + "/video",
|
||||
ossRootPath + "/generate",
|
||||
ossRootPath + "/audio",
|
||||
ossRootPath + "/mix",
|
||||
ossRootPath + "/voice"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建OSS初始化DO对象
|
||||
*/
|
||||
private TikUserOssInitDO createOssInitDO(Long userId, String mobileMd5, OssPathInfo pathInfo) {
|
||||
return new TikUserOssInitDO()
|
||||
.setUserId(userId)
|
||||
.setMobileMd5(mobileMd5)
|
||||
.setOssRootPath(pathInfo.ossRootPath)
|
||||
.setVideoPath(pathInfo.videoPath)
|
||||
.setGeneratePath(pathInfo.generatePath)
|
||||
.setAudioPath(pathInfo.audioPath)
|
||||
.setMixPath(pathInfo.mixPath)
|
||||
.setVoicePath(pathInfo.voicePath)
|
||||
.setInitStatus(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新OSS初始化DO对象的字段
|
||||
*/
|
||||
private void updateOssInitFields(TikUserOssInitDO ossInit, String mobileMd5, OssPathInfo pathInfo) {
|
||||
ossInit.setMobileMd5(mobileMd5)
|
||||
.setOssRootPath(pathInfo.ossRootPath)
|
||||
.setVideoPath(pathInfo.videoPath)
|
||||
.setGeneratePath(pathInfo.generatePath)
|
||||
.setAudioPath(pathInfo.audioPath)
|
||||
.setMixPath(pathInfo.mixPath)
|
||||
.setVoicePath(pathInfo.voicePath)
|
||||
.setInitStatus(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ensureOssInitialized(Long userId) {
|
||||
TikUserOssInitDO ossInit = ossInitMapper.selectByUserId(userId);
|
||||
if (ossInit == null || ossInit.getInitStatus() == 0) {
|
||||
initOssDirectory(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOssRootPath(Long userId) {
|
||||
TikUserOssInitDO ossInit = ossInitMapper.selectByUserId(userId);
|
||||
if (ossInit == null || ossInit.getInitStatus() == 0) {
|
||||
throw exception(OSS_INIT_FAILED);
|
||||
}
|
||||
return ossInit.getOssRootPath();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOssDirectoryByCategory(Long userId, String fileCategory) {
|
||||
// 确保OSS已初始化
|
||||
ensureOssInitialized(userId);
|
||||
|
||||
// 获取OSS初始化记录
|
||||
TikUserOssInitDO ossInit = ossInitMapper.selectByUserId(userId);
|
||||
if (ossInit == null || ossInit.getInitStatus() == 0) {
|
||||
throw exception(OSS_INIT_FAILED);
|
||||
}
|
||||
|
||||
// 根据分类返回对应基础目录路径
|
||||
return switch (fileCategory) {
|
||||
case "video" -> ossInit.getVideoPath();
|
||||
case "generate" -> ossInit.getGeneratePath();
|
||||
case "audio" -> ossInit.getAudioPath();
|
||||
case "mix" -> ossInit.getMixPath();
|
||||
case "voice" -> ossInit.getVoicePath();
|
||||
default -> throw exception(OSS_INIT_FAILED, "不支持的文件分类:" + fileCategory);
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.iocoder.yudao.module.tik.file.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupRespVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件分组关联 Service 接口
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface TikUserFileGroupService {
|
||||
|
||||
/**
|
||||
* 将文件添加到分组(支持批量、多分组)
|
||||
*
|
||||
* @param fileIds 文件编号列表
|
||||
* @param groupIds 分组编号列表
|
||||
*/
|
||||
void addFilesToGroups(List<Long> fileIds, List<Long> groupIds);
|
||||
|
||||
/**
|
||||
* 从分组移除文件
|
||||
*
|
||||
* @param fileIds 文件编号列表
|
||||
* @param groupIds 分组编号列表
|
||||
*/
|
||||
void removeFilesFromGroups(List<Long> fileIds, List<Long> groupIds);
|
||||
|
||||
/**
|
||||
* 查询文件所属的分组列表
|
||||
*
|
||||
* @param fileId 文件编号
|
||||
* @return 分组列表
|
||||
*/
|
||||
List<AppTikFileGroupRespVO> getFileGroups(Long fileId);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package cn.iocoder.yudao.module.tik.file.service;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikFileGroupDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileGroupDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikFileGroupMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileGroupMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikFileGroupRespVO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* 文件分组关联 Service 实现类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class TikUserFileGroupServiceImpl implements TikUserFileGroupService {
|
||||
|
||||
@Resource
|
||||
private TikUserFileGroupMapper userFileGroupMapper;
|
||||
|
||||
@Resource
|
||||
private TikUserFileMapper userFileMapper;
|
||||
|
||||
@Resource
|
||||
private TikFileGroupMapper fileGroupMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void addFilesToGroups(List<Long> fileIds, List<Long> groupIds) {
|
||||
if (CollUtil.isEmpty(fileIds) || CollUtil.isEmpty(groupIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
|
||||
// 校验文件属于当前用户
|
||||
validateFilesBelongToUser(fileIds, userId);
|
||||
|
||||
// 校验分组属于当前用户
|
||||
validateGroupsBelongToUser(groupIds, userId);
|
||||
|
||||
// 批量创建关联关系(去重)
|
||||
for (Long fileId : fileIds) {
|
||||
for (Long groupId : groupIds) {
|
||||
// 检查是否已存在关联
|
||||
TikUserFileGroupDO exist = userFileGroupMapper.selectOne(
|
||||
new LambdaQueryWrapperX<TikUserFileGroupDO>()
|
||||
.eq(TikUserFileGroupDO::getFileId, fileId)
|
||||
.eq(TikUserFileGroupDO::getGroupId, groupId)
|
||||
);
|
||||
|
||||
if (exist == null) {
|
||||
// 创建关联关系
|
||||
TikUserFileGroupDO relation = new TikUserFileGroupDO()
|
||||
.setFileId(fileId)
|
||||
.setGroupId(groupId);
|
||||
userFileGroupMapper.insert(relation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[addFilesToGroups][用户({})添加文件到分组成功,文件数量({}),分组数量({})]",
|
||||
userId, fileIds.size(), groupIds.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void removeFilesFromGroups(List<Long> fileIds, List<Long> groupIds) {
|
||||
if (CollUtil.isEmpty(fileIds) || CollUtil.isEmpty(groupIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 校验文件属于当前用户
|
||||
validateFilesBelongToUser(fileIds, userId);
|
||||
|
||||
// 删除关联关系
|
||||
userFileGroupMapper.delete(
|
||||
new LambdaQueryWrapperX<TikUserFileGroupDO>()
|
||||
.in(TikUserFileGroupDO::getFileId, fileIds)
|
||||
.in(TikUserFileGroupDO::getGroupId, groupIds)
|
||||
);
|
||||
|
||||
log.info("[removeFilesFromGroups][用户({})从分组移除文件成功,文件数量({}),分组数量({})]",
|
||||
userId, fileIds.size(), groupIds.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppTikFileGroupRespVO> getFileGroups(Long fileId) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 校验文件属于当前用户
|
||||
TikUserFileDO file = userFileMapper.selectById(fileId);
|
||||
if (file == null || !file.getUserId().equals(userId)) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
}
|
||||
|
||||
List<TikUserFileGroupDO> relations = userFileGroupMapper.selectListByFileId(fileId);
|
||||
|
||||
if (CollUtil.isEmpty(relations)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<Long> groupIds = relations.stream()
|
||||
.map(TikUserFileGroupDO::getGroupId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<TikFileGroupDO> groups = fileGroupMapper.selectBatchIds(groupIds);
|
||||
return CollectionUtils.convertList(groups, group -> BeanUtils.toBean(group, AppTikFileGroupRespVO.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件属于当前用户
|
||||
*/
|
||||
private void validateFilesBelongToUser(List<Long> fileIds, Long userId) {
|
||||
List<TikUserFileDO> files = userFileMapper.selectBatchIds(fileIds);
|
||||
if (files.size() != fileIds.size()) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
}
|
||||
for (TikUserFileDO file : files) {
|
||||
if (!file.getUserId().equals(userId)) {
|
||||
throw exception(FILE_NOT_EXISTS, "文件不属于当前用户");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验分组属于当前用户
|
||||
*/
|
||||
private void validateGroupsBelongToUser(List<Long> groupIds, Long userId) {
|
||||
List<TikFileGroupDO> groups = fileGroupMapper.selectBatchIds(groupIds);
|
||||
if (groups.size() != groupIds.size()) {
|
||||
throw exception(FILE_GROUP_NOT_EXISTS);
|
||||
}
|
||||
for (TikFileGroupDO group : groups) {
|
||||
if (!group.getUserId().equals(userId)) {
|
||||
throw exception(FILE_GROUP_NOT_BELONG_TO_USER);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package cn.iocoder.yudao.module.tik.file.service;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFilePageReqVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFileRespVO;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户文件 Service 接口
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface TikUserFileService {
|
||||
|
||||
/**
|
||||
* 上传文件(带配额校验)
|
||||
*
|
||||
* @param file 文件
|
||||
* @param fileCategory 文件分类(video/generate/audio/mix/voice)
|
||||
* @return 文件编号
|
||||
*/
|
||||
Long uploadFile(MultipartFile file, String fileCategory);
|
||||
|
||||
/**
|
||||
* 分页查询文件列表
|
||||
*
|
||||
* @param pageReqVO 分页查询条件
|
||||
* @return 文件列表
|
||||
*/
|
||||
PageResult<AppTikUserFileRespVO> getFilePage(AppTikUserFilePageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 删除文件(批量)
|
||||
*
|
||||
* @param fileIds 文件编号列表
|
||||
*/
|
||||
void deleteFiles(List<Long> fileIds);
|
||||
|
||||
/**
|
||||
* 获取视频播放URL(预签名URL)
|
||||
*
|
||||
* @param fileId 文件编号
|
||||
* @return 预签名URL
|
||||
*/
|
||||
String getVideoPlayUrl(Long fileId);
|
||||
|
||||
/**
|
||||
* 获取音频播放URL(预签名URL)
|
||||
*
|
||||
* @param fileId 文件编号
|
||||
* @return 预签名URL
|
||||
*/
|
||||
String getAudioPlayUrl(Long fileId);
|
||||
|
||||
/**
|
||||
* 获取预览URL(视频返回封面,图片返回缩略图)
|
||||
*
|
||||
* @param fileId 文件编号
|
||||
* @param type 预览类型(thumbnail/cover,可选,默认thumbnail)
|
||||
* @return 预览URL(预签名URL)
|
||||
*/
|
||||
String getPreviewUrl(Long fileId, String type);
|
||||
|
||||
/**
|
||||
* 获取缓存的预签名URL(带Redis缓存)
|
||||
*
|
||||
* @param url 文件URL
|
||||
* @param expirationSeconds 过期时间(秒)
|
||||
* @return 预签名URL
|
||||
*/
|
||||
String getCachedPresignUrl(String url, Integer expirationSeconds);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
package cn.iocoder.yudao.module.tik.file.service;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.infra.api.file.FileApi;
|
||||
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
|
||||
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.enums.TikFileCategoryEnum;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFilePageReqVO;
|
||||
import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFileRespVO;
|
||||
import cn.iocoder.yudao.module.tik.quota.service.TikUserQuotaService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* 用户文件 Service 实现类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
|
||||
@Resource
|
||||
private TikUserFileMapper userFileMapper;
|
||||
|
||||
@Resource
|
||||
private TikOssInitService ossInitService;
|
||||
|
||||
@Resource
|
||||
private TikUserQuotaService quotaService;
|
||||
|
||||
@Resource
|
||||
private FileApi fileApi;
|
||||
|
||||
@Resource
|
||||
private FileMapper fileMapper;
|
||||
|
||||
@Override
|
||||
public Long uploadFile(MultipartFile file, String fileCategory) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
Long tenantId = TenantContextHolder.getTenantId();
|
||||
|
||||
// ========== 第一阶段:校验和准备(不在事务中) ==========
|
||||
// 1. 校验文件分类
|
||||
if (TikFileCategoryEnum.valueOfValue(fileCategory) == null) {
|
||||
throw exception(FILE_CATEGORY_INVALID);
|
||||
}
|
||||
|
||||
// 2. 校验配额(只校验,不更新)
|
||||
quotaService.validateStorage(userId, file.getSize());
|
||||
|
||||
// 3. 获取OSS基础目录路径(不包含日期和文件名)
|
||||
String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, fileCategory);
|
||||
|
||||
// 4. 读取文件内容(只读取一次,避免重复读取)
|
||||
byte[] fileContent;
|
||||
try {
|
||||
fileContent = IoUtil.readBytes(file.getInputStream());
|
||||
} catch (Exception e) {
|
||||
log.error("[uploadFile][文件读取失败]", e);
|
||||
throw exception(FILE_NOT_EXISTS, "文件读取失败");
|
||||
}
|
||||
|
||||
// ========== 第二阶段:上传到OSS(不在事务中,优先执行) ==========
|
||||
// 5. 上传文件到OSS(FileService会自动处理文件名,添加日期前缀和时间戳后缀)
|
||||
// FileService.createFile 会自动生成路径:{baseDirectory}/{yyyyMMdd}/{filename}_{timestamp}.ext
|
||||
// 注意:FileService 内部会使用原始文件名,并自动添加时间戳后缀保证唯一性
|
||||
String fileUrl;
|
||||
String filePath;
|
||||
Long infraFileId = null; // 用于失败时删除OSS文件
|
||||
try {
|
||||
fileUrl = fileApi.createFile(fileContent, file.getOriginalFilename(),
|
||||
baseDirectory, file.getContentType());
|
||||
|
||||
// 6. 从 infra_file 表查询实际的文件路径(确保路径100%准确)
|
||||
// 因为 FileService 已经保存了文件记录到 infra_file 表,我们可以通过 URL 查询获取准确的 path
|
||||
FileDO infraFile = getInfraFileByUrl(fileUrl, file.getSize());
|
||||
if (infraFile != null) {
|
||||
filePath = infraFile.getPath();
|
||||
infraFileId = infraFile.getId(); // 保存 infra_file.id,用于失败时删除
|
||||
} else {
|
||||
// 如果查询失败,从URL中提取路径(兜底方案)
|
||||
filePath = extractPathFromUrl(fileUrl);
|
||||
log.warn("[uploadFile][无法从infra_file表查询路径,使用URL提取,URL({})]", fileUrl);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[uploadFile][上传OSS失败]", e);
|
||||
throw exception(FILE_NOT_EXISTS, "上传OSS失败:" + e.getMessage());
|
||||
}
|
||||
|
||||
// ========== 第三阶段:保存数据库(在事务中,如果失败则删除OSS文件) ==========
|
||||
try {
|
||||
return saveFileRecord(userId, file, fileCategory, fileUrl, filePath);
|
||||
} catch (Exception e) {
|
||||
// 数据库保存失败,删除已上传的OSS文件
|
||||
log.error("[uploadFile][保存数据库失败,准备删除OSS文件,URL({})]", fileUrl, e);
|
||||
deleteOssFile(infraFileId, filePath, fileUrl);
|
||||
throw e; // 重新抛出异常
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文件记录到数据库(在事务中执行)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory,
|
||||
String fileUrl, String filePath) {
|
||||
// 7. 获取OSS根路径
|
||||
String ossRootPath = ossInitService.getOssRootPath(userId);
|
||||
|
||||
// 8. 创建文件记录(保存完整路径)
|
||||
TikUserFileDO userFile = new TikUserFileDO()
|
||||
.setUserId(userId)
|
||||
.setFileId(null) // 显式设置为null,file_id是可选的,用于关联infra_file表
|
||||
.setFileName(file.getOriginalFilename()) // 保存原始文件名,用于展示
|
||||
.setFileType(file.getContentType())
|
||||
.setFileCategory(fileCategory)
|
||||
.setFileSize(file.getSize())
|
||||
.setFileUrl(fileUrl)
|
||||
.setFilePath(filePath) // 保存完整的OSS路径(由FileService生成)
|
||||
.setOssRootPath(ossRootPath);
|
||||
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. 更新配额
|
||||
quotaService.increaseUsedStorage(userId, file.getSize());
|
||||
|
||||
log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({})]", userId, userFile.getId());
|
||||
return userFile.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 infra_file 表查询文件信息(返回完整对象,包含 id)
|
||||
*/
|
||||
private FileDO getInfraFileByUrl(String fileUrl, long fileSize) {
|
||||
if (StrUtil.isBlank(fileUrl)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// 移除URL中的查询参数(如果有)
|
||||
String cleanUrl = fileUrl;
|
||||
if (fileUrl.contains("?")) {
|
||||
cleanUrl = fileUrl.substring(0, fileUrl.indexOf("?"));
|
||||
}
|
||||
|
||||
// 通过 URL 和文件大小查询(提高准确性)
|
||||
return fileMapper.selectOne(
|
||||
new LambdaQueryWrapperX<FileDO>()
|
||||
.eq(FileDO::getUrl, cleanUrl)
|
||||
.eq(FileDO::getSize, (int) fileSize) // FileDO.size 是 Integer
|
||||
.orderByDesc(FileDO::getCreateTime)
|
||||
.last("LIMIT 1")
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.warn("[getInfraFileByUrl][查询infra_file表失败,URL({})]", fileUrl, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<AppTikUserFileRespVO> getFilePage(AppTikUserFilePageReqVO pageReqVO) {
|
||||
// 自动填充当前登录用户ID
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
pageReqVO.setUserId(userId);
|
||||
|
||||
// 查询文件列表
|
||||
PageResult<TikUserFileDO> 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);
|
||||
|
||||
// 生成预览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);
|
||||
} else if (isImage) {
|
||||
// 图片没有缩略图时,使用原图
|
||||
previewUrl = getCachedPresignUrl(file.getFileUrl(), 3600);
|
||||
}
|
||||
vo.setPreviewUrl(previewUrl);
|
||||
|
||||
return vo;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteFiles(List<Long> fileIds) {
|
||||
if (CollUtil.isEmpty(fileIds)) {
|
||||
return;
|
||||
}
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件列表
|
||||
List<TikUserFileDO> 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 逻辑删除文件
|
||||
userFileMapper.deleteBatchIds(fileIds);
|
||||
|
||||
// 释放配额
|
||||
long totalSize = files.stream().mapToLong(TikUserFileDO::getFileSize).sum();
|
||||
quotaService.decreaseUsedStorage(userId, totalSize);
|
||||
|
||||
log.info("[deleteFiles][用户({})删除文件成功,文件数量({})]", userId, fileIds.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getVideoPlayUrl(Long fileId) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件
|
||||
TikUserFileDO file = userFileMapper.selectById(fileId);
|
||||
if (file == null || !file.getUserId().equals(userId)) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
}
|
||||
|
||||
// 校验是否为视频文件
|
||||
if (!StrUtil.containsIgnoreCase(file.getFileType(), "video")) {
|
||||
throw exception(FILE_CATEGORY_INVALID, "文件不是视频类型");
|
||||
}
|
||||
|
||||
// 生成预签名URL(1小时有效期)
|
||||
return getCachedPresignUrl(file.getFileUrl(), 3600);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAudioPlayUrl(Long fileId) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件
|
||||
TikUserFileDO file = userFileMapper.selectById(fileId);
|
||||
if (file == null || !file.getUserId().equals(userId)) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
}
|
||||
|
||||
// 校验是否为音频文件
|
||||
if (!StrUtil.containsIgnoreCase(file.getFileType(), "audio")) {
|
||||
throw exception(FILE_CATEGORY_INVALID, "文件不是音频类型");
|
||||
}
|
||||
|
||||
// 生成预签名URL(1小时有效期)
|
||||
return getCachedPresignUrl(file.getFileUrl(), 3600);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPreviewUrl(Long fileId, String type) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件
|
||||
TikUserFileDO file = userFileMapper.selectById(fileId);
|
||||
if (file == null || !file.getUserId().equals(userId)) {
|
||||
throw exception(FILE_NOT_EXISTS);
|
||||
}
|
||||
|
||||
// 根据类型返回预览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();
|
||||
}
|
||||
|
||||
// 生成预签名URL(1小时有效期)
|
||||
return getCachedPresignUrl(previewUrl, 3600);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Cacheable(value = "tik:file:presign",
|
||||
key = "#url + ':' + (#expirationSeconds != null ? #expirationSeconds : 86400)")
|
||||
public String getCachedPresignUrl(String url, Integer expirationSeconds) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
return null;
|
||||
}
|
||||
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包含域名,提取路径部分
|
||||
if (url.contains("://")) {
|
||||
int pathStart = url.indexOf("/", url.indexOf("://") + 3);
|
||||
if (pathStart > 0) {
|
||||
return url.substring(pathStart);
|
||||
}
|
||||
}
|
||||
// 如果已经是路径格式,直接返回
|
||||
if (url.startsWith("/")) {
|
||||
return url;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[extractPathFromUrl][从URL提取路径失败,URL({})]", url, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.tik.file.vo.app;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户 App - 文件分组添加文件 Request VO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Schema(description = "用户 App - 文件分组添加文件 Request VO")
|
||||
@Data
|
||||
public class AppTikFileGroupAddFilesReqVO {
|
||||
|
||||
@Schema(description = "文件编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]")
|
||||
@NotEmpty(message = "文件编号列表不能为空")
|
||||
private List<Long> fileIds;
|
||||
|
||||
@Schema(description = "分组编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]")
|
||||
@NotEmpty(message = "分组编号列表不能为空")
|
||||
private List<Long> groupIds;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.iocoder.yudao.module.tik.file.vo.app;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户 App - 文件分组创建 Request VO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Schema(description = "用户 App - 文件分组创建 Request VO")
|
||||
@Data
|
||||
public class AppTikFileGroupCreateReqVO {
|
||||
|
||||
@Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "我的素材")
|
||||
@NotBlank(message = "分组名称不能为空")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "分组描述", example = "存放常用素材")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "排序", example = "0")
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "分组图标", example = "icon-folder")
|
||||
private String icon;
|
||||
|
||||
@Schema(description = "父分组编号(0表示根分组)", example = "0")
|
||||
private Long parentId;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package cn.iocoder.yudao.module.tik.file.vo.app;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户 App - 文件分组 Response VO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Schema(description = "用户 App - 文件分组 Response VO")
|
||||
@Data
|
||||
public class AppTikFileGroupRespVO {
|
||||
|
||||
@Schema(description = "分组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "分组名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "我的素材")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "分组描述", example = "存放常用素材")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "分组图标", example = "icon-folder")
|
||||
private String icon;
|
||||
|
||||
@Schema(description = "父分组编号(0表示根分组)", example = "0")
|
||||
private Long parentId;
|
||||
|
||||
@Schema(description = "文件数量", example = "10")
|
||||
private Long fileCount;
|
||||
|
||||
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.iocoder.yudao.module.tik.file.vo.app;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户 App - 文件分组更新 Request VO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Schema(description = "用户 App - 文件分组更新 Request VO")
|
||||
@Data
|
||||
public class AppTikFileGroupUpdateReqVO {
|
||||
|
||||
@Schema(description = "分组编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotNull(message = "分组编号不能为空")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "分组名称", example = "我的素材")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "分组描述", example = "存放常用素材")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "排序", example = "0")
|
||||
private Integer sort;
|
||||
|
||||
@Schema(description = "分组图标", example = "icon-folder")
|
||||
private String icon;
|
||||
|
||||
@Schema(description = "父分组编号(0表示根分组)", example = "0")
|
||||
private Long parentId;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package cn.iocoder.yudao.module.tik.file.vo.app;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
|
||||
|
||||
/**
|
||||
* 用户 App - 用户文件分页 Request VO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Schema(description = "用户 App - 用户文件分页 Request VO")
|
||||
@Data
|
||||
public class AppTikUserFilePageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "用户编号(自动填充,无需传递)")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "文件分类(video/generate/audio/mix/voice)", example = "video")
|
||||
private String fileCategory;
|
||||
|
||||
@Schema(description = "文件名称(模糊查询)", example = "test.mp4")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "文件类型(video/image/document等)", example = "video")
|
||||
private String fileType;
|
||||
|
||||
@Schema(description = "分组编号", example = "1")
|
||||
private Long groupId;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
|
||||
private LocalDateTime[] createTime;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package cn.iocoder.yudao.module.tik.file.vo.app;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 用户 App - 用户文件 Response VO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Schema(description = "用户 App - 用户文件 Response VO")
|
||||
@Data
|
||||
public class AppTikUserFileRespVO {
|
||||
|
||||
@Schema(description = "文件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "文件名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "test.mp4")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "文件类型(video/image/document等)", example = "video")
|
||||
private String fileType;
|
||||
|
||||
@Schema(description = "文件分类(video/generate/audio/mix/voice)", example = "video")
|
||||
private String fileCategory;
|
||||
|
||||
@Schema(description = "文件大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024000")
|
||||
private Long fileSize;
|
||||
|
||||
@Schema(description = "文件访问URL", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String fileUrl;
|
||||
|
||||
@Schema(description = "预览URL(视频返回封面,图片返回缩略图)")
|
||||
private String previewUrl;
|
||||
|
||||
@Schema(description = "是否为视频文件", example = "true")
|
||||
private Boolean isVideo;
|
||||
|
||||
@Schema(description = "是否为图片文件", example = "false")
|
||||
private Boolean isImage;
|
||||
|
||||
@Schema(description = "封面图URL(视频文件的封面图)")
|
||||
private String coverUrl;
|
||||
|
||||
@Schema(description = "缩略图URL(图片文件的缩略图)")
|
||||
private String thumbnailUrl;
|
||||
|
||||
@Schema(description = "默认分组编号", example = "1")
|
||||
private Long groupId;
|
||||
|
||||
@Schema(description = "文件描述")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.tik.file.vo.app;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 用户 App - 用户文件上传 Request VO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Schema(description = "用户 App - 用户文件上传 Request VO")
|
||||
@Data
|
||||
public class AppTikUserFileUploadReqVO {
|
||||
|
||||
@Schema(description = "文件(MultipartFile)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private MultipartFile file;
|
||||
|
||||
@Schema(description = "文件分类(video/generate/audio/mix/voice)", requiredMode = Schema.RequiredMode.REQUIRED, example = "video")
|
||||
@NotBlank(message = "文件分类不能为空")
|
||||
private String fileCategory;
|
||||
|
||||
@Schema(description = "文件描述", example = "测试视频")
|
||||
private String description;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.iocoder.yudao.module.tik.file.vo.app;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户 App - OSS初始化 Response VO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Schema(description = "用户 App - OSS初始化 Response VO")
|
||||
@Data
|
||||
public class AppTikUserOssInitRespVO {
|
||||
|
||||
@Schema(description = "OSS根路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456/1")
|
||||
private String ossRootPath;
|
||||
|
||||
@Schema(description = "视频集路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456/1/video/")
|
||||
private String videoPath;
|
||||
|
||||
@Schema(description = "生成集路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456/1/generate/")
|
||||
private String generatePath;
|
||||
|
||||
@Schema(description = "配音集路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456/1/audio/")
|
||||
private String audioPath;
|
||||
|
||||
@Schema(description = "混剪集路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456/1/mix/")
|
||||
private String mixPath;
|
||||
|
||||
@Schema(description = "声音集路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456/1/voice/")
|
||||
private String voicePath;
|
||||
|
||||
@Schema(description = "初始化状态(0-未初始化,1-已初始化)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Integer initStatus;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.iocoder.yudao.module.tik.member.mq.consumer;
|
||||
|
||||
import cn.iocoder.yudao.module.member.api.message.user.MemberUserCreateMessage;
|
||||
import cn.iocoder.yudao.module.tik.quota.service.TikUserQuotaService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
/**
|
||||
* 会员用户创建事件监听器
|
||||
*
|
||||
* 功能:用户注册后自动初始化配额
|
||||
* 触发时机:用户注册时(MemberUserServiceImpl.createUser())
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class MemberUserCreateConsumer {
|
||||
|
||||
@Resource
|
||||
private TikUserQuotaService quotaService;
|
||||
|
||||
/**
|
||||
* 监听用户创建事件(注册时触发)
|
||||
*
|
||||
* @param message 用户创建消息
|
||||
*/
|
||||
@EventListener
|
||||
@Async // 异步处理,避免阻塞主事务
|
||||
public void onMessage(MemberUserCreateMessage message) {
|
||||
log.info("[onMessage][用户注册事件,用户编号({})]", message.getUserId());
|
||||
|
||||
try {
|
||||
Long userId = message.getUserId();
|
||||
|
||||
// 初始化用户配额(默认VIP等级0)
|
||||
// 注意:OSS目录采用懒加载策略,在首次上传时再初始化
|
||||
quotaService.initQuota(userId, 0);
|
||||
log.info("[onMessage][用户({})配额初始化成功]", userId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[onMessage][用户({})配额初始化失败]", message.getUserId(), e);
|
||||
// 注意:这里不抛出异常,避免影响用户注册流程
|
||||
// 可以考虑记录失败日志,后续通过定时任务补偿
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package cn.iocoder.yudao.module.tik.quota.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 用户配额 DO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@TableName("tik_user_quota")
|
||||
@KeySequence("tik_user_quota_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TikUserQuotaDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 配额编号
|
||||
*/
|
||||
@TableId
|
||||
private Long id;
|
||||
/**
|
||||
* 用户编号
|
||||
*/
|
||||
private Long userId;
|
||||
/**
|
||||
* 总存储空间(字节)
|
||||
*/
|
||||
private Long totalStorage;
|
||||
/**
|
||||
* 已使用存储空间(字节)
|
||||
*/
|
||||
private Long usedStorage;
|
||||
/**
|
||||
* 总配额(积分/额度)
|
||||
*/
|
||||
private Long totalQuota;
|
||||
/**
|
||||
* 已使用配额(积分/额度)
|
||||
*/
|
||||
private Long usedQuota;
|
||||
/**
|
||||
* VIP等级
|
||||
*/
|
||||
private Integer vipLevel;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.iocoder.yudao.module.tik.quota.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.module.tik.quota.dal.dataobject.TikUserQuotaDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 用户配额 Mapper
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Mapper
|
||||
public interface TikUserQuotaMapper extends BaseMapperX<TikUserQuotaDO> {
|
||||
|
||||
default TikUserQuotaDO selectByUserId(Long userId) {
|
||||
return selectOne(TikUserQuotaDO::getUserId, userId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package cn.iocoder.yudao.module.tik.quota.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.quota.dal.dataobject.TikUserQuotaDO;
|
||||
|
||||
/**
|
||||
* 用户配额 Service 接口
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface TikUserQuotaService {
|
||||
|
||||
/**
|
||||
* 初始化用户配额
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param vipLevel VIP等级
|
||||
* @return 配额编号
|
||||
*/
|
||||
Long initQuota(Long userId, Integer vipLevel);
|
||||
|
||||
/**
|
||||
* 获取用户配额
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @return 配额信息
|
||||
*/
|
||||
TikUserQuotaDO getQuota(Long userId);
|
||||
|
||||
/**
|
||||
* 获取或创建用户配额(如果不存在则创建)
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @return 配额信息
|
||||
*/
|
||||
TikUserQuotaDO getOrCreateQuota(Long userId);
|
||||
|
||||
/**
|
||||
* 校验存储空间是否足够
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param fileSize 文件大小(字节)
|
||||
*/
|
||||
void validateStorage(Long userId, Long fileSize);
|
||||
|
||||
/**
|
||||
* 增加已使用存储空间
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param fileSize 文件大小(字节)
|
||||
*/
|
||||
void increaseUsedStorage(Long userId, Long fileSize);
|
||||
|
||||
/**
|
||||
* 减少已使用存储空间
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param fileSize 文件大小(字节)
|
||||
*/
|
||||
void decreaseUsedStorage(Long userId, Long fileSize);
|
||||
|
||||
/**
|
||||
* 更新VIP等级和配额
|
||||
*
|
||||
* @param userId 用户编号
|
||||
* @param vipLevel VIP等级
|
||||
* @param totalStorage 总存储空间(字节)
|
||||
* @param totalQuota 总配额(积分/额度)
|
||||
*/
|
||||
void updateVipLevel(Long userId, Integer vipLevel, Long totalStorage, Long totalQuota);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package cn.iocoder.yudao.module.tik.quota.service;
|
||||
|
||||
import cn.iocoder.yudao.module.tik.quota.dal.dataobject.TikUserQuotaDO;
|
||||
import cn.iocoder.yudao.module.tik.quota.dal.mysql.TikUserQuotaMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.QUOTA_NOT_ENOUGH;
|
||||
import static cn.iocoder.yudao.module.tik.enmus.ErrorCodeConstants.QUOTA_NOT_EXISTS;
|
||||
|
||||
/**
|
||||
* 用户配额 Service 实现类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Service
|
||||
@Validated
|
||||
@Slf4j
|
||||
public class TikUserQuotaServiceImpl implements TikUserQuotaService {
|
||||
|
||||
@Resource
|
||||
private TikUserQuotaMapper quotaMapper;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long initQuota(Long userId, Integer vipLevel) {
|
||||
// 检查是否已存在
|
||||
TikUserQuotaDO existing = quotaMapper.selectByUserId(userId);
|
||||
if (existing != null) {
|
||||
log.info("[initQuota][用户({})配额已存在,跳过初始化]", userId);
|
||||
return existing.getId();
|
||||
}
|
||||
|
||||
// 创建配额记录(默认值)
|
||||
TikUserQuotaDO quota = TikUserQuotaDO.builder()
|
||||
.userId(userId)
|
||||
.vipLevel(vipLevel != null ? vipLevel : 0)
|
||||
.totalStorage(0L)
|
||||
.usedStorage(0L)
|
||||
.totalQuota(0L)
|
||||
.usedQuota(0L)
|
||||
.build();
|
||||
quotaMapper.insert(quota);
|
||||
log.info("[initQuota][用户({})配额初始化成功,配额编号({})]", userId, quota.getId());
|
||||
return quota.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TikUserQuotaDO getQuota(Long userId) {
|
||||
return quotaMapper.selectByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TikUserQuotaDO getOrCreateQuota(Long userId) {
|
||||
TikUserQuotaDO quota = getQuota(userId);
|
||||
if (quota == null) {
|
||||
// 如果不存在,创建默认配额
|
||||
Long quotaId = initQuota(userId, 0);
|
||||
quota = quotaMapper.selectById(quotaId);
|
||||
}
|
||||
return quota;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateStorage(Long userId, Long fileSize) {
|
||||
TikUserQuotaDO quota = getOrCreateQuota(userId);
|
||||
if (quota == null) {
|
||||
throw exception(QUOTA_NOT_EXISTS);
|
||||
}
|
||||
|
||||
long remainingStorage = quota.getTotalStorage() - quota.getUsedStorage();
|
||||
if (remainingStorage < fileSize) {
|
||||
throw exception(QUOTA_NOT_ENOUGH);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void increaseUsedStorage(Long userId, Long fileSize) {
|
||||
TikUserQuotaDO quota = getOrCreateQuota(userId);
|
||||
quota.setUsedStorage(quota.getUsedStorage() + fileSize);
|
||||
quotaMapper.updateById(quota);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void decreaseUsedStorage(Long userId, Long fileSize) {
|
||||
TikUserQuotaDO quota = getQuota(userId);
|
||||
if (quota == null) {
|
||||
log.warn("[decreaseUsedStorage][用户({})配额不存在,跳过]", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
long newUsedStorage = Math.max(0, quota.getUsedStorage() - fileSize);
|
||||
quota.setUsedStorage(newUsedStorage);
|
||||
quotaMapper.updateById(quota);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateVipLevel(Long userId, Integer vipLevel, Long totalStorage, Long totalQuota) {
|
||||
TikUserQuotaDO quota = getOrCreateQuota(userId);
|
||||
quota.setVipLevel(vipLevel);
|
||||
if (totalStorage != null) {
|
||||
quota.setTotalStorage(totalStorage);
|
||||
}
|
||||
if (totalQuota != null) {
|
||||
quota.setTotalQuota(totalQuota);
|
||||
}
|
||||
quotaMapper.updateById(quota);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package cn.iocoder.yudao.module.tik.quota.vo.app;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户 App - 用户配额 Response VO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Schema(description = "用户 App - 用户配额 Response VO")
|
||||
@Data
|
||||
public class AppTikUserQuotaRespVO {
|
||||
|
||||
@Schema(description = "配额编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "总存储空间(字节)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1073741824")
|
||||
private Long totalStorage;
|
||||
|
||||
@Schema(description = "已使用存储空间(字节)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024000")
|
||||
private Long usedStorage;
|
||||
|
||||
@Schema(description = "剩余存储空间(字节)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1072717824")
|
||||
private Long remainingStorage;
|
||||
|
||||
@Schema(description = "总配额(积分/额度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1000")
|
||||
private Long totalQuota;
|
||||
|
||||
@Schema(description = "已使用配额(积分/额度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "100")
|
||||
private Long usedQuota;
|
||||
|
||||
@Schema(description = "剩余配额(积分/额度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "900")
|
||||
private Long remainingQuota;
|
||||
|
||||
@Schema(description = "VIP等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
|
||||
private Integer vipLevel;
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user