feat: 图片压缩后上传COS + 修复订单LazyInitializationException + 添加调试日志

This commit is contained in:
AIGC Developer
2025-12-05 21:06:16 +08:00
parent b4b0230ee1
commit 624d560fb4
35 changed files with 1916 additions and 218 deletions

View File

@@ -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'
})
},

View File

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

View File

@@ -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...'
}
}

View File

@@ -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提示词优化专家将用户描述优化为详细、专业的英文提示词...'
}
}

View File

@@ -102,13 +102,10 @@
<el-option :label="$t('orders.cancelled')" value="CANCELLED" />
<el-option :label="$t('orders.refunded')" value="REFUNDED" />
</el-select>
<el-select v-model="filters.type" :placeholder="$t('orders.allTypes')" size="small" @change="handleFilterChange">
<el-option :label="$t('orders.allTypes')" value="" />
<el-option label="商品订单" value="PRODUCT" />
<el-option label="服务订单" value="SERVICE" />
<el-option label="订阅订单" value="SUBSCRIPTION" />
<el-option label="数字商品" value="DIGITAL" />
<el-option label="实体商品" value="PHYSICAL" />
<el-select v-model="filters.paymentMethod" :placeholder="$t('orders.allPaymentMethods') || '全部支付方式'" size="small" @change="handleFilterChange">
<el-option :label="$t('orders.allPaymentMethods') || '全部支付方式'" value="" />
<el-option :label="$t('orders.alipay') || '支付宝'" value="ALIPAY" />
<el-option :label="$t('orders.paypal') || 'PayPal'" value="PAYPAL" />
</el-select>
</div>
<div class="toolbar-right">
@@ -200,6 +197,92 @@
</section>
</main>
<!-- 订单详情弹窗 -->
<el-dialog
v-model="orderDetailVisible"
:title="$t('orders.orderDetail') || '订单详情'"
width="600px"
class="order-detail-dialog"
>
<div v-if="currentOrderDetail" class="order-detail-content">
<div class="detail-section">
<h4>{{ $t('orders.basicInfo') || '基本信息' }}</h4>
<div class="detail-grid">
<div class="detail-item">
<span class="label">{{ $t('orders.orderNumber') }}:</span>
<span class="value">{{ currentOrderDetail.orderNumber || currentOrderDetail.id }}</span>
</div>
<div class="detail-item">
<span class="label">{{ $t('orders.username') }}:</span>
<span class="value">{{ currentOrderDetail.user?.username || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">{{ $t('orders.orderType') || '订单类型' }}:</span>
<span class="value">{{ getOrderTypeText(currentOrderDetail.orderType) }}</span>
</div>
<div class="detail-item">
<span class="label">{{ $t('orders.status') }}:</span>
<span class="status-tag" :class="getStatusClass(currentOrderDetail.status)">
{{ getStatusText(currentOrderDetail.status) }}
</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>{{ $t('orders.paymentInfo') || '支付信息' }}</h4>
<div class="detail-grid">
<div class="detail-item">
<span class="label">{{ $t('orders.amount') }}:</span>
<span class="value amount">{{ currentOrderDetail.currency || '¥' }}{{ currentOrderDetail.totalAmount || 0 }}</span>
</div>
<div class="detail-item">
<span class="label">{{ $t('orders.paymentMethod') }}:</span>
<span class="value">
<template v-if="currentOrderDetail.paymentMethod === 'ALIPAY'">{{ $t('orders.alipay') }}</template>
<template v-else-if="currentOrderDetail.paymentMethod === 'WECHAT'">{{ $t('orders.wechat') }}</template>
<template v-else-if="currentOrderDetail.paymentMethod === 'PAYPAL'">{{ $t('orders.paypal') }}</template>
<template v-else>{{ $t('orders.unpaid') }}</template>
</span>
</div>
<div class="detail-item">
<span class="label">{{ $t('orders.createTime') }}:</span>
<span class="value">{{ formatDate(currentOrderDetail.createdAt) }}</span>
</div>
<div class="detail-item" v-if="currentOrderDetail.paidAt">
<span class="label">{{ $t('orders.paidTime') || '支付时间' }}:</span>
<span class="value">{{ formatDate(currentOrderDetail.paidAt) }}</span>
</div>
</div>
</div>
<div class="detail-section" v-if="currentOrderDetail.contactEmail || currentOrderDetail.contactPhone">
<h4>{{ $t('orders.contactInfo') || '联系信息' }}</h4>
<div class="detail-grid">
<div class="detail-item" v-if="currentOrderDetail.contactEmail">
<span class="label">{{ $t('orders.email') || '邮箱' }}:</span>
<span class="value">{{ currentOrderDetail.contactEmail }}</span>
</div>
<div class="detail-item" v-if="currentOrderDetail.contactPhone">
<span class="label">{{ $t('orders.phone') || '电话' }}:</span>
<span class="value">{{ currentOrderDetail.contactPhone }}</span>
</div>
</div>
</div>
<div class="detail-section" v-if="currentOrderDetail.description">
<h4>{{ $t('orders.description') || '订单描述' }}</h4>
<p class="description-text">{{ currentOrderDetail.description }}</p>
</div>
</div>
<div v-else class="loading-container">
<el-icon class="is-loading"><Loading /></el-icon>
<span>{{ $t('common.loading') || '加载中...' }}</span>
</div>
<template #footer>
<el-button @click="orderDetailVisible = false">{{ $t('common.close') || '关闭' }}</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -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;
}
</style>

View File

@@ -163,6 +163,96 @@
</div>
</section>
</main>
<!-- 任务详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
:title="$t('tasks.taskDetail')"
width="600px"
class="task-detail-dialog"
>
<div class="task-detail-content" v-if="currentTask">
<div class="detail-section">
<h4>基本信息</h4>
<div class="detail-row">
<span class="detail-label">任务ID</span>
<span class="detail-value">{{ currentTask.taskId || currentTask.id }}</span>
</div>
<div class="detail-row">
<span class="detail-label">用户名</span>
<span class="detail-value">{{ currentTask.username || '未知' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">任务类型</span>
<span class="detail-value">{{ currentTask.type || currentTask.taskType || '未知' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">消耗资源</span>
<span class="detail-value">{{ currentTask.resources || '0积分' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">状态</span>
<span class="status-tag" :class="getStatusClass(currentTask.status)">
{{ getStatusText(currentTask.status) }}
</span>
</div>
</div>
<div class="detail-section">
<h4>时间信息</h4>
<div class="detail-row">
<span class="detail-label">创建时间</span>
<span class="detail-value">{{ formatDate(currentTask.createdAt || currentTask.createTime) }}</span>
</div>
<div class="detail-row" v-if="currentTask.updatedAt">
<span class="detail-label">更新时间</span>
<span class="detail-value">{{ formatDate(currentTask.updatedAt) }}</span>
</div>
<div class="detail-row" v-if="currentTask.completedAt">
<span class="detail-label">完成时间</span>
<span class="detail-value">{{ formatDate(currentTask.completedAt) }}</span>
</div>
</div>
<div class="detail-section" v-if="currentTask.progress !== undefined">
<h4>进度信息</h4>
<div class="detail-row">
<span class="detail-label">进度</span>
<el-progress :percentage="currentTask.progress || 0" :status="getProgressStatus(currentTask.status)" />
</div>
</div>
<div class="detail-section" v-if="currentTask.resultUrl">
<h4>结果</h4>
<div class="detail-row">
<span class="detail-label">结果链接</span>
<a :href="currentTask.resultUrl" target="_blank" class="result-link">查看结果</a>
</div>
<div class="result-preview" v-if="isVideoUrl(currentTask.resultUrl)">
<video :src="currentTask.resultUrl" controls class="preview-video"></video>
</div>
<div class="result-preview" v-else-if="isImageUrl(currentTask.resultUrl)">
<img :src="currentTask.resultUrl" class="preview-image" />
</div>
</div>
<div class="detail-section" v-if="currentTask.errorMessage">
<h4>错误信息</h4>
<div class="error-message">{{ currentTask.errorMessage }}</div>
</div>
</div>
<template #footer>
<el-button @click="detailDialogVisible = false">关闭</el-button>
<el-button
v-if="currentTask?.resultUrl"
type="primary"
@click="openResult(currentTask.resultUrl)"
>
查看结果
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -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;
}
</style>

View File

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

View File

@@ -15,24 +15,30 @@
<!-- 登录方式切换 -->
<div class="login-tabs">
<svg width="248" height="59" viewBox="0 0 248 59" fill="none" xmlns="http://www.w3.org/2000/svg" class="tabs-svg">
<!-- 邮箱登录 -->
<g class="tab-email" :class="{ active: loginType === 'email' }" @click="loginType = 'email'" style="cursor: pointer;">
<!-- 透明点击区域 -->
<rect x="0" y="10" width="120" height="40" fill="transparent"/>
<path d="M13.598 21.112V40.638H11.076V39.13H4.316V40.638H1.768V21.112H6.344V17.55H8.996V21.112H13.598ZM4.316 36.712H6.344V31.122H4.316V36.712ZM8.996 36.712H11.076V31.122H8.996V36.712ZM4.316 28.73H6.344V23.556H4.316V28.73ZM8.996 23.556V28.73H11.076V23.556H8.996ZM15.34 18.772H24.232V20.748C23.452 23.4 22.62 25.818 21.736 28.054C23.556 30.654 24.466 32.76 24.492 34.398C24.492 35.958 24.154 37.024 23.504 37.596C22.802 38.22 21.398 38.532 19.318 38.532L18.512 35.802C19.474 35.906 20.28 35.984 20.956 35.984C21.372 35.958 21.658 35.828 21.814 35.62C21.918 35.464 21.97 35.048 21.996 34.398C21.97 32.786 20.982 30.68 19.032 28.054C19.864 26.156 20.67 23.868 21.45 21.164H17.914V41.73H15.34V18.772ZM27.716 27.326H31.616V24.83H34.346V27.326H37.57V29.926H34.346V30.446C35.542 31.538 36.79 32.734 38.038 34.06L36.53 36.348C35.698 35.048 34.97 33.93 34.346 33.046V41.756H31.616V33.202C30.654 35.23 29.432 37.102 27.924 38.818L26.754 35.776C28.756 34.06 30.238 32.11 31.226 29.926H27.716V27.326ZM49.4 25.48V41.73H46.774V40.82H41.106V41.73H38.48V25.48H49.4ZM41.106 38.428H46.774V36.244H41.106V38.428ZM41.106 33.956H46.774V32.058H41.106V33.956ZM41.106 29.77H46.774V27.872H41.106V29.77ZM31.538 21.762C30.966 22.75 30.316 23.634 29.614 24.466L27.248 22.958C28.782 21.294 29.874 19.5 30.498 17.576L33.124 18.148C32.968 18.564 32.838 18.98 32.682 19.37H38.974V21.762H35.568C36.088 22.49 36.504 23.192 36.842 23.842L34.346 24.778C33.878 23.738 33.306 22.724 32.682 21.762H31.538ZM42.64 21.762C42.12 22.828 41.574 23.816 40.95 24.726L38.636 23.244C39.962 21.424 40.898 19.474 41.444 17.446L44.018 18.018C43.862 18.486 43.732 18.928 43.602 19.37H50.83V21.762H46.67C47.19 22.49 47.632 23.192 47.97 23.842L45.578 24.752C45.11 23.712 44.538 22.724 43.862 21.762H42.64ZM57.538 28.522H72.566V35.282H57.538V28.522ZM69.836 32.89V30.888H60.268V32.89H69.836ZM60.45 35.438C61.282 36.296 62.036 37.31 62.712 38.454H67.626C68.354 37.466 68.978 36.426 69.524 35.36L72.046 36.27C71.578 37.05 71.084 37.778 70.564 38.454H76.232V41.028H53.716V38.454H59.826C59.28 37.726 58.63 37.05 57.902 36.4L60.45 35.438ZM56.368 21.112C57.564 21.996 58.63 22.854 59.514 23.686C60.424 22.802 61.152 21.866 61.724 20.878H55.822V18.382H64.792V20.41C64.194 21.918 63.388 23.27 62.374 24.466H68.822C67.262 22.75 66.014 20.904 65.104 18.876L67.366 17.628C67.782 18.59 68.276 19.5 68.848 20.358C69.758 19.63 70.538 18.85 71.188 18.018L73.086 19.708C72.306 20.644 71.37 21.528 70.304 22.308C70.72 22.828 71.188 23.296 71.708 23.764C72.8 22.932 73.71 21.996 74.49 20.982L76.388 22.646C75.608 23.634 74.672 24.544 73.632 25.35C74.776 26.182 76.024 26.962 77.428 27.664L75.634 29.744C73.606 28.6 71.838 27.352 70.33 25.974V26.936H60.788V26.104C59.124 27.56 57.07 28.782 54.626 29.796L52.962 27.664C54.782 26.962 56.316 26.156 57.616 25.246C56.758 24.466 55.744 23.66 54.548 22.828L56.368 21.112ZM82.368 18.408H97.864V26.806H102.258V29.276H98.228L100.282 30.966C98.93 32.63 97.396 33.956 95.68 34.892C97.604 36.296 99.84 37.518 102.388 38.61L101.01 41.002C97.37 39.286 94.432 37.232 92.196 34.814V38.974C92.196 40.794 91.39 41.73 89.804 41.73H86.762L86.164 39.182C87.1 39.286 88.01 39.364 88.894 39.364C89.284 39.364 89.492 39 89.492 38.324V34.918C86.944 37.154 83.928 39.156 80.47 40.95L79.378 38.428C83.278 36.66 86.632 34.58 89.492 32.136V29.276H79.768V26.806H95.108V25.012H83.252V22.672H95.108V20.852H82.368V18.408ZM83.018 29.666C84.526 30.706 85.8 31.746 86.84 32.786L85.072 34.554C84.162 33.566 82.888 32.526 81.224 31.382L83.018 29.666ZM98.176 29.276H92.196V31.824C92.69 32.37 93.236 32.89 93.834 33.41C95.498 32.422 96.954 31.044 98.176 29.276Z" />
</g>
<!-- 分隔线 -->
<path d="M124 18.75V40.25" stroke="#9EA9B6"/>
<!-- 密码登录 -->
<g class="tab-password" :class="{ active: loginType === 'password' }" @click="loginType = 'password'" style="cursor: pointer;">
<!-- 透明点击区域 -->
<rect x="128" y="10" width="120" height="40" fill="transparent"/>
<path d="M155.362 18.46V35.126H152.996V20.956H148.212V35.126H145.768V18.46H155.362ZM149.434 22.49H151.722V31.018C151.644 33.93 151.202 36.27 150.37 38.012C149.564 39.65 148.264 40.898 146.47 41.782L145.014 39.494C146.704 38.636 147.848 37.57 148.472 36.322C149.044 34.944 149.356 33.176 149.434 31.018V22.49ZM152.684 35.724C153.984 37.05 155.076 38.35 155.986 39.624L154.036 41.574C153.308 40.274 152.294 38.896 150.942 37.414L152.684 35.724ZM162.044 30.732H160.328V38.298C161.342 37.882 162.33 37.362 163.318 36.738L163.786 39.156C162.122 40.196 160.224 41.028 158.092 41.704L156.974 39.286C157.442 39.052 157.676 38.688 157.676 38.22V30.732H156.064V28.132H157.676V17.68H160.328V28.132H168.622V30.732H164.384C165.528 34.45 167.14 37.336 169.168 39.364L167.322 41.47C165.008 39.026 163.24 35.438 162.044 30.732ZM166.23 18.746L168.31 20.41C166.516 23.322 164.436 25.506 162.044 26.91L160.588 24.83C162.772 23.478 164.67 21.45 166.23 18.746ZM174.966 18.356H191.034V26.026H174.966V18.356ZM188.278 23.608V20.8H177.722V23.608H188.278ZM176.76 30.16H171.118V27.534H194.856V30.16H179.516L178.814 32.422H191.372C191.164 36.894 190.748 39.546 190.072 40.378C189.396 41.184 188.122 41.6 186.198 41.6C184.924 41.6 183.78 41.522 182.766 41.392L181.882 38.922C183.286 39.052 184.508 39.13 185.6 39.13C186.874 39.13 187.654 38.87 187.966 38.402C188.252 37.908 188.46 36.738 188.59 34.866H175.564L176.76 30.16ZM201.538 28.522H216.566V35.282H201.538V28.522ZM213.836 32.89V30.888H204.268V32.89H213.836ZM204.45 35.438C205.282 36.296 206.036 37.31 206.712 38.454H211.626C212.354 37.466 212.978 36.426 213.524 35.36L216.046 36.27C215.578 37.05 215.084 37.778 214.564 38.454H220.232V41.028H197.716V38.454H203.826C203.28 37.726 202.63 37.05 201.902 36.4L204.45 35.438ZM200.368 21.112C201.564 21.996 202.63 22.854 203.514 23.686C204.424 22.802 205.152 21.866 205.724 20.878H199.822V18.382H208.792V20.41C208.194 21.918 207.388 23.27 206.374 24.466H212.822C211.262 22.75 210.014 20.904 209.104 18.876L211.366 17.628C211.782 18.59 212.276 19.5 212.848 20.358C213.758 19.63 214.538 18.85 215.188 18.018L217.086 19.708C216.306 20.644 215.37 21.528 214.304 22.308C214.72 22.828 215.188 23.296 215.708 23.764C216.8 22.932 217.71 21.996 218.49 20.982L220.388 22.646C219.608 23.634 218.672 24.544 217.632 25.35C218.776 26.182 220.024 26.962 221.428 27.664L219.634 29.744C217.606 28.6 215.838 27.352 214.33 25.974V26.936H204.788V26.104C203.124 27.56 201.07 28.782 198.626 29.796L196.962 27.664C198.782 26.962 200.316 26.156 201.616 25.246C200.758 24.466 199.744 23.66 198.548 22.828L200.368 21.112ZM226.368 18.408H241.864V26.806H246.258V29.276H242.228L244.282 30.966C242.93 32.63 241.396 33.956 239.68 34.892C241.604 36.296 243.84 37.518 246.388 38.61L245.01 41.002C241.37 39.286 238.432 37.232 236.196 34.814V38.974C236.196 40.794 235.39 41.73 233.804 41.73H230.762L230.164 39.182C231.1 39.286 232.01 39.364 232.894 39.364C233.284 39.364 233.492 39 233.492 38.324V34.918C230.944 37.154 227.928 39.156 224.47 40.95L223.378 38.428C227.278 36.66 230.632 34.58 233.492 32.136V29.276H223.768V26.806H239.108V25.012H227.252V22.672H239.108V20.852H226.368V18.408ZM227.018 29.666C228.526 30.706 229.8 31.746 230.84 32.786L229.072 34.554C228.162 33.566 226.888 32.526 225.224 31.382L227.018 29.666ZM242.176 29.276H236.196V31.824C236.69 32.37 237.236 32.89 237.834 33.41C239.498 32.422 240.954 31.044 242.176 29.276Z" />
</g>
</svg>
<!-- 邮箱登录盒子 -->
<div
class="tab-item"
:class="{ active: loginType === 'email' }"
@click="loginType = 'email'"
>
<svg width="105" height="30" viewBox="0 0 105 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.598 6.112V25.638H11.076V24.13H4.316V25.638H1.768V6.112H6.344V2.55H8.996V6.112H13.598ZM4.316 21.712H6.344V16.122H4.316V21.712ZM8.996 21.712H11.076V16.122H8.996V21.712ZM4.316 13.73H6.344V8.556H4.316V13.73ZM8.996 8.556V13.73H11.076V8.556H8.996ZM15.34 3.772H24.232V5.748C23.452 8.4 22.62 10.818 21.736 13.054C23.556 15.654 24.466 17.76 24.492 19.398C24.492 20.958 24.154 22.024 23.504 22.596C22.802 23.22 21.398 23.532 19.318 23.532L18.512 20.802C19.474 20.906 20.28 20.984 20.956 20.984C21.372 20.958 21.658 20.828 21.814 20.62C21.918 20.464 21.97 20.048 21.996 19.398C21.97 17.786 20.982 15.68 19.032 13.054C19.864 11.156 20.67 8.868 21.45 6.164H17.914V26.73H15.34V3.772ZM27.716 12.326H31.616V9.83H34.346V12.326H37.57V14.926H34.346V15.446C35.542 16.538 36.79 17.734 38.038 19.06L36.53 21.348C35.698 20.048 34.97 18.93 34.346 18.046V26.756H31.616V18.202C30.654 20.23 29.432 22.102 27.924 23.818L26.754 20.776C28.756 19.06 30.238 17.11 31.226 14.926H27.716V12.326ZM49.4 10.48V26.73H46.774V25.82H41.106V26.73H38.48V10.48H49.4ZM41.106 23.428H46.774V21.244H41.106V23.428ZM41.106 18.956H46.774V17.058H41.106V18.956ZM41.106 14.77H46.774V12.872H41.106V14.77ZM31.538 6.762C30.966 7.75 30.316 8.634 29.614 9.466L27.248 7.958C28.782 6.294 29.874 4.5 30.498 2.576L33.124 3.148C32.968 3.564 32.838 3.98 32.682 4.37H38.974V6.762H35.568C36.088 7.49 36.504 8.192 36.842 8.842L34.346 9.778C33.878 8.738 33.306 7.724 32.682 6.762H31.538ZM42.64 6.762C42.12 7.828 41.574 8.816 40.95 9.726L38.636 8.244C39.962 6.424 40.898 4.474 41.444 2.446L44.018 3.018C43.862 3.486 43.732 3.928 43.602 4.37H50.83V6.762H46.67C47.19 7.49 47.632 8.192 47.97 8.842L45.578 9.752C45.11 8.712 44.538 7.724 43.862 6.762H42.64ZM57.538 13.522H72.566V20.282H57.538V13.522ZM69.836 17.89V15.888H60.268V17.89H69.836ZM60.45 20.438C61.282 21.296 62.036 22.31 62.712 23.454H67.626C68.354 22.466 68.978 21.426 69.524 20.36L72.046 21.27C71.578 22.05 71.084 22.778 70.564 23.454H76.232V26.028H53.716V23.454H59.826C59.28 22.726 58.63 22.05 57.902 21.4L60.45 20.438ZM56.368 6.112C57.564 6.996 58.63 7.854 59.514 8.686C60.424 7.802 61.152 6.866 61.724 5.878H55.822V3.382H64.792V5.41C64.194 6.918 63.388 8.27 62.374 9.466H68.822C67.262 7.75 66.014 5.904 65.104 3.876L67.366 2.628C67.782 3.59 68.276 4.5 68.848 5.358C69.758 4.63 70.538 3.85 71.188 3.018L73.086 4.708C72.306 5.644 71.37 6.528 70.304 7.308C70.72 7.828 71.188 8.296 71.708 8.764C72.8 7.932 73.71 6.996 74.49 5.982L76.388 7.646C75.608 8.634 74.672 9.544 73.632 10.35C74.776 11.182 76.024 11.962 77.428 12.664L75.634 14.744C73.606 13.6 71.838 12.352 70.33 10.974V11.936H60.788V11.104C59.124 12.56 57.07 13.782 54.626 14.796L52.962 12.664C54.782 11.962 56.316 11.156 57.616 10.246C56.758 9.466 55.744 8.66 54.548 7.828L56.368 6.112ZM82.368 3.408H97.864V11.806H102.258V14.276H98.228L100.282 15.966C98.93 17.63 97.396 18.956 95.68 19.892C97.604 21.296 99.84 22.518 102.388 23.61L101.01 26.002C97.37 24.286 94.432 22.232 92.196 19.814V23.974C92.196 25.794 91.39 26.73 89.804 26.73H86.762L86.164 24.182C87.1 24.286 88.01 24.364 88.894 24.364C89.284 24.364 89.492 24 89.492 23.324V19.918C86.944 22.154 83.928 24.156 80.47 25.95L79.378 23.428C83.278 21.66 86.632 19.58 89.492 17.136V14.276H79.768V11.806H95.108V10.012H83.252V7.672H95.108V5.852H82.368V3.408ZM83.018 14.666C84.526 15.706 85.8 16.746 86.84 17.786L85.072 19.554C84.162 18.566 82.888 17.526 81.224 16.382L83.018 14.666ZM98.176 14.276H92.196V16.824C92.69 17.37 93.236 17.89 93.834 18.41C95.498 17.422 96.954 16.044 98.176 14.276Z" fill="currentColor"/>
</svg>
</div>
<!-- 分隔线 -->
<div class="tab-divider"></div>
<!-- 账号登录盒子 -->
<div
class="tab-item"
:class="{ active: loginType === 'password' }"
@click="loginType = 'password'"
>
<svg width="105" height="30" viewBox="0 0 105 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.362 3.46V20.126H7.996V5.956H3.212V20.126H0.768V3.46H10.362ZM4.434 7.49H6.722V16.018C6.644 18.93 6.202 21.27 5.37 23.012C4.564 24.65 3.264 25.898 1.47 26.782L0.014 24.494C1.704 23.636 2.848 22.57 3.472 21.322C4.044 19.944 4.356 18.176 4.434 16.018V7.49ZM7.684 20.724C8.984 22.05 10.076 23.35 10.986 24.624L9.036 26.574C8.308 25.274 7.294 23.896 5.942 22.414L7.684 20.724ZM17.044 15.732H15.328V23.298C16.342 22.882 17.33 22.362 18.318 21.738L18.786 24.156C17.122 25.196 15.224 26.028 13.092 26.704L11.974 24.286C12.442 24.052 12.676 23.688 12.676 23.22V15.732H11.064V13.132H12.676V2.68H15.328V13.132H23.622V15.732H19.384C20.528 19.45 22.14 22.336 24.168 24.364L22.322 26.47C20.008 24.026 18.24 20.438 17.044 15.732ZM21.23 3.746L23.31 5.41C21.516 8.322 19.436 10.506 17.044 11.91L15.588 9.83C17.772 8.478 19.67 6.45 21.23 3.746ZM29.966 3.356H46.034V11.026H29.966V3.356ZM43.278 8.608V5.8H32.722V8.608H43.278ZM31.76 15.16H26.118V12.534H49.856V15.16H34.516L33.814 17.422H46.372C46.164 21.894 45.748 24.546 45.072 25.378C44.396 26.184 43.122 26.6 41.198 26.6C39.924 26.6 38.78 26.522 37.766 26.392L36.882 23.922C38.286 24.052 39.508 24.13 40.6 24.13C41.874 24.13 42.654 23.87 42.966 23.402C43.252 22.908 43.46 21.738 43.59 19.866H30.564L31.76 15.16ZM56.538 13.522H71.566V20.282H56.538V13.522ZM68.836 17.89V15.888H59.268V17.89H68.836ZM59.45 20.438C60.282 21.296 61.036 22.31 61.712 23.454H66.626C67.354 22.466 67.978 21.426 68.524 20.36L71.046 21.27C70.578 22.05 70.084 22.778 69.564 23.454H75.232V26.028H52.716V23.454H58.826C58.28 22.726 57.63 22.05 56.902 21.4L59.45 20.438ZM55.368 6.112C56.564 6.996 57.63 7.854 58.514 8.686C59.424 7.802 60.152 6.866 60.724 5.878H54.822V3.382H63.792V5.41C63.194 6.918 62.388 8.27 61.374 9.466H67.822C66.262 7.75 65.014 5.904 64.104 3.876L66.366 2.628C66.782 3.59 67.276 4.5 67.848 5.358C68.758 4.63 69.538 3.85 70.188 3.018L72.086 4.708C71.306 5.644 70.37 6.528 69.304 7.308C69.72 7.828 70.188 8.296 70.708 8.764C71.8 7.932 72.71 6.996 73.49 5.982L75.388 7.646C74.608 8.634 73.672 9.544 72.632 10.35C73.776 11.182 75.024 11.962 76.428 12.664L74.634 14.744C72.606 13.6 70.838 12.352 69.33 10.974V11.936H59.788V11.104C58.124 12.56 56.07 13.782 53.626 14.796L51.962 12.664C53.782 11.962 55.316 11.156 56.616 10.246C55.758 9.466 54.744 8.66 53.548 7.828L55.368 6.112ZM81.368 3.408H96.864V11.806H101.258V14.276H97.228L99.282 15.966C97.93 17.63 96.396 18.956 94.68 19.892C96.604 21.296 98.84 22.518 101.388 23.61L100.01 26.002C96.37 24.286 93.432 22.232 91.196 19.814V23.974C91.196 25.794 90.39 26.73 88.804 26.73H85.762L85.164 24.182C86.1 24.286 87.01 24.364 87.894 24.364C88.284 24.364 88.492 24 88.492 23.324V19.918C85.944 22.154 82.928 24.156 79.47 25.95L78.378 23.428C82.278 21.66 85.632 19.58 88.492 17.136V14.276H78.768V11.806H94.108V10.012H82.252V7.672H94.108V5.852H81.368V3.408ZM82.018 14.666C83.526 15.706 84.8 16.746 85.84 17.786L84.072 19.554C83.162 18.566 81.888 17.526 80.224 16.382L82.018 14.666ZM97.176 14.276H91.196V16.824C91.69 17.37 92.236 17.89 92.834 18.41C94.498 17.422 95.954 16.044 97.176 14.276Z" fill="currentColor"/>
</svg>
</div>
</div>
<!-- 登录表单 -->
@@ -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;
}
/* 登录表单 */

View File

@@ -100,7 +100,7 @@
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button
size="small"
@@ -116,6 +116,13 @@
>
测试完成
</el-button>
<el-button
size="small"
type="danger"
@click="handleDeletePayment(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -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()
})

View File

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

View File

@@ -78,7 +78,9 @@
<el-icon><User /></el-icon>
<span>{{ $t('systemSettings.membership') }}</span>
</div>
<!-- 任务清理管理标签暂时隐藏 -->
<div
v-if="false"
class="tab-item"
:class="{ active: activeTab === 'cleanup' }"
@click="activeTab = 'cleanup'"
@@ -256,8 +258,34 @@
<el-input v-model="promptOptimizationModel" style="width: 400px;" placeholder="gpt-5.1-thinking"></el-input>
<div class="model-tip">{{ $t('systemSettings.promptOptimizationModelTip') }}</div>
</el-form-item>
<el-form-item :label="$t('systemSettings.storyboardSystemPrompt')">
<el-input
v-model="storyboardSystemPrompt"
type="textarea"
:rows="4"
style="width: 500px;"
:placeholder="$t('systemSettings.storyboardSystemPromptPlaceholder')">
</el-input>
<div class="model-tip">{{ $t('systemSettings.storyboardSystemPromptTip') }}</div>
</el-form-item>
<el-form-item :label="$t('systemSettings.promptOptimizationSystemPrompt')">
<el-input
v-model="promptOptimizationSystemPrompt"
type="textarea"
:rows="6"
style="width: 500px;"
:placeholder="$t('systemSettings.promptOptimizationSystemPromptPlaceholder')">
</el-input>
<div class="model-tip">{{ $t('systemSettings.promptOptimizationSystemPromptTip') }}</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveAiModelSettings" :loading="savingAiModel">
<el-button
type="primary"
@click="saveAiModelSettings"
:loading="savingAiModel"
class="ai-save-btn"
>
<el-icon v-if="!savingAiModel"><Check /></el-icon>
{{ $t('common.save') }}
</el-button>
</el-form-item>
@@ -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 {

View File

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

View File

@@ -28,6 +28,7 @@
</scm>
<properties>
<java.version>21</java.version>
<tomcat.version>10.1.34</tomcat.version>
</properties>
<dependencies>
<dependency>

View File

@@ -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<Map<String, Object>> deleteTask(
@PathVariable String taskId,
@RequestHeader("Authorization") String token) {
Map<String, Object> 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<Map<String, Object>> batchDeleteTasks(
@RequestBody List<String> taskIds,
@RequestHeader("Authorization") String token) {
Map<String, Object> 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);
}
}
}

View File

@@ -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<String, Object> 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);

View File

@@ -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<Map<String, Object>> 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<Order> 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);
}
// 转换订单数据,添加支付方式信息

