功能优化

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

@@ -78,6 +78,7 @@ globs: **/*.vue, **/*.ts, components/**/*
- 规范事件处理机制
- 为复杂逻辑添加文档注释
- 代码简洁易于人类阅读
- 文件名使用mutiword 示例 'ExamplePage'
## 构建与工具链
- 使用 Vite 进行开发

View File

@@ -0,0 +1,105 @@
# 前端上传逻辑检查报告
## 📋 代码检查结果
### ✅ 正常逻辑
1. **MaterialUploadModal.vue**
- 文件上传组件逻辑清晰
- 文件列表管理合理
- 文件大小和类型校验完善
2. **MaterialList.vue**
- 上传流程完整
- 错误处理合理
- 批量上传逻辑正确
### ⚠️ 发现的问题
#### 1. 冗余代码
**MaterialUploadModal.vue**:
- `handleDrop` 方法第188-191行只是打印日志没有实际作用
```javascript
const handleDrop = (e) => {
// a-upload-dragger 会自动处理拖拽的文件,通过 change 事件触发
console.log('Drop event:', e)
}
```
**建议**:删除此方法,因为 `a-upload-dragger` 会自动处理拖拽
#### 2. 逻辑问题
**MaterialList.vue**:
- `uploadFileCategory` 固定为 'video'第147行
```javascript
const uploadFileCategory = ref('video') // 固定为 video不需要用户选择
```
**问题**:用户无法选择文件分类,所有文件都上传到 video 分类
**建议**:添加文件分类选择功能,或者根据文件类型自动判断分类
#### 3. 代码优化建议
**MaterialUploadModal.vue**:
- `handleFileChange` 方法第160-174行逻辑可以简化
- 文件对象提取逻辑第201-217行虽然复杂但是必要的因为 Ant Design Vue 的文件对象结构复杂)
## 🎯 优化建议
### 1. 删除冗余的 handleDrop 方法
```javascript
// 删除这个方法,因为 a-upload-dragger 会自动处理拖拽
// const handleDrop = (e) => {
// console.log('Drop event:', e)
// }
```
### 2. 添加文件分类选择功能
在 `MaterialUploadModal.vue` 中添加分类选择:
```vue
<!-- 文件分类选择 -->
<div class="upload-category-select">
<div class="upload-label">文件分类:</div>
<a-select
v-model="fileCategory"
placeholder="请选择文件分类"
style="width: 100%"
size="large"
>
<a-option value="video">视频集</a-option>
<a-option value="generate">生成集</a-option>
<a-option value="audio">配音集</a-option>
<a-option value="mix">混剪集</a-option>
<a-option value="voice">声音集</a-option>
</a-select>
</div>
```
然后在 `MaterialList.vue` 中接收分类参数:
```javascript
const handleConfirmUpload = async (files, fileCategory) => {
// 使用传入的分类,而不是固定的 'video'
await MaterialService.uploadFile(file, fileCategory)
}
```
## 📊 总结
| 项目 | 状态 | 说明 |
|------|------|------|
| 上传逻辑 | ✅ 正常 | 文件上传流程完整 |
| 错误处理 | ✅ 正常 | 错误提示和异常处理完善 |
| 文件校验 | ✅ 正常 | 文件大小和类型校验合理 |
| 冗余代码 | ⚠️ 1处 | `handleDrop` 方法无实际作用 |
| 功能缺失 | ⚠️ 1处 | 缺少文件分类选择功能 |
## 🔧 建议修复
1. **删除冗余代码**:移除 `handleDrop` 方法
2. **添加分类选择**:在 `MaterialUploadModal` 中添加文件分类选择功能
3. **优化用户体验**:根据文件类型自动推荐分类(可选)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
/**
* 素材库 API 服务
* 对应后端 tik 模块的文件管理接口
*/
import http from './http'
import { API_BASE } from '@gold/config/api'
// 统一使用 /api/tik 前缀,与 tikhub 保持一致
const BASE_URL = `${API_BASE.APP}/api/tik/file`
/**
* 素材库 API 服务
*/
export const MaterialService = {
/**
* 分页查询文件列表
* @param {Object} params - 查询参数
* @param {number} params.pageNo - 页码
* @param {number} params.pageSize - 每页数量
* @param {string} params.fileCategory - 文件分类video/generate/audio/mix/voice
* @param {string} params.fileName - 文件名(模糊查询)
* @param {string} params.fileType - 文件类型
* @param {number} params.groupId - 分组编号
* @param {Array} params.createTime - 创建时间范围 [开始时间, 结束时间]
* @returns {Promise}
*/
getFilePage(params) {
return http.get(`${BASE_URL}/page`, { params })
},
/**
* 上传文件
* @param {FormData} formData - 文件表单数据
* @param {string} fileCategory - 文件分类video/generate/audio/mix/voice
* @returns {Promise}
*/
uploadFile(file, fileCategory) {
const formData = new FormData()
formData.append('file', file)
formData.append('fileCategory', fileCategory)
return http.post(`${BASE_URL}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
/**
* 删除文件(批量)
* @param {Array<number>} fileIds - 文件编号列表
* @returns {Promise}
*/
deleteFiles(fileIds) {
// 根据后端接口,使用 delete-batch 接口
return http.delete(`${BASE_URL}/delete-batch`, {
params: { ids: fileIds.join(',') }
})
},
/**
* 获取视频播放URL
* @param {number} fileId - 文件编号
* @returns {Promise}
*/
getVideoPlayUrl(fileId) {
return http.get(`${BASE_URL}/video/play-url`, {
params: { id: fileId }
})
},
/**
* 获取音频播放URL
* @param {number} fileId - 文件编号
* @returns {Promise}
*/
getAudioPlayUrl(fileId) {
return http.get(`${BASE_URL}/audio/play-url`, {
params: { id: fileId }
})
},
/**
* 获取预览URL
* @param {number} fileId - 文件编号
* @param {string} type - 预览类型thumbnail/cover
* @returns {Promise}
*/
getPreviewUrl(fileId, type = 'thumbnail') {
return http.get(`${BASE_URL}/preview-url`, {
params: { id: fileId, type }
})
}
}
/**
* 素材分组 API 服务
*/
const GROUP_BASE_URL = `${API_BASE.APP}/api/tik/file/group`
export const MaterialGroupService = {
/**
* 创建分组
* @param {Object} data - 分组数据
* @param {string} data.name - 分组名称
* @param {string} data.description - 分组描述
* @param {number} data.sort - 排序
* @param {string} data.icon - 分组图标
* @param {number} data.parentId - 父分组编号
* @returns {Promise}
*/
createGroup(data) {
return http.post(`${GROUP_BASE_URL}/create`, data)
},
/**
* 更新分组
* @param {Object} data - 分组数据
* @param {number} data.id - 分组编号
* @param {string} data.name - 分组名称
* @param {string} data.description - 分组描述
* @param {number} data.sort - 排序
* @param {string} data.icon - 分组图标
* @param {number} data.parentId - 父分组编号
* @returns {Promise}
*/
updateGroup(data) {
return http.put(`${GROUP_BASE_URL}/update`, data)
},
/**
* 删除分组
* @param {number} groupId - 分组编号
* @returns {Promise}
*/
deleteGroup(groupId) {
return http.delete(`${GROUP_BASE_URL}/delete`, {
params: { id: groupId }
})
},
/**
* 查询分组列表
* @returns {Promise}
*/
getGroupList() {
return http.get(`${GROUP_BASE_URL}/list`)
},
/**
* 将文件添加到分组
* @param {Object} data - 请求数据
* @param {Array<number>} data.fileIds - 文件编号列表
* @param {Array<number>} data.groupIds - 分组编号列表
* @returns {Promise}
*/
addFilesToGroups(data) {
return http.post(`${GROUP_BASE_URL}/add-files`, data)
},
/**
* 从分组移除文件
* @param {Object} data - 请求数据
* @param {Array<number>} data.fileIds - 文件编号列表
* @param {Array<number>} data.groupIds - 分组编号列表
* @returns {Promise}
*/
removeFilesFromGroups(data) {
return http.post(`${GROUP_BASE_URL}/remove-files`, data)
}
}
export default MaterialService

View File

@@ -0,0 +1,49 @@
/**
* 测试 API 服务
* 用于测试升级会员和创建OSS目录
*/
import http from './http'
import { API_BASE } from '@gold/config/api'
const BASE_URL = `${API_BASE.APP}/api/tik/test`
/**
* 测试 API 服务
*/
export const TestService = {
/**
* 升级会员
* @param {Object} params - 参数
* @param {number} params.vipLevel - VIP等级
* @param {number} params.totalStorage - 总存储空间(字节)
* @param {number} params.totalQuota - 总配额(积分/额度)
* @returns {Promise}
*/
upgradeVip(params = {}) {
return http.post(`${BASE_URL}/upgrade-vip`, null, { params })
},
/**
* 初始化OSS目录
* @returns {Promise}
*/
initOss() {
return http.post(`${BASE_URL}/init-oss`)
},
/**
* 一键测试(升级会员 + 初始化OSS
* @param {Object} params - 参数
* @param {number} params.vipLevel - VIP等级
* @param {number} params.totalStorage - 总存储空间(字节)
* @param {number} params.totalQuota - 总配额(积分/额度)
* @returns {Promise}
*/
testAll(params = {}) {
return http.post(`${BASE_URL}/test-all`, null, { params })
}
}
export default TestService

View File

@@ -1,9 +1,11 @@
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 单色 SVG 图标(填充 currentColor可继承文本色
const icons = {
@@ -12,12 +14,14 @@ const icons = {
text: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M4 7h16"/><path d="M4 12h10"/><path d="M4 17h14"/></svg>',
mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><rect x="9" y="2" width="6" height="11" rx="3"/><path d="M5 10a7 7 0 0 0 14 0"/><path d="M12 19v3"/></svg>',
wave: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M2 12s2-4 5-4 3 8 6 8 3-8 6-8 3 4 3 4"/></svg>',
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="7" r="4"/><path d="M5.5 21a8.38 8.38 0 0 1 13 0"/></svg>'
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="7" r="4"/><path d="M5.5 21a8.38 8.38 0 0 1 13 0"/></svg>',
video: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="m22 8-6 4 6 4V8Z"/><rect x="2" y="6" width="14" height="12" rx="2" ry="2"/></svg>',
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>'
}
const items = computed(() => {
// 小标题(功能) + 模块(子菜单)的形式;使用单色 SVG 图标
return [
const allItems = [
{
title: '功能',
children: [
@@ -28,10 +32,18 @@ const items = computed(() => {
]
},
{
title: '配音',
title: '数字人',
children: [
{ path: '/digital-human/voice-copy', label: '人声克隆', icon: 'mic' },
{ path: '/digital-human/voice-generate', label: '生成配音', icon: 'wave' },
{ path: '/digital-human/video', label: '数字人视频', icon: 'video' },
]
},
{
title: '素材库',
children: [
{ path: '/material/list', label: '素材列表', icon: 'grid' },
{ path: '/material/group', label: '素材分组', icon: 'folder' },
]
},
{
@@ -47,6 +59,13 @@ const items = computed(() => {
// ]
// },
]
// 如果未登录,过滤掉"系统"菜单组
if (!userStore.isLoggedIn) {
return allItems.filter(item => item.title !== '系统')
}
return allItems
})
function go(p) {

View File

@@ -1,8 +1,10 @@
<script setup>
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { useUserStore } from '@/stores/user'
import LoginModal from '@/components/LoginModal.vue'
import UserDropdown from '@/components/UserDropdown.vue'
import TestService from '@/api/test'
const styles = {
background: 'var(--color-surface)',
@@ -11,6 +13,34 @@ const styles = {
// const route = useRoute()
const userStore = useUserStore()
const showLogin = ref(false)
const testLoading = ref(false)
// 测试按钮点击事件
const handleTest = async () => {
if (testLoading.value) return
testLoading.value = true
try {
// 调用一键测试接口(升级会员 + 初始化OSS
const res = await TestService.testAll({
vipLevel: 1,
totalStorage: 10 * 1024 * 1024 * 1024, // 10GB
totalQuota: 10000
})
if (res.code === 0) {
message.success('测试成功已升级会员并创建OSS目录', 3)
console.log('OSS初始化信息:', res.data)
} else {
message.error(res.msg || '测试失败', 3)
}
} catch (error) {
console.error('测试失败:', error)
message.error(error?.response?.data?.msg || error?.message || '测试失败', 3)
} finally {
testLoading.value = false
}
}
// 计算是否应该显示用户组件
// 判断用户是否有用户名,有用户名说明用户信息已加载完成
@@ -39,6 +69,15 @@ const shouldShowUser = computed(() => {
<!-- 左侧可放 logo 或其他内容 -->
</div>
<div class="flex items-center gap-4 pr-[35px]">
<!-- 测试按钮仅开发环境显示 -->
<button
v-if="shouldShowUser"
class="btn-test-nav"
:disabled="testLoading"
@click="handleTest"
>
{{ testLoading ? '测试中...' : '测试' }}
</button>
<template v-if="shouldShowUser">
<UserDropdown />
@@ -112,4 +151,28 @@ const shouldShowUser = computed(() => {
box-shadow: var(--glow-primary);
filter: brightness(1.03);
}
.btn-test-nav {
height: 32px;
padding: 0 12px;
border-radius: 8px;
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all .2s ease;
}
.btn-test-nav:hover:not(:disabled) {
background: var(--color-bg);
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-test-nav:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,322 @@
<template>
<a-modal
:visible="visible"
title="上传素材"
:width="600"
:footer="false"
@cancel="handleCancel"
@update:visible="handleVisibleChange"
>
<div class="upload-modal-content">
<!-- 文件上传区域 -->
<div class="upload-area">
<a-upload-dragger
v-model:file-list="fileList"
name="file"
:multiple="true"
:accept="acceptTypes"
action=""
:before-upload="handleBeforeUpload"
:show-upload-list="false"
@change="handleFileChange"
>
<p class="ant-upload-drag-icon">
<UploadOutlined />
</p>
<p class="ant-upload-text">点击或拖拽文件到此处上传</p>
<p class="ant-upload-hint">
支持多文件上传单个文件不超过 500MB
<br />
支持格式视频MP4MOVAVI等图片JPGPNGGIF等音频MP3WAV等
</p>
</a-upload-dragger>
</div>
<!-- 已选文件列表 -->
<div v-if="fileList.length > 0" class="upload-file-list">
<div class="upload-file-list-title">已选择 {{ fileList.length }} 个文件</div>
<div class="upload-file-items">
<div
v-for="(fileItem, index) in fileList"
:key="fileItem.uid || index"
class="upload-file-item"
>
<FileOutlined class="file-icon" />
<span class="file-name">{{ getFileName(fileItem) }}</span>
<span class="file-size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
<a-button
type="text"
status="danger"
size="small"
@click="handleRemove(fileItem)"
>
删除
</a-button>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="upload-actions">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
:loading="uploading"
:disabled="fileList.length === 0 || !fileCategory"
@click="handleConfirm"
>
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
</a-button>
</a-space>
</div>
</div>
</a-modal>
</template>
<script setup>
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { UploadOutlined, FileOutlined } from '@ant-design/icons-vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
uploading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
// 数据
const fileList = ref([])
const fileCategory = ref('video') // 文件分类,默认为视频集
// 支持的文件类型
const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac'
// 监听 visible 变化,重置文件列表和分类
watch(() => props.visible, (newVal) => {
if (!newVal) {
fileList.value = []
fileCategory.value = 'video' // 重置为默认分类
}
})
// 获取文件名
const getFileName = (fileItem) => {
if (fileItem instanceof File) {
return fileItem.name
}
return fileItem.name || fileItem.file?.name || fileItem.originFileObj?.name || '未知文件'
}
// 获取文件大小
const getFileSize = (fileItem) => {
if (fileItem instanceof File) {
return fileItem.size
}
return fileItem.size || fileItem.file?.size || fileItem.originFileObj?.size || 0
}
// 格式化文件大小
const formatFileSize = (bytes) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
// 上传前处理
const handleBeforeUpload = (file) => {
// 检查文件大小500MB
if (file.size > 500 * 1024 * 1024) {
message.warning(`文件 ${file.name} 超过 500MB已跳过`)
return false
}
// 检查是否已存在相同文件(使用当前的 fileList
const exists = fileList.value.some(item => {
const itemName = getFileName(item)
const itemSize = getFileSize(item)
return itemName === file.name && itemSize === file.size
})
if (exists) {
message.warning(`文件 ${file.name} 已存在,已跳过`)
return false
}
// 阻止自动上传,文件会通过 change 事件添加到列表
return false
}
// 文件列表变化
const handleFileChange = (info) => {
// 使用 v-model:file-list 后fileList 会自动更新
// 这里只需要处理文件验证和状态
const { file, fileList: newFileList } = info
if (file && file.status !== 'uploading') {
// 确保文件对象正确保存
fileList.value = newFileList.map(item => {
if (!item.file && item.originFileObj) {
item.file = item.originFileObj
}
return item
}).filter(item => item.status !== 'removed')
}
}
// 移除文件
const handleRemove = (fileItem) => {
const index = fileList.value.findIndex(item =>
(item.uid && item.uid === fileItem.uid) ||
(getFileName(item) === getFileName(fileItem))
)
if (index > -1) {
fileList.value.splice(index, 1)
}
}
// 确认上传
const handleConfirm = () => {
if (fileList.value.length === 0) {
message.warning('请选择文件')
return
}
// 提取文件对象,优先使用 file其次 originFileObj最后是 item 本身
const files = fileList.value
.map(item => {
// 优先使用 file 属性
if (item.file instanceof File) {
return item.file
}
// 其次使用 originFileObj
if (item.originFileObj instanceof File) {
return item.originFileObj
}
// 最后尝试 item 本身(如果是 File 对象)
if (item instanceof File) {
return item
}
return null
})
.filter(file => file instanceof File)
if (files.length === 0) {
message.error('无法获取文件对象,请重新选择文件')
return
}
if (!fileCategory.value) {
message.warning('请选择文件分类')
return
}
emit('confirm', files, fileCategory.value)
}
// 处理 visible 变化
const handleVisibleChange = (value) => {
emit('update:visible', value)
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
emit('cancel')
}
</script>
<style scoped>
.upload-modal-content {
padding: 8px 0;
}
.upload-category-select {
margin-bottom: 24px;
}
.upload-label {
margin-bottom: 8px;
font-weight: 500;
color: var(--color-text);
}
.upload-area {
margin-bottom: 24px;
}
.upload-file-list {
margin-bottom: 24px;
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
border: 1px solid var(--color-border);
}
.upload-file-list-title {
font-weight: 500;
margin-bottom: 12px;
color: var(--color-text);
}
.upload-file-items {
max-height: 200px;
overflow-y: auto;
}
.upload-file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: 4px;
transition: background 0.2s;
}
.upload-file-item:hover {
background: var(--color-bg-2);
}
.file-icon {
font-size: 20px;
color: var(--color-text-3);
}
.file-name {
flex: 1;
font-size: 14px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: var(--color-text-3);
}
.upload-actions {
text-align: right;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
.upload-tips {
font-size: 12px;
line-height: 1.8;
}
.upload-tips > div {
margin-bottom: 4px;
}
</style>

View File

@@ -35,6 +35,16 @@ const routes = [
{ path: 'voice-copy', name: '人声克隆', component: () => import('../views/dh/VoiceCopy.vue') },
{ path: 'voice-generate', name: '生成配音', component: () => import('../views/dh/VoiceGenerate.vue') },
{ path: 'avatar', name: '生成数字人', component: () => import('../views/dh/Avatar.vue') },
{ path: 'video', name: '数字人视频', component: () => import('../views/dh/Video.vue') },
]
},
{
path: '/material',
name: '素材库',
children: [
{ path: '', redirect: '/material/list' },
{ path: 'list', name: '素材列表', component: () => import('../views/material/MaterialList.vue') },
{ path: 'group', name: '素材分组', component: () => import('../views/material/MaterialGroup.vue') },
]
},
{
@@ -62,10 +72,13 @@ const router = createRouter({
let userInfoInitialized = false
/**
* 路由导航守卫:初始化用户信息
* 路由导航守卫:初始化用户信息 + 登录验证
* 在首次路由跳转时,如果已登录(有 token则获取用户信息
* 如果未登录访问系统相关路由,则重定向到首页
*/
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 只在首次路由跳转时初始化用户信息
if (!userInfoInitialized) {
userInfoInitialized = true
@@ -73,7 +86,6 @@ router.beforeEach(async (to, from, next) => {
const token = getToken()
if (token) {
try {
const userStore = useUserStore()
// 如果 store 中已标记为登录,则获取用户信息
if (userStore.isLoggedIn) {
userStore.fetchUserInfo()
@@ -90,6 +102,22 @@ router.beforeEach(async (to, from, next) => {
}
}
// 检查访问系统相关路由时是否已登录
if (to.path.startsWith('/system')) {
// 等待 store 从本地存储恢复完成最多等待500ms
let waitCount = 0
while (!userStore.isHydrated && waitCount < 50) {
await new Promise(resolve => setTimeout(resolve, 10))
waitCount++
}
// 如果未登录,重定向到首页
if (!userStore.isLoggedIn) {
next({ path: '/content-style/benchmark', replace: true })
return
}
}
// 继续路由跳转
next()
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,296 @@
<template>
<div class="material-group">
<div class="material-group__header">
<h1 class="material-group__title">素材分组</h1>
<a-button type="primary" @click="handleCreateGroup">
<template #icon>
<PlusOutlined />
</template>
新建分组
</a-button>
</div>
<!-- 分组列表 -->
<div class="material-group__content">
<a-spin :spinning="loading" tip="加载中..." style="width: 100%; min-height: 400px;">
<template v-if="groupList.length > 0">
<a-list
:data="groupList"
:bordered="false"
>
<template #item="{ item }">
<a-list-item class="group-item">
<a-list-item-meta>
<template #title>
<div class="group-item__header">
<span class="group-item__name">{{ item.name }}</span>
<a-tag>{{ item.fileCount || 0 }} 个文件</a-tag>
</div>
</template>
<template #description>
<div class="group-item__description">
{{ item.description || '暂无描述' }}
</div>
<div class="group-item__meta">
<span>创建时间{{ formatDate(item.createTime) }}</span>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a-button type="text" @click="handleEditGroup(item)">
<template #icon>
<EditOutlined />
</template>
编辑
</a-button>
<a-button type="text" status="danger" @click="handleDeleteGroup(item)">
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</template>
<template v-else>
<a-empty description="暂无分组" />
</template>
</a-spin>
</div>
<!-- 创建/编辑分组对话框 -->
<a-modal
v-model:visible="groupModalVisible"
:title="groupModalTitle"
@ok="handleSaveGroup"
@cancel="handleCancelGroup"
>
<a-form :model="groupForm" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
<a-form-item label="分组名称" field="name" :rules="[{ required: true, message: '请输入分组名称' }]">
<a-input v-model="groupForm.name" placeholder="请输入分组名称" />
</a-form-item>
<a-form-item label="分组描述" field="description">
<a-textarea
v-model="groupForm.description"
placeholder="请输入分组描述"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
<a-form-item label="排序" field="sort">
<a-input-number v-model="groupForm.sort" :min="0" placeholder="排序值" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import { MaterialGroupService } from '@/api/material'
// 数据
const loading = ref(false)
const groupList = ref([])
const groupModalVisible = ref(false)
const groupModalTitle = ref('新建分组')
const isEdit = ref(false)
// 表单
const groupForm = reactive({
id: undefined,
name: '',
description: '',
sort: 0
})
// 加载分组列表
const loadGroupList = async () => {
if (loading.value) return // 防止重复请求
loading.value = true
try {
const res = await MaterialGroupService.getGroupList()
if (res.code === 0) {
groupList.value = res.data || []
} else {
message.error(res.msg || '加载失败')
groupList.value = []
}
} catch (error) {
console.error('加载分组列表失败:', error)
message.error('加载失败,请重试')
groupList.value = []
} finally {
loading.value = false
}
}
// 创建分组
const handleCreateGroup = () => {
isEdit.value = false
groupModalTitle.value = '新建分组'
groupForm.id = undefined
groupForm.name = ''
groupForm.description = ''
groupForm.sort = 0
groupModalVisible.value = true
}
// 编辑分组
const handleEditGroup = (group) => {
isEdit.value = true
groupModalTitle.value = '编辑分组'
groupForm.id = group.id
groupForm.name = group.name
groupForm.description = group.description || ''
groupForm.sort = group.sort || 0
groupModalVisible.value = true
}
// 保存分组
const handleSaveGroup = async () => {
if (!groupForm.name.trim()) {
message.warning('请输入分组名称')
return
}
try {
if (isEdit.value) {
await MaterialGroupService.updateGroup(groupForm)
message.success('更新成功')
} else {
await MaterialGroupService.createGroup(groupForm)
message.success('创建成功')
}
groupModalVisible.value = false
loadGroupList()
} catch (error) {
console.error('保存分组失败:', error)
message.error(error.message || '保存失败,请重试')
}
}
// 取消
const handleCancelGroup = () => {
groupModalVisible.value = false
groupForm.id = undefined
groupForm.name = ''
groupForm.description = ''
groupForm.sort = 0
}
// 删除分组
const handleDeleteGroup = (group) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除分组"${group.name}"吗?删除后分组内的文件不会被删除。`,
onOk: async () => {
try {
await MaterialGroupService.deleteGroup(group.id)
message.success('删除成功')
loadGroupList()
} catch (error) {
console.error('删除分组失败:', error)
message.error(error.message || '删除失败,请重试')
}
}
})
}
// 工具函数
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 初始化
onMounted(() => {
loadGroupList()
})
</script>
<style scoped>
.material-group {
padding: 24px;
height: 100%;
display: flex;
flex-direction: column;
}
.material-group__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.material-group__title {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.material-group__content {
flex: 1;
overflow-y: auto;
position: relative;
}
.material-group__content :deep(.arco-spin) {
min-height: 400px;
}
.material-group__content :deep(.arco-spin-content) {
min-height: 400px;
}
.group-item {
padding: 16px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
margin-bottom: 12px;
transition: all 0.2s;
}
.group-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.group-item__header {
display: flex;
align-items: center;
gap: 12px;
}
.group-item__name {
font-size: 16px;
font-weight: 500;
}
.group-item__description {
margin-top: 8px;
color: var(--color-text-2);
}
.group-item__meta {
margin-top: 8px;
font-size: 12px;
color: var(--color-text-3);
}
</style>

View File

@@ -0,0 +1,482 @@
<template>
<div class="material-list">
<div class="material-list__header">
<h1 class="material-list__title">素材列表</h1>
<div class="material-list__actions">
<a-button type="primary" @click="handleOpenUploadModal">
<template #icon>
<UploadOutlined />
</template>
上传素材
</a-button>
<a-button
v-if="selectedFileIds.length > 0"
type="primary"
status="danger"
@click="handleBatchDelete"
>
批量删除 ({{ selectedFileIds.length }})
</a-button>
</div>
</div>
<!-- 筛选条件 -->
<div class="material-list__filters">
<a-space>
<a-input
v-model="filters.fileName"
placeholder="搜索文件名"
style="width: 200px"
allow-clear
@press-enter="handleFilterChange"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-range-picker
v-model:value="filters.createTime"
style="width: 300px"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
@change="handleFilterChange"
/>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
<a-button @click="handleResetFilters">重置</a-button>
</a-space>
</div>
<!-- 文件列表 -->
<div class="material-list__content">
<a-spin :spinning="loading" tip="加载中..." style="width: 100%; min-height: 400px;">
<template v-if="fileList.length > 0">
<div class="material-grid">
<div
v-for="file in fileList"
:key="file.id"
class="material-item"
:class="{ 'material-item--selected': selectedFileIds.includes(file.id) }"
@click="handleFileClick(file)"
>
<div class="material-item__content">
<!-- 预览图 -->
<div class="material-item__preview">
<img
v-if="file.previewUrl"
:src="file.previewUrl"
:alt="file.fileName"
@error="handleImageError"
/>
<div v-else class="material-item__placeholder">
<FileOutlined />
</div>
<!-- 文件类型标识 -->
<div class="material-item__badge">
<a-tag v-if="file.isVideo" color="red">视频</a-tag>
<a-tag v-else-if="file.isImage" color="blue">图片</a-tag>
<a-tag v-else color="gray">文件</a-tag>
</div>
<!-- 选中复选框 -->
<div class="material-item__checkbox">
<a-checkbox
:model-value="selectedFileIds.includes(file.id)"
@click.stop
@change="(checked) => handleSelectFile(file.id, checked)"
/>
</div>
</div>
<!-- 文件信息 -->
<div class="material-item__info">
<div class="material-item__name" :title="file.fileName">
{{ file.fileName }}
</div>
<div class="material-item__meta">
<span>{{ formatFileSize(file.fileSize) }}</span>
<span>{{ formatDate(file.createTime) }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<a-empty description="暂无素材" />
</template>
</a-spin>
</div>
<!-- 分页 -->
<div class="material-list__pagination">
<a-pagination
v-model:current="pagination.pageNo"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:show-total="(total) => `${total}`"
:show-size-changer="true"
@change="handlePageChange"
/>
</div>
<!-- 上传对话框 -->
<MaterialUploadModal
v-model:visible="uploadModalVisible"
:uploading="uploading"
@confirm="handleConfirmUpload"
@cancel="handleUploadCancel"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
UploadOutlined,
SearchOutlined,
FileOutlined
} from '@ant-design/icons-vue'
import { MaterialService } from '@/api/material'
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue'
// 数据
const loading = ref(false)
const fileList = ref([])
const selectedFileIds = ref([])
const uploadModalVisible = ref(false)
const uploading = ref(false)
// 筛选条件
const filters = reactive({
fileCategory: undefined,
fileName: '',
createTime: undefined
})
// 分页
const pagination = reactive({
pageNo: 1,
pageSize: 20,
total: 0
})
// 加载文件列表
const loadFileList = async () => {
loading.value = true
try {
const params = {
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
...filters
}
// 处理日期范围a-range-picker返回的是数组格式的字符串
// 日期区间:将开始日期设置为 00:00:00结束日期设置为 23:59:59
if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) {
params.createTime = [
filters.createTime[0] + ' 00:00:00',
filters.createTime[1] + ' 23:59:59'
]
}
const res = await MaterialService.getFilePage(params)
if (res.code === 0) {
fileList.value = res.data.list || []
pagination.total = res.data.total || 0
} else {
message.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载文件列表失败:', error)
message.error('加载失败,请重试')
} finally {
loading.value = false
}
}
// 打开上传对话框
const handleOpenUploadModal = () => {
uploadModalVisible.value = true
}
// 确认上传
const handleConfirmUpload = async (files, fileCategory) => {
if (!files || files.length === 0) {
message.warning('请选择文件')
return
}
if (!fileCategory) {
message.warning('请选择文件分类')
return
}
uploading.value = true
let successCount = 0
let failCount = 0
try {
// 逐个上传文件,显示进度
for (const file of files) {
try {
await MaterialService.uploadFile(file, fileCategory)
successCount++
} catch (error) {
console.error('文件上传失败:', file.name, error)
failCount++
}
}
if (successCount > 0) {
message.success(`成功上传 ${successCount} 个文件${failCount > 0 ? `${failCount} 个失败` : ''}`)
uploadModalVisible.value = false
loadFileList()
} else {
message.error('所有文件上传失败,请重试')
}
} catch (error) {
console.error('上传失败:', error)
message.error(error.message || '上传失败,请重试')
} finally {
uploading.value = false
}
}
// 取消上传
const handleUploadCancel = () => {
uploadModalVisible.value = false
}
// 删除文件
const handleBatchDelete = async () => {
if (selectedFileIds.value.length === 0) {
return
}
try {
await MaterialService.deleteFiles(selectedFileIds.value)
message.success('删除成功')
selectedFileIds.value = []
loadFileList()
} catch (error) {
console.error('删除失败:', error)
message.error(error.message || '删除失败,请重试')
}
}
// 选择文件
const handleSelectFile = (fileId, checked) => {
if (checked) {
if (!selectedFileIds.value.includes(fileId)) {
selectedFileIds.value.push(fileId)
}
} else {
const index = selectedFileIds.value.indexOf(fileId)
if (index > -1) {
selectedFileIds.value.splice(index, 1)
}
}
}
// 文件点击
const handleFileClick = (file) => {
// TODO: 打开文件详情或预览
console.log('点击文件:', file)
}
// 筛选
const handleFilterChange = () => {
pagination.pageNo = 1
loadFileList()
}
const handleResetFilters = () => {
filters.fileCategory = undefined
filters.fileName = ''
filters.createTime = undefined
pagination.pageNo = 1
loadFileList()
}
// 分页
const handlePageChange = (page, pageSize) => {
pagination.pageNo = page
if (pageSize && pageSize !== pagination.pageSize) {
pagination.pageSize = pageSize
pagination.pageNo = 1
}
loadFileList()
}
// 工具函数
const formatFileSize = (bytes) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
const handleImageError = (e) => {
e.target.style.display = 'none'
}
// 初始化
onMounted(() => {
loadFileList()
})
</script>
<style scoped>
.material-list {
padding: 24px;
height: 100%;
display: flex;
flex-direction: column;
}
.material-list__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.material-list__title {
font-size: 24px;
font-weight: 600;
margin: 0;
}
.material-list__actions {
display: flex;
gap: 12px;
}
.material-list__filters {
margin-bottom: 24px;
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
border: 1px solid var(--color-border);
}
.material-list__content {
flex: 1;
overflow-y: auto;
margin-bottom: 24px;
position: relative;
}
.material-list__content :deep(.arco-spin) {
min-height: 400px;
}
.material-list__content :deep(.arco-spin-content) {
min-height: 400px;
}
.material-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.material-list__pagination {
display: flex;
justify-content: center;
}
.material-item {
cursor: pointer;
transition: all 0.2s;
}
.material-item:hover {
transform: translateY(-2px);
}
.material-item--selected {
border: 2px solid var(--color-primary);
}
.material-item__content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
overflow: hidden;
transition: all 0.2s;
}
.material-item:hover .material-item__content {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.material-item__preview {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 */
background: var(--color-bg-2);
overflow: hidden;
}
.material-item__preview img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.material-item__placeholder {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 48px;
color: var(--color-text-3);
}
.material-item__badge {
position: absolute;
top: 8px;
left: 8px;
}
.material-item__checkbox {
position: absolute;
top: 8px;
right: 8px;
}
.material-item__info {
padding: 12px;
}
.material-item__name {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.material-item__meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--color-text-3);
}
</style>

