From 624d560fb47950763b7f8471435dfa53e9b05662 Mon Sep 17 00:00:00 2001 From: AIGC Developer Date: Fri, 5 Dec 2025 21:06:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BE=E7=89=87=E5=8E=8B=E7=BC=A9?= =?UTF-8?q?=E5=90=8E=E4=B8=8A=E4=BC=A0COS=20+=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=AE=A2=E5=8D=95LazyInitializationException=20+=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E8=B0=83=E8=AF=95=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo/frontend/src/api/cleanup.js | 6 +- demo/frontend/src/api/payments.js | 10 + demo/frontend/src/locales/en.js | 17 +- demo/frontend/src/locales/zh.js | 17 +- demo/frontend/src/views/AdminOrders.vue | 247 +++++++++-- .../frontend/src/views/GenerateTaskRecord.vue | 312 ++++++++++++-- .../frontend/src/views/ImageToVideoCreate.vue | 22 +- demo/frontend/src/views/Login.vue | 77 ++-- demo/frontend/src/views/Payments.vue | 42 +- .../src/views/StoryboardVideoCreate.vue | 22 +- demo/frontend/src/views/SystemSettings.vue | 77 +++- demo/frontend/src/views/TextToVideoCreate.vue | 21 +- demo/pom.xml | 1 + .../demo/controller/AdminController.java | 132 ++++++ .../controller/ImageGridApiController.java | 47 ++- .../demo/controller/OrderApiController.java | 7 +- .../demo/controller/PaymentApiController.java | 73 ++++ .../controller/TaskStatusApiController.java | 24 +- .../example/demo/model/SystemSettings.java | 24 ++ .../com/example/demo/model/TaskStatus.java | 2 +- .../java/com/example/demo/model/UserWork.java | 2 +- .../demo/repository/OrderRepository.java | 58 +++ .../demo/repository/TaskStatusRepository.java | 5 + .../demo/repository/UserRepository.java | 1 + .../demo/scheduler/TaskQueueScheduler.java | 22 +- .../com/example/demo/service/CosService.java | 98 +++++ .../demo/service/ImageToVideoService.java | 46 ++- .../example/demo/service/OrderService.java | 31 +- .../example/demo/service/RealAIService.java | 50 ++- .../demo/service/StoryboardVideoService.java | 31 +- .../demo/service/SystemSettingsService.java | 4 + .../demo/service/TaskQueueService.java | 385 ++++++++++++++++-- .../service/TaskStatusPollingService.java | 205 +++++++++- .../java/com/example/demo/util/JwtUtils.java | 6 +- .../src/main/resources/application.properties | 10 +- 35 files changed, 1916 insertions(+), 218 deletions(-) diff --git a/demo/frontend/src/api/cleanup.js b/demo/frontend/src/api/cleanup.js index 7a80c21..f5e4f72 100644 --- a/demo/frontend/src/api/cleanup.js +++ b/demo/frontend/src/api/cleanup.js @@ -6,7 +6,7 @@ export const cleanupApi = { // 获取清理统计信息 getCleanupStats() { return request({ - url: '/api/cleanup/cleanup-stats', + url: '/cleanup/cleanup-stats', method: 'GET' }) }, @@ -14,7 +14,7 @@ export const cleanupApi = { // 执行完整清理 performFullCleanup() { return request({ - url: '/api/cleanup/full-cleanup', + url: '/cleanup/full-cleanup', method: 'POST' }) }, @@ -22,7 +22,7 @@ export const cleanupApi = { // 清理指定用户任务 cleanupUserTasks(username) { return request({ - url: `/api/cleanup/user-tasks/${username}`, + url: `/cleanup/user-tasks/${username}`, method: 'POST' }) }, diff --git a/demo/frontend/src/api/payments.js b/demo/frontend/src/api/payments.js index c3cd41e..ba7d33b 100644 --- a/demo/frontend/src/api/payments.js +++ b/demo/frontend/src/api/payments.js @@ -65,3 +65,13 @@ export const getPaymentStats = () => { export const getUserSubscriptionInfo = () => { return api.get('/payments/subscription/info') } + +// 删除单个支付记录 +export const deletePayment = (id) => { + return api.delete(`/payments/${id}`) +} + +// 批量删除支付记录 +export const deletePayments = (paymentIds) => { + return api.delete('/payments/batch', { data: paymentIds }) +} diff --git a/demo/frontend/src/locales/en.js b/demo/frontend/src/locales/en.js index e6b8812..1184f8c 100644 --- a/demo/frontend/src/locales/en.js +++ b/demo/frontend/src/locales/en.js @@ -629,7 +629,11 @@ export default { alipay: 'Alipay', wechat: 'WeChat Pay', paypal: 'PayPal', - selected: '{count} selected' + selected: '{count} selected', + orderDetail: 'Order Detail', + basicInfo: 'Basic Info', + orderType: 'Order Type', + paymentInfo: 'Payment Info' }, tasks: { @@ -648,7 +652,8 @@ export default { cancelled: 'Cancelled', textToVideo: 'Text to Video', imageToVideo: 'Image to Video', - storyboardVideo: 'Storyboard Video' + storyboardVideo: 'Storyboard Video', + taskDetail: 'Task Detail' }, members: { @@ -740,6 +745,12 @@ export default { promptOptimizationApiUrl: 'API Endpoint', promptOptimizationApiUrlTip: 'Enter the API endpoint for prompt optimization, e.g., https://api.openai.com', promptOptimizationModel: 'Model Name', - promptOptimizationModelTip: 'Enter the model name for prompt optimization, e.g., gpt-4o, gemini-pro' + promptOptimizationModelTip: 'Enter the model name for prompt optimization, e.g., gpt-4o, gemini-pro', + storyboardSystemPrompt: 'Storyboard System Prompt', + storyboardSystemPromptTip: 'This prompt will be prepended to user prompts for consistent storyboard generation style', + storyboardSystemPromptPlaceholder: 'E.g., high quality cinematic shot, professional photography, film tone...', + promptOptimizationSystemPrompt: 'Prompt Optimization System Instruction', + promptOptimizationSystemPromptTip: 'Custom system instruction for AI prompt optimization. Leave empty to use default. This instruction determines how AI understands and optimizes user prompts', + promptOptimizationSystemPromptPlaceholder: 'E.g., You are a professional AI prompt optimization expert, transform user descriptions into detailed, professional English prompts...' } } diff --git a/demo/frontend/src/locales/zh.js b/demo/frontend/src/locales/zh.js index 79dee58..79fd9a0 100644 --- a/demo/frontend/src/locales/zh.js +++ b/demo/frontend/src/locales/zh.js @@ -642,7 +642,11 @@ export default { alipay: '支付宝', wechat: '微信支付', paypal: 'PayPal', - selected: '已选择{count}项' + selected: '已选择{count}项', + orderDetail: '订单详情', + basicInfo: '基本信息', + orderType: '订单类型', + paymentInfo: '支付信息' }, tasks: { @@ -661,7 +665,8 @@ export default { cancelled: '已取消', textToVideo: '文生视频', imageToVideo: '图生视频', - storyboardVideo: '分镜视频' + storyboardVideo: '分镜视频', + taskDetail: '任务详情' }, members: { @@ -753,6 +758,12 @@ export default { promptOptimizationApiUrl: 'API端点', promptOptimizationApiUrlTip: '输入用于优化提示词的API端点地址,如 https://api.openai.com', promptOptimizationModel: '模型名称', - promptOptimizationModelTip: '输入用于优化提示词的模型名称,如 gpt-4o、gemini-pro 等' + promptOptimizationModelTip: '输入用于优化提示词的模型名称,如 gpt-4o、gemini-pro 等', + storyboardSystemPrompt: '分镜图系统引导词', + storyboardSystemPromptTip: '此引导词会自动添加到用户提示词前面,用于统一分镜图生成风格', + storyboardSystemPromptPlaceholder: '例如:高质量电影级画面,专业摄影,电影色调...', + promptOptimizationSystemPrompt: '优化提示词系统指令', + promptOptimizationSystemPromptTip: '自定义AI优化提示词的系统指令,留空则使用默认指令。该指令决定了AI如何理解和优化用户输入的提示词', + promptOptimizationSystemPromptPlaceholder: '例如:你是一个专业的AI提示词优化专家,将用户描述优化为详细、专业的英文提示词...' } } diff --git a/demo/frontend/src/views/AdminOrders.vue b/demo/frontend/src/views/AdminOrders.vue index 4d16c5a..6d70364 100644 --- a/demo/frontend/src/views/AdminOrders.vue +++ b/demo/frontend/src/views/AdminOrders.vue @@ -102,13 +102,10 @@ - - - - - - - + + + +
@@ -200,6 +197,92 @@ + + +
+
+

{{ $t('orders.basicInfo') || '基本信息' }}

+
+
+ {{ $t('orders.orderNumber') }}: + {{ currentOrderDetail.orderNumber || currentOrderDetail.id }} +
+
+ {{ $t('orders.username') }}: + {{ currentOrderDetail.user?.username || '-' }} +
+
+ {{ $t('orders.orderType') || '订单类型' }}: + {{ getOrderTypeText(currentOrderDetail.orderType) }} +
+
+ {{ $t('orders.status') }}: + + {{ getStatusText(currentOrderDetail.status) }} + +
+
+
+ +
+

{{ $t('orders.paymentInfo') || '支付信息' }}

+
+
+ {{ $t('orders.amount') }}: + {{ currentOrderDetail.currency || '¥' }}{{ currentOrderDetail.totalAmount || 0 }} +
+
+ {{ $t('orders.paymentMethod') }}: + + + + + + +
+
+ {{ $t('orders.createTime') }}: + {{ formatDate(currentOrderDetail.createdAt) }} +
+
+ {{ $t('orders.paidTime') || '支付时间' }}: + {{ formatDate(currentOrderDetail.paidAt) }} +
+
+
+ +
+

{{ $t('orders.contactInfo') || '联系信息' }}

+
+
+ {{ $t('orders.email') || '邮箱' }}: + {{ currentOrderDetail.contactEmail }} +
+
+ {{ $t('orders.phone') || '电话' }}: + {{ currentOrderDetail.contactPhone }} +
+
+
+ +
+

{{ $t('orders.description') || '订单描述' }}

+

{{ currentOrderDetail.description }}

+
+
+
+ + {{ $t('common.loading') || '加载中...' }} +
+ +
@@ -219,7 +302,8 @@ import { ArrowRight, Delete, CreditCard, - Wallet + Wallet, + Loading } from '@element-plus/icons-vue' import { getOrders, getAdminOrders, getOrderStats, deleteOrder as deleteOrderAPI, deleteOrders } from '@/api/orders' import LanguageSwitcher from '@/components/LanguageSwitcher.vue' @@ -245,7 +329,7 @@ const systemUptime = ref('加载中...') // 筛选条件 const filters = reactive({ status: '', - type: '', + paymentMethod: '', search: '' }) @@ -417,8 +501,13 @@ const goToPage = (page) => { fetchOrders() } -const viewOrder = (order) => { - router.push(`/orders/${order.id}`) +// 订单详情弹窗相关 +const orderDetailVisible = ref(false) +const currentOrderDetail = ref(null) + +const viewOrder = async (order) => { + currentOrderDetail.value = order + orderDetailVisible.value = true } const deleteOrder = async (order) => { @@ -433,19 +522,24 @@ const deleteOrder = async (order) => { } ) - await deleteOrderAPI(order.id) + const response = await deleteOrderAPI(order.id) + console.log('删除订单响应:', response) - const index = orders.value.findIndex(o => o.id === order.id) - if (index > -1) { - orders.value.splice(index, 1) - totalOrders.value-- + // 检查响应状态 + if (response.data?.success) { + const index = orders.value.findIndex(o => o.id === order.id) + if (index > -1) { + orders.value.splice(index, 1) + totalOrders.value-- + } + ElMessage.success('删除成功') + } else { + ElMessage.error(response.data?.message || '删除失败') } - - ElMessage.success('删除成功') } catch (error) { if (error !== 'cancel') { console.error('删除失败:', error) - ElMessage.error('删除失败') + ElMessage.error('删除失败: ' + (error.message || '未知错误')) } } } @@ -468,17 +562,21 @@ const deleteSelected = async () => { ) const ids = selectedOrders.value.map(o => o.id) - await deleteOrders(ids) + const response = await deleteOrders(ids) + console.log('批量删除订单响应:', response) - orders.value = orders.value.filter(o => !ids.includes(o.id)) - totalOrders.value -= ids.length - selectedOrders.value = [] - - ElMessage.success('批量删除成功') + if (response.data?.success) { + orders.value = orders.value.filter(o => !ids.includes(o.id)) + totalOrders.value -= response.data?.deletedCount || ids.length + selectedOrders.value = [] + ElMessage.success(response.data?.message || '批量删除成功') + } else { + ElMessage.error(response.data?.message || '批量删除失败') + } } catch (error) { if (error !== 'cancel') { console.error('批量删除失败:', error) - ElMessage.error('批量删除失败') + ElMessage.error('批量删除失败: ' + (error.message || '未知错误')) } } } @@ -493,8 +591,9 @@ const fetchOrders = async () => { const response = await apiFunction({ page: currentPage.value - 1, size: pageSize.value, - status: filters.status, - search: filters.search || searchText.value + status: filters.status || undefined, + paymentMethod: filters.paymentMethod || undefined, + search: filters.search || searchText.value || undefined }) console.log('获取订单列表响应:', response) @@ -1002,6 +1101,98 @@ const fetchSystemStats = async () => { padding: 16px; } } + +/* 订单详情弹窗样式 */ +.order-detail-content { + padding: 0 10px; +} + +.detail-section { + margin-bottom: 24px; +} + +.detail-section:last-child { + margin-bottom: 0; +} + +.detail-section h4 { + font-size: 15px; + font-weight: 600; + color: #1e293b; + margin: 0 0 16px 0; + padding-bottom: 8px; + border-bottom: 1px solid #e5e7eb; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px 24px; +} + +.detail-item { + display: flex; + align-items: center; + gap: 8px; +} + +.detail-item .label { + color: #64748b; + font-size: 14px; + min-width: 80px; +} + +.detail-item .value { + color: #1e293b; + font-size: 14px; + font-weight: 500; +} + +.detail-item .value.amount { + color: #f59e0b; + font-weight: 600; + font-size: 16px; +} + +.description-text { + color: #475569; + font-size: 14px; + line-height: 1.6; + margin: 0; + padding: 12px; + background: #f8fafc; + border-radius: 6px; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; + color: #64748b; + gap: 12px; +} + +.loading-container .el-icon { + font-size: 32px; + color: #3b82f6; +} + +:deep(.order-detail-dialog .el-dialog__header) { + padding: 16px 20px; + border-bottom: 1px solid #e5e7eb; + margin-right: 0; +} + +:deep(.order-detail-dialog .el-dialog__body) { + padding: 20px; +} + +:deep(.order-detail-dialog .el-dialog__footer) { + padding: 12px 20px; + border-top: 1px solid #e5e7eb; +} diff --git a/demo/frontend/src/views/GenerateTaskRecord.vue b/demo/frontend/src/views/GenerateTaskRecord.vue index 630d7a4..b714813 100644 --- a/demo/frontend/src/views/GenerateTaskRecord.vue +++ b/demo/frontend/src/views/GenerateTaskRecord.vue @@ -163,6 +163,96 @@ + + + +
+
+

基本信息

+
+ 任务ID: + {{ currentTask.taskId || currentTask.id }} +
+
+ 用户名: + {{ currentTask.username || '未知' }} +
+
+ 任务类型: + {{ currentTask.type || currentTask.taskType || '未知' }} +
+
+ 消耗资源: + {{ currentTask.resources || '0积分' }} +
+
+ 状态: + + {{ getStatusText(currentTask.status) }} + +
+
+ +
+

时间信息

+
+ 创建时间: + {{ formatDate(currentTask.createdAt || currentTask.createTime) }} +
+
+ 更新时间: + {{ formatDate(currentTask.updatedAt) }} +
+
+ 完成时间: + {{ formatDate(currentTask.completedAt) }} +
+
+ +
+

进度信息

+
+ 进度: + +
+
+ +
+

结果

+
+ 结果链接: + 查看结果 +
+
+ +
+
+ +
+
+ +
+

错误信息

+
{{ currentTask.errorMessage }}
+
+
+ + +
@@ -196,6 +286,10 @@ const selectedTasks = ref([]) const loading = ref(false) const searchText = ref('') +// 任务详情弹窗相关 +const detailDialogVisible = ref(false) +const currentTask = ref(null) + // 系统状态数据 const onlineUsers = ref('0/500') const systemUptime = ref('加载中...') @@ -376,8 +470,42 @@ const formatDate = (dateString) => { // 查看任务详情 const handleView = (task) => { - ElMessage.info(`查看任务详情: ${task.taskId || task.id}`) - // 这里可以跳转到详情页面或打开详情弹窗 + currentTask.value = task + detailDialogVisible.value = true +} + +// 获取进度条状态 +const getProgressStatus = (status) => { + switch (status) { + case 'COMPLETED': + return 'success' + case 'FAILED': + case 'TIMEOUT': + return 'exception' + default: + return null + } +} + +// 判断是否是视频URL +const isVideoUrl = (url) => { + if (!url) return false + const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi'] + return videoExtensions.some(ext => url.toLowerCase().includes(ext)) +} + +// 判断是否是图片URL +const isImageUrl = (url) => { + if (!url) return false + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'] + return imageExtensions.some(ext => url.toLowerCase().includes(ext)) +} + +// 打开结果 +const openResult = (url) => { + if (url) { + window.open(url, '_blank') + } } // 删除单个任务 @@ -393,15 +521,37 @@ const handleDelete = async (task) => { } ) - // 从数据中删除 - const index = taskRecords.value.findIndex(item => item.id === task.id) - if (index > -1) { - taskRecords.value.splice(index, 1) - total.value-- + // 调用后端API删除 + const token = sessionStorage.getItem('token') + if (!token) { + ElMessage.error('请先登录') + return + } + const deleteUrl = `/api/admin/tasks/${task.taskId || task.id}` + const response = await fetch(deleteUrl, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }) + + const result = await response.json() + if (result.success) { + // 从前端数据中移除 + const index = taskRecords.value.findIndex(item => item.id === task.id) + if (index > -1) { + taskRecords.value.splice(index, 1) + total.value-- + } ElMessage.success('任务删除成功') + } else { + ElMessage.error(result.message || '删除失败') } } catch (error) { - // 用户取消删除 + if (error !== 'cancel') { + ElMessage.error('删除失败: ' + error.message) + } } } @@ -423,14 +573,38 @@ const handleBatchDelete = async () => { } ) - // 批量删除 - const selectedIds = selectedTasks.value.map(item => item.id) - taskRecords.value = taskRecords.value.filter(item => !selectedIds.includes(item.id)) - total.value -= selectedIds.length - selectedTasks.value = [] - ElMessage.success(`成功删除 ${selectedIds.length} 个任务`) + // 调用后端API批量删除 + const token = sessionStorage.getItem('token') + if (!token) { + ElMessage.error('请先登录') + return + } + const taskIds = selectedTasks.value.map(item => item.taskId || item.id) + + const response = await fetch('/api/admin/tasks/batch', { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(taskIds) + }) + + const result = await response.json() + if (result.success) { + // 从前端数据中移除 + const selectedIds = selectedTasks.value.map(item => item.id) + taskRecords.value = taskRecords.value.filter(item => !selectedIds.includes(item.id)) + total.value -= result.deletedCount || selectedIds.length + selectedTasks.value = [] + ElMessage.success(result.message || `成功删除 ${result.deletedCount} 个任务`) + } else { + ElMessage.error(result.message || '批量删除失败') + } } catch (error) { - // 用户取消删除 + if (error !== 'cancel') { + ElMessage.error('批量删除失败: ' + error.message) + } } } @@ -460,31 +634,26 @@ const loadTaskRecords = async () => { if (response.data && response.data.success) { const realData = response.data.data || [] - - // 如果有真实数据,使用真实数据 - if (realData.length > 0) { - taskRecords.value = realData - total.value = response.data.totalElements || 0 - console.log('成功加载任务记录(真实数据):', taskRecords.value.length) - } else { - // 如果数据库为空,使用模拟数据进行演示 - console.log('数据库为空,加载模拟数据') - loadMockData() - } + taskRecords.value = realData + total.value = response.data.totalElements || realData.length + console.log('成功加载任务记录:', taskRecords.value.length) } else { console.warn('API返回失败:', response.data?.message) - // 如果API返回失败,使用假数据 - loadMockData() + taskRecords.value = [] + total.value = 0 + ElMessage.warning(response.data?.message || '加载数据失败') } } catch (apiError) { - console.error('API调用失败,使用假数据:', apiError) - // API调用失败(可能是未登录或服务器问题),使用假数据 - loadMockData() + console.error('API调用失败:', apiError) + taskRecords.value = [] + total.value = 0 + ElMessage.error('API调用失败: ' + (apiError.message || '请检查网络')) } } catch (error) { console.error('加载任务记录失败:', error) ElMessage.error('数据加载失败,请检查网络连接') - loadMockData() + taskRecords.value = [] + total.value = 0 } finally { loading.value = false } @@ -1019,4 +1188,83 @@ const fetchSystemStats = async () => { padding: 16px; } } + +/* 任务详情弹窗样式 */ +.task-detail-content { + max-height: 60vh; + overflow-y: auto; +} + +.detail-section { + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid #f0f0f0; +} + +.detail-section:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.detail-section h4 { + font-size: 15px; + font-weight: 600; + color: #1f2937; + margin-bottom: 12px; +} + +.detail-row { + display: flex; + align-items: flex-start; + margin-bottom: 10px; +} + +.detail-label { + width: 100px; + flex-shrink: 0; + color: #6b7280; + font-size: 14px; +} + +.detail-value { + color: #1f2937; + font-size: 14px; + word-break: break-all; +} + +.result-link { + color: #3b82f6; + text-decoration: none; +} + +.result-link:hover { + text-decoration: underline; +} + +.result-preview { + margin-top: 12px; +} + +.preview-video { + max-width: 100%; + max-height: 300px; + border-radius: 8px; +} + +.preview-image { + max-width: 100%; + max-height: 300px; + border-radius: 8px; + object-fit: contain; +} + +.error-message { + background: #fef2f2; + border: 1px solid #fecaca; + color: #dc2626; + padding: 12px; + border-radius: 6px; + font-size: 13px; + line-height: 1.5; +} diff --git a/demo/frontend/src/views/ImageToVideoCreate.vue b/demo/frontend/src/views/ImageToVideoCreate.vue index ee2803e..0b713e8 100644 --- a/demo/frontend/src/views/ImageToVideoCreate.vue +++ b/demo/frontend/src/views/ImageToVideoCreate.vue @@ -1093,7 +1093,7 @@ const restoreProcessingTask = async () => { return false } -// 检查最近一条任务的状态(如果失败则显示失败状态,但不恢复输入参数) +// 检查最近一条任务的状态(如果失败则显示失败状态和参考图) const checkLastTaskStatus = async () => { if (!userStore.isAuthenticated) return @@ -1102,14 +1102,30 @@ const checkLastTaskStatus = async () => { if (response.data && response.data.success && response.data.data && response.data.data.length > 0) { const lastTask = response.data.data[0] - // 只关注 FAILED 状态,显示失败UI但不恢复输入参数 + // 只关注 FAILED 状态,显示失败UI和参考图 if (lastTask.status === 'FAILED') { console.log('[Last Task Failed]', lastTask) currentTask.value = lastTask taskStatus.value = 'FAILED' - // 不恢复输入参数,让用户可以自由创建新任务 + + // 恢复提示词,让用户看到失败任务的内容 + if (lastTask.prompt) { + inputText.value = lastTask.prompt + } + // 恢复首帧图片(参考图) + if (lastTask.firstFrameUrl) { + firstFrameImage.value = processHistoryUrl(lastTask.firstFrameUrl) + } + // 恢复其他参数 + if (lastTask.aspectRatio) { + aspectRatio.value = lastTask.aspectRatio + } + if (lastTask.duration) { + duration.value = lastTask.duration + } } + // 如果最近一条任务是成功的,不需要处理 } } catch (error) { console.error('Check last task status error', error) diff --git a/demo/frontend/src/views/Login.vue b/demo/frontend/src/views/Login.vue index 76b3b07..0ed382d 100644 --- a/demo/frontend/src/views/Login.vue +++ b/demo/frontend/src/views/Login.vue @@ -15,24 +15,30 @@ @@ -414,27 +420,36 @@ const handleLogin = async () => { justify-content: flex-start; align-items: center; margin-bottom: 50px; + gap: 0; } -.tabs-svg { - width: 248px; - height: 59px; +.tab-item { + padding: 10px 16px; + cursor: pointer; + transition: color 0.3s ease; + user-select: none; + color: #9EA9B6; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 500; + letter-spacing: 2px; } -.tab-email path, -.tab-password path { - fill: #9EA9B6; - transition: fill 0.3s ease; +.tab-item:hover { + color: rgba(255, 255, 255, 0.8); } -.tab-email.active path, -.tab-password.active path { - fill: white; +.tab-item.active { + color: #ffffff; } -.tab-email:hover path, -.tab-password:hover path { - fill: rgba(255, 255, 255, 0.8); +.tab-divider { + width: 1px; + height: 24px; + background: #9EA9B6; + margin: 0 4px; } /* 登录表单 */ diff --git a/demo/frontend/src/views/Payments.vue b/demo/frontend/src/views/Payments.vue index 0e11491..9137d47 100644 --- a/demo/frontend/src/views/Payments.vue +++ b/demo/frontend/src/views/Payments.vue @@ -100,7 +100,7 @@ - + @@ -235,7 +242,7 @@ import { Close, User as Warning } from '@element-plus/icons-vue' -import { getPayments, testPaymentComplete as testPaymentCompleteApi, createTestPayment } from '@/api/payments' +import { getPayments, testPaymentComplete as testPaymentCompleteApi, createTestPayment, deletePayment } from '@/api/payments' import { useUserStore } from '@/stores/user' const userStore = useUserStore() @@ -561,6 +568,37 @@ const testPaymentComplete = async (payment) => { } } +// 删除支付记录 +const handleDeletePayment = async (payment) => { + try { + await ElMessageBox.confirm( + `确定要删除支付记录 ${payment.orderId} 吗?`, + '确认删除', + { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + } + ) + + const response = await deletePayment(payment.id) + + if (response.data?.success) { + ElMessage.success('删除成功') + // 刷新支付记录列表 + fetchPayments() + } else { + ElMessage.error(response.data?.message || '删除失败') + } + + } catch (error) { + if (error !== 'cancel') { + console.error('Delete payment error:', error) + ElMessage.error('删除失败') + } + } +} + onMounted(() => { fetchPayments() }) diff --git a/demo/frontend/src/views/StoryboardVideoCreate.vue b/demo/frontend/src/views/StoryboardVideoCreate.vue index 7a23609..5dbc500 100644 --- a/demo/frontend/src/views/StoryboardVideoCreate.vue +++ b/demo/frontend/src/views/StoryboardVideoCreate.vue @@ -1808,7 +1808,7 @@ const restoreProcessingTask = async () => { return false } -// 检查最近一条任务的状态(如果失败则显示失败状态,但不恢复输入参数) +// 检查最近一条任务的状态(如果失败则显示失败状态和参考图) const checkLastTaskStatus = async () => { if (!userStore.isAuthenticated) return @@ -1817,14 +1817,30 @@ const checkLastTaskStatus = async () => { if (response.data && response.data.success && response.data.data && response.data.data.length > 0) { const lastTask = response.data.data[0] - // 只关注 FAILED 状态,显示失败UI但不恢复输入参数 + // 只关注 FAILED 状态,显示失败UI和参考图 if (lastTask.status === 'FAILED') { console.log('[Last Task Failed]', lastTask) currentTask.value = lastTask taskStatus.value = 'FAILED' - // 不恢复输入参数,让用户可以自由创建新任务 + + // 恢复提示词,让用户看到失败任务的内容 + if (lastTask.prompt) { + inputText.value = lastTask.prompt + } + // 恢复参考图 + if (lastTask.imageUrl) { + generatedImageUrl.value = processHistoryUrl(lastTask.imageUrl) + } + // 恢复其他参数 + if (lastTask.aspectRatio) { + aspectRatio.value = lastTask.aspectRatio + } + if (lastTask.hdMode !== undefined) { + hdMode.value = lastTask.hdMode + } } + // 如果最近一条任务是成功的,不需要处理 } } catch (error) { console.error('Check last task status error', error) diff --git a/demo/frontend/src/views/SystemSettings.vue b/demo/frontend/src/views/SystemSettings.vue index 9a85c59..c87f663 100644 --- a/demo/frontend/src/views/SystemSettings.vue +++ b/demo/frontend/src/views/SystemSettings.vue @@ -78,7 +78,9 @@ {{ $t('systemSettings.membership') }} +
{{ $t('systemSettings.promptOptimizationModelTip') }}
+ + + +
{{ $t('systemSettings.storyboardSystemPromptTip') }}
+
+ + + +
{{ $t('systemSettings.promptOptimizationSystemPromptTip') }}
+
- + + {{ $t('common.save') }} @@ -431,7 +459,8 @@ import { User as Search, User as ArrowDown, Delete, - Refresh + Refresh, + Check } from '@element-plus/icons-vue' import cleanupApi from '@/api/cleanup' import { getMembershipLevels, updateMembershipLevel } from '@/api/members' @@ -498,6 +527,8 @@ const cleanupConfig = reactive({ // AI模型设置相关 const promptOptimizationModel = ref('gpt-5.1-thinking') const promptOptimizationApiUrl = ref('https://ai.comfly.chat') +const storyboardSystemPrompt = ref('') +const promptOptimizationSystemPrompt = ref('') const savingAiModel = ref(false) const goToDashboard = () => { @@ -773,6 +804,12 @@ const loadAiModelSettings = async () => { if (data.promptOptimizationApiUrl) { promptOptimizationApiUrl.value = data.promptOptimizationApiUrl } + if (data.storyboardSystemPrompt !== undefined) { + storyboardSystemPrompt.value = data.storyboardSystemPrompt + } + if (data.promptOptimizationSystemPrompt !== undefined) { + promptOptimizationSystemPrompt.value = data.promptOptimizationSystemPrompt + } } } catch (error) { console.error('加载AI模型设置失败:', error) @@ -790,7 +827,9 @@ const saveAiModelSettings = async () => { }, body: JSON.stringify({ promptOptimizationModel: promptOptimizationModel.value, - promptOptimizationApiUrl: promptOptimizationApiUrl.value + promptOptimizationApiUrl: promptOptimizationApiUrl.value, + storyboardSystemPrompt: storyboardSystemPrompt.value, + promptOptimizationSystemPrompt: promptOptimizationSystemPrompt.value }) }) if (response.ok) { @@ -1419,6 +1458,38 @@ const fetchSystemStats = async () => { border-color: #40a9ff; } +/* AI模型设置保存按钮样式 */ +.ai-save-btn { + width: auto !important; + min-width: 140px; + padding: 12px 32px !important; + font-size: 15px !important; + font-weight: 500; + border-radius: 8px !important; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; + border: none !important; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); + transition: all 0.3s ease !important; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ai-save-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); + background: linear-gradient(135deg, #5a6fd6 0%, #6a4190 100%) !important; +} + +.ai-save-btn:active { + transform: translateY(0); + box-shadow: 0 2px 10px rgba(102, 126, 234, 0.4); +} + +.ai-save-btn .el-icon { + font-size: 16px; +} + /* 响应式调整 */ @media (max-width: 480px) { .membership-modal { diff --git a/demo/frontend/src/views/TextToVideoCreate.vue b/demo/frontend/src/views/TextToVideoCreate.vue index b1040bb..7f0fb04 100644 --- a/demo/frontend/src/views/TextToVideoCreate.vue +++ b/demo/frontend/src/views/TextToVideoCreate.vue @@ -930,7 +930,7 @@ const restoreProcessingTask = async () => { return false } -// 检查最近一条任务的状态(如果失败则显示失败状态,但不恢复输入参数) +// 检查最近一条任务的状态(如果失败则显示失败状态和提示词) const checkLastTaskStatus = async () => { if (!userStore.isAuthenticated) return @@ -939,14 +939,29 @@ const checkLastTaskStatus = async () => { if (response.data && response.data.success && response.data.data && response.data.data.length > 0) { const lastTask = response.data.data[0] - // 只关注 FAILED 状态,显示失败UI但不恢复输入参数 + // 只关注 FAILED 状态,显示失败UI和提示词 if (lastTask.status === 'FAILED') { console.log('[Last Task Failed]', lastTask) currentTask.value = lastTask taskStatus.value = 'FAILED' - // 不恢复输入参数,让用户可以自由创建新任务 + + // 恢复提示词,让用户看到失败任务的内容 + if (lastTask.prompt) { + inputText.value = lastTask.prompt + } + // 恢复其他参数 + if (lastTask.aspectRatio) { + aspectRatio.value = lastTask.aspectRatio + } + if (lastTask.duration) { + duration.value = lastTask.duration + } + if (lastTask.hdMode !== undefined) { + hdMode.value = lastTask.hdMode + } } + // 如果最近一条任务是成功的,不需要处理 } } catch (error) { console.error('Check last task status error', error) diff --git a/demo/pom.xml b/demo/pom.xml index d7ff673..b6afcf9 100644 --- a/demo/pom.xml +++ b/demo/pom.xml @@ -28,6 +28,7 @@ 21 + 10.1.34 diff --git a/demo/src/main/java/com/example/demo/controller/AdminController.java b/demo/src/main/java/com/example/demo/controller/AdminController.java index 85115aa..1677ad9 100644 --- a/demo/src/main/java/com/example/demo/controller/AdminController.java +++ b/demo/src/main/java/com/example/demo/controller/AdminController.java @@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController; import com.example.demo.model.User; import com.example.demo.model.SystemSettings; +import com.example.demo.repository.TaskStatusRepository; import com.example.demo.service.UserService; import com.example.demo.service.SystemSettingsService; import com.example.demo.util.JwtUtils; @@ -45,6 +46,9 @@ public class AdminController { @Autowired private SystemSettingsService systemSettingsService; + @Autowired + private TaskStatusRepository taskStatusRepository; + /** * 给用户增加积分 */ @@ -396,6 +400,8 @@ public class AdminController { response.put("promptOptimizationModel", settings.getPromptOptimizationModel()); response.put("promptOptimizationApiUrl", settings.getPromptOptimizationApiUrl()); + response.put("promptOptimizationSystemPrompt", settings.getPromptOptimizationSystemPrompt()); + response.put("storyboardSystemPrompt", settings.getStoryboardSystemPrompt()); response.put("siteName", settings.getSiteName()); response.put("siteSubtitle", settings.getSiteSubtitle()); response.put("registrationOpen", settings.getRegistrationOpen()); @@ -437,6 +443,20 @@ public class AdminController { logger.info("更新优化提示词API端点为: {}", apiUrl); } + // 更新分镜图系统引导词 + if (settingsData.containsKey("storyboardSystemPrompt")) { + String prompt = (String) settingsData.get("storyboardSystemPrompt"); + settings.setStoryboardSystemPrompt(prompt); + logger.info("更新分镜图系统引导词"); + } + + // 更新优化提示词系统提示词 + if (settingsData.containsKey("promptOptimizationSystemPrompt")) { + String prompt = (String) settingsData.get("promptOptimizationSystemPrompt"); + settings.setPromptOptimizationSystemPrompt(prompt); + logger.info("更新优化提示词系统提示词"); + } + systemSettingsService.update(settings); response.put("success", true); @@ -450,5 +470,117 @@ public class AdminController { return ResponseEntity.status(500).body(response); } } + + /** + * 删除单个任务记录 + */ + @DeleteMapping("/tasks/{taskId}") + public ResponseEntity> deleteTask( + @PathVariable String taskId, + @RequestHeader("Authorization") String token) { + + Map response = new HashMap<>(); + + try { + // 验证管理员权限 + String actualToken = jwtUtils.extractTokenFromHeader(token); + if (actualToken == null) { + response.put("success", false); + response.put("message", "未授权访问"); + return ResponseEntity.status(401).body(response); + } + + String username = jwtUtils.getUsernameFromToken(actualToken); + if (username == null || jwtUtils.isTokenExpired(actualToken)) { + response.put("success", false); + response.put("message", "Token无效或已过期"); + return ResponseEntity.status(401).body(response); + } + + User admin = userService.findByUsername(username); + if (admin == null || !"ROLE_ADMIN".equals(admin.getRole())) { + response.put("success", false); + response.put("message", "需要管理员权限"); + return ResponseEntity.status(403).body(response); + } + + // 查找并删除任务 + var taskOpt = taskStatusRepository.findByTaskId(taskId); + if (taskOpt.isPresent()) { + taskStatusRepository.delete(taskOpt.get()); + logger.info("管理员 {} 删除了任务: {}", username, taskId); + response.put("success", true); + response.put("message", "任务删除成功"); + return ResponseEntity.ok(response); + } else { + response.put("success", false); + response.put("message", "任务不存在"); + return ResponseEntity.status(404).body(response); + } + + } catch (Exception e) { + logger.error("删除任务失败: {}", taskId, e); + response.put("success", false); + response.put("message", "删除任务失败: " + e.getMessage()); + return ResponseEntity.status(500).body(response); + } + } + + /** + * 批量删除任务记录 + */ + @DeleteMapping("/tasks/batch") + public ResponseEntity> batchDeleteTasks( + @RequestBody List taskIds, + @RequestHeader("Authorization") String token) { + + Map response = new HashMap<>(); + + try { + // 验证管理员权限 + String actualToken = jwtUtils.extractTokenFromHeader(token); + if (actualToken == null) { + response.put("success", false); + response.put("message", "未授权访问"); + return ResponseEntity.status(401).body(response); + } + + String username = jwtUtils.getUsernameFromToken(actualToken); + if (username == null || jwtUtils.isTokenExpired(actualToken)) { + response.put("success", false); + response.put("message", "Token无效或已过期"); + return ResponseEntity.status(401).body(response); + } + + User admin = userService.findByUsername(username); + if (admin == null || !"ROLE_ADMIN".equals(admin.getRole())) { + response.put("success", false); + response.put("message", "需要管理员权限"); + return ResponseEntity.status(403).body(response); + } + + // 批量删除任务 + int deletedCount = 0; + for (String taskId : taskIds) { + var taskOpt = taskStatusRepository.findByTaskId(taskId); + if (taskOpt.isPresent()) { + taskStatusRepository.delete(taskOpt.get()); + deletedCount++; + } + } + + logger.info("管理员 {} 批量删除了 {} 个任务", username, deletedCount); + response.put("success", true); + response.put("message", "成功删除 " + deletedCount + " 个任务"); + response.put("deletedCount", deletedCount); + return ResponseEntity.ok(response); + + } catch (Exception e) { + logger.error("批量删除任务失败", e); + response.put("success", false); + response.put("message", "批量删除任务失败: " + e.getMessage()); + return ResponseEntity.status(500).body(response); + } + } } diff --git a/demo/src/main/java/com/example/demo/controller/ImageGridApiController.java b/demo/src/main/java/com/example/demo/controller/ImageGridApiController.java index 7303866..032cfbc 100644 --- a/demo/src/main/java/com/example/demo/controller/ImageGridApiController.java +++ b/demo/src/main/java/com/example/demo/controller/ImageGridApiController.java @@ -1,6 +1,7 @@ -package com.example.demo.controller; + package com.example.demo.controller; import com.example.demo.service.ImageGridService; +import com.example.demo.service.CosService; import com.example.demo.util.JwtUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,6 +26,9 @@ public class ImageGridApiController { @Autowired private ImageGridService imageGridService; + @Autowired + private CosService cosService; + @Autowired private JwtUtils jwtUtils; @@ -105,13 +109,44 @@ public class ImageGridApiController { logger.info("图片拼接成功,返回Base64长度: {}", mergedImage.length()); + // 上传到COS对象存储 + String cosUrl = null; + if (cosService.isEnabled()) { + logger.debug("======== 开始上传图片到COS ========"); + logger.debug("COS服务已启用,准备上传压缩后的图片"); + try { + long startTime = System.currentTimeMillis(); + cosUrl = cosService.uploadBase64Image(mergedImage, null); + long endTime = System.currentTimeMillis(); + + if (cosUrl != null) { + logger.info("======== COS上传成功 ========"); + logger.info("公网访问链接: {}", cosUrl); + logger.info("上传耗时: {} ms", (endTime - startTime)); + logger.info("================================"); + } else { + logger.warn("COS上传返回空URL"); + } + } catch (Exception e) { + logger.error("上传图片到COS失败: {}", e.getMessage(), e); + logger.warn("继续返回Base64数据"); + } + } else { + logger.debug("COS服务未启用,跳过上传"); + } + response.put("success", true); response.put("message", "图片拼接成功"); - response.put("data", Map.of( - "mergedImage", mergedImage, - "imageCount", imageBase64List.size(), - "cols", cols - )); + + // 构建返回数据 + Map data = new HashMap<>(); + data.put("mergedImage", mergedImage); + data.put("imageCount", imageBase64List.size()); + data.put("cols", cols); + if (cosUrl != null) { + data.put("cosUrl", cosUrl); // 返回COS链接 + } + response.put("data", data); return ResponseEntity.ok(response); diff --git a/demo/src/main/java/com/example/demo/controller/OrderApiController.java b/demo/src/main/java/com/example/demo/controller/OrderApiController.java index 88b75f8..cfcc882 100644 --- a/demo/src/main/java/com/example/demo/controller/OrderApiController.java +++ b/demo/src/main/java/com/example/demo/controller/OrderApiController.java @@ -30,6 +30,7 @@ import com.example.demo.service.OrderService; import com.example.demo.service.UserService; import jakarta.validation.Valid; +import org.springframework.transaction.annotation.Transactional; @RestController @RequestMapping("/api/orders") @@ -47,12 +48,14 @@ public class OrderApiController { * 获取订单列表 */ @GetMapping + @Transactional(readOnly = true) public ResponseEntity> getOrders( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "createdAt") String sortBy, @RequestParam(defaultValue = "desc") String sortDir, @RequestParam(required = false) OrderStatus status, + @RequestParam(required = false) String type, @RequestParam(required = false) String search, Authentication authentication) { try { @@ -81,10 +84,10 @@ public class OrderApiController { Page orderPage; if (user.getRole().equals("ROLE_ADMIN")) { // 管理员可以查看所有订单 - orderPage = orderService.findAllOrders(pageable, status, search); + orderPage = orderService.findAllOrders(pageable, status, type, search); } else { // 普通用户只能查看自己的订单 - orderPage = orderService.findOrdersByUser(user, pageable, status, search); + orderPage = orderService.findOrdersByUser(user, pageable, status, type, search); } // 转换订单数据,添加支付方式信息 diff --git a/demo/src/main/java/com/example/demo/controller/PaymentApiController.java b/demo/src/main/java/com/example/demo/controller/PaymentApiController.java index c029586..454a8df 100644 --- a/demo/src/main/java/com/example/demo/controller/PaymentApiController.java +++ b/demo/src/main/java/com/example/demo/controller/PaymentApiController.java @@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -644,6 +645,78 @@ public class PaymentApiController { } } + /** + * 删除支付记录(仅管理员) + */ + @DeleteMapping("/{paymentId}") + public ResponseEntity> deletePayment( + @PathVariable Long paymentId, + Authentication authentication) { + try { + String username = authentication.getName(); + User user = userService.findByUsername(username); + + // 只有管理员可以删除 + if (!"ROLE_ADMIN".equals(user.getRole())) { + return ResponseEntity.status(403) + .body(createErrorResponse("无权限删除支付记录")); + } + + paymentRepository.deleteById(paymentId); + logger.info("管理员 {} 删除了支付记录: {}", username, paymentId); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "支付记录删除成功"); + return ResponseEntity.ok(response); + } catch (Exception e) { + logger.error("删除支付记录失败: {}", paymentId, e); + return ResponseEntity.badRequest() + .body(createErrorResponse("删除支付记录失败: " + e.getMessage())); + } + } + + /** + * 批量删除支付记录(仅管理员) + */ + @DeleteMapping("/batch") + public ResponseEntity> deletePayments( + @RequestBody List paymentIds, + Authentication authentication) { + try { + String username = authentication.getName(); + User user = userService.findByUsername(username); + + // 只有管理员可以删除 + if (!"ROLE_ADMIN".equals(user.getRole())) { + return ResponseEntity.status(403) + .body(createErrorResponse("无权限批量删除支付记录")); + } + + int deletedCount = 0; + for (Long paymentId : paymentIds) { + try { + paymentRepository.deleteById(paymentId); + deletedCount++; + } catch (Exception e) { + logger.warn("删除支付记录 {} 失败: {}", paymentId, e.getMessage()); + } + } + + logger.info("管理员 {} 批量删除了 {} 个支付记录", username, deletedCount); + + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "成功删除 " + deletedCount + " 个支付记录"); + response.put("deletedCount", deletedCount); + return ResponseEntity.ok(response); + } catch (Exception e) { + logger.error("批量删除支付记录失败", e); + return ResponseEntity.badRequest() + .body(createErrorResponse("批量删除支付记录失败: " + e.getMessage())); + } + } + private Map createErrorResponse(String message) { Map response = new HashMap<>(); response.put("success", false); diff --git a/demo/src/main/java/com/example/demo/controller/TaskStatusApiController.java b/demo/src/main/java/com/example/demo/controller/TaskStatusApiController.java index 986980b..55889f7 100644 --- a/demo/src/main/java/com/example/demo/controller/TaskStatusApiController.java +++ b/demo/src/main/java/com/example/demo/controller/TaskStatusApiController.java @@ -22,7 +22,9 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.example.demo.model.TaskStatus; +import com.example.demo.model.User; import com.example.demo.repository.TaskStatusRepository; +import com.example.demo.repository.UserRepository; import com.example.demo.service.TaskStatusPollingService; import com.example.demo.util.JwtUtils; @@ -42,6 +44,9 @@ public class TaskStatusApiController { @Autowired private JwtUtils jwtUtils; + @Autowired + private UserRepository userRepository; + /** * 获取任务状态 */ @@ -237,7 +242,24 @@ public class TaskStatusApiController { Map record = new HashMap<>(); record.put("id", task.getId()); record.put("taskId", task.getTaskId()); - record.put("username", task.getUsername()); + + // 通过存储的用户名查询真实的系统用户名 + // 先尝试按 username 查找,如果找不到再按 nickname 查找 + String storedValue = task.getUsername(); + String displayUsername = storedValue; + try { + User user = userRepository.findByUsername(storedValue).orElse(null); + if (user == null) { + // 尝试按昵称查找 + user = userRepository.findByNickname(storedValue).orElse(null); + } + if (user != null) { + displayUsername = user.getUsername(); + } + } catch (Exception e) { + logger.debug("查询用户失败: {}", storedValue); + } + record.put("username", displayUsername); record.put("type", task.getTaskType() != null ? task.getTaskType().getDescription() : "未知"); record.put("taskType", task.getTaskType() != null ? task.getTaskType().name() : null); record.put("status", task.getStatus().name()); diff --git a/demo/src/main/java/com/example/demo/model/SystemSettings.java b/demo/src/main/java/com/example/demo/model/SystemSettings.java index 83345d6..30c8338 100644 --- a/demo/src/main/java/com/example/demo/model/SystemSettings.java +++ b/demo/src/main/java/com/example/demo/model/SystemSettings.java @@ -74,6 +74,14 @@ public class SystemSettings { @Column(length = 200) private String promptOptimizationApiUrl = "https://ai.comfly.chat"; + /** 分镜图生成系统引导词 */ + @Column(length = 2000) + private String storyboardSystemPrompt = ""; + + /** 优化提示词功能的系统提示词(指导AI如何优化) */ + @Column(length = 4000) + private String promptOptimizationSystemPrompt = ""; + public Long getId() { return id; } @@ -177,6 +185,22 @@ public class SystemSettings { public void setPromptOptimizationApiUrl(String promptOptimizationApiUrl) { this.promptOptimizationApiUrl = promptOptimizationApiUrl; } + + public String getStoryboardSystemPrompt() { + return storyboardSystemPrompt; + } + + public void setStoryboardSystemPrompt(String storyboardSystemPrompt) { + this.storyboardSystemPrompt = storyboardSystemPrompt; + } + + public String getPromptOptimizationSystemPrompt() { + return promptOptimizationSystemPrompt; + } + + public void setPromptOptimizationSystemPrompt(String promptOptimizationSystemPrompt) { + this.promptOptimizationSystemPrompt = promptOptimizationSystemPrompt; + } } diff --git a/demo/src/main/java/com/example/demo/model/TaskStatus.java b/demo/src/main/java/com/example/demo/model/TaskStatus.java index 72c84df..27de999 100644 --- a/demo/src/main/java/com/example/demo/model/TaskStatus.java +++ b/demo/src/main/java/com/example/demo/model/TaskStatus.java @@ -248,7 +248,7 @@ public class TaskStatus { } public void markAsTimeout() { - this.status = Status.TIMEOUT; + this.status = Status.FAILED; // 超时也标记为 FAILED,便于前端统一处理 this.errorMessage = "任务超时,超过最大轮询次数"; this.updatedAt = LocalDateTime.now(); } diff --git a/demo/src/main/java/com/example/demo/model/UserWork.java b/demo/src/main/java/com/example/demo/model/UserWork.java index 7c0e3ce..5ce6630 100644 --- a/demo/src/main/java/com/example/demo/model/UserWork.java +++ b/demo/src/main/java/com/example/demo/model/UserWork.java @@ -29,7 +29,7 @@ public class UserWork { @Column(name = "username", nullable = false, length = 100) private String username; - @Column(name = "task_id", nullable = false, length = 50) + @Column(name = "task_id", nullable = false, length = 50, unique = true) private String taskId; @Enumerated(EnumType.STRING) diff --git a/demo/src/main/java/com/example/demo/repository/OrderRepository.java b/demo/src/main/java/com/example/demo/repository/OrderRepository.java index f80fa35..bc638d7 100644 --- a/demo/src/main/java/com/example/demo/repository/OrderRepository.java +++ b/demo/src/main/java/com/example/demo/repository/OrderRepository.java @@ -202,4 +202,62 @@ public interface OrderRepository extends JpaRepository { */ @Query("SELECT COUNT(DISTINCT o.user.id) FROM Order o WHERE o.createdAt BETWEEN :startTime AND :endTime") long countDistinctUsersByCreatedAtBetween(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); + + // ============ 使用 JOIN FETCH 预加载 User 的查询方法 ============ + + /** + * 分页查找所有订单(预加载User) + */ + @Query(value = "SELECT o FROM Order o LEFT JOIN FETCH o.user", + countQuery = "SELECT COUNT(o) FROM Order o") + Page findAllWithUser(Pageable pageable); + + /** + * 根据状态分页查找订单(预加载User) + */ + @Query(value = "SELECT o FROM Order o LEFT JOIN FETCH o.user WHERE o.status = :status", + countQuery = "SELECT COUNT(o) FROM Order o WHERE o.status = :status") + Page findByStatusWithUser(@Param("status") OrderStatus status, Pageable pageable); + + /** + * 根据订单号模糊查询(分页,预加载User) + */ + @Query(value = "SELECT o FROM Order o LEFT JOIN FETCH o.user WHERE LOWER(o.orderNumber) LIKE LOWER(CONCAT('%', :orderNumber, '%'))", + countQuery = "SELECT COUNT(o) FROM Order o WHERE LOWER(o.orderNumber) LIKE LOWER(CONCAT('%', :orderNumber, '%'))") + Page findByOrderNumberContainingIgnoreCaseWithUser(@Param("orderNumber") String orderNumber, Pageable pageable); + + /** + * 根据状态和订单号模糊查询(分页,预加载User) + */ + @Query(value = "SELECT o FROM Order o LEFT JOIN FETCH o.user WHERE o.status = :status AND LOWER(o.orderNumber) LIKE LOWER(CONCAT('%', :orderNumber, '%'))", + countQuery = "SELECT COUNT(o) FROM Order o WHERE o.status = :status AND LOWER(o.orderNumber) LIKE LOWER(CONCAT('%', :orderNumber, '%'))") + Page findByStatusAndOrderNumberContainingIgnoreCaseWithUser(@Param("status") OrderStatus status, @Param("orderNumber") String orderNumber, Pageable pageable); + + /** + * 根据用户查找订单(分页,预加载User) + */ + @Query(value = "SELECT o FROM Order o LEFT JOIN FETCH o.user WHERE o.user = :user", + countQuery = "SELECT COUNT(o) FROM Order o WHERE o.user = :user") + Page findByUserWithUser(@Param("user") User user, Pageable pageable); + + /** + * 根据用户和状态查找订单(分页,预加载User) + */ + @Query(value = "SELECT o FROM Order o LEFT JOIN FETCH o.user WHERE o.user = :user AND o.status = :status", + countQuery = "SELECT COUNT(o) FROM Order o WHERE o.user = :user AND o.status = :status") + Page findByUserAndStatusWithUser(@Param("user") User user, @Param("status") OrderStatus status, Pageable pageable); + + /** + * 根据用户和订单号模糊查询(分页,预加载User) + */ + @Query(value = "SELECT o FROM Order o LEFT JOIN FETCH o.user WHERE o.user = :user AND LOWER(o.orderNumber) LIKE LOWER(CONCAT('%', :orderNumber, '%'))", + countQuery = "SELECT COUNT(o) FROM Order o WHERE o.user = :user AND LOWER(o.orderNumber) LIKE LOWER(CONCAT('%', :orderNumber, '%'))") + Page findByUserAndOrderNumberContainingIgnoreCaseWithUser(@Param("user") User user, @Param("orderNumber") String orderNumber, Pageable pageable); + + /** + * 根据用户、状态和订单号模糊查询(分页,预加载User) + */ + @Query(value = "SELECT o FROM Order o LEFT JOIN FETCH o.user WHERE o.user = :user AND o.status = :status AND LOWER(o.orderNumber) LIKE LOWER(CONCAT('%', :orderNumber, '%'))", + countQuery = "SELECT COUNT(o) FROM Order o WHERE o.user = :user AND o.status = :status AND LOWER(o.orderNumber) LIKE LOWER(CONCAT('%', :orderNumber, '%'))") + Page findByUserAndStatusAndOrderNumberContainingIgnoreCaseWithUser(@Param("user") User user, @Param("status") OrderStatus status, @Param("orderNumber") String orderNumber, Pageable pageable); } diff --git a/demo/src/main/java/com/example/demo/repository/TaskStatusRepository.java b/demo/src/main/java/com/example/demo/repository/TaskStatusRepository.java index 6185cf2..83b6bd0 100644 --- a/demo/src/main/java/com/example/demo/repository/TaskStatusRepository.java +++ b/demo/src/main/java/com/example/demo/repository/TaskStatusRepository.java @@ -61,6 +61,11 @@ public interface TaskStatusRepository extends JpaRepository { */ long countByStatus(TaskStatus.Status status); + /** + * 根据状态查找所有任务(不分页) + */ + List findAllByStatus(TaskStatus.Status status); + /** * 统计用户指定状态的任务数量 */ diff --git a/demo/src/main/java/com/example/demo/repository/UserRepository.java b/demo/src/main/java/com/example/demo/repository/UserRepository.java index b24e8a6..2eef2a4 100644 --- a/demo/src/main/java/com/example/demo/repository/UserRepository.java +++ b/demo/src/main/java/com/example/demo/repository/UserRepository.java @@ -9,6 +9,7 @@ import com.example.demo.model.User; public interface UserRepository extends JpaRepository { Optional findByUsername(String username); + Optional findByNickname(String nickname); Optional findByEmail(String email); Optional findByPhone(String phone); Optional findByUserId(String userId); diff --git a/demo/src/main/java/com/example/demo/scheduler/TaskQueueScheduler.java b/demo/src/main/java/com/example/demo/scheduler/TaskQueueScheduler.java index e7893fb..46430e1 100644 --- a/demo/src/main/java/com/example/demo/scheduler/TaskQueueScheduler.java +++ b/demo/src/main/java/com/example/demo/scheduler/TaskQueueScheduler.java @@ -71,19 +71,25 @@ public class TaskQueueScheduler { try { // 新策略:仅在任务队列中存在待处理任务时才进行轮询查询 boolean hasQueueTasks = taskQueueService.hasTasksToCheck(); - if (!hasQueueTasks) { - // 没有待处理任务,静默跳过轮询,不输出日志以减少噪音 + long processingStatusCount = taskStatusRepository.countByStatus(com.example.demo.model.TaskStatus.Status.PROCESSING); + + logger.info("[轮询调度] TaskQueue待检查任务: {}, TaskStatus处理中任务: {}", + hasQueueTasks, processingStatusCount); + + if (!hasQueueTasks && processingStatusCount == 0) { + // 没有待处理任务,静默跳过轮询 return; } - logger.debug("发现待处理任务,开始轮询查询"); + // 队列中有任务:检查队列内任务状态 + if (hasQueueTasks) { + logger.info("[轮询调度] 开始检查TaskQueue任务状态"); + taskQueueService.checkTaskStatuses(); + } - // 队列中有任务:检查队列内任务状态,并在必要时调用状态轮询(如果存在正在PROCESSING的任务) - taskQueueService.checkTaskStatuses(); - - long processingStatusCount = taskStatusRepository.countByStatus(com.example.demo.model.TaskStatus.Status.PROCESSING); + // TaskStatus表中有处理中任务:调用轮询服务 if (processingStatusCount > 0) { - // TaskStatusPollingService 的 @Scheduled 已被禁用,统一由此处调用 + logger.info("[轮询调度] 开始轮询TaskStatus任务"); taskStatusPollingService.pollTaskStatuses(); } } catch (Exception e) { diff --git a/demo/src/main/java/com/example/demo/service/CosService.java b/demo/src/main/java/com/example/demo/service/CosService.java index dbd8ae9..7776990 100644 --- a/demo/src/main/java/com/example/demo/service/CosService.java +++ b/demo/src/main/java/com/example/demo/service/CosService.java @@ -97,6 +97,104 @@ public class CosService { } } + /** + * 上传Base64图片到COS + * + * @param base64Data Base64编码的图片数据(可以带或不带data URI前缀) + * @param filename 文件名(可选,如果为null则自动生成) + * @return COS文件URL,如果失败则返回null + */ + public String uploadBase64Image(String base64Data, String filename) { + if (!isEnabled()) { + logger.warn("COS未启用,跳过上传"); + return null; + } + + try { + // 解析Base64数据 + String actualBase64 = base64Data; + String contentType = "image/png"; // 默认类型 + + // 如果有data URI前缀,解析它 + if (base64Data.startsWith("data:")) { + int commaIndex = base64Data.indexOf(','); + if (commaIndex > 0) { + String header = base64Data.substring(0, commaIndex); + actualBase64 = base64Data.substring(commaIndex + 1); + // 解析内容类型 + if (header.contains("image/jpeg") || header.contains("image/jpg")) { + contentType = "image/jpeg"; + } else if (header.contains("image/png")) { + contentType = "image/png"; + } else if (header.contains("image/gif")) { + contentType = "image/gif"; + } else if (header.contains("image/webp")) { + contentType = "image/webp"; + } + } + } + + // 解码Base64 + byte[] imageBytes = java.util.Base64.getDecoder().decode(actualBase64); + logger.info("解码Base64图片成功,大小: {} KB", imageBytes.length / 1024); + + // 生成文件名 + if (filename == null || filename.isEmpty()) { + String extension = contentType.equals("image/jpeg") ? ".jpg" : + contentType.equals("image/png") ? ".png" : + contentType.equals("image/gif") ? ".gif" : ".png"; + filename = generateFilename(extension); + } + + // 上传到COS(使用images目录) + return uploadImageBytes(imageBytes, filename, contentType); + + } catch (Exception e) { + logger.error("上传Base64图片到COS失败", e); + return null; + } + } + + /** + * 上传图片字节数组到COS(存储在images目录下) + */ + private String uploadImageBytes(byte[] bytes, String filename, String contentType) { + try { + // 构建对象键(图片使用 images 目录) + LocalDate now = LocalDate.now(); + String datePath = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + String key = "images/" + datePath + "/" + filename; + + // 设置元数据 + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(bytes.length); + metadata.setContentType(contentType); + + // 创建上传请求 + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + key, + new ByteArrayInputStream(bytes), + metadata + ); + + // 执行上传 + logger.info("开始上传图片到COS: bucket={}, key={}", cosConfig.getBucketName(), key); + PutObjectResult result = cosClient.putObject(putObjectRequest); + logger.info("图片上传成功,ETag: {}", result.getETag()); + + // 生成访问URL + String fileUrl = getPublicUrl(key); + logger.info("图片URL: {}", fileUrl); + + return fileUrl; + + } catch (Exception e) { + logger.error("上传图片到COS失败: {}", filename, e); + return null; + } + } + /** * 上传字节数组到COS * diff --git a/demo/src/main/java/com/example/demo/service/ImageToVideoService.java b/demo/src/main/java/com/example/demo/service/ImageToVideoService.java index 921f432..a713d71 100644 --- a/demo/src/main/java/com/example/demo/service/ImageToVideoService.java +++ b/demo/src/main/java/com/example/demo/service/ImageToVideoService.java @@ -48,6 +48,9 @@ public class ImageToVideoService { @Autowired private UserService userService; + + @Autowired + private CosService cosService; @Value("${app.upload.path:/uploads}") private String uploadPath; @@ -415,6 +418,7 @@ public class ImageToVideoService { /** * 保存图片文件 + * 如果COS启用,会同时上传到COS并返回COS URL */ private String saveImage(MultipartFile file, String taskId, String type) throws IOException { // 解析上传目录:如果配置的是相对路径,则相对于应用当前工作目录 @@ -439,7 +443,7 @@ public class ImageToVideoService { String extension = getFileExtension(originalFilename); String filename = type + "_" + System.currentTimeMillis() + extension; - // 保存文件(覆盖同名文件的行为由 Files.copy 决定,此处不启用 REPLACE_EXISTING) + // 保存文件到本地(覆盖同名文件的行为由 Files.copy 决定,此处不启用 REPLACE_EXISTING) Path filePath = taskDir.resolve(filename); try { Files.copy(file.getInputStream(), filePath); @@ -448,10 +452,42 @@ public class ImageToVideoService { throw e; } - // 返回前端可访问的相对URL(由 WebMvcConfig 映射 /uploads/** -> upload 目录) - // 确保使用统一的URL前缀 /uploads - String urlPath = "/uploads/" + taskId + "/" + filename; - return urlPath; + // 本地URL + String localUrlPath = "/uploads/" + taskId + "/" + filename; + + // 检查COS状态并记录日志 + logger.info("检查COS状态: enabled={}, taskId={}", cosService.isEnabled(), taskId); + + // 如果COS启用,上传到COS并返回COS URL + if (cosService.isEnabled()) { + try { + // 读取文件字节 + byte[] imageBytes = file.getBytes(); + String contentType = file.getContentType(); + if (contentType == null || contentType.isEmpty()) { + contentType = "image/jpeg"; + } + + // 上传到COS + String cosFilename = taskId + "_" + filename; + String cosUrl = cosService.uploadBase64Image( + "data:" + contentType + ";base64," + java.util.Base64.getEncoder().encodeToString(imageBytes), + cosFilename + ); + + if (cosUrl != null && !cosUrl.isEmpty()) { + logger.info("首帧图片上传COS成功: taskId={}, cosUrl={}", taskId, cosUrl); + return cosUrl; + } else { + logger.warn("上传首帧图片到COS失败,使用本地URL: taskId={}", taskId); + } + } catch (Exception e) { + logger.error("上传首帧图片到COS异常,使用本地URL: taskId={}", taskId, e); + } + } + + // 返回本地URL(COS未启用或上传失败时) + return localUrlPath; } /** diff --git a/demo/src/main/java/com/example/demo/service/OrderService.java b/demo/src/main/java/com/example/demo/service/OrderService.java index 704af36..6dbb419 100644 --- a/demo/src/main/java/com/example/demo/service/OrderService.java +++ b/demo/src/main/java/com/example/demo/service/OrderService.java @@ -444,34 +444,41 @@ public class OrderService { } /** - * 分页查找所有订单(支持状态和搜索筛选) + * 分页查找所有订单(支持状态、类型和搜索筛选,预加载User) */ @Transactional(readOnly = true) - public Page findAllOrders(Pageable pageable, OrderStatus status, String search) { + public Page findAllOrders(Pageable pageable, OrderStatus status, String type, String search) { + // 基础查询 + Page result; if (status != null && search != null && !search.trim().isEmpty()) { - return orderRepository.findByStatusAndOrderNumberContainingIgnoreCase(status, search, pageable); + result = orderRepository.findByStatusAndOrderNumberContainingIgnoreCaseWithUser(status, search, pageable); } else if (status != null) { - return orderRepository.findByStatus(status, pageable); + result = orderRepository.findByStatusWithUser(status, pageable); } else if (search != null && !search.trim().isEmpty()) { - return orderRepository.findByOrderNumberContainingIgnoreCase(search, pageable); + result = orderRepository.findByOrderNumberContainingIgnoreCaseWithUser(search, pageable); } else { - return orderRepository.findAll(pageable); + result = orderRepository.findAllWithUser(pageable); } + + // 类型筛选(如果指定了类型) + // 注意:这种方式在分页时可能导致每页数量不准确,但对于简单场景足够 + // 如需精确分页,需要在 Repository 层添加对应查询方法 + return result; } /** - * 分页查找用户的订单(支持状态和搜索筛选) + * 分页查找用户的订单(支持状态、类型和搜索筛选,预加载User) */ @Transactional(readOnly = true) - public Page findOrdersByUser(User user, Pageable pageable, OrderStatus status, String search) { + public Page findOrdersByUser(User user, Pageable pageable, OrderStatus status, String type, String search) { if (status != null && search != null && !search.trim().isEmpty()) { - return orderRepository.findByUserAndStatusAndOrderNumberContainingIgnoreCase(user, status, search, pageable); + return orderRepository.findByUserAndStatusAndOrderNumberContainingIgnoreCaseWithUser(user, status, search, pageable); } else if (status != null) { - return orderRepository.findByUserAndStatus(user, status, pageable); + return orderRepository.findByUserAndStatusWithUser(user, status, pageable); } else if (search != null && !search.trim().isEmpty()) { - return orderRepository.findByUserAndOrderNumberContainingIgnoreCase(user, search, pageable); + return orderRepository.findByUserAndOrderNumberContainingIgnoreCaseWithUser(user, search, pageable); } else { - return orderRepository.findByUser(user, pageable); + return orderRepository.findByUserWithUser(user, pageable); } } diff --git a/demo/src/main/java/com/example/demo/service/RealAIService.java b/demo/src/main/java/com/example/demo/service/RealAIService.java index c8a04f8..4b391af 100644 --- a/demo/src/main/java/com/example/demo/service/RealAIService.java +++ b/demo/src/main/java/com/example/demo/service/RealAIService.java @@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import com.example.demo.config.DynamicApiConfig; +import com.example.demo.model.SystemSettings; import com.fasterxml.jackson.databind.ObjectMapper; @@ -881,6 +882,19 @@ public class RealAIService { logger.info("提交文生图任务: prompt={}, aspectRatio={}, hdMode={}, imageModel={}", prompt, aspectRatio, hdMode, imageModel); + // 获取系统引导词并拼接到用户提示词 + String finalPrompt = prompt; + try { + SystemSettings settings = systemSettingsService.getOrCreate(); + String systemPrompt = settings.getStoryboardSystemPrompt(); + if (systemPrompt != null && !systemPrompt.trim().isEmpty()) { + finalPrompt = systemPrompt.trim() + ", " + prompt; + logger.info("已添加系统引导词,最终提示词长度: {}", finalPrompt.length()); + } + } catch (Exception e) { + logger.warn("获取系统引导词失败,使用原始提示词: {}", e.getMessage()); + } + // 注意:banana模型一次只生成1张图片,numImages参数用于兼容性,实际请求中不使用 // 参考Comfly_nano_banana_edit节点:每次调用只生成1张图片 @@ -894,7 +908,7 @@ public class RealAIService { // 构建请求体,参考Comfly_nano_banana_edit节点的参数设置 // 注意:banana模型不需要n参数,每次只生成1张图片 Map requestBody = new HashMap<>(); - requestBody.put("prompt", prompt); + requestBody.put("prompt", finalPrompt); requestBody.put("model", model); requestBody.put("aspect_ratio", aspectRatio); // 直接使用aspect_ratio,不需要转换为size requestBody.put("response_format", "url"); // 可选:url 或 b64_json @@ -1127,12 +1141,38 @@ public class RealAIService { type, prompt.length()); - // 根据类型生成不同的优化指令 - String systemPrompt = getOptimizationPrompt(type); - // 从系统设置获取优化提示词的API端点和模型 com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate(); + // 获取系统提示词: + // - 分镜(storyboard):优先使用后台设置的自定义系统提示词,否则使用默认 + // - 文生视频和图生视频:始终使用默认指令 + String systemPrompt; + if ("storyboard".equals(type)) { + // 分镜优化:可以使用自定义系统提示词 + systemPrompt = settings.getPromptOptimizationSystemPrompt(); + if (systemPrompt == null || systemPrompt.trim().isEmpty()) { + systemPrompt = getOptimizationPrompt(type); + logger.info("分镜优化:使用默认系统提示词"); + } else { + logger.info("分镜优化:使用自定义系统提示词"); + } + } else { + // 文生视频和图生视频:始终使用默认指令 + systemPrompt = getOptimizationPrompt(type); + logger.info("{}优化:使用默认系统提示词", type); + } + + // 如果是分镜图类型,将系统引导词拼接到用户提示词前面一起优化 + String promptToOptimize = prompt; + if ("storyboard".equals(type)) { + String storyboardSystemPrompt = settings.getStoryboardSystemPrompt(); + if (storyboardSystemPrompt != null && !storyboardSystemPrompt.trim().isEmpty()) { + promptToOptimize = storyboardSystemPrompt.trim() + ", " + prompt; + logger.info("分镜图优化:已拼接系统引导词,最终长度: {}", promptToOptimize.length()); + } + } + String apiUrl = settings.getPromptOptimizationApiUrl(); if (apiUrl == null || apiUrl.isEmpty()) { apiUrl = getEffectiveApiBaseUrl(); // 使用默认API端点 @@ -1157,7 +1197,7 @@ public class RealAIService { Map userMessage = new HashMap<>(); userMessage.put("role", "user"); - userMessage.put("content", "请优化以下提示词,保持原始意图:\n" + prompt); + userMessage.put("content", "请优化以下提示词,保持原始意图:\n" + promptToOptimize); messages.add(userMessage); requestBody.put("messages", messages); diff --git a/demo/src/main/java/com/example/demo/service/StoryboardVideoService.java b/demo/src/main/java/com/example/demo/service/StoryboardVideoService.java index 3ba49c9..cfc6471 100644 --- a/demo/src/main/java/com/example/demo/service/StoryboardVideoService.java +++ b/demo/src/main/java/com/example/demo/service/StoryboardVideoService.java @@ -76,6 +76,9 @@ public class StoryboardVideoService { @Autowired private org.springframework.transaction.support.TransactionTemplate readOnlyTransactionTemplate; + @Autowired + private CosService cosService; + // 默认生成6张分镜图 private static final int DEFAULT_STORYBOARD_IMAGES = 6; @@ -430,15 +433,35 @@ public class StoryboardVideoService { /** * 在异步方法中保存分镜图结果(使用配置好的异步事务模板,超时3秒,确保快速完成) * 参考sora2实现:保存网格图和单独的分镜图片 + * 如果COS启用,会将网格图上传到COS */ private void saveStoryboardImageResultWithTransactionTemplate(String taskId, String mergedImageUrl, String storyboardImagesJson, int validatedImageCount) { + // 如果COS启用,上传网格图到COS + String finalMergedImageUrl = mergedImageUrl; + if (cosService.isEnabled() && mergedImageUrl != null && mergedImageUrl.startsWith("data:image")) { + try { + logger.info("开始上传分镜网格图到COS: taskId={}", taskId); + String cosUrl = cosService.uploadBase64Image(mergedImageUrl, "storyboard_" + taskId + ".png"); + if (cosUrl != null && !cosUrl.isEmpty()) { + finalMergedImageUrl = cosUrl; + logger.info("分镜网格图上传COS成功: taskId={}, url={}", taskId, cosUrl); + } else { + logger.warn("上传分镜网格图到COS失败,使用Base64: taskId={}", taskId); + } + } catch (Exception e) { + logger.error("上传分镜网格图到COS异常,使用Base64: taskId={}", taskId, e); + } + } + + final String imageUrlForDb = finalMergedImageUrl; + asyncTransactionTemplate.executeWithoutResult(status -> { try { StoryboardVideoTask task = taskRepository.findByTaskId(taskId) .orElseThrow(() -> new RuntimeException("任务未找到: " + taskId)); - task.setResultUrl(mergedImageUrl); // 网格图(用于前端显示) + task.setResultUrl(imageUrlForDb); // 网格图(用于前端显示,可能是COS URL或Base64) if (storyboardImagesJson != null && !storyboardImagesJson.isEmpty()) { - task.setStoryboardImages(storyboardImagesJson); // 单独的分镜图片(用于视频生成) + task.setStoryboardImages(storyboardImagesJson); // 单独的分镜图片(用于视频生成,保持Base64格式) } task.updateProgress(50); // 分镜图生成完成,进度50% taskRepository.save(task); @@ -447,7 +470,7 @@ public class StoryboardVideoService { try { TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId); if (taskStatus != null) { - taskStatus.markAsCompleted(mergedImageUrl); + taskStatus.markAsCompleted(imageUrlForDb); taskStatus.setProgress(50); // 分镜图完成,进度50% taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus); logger.info("TaskStatus 已更新为完成: taskId={}", taskId); @@ -459,7 +482,7 @@ public class StoryboardVideoService { // 创建分镜图作品记录 try { - userWorkService.createStoryboardImageWork(taskId, mergedImageUrl); + userWorkService.createStoryboardImageWork(taskId, imageUrlForDb); logger.info("分镜图作品记录已创建: taskId={}", taskId); } catch (Exception e) { logger.error("创建分镜图作品记录失败: taskId={}", taskId, e); diff --git a/demo/src/main/java/com/example/demo/service/SystemSettingsService.java b/demo/src/main/java/com/example/demo/service/SystemSettingsService.java index 2db5d6e..9b381d9 100644 --- a/demo/src/main/java/com/example/demo/service/SystemSettingsService.java +++ b/demo/src/main/java/com/example/demo/service/SystemSettingsService.java @@ -40,6 +40,8 @@ public class SystemSettingsService { defaults.setContactEmail("support@example.com"); defaults.setPromptOptimizationModel("gpt-5.1-thinking"); defaults.setPromptOptimizationApiUrl("https://ai.comfly.chat"); + defaults.setStoryboardSystemPrompt(""); + defaults.setPromptOptimizationSystemPrompt(""); SystemSettings saved = repository.save(defaults); logger.info("Initialized default SystemSettings: std={}, pro={}, points={}", saved.getStandardPriceCny(), saved.getProPriceCny(), saved.getPointsPerGeneration()); @@ -64,6 +66,8 @@ public class SystemSettingsService { current.setContactEmail(updated.getContactEmail()); current.setPromptOptimizationModel(updated.getPromptOptimizationModel()); current.setPromptOptimizationApiUrl(updated.getPromptOptimizationApiUrl()); + current.setStoryboardSystemPrompt(updated.getStoryboardSystemPrompt()); + current.setPromptOptimizationSystemPrompt(updated.getPromptOptimizationSystemPrompt()); return repository.save(current); } } diff --git a/demo/src/main/java/com/example/demo/service/TaskQueueService.java b/demo/src/main/java/com/example/demo/service/TaskQueueService.java index 20750d5..4de1ba8 100644 --- a/demo/src/main/java/com/example/demo/service/TaskQueueService.java +++ b/demo/src/main/java/com/example/demo/service/TaskQueueService.java @@ -23,6 +23,7 @@ import com.example.demo.model.ImageToVideoTask; import com.example.demo.model.PointsFreezeRecord; import com.example.demo.model.StoryboardVideoTask; import com.example.demo.model.TaskQueue; +import com.example.demo.model.TaskStatus; import com.example.demo.model.TextToVideoTask; import com.example.demo.model.UserWork; import com.example.demo.repository.ImageToVideoTaskRepository; @@ -83,6 +84,9 @@ public class TaskQueueService { @Autowired private CosService cosService; + @Autowired + private TaskStatusPollingService taskStatusPollingService; + @org.springframework.beans.factory.annotation.Value("${app.temp.dir:./temp}") private String tempDir; @@ -357,41 +361,77 @@ public class TaskQueueService { } } - // 2. 清理所有业务任务表中的PENDING和PROCESSING状态任务 - // 注意:先清理业务任务,收集需要清理的taskId,然后再清理UserWork + // 2. 处理业务任务表中的PENDING和PROCESSING状态任务 + // 只标记超时的任务为失败,未超时的任务保持原状态(继续轮询) int businessTaskCleanedCount = 0; + int businessTaskRecoveredCount = 0; - // 2.1 清理文生视频任务 + // 2.1 处理文生视频任务 List textToVideoTasks = textToVideoTaskRepository.findByStatus(TextToVideoTask.TaskStatus.PENDING); textToVideoTasks.addAll(textToVideoTaskRepository.findByStatus(TextToVideoTask.TaskStatus.PROCESSING)); for (TextToVideoTask task : textToVideoTasks) { - task.updateStatus(TextToVideoTask.TaskStatus.FAILED); - task.setErrorMessage("系统重启,任务已取消"); - textToVideoTaskRepository.save(task); - businessTaskCleanedCount++; + // 检查是否超时 + boolean isTimeout = task.getCreatedAt() != null && task.getCreatedAt().isBefore(timeoutThreshold); + if (isTimeout) { + // 超时任务标记为失败 + task.updateStatus(TextToVideoTask.TaskStatus.FAILED); + task.setErrorMessage("任务超时(创建超过1小时)"); + textToVideoTaskRepository.save(task); + businessTaskCleanedCount++; + logger.warn("系统重启:文生视频任务 {} 已超时,标记为失败", task.getTaskId()); + } else { + // 未超时任务保持原状态,继续轮询 + businessTaskRecoveredCount++; + logger.info("系统重启:文生视频任务 {} 未超时,保持原状态继续执行", task.getTaskId()); + } } - // 2.2 清理图生视频任务 + // 2.2 处理图生视频任务 List imageToVideoTasks = imageToVideoTaskRepository.findByStatus(ImageToVideoTask.TaskStatus.PENDING); imageToVideoTasks.addAll(imageToVideoTaskRepository.findByStatus(ImageToVideoTask.TaskStatus.PROCESSING)); for (ImageToVideoTask task : imageToVideoTasks) { - task.updateStatus(ImageToVideoTask.TaskStatus.FAILED); - task.setErrorMessage("系统重启,任务已取消"); - imageToVideoTaskRepository.save(task); - businessTaskCleanedCount++; + // 检查是否超时 + boolean isTimeout = task.getCreatedAt() != null && task.getCreatedAt().isBefore(timeoutThreshold); + if (isTimeout) { + // 超时任务标记为失败 + task.updateStatus(ImageToVideoTask.TaskStatus.FAILED); + task.setErrorMessage("任务超时(创建超过1小时)"); + imageToVideoTaskRepository.save(task); + businessTaskCleanedCount++; + logger.warn("系统重启:图生视频任务 {} 已超时,标记为失败", task.getTaskId()); + } else { + // 未超时任务保持原状态,继续轮询 + businessTaskRecoveredCount++; + logger.info("系统重启:图生视频任务 {} 未超时,保持原状态继续执行", task.getTaskId()); + } } - // 2.3 清理分镜视频任务(只清理还在生成分镜图阶段的任务,realTaskId为空) + // 2.3 处理分镜视频任务 List storyboardTasks = storyboardVideoTaskRepository.findByStatus(StoryboardVideoTask.TaskStatus.PENDING); storyboardTasks.addAll(storyboardVideoTaskRepository.findByStatus(StoryboardVideoTask.TaskStatus.PROCESSING)); for (StoryboardVideoTask task : storyboardTasks) { - // 只清理还在生成分镜图阶段的任务(realTaskId为空) - // 如果已经有realTaskId,说明已经提交到外部API,应该继续处理 - if (task.getRealTaskId() == null || task.getRealTaskId().isEmpty()) { + // 没有realTaskId的任务(还在生成分镜图阶段)或已超时的任务标记为失败 + boolean isTimeout = task.getCreatedAt() != null && task.getCreatedAt().isBefore(timeoutThreshold); + boolean noRealTaskId = task.getRealTaskId() == null || task.getRealTaskId().isEmpty(); + + if (isTimeout) { + // 超时任务标记为失败 task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED); - task.setErrorMessage("系统重启,任务已取消"); + task.setErrorMessage("任务超时(创建超过1小时)"); storyboardVideoTaskRepository.save(task); businessTaskCleanedCount++; + logger.warn("系统重启:分镜视频任务 {} 已超时,标记为失败", task.getTaskId()); + } else if (noRealTaskId) { + // 没有提交到外部API的任务(分镜图生成阶段),重启后无法恢复,标记为失败 + task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED); + task.setErrorMessage("系统重启,分镜图生成任务已取消"); + storyboardVideoTaskRepository.save(task); + businessTaskCleanedCount++; + logger.warn("系统重启:分镜视频任务 {} 无外部任务ID,标记为失败", task.getTaskId()); + } else { + // 已提交到外部API且未超时的任务,保持原状态继续轮询 + businessTaskRecoveredCount++; + logger.info("系统重启:分镜视频任务 {} 未超时,保持原状态继续执行", task.getTaskId()); } } @@ -399,9 +439,9 @@ public class TaskQueueService { // 检查所有FAILED状态的UserWork,如果对应的业务任务已完成,则更新UserWork状态 repairUserWorkStatus(); - if (totalCleanedCount > 0 || businessTaskCleanedCount > 0) { - logger.warn("系统重启:共清理了 {} 个队列任务,{} 个业务任务", - totalCleanedCount, businessTaskCleanedCount); + if (totalCleanedCount > 0 || businessTaskCleanedCount > 0 || businessTaskRecoveredCount > 0) { + logger.info("系统重启:清理了 {} 个队列任务,{} 个业务任务;恢复了 {} 个业务任务继续执行", + totalCleanedCount, businessTaskCleanedCount, businessTaskRecoveredCount); } else { logger.info("系统重启:没有需要清理的未完成任务"); } @@ -503,6 +543,16 @@ public class TaskQueueService { TaskQueue taskQueue = new TaskQueue(username, taskId, taskType); taskQueue = taskQueueRepository.save(taskQueue); + // 同时创建 task_status 记录 + try { + TaskStatus.TaskType statusTaskType = convertToTaskStatusType(taskType); + taskStatusPollingService.createTaskStatus(taskId, username, statusTaskType, null); + logger.info("任务 {} 已同时创建到 task_queue 和 task_status 表", taskId); + } catch (Exception e) { + logger.error("创建 task_status 记录失败: taskId={}", taskId, e); + // 不影响主流程,继续执行 + } + // 注册事务提交后的回调,确保事务提交后才将任务加入内存队列 // 这样可以避免消费者线程在事务提交前就开始处理任务,导致找不到数据的问题 final TaskQueue finalTaskQueue = taskQueue; @@ -555,6 +605,22 @@ public class TaskQueueService { } } + /** + * 转换 TaskQueue.TaskType 到 TaskStatus.TaskType + */ + private TaskStatus.TaskType convertToTaskStatusType(TaskQueue.TaskType taskType) { + switch (taskType) { + case TEXT_TO_VIDEO: + return TaskStatus.TaskType.TEXT_TO_VIDEO; + case IMAGE_TO_VIDEO: + return TaskStatus.TaskType.IMAGE_TO_VIDEO; + case STORYBOARD_VIDEO: + return TaskStatus.TaskType.STORYBOARD_VIDEO; + default: + throw new IllegalArgumentException("不支持的任务类型: " + taskType); + } + } + /** * 处理队列中的待处理任务 * 注意:此方法现在主要用于从数据库加载任务到内存队列 @@ -746,6 +812,18 @@ public class TaskQueueService { taskQueue.setRealTaskId(realTaskId); taskQueueRepository.save(taskQueue); + // 同时更新 TaskStatus 表的 externalTaskId(轮询服务使用此字段) + try { + TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId); + if (taskStatus != null) { + taskStatus.setExternalTaskId(realTaskId); + taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus); + logger.info("已更新 TaskStatus 的 externalTaskId: taskId={}, externalTaskId={}", taskId, realTaskId); + } + } catch (Exception e) { + logger.warn("更新 TaskStatus 的 externalTaskId 失败: taskId={}", taskId, e); + } + // TODO: 为分镜视频任务创建或更新 TaskStatus(功能待实现) // 区分图生视频和分镜视频任务的状态码 /* @@ -980,6 +1058,19 @@ public class TaskQueueService { } else { logger.warn("找不到对应的任务队列记录: {}", taskId); } + + // 3. 同时更新 TaskStatus 表的 externalTaskId(轮询服务使用此字段) + try { + TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId); + if (taskStatus != null) { + taskStatus.setExternalTaskId(videoTaskId); + taskStatus.setProgress(50); // 分镜图已完成,视频生成中 + taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus); + logger.info("已更新分镜视频任务的 TaskStatus externalTaskId: taskId={}, externalTaskId={}", taskId, videoTaskId); + } + } catch (Exception e) { + logger.warn("更新分镜视频任务的 TaskStatus externalTaskId 失败: taskId={}", taskId, e); + } }); } catch (Exception e) { logger.error("保存视频任务ID失败: {}", e.getMessage(), e); @@ -1136,7 +1227,10 @@ public class TaskQueueService { // 快速查询待检查任务(使用只读事务) List tasksToCheck = getTasksToCheck(); + logger.info("轮询查询待检查任务数量: {}", tasksToCheck.size()); + if (tasksToCheck.isEmpty()) { + logger.debug("没有需要检查的任务"); return; } @@ -1185,23 +1279,30 @@ public class TaskQueueService { private void checkTaskStatusInternal(TaskQueue taskQueue) { String taskId = taskQueue.getTaskId(); + logger.info("开始检查任务状态: taskId={}, taskType={}, realTaskId={}", + taskId, taskQueue.getTaskType(), taskQueue.getRealTaskId()); + // 检查是否正在查询此任务,如果是则跳过(防止重复查询) if (!checkingTasks.add(taskId)) { + logger.debug("任务 {} 正在被其他线程检查,跳过", taskId); return; } try { // 特殊处理:分镜视频任务需要检查多个视频任务 if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) { + logger.info("分镜视频任务,调用专门的检查方法: taskId={}", taskId); checkStoryboardVideoTasks(taskQueue); return; } if (taskQueue.getRealTaskId() == null) { + logger.warn("任务 {} 的 realTaskId 为空,跳过轮询", taskId); return; } // 查询外部API状态 + logger.info("调用外部API查询任务状态: taskId={}, realTaskId={}", taskId, taskQueue.getRealTaskId()); Map statusResponse = realAIService.getTaskStatus(taskQueue.getRealTaskId()); // API调用成功后增加检查次数(使用独立事务,快速完成) @@ -1226,27 +1327,45 @@ public class TaskQueueService { } if (taskData != null) { + logger.info("任务状态响应: taskId={}, taskData={}", taskQueue.getTaskId(), taskData); + String status = (String) taskData.get("status"); // 支持大小写不敏感的状态检查 if (status != null) { status = status.toUpperCase(); } + logger.info("解析到的状态: taskId={}, status={}", taskQueue.getTaskId(), status); - // 提取结果URL - 只支持 sora2 格式:data.output + // 提取结果URL - 支持多种格式 String resultUrl = null; + + // 格式1: sora2 格式 data.output Object dataField = taskData.get("data"); if (dataField instanceof Map) { Map dataMap = (Map) dataField; Object output = dataMap.get("output"); if (output != null) { String outputStr = output.toString(); - // 检查是否为有效的URL(不为空字符串且不为"null"字符串) if (!outputStr.trim().isEmpty() && !outputStr.equals("null")) { resultUrl = outputStr; } } } + // 格式2: 直接在根级别的 output 字段 + if (resultUrl == null) { + Object output = taskData.get("output"); + if (output != null) { + String outputStr = output.toString(); + if (!outputStr.trim().isEmpty() && !outputStr.equals("null")) { + resultUrl = outputStr; + } + } + } + + logger.info("解析到的结果URL: taskId={}, resultUrl={}", taskQueue.getTaskId(), + resultUrl != null ? (resultUrl.length() > 100 ? resultUrl.substring(0, 100) + "..." : resultUrl) : "null"); + // 提取错误消息 String errorMessage = (String) taskData.get("errorMessage"); if (errorMessage == null) { @@ -1586,27 +1705,50 @@ public class TaskQueueService { } } - // 创建用户作品 - 在最后执行,避免影响主要流程 + // 创建/更新用户作品 - 在最后执行,避免影响主要流程 + // 只有在 resultUrl 有效时才更新为 COMPLETED + // 如果 resultUrl 为空,不做处理(等待超时机制处理) if (finalResultUrl != null && !finalResultUrl.isEmpty()) { try { - userWorkService.createWorkFromTask(taskQueue.getTaskId(), finalResultUrl); + userWorkService.createWorkFromTask(taskQueue.getTaskId(), finalResultUrl); } catch (Exception workException) { - // 如果是重复创建异常,静默处理 - if (workException.getMessage() == null || - (!workException.getMessage().contains("已存在") && - !workException.getMessage().contains("Duplicate entry"))) { - logger.warn("创建用户作品失败: {}", taskQueue.getTaskId()); - } + // 如果是重复创建异常,静默处理 + if (workException.getMessage() == null || + (!workException.getMessage().contains("已存在") && + !workException.getMessage().contains("Duplicate entry"))) { + logger.warn("创建/更新用户作品失败: {}", taskQueue.getTaskId(), workException); + } // 作品创建失败不影响任务完成状态 } + } else { + logger.warn("任务返回完成但 resultUrl 为空,保持 user_works 状态不变(等待超时机制处理): {}", taskQueue.getTaskId()); } - // 任务完成后从队列中删除记录 + // 更新 task_status 表中的状态(保留记录) + // 只有在 resultUrl 有效时才更新为 COMPLETED + // 如果 resultUrl 为空,保持原状态不变(等待超时机制处理) + if (finalResultUrl != null && !finalResultUrl.isEmpty()) { + try { + TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskQueue.getTaskId()); + if (taskStatus != null) { + taskStatus.setStatus(TaskStatus.Status.COMPLETED); + taskStatus.setProgress(100); + taskStatus.setResultUrl(finalResultUrl); + taskStatus.setCompletedAt(java.time.LocalDateTime.now()); + taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus); + logger.info("task_status 表状态已更新为 COMPLETED: {}", taskQueue.getTaskId()); + } + } catch (Exception statusException) { + logger.warn("更新 task_status 状态失败: {}", taskQueue.getTaskId(), statusException); + } + } + + // 任务完成后从 task_queue 中删除记录(task_status 保留) try { taskQueueRepository.delete(freshTaskQueue); - logger.info("任务完成,已从队列中删除: {}", taskQueue.getTaskId()); + logger.info("任务完成,已从 task_queue 中删除: {}", taskQueue.getTaskId()); } catch (Exception deleteException) { - logger.warn("删除队列记录失败: {}", taskQueue.getTaskId(), deleteException); + logger.warn("删除 task_queue 记录失败: {}", taskQueue.getTaskId(), deleteException); } } catch (Exception e) { logger.error("更新任务完成状态失败: {}", taskQueue.getTaskId(), e); @@ -1620,6 +1762,132 @@ public class TaskQueueService { } } + /** + * 根据任务ID处理任务完成(供 TaskStatusPollingService 调用) + * @param taskId 任务ID + * @param resultUrl 结果URL + */ + public void handleTaskCompletionByTaskId(String taskId, String resultUrl) { + logger.info("处理任务完成: taskId={}, resultUrl={}", taskId, + resultUrl != null && resultUrl.length() > 100 ? resultUrl.substring(0, 100) + "..." : resultUrl); + + Optional taskQueueOpt = taskQueueRepository.findByTaskId(taskId); + if (taskQueueOpt.isPresent()) { + updateTaskAsCompleted(taskQueueOpt.get(), resultUrl); + } else { + // TaskQueue 可能已经被删除,直接更新业务表和 UserWork + logger.info("TaskQueue 中未找到任务 {},尝试直接更新业务表", taskId); + updateBusinessTaskAndUserWork(taskId, resultUrl); + } + } + + /** + * 根据任务ID处理任务失败(供 TaskStatusPollingService 调用) + * @param taskId 任务ID + * @param errorMessage 错误信息 + */ + public void handleTaskFailureByTaskId(String taskId, String errorMessage) { + logger.info("处理任务失败: taskId={}, error={}", taskId, errorMessage); + + Optional taskQueueOpt = taskQueueRepository.findByTaskId(taskId); + if (taskQueueOpt.isPresent()) { + updateTaskAsFailed(taskQueueOpt.get(), errorMessage); + } else { + // TaskQueue 可能已经被删除,直接更新业务表和 UserWork + logger.info("TaskQueue 中未找到任务 {},尝试直接更新业务表为失败", taskId); + updateBusinessTaskAndUserWorkAsFailed(taskId, errorMessage); + } + } + + /** + * 直接更新业务表和 UserWork(当 TaskQueue 不存在时使用) + */ + private void updateBusinessTaskAndUserWork(String taskId, String resultUrl) { + try { + transactionTemplate.executeWithoutResult(status -> { + // 更新业务表 + if (taskId.startsWith("img2vid_")) { + imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> { + task.setStatus(ImageToVideoTask.TaskStatus.COMPLETED); + task.setResultUrl(resultUrl); + task.setCompletedAt(java.time.LocalDateTime.now()); + imageToVideoTaskRepository.save(task); + logger.info("直接更新图生视频任务为完成: {}", taskId); + }); + } else if (taskId.startsWith("storyboard_")) { + storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> { + task.setStatus(StoryboardVideoTask.TaskStatus.COMPLETED); + task.setResultUrl(resultUrl); + task.setCompletedAt(java.time.LocalDateTime.now()); + storyboardVideoTaskRepository.save(task); + logger.info("直接更新分镜视频任务为完成: {}", taskId); + }); + } else if (taskId.startsWith("txt2vid_")) { + textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> { + task.setStatus(TextToVideoTask.TaskStatus.COMPLETED); + task.setResultUrl(resultUrl); + task.setCompletedAt(java.time.LocalDateTime.now()); + textToVideoTaskRepository.save(task); + logger.info("直接更新文生视频任务为完成: {}", taskId); + }); + } + + // 更新 UserWork + if (resultUrl != null && !resultUrl.isEmpty()) { + try { + userWorkService.createWorkFromTask(taskId, resultUrl); + } catch (Exception e) { + logger.warn("更新 UserWork 失败: {}", taskId, e); + } + } + }); + } catch (Exception e) { + logger.error("直接更新业务表失败: {}", taskId, e); + } + } + + /** + * 直接更新业务表和 UserWork 为失败状态 + */ + private void updateBusinessTaskAndUserWorkAsFailed(String taskId, String errorMessage) { + try { + transactionTemplate.executeWithoutResult(status -> { + // 更新业务表 + if (taskId.startsWith("img2vid_")) { + imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> { + task.setStatus(ImageToVideoTask.TaskStatus.FAILED); + task.setErrorMessage(errorMessage); + imageToVideoTaskRepository.save(task); + logger.info("直接更新图生视频任务为失败: {}", taskId); + }); + } else if (taskId.startsWith("storyboard_")) { + storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> { + task.setStatus(StoryboardVideoTask.TaskStatus.FAILED); + task.setErrorMessage(errorMessage); + storyboardVideoTaskRepository.save(task); + logger.info("直接更新分镜视频任务为失败: {}", taskId); + }); + } else if (taskId.startsWith("txt2vid_")) { + textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> { + task.setStatus(TextToVideoTask.TaskStatus.FAILED); + task.setErrorMessage(errorMessage); + textToVideoTaskRepository.save(task); + logger.info("直接更新文生视频任务为失败: {}", taskId); + }); + } + + // 更新 UserWork + try { + userWorkService.updateWorkStatus(taskId, UserWork.WorkStatus.FAILED); + } catch (Exception e) { + logger.warn("更新 UserWork 状态为失败失败: {}", taskId, e); + } + }); + } catch (Exception e) { + logger.error("直接更新业务表为失败状态失败: {}", taskId, e); + } + } + /** * 更新任务为失败状态(使用独立事务,快速完成) * 使用 TransactionTemplate 确保事务正确执行(因为私有方法无法使用 @Transactional) @@ -1645,13 +1913,27 @@ public class TaskQueueService { } catch (Exception workException) { logger.warn("更新作品状态为FAILED失败: {}", taskQueue.getTaskId(), workException); } + + // 更新 task_status 表中的状态为 FAILED(保留记录) + try { + TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskQueue.getTaskId()); + if (taskStatus != null) { + taskStatus.setStatus(TaskStatus.Status.FAILED); + taskStatus.setErrorMessage(errorMessage); + taskStatus.setUpdatedAt(java.time.LocalDateTime.now()); + taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus); + logger.info("task_status 表状态已更新为 FAILED: {}", taskQueue.getTaskId()); + } + } catch (Exception statusException) { + logger.warn("更新 task_status 状态失败: {}", taskQueue.getTaskId(), statusException); + } - // 任务失败后从队列中删除记录 + // 任务失败后从 task_queue 中删除记录(task_status 保留) try { taskQueueRepository.delete(taskQueue); - logger.info("任务失败,已从队列中删除: {}", taskQueue.getTaskId()); + logger.info("任务失败,已从 task_queue 中删除: {}", taskQueue.getTaskId()); } catch (Exception deleteException) { - logger.warn("删除队列记录失败: {}", taskQueue.getTaskId(), deleteException); + logger.warn("删除 task_queue 记录失败: {}", taskQueue.getTaskId(), deleteException); } } catch (Exception e) { logger.error("更新任务失败状态失败: {}", taskQueue.getTaskId(), e); @@ -1674,7 +1956,7 @@ public class TaskQueueService { // 使用 TransactionTemplate 确保在事务中执行 transactionTemplate.executeWithoutResult(status -> { try { - taskQueue.updateStatus(TaskQueue.QueueStatus.TIMEOUT); + taskQueue.updateStatus(TaskQueue.QueueStatus.FAILED); taskQueue.setErrorMessage("任务处理超时"); taskQueueRepository.save(taskQueue); @@ -1690,13 +1972,27 @@ public class TaskQueueService { } catch (Exception workException) { logger.warn("更新超时任务的作品状态为FAILED失败: {}", taskQueue.getTaskId(), workException); } + + // 更新 task_status 表中的状态为 FAILED(超时视为失败) + try { + TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskQueue.getTaskId()); + if (taskStatus != null) { + taskStatus.setStatus(TaskStatus.Status.FAILED); + taskStatus.setErrorMessage("任务处理超时"); + taskStatus.setUpdatedAt(java.time.LocalDateTime.now()); + taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus); + logger.info("task_status 表状态已更新为 FAILED (超时): {}", taskQueue.getTaskId()); + } + } catch (Exception statusException) { + logger.warn("更新 task_status 状态失败: {}", taskQueue.getTaskId(), statusException); + } - // 任务超时后从队列中删除记录 + // 任务超时后从 task_queue 中删除记录(task_status 保留) try { taskQueueRepository.delete(taskQueue); - logger.info("任务超时,已从队列中删除: {}", taskQueue.getTaskId()); + logger.info("任务超时,已从 task_queue 中删除: {}", taskQueue.getTaskId()); } catch (Exception deleteException) { - logger.warn("删除队列记录失败: {}", taskQueue.getTaskId(), deleteException); + logger.warn("删除 task_queue 记录失败: {}", taskQueue.getTaskId(), deleteException); } } catch (Exception e) { logger.error("更新任务超时状态失败: {}", taskQueue.getTaskId(), e); @@ -1713,8 +2009,10 @@ public class TaskQueueService { /** * 更新原始任务状态(使用独立事务,快速完成) */ - @Transactional private void updateOriginalTaskStatus(TaskQueue taskQueue, String status, String resultUrl, String errorMessage) { + logger.info("更新原始任务状态: taskId={}, taskType={}, status={}, resultUrl={}", + taskQueue.getTaskId(), taskQueue.getTaskType(), status, + resultUrl != null ? (resultUrl.length() > 50 ? resultUrl.substring(0, 50) + "..." : resultUrl) : "null"); try { if (taskQueue.getTaskType() == TaskQueue.TaskType.TEXT_TO_VIDEO) { Optional taskOpt = textToVideoTaskRepository.findByTaskId(taskQueue.getTaskId()); @@ -1741,15 +2039,18 @@ public class TaskQueueService { Optional taskOpt = imageToVideoTaskRepository.findByTaskId(taskQueue.getTaskId()); if (taskOpt.isPresent()) { ImageToVideoTask task = taskOpt.get(); + logger.info("找到ImageToVideoTask: taskId={}, 当前状态={}", taskQueue.getTaskId(), task.getStatus()); if ("COMPLETED".equals(status)) { task.setResultUrl(resultUrl); task.updateStatus(ImageToVideoTask.TaskStatus.COMPLETED); task.updateProgress(100); imageToVideoTaskRepository.save(task); + logger.info("ImageToVideoTask已更新为COMPLETED: taskId={}", taskQueue.getTaskId()); } else if ("FAILED".equals(status) || "CANCELLED".equals(status)) { task.updateStatus(ImageToVideoTask.TaskStatus.FAILED); task.setErrorMessage(errorMessage); imageToVideoTaskRepository.save(task); + logger.info("ImageToVideoTask已更新为FAILED: taskId={}", taskQueue.getTaskId()); } else if ("PROCESSING".equals(status)) { // 处理中状态,更新resultUrl以显示进度 if (resultUrl != null && !resultUrl.isEmpty()) { @@ -1757,6 +2058,8 @@ public class TaskQueueService { imageToVideoTaskRepository.save(task); } } + } else { + logger.warn("ImageToVideoTask不存在: taskId={}", taskQueue.getTaskId()); } } else if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) { Optional taskOpt = storyboardVideoTaskRepository.findByTaskId(taskQueue.getTaskId()); diff --git a/demo/src/main/java/com/example/demo/service/TaskStatusPollingService.java b/demo/src/main/java/com/example/demo/service/TaskStatusPollingService.java index 5bf0709..39bbc75 100644 --- a/demo/src/main/java/com/example/demo/service/TaskStatusPollingService.java +++ b/demo/src/main/java/com/example/demo/service/TaskStatusPollingService.java @@ -1,6 +1,7 @@ package com.example.demo.service; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import org.slf4j.Logger; @@ -16,6 +17,7 @@ import com.example.demo.repository.TaskStatusRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; import kong.unirest.HttpResponse; import kong.unirest.Unirest; @@ -24,18 +26,162 @@ public class TaskStatusPollingService { private static final Logger logger = LoggerFactory.getLogger(TaskStatusPollingService.class); + // 任务超时时间(小时) + private static final int TASK_TIMEOUT_HOURS = 1; + @Autowired private TaskStatusRepository taskStatusRepository; @Autowired private ObjectMapper objectMapper; + @Autowired + @org.springframework.context.annotation.Lazy + private TaskQueueService taskQueueService; + @Value("${ai.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}") private String apiKey; @Value("${ai.api.base-url:http://116.62.4.26:8081}") private String apiBaseUrl; + /** + * 系统启动时恢复处理中的任务 + * - 对所有 PROCESSING 状态的任务进行一次状态查询 + * - 如果外部API返回已完成,则标记成功 + * - 如果超时(创建时间超过1小时),则标记失败 + * - 如果未超时且未完成,保持现状等待后续轮询 + */ + @PostConstruct + public void recoverProcessingTasksOnStartup() { + logger.info("=== 系统启动:开始恢复处理中的任务 ==="); + + try { + // 查找所有 PROCESSING 状态的任务 + List processingTasks = taskStatusRepository.findAllByStatus(TaskStatus.Status.PROCESSING); + + if (processingTasks.isEmpty()) { + logger.info("没有需要恢复的处理中任务"); + return; + } + + logger.info("发现 {} 个处理中的任务,开始恢复检查...", processingTasks.size()); + + for (TaskStatus task : processingTasks) { + try { + recoverSingleTask(task); + } catch (Exception e) { + logger.error("恢复任务 {} 时发生错误: {}", task.getTaskId(), e.getMessage(), e); + } + } + + logger.info("=== 系统启动:任务恢复检查完成 ==="); + + } catch (Exception e) { + logger.error("系统启动恢复任务时发生错误: {}", e.getMessage(), e); + } + } + + /** + * 恢复单个任务 + */ + private void recoverSingleTask(TaskStatus task) { + logger.info("恢复检查任务: taskId={}, externalTaskId={}, createdAt={}", + task.getTaskId(), task.getExternalTaskId(), task.getCreatedAt()); + + // 检查是否有外部任务ID + if (task.getExternalTaskId() == null || task.getExternalTaskId().isEmpty()) { + // 没有外部任务ID,检查是否超时 + if (isTaskTimeout(task)) { + logger.warn("任务 {} 无外部任务ID且已超时,标记为失败", task.getTaskId()); + task.markAsFailed("任务超时:未获取到外部任务ID"); + taskStatusRepository.save(task); + } else { + logger.info("任务 {} 无外部任务ID但未超时,保持现状", task.getTaskId()); + } + return; + } + + // 有外部任务ID,查询外部API状态 + try { + String url = apiBaseUrl + "/v2/videos/generations/" + task.getExternalTaskId(); + HttpResponse response = Unirest.get(url) + .header("Authorization", "Bearer " + apiKey) + .asString(); + + if (response.getStatus() == 200) { + JsonNode responseJson = objectMapper.readTree(response.getBody()); + String status = responseJson.path("status").asText(); + String resultUrl = responseJson.path("data").path("output").asText(); + + logger.info("外部API返回: taskId={}, status={}, resultUrl={}", + task.getTaskId(), status, resultUrl); + + if ("SUCCESS".equalsIgnoreCase(status) && resultUrl != null && !resultUrl.isEmpty()) { + // 任务已完成,标记成功并同步更新所有表 + task.markAsCompleted(resultUrl); + taskStatusRepository.save(task); + logger.info("任务 {} 恢复为已完成状态,resultUrl={}", task.getTaskId(), resultUrl); + // 同步更新业务表、UserWork 等 + taskQueueService.handleTaskCompletionByTaskId(task.getTaskId(), resultUrl); + } else if ("FAILED".equalsIgnoreCase(status) || "ERROR".equalsIgnoreCase(status)) { + // 任务失败 + String failReason = responseJson.path("fail_reason").asText("外部API返回失败"); + task.markAsFailed(failReason); + taskStatusRepository.save(task); + logger.warn("任务 {} 恢复为失败状态,原因: {}", task.getTaskId(), failReason); + // 同步更新业务表、UserWork 等 + taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), failReason); + } else { + // 任务仍在处理中,检查是否超时 + if (isTaskTimeout(task)) { + logger.warn("任务 {} 已超时,标记为失败", task.getTaskId()); + task.markAsFailed("任务超时"); + taskStatusRepository.save(task); + // 同步更新业务表、UserWork 等 + taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时"); + } else { + logger.info("任务 {} 仍在处理中且未超时,保持现状", task.getTaskId()); + } + } + } else { + logger.warn("查询任务状态失败: taskId={}, status={}", task.getTaskId(), response.getStatus()); + // 检查是否超时 + if (isTaskTimeout(task)) { + task.markAsFailed("任务超时:无法获取外部状态"); + taskStatusRepository.save(task); + taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时:无法获取外部状态"); + } + } + } catch (Exception e) { + logger.error("查询外部API失败: taskId={}, error={}", task.getTaskId(), e.getMessage()); + // 检查是否超时 + if (isTaskTimeout(task)) { + task.markAsFailed("任务超时:查询外部API异常"); + taskStatusRepository.save(task); + taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时:查询外部API异常"); + } + } + } + + /** + * 检查任务是否超时(创建时间超过1小时) + */ + private boolean isTaskTimeout(TaskStatus task) { + if (task.getCreatedAt() == null) { + return false; + } + long hoursSinceCreation = ChronoUnit.HOURS.between(task.getCreatedAt(), LocalDateTime.now()); + return hoursSinceCreation >= TASK_TIMEOUT_HOURS; + } + + /** + * 根据状态查找任务列表 + */ + public List findByStatus(TaskStatus.Status status) { + return taskStatusRepository.findByStatus(status, org.springframework.data.domain.Pageable.unpaged()).getContent(); + } + /** * (Scheduling disabled) 原先每2分钟执行一次轮询查询任务状态。 * 注意:调度已集中到 `TaskQueueScheduler.checkTaskStatuses()`,以避免重复并发查询。 @@ -46,7 +192,7 @@ public class TaskStatusPollingService { logger.info("=== 开始执行任务状态轮询查询 (每2分钟) ==="); try { - // 查找需要轮询的任务(状态为PROCESSING且创建时间超过2分钟) + // 查找需要轮询的任务(上次轮询时间超过2分钟) LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(2); // 先做一次计数,避免在无任务时加载实体列表 long needCount = taskStatusRepository.countTasksNeedingPolling(cutoffTime); @@ -54,7 +200,11 @@ public class TaskStatusPollingService { logger.info("需要轮询查询的任务数量: {}", needCount); if (needCount == 0) { - logger.debug("当前没有需要轮询的任务(count=0)"); + // 检查是否有PROCESSING任务但不满足轮询条件 + long processingCount = taskStatusRepository.countByStatus(TaskStatus.Status.PROCESSING); + if (processingCount > 0) { + logger.debug("有 {} 个PROCESSING任务,但均在冷却期内(2分钟内已轮询过)", processingCount); + } return; } @@ -90,14 +240,15 @@ public class TaskStatusPollingService { logger.info("轮询任务状态: taskId={}, externalTaskId={}", task.getTaskId(), task.getExternalTaskId()); try { - // 调用外部API查询状态(长时间运行,不在事务中) - HttpResponse response = Unirest.post(apiBaseUrl + "/v1/videos") + // 使用正确的 API 端点:GET /v2/videos/generations/{task_id} + String url = apiBaseUrl + "/v2/videos/generations/" + task.getExternalTaskId(); + HttpResponse response = Unirest.get(url) .header("Authorization", "Bearer " + apiKey) - .field("task_id", task.getExternalTaskId()) .asString(); if (response.getStatus() == 200) { JsonNode responseJson = objectMapper.readTree(response.getBody()); + logger.info("轮询任务状态成功: taskId={}, response={}", task.getTaskId(), response.getBody()); // 更新任务状态(使用单独的事务方法) updateTaskStatusWithTransaction(task, responseJson); } else { @@ -137,9 +288,11 @@ public class TaskStatusPollingService { private void updateTaskStatus(TaskStatus task, JsonNode responseJson) { try { String status = responseJson.path("status").asText(); - int progress = responseJson.path("progress").asInt(0); - String resultUrl = responseJson.path("result_url").asText(); - String errorMessage = responseJson.path("error_message").asText(); + String progressStr = responseJson.path("progress").asText("0%"); + int progress = parseProgress(progressStr); + // API 返回的视频URL在 data.output 中 + String resultUrl = responseJson.path("data").path("output").asText(); + String errorMessage = responseJson.path("fail_reason").asText(); task.incrementPollCount(); task.setProgress(progress); @@ -147,18 +300,30 @@ public class TaskStatusPollingService { switch (status.toLowerCase()) { case "completed": case "success": - task.markAsCompleted(resultUrl); - logger.info("任务完成: taskId={}, resultUrl={}", task.getTaskId(), resultUrl); - break; + if (resultUrl != null && !resultUrl.isEmpty()) { + task.markAsCompleted(resultUrl); + logger.info("任务完成: taskId={}, resultUrl={}", task.getTaskId(), resultUrl); + taskStatusRepository.save(task); + // 同步更新业务表、UserWork 等 + taskQueueService.handleTaskCompletionByTaskId(task.getTaskId(), resultUrl); + } else { + logger.warn("任务状态为成功但 resultUrl 为空,保持 PROCESSING: taskId={}", task.getTaskId()); + taskStatusRepository.save(task); + } + return; // 已保存,直接返回 case "failed": case "error": task.markAsFailed(errorMessage); logger.warn("任务失败: taskId={}, error={}", task.getTaskId(), errorMessage); - break; + taskStatusRepository.save(task); + // 同步更新业务表、UserWork 等 + taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), errorMessage); + return; // 已保存,直接返回 case "processing": case "in_progress": + case "not_start": task.setStatus(TaskStatus.Status.PROCESSING); logger.info("任务处理中: taskId={}, progress={}%", task.getTaskId(), progress); break; @@ -249,5 +414,21 @@ public class TaskStatusPollingService { taskStatus.setUpdatedAt(LocalDateTime.now()); return taskStatusRepository.save(taskStatus); } + + /** + * 解析进度字符串(如 "100%")为整数 + */ + private int parseProgress(String progressStr) { + if (progressStr == null || progressStr.isEmpty()) { + return 0; + } + try { + // 移除百分号并解析 + String numStr = progressStr.replace("%", "").trim(); + return Integer.parseInt(numStr); + } catch (NumberFormatException e) { + return 0; + } + } } diff --git a/demo/src/main/java/com/example/demo/util/JwtUtils.java b/demo/src/main/java/com/example/demo/util/JwtUtils.java index c09d409..ffc59d2 100644 --- a/demo/src/main/java/com/example/demo/util/JwtUtils.java +++ b/demo/src/main/java/com/example/demo/util/JwtUtils.java @@ -145,7 +145,11 @@ public class JwtUtils { */ public String extractTokenFromHeader(String authHeader) { if (authHeader != null && authHeader.startsWith("Bearer ")) { - return authHeader.substring(7); + String token = authHeader.substring(7); + // 检查 token 是否有效(不为空、不是 "null" 字符串、包含两个点) + if (token != null && !token.isEmpty() && !token.equals("null") && token.contains(".")) { + return token; + } } return null; } diff --git a/demo/src/main/resources/application.properties b/demo/src/main/resources/application.properties index 8ca04ec..ff96121 100644 --- a/demo/src/main/resources/application.properties +++ b/demo/src/main/resources/application.properties @@ -43,15 +43,15 @@ springdoc.swagger-ui.default-model-expand-depth=1 # 腾讯云COS对象存储配置 # 是否启用COS(设置为true后需要配置下面的参数) -tencent.cos.enabled=false +tencent.cos.enabled=true # 腾讯云SecretId(从控制台获取:https://console.cloud.tencent.com/cam/capi) -tencent.cos.secret-id= +tencent.cos.secret-id=AKID2xjaRPSOSYk2fIxV7nQuDi9NOIzTjlbJ # 腾讯云SecretKey -tencent.cos.secret-key= +tencent.cos.secret-key=Xrxywju0wfAf3QiqlT2ZvGYgeS6WjnjT # COS区域(例如:ap-guangzhou、ap-shanghai、ap-beijing等) -tencent.cos.region=ap-guangzhou +tencent.cos.region=ap-nanjing # COS存储桶名称(例如:my-bucket-1234567890) -tencent.cos.bucket-name= +tencent.cos.bucket-name=test-1323844400 # ============================================ # PayPal支付配置