View File

@@ -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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> deletePayments(
@RequestBody List<Long> 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<String, Object> 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<String, Object> createErrorResponse(String message) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);

View File

@@ -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<String, Object> 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());

View File

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

View File

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

View File

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

View File

@@ -202,4 +202,62 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
*/
@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<Order> 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<Order> 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<Order> 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<Order> 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<Order> 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<Order> 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<Order> 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<Order> findByUserAndStatusAndOrderNumberContainingIgnoreCaseWithUser(@Param("user") User user, @Param("status") OrderStatus status, @Param("orderNumber") String orderNumber, Pageable pageable);
}

View File

@@ -61,6 +61,11 @@ public interface TaskStatusRepository extends JpaRepository<TaskStatus, Long> {
*/
long countByStatus(TaskStatus.Status status);
/**
* 根据状态查找所有任务(不分页)
*/
List<TaskStatus> findAllByStatus(TaskStatus.Status status);
/**
* 统计用户指定状态的任务数量
*/

View File

@@ -9,6 +9,7 @@ import com.example.demo.model.User;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByNickname(String nickname);
Optional<User> findByEmail(String email);
Optional<User> findByPhone(String phone);
Optional<User> findByUserId(String userId);

View File

@@ -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) {

View File

@@ -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
*

View File

@@ -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);
}
}
// 返回本地URLCOS未启用或上传失败时
return localUrlPath;
}
/**

View File

@@ -444,34 +444,41 @@ public class OrderService {
}
/**
* 分页查找所有订单(支持状态和搜索筛选)
* 分页查找所有订单(支持状态、类型和搜索筛选预加载User
*/
@Transactional(readOnly = true)
public Page<Order> findAllOrders(Pageable pageable, OrderStatus status, String search) {
public Page<Order> findAllOrders(Pageable pageable, OrderStatus status, String type, String search) {
// 基础查询
Page<Order> 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<Order> findOrdersByUser(User user, Pageable pageable, OrderStatus status, String search) {
public Page<Order> 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);
}
}