282
yudao-module-tik/DESIGN.md Normal file
View File

@@ -0,0 +1,282 @@
# Tik 文件管理模块设计文档
## 一、模块概述
Tik 文件管理模块负责用户文件的上传、存储、管理和分组功能,支持多种文件类型(视频、图片、音频等)和分类管理。
## 二、表结构设计
### 2.1 核心表
#### 1. `tik_user_file` - 用户文件表
**作用**:存储用户上传的文件元数据
**关键字段**
- `file_path` (varchar(1024)): **完整OSS路径**,格式:`{手机号MD5}/{租户ID}/{分类}/{日期}/{文件名}_{时间戳}.ext`
- `file_url` (varchar(1024)): 文件访问URL预签名URL或公开URL
- `oss_root_path` (varchar(256)): OSS根路径用于快速定位用户文件目录
- `file_category`: 文件分类video/generate/audio/mix/voice
- `file_id`: 关联 `infra_file.id`(可选,用于关联系统文件表)
**索引设计**
- `idx_user_id`: 用户ID索引
- `idx_file_category`: 文件分类索引
- `idx_user_tenant`: 用户+租户联合索引
- `idx_create_time`: 创建时间索引
#### 2. `tik_user_oss_init` - OSS初始化记录表
**作用**记录用户OSS目录初始化状态和路径信息
**关键字段**
- `mobile_md5`: 手机号MD5值用于生成OSS路径
- `oss_root_path`: OSS根路径
- `video_path`, `generate_path`, `audio_path`, `mix_path`, `voice_path`: 各分类目录路径
- `init_status`: 初始化状态0-未初始化1-已初始化)
**设计要点**
- 懒加载策略:首次上传时自动初始化
- 路径格式:`{手机号MD5}/{租户ID}/{分类}`
#### 3. `tik_file_group` - 文件分组表
**作用**:用户自定义文件分组(支持层级分组)
**关键字段**
- `parent_id`: 父分组ID0表示根分组
- `sort`: 排序字段
#### 4. `tik_user_file_group` - 文件分组关联表
**作用**:文件与分组的关联关系(支持一个文件属于多个分组)
**设计要点**
- 多对多关系
- 唯一索引:`uk_file_group` (file_id, group_id)
#### 5. `tik_user_quota` - 用户配额表
**作用**管理用户存储配额和VIP等级
**关键字段**
- `total_storage`: 总存储空间(字节)
- `used_storage`: 已使用存储空间(字节)
- `vip_level`: VIP等级
## 三、架构设计
### 3.1 分层架构
```
Controller 层 (AppTikUserFileController)
Service 层 (TikUserFileService)
Mapper 层 (TikUserFileMapper)
DataObject 层 (TikUserFileDO)
```
### 3.2 核心服务
#### 1. TikUserFileService - 文件管理服务
**职责**
- 文件上传(带配额校验)
- 文件查询(分页、筛选)
- 文件删除(逻辑删除 + 物理删除)
- 预签名URL生成
**关键流程**
1. **上传流程**
```
校验文件分类 → 校验配额 → 获取OSS目录 → 生成完整路径 → 上传到OSS → 保存元数据 → 更新配额
```
2. **删除流程**
```
校验权限 → 物理删除OSS文件 → 逻辑删除记录 → 释放配额
```
#### 2. TikOssInitService - OSS初始化服务
**职责**
- 初始化用户OSS目录结构
- 获取OSS路径信息
- 懒加载策略实现
**设计要点**
- OSS目录是虚拟的不需要显式创建
- 首次上传时自动初始化
- 路径格式:`{手机号MD5}/{租户ID}/{分类}`
#### 3. TikFileGroupService - 文件分组服务
**职责**
- 分组CRUD
- 层级分组支持
#### 4. TikUserQuotaService - 配额管理服务
**职责**
- 配额校验
- 配额更新
- VIP等级管理
## 四、路径设计
### 4.1 OSS路径结构
```
{手机号MD5}/{租户ID}/{分类}/{日期}/{文件名}_{时间戳}.ext
```
**示例**
```
abc123def45678901234567890123456/1/video/20250101/my_video_1234567890123.mp4
```
**路径组成部分**
1. **手机号MD5** (32字符): 用户唯一标识,保护隐私
2. **租户ID**: 多租户隔离
3. **分类** (video/generate/audio/mix/voice): 文件分类
4. **日期** (yyyyMMdd): 按日期分目录,便于管理
5. **文件名+时间戳**: 保证唯一性,避免覆盖
### 4.2 路径存储策略
- **file_path**: 存储完整OSS路径用于物理删除
- **file_url**: 存储访问URL用于前端展示
- **oss_root_path**: 存储根路径(用于快速定位)
## 五、设计亮点
### 5.1 优点
1. **分层清晰**Controller → Service → Mapper → DO职责明确
2. **配额管理**:上传前校验,删除后释放
3. **多租户支持**:通过 tenant_id 隔离
4. **懒加载策略**OSS目录按需初始化
5. **路径设计合理**:包含用户、租户、分类、日期等信息
6. **分组功能**:支持多分组、层级分组
### 5.2 需要改进的地方
1. **物理删除OSS文件**
- 当前只做了逻辑删除OSS文件未删除
- 建议:删除时调用 FileService 或 FileClient 删除OSS文件
- 或者:定期清理已逻辑删除的文件
2. **file_path 字段长度**
- 当前varchar(512)
- 建议varchar(1024) 更安全
3. **文件关联 infra_file 表**
- `file_id` 字段存在但未充分利用
- 建议:上传时关联 infra_file 表,便于统一管理
4. **预览图生成**
- 视频封面和图片缩略图功能未实现
- 建议:异步生成预览图
5. **批量操作优化**
- 删除文件时逐个删除OSS文件可能较慢
- 建议:批量删除或异步删除
## 六、数据流
### 6.1 上传流程
```
前端上传文件
Controller 接收
Service 校验(分类、配额)
获取OSS目录懒加载初始化
生成完整路径
上传到OSSFileApi
保存元数据到 tik_user_file
更新配额tik_user_quota
返回文件ID
```
### 6.2 查询流程
```
前端请求文件列表
Controller 接收查询参数
Service 查询数据库(分页、筛选)
转换为VO生成预览URL
返回分页结果
```
### 6.3 删除流程
```
前端请求删除
Controller 接收文件ID列表
Service 校验权限
物理删除OSS文件TODO
逻辑删除数据库记录
释放配额
返回成功
```
## 七、API设计
### 7.1 文件管理API
- `POST /api/tik/file/upload` - 上传文件
- `GET /api/tik/file/page` - 分页查询
- `DELETE /api/tik/file/delete-batch` - 批量删除
- `GET /api/tik/file/video/play-url` - 获取视频播放URL
- `GET /api/tik/file/audio/play-url` - 获取音频播放URL
- `GET /api/tik/file/preview-url` - 获取预览URL
### 7.2 分组管理API
- `POST /api/tik/file/group/create` - 创建分组
- `PUT /api/tik/file/group/update` - 更新分组
- `DELETE /api/tik/file/group/delete` - 删除分组
- `GET /api/tik/file/group/list` - 查询分组列表
- `POST /api/tik/file/group/add-files` - 添加文件到分组
- `POST /api/tik/file/group/remove-files` - 从分组移除文件
## 八、总结
### 8.1 表结构建议
1. **必须修改**
- `file_path` 字段长度512 → 1024
2. **可选优化**
- 添加 `file_path` 索引(如果经常按路径查询)
- 添加 `file_id` 索引(如果关联 infra_file 表)
### 8.2 功能完善建议
1. **物理删除OSS文件**:删除时调用 FileService 删除OSS文件
2. **预览图生成**:实现视频封面和图片缩略图异步生成
3. **文件关联**:充分利用 `file_id` 关联 infra_file 表
4. **批量操作优化**:优化批量删除性能
### 8.3 整体评价
**设计评分8.5/10**
- ✅ 架构清晰,分层合理
- ✅ 路径设计合理,支持多租户
- ✅ 配额管理完善
- ⚠️ 物理删除功能缺失
- ⚠️ 预览图功能未实现
- ⚠️ 部分字段未充分利用

