()
+ .eq(FileDO::getUrl, fileUrl)
+ .orderByDesc(FileDO::getCreateTime)
+ .last("LIMIT 1")
+);
+String filePath = infraFile != null ? infraFile.getPath() : null;
+```
+
+### 方案2:从 URL 中提取 path
+
+**优点**:
+- 不需要查询数据库
+- 性能好
+
+**缺点**:
+- URL 可能包含域名、查询参数
+- 提取逻辑复杂,可能不准确
+
+### 方案3:修改 FileApi 返回 path(不推荐)
+
+**缺点**:
+- 需要修改框架代码
+- 影响其他模块
+
+## 🎯 推荐实现
+
+**使用方案1**:从 infra_file 表查询 path,确保路径100%准确。
+
diff --git a/yudao-module-tik/LOGIC_REVIEW.md b/yudao-module-tik/LOGIC_REVIEW.md
new file mode 100644
index 0000000000..f9541c975f
--- /dev/null
+++ b/yudao-module-tik/LOGIC_REVIEW.md
@@ -0,0 +1,111 @@
+# 文件上传逻辑检查报告
+
+## ✅ 已修复的问题
+
+### 1. 路径不一致问题(已修复)
+
+**问题**:
+- `FileService.createFile()` 和 `generateFullFilePath()` 使用不同的时间戳
+- 导致 `filePath` 和实际 OSS 路径不匹配
+
+**修复方案**:
+- 从 `infra_file` 表查询实际路径(通过 URL + 文件大小)
+- 确保路径100%准确
+- 兜底方案:从 URL 提取路径
+
+**代码位置**:
+```java
+// 从 infra_file 表查询实际的文件路径(确保路径100%准确)
+String filePath = getFilePathFromInfraFile(fileUrl, file.getSize());
+if (StrUtil.isBlank(filePath)) {
+ // 如果查询失败,从URL中提取路径(兜底方案)
+ filePath = extractPathFromUrl(fileUrl);
+}
+```
+
+### 2. 冗余代码清理
+
+**已删除**:
+- `generateFullFilePath()` 方法(已删除,不再需要手动生成路径)
+
+**保留**:
+- `extractPathFromUrl()` 方法(作为兜底方案,在删除文件时也会用到)
+
+## 📊 当前逻辑流程
+
+```
+1. 校验文件分类
+ ↓
+2. 校验配额
+ ↓
+3. 获取OSS基础目录
+ ↓
+4. 读取文件内容
+ ↓
+5. 上传到OSS(FileService.createFile)
+ - FileService 自动生成路径并保存到 infra_file 表
+ - 返回 fileUrl
+ ↓
+6. 从 infra_file 表查询实际路径(✅ 确保准确)
+ - 通过 URL + 文件大小精确匹配
+ - 兜底:从 URL 提取路径
+ ↓
+7. 获取OSS根路径
+ ↓
+8. 保存文件记录到 tik_user_file 表
+ - file_path: 从 infra_file 表查询的准确路径
+ - file_url: FileService 返回的 URL
+ ↓
+9. 更新配额
+```
+
+## ✅ 逻辑可行性检查
+
+### 1. 路径准确性 ✅
+- **方案**:从 `infra_file` 表查询
+- **准确性**:100%(直接使用 FileService 保存的路径)
+- **性能**:一次数据库查询,可接受
+
+### 2. 兜底方案 ✅
+- **方案**:从 URL 提取路径
+- **适用场景**:查询失败时使用
+- **准确性**:中等(URL 可能包含域名和查询参数)
+
+### 3. 文件删除 ✅
+- **当前**:使用 `file_path` 字段
+- **准确性**:高(路径来自 infra_file 表)
+- **TODO**:实现物理删除 OSS 文件
+
+## 🎯 优化建议
+
+### 1. 关联 file_id(可选)
+
+如果后续需要关联 `infra_file` 表,可以在查询时保存 `file_id`:
+
+```java
+FileDO infraFile = fileMapper.selectOne(...);
+if (infraFile != null) {
+ userFile.setFileId(infraFile.getId()); // 关联 infra_file 表
+ filePath = infraFile.getPath();
+}
+```
+
+### 2. 性能优化(可选)
+
+如果担心查询性能,可以:
+- 添加缓存(URL → path 的映射)
+- 或者:直接使用 URL 提取路径(但准确性降低)
+
+## 📝 总结
+
+**当前逻辑**:
+- ✅ 路径准确性:100%(从 infra_file 表查询)
+- ✅ 代码简洁:删除了冗余的路径生成逻辑
+- ✅ 兜底方案:URL 提取路径
+- ✅ 可行性:完全可行
+
+**建议**:
+- 当前实现已经是最优方案
+- 路径准确性有保障
+- 代码逻辑清晰,无冗余
+
diff --git a/yudao-module-tik/UPLOAD_STRATEGY.md b/yudao-module-tik/UPLOAD_STRATEGY.md
new file mode 100644
index 0000000000..5200358283
--- /dev/null
+++ b/yudao-module-tik/UPLOAD_STRATEGY.md
@@ -0,0 +1,76 @@
+# 文件上传策略分析
+
+## 🎯 业界成熟方案:先上传OSS,再存数据库
+
+### 方案对比
+
+| 方案 | 优点 | 缺点 | 适用场景 |
+|------|------|------|----------|
+| **先上传OSS,再存数据库** ✅ | 1. OSS上传失败不影响数据库
2. 数据库事务可快速回滚
3. 用户体验好(文件已上传)
4. 孤立文件可定时清理 | 1. 数据库失败会产生孤立文件
2. 需要清理机制 | **推荐方案**(业界主流) |
+| 先存数据库,再上传OSS | 1. 数据库失败不会上传OSS
2. 不会产生孤立文件 | 1. OSS上传失败需要回滚数据库
2. 数据库事务时间长
3. 用户体验差 | 不推荐 |
+
+### 为什么选择"先上传OSS,再存数据库"?
+
+1. **性能优势**
+ - OSS上传是外部服务调用,不应该阻塞数据库事务
+ - 数据库事务时间短,减少锁竞争
+
+2. **可靠性优势**
+ - OSS上传失败,直接返回错误,不产生脏数据
+ - 数据库保存失败,OSS文件可以后续清理(定时任务)
+
+3. **用户体验优势**
+ - 文件已上传成功,即使数据库失败,文件还在
+ - 可以重试数据库保存,无需重新上传
+
+4. **业界实践**
+ - 阿里云、腾讯云、AWS 等主流云服务都推荐此方案
+ - 大多数开源项目采用此方案
+
+### 当前实现方案
+
+```
+1. 校验(文件分类、配额)
+ ↓
+2. 读取文件内容
+ ↓
+3. 上传到OSS(FileService.createFile)
+ - 成功:返回 fileUrl 和 filePath
+ - 失败:直接抛出异常,不保存数据库
+ ↓
+4. 保存数据库(事务中)
+ - 成功:返回文件ID
+ - 失败:删除OSS文件,抛出异常
+ ↓
+5. 更新配额
+```
+
+### 异常处理
+
+1. **OSS上传失败**
+ - 直接抛出异常,不保存数据库
+ - 用户可重试上传
+
+2. **数据库保存失败**
+ - 删除已上传的OSS文件(清理)
+ - 抛出异常,用户可重试
+
+3. **孤立文件清理**
+ - 定时任务清理未关联数据库的OSS文件
+ - 基于 infra_file 表的创建时间判断
+
+### 优化建议
+
+1. **异步清理孤立文件**
+ - 定时任务扫描 infra_file 表
+ - 删除超过7天未关联 tik_user_file 的文件
+
+2. **重试机制**
+ - 数据库保存失败时,记录重试队列
+ - 后台任务重试保存
+
+3. **监控告警**
+ - 监控OSS上传失败率
+ - 监控数据库保存失败率
+ - 监控孤立文件数量
+
diff --git a/yudao-module-tik/pom.xml b/yudao-module-tik/pom.xml
index 62b2437fd7..6371a7cfd1 100644
--- a/yudao-module-tik/pom.xml
+++ b/yudao-module-tik/pom.xml
@@ -309,6 +309,12 @@
2025.09-SNAPSHOT
compile
+
+ cn.iocoder.boot
+ yudao-module-member
+ 2025.09-SNAPSHOT
+ compile
+
\ No newline at end of file
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java
index 43f9dac8fc..c4c146b880 100644
--- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java
@@ -6,10 +6,23 @@ import cn.iocoder.yudao.framework.common.exception.ErrorCode;
* AI 错误码枚举类
*
* 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, "分组不属于当前用户");
+
}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikFileGroupController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikFileGroupController.java
new file mode 100644
index 0000000000..51167eb0a0
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikFileGroupController.java
@@ -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 createGroup(@Valid @RequestBody AppTikFileGroupCreateReqVO createReqVO) {
+ return success(fileGroupService.createGroup(createReqVO));
+ }
+
+ @PutMapping("/update")
+ @Operation(summary = "更新分组")
+ public CommonResult 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 deleteGroup(@RequestParam("id") Long groupId) {
+ fileGroupService.deleteGroup(groupId);
+ return success(true);
+ }
+
+ @GetMapping("/list")
+ @Operation(summary = "查询分组列表")
+ public CommonResult> getGroupList() {
+ return success(fileGroupService.getGroupList());
+ }
+
+ @PostMapping("/add-files")
+ @Operation(summary = "将文件添加到分组")
+ public CommonResult addFilesToGroups(@Valid @RequestBody AppTikFileGroupAddFilesReqVO reqVO) {
+ userFileGroupService.addFilesToGroups(reqVO.getFileIds(), reqVO.getGroupIds());
+ return success(true);
+ }
+
+ @PostMapping("/remove-files")
+ @Operation(summary = "从分组移除文件")
+ public CommonResult removeFilesFromGroups(@Valid @RequestBody AppTikFileGroupAddFilesReqVO reqVO) {
+ userFileGroupService.removeFilesFromGroups(reqVO.getFileIds(), reqVO.getGroupIds());
+ return success(true);
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikTestController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikTestController.java
new file mode 100644
index 0000000000..bfb276dcb1
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikTestController.java
@@ -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 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 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 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);
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java
new file mode 100644
index 0000000000..cca58d49f9
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikUserFileController.java
@@ -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 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> 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 deleteFiles(@RequestParam("ids") String ids) {
+ List 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 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 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 getPreviewUrl(
+ @RequestParam("id") Long fileId,
+ @RequestParam(value = "type", required = false, defaultValue = "thumbnail") String type) {
+ return success(userFileService.getPreviewUrl(fileId, type));
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikFileGroupDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikFileGroupDO.java
new file mode 100644
index 0000000000..4ff4061c43
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikFileGroupDO.java
@@ -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;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java
new file mode 100644
index 0000000000..6aef3d06a7
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileDO.java
@@ -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;
+
+}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileGroupDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileGroupDO.java
new file mode 100644
index 0000000000..684dba54da
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserFileGroupDO.java
@@ -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;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserOssInitDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserOssInitDO.java
new file mode 100644
index 0000000000..812dedbb42
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/dataobject/TikUserOssInitDO.java
@@ -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;
+
+}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikFileGroupMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikFileGroupMapper.java
new file mode 100644
index 0000000000..74994ccfdf
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikFileGroupMapper.java
@@ -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 {
+
+ default List selectListByUserId(Long userId) {
+ return selectList(new LambdaQueryWrapperX()
+ .eq(TikFileGroupDO::getUserId, userId)
+ .orderByAsc(TikFileGroupDO::getSort)
+ .orderByDesc(TikFileGroupDO::getId));
+ }
+
+ default TikFileGroupDO selectByUserIdAndName(Long userId, String name) {
+ return selectOne(new LambdaQueryWrapperX()
+ .eq(TikFileGroupDO::getUserId, userId)
+ .eq(TikFileGroupDO::getName, name));
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserFileGroupMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserFileGroupMapper.java
new file mode 100644
index 0000000000..e3f4072b99
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserFileGroupMapper.java
@@ -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 {
+
+ default List selectListByFileId(Long fileId) {
+ return selectList(TikUserFileGroupDO::getFileId, fileId);
+ }
+
+ default List selectListByFileIds(Collection fileIds) {
+ return selectList(TikUserFileGroupDO::getFileId, fileIds);
+ }
+
+ default List selectListByGroupId(Long groupId) {
+ return selectList(TikUserFileGroupDO::getGroupId, groupId);
+ }
+
+ default void deleteByFileIdAndGroupIds(Long fileId, Collection groupIds) {
+ delete(new LambdaQueryWrapperX()
+ .eq(TikUserFileGroupDO::getFileId, fileId)
+ .inIfPresent(TikUserFileGroupDO::getGroupId, groupIds));
+ }
+
+ default void deleteByFileIds(Collection fileIds) {
+ delete(TikUserFileGroupDO::getFileId, fileIds);
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserFileMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserFileMapper.java
new file mode 100644
index 0000000000..31ddd6b859
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserFileMapper.java
@@ -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 {
+
+ default PageResult selectPage(AppTikUserFilePageReqVO reqVO) {
+ return selectPage(reqVO, new LambdaQueryWrapperX()
+ .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));
+ }
+
+}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserOssInitMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserOssInitMapper.java
new file mode 100644
index 0000000000..abe4303e95
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserOssInitMapper.java
@@ -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 {
+
+ default TikUserOssInitDO selectByUserId(Long userId) {
+ return selectOne(TikUserOssInitDO::getUserId, userId);
+ }
+
+}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/enums/TikFileCategoryEnum.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/enums/TikFileCategoryEnum.java
new file mode 100644
index 0000000000..aa278d0e7a
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/enums/TikFileCategoryEnum.java
@@ -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 {
+
+ 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;
+ }
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikFileGroupService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikFileGroupService.java
new file mode 100644
index 0000000000..2521304cfc
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikFileGroupService.java
@@ -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 getGroupList();
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikFileGroupServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikFileGroupServiceImpl.java
new file mode 100644
index 0000000000..160000c97d
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikFileGroupServiceImpl.java
@@ -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()
+ .eq(TikUserFileGroupDO::getGroupId, groupId));
+
+ // 逻辑删除分组
+ fileGroupMapper.deleteById(groupId);
+
+ log.info("[deleteGroup][用户({})删除分组成功,分组编号({})]", userId, groupId);
+ }
+
+ @Override
+ public List getGroupList() {
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+ Long tenantId = TenantContextHolder.getTenantId();
+
+ List groups = fileGroupMapper.selectList(
+ new LambdaQueryWrapperX()
+ .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()
+ .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);
+ }
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitService.java
new file mode 100644
index 0000000000..9c49a0a4f8
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitService.java
@@ -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);
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java
new file mode 100644
index 0000000000..8218c8596c
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikOssInitServiceImpl.java
@@ -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);
+ };
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupService.java
new file mode 100644
index 0000000000..82b3a15da3
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupService.java
@@ -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 fileIds, List groupIds);
+
+ /**
+ * 从分组移除文件
+ *
+ * @param fileIds 文件编号列表
+ * @param groupIds 分组编号列表
+ */
+ void removeFilesFromGroups(List fileIds, List groupIds);
+
+ /**
+ * 查询文件所属的分组列表
+ *
+ * @param fileId 文件编号
+ * @return 分组列表
+ */
+ List getFileGroups(Long fileId);
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java
new file mode 100644
index 0000000000..caf6e49859
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileGroupServiceImpl.java
@@ -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 fileIds, List 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()
+ .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 fileIds, List groupIds) {
+ if (CollUtil.isEmpty(fileIds) || CollUtil.isEmpty(groupIds)) {
+ return;
+ }
+
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+
+ // 校验文件属于当前用户
+ validateFilesBelongToUser(fileIds, userId);
+
+ // 删除关联关系
+ userFileGroupMapper.delete(
+ new LambdaQueryWrapperX()
+ .in(TikUserFileGroupDO::getFileId, fileIds)
+ .in(TikUserFileGroupDO::getGroupId, groupIds)
+ );
+
+ log.info("[removeFilesFromGroups][用户({})从分组移除文件成功,文件数量({}),分组数量({})]",
+ userId, fileIds.size(), groupIds.size());
+ }
+
+ @Override
+ public List 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 relations = userFileGroupMapper.selectListByFileId(fileId);
+
+ if (CollUtil.isEmpty(relations)) {
+ return Collections.emptyList();
+ }
+
+ List groupIds = relations.stream()
+ .map(TikUserFileGroupDO::getGroupId)
+ .collect(Collectors.toList());
+
+ List groups = fileGroupMapper.selectBatchIds(groupIds);
+ return CollectionUtils.convertList(groups, group -> BeanUtils.toBean(group, AppTikFileGroupRespVO.class));
+ }
+
+ /**
+ * 校验文件属于当前用户
+ */
+ private void validateFilesBelongToUser(List fileIds, Long userId) {
+ List 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 groupIds, Long userId) {
+ List 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);
+ }
+ }
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java
new file mode 100644
index 0000000000..f196a6150e
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileService.java
@@ -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 getFilePage(AppTikUserFilePageReqVO pageReqVO);
+
+ /**
+ * 删除文件(批量)
+ *
+ * @param fileIds 文件编号列表
+ */
+ void deleteFiles(List 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);
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java
new file mode 100644
index 0000000000..47779c77f7
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java
@@ -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()
+ .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 getFilePage(AppTikUserFilePageReqVO pageReqVO) {
+ // 自动填充当前登录用户ID
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+ pageReqVO.setUserId(userId);
+
+ // 查询文件列表
+ PageResult pageResult = userFileMapper.selectPage(pageReqVO);
+
+ // 转换为VO并生成预览URL
+ return CollectionUtils.convertPage(pageResult, file -> {
+ AppTikUserFileRespVO vo = BeanUtils.toBean(file, AppTikUserFileRespVO.class);
+
+ // 判断文件类型
+ boolean isVideo = StrUtil.containsIgnoreCase(file.getFileType(), "video");
+ boolean isImage = FileTypeUtils.isImage(file.getFileType());
+ vo.setIsVideo(isVideo);
+ vo.setIsImage(isImage);
+
+ // 生成预览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 fileIds) {
+ if (CollUtil.isEmpty(fileIds)) {
+ return;
+ }
+ Long userId = SecurityFrameworkUtils.getLoginUserId();
+
+ // 查询文件列表
+ List files = userFileMapper.selectBatchIds(fileIds);
+ if (files.isEmpty()) {
+ return;
+ }
+
+ // 校验文件属于当前用户
+ for (TikUserFileDO file : files) {
+ if (!file.getUserId().equals(userId)) {
+ throw exception(FILE_NOT_EXISTS, "文件不属于当前用户");
+ }
+ }
+
+ // 物理删除OSS文件(使用 file_path,如果为空则使用 file_url)
+ for (TikUserFileDO file : files) {
+ try {
+ // 优先使用 file_path,如果没有则从 file_url 提取路径
+ String pathToDelete = file.getFilePath();
+ if (StrUtil.isBlank(pathToDelete)) {
+ // 如果 file_path 为空,尝试从 file_url 提取路径
+ // file_url 格式可能是:https://domain.com/path/to/file 或 /path/to/file
+ pathToDelete = extractPathFromUrl(file.getFileUrl());
+ }
+
+ if (StrUtil.isNotBlank(pathToDelete)) {
+ // 通过 FileApi 删除文件(需要先获取 FileDO,然后删除)
+ // 注意:这里需要关联 infra_file 表获取 configId 和 path
+ // 如果 file_id 存在,可以通过 FileService 删除
+ // 否则需要直接调用 FileClient 删除
+ // TODO: 实现物理删除OSS文件的逻辑
+ log.debug("[deleteFiles][准备删除OSS文件,路径({})]", pathToDelete);
+ }
+ } catch (Exception e) {
+ // 删除OSS文件失败不影响逻辑删除,只记录日志
+ log.warn("[deleteFiles][删除OSS文件失败,文件编号({}),路径({})]",
+ file.getId(), file.getFilePath(), e);
+ }
+ }
+
+ // 逻辑删除文件
+ 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;
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupAddFilesReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupAddFilesReqVO.java
new file mode 100644
index 0000000000..a2fd2d2a3f
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupAddFilesReqVO.java
@@ -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 fileIds;
+
+ @Schema(description = "分组编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]")
+ @NotEmpty(message = "分组编号列表不能为空")
+ private List groupIds;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupCreateReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupCreateReqVO.java
new file mode 100644
index 0000000000..79bf337a68
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupCreateReqVO.java
@@ -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;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupRespVO.java
new file mode 100644
index 0000000000..d864297dd8
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupRespVO.java
@@ -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;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupUpdateReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupUpdateReqVO.java
new file mode 100644
index 0000000000..0aa74cd6b5
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikFileGroupUpdateReqVO.java
@@ -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;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFilePageReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFilePageReqVO.java
new file mode 100644
index 0000000000..eae2ac5694
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFilePageReqVO.java
@@ -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;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java
new file mode 100644
index 0000000000..5af6d8934e
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java
@@ -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;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileUploadReqVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileUploadReqVO.java
new file mode 100644
index 0000000000..08667761cb
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileUploadReqVO.java
@@ -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;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserOssInitRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserOssInitRespVO.java
new file mode 100644
index 0000000000..9b91fd0c3d
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserOssInitRespVO.java
@@ -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;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/member/mq/consumer/MemberUserCreateConsumer.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/member/mq/consumer/MemberUserCreateConsumer.java
new file mode 100644
index 0000000000..4c97e2fda6
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/member/mq/consumer/MemberUserCreateConsumer.java
@@ -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);
+ // 注意:这里不抛出异常,避免影响用户注册流程
+ // 可以考虑记录失败日志,后续通过定时任务补偿
+ }
+ }
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/dataobject/TikUserQuotaDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/dataobject/TikUserQuotaDO.java
new file mode 100644
index 0000000000..e09acc28c4
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/dataobject/TikUserQuotaDO.java
@@ -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;
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/mysql/TikUserQuotaMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/mysql/TikUserQuotaMapper.java
new file mode 100644
index 0000000000..2e221bf24d
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/mysql/TikUserQuotaMapper.java
@@ -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 {
+
+ default TikUserQuotaDO selectByUserId(Long userId) {
+ return selectOne(TikUserQuotaDO::getUserId, userId);
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaService.java
new file mode 100644
index 0000000000..c08f0cd915
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaService.java
@@ -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);
+
+}
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java
new file mode 100644
index 0000000000..fc87062569
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java
@@ -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);
+ }
+
+}
+
diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/vo/app/AppTikUserQuotaRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/vo/app/AppTikUserQuotaRespVO.java
new file mode 100644
index 0000000000..802c5a9d74
--- /dev/null
+++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/vo/app/AppTikUserQuotaRespVO.java
@@ -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;
+
+}
+