功能优化

This commit is contained in:
2025-11-16 19:35:55 +08:00
parent c2bd94cfad
commit bdbe74cebb
53 changed files with 8235 additions and 107 deletions

View File

@@ -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, "分组不属于当前用户");
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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 NULLMyBatis 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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}

View File

@@ -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);
};
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}
}

View File

@@ -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);
}

View File

@@ -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. 上传文件到OSSFileService会自动处理文件名添加日期前缀和时间戳后缀
// 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) // 显式设置为nullfile_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, "文件不是视频类型");
}
// 生成预签名URL1小时有效期
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, "文件不是音频类型");
}
// 生成预签名URL1小时有效期
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();
}
// 生成预签名URL1小时有效期
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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
// 注意:这里不抛出异常,避免影响用户注册流程
// 可以考虑记录失败日志,后续通过定时任务补偿
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}