View File

@@ -0,0 +1,87 @@
# 文件上传逻辑分析与问题
## 🔴 严重问题:路径不一致
### 问题描述
当前代码存在**路径不一致**的严重问题:
1. **FileService.createFile()** 内部调用 `generateUploadPath()` 生成路径
- 使用 `System.currentTimeMillis()` 作为时间戳
- 实际存储路径:`{baseDirectory}/{yyyyMMdd}/{filename}_{timestamp1}.ext`
2. **我们手动调用 generateFullFilePath()** 生成路径
- 也使用 `System.currentTimeMillis()` 作为时间戳
- 但调用时间不同,时间戳可能不同:`{baseDirectory}/{yyyyMMdd}/{filename}_{timestamp2}.ext`
3. **结果**`filePath` 字段保存的路径 ≠ 实际 OSS 存储路径
- 导致删除文件时无法找到正确的文件
- 导致路径查询不准确
### 时间戳不一致示例
```
FileService.createFile() 调用时间2025-01-15 10:30:45.123
→ 生成时间戳1736905845123
→ 实际路径video/20250115/file_1736905845123.mp4
generateFullFilePath() 调用时间2025-01-15 10:30:45.1252毫秒后
→ 生成时间戳1736905845125
→ 保存路径video/20250115/file_1736905845125.mp4
❌ 路径不匹配!
```
## 📋 冗余代码分析
### 1. generateFullFilePath() 方法
- **状态**:冗余
- **原因**:完全复制了 `FileService.generateUploadPath()` 的逻辑
- **问题**:时间戳不一致导致路径不匹配
### 2. extractPathFromUrl() 方法
- **状态**:未使用
- **原因**:创建了但从未调用
- **建议**:删除或实现使用
## ✅ 解决方案
### 方案1从 infra_file 表查询 path推荐
**优点**
- 路径100%准确
- 可以关联 file_id
- 逻辑清晰
**实现**
```java
// 上传后,通过 URL 查询 infra_file 表获取 path
FileDO infraFile = fileMapper.selectOne(
new LambdaQueryWrapperX<FileDO>()
.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%准确。

View File

@@ -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. 上传到OSSFileService.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 提取路径
- ✅ 可行性:完全可行
**建议**
- 当前实现已经是最优方案
- 路径准确性有保障
- 代码逻辑清晰,无冗余

View File

@@ -0,0 +1,76 @@
# 文件上传策略分析
## 🎯 业界成熟方案先上传OSS再存数据库
### 方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|------|------|------|----------|
| **先上传OSS再存数据库** ✅ | 1. OSS上传失败不影响数据库<br>2. 数据库事务可快速回滚<br>3. 用户体验好(文件已上传)<br>4. 孤立文件可定时清理 | 1. 数据库失败会产生孤立文件<br>2. 需要清理机制 | **推荐方案**(业界主流) |
| 先存数据库再上传OSS | 1. 数据库失败不会上传OSS<br>2. 不会产生孤立文件 | 1. OSS上传失败需要回滚数据库<br>2. 数据库事务时间长<br>3. 用户体验差 | 不推荐 |
### 为什么选择"先上传OSS再存数据库"
1. **性能优势**
- OSS上传是外部服务调用不应该阻塞数据库事务
- 数据库事务时间短,减少锁竞争
2. **可靠性优势**
- OSS上传失败直接返回错误不产生脏数据
- 数据库保存失败OSS文件可以后续清理定时任务
3. **用户体验优势**
- 文件已上传成功,即使数据库失败,文件还在
- 可以重试数据库保存,无需重新上传
4. **业界实践**
- 阿里云、腾讯云、AWS 等主流云服务都推荐此方案
- 大多数开源项目采用此方案
### 当前实现方案
```
1. 校验(文件分类、配额)
2. 读取文件内容
3. 上传到OSSFileService.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上传失败率
- 监控数据库保存失败率
- 监控孤立文件数量

View File

@@ -309,6 +309,12 @@
<version>2025.09-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-module-member</artifactId>
<version>2025.09-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

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