View File

@@ -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<String, Object> 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<String, String> userMessage = new HashMap<>();
userMessage.put("role", "user");
userMessage.put("content", "请优化以下提示词,保持原始意图:\n" + prompt);
userMessage.put("content", "请优化以下提示词,保持原始意图:\n" + promptToOptimize);
messages.add(userMessage);
requestBody.put("messages", messages);

View File

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

View File

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

View File

@@ -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<TextToVideoTask> 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<ImageToVideoTask> 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<StoryboardVideoTask> 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<TaskQueue> 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<String, Object> 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<TaskQueue> 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<TaskQueue> 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<TextToVideoTask> taskOpt = textToVideoTaskRepository.findByTaskId(taskQueue.getTaskId());
@@ -1741,15 +2039,18 @@ public class TaskQueueService {
Optional<ImageToVideoTask> 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<StoryboardVideoTask> taskOpt = storyboardVideoTaskRepository.findByTaskId(taskQueue.getTaskId());

View File

@@ -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<TaskStatus> 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<String> 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<TaskStatus> 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<String> response = Unirest.post(apiBaseUrl + "/v1/videos")
// 使用正确的 API 端点GET /v2/videos/generations/{task_id}
String url = apiBaseUrl + "/v2/videos/generations/" + task.getExternalTaskId();
HttpResponse<String> 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;
}
}
}

View File

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

View File

@@ -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支付配置