优化: Safari下载兼容、禁用生产Swagger、前端构建优化移除console、更新COS配置

This commit is contained in:
AIGC Developer
2026-01-05 15:40:28 +08:00
parent 38630dbb66
commit a99cfa28e5
40 changed files with 2550 additions and 1320 deletions

View File

@@ -5,7 +5,8 @@ export const getMyWorks = (params = {}) => {
return api.get('/works/my-works', {
params: {
page: params.page || 0,
size: params.size || 10
size: params.size || 10,
includeProcessing: params.includeProcessing !== false // 默认包含正在处理中的作品
}
})
}

View File

@@ -23,7 +23,7 @@
<div class="method-icon alipay-icon">
<el-icon><CreditCard /></el-icon>
</div>
<span>Alipay扫码支付</span>
<span>支付宝支付</span>
</div>
<div
class="payment-method"
@@ -43,24 +43,25 @@
<div class="amount-value">${{ amount }}</div>
</div>
<!-- 支付宝二维码区域 -->
<div v-if="selectedMethod === 'alipay'" class="qr-section">
<div class="qr-code">
<img v-if="showQrCode && qrCodeUrl" :src="qrCodeUrl" style="width: 200px; height: 200px; margin: 0; padding: 0; border: none; object-fit: contain; background: #1a1a1a;" alt="支付二维码" />
<div v-if="!showQrCode" class="qr-placeholder">
<svg width="200" height="200" viewBox="0 0 360 360" fill="none" xmlns="http://www.w3.org/2000/svg">
<foreignObject x="-5.8" y="-5.8" width="371.6" height="371.6"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.9px);clip-path:url(#bgblur_0_605_316_clip_path);height:100%;width:100%"></div></foreignObject>
<rect data-figma-bg-blur-radius="5.8" width="360" height="360" rx="10" fill="#0F0F12" fill-opacity="0.9"/>
<defs>
<clipPath id="bgblur_0_605_316_clip_path" transform="translate(5.8 5.8)"><rect width="360" height="360" rx="10"/></clipPath>
</defs>
<!-- 加载动画 -->
<text x="180" y="165" text-anchor="middle" fill="rgba(255,255,255,0.6)" font-size="28" font-family="Arial" font-weight="500">正在生成二维码</text>
<text x="180" y="210" text-anchor="middle" fill="rgba(255,255,255,0.4)" font-size="22" font-family="Arial">请稍候...</text>
<!-- 支付宝支付区域 -->
<div v-if="selectedMethod === 'alipay'" class="alipay-section">
<div class="alipay-info">
<div class="alipay-logo">
<svg width="100" height="32" viewBox="0 0 100 32" fill="none">
<text x="0" y="24" font-family="Arial" font-size="20" font-weight="bold" fill="#1677FF">支付宝</text>
</svg>
</div>
<p class="alipay-desc">安全便捷的在线支付方式</p>
<p class="alipay-desc-small">点击下方按钮跳转到支付宝完成支付</p>
</div>
<div class="qr-tip">支付前请阅读Vionow支付服务条款</div>
<button
class="alipay-pay-button"
@click="handlePay"
:disabled="loading"
>
<span v-if="!loading">前往支付宝支付</span>
<span v-else>正在跳转...</span>
</button>
</div>
<!-- PayPal支付按钮区域 -->
@@ -84,17 +85,6 @@
</button>
</div>
<!-- 支付提示 -->
<div v-if="selectedMethod === 'alipay'" class="action-section">
<div class="pay-tip">
<p>请使用支付宝扫描上方二维码完成支付</p>
<p class="tip-small">支付完成后页面将自动更新</p>
</div>
<button class="check-payment-btn" @click="manualCheckPayment" :disabled="!currentPaymentId">
我已完成支付
</button>
</div>
<!-- 底部链接 -->
<div class="footer-link">
<a href="#" @click.prevent="showAgreement">Vionow支付服务条款</a>
@@ -136,8 +126,6 @@ const visible = ref(false)
const selectedMethod = ref('alipay')
const loading = ref(false)
const currentPaymentId = ref(null)
const qrCodeUrl = ref('') // 二维码URL
const showQrCode = ref(false) // 是否显示二维码
let paymentPollingTimer = null
let isPaymentStarted = false // 防止重复调用
let lastPlanType = '' // 记录上一次的套餐类型
@@ -165,14 +153,8 @@ watch(() => props.modelValue, (newVal) => {
console.log('同一套餐,复用 paymentId:', currentPaymentId.value)
}
qrCodeUrl.value = ''
showQrCode.value = false
// 只有选择支付宝时才自动生成二维码PayPal需要用户点击按钮
if (!isPaymentStarted && selectedMethod.value === 'alipay') {
isPaymentStarted = true
handlePay()
}
// 重置状态
isPaymentStarted = false
}
// 关闭时重置标志
if (!newVal) {
@@ -183,11 +165,9 @@ watch(() => props.modelValue, (newVal) => {
// 监听 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
// 如果模态框关闭,停止轮询并重置二维码状态但保留paymentId和lastPlanType用于同套餐复用
// 如果模态框关闭,停止轮询
if (!newVal) {
stopPaymentPolling()
qrCodeUrl.value = ''
showQrCode.value = false
}
})
@@ -197,10 +177,6 @@ const selectMethod = async (method) => {
console.log('切换支付方式:', selectedMethod.value, '->', method, '复用 paymentId:', currentPaymentId.value)
selectedMethod.value = method
// 重置二维码显示
qrCodeUrl.value = ''
showQrCode.value = false
// 如果已有支付记录更新支付方式和描述复用paymentId
if (currentPaymentId.value) {
try {
@@ -230,22 +206,12 @@ const selectMethod = async (method) => {
if (response.ok && responseData.success) {
console.log('✅ 支付方式更新成功复用paymentId:', currentPaymentId.value)
// 如果切换到支付宝,生成新二维码
if (method === 'alipay') {
await handleAlipayPayment()
}
} else {
console.error('❌ 支付方式更新失败:', responseData.message || response.statusText)
}
} catch (error) {
console.error('❌ 更新支付方式异常:', error)
}
} else {
// 没有支付记录,如果是支付宝则自动创建
if (method === 'alipay') {
isPaymentStarted = true
await handlePay()
}
}
}
}
@@ -323,11 +289,7 @@ const handlePay = async () => {
// 处理支付宝支付
const handleAlipayPayment = async () => {
// 重置二维码显示状态
showQrCode.value = false
qrCodeUrl.value = ''
ElMessage.info('正在生成支付二维码...')
ElMessage.info('正在创建支付宝支付...')
const paymentId = currentPaymentId.value
console.log('=== 开始支付宝支付流程 ===')
@@ -338,26 +300,36 @@ const handleAlipayPayment = async () => {
console.log('支付宝支付响应:', alipayResponse)
if (alipayResponse.data && alipayResponse.data.success) {
// 显示二维码
const qrCode = alipayResponse.data.data.qrCode
console.log('支付宝二维码:', qrCode)
const responseData = alipayResponse.data.data
// 使用QuickChart API生成二维码
qrCodeUrl.value = `https://quickchart.io/qr?text=${encodeURIComponent(qrCode)}&size=200&margin=0&dark=ffffff&light=1a1a1a`
// 检查是否是电脑网页支付返回HTML表单
if (responseData.payType === 'PAGE_PAY' && responseData.payForm) {
console.log('使用电脑网页支付,跳转到支付宝页面')
ElMessage.success('正在跳转到支付宝支付页面...')
// 创建一个临时的div来渲染表单并自动提交
const div = document.createElement('div')
div.innerHTML = responseData.payForm
document.body.appendChild(div)
// 找到表单并提交
const form = div.querySelector('form')
if (form) {
form.submit()
} else {
console.error('未找到支付表单')
ElMessage.error('支付页面生成失败')
document.body.removeChild(div)
}
return
}
console.log('二维码图片URL已生成:', qrCodeUrl.value)
// 显示二维码
showQrCode.value = true
console.log('二维码已显示')
// 开始轮询支付状态
console.log('=== 开始轮询支付状态 ===')
startPaymentPolling(paymentId)
console.error('支付宝响应格式未知:', responseData)
ElMessage.error('支付创建失败,请重试')
} else {
console.error('支付宝响应失败:', alipayResponse)
ElMessage.error(alipayResponse.data?.message || '生成二维码失败')
emit('pay-error', new Error(alipayResponse.data?.message || '生成二维码失败'))
ElMessage.error(alipayResponse.data?.message || '支付创建失败')
emit('pay-error', new Error(alipayResponse.data?.message || '支付创建失败'))
}
}
@@ -962,6 +934,74 @@ const showAgreement = () => {
transform: none;
}
/* 支付宝支付区域 */
.alipay-section {
text-align: center;
margin-bottom: 24px;
padding: 32px 24px;
background: linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.alipay-info {
margin-bottom: 24px;
}
.alipay-logo {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 16px;
}
.alipay-desc {
color: white !important;
font-size: 16px !important;
font-weight: 500 !important;
margin: 8px 0 !important;
line-height: 1.5 !important;
}
.alipay-desc-small {
color: rgba(255, 255, 255, 0.7) !important;
font-size: 13px !important;
font-weight: 400 !important;
margin: 4px 0 !important;
line-height: 1.5 !important;
}
.alipay-pay-button {
width: 100%;
padding: 16px 24px;
background: linear-gradient(135deg, #1677FF 0%, #0958d9 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.3);
}
.alipay-pay-button:hover:not(:disabled) {
background: linear-gradient(135deg, #0958d9 0%, #003eb3 100%);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.4);
}
.alipay-pay-button:active:not(:disabled) {
transform: translateY(0);
}
.alipay-pay-button:disabled {
background: #666;
cursor: not-allowed;
opacity: 0.6;
transform: none;
}
/* 二维码区域 */
.qr-section {
text-align: center;

View File

@@ -133,6 +133,7 @@ export default {
generating: 'Generating',
completed: 'Completed',
failed: 'Failed',
failReason: 'Failure Reason',
prompt: 'Prompt',
promptPlaceholder: 'Enter video description...',
optimizePrompt: 'Optimize Prompt',
@@ -400,9 +401,15 @@ export default {
resumingStoryboardTask: 'Detected unfinished storyboard generation task, resuming...',
resumingTask: 'Detected unfinished task, resuming...',
storyboardReady: 'Storyboard generated, click button below to generate video',
readyForVideo: 'Storyboard ready, you can generate video now',
uploadStoryboardFirst: 'Please upload storyboard first',
regenerate: 'Regenerate',
regenerateConfirm: 'Regenerating will consume points and create a new task. Continue?',
regenerateTitle: 'Regenerate Storyboard',
generateVideo: 'Generate Video',
readyToGenerateVideo: 'Storyboard parameters filled, ready to generate video',
downloadImage: 'Download Storyboard',
imageUrlNotAvailable: 'Storyboard image URL not available',
statusPending: 'Pending',
statusProcessing: 'Processing',
statusCompleted: 'Completed',

View File

@@ -135,6 +135,7 @@ export default {
generating: '生成中',
completed: '已完成',
failed: '失败',
failReason: '失败原因',
prompt: '提示词',
promptPlaceholder: '请输入视频描述...',
optimizePrompt: '优化提示词',
@@ -404,9 +405,15 @@ export default {
resumingStoryboardTask: '检测到未完成的分镜图生成任务,继续处理中...',
resumingTask: '检测到未完成的任务,继续处理中...',
storyboardReady: '分镜图已生成,点击下方按钮生成视频',
readyForVideo: '分镜图已就绪,可以生成视频',
uploadStoryboardFirst: '请先上传分镜图',
regenerate: '重新生成',
regenerateConfirm: '重新生成将消耗积分并创建新任务,确定要继续吗?',
regenerateTitle: '重新生成分镜图',
generateVideo: '生成视频',
readyToGenerateVideo: '已填充分镜图参数,可以生成视频',
downloadImage: '下载分镜图',
imageUrlNotAvailable: '分镜图地址不可用',
statusPending: '排队中',
statusProcessing: '生成中',
statusCompleted: '已完成',
@@ -498,7 +505,8 @@ export default {
free: '免费版',
standard: '标准版',
professional: '专业版',
perMonth: '/年',
perMonth: '年',
perYear: '年',
subscribe: '立即订阅',
renew: '续费',
upgrade: '升级',

View File

@@ -0,0 +1,193 @@
/**
* 跨浏览器兼容的文件下载工具
* 特别针对 Safari/iOS 进行了优化
*/
/**
* 检测是否为 Safari 浏览器
*/
export const isSafari = () => {
const ua = navigator.userAgent.toLowerCase()
return ua.includes('safari') && !ua.includes('chrome') && !ua.includes('android')
}
/**
* 检测是否为 iOS 设备
*/
export const isIOS = () => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
}
/**
* 通用文件下载函数
* @param {string} url - 文件URL
* @param {string} filename - 下载文件名
* @param {string} mimeType - 文件MIME类型可选
* @returns {Promise<boolean>} - 下载是否成功
*/
export const downloadFile = async (url, filename, mimeType = '') => {
try {
// Safari 和 iOS 特殊处理
if (isSafari() || isIOS()) {
return await downloadForSafari(url, filename, mimeType)
}
// 其他浏览器使用标准方式
return await downloadStandard(url, filename)
} catch (error) {
console.error('下载失败,尝试备用方案:', error)
// 最终备用方案:新窗口打开
window.open(url, '_blank')
return false
}
}
/**
* Safari/iOS 专用下载方法
*/
const downloadForSafari = async (url, filename, mimeType) => {
try {
// 方案1尝试使用 fetch + blob
const response = await fetch(url, {
mode: 'cors',
credentials: 'omit'
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const blob = await response.blob()
// 创建带正确 MIME 类型的 blob
const finalMimeType = mimeType || blob.type || getMimeType(filename)
const finalBlob = new Blob([blob], { type: finalMimeType })
// Safari 需要使用 FileReader 转换为 data URL
return new Promise((resolve) => {
const reader = new FileReader()
reader.onloadend = () => {
const dataUrl = reader.result
// 创建临时链接
const link = document.createElement('a')
link.href = dataUrl
link.download = filename
link.style.display = 'none'
// Safari 需要将链接添加到 DOM
document.body.appendChild(link)
// 使用 setTimeout 确保 Safari 能正确处理
setTimeout(() => {
link.click()
// 延迟移除链接
setTimeout(() => {
document.body.removeChild(link)
}, 100)
resolve(true)
}, 0)
}
reader.onerror = () => {
// FileReader 失败,尝试直接打开
window.open(url, '_blank')
resolve(false)
}
reader.readAsDataURL(finalBlob)
})
} catch (error) {
console.error('Safari 下载失败:', error)
// 备用方案:直接打开新窗口
// 对于视频文件Safari 会显示播放器,用户可以长按保存
window.open(url, '_blank')
return false
}
}
/**
* 标准浏览器下载方法
*/
const downloadStandard = async (url, filename) => {
try {
const response = await fetch(url, {
mode: 'cors',
credentials: 'omit'
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const blob = await response.blob()
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// 清理
setTimeout(() => {
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
}, 100)
return true
} catch (error) {
console.error('标准下载失败:', error)
throw error
}
}
/**
* 根据文件名获取 MIME 类型
*/
const getMimeType = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase()
const mimeTypes = {
'mp4': 'video/mp4',
'webm': 'video/webm',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp'
}
return mimeTypes[ext] || 'application/octet-stream'
}
/**
* 下载视频文件
*/
export const downloadVideo = async (url, taskId) => {
const filename = `video_${taskId || Date.now()}.mp4`
return downloadFile(url, filename, 'video/mp4')
}
/**
* 下载图片文件
*/
export const downloadImage = async (url, taskId, prefix = 'image') => {
// 根据 URL 判断图片格式
let ext = 'png'
if (url.includes('.jpg') || url.includes('.jpeg')) {
ext = 'jpg'
} else if (url.includes('.webp')) {
ext = 'webp'
}
const filename = `${prefix}_${taskId || Date.now()}.${ext}`
const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
return downloadFile(url, filename, mimeType)
}

View File

@@ -61,15 +61,7 @@
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="apiManagement">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.apiManagement') }}</span>
</el-dropdown-item>
<el-dropdown-item command="taskRecord">
<el-icon><Document /></el-icon>
<span>{{ $t('nav.tasks') }}</span>
</el-dropdown-item>
<el-dropdown-item divided command="exitAdmin">
<el-dropdown-item command="exitAdmin">
{{ $t('admin.exitAdmin') }}
</el-dropdown-item>
</el-dropdown-menu>

View File

@@ -37,7 +37,14 @@
<!-- 图片输入区域 -->
<div class="image-input-section">
<div class="image-upload-area">
<div class="upload-box" @click="uploadFirstFrame">
<div
class="upload-box"
:class="{ 'drag-over': isDraggingFirstFrame }"
@click="uploadFirstFrame"
@dragover.prevent="isDraggingFirstFrame = true"
@dragleave.prevent="isDraggingFirstFrame = false"
@drop.prevent="handleDropFirstFrame"
>
<div v-if="!firstFrameImage" class="upload-placeholder">
<div class="upload-icon">+</div>
<div class="upload-text">{{ t('video.imageToVideo.firstFrame') }}</div>
@@ -199,6 +206,10 @@
<div class="failed-icon"></div>
<div class="failed-text">{{ t('video.imageToVideo.generateFailed') }}</div>
<div class="failed-desc">{{ t('video.imageToVideo.generateFailedDesc') }}</div>
<div class="failed-reason" v-if="currentTask && currentTask.errorMessage">
<span class="reason-label">{{ t('video.failReason') }}</span>
<span class="reason-text">{{ currentTask.errorMessage }}</span>
</div>
</div>
<div class="result-actions">
<button class="action-btn primary" @click="retryTask">{{ t('video.imageToVideo.retry') }}</button>
@@ -258,19 +269,15 @@
<div class="queue-link">{{ t('video.imageToVideo.subscribeToSpeedUp') }}</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('video.imageToVideo.cancel') }}</button>
</div>
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl" class="history-video-thumbnail" @click="toggleHistoryVideo(task)">
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl" class="history-video-thumbnail">
<video
:ref="el => setVideoRef(task.taskId, el)"
:src="processHistoryUrl(task.resultUrl)"
muted
controls
preload="metadata"
@loadedmetadata="handleVideoLoaded"
@error="handleVideoError"
@click.stop="toggleHistoryVideo(task)"
></video>
<div class="play-overlay" v-if="!playingVideos[task.taskId]">
<div class="play-icon"></div>
</div>
</div>
<div v-else-if="task.firstFrameUrl" class="history-image-thumbnail">
<img :src="task.firstFrameUrl" :alt="t('video.imageToVideo.firstFrameImage')" />
@@ -382,6 +389,7 @@ const firstFrameImage = ref('')
const lastFrameImage = ref('')
const firstFrameFile = ref(null)
const lastFrameFile = ref(null)
const isDraggingFirstFrame = ref(false)
// 任务状态
const currentTask = ref(null)
@@ -544,6 +552,34 @@ const uploadFirstFrame = () => {
input.click()
}
// 处理拖拽上传首帧图片
const handleDropFirstFrame = (e) => {
isDraggingFirstFrame.value = false
const files = e.dataTransfer.files
if (files.length === 0) return
const file = files[0]
if (!file.type.startsWith('image/')) {
ElMessage.error(t('video.imageToVideo.invalidImageFile'))
return
}
const maxFileSize = 100 * 1024 * 1024 // 100MB
if (file.size > maxFileSize) {
ElMessage.error(t('video.imageToVideo.fileSizeLimit'))
return
}
firstFrameFile.value = file
console.log('[Drop] 文件已设置:', file.name, file.size, 'bytes')
const reader = new FileReader()
reader.onload = (e) => {
firstFrameImage.value = e.target.result
console.log('[Drop] 图片预览已设置base64长度:', e.target.result.length)
}
reader.readAsDataURL(file)
}
const uploadLastFrame = () => {
const input = document.createElement('input')
input.type = 'file'
@@ -1021,25 +1057,13 @@ const downloadVideo = async () => {
try {
ElMessage.info('正在准备下载...')
// 获取视频文件
const response = await fetch(currentTask.value.resultUrl)
const blob = await response.blob()
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `video_${currentTask.value.taskId || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.imageToVideo.startDownload'))
const { downloadVideo: downloadVideoUtil } = await import('@/utils/download')
const success = await downloadVideoUtil(currentTask.value.resultUrl, currentTask.value.taskId || Date.now())
if (success) {
ElMessage.success(t('video.imageToVideo.startDownload'))
}
} catch (error) {
console.error('下载失败:', error)
// 备用方案:直接打开链接
window.open(currentTask.value.resultUrl, '_blank')
}
}
@@ -1054,20 +1078,11 @@ const downloadHistoryVideo = async (task) => {
try {
ElMessage.info('正在准备下载...')
const videoUrl = processHistoryUrl(task.resultUrl)
const response = await fetch(videoUrl)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `video_${task.taskId || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.imageToVideo.startDownload'))
const { downloadVideo: downloadVideoUtil } = await import('@/utils/download')
const success = await downloadVideoUtil(videoUrl, task.taskId || Date.now())
if (success) {
ElMessage.success(t('video.imageToVideo.startDownload'))
}
} catch (error) {
console.error('下载失败:', error)
window.open(processHistoryUrl(task.resultUrl), '_blank')
@@ -1186,14 +1201,14 @@ const loadHistory = async () => {
}
try {
// 请求更多条数以确保能筛选出足够的任务
const response = await imageToVideoApi.getTasks(0, 50)
// 请求全部已完成的任务,不限制数量
const response = await imageToVideoApi.getTasks(0, 1000)
console.log('历史记录API响应:', response.data)
if (response.data && response.data.success) {
// 只显示已完成的任务,不显示失败的任务
const tasks = (response.data.data || []).filter(task =>
task.status === 'COMPLETED'
).slice(0, 10)
)
// 处理URL确保相对路径正确
historyTasks.value = tasks.map(task => ({
@@ -1502,7 +1517,7 @@ onMounted(async () => {
// 注意firstFrameFile 为 null用户需要重新上传或点击生成时会提示
}
ElMessage.success(t('video.imageToVideo.historyParamsFilled') || '已填充历史参数')
// 静默填充,不显示弹窗提示(减少干扰)
// 清除URL中的query参数避免刷新页面重复填充
router.replace({ path: route.path })
}
@@ -1788,6 +1803,12 @@ onUnmounted(() => {
background: #1a1a1a;
}
.upload-box.drag-over {
border-color: #3b82f6;
background: #1a2a3a;
border-style: solid;
}
.upload-box.optional {
opacity: 0.7;
}
@@ -2583,6 +2604,30 @@ onUnmounted(() => {
color: #9ca3af;
}
.failed-reason {
margin-top: 16px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border-radius: 8px;
border: 1px solid rgba(239, 68, 68, 0.2);
text-align: left;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.failed-reason .reason-label {
color: #ef4444;
font-weight: 500;
font-size: 13px;
}
.failed-reason .reason-text {
color: #f87171;
font-size: 13px;
word-break: break-word;
}
/* 其他状态 */
.status-placeholder {
width: 100%;

View File

@@ -10,7 +10,7 @@
<!-- 欢迎标题 -->
<div class="welcome-title">
<span class="welcome-text">欢迎来到</span>
<span class="brand-name">VidFlow</span>
<span class="brand-name">Vionow</span>
</div>
<!-- 登录方式切换 -->

View File

@@ -220,10 +220,10 @@
<div class="title" :title="item.title">{{ item.title }}</div>
<div class="sub">
{{ item.date || t('profile.unknown') }} · {{ item.id }}
<span v-if="item.quality" class="quality-badge" :class="`quality-${(item.quality || '').toLowerCase()}`">
<span v-if="item.quality && item.type !== 'image'" class="quality-badge" :class="`quality-${(item.quality || '').toLowerCase()}`">
{{ formatQuality(item.quality) }}
</span>
· {{ item.sizeText }}
<span v-if="item.sizeText && item.sizeText !== '未知大小'"> · {{ item.sizeText }}</span>
</div>
</div>
<template #footer>
@@ -345,11 +345,11 @@
<span class="label">{{ t('profile.workId') }}</span>
<span class="value">{{ selectedItem.taskId }}</span>
</div>
<div class="metadata-item">
<div class="metadata-item" v-if="selectedItem.type !== 'image'">
<span class="label">{{ t('profile.duration') }}</span>
<span class="value">{{ formatDuration(selectedItem.duration) || '5s' }}</span>
</div>
<div class="metadata-item">
<div class="metadata-item" v-if="selectedItem.type !== 'image'">
<span class="label">{{ t('profile.quality') }}</span>
<span class="value">{{ selectedItem.quality || '1080p' }}</span>
</div>
@@ -511,6 +511,8 @@ const hasMore = ref(true)
const items = ref([])
const showBackToTop = ref(false) // 回到顶部按钮显示状态
const failedUrls = ref(new Set()) // 记录加载失败的URL
const pollingIntervalId = ref(null) // 轮询定时器ID
const POLLING_INTERVAL = 120000 // 2分钟轮询间隔
// 处理URL确保相对路径正确
const processUrl = (url) => {
@@ -564,6 +566,9 @@ const transformWorkData = (work) => {
quality: work.quality || work.resolution || '',
username: work.username || work.user?.username || work.creator || work.author || work.owner || '未知用户',
status: work.status || 'COMPLETED',
uploadedImages: work.uploadedImages || null, // 用户上传的参考图,用于做同款
imagePrompt: work.imagePrompt || null, // 分镜图优化后的提示词
workType: work.workType || '', // 原始作品类型
// overlayText 已移除,前端详情不再显示浮动文本
}
}
@@ -689,6 +694,9 @@ const loadList = async () => {
if (page.value === 1) items.value = []
items.value = items.value.concat(transformedData)
hasMore.value = data.length === pageSize.value
// 检查是否有处理中的任务,如果有则启动轮询
checkAndStartPolling()
} else {
throw new Error(response.data.message || t('profile.loadWorksFailed'))
}
@@ -700,12 +708,80 @@ const loadList = async () => {
}
}
// 检查是否有处理中的任务,如果有则启动轮询
const checkAndStartPolling = () => {
const hasProcessingTasks = items.value.some(
item => item.status === 'PROCESSING' || item.status === 'PENDING'
)
if (hasProcessingTasks && !pollingIntervalId.value) {
console.log('[MyWorks] 检测到处理中的任务启动2分钟轮询')
startPolling()
} else if (!hasProcessingTasks && pollingIntervalId.value) {
console.log('[MyWorks] 没有处理中的任务,停止轮询')
stopPolling()
}
}
// 启动轮询
const startPolling = () => {
if (pollingIntervalId.value) return // 避免重复启动
pollingIntervalId.value = setInterval(async () => {
console.log('[MyWorks] 执行轮询刷新...')
// 静默刷新不显示loading
try {
const response = await getMyWorks({
page: 0,
size: pageSize.value
})
if (response.data.success) {
const data = response.data.data || []
const transformedData = data
.map(transformWorkData)
.filter(work => work.status !== 'FAILED' && work.status !== 'DELETED')
// 更新列表
items.value = transformedData
// 检查是否还需要继续轮询
const hasProcessingTasks = transformedData.some(
item => item.status === 'PROCESSING' || item.status === 'PENDING'
)
if (!hasProcessingTasks) {
console.log('[MyWorks] 所有任务已完成,停止轮询')
stopPolling()
// 刷新用户积分
await userStore.fetchCurrentUser()
}
}
} catch (error) {
console.error('[MyWorks] 轮询刷新失败:', error)
}
}, POLLING_INTERVAL)
}
// 停止轮询
const stopPolling = () => {
if (pollingIntervalId.value) {
clearInterval(pollingIntervalId.value)
pollingIntervalId.value = null
console.log('[MyWorks] 轮询已停止')
}
}
// 筛选后的作品列表
const filteredItems = computed(() => {
let filtered = [...items.value]
// 过滤掉加载失败的作品
// 过滤掉加载失败的作品(但保留 PROCESSING 和 PENDING 状态的作品)
filtered = filtered.filter(item => {
// PROCESSING 和 PENDING 状态的作品始终保留,不受 failedUrls 影响
if (item.status === 'PROCESSING' || item.status === 'PENDING') {
return true
}
const resultUrlFailed = item.resultUrl && failedUrls.value.has(item.resultUrl)
const coverFailed = item.cover && failedUrls.value.has(item.cover)
return !resultUrlFailed && !coverFailed
@@ -1028,22 +1104,41 @@ const createSimilar = (item) => {
taskId: item.taskId,
prompt: item.prompt || '',
aspectRatio: item.aspectRatio || '',
duration: item.duration || ''
duration: item.duration || '',
hdMode: item.quality === 'HD' ? 'true' : 'false'
}
// 添加参考图(图生视频需要,分镜图的 cover 是生成结果不传递)
// 分镜图的 cover 是生成的分镜图,不是用户上传的原始参考图
if (item.category !== '分镜图' && item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
// 添加参考图(图生视频需要,分镜图/分镜视频的 cover 是生成结果不传递到 referenceImage
if (item.category !== '分镜图' && item.category !== '分镜视频' && item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.referenceImage = item.cover
}
console.log('[做同款] 跳转参数:', query, 'category:', item.category)
// 用户上传的参考图(分镜图/分镜视频做同款需要)
// uploadedImages 现在存储的是 COS URL可以直接通过 URL 传递
if (item.uploadedImages) {
query.uploadedImages = item.uploadedImages
}
console.log('[做同款] 跳转参数:', query, 'category:', item.category, 'workType:', item.workType)
if (item.category === '文生视频') {
router.push({ path: '/text-to-video/create', query })
} else if (item.category === '图生视频') {
router.push({ path: '/image-to-video/create', query })
} else if (item.category === '分镜视频' || item.category === '分镜图') {
} else if (item.category === '分镜' || item.workType === 'STORYBOARD_IMAGE') {
// 分镜图做同款:进入 Step 1生成分镜图使用 imagePrompt
query.step = 'image'
if (item.imagePrompt) {
query.imagePrompt = item.imagePrompt
}
router.push({ path: '/storyboard-video/create', query })
} else if (item.category === '分镜视频') {
// 分镜视频做同款:进入 Step 2生成视频携带已生成的分镜图
query.step = 'video'
// cover 是分镜图thumbnailUrl传递给 Step 2 作为分镜图参考
if (item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.storyboardImage = item.cover
}
router.push({ path: '/storyboard-video/create', query })
} else {
// 默认跳转到文生视频
@@ -1070,36 +1165,20 @@ const download = async (item) => {
console.warn('记录下载次数失败:', err)
}
// 尝试直接从 resultUrl 下载(绕过后端代理)
// 使用兼容 Safari 的下载工具
const videoUrl = item.resultUrl
console.log('直接下载视频URL:', videoUrl)
console.log('下载URL:', videoUrl)
try {
const response = await fetch(videoUrl)
if (!response.ok) {
throw new Error(`下载失败: ${response.status}`)
}
const blob = await response.blob()
console.log('文件大小:', blob.size, 'bytes')
if (blob.size === 0) {
throw new Error('文件内容为空')
}
const blobUrl = window.URL.createObjectURL(blob)
const { downloadFile } = await import('@/utils/download')
const filename = `${item.title || 'work'}_${Date.now()}${item.type === 'video' ? '.mp4' : '.png'}`
const mimeType = item.type === 'video' ? 'video/mp4' : 'image/png'
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000)
ElMessage.success(t('works.downloadComplete'))
return
const success = await downloadFile(videoUrl, filename, mimeType)
if (success) {
ElMessage.success(t('works.downloadComplete'))
return
}
} catch (directError) {
console.warn('直接下载失败,尝试后端代理:', directError)
}
@@ -1134,9 +1213,6 @@ const download = async (item) => {
throw new Error('文件内容为空可能URL已过期')
}
// 创建 blob URL
const blobUrl = window.URL.createObjectURL(blob)
// 从响应头获取文件名
const contentDisposition = response.headers.get('content-disposition')
let filename = item.title || 'work'
@@ -1155,13 +1231,10 @@ const download = async (item) => {
console.log('下载文件名:', filename)
// 创建下载链接并触发下载
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
// 使用兼容工具下载 blob
const blobUrl = window.URL.createObjectURL(blob)
const { downloadFile } = await import('@/utils/download')
await downloadFile(blobUrl, filename, item.type === 'video' ? 'video/mp4' : 'image/png')
// 延迟释放 blob URL
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000)
@@ -1560,6 +1633,8 @@ onMounted(async () => {
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
// 清理轮询定时器
stopPolling()
})
// 当页面被激活时(从其他页面返回时)刷新列表

View File

@@ -126,8 +126,7 @@
</div>
</div>
<div class="video-action">
<el-button v-if="index === 0 && video.status === 'COMPLETED'" type="primary" size="small" @click.stop="createSimilar(video)">{{ t('profile.createSimilar') }}</el-button>
<span v-else-if="video.status === 'COMPLETED'" class="director-text">DIRECTED BY VANNOCENT</span>
<el-button v-if="video.status === 'COMPLETED'" type="primary" size="small" @click.stop="createSimilar(video)">{{ t('profile.createSimilar') }}</el-button>
<span v-else-if="video.status === 'FAILED'" class="status-text failed">生成失败</span>
<span v-else class="status-text processing">{{ video.status === 'PENDING' ? '排队中...' : '生成中...' }}</span>
</div>
@@ -341,6 +340,8 @@ const userLoading = ref(false)
// 视频数据
const videos = ref([])
const loading = ref(false)
const pollingIntervalId = ref(null) // 轮询定时器ID
const POLLING_INTERVAL = 120000 // 2分钟轮询间隔
// 详情弹窗
const detailDialogVisible = ref(false)
@@ -536,18 +537,37 @@ const createSimilar = (item) => {
}
// 添加参考图(分镜图的 cover 是生成结果,不是原始参考图,不传递)
if (item.category !== '分镜图' && item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
if (item.category !== '分镜图' && item.category !== '分镜视频' && item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.referenceImage = item.cover
}
console.log('[做同款] 跳转参数:', query, 'category:', item.category)
// 用户上传的参考图(分镜图/分镜视频做同款需要)
// uploadedImages 现在存储的是 COS URL可以直接通过 URL 传递
if (item.uploadedImages) {
query.uploadedImages = item.uploadedImages
}
console.log('[做同款] 跳转参数:', query, 'category:', item.category, 'workType:', item.workType)
// 根据作品类型跳转
if (item.category === '文生视频') {
router.push({ path: '/text-to-video/create', query })
} else if (item.category === '图生视频') {
router.push({ path: '/image-to-video/create', query })
} else if (item.category === '分镜视频' || item.category === '分镜图') {
} else if (item.category === '分镜' || item.workType === 'STORYBOARD_IMAGE') {
// 分镜图做同款:进入 Step 1生成分镜图使用 imagePrompt
query.step = 'image'
if (item.imagePrompt) {
query.imagePrompt = item.imagePrompt
}
router.push({ path: '/storyboard-video/create', query })
} else if (item.category === '分镜视频') {
// 分镜视频做同款:进入 Step 2生成视频携带已生成的分镜图
query.step = 'video'
// cover 是分镜图thumbnailUrl传递给 Step 2 作为分镜图参考
if (item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
query.storyboardImage = item.cover
}
router.push({ path: '/storyboard-video/create', query })
} else {
// 默认根据类型跳转
@@ -600,6 +620,9 @@ const transformWorkData = (work) => {
quality: work.quality || work.resolution || '',
username: work.username || work.user?.username || work.creator || work.author || work.owner || '未知用户',
status: work.status || 'COMPLETED',
uploadedImages: work.uploadedImages || null, // 用户上传的参考图,用于做同款
imagePrompt: work.imagePrompt || null, // 分镜图优化后的提示词
workType: work.workType || '', // 原始作品类型
}
}
@@ -680,6 +703,9 @@ const loadVideos = async () => {
.map(transformWorkData)
.filter(work => work.status !== 'FAILED' && work.status !== 'DELETED')
console.log('转换后的作品列表:', videos.value)
// 检查是否有处理中的任务,如果有则启动轮询
checkAndStartPolling()
} else {
console.error('获取作品列表失败:', response?.data?.message || '未知错误')
}
@@ -691,6 +717,66 @@ const loadVideos = async () => {
}
}
// 检查是否有处理中的任务,如果有则启动轮询
const checkAndStartPolling = () => {
const hasProcessingTasks = videos.value.some(
item => item.status === 'PROCESSING' || item.status === 'PENDING'
)
if (hasProcessingTasks && !pollingIntervalId.value) {
console.log('[Profile] 检测到处理中的任务启动2分钟轮询')
startPolling()
} else if (!hasProcessingTasks && pollingIntervalId.value) {
console.log('[Profile] 没有处理中的任务,停止轮询')
stopPolling()
}
}
// 启动轮询
const startPolling = () => {
if (pollingIntervalId.value) return // 避免重复启动
pollingIntervalId.value = setInterval(async () => {
console.log('[Profile] 执行轮询刷新...')
try {
const response = await getMyWorks({
page: 0,
size: 10
})
if (response && response.data && response.data.success) {
const data = response.data.data || []
videos.value = data
.map(transformWorkData)
.filter(work => work.status !== 'FAILED' && work.status !== 'DELETED')
// 检查是否还需要继续轮询
const hasProcessingTasks = videos.value.some(
item => item.status === 'PROCESSING' || item.status === 'PENDING'
)
if (!hasProcessingTasks) {
console.log('[Profile] 所有任务已完成,停止轮询')
stopPolling()
// 刷新用户积分
await userStore.fetchCurrentUser()
}
}
} catch (error) {
console.error('[Profile] 轮询刷新失败:', error)
}
}, POLLING_INTERVAL)
}
// 停止轮询
const stopPolling = () => {
if (pollingIntervalId.value) {
clearInterval(pollingIntervalId.value)
pollingIntervalId.value = null
console.log('[Profile] 轮询已停止')
}
}
// 编辑个人资料
const editProfile = () => {
// TODO: 可以跳转到编辑页面或打开编辑对话框
@@ -743,6 +829,8 @@ onMounted(async () => {
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
// 清理轮询定时器
stopPolling()
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -176,6 +176,10 @@
<div class="failed-icon"></div>
<div class="failed-text">{{ t('video.textToVideo.generationFailed') }}</div>
<div class="failed-desc">{{ t('video.textToVideo.checkInputOrRetry') }}</div>
<div class="failed-reason" v-if="currentTask && currentTask.errorMessage">
<span class="reason-label">{{ t('video.failReason') }}</span>
<span class="reason-text">{{ currentTask.errorMessage }}</span>
</div>
</div>
<div class="result-actions">
<button class="action-btn primary" @click="retryTask">{{ t('video.textToVideo.regenerate') }}</button>
@@ -235,19 +239,15 @@
<div class="queue-link">{{ t('video.textToVideo.subscribeToSpeedUp') }}</div>
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('common.cancel') }}</button>
</div>
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl" class="history-video-thumbnail" @click="toggleHistoryVideo(task)">
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl" class="history-video-thumbnail">
<video
:ref="el => setVideoRef(task.taskId, el)"
:src="processHistoryUrl(task.resultUrl)"
muted
controls
preload="metadata"
@loadedmetadata="handleVideoLoaded"
@error="handleVideoError"
@click.stop="toggleHistoryVideo(task)"
></video>
<div class="play-overlay" v-if="!playingVideos[task.taskId]">
<div class="play-icon"></div>
</div>
</div>
<div v-else class="history-placeholder">
<div class="no-result-text">{{ t('video.textToVideo.noResult') }}</div>
@@ -778,25 +778,13 @@ const downloadVideo = async () => {
try {
ElMessage.info('正在准备下载...')
// 获取视频文件
const response = await fetch(currentTask.value.resultUrl)
const blob = await response.blob()
// 创建下载链接
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `video_${currentTask.value.taskId || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.textToVideo.downloadStarted'))
const { downloadVideo: downloadVideoUtil } = await import('@/utils/download')
const success = await downloadVideoUtil(currentTask.value.resultUrl, currentTask.value.taskId || Date.now())
if (success) {
ElMessage.success(t('video.textToVideo.downloadStarted'))
}
} catch (error) {
console.error('下载失败:', error)
// 备用方案:直接打开链接
window.open(currentTask.value.resultUrl, '_blank')
}
}
@@ -811,20 +799,11 @@ const downloadHistoryVideo = async (task) => {
try {
ElMessage.info('正在准备下载...')
const videoUrl = processHistoryUrl(task.resultUrl)
const response = await fetch(videoUrl)
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `video_${task.taskId || Date.now()}.mp4`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success(t('video.textToVideo.downloadStarted'))
const { downloadVideo: downloadVideoUtil } = await import('@/utils/download')
const success = await downloadVideoUtil(videoUrl, task.taskId || Date.now())
if (success) {
ElMessage.success(t('video.textToVideo.downloadStarted'))
}
} catch (error) {
console.error('下载失败:', error)
window.open(processHistoryUrl(task.resultUrl), '_blank')
@@ -921,13 +900,13 @@ const loadHistory = async () => {
}
try {
// 请求更多条数以确保能筛选出足够的 COMPLETED 任务
const response = await textToVideoApi.getTasks(0, 50)
// 请求全部已完成的任务,不限制数量
const response = await textToVideoApi.getTasks(0, 1000)
if (response.data && response.data.success) {
// 只显示已完成的任务,排队中和处理中的任务在主创作区显示
const tasks = (response.data.data || []).filter(task =>
task.status === 'COMPLETED'
).slice(0, 5)
)
// 处理URL确保相对路径正确
historyTasks.value = tasks.map(task => ({
@@ -1182,7 +1161,7 @@ onMounted(async () => {
if (route.query.duration) {
duration.value = parseInt(route.query.duration) || 10
}
ElMessage.success(t('video.textToVideo.historyParamsFilled'))
// 静默填充,不显示弹窗提示(减少干扰)
// 清除URL中的query参数避免刷新页面重复填充
router.replace({ path: route.path })
}
@@ -2181,6 +2160,30 @@ onUnmounted(() => {
color: #9ca3af;
}
.failed-reason {
margin-top: 16px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border-radius: 8px;
border: 1px solid rgba(239, 68, 68, 0.2);
text-align: left;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.failed-reason .reason-label {
color: #ef4444;
font-weight: 500;
font-size: 13px;
}
.failed-reason .reason-text {
color: #f87171;
font-size: 13px;
word-break: break-word;
}
/* 其他状态 */
.status-placeholder {
width: 100%;

View File

@@ -1,369 +0,0 @@
# TextToVideoCreate.vue 国际化支持完整报告
## 修改概述
已为 `TextToVideoCreate.vue`(文生视频创建页面)添加完整的国际化支持,将所有硬编码的中文文字替换为 `t()` 函数调用。
## 修改详情
### 1. 导入和初始化
**添加的导入:**
```javascript
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
```
### 2. 模板部分替换(共计 30+ 处)
#### 2.1 顶部导航栏
- `← 首页``{{ t('common.home') }}`
- `alt="用户头像"``:alt="t('video.textToVideo.userAvatar')"`
#### 2.2 创作模式标签
- `文生视频``{{ t('home.textToVideo') }}`
- `图生视频``{{ t('home.imageToVideo') }}`
- `分镜视频``{{ t('home.storyboardVideo') }}`
#### 2.3 文本输入区域
- `placeholder="输入文字,描述想要生成的内容"``:placeholder="t('video.textToVideo.textInputPlaceholder')"`
- `优化中...``{{ t('video.textToVideo.optimizing') }}`
- `一键优化``{{ t('video.textToVideo.oneClickOptimize') }}`
#### 2.4 视频设置
- `比例``{{ t('video.textToVideo.aspectRatio') }}`
- `时长``{{ t('video.duration') }}`
- `高清模式 (1080P)``{{ t('video.textToVideo.hdMode') }}`
- `开启消耗20积分``{{ t('video.textToVideo.hdModeCost') }}`
#### 2.5 生成按钮区域
- `开始生成``{{ t('video.textToVideo.startGenerate') }}`
- `请先登录``{{ t('video.textToVideo.pleaseLogin') }}`
- `需要登录后才能提交任务``{{ t('video.textToVideo.loginRequired') }}`
- `立即登录``{{ t('video.textToVideo.loginNow') }}`
#### 2.6 任务状态显示
- `文生视频 [日期]``{{ t('home.textToVideo') }} {{ formatDate(...) }}`
- `生成中``{{ t('video.generating') }}`
- `进行中``{{ t('video.textToVideo.inProgress') }}`
- `视频生成完成,但未获取到视频链接``{{ t('video.textToVideo.noVideoUrl') }}`
#### 2.7 水印选择
- `带水印``{{ t('video.textToVideo.withWatermark') }}`
- `不带水印 会员专享``{{ t('video.textToVideo.withoutWatermark') }}`
#### 2.8 操作按钮
- `做同款``{{ t('video.textToVideo.createSimilar') }}`
- `title="下载视频"``:title="t('video.textToVideo.downloadVideo')"`
- `title="删除作品"``:title="t('video.textToVideo.deleteWork')"`
#### 2.9 失败状态
- `生成失败``{{ t('video.textToVideo.generationFailed') }}`
- `请检查输入内容或重试``{{ t('video.textToVideo.checkInputOrRetry') }}`
- `重新生成``{{ t('video.textToVideo.regenerate') }}`
#### 2.10 初始状态
- `开始创作您的第一个作品吧!``{{ t('video.textToVideo.startCreating') }}`
#### 2.11 历史记录区域
- `进行中``{{ t('video.textToVideo.inProgress') }}`
- `文生视频``{{ t('home.textToVideo') }}`
- `无描述``{{ t('video.textToVideo.noDescription') }}`
- `排队中``{{ t('video.textToVideo.queuing') }}`
- `订阅套餐以提升生成速度``{{ t('video.textToVideo.subscribeToSpeedUp') }}`
- `取消``{{ t('common.cancel') }}`
- `暂无结果``{{ t('video.textToVideo.noResult') }}`
- `做同款``{{ t('video.textToVideo.createSimilar') }}`
#### 2.12 用户菜单
- `个人资料``{{ t('common.userProfile') }}`
- `我的作品``{{ t('profile.myWorks') }}`
- `会员订阅``{{ t('profile.subscription') }}`
- `系统设置``{{ t('common.settings') }}`
- `退出登录``{{ t('common.logout') }}`
### 3. JavaScript 部分替换(共计 30+ 处)
#### 3.1 startGenerate 函数
- `'请先登录后再提交任务'``t('video.textToVideo.pleaseLoginFirst')`
- `'已有任务在进行中,请等待完成或取消当前任务'``t('video.textToVideo.taskInProgress')`
- `'请输入文本描述'``t('video.textToVideo.pleaseEnterText')`
- `'正在创建任务...'``t('video.textToVideo.creatingTask')`
- `'任务创建成功,开始处理...'``t('video.textToVideo.taskCreated')`
- `'创建任务失败'``t('video.textToVideo.createTaskFailed')`
#### 3.2 startPollingTask 函数
- `'视频生成完成!'``t('video.textToVideo.videoCompleted')`
- `'视频生成失败:'``t('video.textToVideo.videoFailed')`
#### 3.3 getStatusText 函数
```javascript
const statusMap = {
'PENDING': t('video.textToVideo.statusPending'),
'PROCESSING': t('video.textToVideo.statusProcessing'),
'COMPLETED': t('video.completed'),
'FAILED': t('video.failed'),
'CANCELLED': t('video.textToVideo.statusCancelled')
}
return statusMap[status] || t('video.textToVideo.statusUnknown')
```
#### 3.4 optimizePromptHandler 函数
- `'请输入提示词'``t('video.textToVideo.pleaseEnterPrompt')`
- `'提示词过长请控制在2000字符以内'``t('video.textToVideo.promptTooLong')`
- `'正在优化提示词,请稍候...'``t('video.textToVideo.optimizingPrompt')`
- `'提示词优化成功!'``t('video.textToVideo.optimizeSuccess')`
- `'提示词已优化,但可能无明显变化'``t('video.textToVideo.optimizeNoChange')`
- `'优化失败'``t('video.textToVideo.optimizeFailed')`
- `'请求参数错误'``t('video.textToVideo.requestParamError')`
- `'请求超时,请稍后重试'``t('video.textToVideo.requestTimeout')`
- `'服务器错误,请稍后重试'``t('video.textToVideo.serverError')`
- `'网络错误,请检查网络连接'``t('video.textToVideo.networkError')`
- `'网络连接错误,请检查您的网络'``t('video.textToVideo.networkConnectionError')`
#### 3.5 downloadVideo 函数
- `'开始下载视频'``t('video.textToVideo.downloadStarted')`
- `'视频链接不可用'``t('video.textToVideo.videoUrlNotAvailable')`
#### 3.6 deleteWork 函数
- `'没有可删除的作品'``t('video.textToVideo.noWorkToDelete')`
- `'确定要删除这个作品吗?'``t('video.textToVideo.deleteConfirm')`
- `'确认删除'``t('video.textToVideo.confirmDelete')`
- `'确定'``t('common.confirm')`
- `'取消'``t('common.cancel')`
- `'作品已删除'``t('video.textToVideo.workDeleted')`
- `'已取消删除'``t('video.textToVideo.deleteCancelled')`
#### 3.7 createSimilarFromHistory 函数
- `'已填充历史记录参数,可以开始生成'``t('video.textToVideo.historyParamsFilled')`
#### 3.8 cancelTask 函数
- `'取消功能待实现'``t('video.textToVideo.cancelFunctionTBD')`
#### 3.9 restoreProcessingTask 函数
- `'检测到未完成的任务,继续处理中...'``t('video.textToVideo.unfinishedTaskDetected')`
## 需要新增的翻译键列表
### 在 `video.textToVideo.*` 命名空间下新增(共 40 个键):
```javascript
video: {
textToVideo: {
// 界面文本
userAvatar: '用户头像',
textInputPlaceholder: '输入文字,描述想要生成的内容',
optimizing: '优化中...',
oneClickOptimize: '一键优化',
aspectRatio: '比例',
hdMode: '高清模式 (1080P)',
hdModeCost: '开启消耗20积分',
startGenerate: '开始生成',
pleaseLogin: '请先登录',
loginRequired: '需要登录后才能提交任务',
loginNow: '立即登录',
// 任务状态
inProgress: '进行中',
noVideoUrl: '视频生成完成,但未获取到视频链接',
// 水印选项
withWatermark: '带水印',
withoutWatermark: '不带水印 会员专享',
// 操作按钮
createSimilar: '做同款',
downloadVideo: '下载视频',
deleteWork: '删除作品',
// 状态文本
generationFailed: '生成失败',
checkInputOrRetry: '请检查输入内容或重试',
regenerate: '重新生成',
startCreating: '开始创作您的第一个作品吧!',
// 历史记录
noDescription: '无描述',
queuing: '排队中',
subscribeToSpeedUp: '订阅套餐以提升生成速度',
noResult: '暂无结果',
// 消息提示
pleaseLoginFirst: '请先登录后再提交任务',
taskInProgress: '已有任务在进行中,请等待完成或取消当前任务',
pleaseEnterText: '请输入文本描述',
creatingTask: '正在创建任务...',
taskCreated: '任务创建成功,开始处理...',
createTaskFailed: '创建任务失败',
videoCompleted: '视频生成完成!',
videoFailed: '视频生成失败:',
// 状态映射
statusPending: '等待中',
statusProcessing: '处理中',
statusCancelled: '已取消',
statusUnknown: '未知',
// 优化提示词
pleaseEnterPrompt: '请输入提示词',
promptTooLong: '提示词过长请控制在2000字符以内',
optimizingPrompt: '正在优化提示词,请稍候...',
optimizeSuccess: '提示词优化成功!',
optimizeNoChange: '提示词已优化,但可能无明显变化',
optimizeFailed: '优化失败',
requestParamError: '请求参数错误',
requestTimeout: '请求超时,请稍后重试',
serverError: '服务器错误,请稍后重试',
networkError: '网络错误,请检查网络连接',
networkConnectionError: '网络连接错误,请检查您的网络',
// 下载和删除
downloadStarted: '开始下载视频',
videoUrlNotAvailable: '视频链接不可用',
noWorkToDelete: '没有可删除的作品',
deleteConfirm: '确定要删除这个作品吗?',
confirmDelete: '确认删除',
workDeleted: '作品已删除',
deleteCancelled: '已取消删除',
// 其他
historyParamsFilled: '已填充历史记录参数,可以开始生成',
cancelFunctionTBD: '取消功能待实现',
unfinishedTaskDetected: '检测到未完成的任务,继续处理中...'
}
}
```
### 英文翻译建议:
```javascript
video: {
textToVideo: {
// UI text
userAvatar: 'User Avatar',
textInputPlaceholder: 'Enter text to describe the content you want to generate',
optimizing: 'Optimizing...',
oneClickOptimize: 'One-Click Optimize',
aspectRatio: 'Aspect Ratio',
hdMode: 'HD Mode (1080P)',
hdModeCost: 'Enabling costs 20 points',
startGenerate: 'Start Generate',
pleaseLogin: 'Please Login',
loginRequired: 'Login required to submit task',
loginNow: 'Login Now',
// Task status
inProgress: 'In Progress',
noVideoUrl: 'Video generated but URL not available',
// Watermark options
withWatermark: 'With Watermark',
withoutWatermark: 'Without Watermark (Member Exclusive)',
// Action buttons
createSimilar: 'Create Similar',
downloadVideo: 'Download Video',
deleteWork: 'Delete Work',
// Status text
generationFailed: 'Generation Failed',
checkInputOrRetry: 'Please check input or retry',
regenerate: 'Regenerate',
startCreating: 'Start creating your first work!',
// History
noDescription: 'No description',
queuing: 'Queuing',
subscribeToSpeedUp: 'Subscribe to speed up generation',
noResult: 'No result',
// Messages
pleaseLoginFirst: 'Please login first to submit task',
taskInProgress: 'A task is already in progress, please wait or cancel current task',
pleaseEnterText: 'Please enter text description',
creatingTask: 'Creating task...',
taskCreated: 'Task created successfully, processing...',
createTaskFailed: 'Failed to create task',
videoCompleted: 'Video generation completed!',
videoFailed: 'Video generation failed: ',
// Status mapping
statusPending: 'Pending',
statusProcessing: 'Processing',
statusCancelled: 'Cancelled',
statusUnknown: 'Unknown',
// Optimize prompt
pleaseEnterPrompt: 'Please enter prompt',
promptTooLong: 'Prompt too long, please keep within 2000 characters',
optimizingPrompt: 'Optimizing prompt, please wait...',
optimizeSuccess: 'Prompt optimized successfully!',
optimizeNoChange: 'Prompt optimized but may have no obvious changes',
optimizeFailed: 'Optimization failed',
requestParamError: 'Request parameter error',
requestTimeout: 'Request timeout, please retry later',
serverError: 'Server error, please retry later',
networkError: 'Network error, please check connection',
networkConnectionError: 'Network connection error, please check your network',
// Download and delete
downloadStarted: 'Download started',
videoUrlNotAvailable: 'Video URL not available',
noWorkToDelete: 'No work to delete',
deleteConfirm: 'Are you sure you want to delete this work?',
confirmDelete: 'Confirm Delete',
workDeleted: 'Work deleted',
deleteCancelled: 'Delete cancelled',
// Others
historyParamsFilled: 'History parameters filled, ready to generate',
cancelFunctionTBD: 'Cancel function to be implemented',
unfinishedTaskDetected: 'Unfinished task detected, continuing processing...'
}
}
```
## 修改统计
- **模板替换:** 30+ 处
- **JavaScript 替换:** 30+ 处
- **新增翻译键:** 60 个(中英文各 60 个)
- **总计修改:** 60+ 处
## 使用的命名空间
1. **video.textToVideo.*** - 文生视频创建页面专用翻译(新增 60 个键)
2. **common.*** - 通用翻译(已存在)
- home, cancel, confirm, settings, userProfile, logout
3. **home.*** - 首页相关翻译(已存在)
- textToVideo, imageToVideo, storyboardVideo
4. **profile.*** - 个人主页相关翻译(已存在)
- myWorks, subscription
5. **video.*** - 视频相关通用翻译(已存在)
- duration, generating, completed, failed
## 注意事项
1. **日期格式化函数未国际化:** `formatDate` 函数中的日期格式(`年月日`)仍然是中文硬编码,可能需要根据语言环境动态调整
2. **ElMessage.confirm 方法:** 确认对话框已完全国际化
3. **所有用户可见文本已国际化:** 包括按钮、标签、提示信息、错误消息等
4. **控制台日志保持中文:** console.log 中的调试信息保持中文,不影响用户体验
## 验证建议
1. 切换语言后测试所有界面文本是否正确显示
2. 测试所有交互操作的提示消息是否正确国际化
3. 验证错误处理消息的国际化是否完整
4. 检查历史记录区域的动态内容显示
## 文件路径
- **修改的文件:** `C:\Users\UI\Desktop\AIGC\demo\frontend\src\views\TextToVideoCreate.vue`
- **需要添加翻译的文件:**
- `C:\Users\UI\Desktop\AIGC\demo\frontend\src\locales\zh.js`
- `C:\Users\UI\Desktop\AIGC\demo\frontend\src\locales\en.js`
---
**完成时间:** 2025-11-13
**修改者:** Claude Code Assistant

View File

@@ -55,6 +55,18 @@ export default defineConfig({
outDir: 'dist',
assetsDir: 'static',
copyPublicDir: true,
// 使用 terser 压缩,移除 console
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
},
// 启用 CSS 代码分割
cssCodeSplit: true,
// 禁用 source map
sourcemap: false,
// 代码分割优化
rollupOptions: {
output: {
@@ -83,16 +95,6 @@ export default defineConfig({
}
}
},
// 生产环境移除 console
// 注意:如果使用 terser需要安装: npm install -D terser
// 暂时使用 esbuild默认更快
minify: 'esbuild',
// terserOptions: {
// compress: {
// drop_console: true,
// drop_debugger: true
// }
// },
// 块大小警告限制
chunkSizeWarningLimit: 1000
}

View File

@@ -296,6 +296,7 @@ CREATE TABLE IF NOT EXISTS user_works (
like_count INT NOT NULL DEFAULT 0 COMMENT '点赞次数',
download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数',
tags VARCHAR(500) COMMENT '标签',
uploaded_images LONGTEXT COMMENT '用户上传的参考图片JSON数组用于做同款功能恢复',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
completed_at DATETIME COMMENT '完成时间',
@@ -428,5 +429,29 @@ CREATE TABLE IF NOT EXISTS storyboard_video_tasks (
UPDATE users
SET role = 'ROLE_ADMIN',
updated_at = CURRENT_TIMESTAMP
WHERE email = '984523799@qq.com';
WHERE email = 'shanghairuiyi2026@163.com';
-- ============================================
-- 分镜视频任务表字段更新
-- ============================================
-- 添加视频阶段参考图字段(与分镜图阶段的参考图分开存储)
ALTER TABLE storyboard_video_tasks
ADD COLUMN IF NOT EXISTS video_reference_images LONGTEXT COMMENT '视频阶段用户上传的参考图片JSON数组- 生成视频时使用'
AFTER uploaded_images;
-- 如果 uploaded_images 字段不存在,也添加它
ALTER TABLE storyboard_video_tasks
ADD COLUMN IF NOT EXISTS uploaded_images LONGTEXT COMMENT '用户上传的多张参考图片JSON数组- 生成分镜图时使用'
AFTER image_model;
-- 如果 image_model 字段不存在,添加它
ALTER TABLE storyboard_video_tasks
ADD COLUMN IF NOT EXISTS image_model VARCHAR(50) DEFAULT 'nano-banana-2' COMMENT '图像生成模型'
AFTER hd_mode;
-- 如果 duration 字段不存在,添加它
ALTER TABLE storyboard_video_tasks
ADD COLUMN IF NOT EXISTS duration INT DEFAULT 10 COMMENT '视频时长(秒)'
AFTER hd_mode;

View File

@@ -0,0 +1,19 @@
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 单独的密码编码器配置类
* 避免与 SecurityConfig 产生循环依赖
*/
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -20,79 +20,74 @@ import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import com.example.demo.security.JwtAuthenticationFilter;
import com.example.demo.security.PlainTextPasswordEncoder;
import com.example.demo.service.RedisTokenService;
import com.example.demo.service.UserService;
import com.example.demo.util.JwtUtils;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder();
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtUtils jwtUtils, UserService userService,
RedisTokenService redisTokenService) throws Exception {
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 允许基于表单的账号密码登录后保持会话
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
.authorizeHttpRequests(auth -> auth
// Swagger/OpenAPI 路径 - 必须放在最前面,完全公开访问
.requestMatchers(
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/v3/api-docs",
"/api-docs/**",
"/swagger-resources/**",
"/webjars/**",
"/configuration/ui",
"/configuration/security",
"/swagger-config",
"/api/swagger-config"
).permitAll()
// 公共路径
.requestMatchers(
"/login",
"/register",
"/api/public/**",
"/api/auth/**",
"/api/verification/**",
"/api/email/**",
"/api/tencent/**",
"/api/test/**",
"/api/polling/**",
"/api/diagnostic/**",
"/api/polling-diagnostic/**",
"/api/monitor/**",
"/api/health/**",
"/css/**",
"/js/**",
"/h2-console/**"
).permitAll()
.requestMatchers("/api/orders/stats").permitAll() // 统计接口允许匿名访问
.requestMatchers("/api/orders/**").authenticated() // 订单接口需要认证
.requestMatchers("/api/payments/alipay/notify", "/api/payments/alipay/return").permitAll() // 支付宝回调接口允许匿名访问(外部调用)
.requestMatchers("/api/payments/**").authenticated() // 其他支付接口需要认证
.requestMatchers("/api/image-to-video/**").authenticated() // 图生视频接口需要认证
.requestMatchers("/api/text-to-video/**").authenticated() // 文生视频接口需要认证
.requestMatchers("/api/storyboard-video/**").authenticated() // 分镜视频接口需要认证
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 仪表盘API需要管理员权限
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 管理员API需要管理员权限
.requestMatchers("/settings", "/settings/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.requestMatchers("/users/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.anyRequest().permitAll()
.authorizeHttpRequests(auth -> auth
// Swagger/OpenAPI 路径
.requestMatchers(
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/v3/api-docs",
"/api-docs/**",
"/swagger-resources/**",
"/webjars/**",
"/configuration/ui",
"/configuration/security",
"/swagger-config",
"/api/swagger-config"
).permitAll()
// 公共路径
.requestMatchers(
"/login",
"/register",
"/api/public/**",
"/api/auth/**",
"/api/verification/**",
"/api/email/**",
"/api/tencent/**",
"/api/test/**",
"/api/polling/**",
"/api/diagnostic/**",
"/api/polling-diagnostic/**",
"/api/monitor/**",
"/api/health/**",
"/css/**",
"/js/**",
"/h2-console/**"
).permitAll()
.requestMatchers("/api/orders/stats").permitAll()
.requestMatchers("/api/orders/**").authenticated()
.requestMatchers("/api/payments/alipay/notify", "/api/payments/alipay/return").permitAll()
.requestMatchers("/api/payments/**").authenticated()
.requestMatchers("/api/image-to-video/**").authenticated()
.requestMatchers("/api/text-to-video/**").authenticated()
.requestMatchers("/api/storyboard-video/**").authenticated()
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.requestMatchers("/settings", "/settings/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.requestMatchers("/users/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.anyRequest().permitAll()
)
.formLogin(form -> form
.loginPage("/login")
// 使用email作为表单登录的用户名参数账号即邮箱
.usernameParameter("email")
.passwordParameter("password")
.defaultSuccessUrl("/", true)
@@ -101,15 +96,13 @@ public class SecurityConfig {
.logout(Customizer.withDefaults());
// 添加JWT过滤器
http.addFilterBefore(jwtAuthenticationFilter(jwtUtils, userService, redisTokenService), UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// H2 控制台需要以下设置
http.headers(headers -> headers.frameOptions(frame -> frame.disable()));
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
@@ -119,75 +112,32 @@ public class SecurityConfig {
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config, DaoAuthenticationProvider authenticationProvider) throws Exception {
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 面向海外用户使用最宽松的CORS配置
// 使用allowedOriginPatterns支持通配符当allowCredentials=true时必须使用patterns而不是origins
configuration.setAllowedOriginPatterns(Arrays.asList(
// 允许所有HTTP和HTTPS来源任意域名、任意IP、任意端口
"http://*:*",
"https://*:*",
"http://*",
"https://*",
"http://*.*",
"https://*.*",
"http://*.*.*",
"https://*.*.*",
// 本地开发环境
"http://localhost:*",
"http://127.0.0.1:*",
"http://0.0.0.0:*",
// 常见开发工具和隧道服务
"https://*.ngrok.io",
"https://*.ngrok-free.app",
"https://*.cloudflare.com",
"https://*.vercel.app",
"https://*.netlify.app",
"https://*.herokuapp.com",
"https://*.github.io",
"https://*.gitlab.io",
// 生产域名(如果有)
"https://vionow.com",
"https://www.vionow.com",
"http://vionow.com",
"http://www.vionow.com",
"https://*.vionow.com"
"http://*:*", "https://*:*", "http://*", "https://*",
"http://*.*", "https://*.*", "http://*.*.*", "https://*.*.*",
"http://localhost:*", "http://127.0.0.1:*", "http://0.0.0.0:*",
"https://*.ngrok.io", "https://*.ngrok-free.app",
"https://*.cloudflare.com", "https://*.vercel.app",
"https://*.netlify.app", "https://*.herokuapp.com",
"https://*.github.io", "https://*.gitlab.io",
"https://vionow.com", "https://www.vionow.com",
"http://vionow.com", "http://www.vionow.com", "https://*.vionow.com"
));
// 允许所有HTTP方法
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD", "TRACE", "CONNECT"));
// 允许所有请求头
configuration.setAllowedHeaders(Arrays.asList("*"));
// 允许携带凭证cookies, authorization headers等
configuration.setAllowCredentials(true);
// 暴露所有响应头给客户端
configuration.setExposedHeaders(Arrays.asList("*"));
// 预检请求缓存时间1小时减少预检请求频率
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtils jwtUtils, UserService userService,
com.example.demo.service.RedisTokenService redisTokenService) {
return new JwtAuthenticationFilter(jwtUtils, userService, redisTokenService);
}
}

View File

@@ -51,7 +51,7 @@ public class StoryboardVideoApiController {
String aspectRatio = (String) request.getOrDefault("aspectRatio", "16:9");
Boolean hdMode = (Boolean) request.getOrDefault("hdMode", false);
String imageUrl = (String) request.get("imageUrl");
String imageModel = (String) request.getOrDefault("imageModel", "nano-banana");
String imageModel = (String) request.getOrDefault("imageModel", "nano-banana-2");
// 提取用户上传的多张图片(新增)
@SuppressWarnings("unchecked")
@@ -129,6 +129,7 @@ public class StoryboardVideoApiController {
taskData.put("status", task.getStatus());
taskData.put("progress", task.getProgress());
taskData.put("resultUrl", task.getResultUrl());
taskData.put("videoUrls", task.getVideoUrls()); // 视频URLJSON数组
taskData.put("imageUrl", task.getImageUrl()); // 参考图片(旧字段)
taskData.put("uploadedImages", task.getUploadedImages()); // 用户上传的参考图JSON数组
taskData.put("prompt", task.getPrompt());

View File

@@ -115,6 +115,12 @@ public class UserWorkApiController {
Page<UserWork> works;
if (includeProcessing) {
works = userWorkService.getAllUserWorks(username, page, size);
// 调试日志:检查是否有 PROCESSING 状态的作品
long processingCount = works.getContent().stream()
.filter(w -> w.getStatus() == UserWork.WorkStatus.PROCESSING || w.getStatus() == UserWork.WorkStatus.PENDING)
.count();
logger.info("获取作品列表: username={}, total={}, processing/pending={}",
username, works.getTotalElements(), processingCount);
} else {
works = userWorkService.getUserWorks(username, page, size);
}

View File

@@ -44,10 +44,13 @@ public class StoryboardVideoTask {
private Integer duration; // 视频时长5, 10, 15
@Column(name = "image_model", length = 50)
private String imageModel = "nano-banana"; // 图像生成模型nano-banana, nano-banana2
private String imageModel = "nano-banana-2"; // 图像生成模型nano-banana, nano-banana-2
@Column(name = "uploaded_images", columnDefinition = "LONGTEXT")
private String uploadedImages; // 用户上传的多张参考图片JSON数组最多3张Base64格式
private String uploadedImages; // 用户上传的多张参考图片JSON数组最多3张Base64格式- 生成分镜图时使用
@Column(name = "video_reference_images", columnDefinition = "LONGTEXT")
private String videoReferenceImages; // 视频阶段用户上传的参考图片JSON数组- 生成视频时使用
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
@@ -188,6 +191,8 @@ public class StoryboardVideoTask {
public void setImageModel(String imageModel) { this.imageModel = imageModel; }
public String getUploadedImages() { return uploadedImages; }
public void setUploadedImages(String uploadedImages) { this.uploadedImages = uploadedImages; }
public String getVideoReferenceImages() { return videoReferenceImages; }
public void setVideoReferenceImages(String videoReferenceImages) { this.videoReferenceImages = videoReferenceImages; }
public TaskStatus getStatus() { return status; }
public void setStatus(TaskStatus status) { this.status = status; }
public int getProgress() { return progress; }

View File

@@ -91,6 +91,9 @@ public class UserWork {
@Column(name = "tags", length = 500)
private String tags; // 标签,用逗号分隔
@Column(name = "uploaded_images", columnDefinition = "LONGTEXT")
private String uploadedImages; // 用户上传的参考图片JSON数组用于"做同款"功能恢复
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -124,6 +127,7 @@ public class UserWork {
* 作品状态枚举
*/
public enum WorkStatus {
PENDING("排队中"),
PROCESSING("处理中"),
COMPLETED("已完成"),
FAILED("失败"),
@@ -378,6 +382,14 @@ public class UserWork {
this.tags = tags;
}
public String getUploadedImages() {
return uploadedImages;
}
public void setUploadedImages(String uploadedImages) {
this.uploadedImages = uploadedImages;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}

View File

@@ -6,6 +6,7 @@ import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -79,6 +80,7 @@ public interface CompletedTaskArchiveRepository extends JpaRepository<CompletedT
/**
* 删除超过指定天数的归档任务
*/
@Modifying
@Query("DELETE FROM CompletedTaskArchive c WHERE c.archivedAt < :cutoffDate")
int deleteOlderThan(@Param("cutoffDate") LocalDateTime cutoffDate);
}

View File

@@ -6,6 +6,7 @@ import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -79,6 +80,7 @@ public interface FailedTaskCleanupLogRepository extends JpaRepository<FailedTask
/**
* 删除超过指定天数的清理日志
*/
@Modifying
@Query("DELETE FROM FailedTaskCleanupLog f WHERE f.cleanedAt < :cutoffDate")
int deleteOlderThan(@Param("cutoffDate") LocalDateTime cutoffDate);
}

View File

@@ -86,18 +86,18 @@ public class TaskQueueScheduler {
return;
}
logger.info("[轮询调度] TaskQueue待检查任务: {}, TaskStatus处理中任务: {}",
logger.debug("[轮询调度] TaskQueue待检查任务: {}, TaskStatus处理中任务: {}",
hasQueueTasks, processingStatusCount);
// 队列中有任务:检查队列内任务状态
if (hasQueueTasks) {
logger.info("[轮询调度] 开始检查TaskQueue任务状态");
logger.debug("[轮询调度] 开始检查TaskQueue任务状态");
taskQueueService.checkTaskStatuses();
}
// TaskStatus表中有处理中任务调用轮询服务
if (processingStatusCount > 0) {
logger.info("[轮询调度] 开始轮询TaskStatus任务");
logger.debug("[轮询调度] 开始轮询TaskStatus任务");
taskStatusPollingService.pollTaskStatuses();
}
} catch (Exception e) {

View File

@@ -16,8 +16,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import com.example.demo.model.User;
import com.example.demo.service.RedisTokenService;
import com.example.demo.service.UserService;
import com.example.demo.repository.UserRepository;
import com.example.demo.util.JwtUtils;
import io.jsonwebtoken.ExpiredJwtException;
@@ -26,19 +25,21 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* JWT认证过滤器
* 注意:直接使用 UserRepository 而不是 UserService避免循环依赖
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtUtils jwtUtils;
private final UserService userService;
private final RedisTokenService redisTokenService;
private final UserRepository userRepository;
public JwtAuthenticationFilter(JwtUtils jwtUtils, UserService userService, RedisTokenService redisTokenService) {
public JwtAuthenticationFilter(JwtUtils jwtUtils, UserRepository userRepository) {
this.jwtUtils = jwtUtils;
this.userService = userService;
this.redisTokenService = redisTokenService;
this.userRepository = userRepository;
}
@Override
@@ -71,7 +72,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
}
// 如果是公开路径,直接放行不进行JWT验证
// 如果是公开路径,直接放行
if (isPublicPath) {
filterChain.doFilter(request, response);
return;
@@ -79,16 +80,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
try {
String authHeader = request.getHeader("Authorization");
String token = jwtUtils.extractTokenFromHeader(authHeader);
if (token != null && !token.equals("null") && !token.trim().isEmpty()) {
String username = jwtUtils.getUsernameFromToken(token);
if (username != null && jwtUtils.validateToken(token, username)) {
// Redis 验证已降级isTokenValid 总是返回 true
// 主要依赖 JWT 本身的有效性验证
User user = userService.findByUsernameOrNull(username);
// 直接使用 Repository 查询用户
User user = userRepository.findByUsername(username).orElse(null);
if (user != null) {
// 检查用户是否被封禁
@@ -101,12 +100,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return;
}
// 创建用户权限列表
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(user.getRole()));
// 使用用户名字符串作为Principal而不是User对象
// 这样 authentication.getName() 会返回用户名字符串
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), null, authorities);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
@@ -117,11 +113,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
}
} catch (ExpiredJwtException e) {
// Token过期返回401让前端跳转登录页
logger.warn("JWT已过期: 过期时间={}, 当前时间={}, token前30字符={}",
e.getClaims().getExpiration(),
new java.util.Date(),
e.getClaims().getSubject());
logger.warn("JWT已过期: {}", e.getMessage());
SecurityContextHolder.clearContext();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
@@ -129,7 +121,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return;
} catch (Exception e) {
logger.warn("JWT认证失败: {}", e.getMessage());
// 清除可能存在的认证信息
SecurityContextHolder.clearContext();
}

View File

@@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.alipay.api.domain.AlipayTradePagePayModel;
import com.alipay.api.domain.AlipayTradePrecreateModel;
import com.alipay.api.internal.util.AlipaySignature;
import com.example.demo.model.Payment;
@@ -65,7 +66,7 @@ public class AlipayService {
}
/**
* 创建支付宝支付订单并生成二维码
* 创建支付宝支付订单(电脑网站支付)
*/
public Map<String, Object> createPayment(Payment payment) {
try {
@@ -84,8 +85,8 @@ public class AlipayService {
paymentRepository.save(payment);
logger.info("支付记录已保存ID{}", payment.getId());
// 调用真实的支付API
return callRealAlipayAPI(payment);
// 调用电脑网站支付API
return callPagePayAPI(payment);
} catch (Exception e) {
logger.error("创建支付订单时发生异常,订单号:{},错误:{}", payment.getOrderId(), e.getMessage(), e);
@@ -108,6 +109,60 @@ public class AlipayService {
}
}
/**
* 调用电脑网页支付APIalipay.trade.page.pay
* 返回支付页面的HTML表单前端直接渲染即可跳转到支付宝
*/
private Map<String, Object> callPagePayAPI(Payment payment) throws Exception {
logger.info("=== 使用IJPay调用支付宝电脑网页支付API ===");
logger.info("网关地址: {}", gatewayUrl);
logger.info("应用ID: {}", appId);
logger.info("通知URL: {}", notifyUrl);
logger.info("返回URL: {}", returnUrl);
// 在调用前确保配置已设置
ensureAliPayConfigSet();
// 设置业务参数
AlipayTradePagePayModel model = new AlipayTradePagePayModel();
model.setOutTradeNo(payment.getOrderId());
model.setProductCode("FAST_INSTANT_TRADE_PAY"); // 电脑网站支付固定值
// 如果是美元按汇率转换为人民币支付宝只支持CNY
java.math.BigDecimal amount = payment.getAmount();
String currency = payment.getCurrency();
if ("USD".equalsIgnoreCase(currency)) {
java.math.BigDecimal exchangeRate = new java.math.BigDecimal("7.2");
amount = amount.multiply(exchangeRate).setScale(2, java.math.RoundingMode.HALF_UP);
logger.info("货币转换: {} USD -> {} CNY (汇率: 7.2)", payment.getAmount(), amount);
}
model.setTotalAmount(amount.toString());
model.setSubject(payment.getDescription() != null ? payment.getDescription() : "AIGC会员订阅");
model.setBody(payment.getDescription() != null ? payment.getDescription() : "AIGC平台会员订阅服务");
model.setTimeoutExpress("30m"); // 订单超时时间
logger.info("调用支付宝电脑网页支付API订单号{},金额:{},商品名称:{}",
model.getOutTradeNo(), model.getTotalAmount(), model.getSubject());
// 使用IJPay调用电脑网页支付API返回HTML表单
String form = AliPayApi.tradePage(model, notifyUrl, returnUrl);
if (form == null || form.isEmpty()) {
logger.error("支付宝电脑网页支付API返回为空");
throw new RuntimeException("支付宝支付页面生成失败");
}
logger.info("支付宝电脑网页支付表单生成成功,订单号:{}", payment.getOrderId());
Map<String, Object> result = new HashMap<>();
result.put("payForm", form); // HTML表单前端直接渲染
result.put("outTradeNo", payment.getOrderId());
result.put("success", true);
result.put("payType", "PAGE_PAY"); // 标识支付类型
return result;
}
/**
* 确保AliPayApiConfigKit中已设置配置
* 如果未设置从AliPayApiConfigHolder获取并设置

View File

@@ -173,9 +173,10 @@ public class ImageToVideoService {
if (page < 0) {
page = 0;
}
if (size <= 0 || size > 100) {
size = 10; // 默认每页10条最大100条
if (size <= 0) {
size = 10; // 默认每页10条
}
// 移除size上限限制允许获取全部历史记录
Pageable pageable = PageRequest.of(page, size);
Page<ImageToVideoTask> taskPage = taskRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
@@ -345,10 +346,11 @@ public class ImageToVideoService {
}
}
}
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
} catch (Exception saveException) {
logger.error("保存失败状态时出错: {}", task.getTaskId(), saveException);
} finally {
// 确保无论如何都返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
}
}
@@ -375,6 +377,8 @@ public class ImageToVideoService {
ImageToVideoTask currentTask = taskRepository.findByTaskId(task.getTaskId()).orElse(null);
if (currentTask != null && currentTask.getStatus() == ImageToVideoTask.TaskStatus.CANCELLED) {
logger.info("任务 {} 已被取消,停止轮询", task.getTaskId());
// 任务取消时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
return;
}
@@ -496,6 +500,8 @@ public class ImageToVideoService {
} catch (InterruptedException e) {
logger.error("轮询任务状态被中断: {}", task.getTaskId(), e);
// 线程中断时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
Thread.currentThread().interrupt();
} catch (Exception e) {
logger.error("轮询任务状态异常: {}", task.getTaskId(), e);
@@ -503,6 +509,8 @@ public class ImageToVideoService {
if (e.getCause() != null) {
logger.error("异常原因: {}", e.getCause().getMessage());
}
// 轮询异常时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
}
}

View File

@@ -853,12 +853,12 @@ public class RealAIService {
* 参考Comfly项目的Comfly_nano_banana_edit节点实现
*/
public Map<String, Object> submitTextToImageTask(String prompt, String aspectRatio, int numImages, boolean hdMode) {
return submitTextToImageTask(prompt, aspectRatio, numImages, hdMode, "nano-banana");
return submitTextToImageTask(prompt, aspectRatio, numImages, hdMode, "nano-banana-2");
}
/**
* 提交文生图任务(使用指定的模型)
* @param imageModel 图像生成模型nano-banana 或 nano-banana2
* @param imageModel 图像生成模型nano-banana 或 nano-banana-2
*/
public Map<String, Object> submitTextToImageTask(String prompt, String aspectRatio, int numImages, boolean hdMode, String imageModel) {
try {
@@ -885,8 +885,8 @@ public class RealAIService {
// 参考Comfly_nano_banana_edit节点使用 /v1/images/generations 端点
String url = getEffectiveImageApiBaseUrl() + "/v1/images/generations";
// 使用指定的模型,默认为 nano-banana
String model = (imageModel != null && !imageModel.isEmpty()) ? imageModel : "nano-banana";
// 强制使用 nano-banana-2 模型,忽略用户选择
String model = "nano-banana-2";
// 构建请求体参考Comfly_nano_banana_edit节点的参数设置
// 注意banana模型不需要n参数每次只生成1张图片
@@ -978,6 +978,14 @@ public class RealAIService {
* @return API响应
*/
public Map<String, Object> submitImageToImageTask(String prompt, String imageBase64, String aspectRatio, boolean hdMode) {
return submitImageToImageTask(prompt, imageBase64, aspectRatio, hdMode, "nano-banana-2");
}
/**
* 提交图生图任务(使用指定的模型)
* @param imageModel 图像生成模型参数(忽略,强制使用 nano-banana-2
*/
public Map<String, Object> submitImageToImageTask(String prompt, String imageBase64, String aspectRatio, boolean hdMode, String imageModel) {
// 参数验证:图生图必须有参考图片
if (imageBase64 == null || imageBase64.isEmpty()) {
logger.error("图生图任务失败:缺少参考图片");
@@ -985,15 +993,15 @@ public class RealAIService {
}
try {
logger.info("提交图生图任务banana模型: prompt={}, aspectRatio={}, hdMode={}, imageBase64长度={}",
prompt, aspectRatio, hdMode, imageBase64.length());
// 强制使用 nano-banana-2 模型,忽略用户选择
String model = "nano-banana-2";
logger.info("提交图生图任务: prompt={}, aspectRatio={}, hdMode={}, imageModel={}, imageBase64长度={}",
prompt, aspectRatio, hdMode, model, imageBase64.length());
// 图生图使用 /v1/images/edits 端点(参考 Comfly_nano_banana_edit
String url = getEffectiveImageApiBaseUrl() + "/v1/images/edits";
// 模型选择
String model = "nano-banana";
// 处理图片数据:如果是 URL先下载转换为 base64
String base64Data = imageBase64;
if (imageBase64.startsWith("http://") || imageBase64.startsWith("https://")) {

View File

@@ -133,14 +133,46 @@ public class StoryboardVideoService {
task.setImageModel(imageModel);
}
// 保存用户上传的多张图片(JSON数组
// 保存用户上传的多张图片(上传到COS后存储URL
if (uploadedImages != null && !uploadedImages.isEmpty()) {
try {
String uploadedImagesJson = objectMapper.writeValueAsString(uploadedImages);
task.setUploadedImages(uploadedImagesJson);
logger.info("保存用户上传的图片: taskId={}, 数量={}", taskId, uploadedImages.size());
List<String> processedImages = new ArrayList<>();
int imageIndex = 0;
for (String img : uploadedImages) {
if (img == null || img.isEmpty() || "null".equals(img)) {
continue;
}
// 如果是Base64图片且COS启用上传到COS
if (img.startsWith("data:image") && cosService.isEnabled()) {
try {
String cosUrl = cosService.uploadBase64Image(img, "uploaded_" + taskId + "_" + imageIndex + ".png");
if (cosUrl != null && !cosUrl.isEmpty()) {
processedImages.add(cosUrl);
logger.info("用户上传图片{}上传COS成功: taskId={}", imageIndex, taskId);
} else {
// COS上传失败保留原始Base64
processedImages.add(img);
logger.warn("用户上传图片{}上传COS失败保留Base64: taskId={}", imageIndex, taskId);
}
} catch (Exception e) {
// 异常时保留原始Base64
processedImages.add(img);
logger.error("用户上传图片{}上传COS异常: taskId={}", imageIndex, taskId, e);
}
} else {
// 非Base64或COS未启用直接保留
processedImages.add(img);
}
imageIndex++;
}
if (!processedImages.isEmpty()) {
String uploadedImagesJson = objectMapper.writeValueAsString(processedImages);
task.setUploadedImages(uploadedImagesJson);
logger.info("保存用户上传的图片: taskId={}, 数量={}", taskId, processedImages.size());
}
} catch (Exception e) {
logger.error("序列化上传图片失败: taskId={}", taskId, e);
logger.error("处理上传图片失败: taskId={}", taskId, e);
}
}
@@ -390,7 +422,8 @@ public class StoryboardVideoService {
finalPrompt,
refImage,
generationAspectRatio,
hdMode
hdMode,
imageModel // 使用用户选择的图像生成模型
);
} else {
// 无参考图片使用文生图API
@@ -673,6 +706,16 @@ public class StoryboardVideoService {
// 不抛出异常,避免影响主流程
}
// 更新分镜视频 UserWork 状态为 COMPLETED分镜图阶段完成
// 这样主页不会显示"生成中",用户点击"生成视频"后会重新创建 PROCESSING 状态的记录
try {
userWorkService.updateStoryboardVideoWorkToImageCompleted(taskId, imageUrlForDb);
logger.info("分镜视频作品状态已更新为分镜图完成: taskId={}", taskId);
} catch (Exception e) {
logger.warn("更新分镜视频作品状态失败: taskId={}, error={}", taskId, e.getMessage());
// 不抛出异常,避免影响主流程
}
// 分镜图生成完成,从任务队列中移除(用户点击"生成视频"时会重新添加)
try {
taskQueueService.removeTaskFromQueue(taskId);
@@ -804,6 +847,19 @@ public class StoryboardVideoService {
} catch (Exception ue) {
logger.warn("回退更新UserWork状态失败: taskId={}", taskId);
}
// 回退时也需要返还积分
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("回退更新时已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception e) {
logger.debug("回退更新时分镜视频积分返还失败(可能未冻结或已返还): taskId={}_vid", taskId);
}
try {
userService.returnFrozenPoints(taskId + "_img");
logger.info("回退更新时已返还分镜图积分: taskId={}_img", taskId);
} catch (Exception e) {
logger.debug("回退更新时分镜图积分返还失败(可能未冻结或已返还): taskId={}_img", taskId);
}
}
} catch (Exception ex) {
logger.error("更新任务失败状态失败: {}", taskId, ex);
@@ -887,17 +943,21 @@ public class StoryboardVideoService {
paramsUpdated = true;
}
// 更新参考图(如果提供了新的参考图)
// 更新视频阶段的参考图(如果提供了新的参考图)
if (referenceImages != null && !referenceImages.isEmpty()) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
String uploadedImagesJson = mapper.writeValueAsString(referenceImages);
task.setUploadedImages(uploadedImagesJson);
logger.info("更新任务 {} 的参考图: {} 张", taskId, referenceImages.size());
String videoRefImagesJson = mapper.writeValueAsString(referenceImages);
task.setVideoReferenceImages(videoRefImagesJson);
logger.info("更新任务 {} 的视频参考图: {} 张", taskId, referenceImages.size());
paramsUpdated = true;
} catch (Exception e) {
logger.warn("序列化参考图失败: {}", e.getMessage());
logger.warn("序列化视频参考图失败: {}", e.getMessage());
}
} else {
// 如果没有提供新的参考图,清空视频参考图字段
task.setVideoReferenceImages(null);
logger.info("任务 {} 未提供视频参考图,清空该字段", taskId);
}
// 如果是 COMPLETED 或 FAILED 状态,更新为 PROCESSING表示正在生成视频
@@ -925,6 +985,14 @@ public class StoryboardVideoService {
logger.error("更新 TaskStatus 状态失败: {}", taskId, e);
}
// 更新 UserWork 状态为 PROCESSING视频生成中
try {
userWorkService.updateStoryboardVideoWorkToProcessing(taskId);
logger.info("UserWork 已更新为 PROCESSING开始视频生成: taskId={}", taskId);
} catch (Exception e) {
logger.warn("更新 UserWork 状态失败: taskId={}, error={}", taskId, e.getMessage());
}
// 如果有任何参数更新,保存任务
if (paramsUpdated) {
taskRepository.save(task);
@@ -936,6 +1004,13 @@ public class StoryboardVideoService {
taskQueueService.addStoryboardVideoTask(task.getUsername(), taskId);
} catch (Exception e) {
logger.error("添加分镜视频任务到队列失败: {}", taskId, e);
// 返还刚刚冻结的视频积分
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("添加队列失败,已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception re) {
logger.warn("返还分镜视频积分失败: taskId={}_vid, error={}", taskId, re.getMessage());
}
throw new RuntimeException("添加视频生成任务失败: " + e.getMessage());
}
@@ -1313,11 +1388,12 @@ public class StoryboardVideoService {
}
}
// 返还冻结积分
// 返还冻结积分(分镜图阶段失败,返还 _img 积分)
try {
userService.returnFrozenPoints(task.getTaskId());
userService.returnFrozenPoints(task.getTaskId() + "_img");
logger.info("已返还分镜图积分: taskId={}_img", task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
logger.warn("返还冻结积分失败(可能已处理): taskId={}_img", task.getTaskId());
}
logger.warn("分镜图生成任务超时,已标记为失败: taskId={}", task.getTaskId());
@@ -1379,11 +1455,12 @@ public class StoryboardVideoService {
}
}
// 返还冻结积分
// 返还冻结积分(视频生成阶段失败,返还 _vid 积分)
try {
userService.returnFrozenPoints(task.getTaskId());
userService.returnFrozenPoints(task.getTaskId() + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
logger.warn("返还冻结积分失败(可能已处理): taskId={}_vid", task.getTaskId());
}
logger.warn("视频生成任务超时,已标记为失败: taskId={}, progress={}", task.getTaskId(), progress);

View File

@@ -362,7 +362,23 @@ public class TaskQueueService {
// 返还冻结的积分
try {
userService.returnFrozenPoints(taskQueue.getTaskId());
// 分镜视频任务需要同时返还 _vid 和 _img 积分
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_vid");
logger.debug("系统重启:已返还分镜视频积分: taskId={}_vid", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("系统重启:分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskQueue.getTaskId());
}
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_img");
logger.debug("系统重启:已返还分镜图积分: taskId={}_img", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("系统重启:分镜图积分返还失败(可能未冻结): taskId={}_img", taskQueue.getTaskId());
}
} else {
userService.returnFrozenPoints(taskQueue.getTaskId());
}
} catch (Exception e) {
logger.debug("返还积分失败(可能未冻结): taskId={}", taskQueue.getTaskId());
}
@@ -483,6 +499,19 @@ public class TaskQueueService {
logger.warn("记录错误日志失败: {}", task.getTaskId());
}
}
// 返还分镜任务冻结的积分_img 和 _vid
try {
userService.returnFrozenPoints(task.getTaskId() + "_vid");
logger.debug("系统重启:已返还分镜视频积分: taskId={}_vid", task.getTaskId());
} catch (Exception e) {
logger.debug("系统重启:分镜视频积分返还失败(可能未冻结): taskId={}_vid", task.getTaskId());
}
try {
userService.returnFrozenPoints(task.getTaskId() + "_img");
logger.debug("系统重启:已返还分镜图积分: taskId={}_img", task.getTaskId());
} catch (Exception e) {
logger.debug("系统重启:分镜图积分返还失败(可能未冻结): taskId={}_img", task.getTaskId());
}
businessTaskCleanedCount++;
logger.warn("系统重启:分镜视频任务 {} 已超时,标记为失败", task.getTaskId());
} else if (noRealTaskId) {
@@ -508,6 +537,13 @@ public class TaskQueueService {
logger.warn("记录错误日志失败: {}", task.getTaskId());
}
}
// 返还分镜图冻结的积分(分镜图阶段失败,只有 _img 积分)
try {
userService.returnFrozenPoints(task.getTaskId() + "_img");
logger.debug("系统重启:已返还分镜图积分: taskId={}_img", task.getTaskId());
} catch (Exception e) {
logger.debug("系统重启:分镜图积分返还失败(可能未冻结): taskId={}_img", task.getTaskId());
}
businessTaskCleanedCount++;
logger.warn("系统重启:分镜视频任务 {} 无外部任务ID标记为失败", task.getTaskId());
} else {
@@ -636,10 +672,16 @@ public class TaskQueueService {
Integer requiredPoints = calculateRequiredPoints(taskType);
// 冻结积分
PointsFreezeRecord.TaskType freezeTaskType = convertTaskType(taskType);
userService.freezePoints(username, taskId, freezeTaskType, requiredPoints,
"任务提交冻结积分 - " + taskType.getDescription());
// 注意:分镜视频任务的积分已在 StoryboardVideoService 中使用 _img 和 _vid 后缀冻结
// 这里跳过分镜视频任务的积分冻结,避免重复冻结
if (taskType != TaskQueue.TaskType.STORYBOARD_VIDEO) {
PointsFreezeRecord.TaskType freezeTaskType = convertTaskType(taskType);
userService.freezePoints(username, taskId, freezeTaskType, requiredPoints,
"任务提交冻结积分 - " + taskType.getDescription());
} else {
logger.info("分镜视频任务 {} 跳过积分冻结(已在业务层冻结)", taskId);
}
// 创建新的队列任务
TaskQueue taskQueue = new TaskQueue(username, taskId, taskType);
@@ -790,8 +832,22 @@ public class TaskQueueService {
// 返还冻结的积分
try {
// 分镜视频使用 taskId + "_vid" 作为积分冻结ID
// 分镜图使用 taskId + "_img" 作为积分冻结ID
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
userService.returnFrozenPoints(taskId + "_vid");
// 尝试返还视频积分
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception e) {
logger.debug("分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskId);
}
// 同时尝试返还分镜图积分(如果分镜图阶段失败)
try {
userService.returnFrozenPoints(taskId + "_img");
logger.info("已返还分镜图积分: taskId={}_img", taskId);
} catch (Exception e) {
logger.debug("分镜图积分返还失败(可能未冻结或已返还): taskId={}_img", taskId);
}
} else {
userService.returnFrozenPoints(taskId);
}
@@ -1135,29 +1191,30 @@ public class TaskQueueService {
// 获取分镜图(取第一张)
String storyboardImage = images.get(0);
// 解析用户上传的图片
// 解析视频阶段用户上传的参考图(优先使用 videoReferenceImages 字段)
List<String> userUploadedImages = new ArrayList<>();
String uploadedImagesJson = task.getUploadedImages();
if (uploadedImagesJson != null && !uploadedImagesJson.isEmpty()) {
String videoRefImagesJson = task.getVideoReferenceImages();
if (videoRefImagesJson != null && !videoRefImagesJson.isEmpty()) {
try {
List<String> parsedUploads = objectMapper.readValue(uploadedImagesJson,
List<String> parsedUploads = objectMapper.readValue(videoRefImagesJson,
objectMapper.getTypeFactory().constructCollectionType(List.class, String.class));
for (String img : parsedUploads) {
if (img != null && !img.isEmpty() && !"null".equals(img)) {
userUploadedImages.add(img);
}
}
logger.info("解析用户上传图片,有效数量: {}", userUploadedImages.size());
logger.info("解析视频阶段参考图,有效数量: {}", userUploadedImages.size());
} catch (Exception e) {
logger.warn("解析用户上传图片失败: {}", e.getMessage());
logger.warn("解析视频阶段参考图失败: {}", e.getMessage());
}
}
// 注意:不再使用 uploadedImages分镜图阶段的参考图只使用 videoReferenceImages
// 生成视频时进行拼图处理(分镜图 + 用户上传图片
// 生成视频时进行拼图处理(分镜图 + 视频阶段用户上传的参考图
String imageForVideo;
if (!userUploadedImages.isEmpty()) {
// 有用户上传图片,创建复合布局
logger.info("创建复合布局(分镜图 + {}张用户图片: 目标比例={}", userUploadedImages.size(), task.getAspectRatio());
logger.info("创建复合布局(分镜图 + {}张视频参考图: 目标比例={}", userUploadedImages.size(), task.getAspectRatio());
try {
imageForVideo = imageGridService.createStoryboardLayout(storyboardImage, userUploadedImages, task.getAspectRatio());
} catch (Exception e) {
@@ -1166,7 +1223,7 @@ public class TaskQueueService {
}
} else {
// 无用户上传图片,创建简单布局(分镜图适配目标比例)
logger.info("创建简单布局(无用户图片: 目标比例={}", task.getAspectRatio());
logger.info("创建简单布局(无视频参考图: 目标比例={}", task.getAspectRatio());
try {
imageForVideo = imageGridService.createStoryboardLayout(storyboardImage, null, task.getAspectRatio());
} catch (Exception e) {
@@ -1440,20 +1497,22 @@ public class TaskQueueService {
}
/**
* 获取待检查任务列表(只读事务,快速完成)
* 获取待检查任务列表(使用 TransactionTemplate 手动管理事务,快速完成)
*/
@Transactional(readOnly = true)
public List<TaskQueue> getTasksToCheck() {
return taskQueueRepository.findTasksToCheck();
return readOnlyTransactionTemplate.execute(status -> {
return taskQueueRepository.findTasksToCheck();
});
}
/**
* 检查是否有待处理的任务(快速检查,只统计数量)
* @return true 如果有待处理任务false 否则
*/
@Transactional(readOnly = true)
public boolean hasTasksToCheck() {
return taskQueueRepository.countTasksToCheck() > 0;
return readOnlyTransactionTemplate.execute(status -> {
return taskQueueRepository.countTasksToCheck() > 0;
});
}
/**
@@ -1898,7 +1957,8 @@ public class TaskQueueService {
private void updateTaskAsCompleted(TaskQueue taskQueue, String resultUrl) {
String taskId = taskQueue.getTaskId();
// 第一步:在事务中完成数据库更新不包含COS上传
// 第一步:在事务中完成核心数据库更新
boolean transactionSuccess = false;
try {
transactionTemplate.executeWithoutResult(status -> {
try {
@@ -1915,6 +1975,7 @@ public class TaskQueueService {
}
if (freshTaskQueue.getStatus() == TaskQueue.QueueStatus.COMPLETED) {
logger.info("任务已完成,跳过重复处理: {}", taskId);
return;
}
@@ -1934,39 +1995,6 @@ public class TaskQueueService {
logger.warn("积分扣除失败(不影响任务完成): taskId={}, error={}", taskId, e.getMessage());
}
// 更新原始任务状态先用原始URL
updateOriginalTaskStatus(taskQueue, "COMPLETED", resultUrl, null);
// 创建/更新用户作品
if (resultUrl != null && !resultUrl.isEmpty()) {
try {
userWorkService.createWorkFromTask(taskId, resultUrl);
} catch (Exception workException) {
if (workException.getMessage() == null ||
(!workException.getMessage().contains("已存在") &&
!workException.getMessage().contains("Duplicate entry"))) {
logger.warn("创建/更新用户作品失败: {}", taskId, workException);
}
}
}
// 更新 task_status 表中的状态
if (resultUrl != null && !resultUrl.isEmpty()) {
try {
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
if (taskStatus != null) {
taskStatus.setStatus(TaskStatus.Status.COMPLETED);
taskStatus.setProgress(100);
taskStatus.setResultUrl(resultUrl);
taskStatus.setCompletedAt(java.time.LocalDateTime.now());
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
logger.info("task_status 表状态已更新为 COMPLETED: {}", taskId);
}
} catch (Exception statusException) {
logger.warn("更新 task_status 状态失败: {}", taskId, statusException);
}
}
// 任务完成后从 task_queue 中删除记录
try {
taskQueueRepository.delete(freshTaskQueue);
@@ -1980,20 +2008,20 @@ public class TaskQueueService {
throw e;
}
});
transactionSuccess = true;
} catch (Exception e) {
logger.error("执行更新任务完成状态事务失败: {}", taskId, e);
return; // 事务失败不继续COS上传
}
// 第二步在事务外执行COS上传避免事务超时
String finalResultUrl = resultUrl;
if (resultUrl != null && !resultUrl.isEmpty() && cosService.isEnabled()) {
try {
logger.info("开始上传视频到COS事务外: taskId={}", taskId);
String cosUrl = cosService.uploadVideoFromUrl(resultUrl, null);
if (cosUrl != null && !cosUrl.isEmpty()) {
logger.info("COS上传成功: taskId={}, cosUrl={}", taskId, cosUrl);
// 用新事务更新URL为COS URL
updateResultUrlToCos(taskQueue, cosUrl);
finalResultUrl = cosUrl;
} else {
logger.warn("COS上传返回空URL使用原始URL: taskId={}", taskId);
}
@@ -2001,24 +2029,75 @@ public class TaskQueueService {
logger.error("上传视频到COS失败使用原始URL: taskId={}", taskId, cosException);
}
}
// 第三步更新业务表和UserWork使用级联更新避免锁竞争
if (finalResultUrl != null && !finalResultUrl.isEmpty()) {
try {
// 通过 TaskStatusPollingService 的级联更新来更新所有相关表
// 这样可以避免多个事务同时更新同一条记录
boolean updated = taskStatusPollingService.updateTaskStatusWithCascade(
taskId,
TaskStatus.Status.COMPLETED,
finalResultUrl,
null
);
if (updated) {
logger.info("任务完成状态已通过级联更新: taskId={}", taskId);
} else {
// 级联更新失败,回退到直接更新
logger.warn("级联更新失败,回退到直接更新: taskId={}", taskId);
updateOriginalTaskStatus(taskQueue, "COMPLETED", finalResultUrl, null);
try {
userWorkService.createWorkFromTask(taskId, finalResultUrl);
} catch (Exception e) {
logger.warn("创建/更新用户作品失败: {}", taskId, e);
}
}
} catch (Exception e) {
logger.error("更新任务完成状态失败: {}", taskId, e);
}
}
}
/**
* 更新结果URL为COS URL独立事务
* 只更新URL字段不重复更新状态
*/
private void updateResultUrlToCos(TaskQueue taskQueue, String cosUrl) {
String taskId = taskQueue.getTaskId();
try {
transactionTemplate.executeWithoutResult(status -> {
try {
// 更新原始任务的resultUrl
updateOriginalTaskStatus(taskQueue, "COMPLETED", cosUrl, null);
// 直接更新业务表的resultUrl不通过updateOriginalTaskStatus避免重复更新
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setResultUrl(cosUrl);
// 同时更新videoUrls
if (!cosUrl.startsWith("data:image")) {
task.setVideoUrls("[\"" + cosUrl + "\"]");
}
storyboardVideoTaskRepository.save(task);
logger.info("已更新StoryboardVideoTask的COS URL: taskId={}", taskId);
});
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.IMAGE_TO_VIDEO) {
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setResultUrl(cosUrl);
imageToVideoTaskRepository.save(task);
logger.info("已更新ImageToVideoTask的COS URL: taskId={}", taskId);
});
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.TEXT_TO_VIDEO) {
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setResultUrl(cosUrl);
textToVideoTaskRepository.save(task);
logger.info("已更新TextToVideoTask的COS URL: taskId={}", taskId);
});
}
// 更新用户作品的resultUrl
// 更新用户作品的resultUrl(使用简单更新,不创建新记录)
try {
userWorkService.createWorkFromTask(taskId, cosUrl);
userWorkService.updateWorkResultUrl(taskId, cosUrl);
} catch (Exception e) {
// 忽略
logger.warn("更新UserWork COS URL失败: {}", taskId, e);
}
// 更新task_status的resultUrl
@@ -2113,10 +2192,15 @@ public class TaskQueueService {
imageToVideoTaskRepository.save(task);
logger.info("回退更新图生视频任务为完成: {}", taskId);
});
} else if (taskId.startsWith("storyboard_")) {
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setStatus(StoryboardVideoTask.TaskStatus.COMPLETED);
task.setResultUrl(resultUrl);
// 如果是视频URL不是Base64图片同时设置videoUrls
if (resultUrl != null && !resultUrl.startsWith("data:image")) {
task.setVideoUrls("[\"" + resultUrl + "\"]");
logger.info("回退更新时设置videoUrls: taskId={}", taskId);
}
task.setProgress(100);
task.setCompletedAt(java.time.LocalDateTime.now());
storyboardVideoTaskRepository.save(task);
@@ -2177,7 +2261,14 @@ public class TaskQueueService {
imageToVideoTaskRepository.save(task);
logger.info("回退更新图生视频任务为失败: {}", taskId);
});
} else if (taskId.startsWith("storyboard_")) {
// 返还冻结的积分
try {
userService.returnFrozenPoints(taskId);
logger.info("回退更新时已返还积分: taskId={}", taskId);
} catch (Exception e) {
logger.debug("回退更新时积分返还失败(可能未冻结): taskId={}", taskId);
}
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setStatus(StoryboardVideoTask.TaskStatus.FAILED);
task.setErrorMessage(errorMessage);
@@ -2185,6 +2276,19 @@ public class TaskQueueService {
storyboardVideoTaskRepository.save(task);
logger.info("回退更新分镜视频任务为失败: {}", taskId);
});
// 分镜任务需要同时返还 _vid 和 _img 积分
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("回退更新时已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception e) {
logger.debug("回退更新时分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskId);
}
try {
userService.returnFrozenPoints(taskId + "_img");
logger.info("回退更新时已返还分镜图积分: taskId={}_img", taskId);
} catch (Exception e) {
logger.debug("回退更新时分镜图积分返还失败(可能未冻结): taskId={}_img", taskId);
}
} else if (taskId.startsWith("txt2vid_")) {
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setStatus(TextToVideoTask.TaskStatus.FAILED);
@@ -2193,6 +2297,13 @@ public class TaskQueueService {
textToVideoTaskRepository.save(task);
logger.info("回退更新文生视频任务为失败: {}", taskId);
});
// 返还冻结的积分
try {
userService.returnFrozenPoints(taskId);
logger.info("回退更新时已返还积分: taskId={}", taskId);
} catch (Exception e) {
logger.debug("回退更新时积分返还失败(可能未冻结): taskId={}", taskId);
}
}
// 更新 UserWork
@@ -2211,7 +2322,7 @@ public class TaskQueueService {
taskType = "IMAGE_TO_VIDEO";
username = imageToVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
} else if (taskId.startsWith("storyboard_")) {
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
taskType = "STORYBOARD_VIDEO";
username = storyboardVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
@@ -2254,8 +2365,22 @@ public class TaskQueueService {
// 返还冻结的积分
try {
// 分镜视频使用 taskId + "_vid" 作为积分冻结ID
// 分镜图使用 taskId + "_img" 作为积分冻结ID
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_vid");
// 尝试返还视频积分
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskQueue.getTaskId());
}
// 同时尝试返还分镜图积分(如果分镜图阶段失败)
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_img");
logger.info("已返还分镜图积分: taskId={}_img", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("分镜图积分返还失败(可能未冻结或已返还): taskId={}_img", taskQueue.getTaskId());
}
} else {
userService.returnFrozenPoints(taskQueue.getTaskId());
}
@@ -2334,7 +2459,23 @@ public class TaskQueueService {
taskQueueRepository.save(taskQueue);
// 返还冻结的积分
userService.returnFrozenPoints(taskQueue.getTaskId());
// 分镜视频任务需要同时返还 _vid 和 _img 积分
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskQueue.getTaskId());
}
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_img");
logger.info("已返还分镜图积分: taskId={}_img", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("分镜图积分返还失败(可能未冻结): taskId={}_img", taskQueue.getTaskId());
}
} else {
userService.returnFrozenPoints(taskQueue.getTaskId());
}
// 更新原始任务状态
updateOriginalTaskStatus(taskQueue, "FAILED", null, "任务处理超时");
@@ -2480,6 +2621,20 @@ public class TaskQueueService {
if (taskOpt.isPresent()) {
StoryboardVideoTask task = taskOpt.get();
if ("COMPLETED".equals(status)) {
// 保存视频URL到videoUrls字段JSON数组格式
if (resultUrl != null && !resultUrl.isEmpty() && !resultUrl.startsWith("data:image")) {
// 视频URL保存到videoUrls
try {
ObjectMapper mapper = new ObjectMapper();
String videoUrlsJson = mapper.writeValueAsString(java.util.List.of(resultUrl));
task.setVideoUrls(videoUrlsJson);
logger.info("已设置videoUrls: taskId={}, videoUrls={}", taskId, videoUrlsJson);
} catch (Exception e) {
// 如果JSON序列化失败直接保存原始URL
task.setVideoUrls("[\"" + resultUrl + "\"]");
logger.warn("JSON序列化失败使用简单格式: taskId={}", taskId);
}
}
// resultUrl 现在存储的是视频URL替换之前的分镜图Base64
task.setResultUrl(resultUrl);
task.setRealTaskId(taskQueue.getRealTaskId());
@@ -2540,7 +2695,23 @@ public class TaskQueueService {
taskQueueRepository.save(taskQueue);
// 返还冻结的积分
userService.returnFrozenPoints(taskId);
// 分镜视频任务需要同时返还 _vid 和 _img 积分
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception e) {
logger.debug("分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskId);
}
try {
userService.returnFrozenPoints(taskId + "_img");
logger.info("已返还分镜图积分: taskId={}_img", taskId);
} catch (Exception e) {
logger.debug("分镜图积分返还失败(可能未冻结): taskId={}_img", taskId);
}
} else {
userService.returnFrozenPoints(taskId);
}
// 更新原始任务状态
updateOriginalTaskStatus(taskQueue, "CANCELLED", null, "用户取消了任务");
@@ -2651,10 +2822,26 @@ public class TaskQueueService {
updateRelatedTaskStatus(task.getTaskId(), task.getTaskType());
// 返还冻结积分
try {
userService.returnFrozenPoints(task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
// 分镜视频任务需要同时返还 _vid 和 _img 积分
if (task.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
try {
userService.returnFrozenPoints(task.getTaskId() + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", task.getTaskId());
} catch (Exception e) {
logger.warn("分镜视频积分返还失败(可能已处理): taskId={}_vid", task.getTaskId());
}
try {
userService.returnFrozenPoints(task.getTaskId() + "_img");
logger.info("已返还分镜图积分: taskId={}_img", task.getTaskId());
} catch (Exception e) {
logger.warn("分镜图积分返还失败(可能已处理): taskId={}_img", task.getTaskId());
}
} else {
try {
userService.returnFrozenPoints(task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
}
}
logger.warn("队列任务超时,已标记为失败: taskId={}, taskType={}, createdAt={}",

View File

@@ -15,6 +15,7 @@ import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.example.demo.model.TaskStatus;
import com.example.demo.model.UserWork;
import com.example.demo.repository.TaskStatusRepository;
import com.example.demo.config.DynamicApiConfig;
import com.fasterxml.jackson.databind.JsonNode;
@@ -63,6 +64,9 @@ public class TaskStatusPollingService {
@Autowired
private DynamicApiConfig dynamicApiConfig;
@Autowired
private UserWorkService userWorkService;
/**
* 系统启动时恢复处理中的任务
* - 对所有 PROCESSING 状态的任务进行一次状态查询
@@ -611,6 +615,11 @@ public class TaskStatusPollingService {
}
if (resultUrl != null) {
task.setResultUrl(resultUrl);
// 如果是视频URL不是Base64图片同时设置videoUrls
if (status == TaskStatus.Status.COMPLETED && !resultUrl.startsWith("data:image")) {
task.setVideoUrls("[\"" + resultUrl + "\"]");
logger.info("已设置videoUrls: taskId={}", taskId);
}
}
if (status == TaskStatus.Status.COMPLETED) {
task.setProgress(100);
@@ -683,42 +692,92 @@ public class TaskStatusPollingService {
}
}
// 如果是失败状态,记录错误日志
if (status == TaskStatus.Status.FAILED && errorMessage != null) {
// 如果是失败状态,返还冻结的积分并记录错误日志
if (status == TaskStatus.Status.FAILED) {
// 返还冻结的积分
try {
String username = null;
String taskType = "UNKNOWN";
if (taskId.startsWith("img2vid_")) {
taskType = "IMAGE_TO_VIDEO";
username = imageToVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
taskType = "STORYBOARD_VIDEO";
username = storyboardVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
} else if (taskId.startsWith("txt2vid_")) {
taskType = "TEXT_TO_VIDEO";
username = textToVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
}
if (username != null) {
userErrorLogService.logErrorAsync(
username,
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
errorMessage,
"TaskStatusPollingService.updateTaskStatusWithCascade",
taskId,
taskType
);
if (taskId.startsWith("sb_") || taskId.startsWith("storyboard_")) {
// 分镜任务需要同时返还 _vid 和 _img 积分
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("级联更新时已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception e) {
logger.debug("级联更新时分镜视频积分返还失败(可能未冻结或已返还): taskId={}_vid", taskId);
}
try {
userService.returnFrozenPoints(taskId + "_img");
logger.info("级联更新时已返还分镜图积分: taskId={}_img", taskId);
} catch (Exception e) {
logger.debug("级联更新时分镜图积分返还失败(可能未冻结或已返还): taskId={}_img", taskId);
}
} else {
logger.warn("无法记录错误日志,未找到用户名: taskId={}", taskId);
// 其他任务类型直接使用 taskId
userService.returnFrozenPoints(taskId);
logger.info("级联更新时已返还积分: taskId={}", taskId);
}
} catch (Exception e) {
logger.debug("级联更新时积分返还失败(可能未冻结或已返还): taskId={}", taskId);
}
// 记录错误日志
if (errorMessage != null) {
try {
String username = null;
String taskType = "UNKNOWN";
if (taskId.startsWith("img2vid_")) {
taskType = "IMAGE_TO_VIDEO";
username = imageToVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
taskType = "STORYBOARD_VIDEO";
username = storyboardVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
} else if (taskId.startsWith("txt2vid_")) {
taskType = "TEXT_TO_VIDEO";
username = textToVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
}
if (username != null) {
userErrorLogService.logErrorAsync(
username,
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
errorMessage,
"TaskStatusPollingService.updateTaskStatusWithCascade",
taskId,
taskType
);
} else {
logger.warn("无法记录错误日志,未找到用户名: taskId={}", taskId);
}
} catch (Exception logException) {
logger.warn("记录错误日志失败: taskId={}, error={}", taskId, logException.getMessage());
}
} catch (Exception logException) {
logger.warn("记录错误日志失败: taskId={}, error={}", taskId, logException.getMessage());
}
}
// 同步 UserWork 状态
try {
UserWork.WorkStatus workStatus = convertToUserWorkStatus(status);
userWorkService.updateWorkStatusWithResult(taskId, workStatus, resultUrl);
logger.info("已同步 UserWork 状态: taskId={}, status={}", taskId, workStatus);
} catch (Exception e) {
logger.warn("同步 UserWork 状态失败: taskId={}, error={}", taskId, e.getMessage());
}
}
/**
* 将 TaskStatus.Status 转换为 UserWork.WorkStatus
*/
private UserWork.WorkStatus convertToUserWorkStatus(TaskStatus.Status status) {
return switch (status) {
case PENDING -> UserWork.WorkStatus.PENDING;
case PROCESSING -> UserWork.WorkStatus.PROCESSING;
case COMPLETED -> UserWork.WorkStatus.COMPLETED;
case FAILED -> UserWork.WorkStatus.FAILED;
default -> UserWork.WorkStatus.PROCESSING;
};
}
/**

View File

@@ -41,6 +41,9 @@ public class TextToVideoService {
@Autowired
private UserWorkService userWorkService;
@Autowired
private UserService userService;
@Autowired
private TaskStatusPollingService taskStatusPollingService;
@@ -196,6 +199,8 @@ public class TextToVideoService {
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
}
}
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
return CompletableFuture.completedFuture(null); // 直接返回,不进行轮询
}
@@ -231,6 +236,9 @@ public class TextToVideoService {
}
} catch (Exception saveException) {
logger.error("保存失败状态时出错: {}", task.getTaskId(), saveException);
} finally {
// 确保无论如何都返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
}
}
@@ -257,6 +265,8 @@ public class TextToVideoService {
TextToVideoTask currentTask = taskRepository.findByTaskId(task.getTaskId()).orElse(null);
if (currentTask != null && currentTask.getStatus() == TextToVideoTask.TaskStatus.CANCELLED) {
logger.info("任务 {} 已被取消,停止轮询", task.getTaskId());
// 任务取消时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
return;
}
@@ -329,6 +339,8 @@ public class TextToVideoService {
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
}
}
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
logger.error("文生视频任务失败: {}", task.getTaskId());
return;
} else if ("processing".equals(status) || "pending".equals(status) || "running".equals(status)) {
@@ -370,10 +382,14 @@ public class TextToVideoService {
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
}
}
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
logger.error("文生视频任务超时: {}", task.getTaskId());
} catch (InterruptedException e) {
logger.error("轮询任务状态被中断: {}", task.getTaskId(), e);
// 线程中断时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
Thread.currentThread().interrupt();
} catch (Exception e) {
logger.error("轮询任务状态异常: {}", task.getTaskId(), e);
@@ -381,6 +397,8 @@ public class TextToVideoService {
if (e.getCause() != null) {
logger.error("异常原因: {}", e.getCause().getMessage());
}
// 轮询异常时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
}
}
@@ -421,9 +439,10 @@ public class TextToVideoService {
if (page < 0) {
page = 0;
}
if (size <= 0 || size > 100) {
size = 10; // 默认每页10条最大100条
if (size <= 0) {
size = 10; // 默认每页10条
}
// 移除size上限限制允许获取全部历史记录
Pageable pageable = PageRequest.of(page, size);
Page<TextToVideoTask> taskPage = taskRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
@@ -547,6 +566,9 @@ public class TextToVideoService {
}
}
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
logger.warn("文生视频任务超时,已标记为失败: taskId={}", task.getTaskId());
handledCount++;
@@ -631,4 +653,16 @@ public class TextToVideoService {
return task;
}
/**
* 安全返还冻结积分(捕获异常,避免影响主流程)
*/
private void returnFrozenPointsSafely(String taskId) {
try {
userService.returnFrozenPoints(taskId);
logger.info("已返还冻结积分: taskId={}", taskId);
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能未冻结): taskId={}, error={}", taskId, e.getMessage());
}
}
}

View File

@@ -7,6 +7,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
@@ -36,7 +37,7 @@ public class UserService {
private final CacheManager cacheManager;
private final MembershipLevelRepository membershipLevelRepository;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder,
public UserService(UserRepository userRepository, @Lazy PasswordEncoder passwordEncoder,
PointsFreezeRecordRepository pointsFreezeRecordRepository,
com.example.demo.repository.OrderRepository orderRepository,
com.example.demo.repository.PaymentRepository paymentRepository,
@@ -364,15 +365,20 @@ public class UserService {
/**
* 返还冻结的积分(任务失败)
* 使用悲观锁防止并发重复返还
* 使用 REQUIRES_NEW 传播行为,防止异常导致外部事务回滚
* 如果积分已经返还或扣除,则静默返回(幂等操作)
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void returnFrozenPoints(String taskId) {
PointsFreezeRecord record = pointsFreezeRecordRepository.findByTaskId(taskId)
// 使用悲观写锁查询,防止并发重复返还
PointsFreezeRecord record = pointsFreezeRecordRepository.findByTaskIdWithLock(taskId)
.orElseThrow(() -> new RuntimeException("找不到冻结记录: " + taskId));
// 如果状态不是 FROZEN说明已经处理过静默返回幂等操作
if (record.getStatus() != PointsFreezeRecord.FreezeStatus.FROZEN) {
throw new RuntimeException("冻结记录状态不正确: " + record.getStatus());
logger.debug("积分记录状态为 {},跳过返还: taskId={}", record.getStatus(), taskId);
return;
}
User user = userRepository.findByUsername(record.getUsername())

View File

@@ -294,6 +294,7 @@ public class UserWorkService {
work.setPointsCost(task.getCostPoints());
work.setStatus(UserWork.WorkStatus.COMPLETED);
work.setCompletedAt(LocalDateTime.now());
work.setUploadedImages(task.getUploadedImages()); // 同步用户上传的参考图,用于"做同款"
work = userWorkRepository.save(work);
logger.info("创建分镜视频作品成功: {}, 用户: {}, 分镜图: {}", work.getId(), work.getUsername(), task.getResultUrl() != null ? "" : "");
@@ -334,12 +335,59 @@ public class UserWorkService {
work.setPointsCost(0); // 分镜图不单独扣费
work.setStatus(UserWork.WorkStatus.COMPLETED);
work.setCompletedAt(LocalDateTime.now());
work.setUploadedImages(task.getUploadedImages()); // 同步用户上传的参考图,用于"做同款"
work = userWorkRepository.save(work);
logger.info("创建分镜图作品成功: {}, 用户: {}", work.getId(), work.getUsername());
return work;
}
/**
* 更新分镜视频作品状态为分镜图完成
* 分镜图生成完成后调用,将 PROCESSING 状态改为 COMPLETED
* 这样主页不会显示"生成中"的轮询状态
*/
@Transactional
public void updateStoryboardVideoWorkToImageCompleted(String taskId, String imageUrl) {
Optional<UserWork> workOpt = userWorkRepository.findByTaskId(taskId);
if (workOpt.isPresent()) {
UserWork work = workOpt.get();
// 只更新 PROCESSING 状态的分镜视频作品
if (work.getWorkType() == UserWork.WorkType.STORYBOARD_VIDEO
&& work.getStatus() == UserWork.WorkStatus.PROCESSING) {
work.setStatus(UserWork.WorkStatus.COMPLETED);
work.setResultUrl(imageUrl); // 暂时设置为分镜图URL
work.setThumbnailUrl(imageUrl);
work.setCompletedAt(LocalDateTime.now());
userWorkRepository.save(work);
logger.info("分镜视频作品状态已更新为分镜图完成: taskId={}, workId={}", taskId, work.getId());
}
} else {
logger.warn("未找到分镜视频作品记录: taskId={}", taskId);
}
}
/**
* 更新分镜视频作品状态为视频生成中
* 用户点击"生成视频"后调用,将 COMPLETED 状态改回 PROCESSING
*/
@Transactional
public void updateStoryboardVideoWorkToProcessing(String taskId) {
Optional<UserWork> workOpt = userWorkRepository.findByTaskId(taskId);
if (workOpt.isPresent()) {
UserWork work = workOpt.get();
// 只更新分镜视频作品
if (work.getWorkType() == UserWork.WorkType.STORYBOARD_VIDEO) {
work.setStatus(UserWork.WorkStatus.PROCESSING);
work.setCompletedAt(null); // 清除完成时间
userWorkRepository.save(work);
logger.info("分镜视频作品状态已更新为视频生成中: taskId={}, workId={}", taskId, work.getId());
}
} else {
logger.warn("未找到分镜视频作品记录: taskId={}", taskId);
}
}
/**
* 根据用户名获取用户ID
*/
@@ -451,6 +499,7 @@ public class UserWorkService {
/**
* 删除作品(软删除)
* 如果是分镜图作品,同时删除对应的分镜视频作品
*/
@Transactional
public boolean deleteWork(Long workId, String username) {
@@ -466,6 +515,30 @@ public class UserWorkService {
int result = userWorkRepository.softDeleteWork(workId, username, LocalDateTime.now());
logger.info("删除作品成功: {}, 用户: {}", workId, username);
// 如果是分镜图作品task_id 以 _image 结尾),同时删除对应的分镜视频作品
String taskId = work.getTaskId();
if (taskId != null && taskId.endsWith("_image")) {
String videoTaskId = taskId.replace("_image", "");
Optional<UserWork> videoWorkOpt = userWorkRepository.findByTaskId(videoTaskId);
if (videoWorkOpt.isPresent()) {
UserWork videoWork = videoWorkOpt.get();
userWorkRepository.softDeleteWork(videoWork.getId(), username, LocalDateTime.now());
logger.info("同时删除分镜视频作品: {}, taskId: {}", videoWork.getId(), videoTaskId);
}
}
// 如果是分镜视频作品,同时删除对应的分镜图作品
if (taskId != null && work.getWorkType() == UserWork.WorkType.STORYBOARD_VIDEO) {
String imageTaskId = taskId + "_image";
Optional<UserWork> imageWorkOpt = userWorkRepository.findByTaskId(imageTaskId);
if (imageWorkOpt.isPresent()) {
UserWork imageWork = imageWorkOpt.get();
userWorkRepository.softDeleteWork(imageWork.getId(), username, LocalDateTime.now());
logger.info("同时删除分镜图作品: {}, taskId: {}", imageWork.getId(), imageTaskId);
}
}
return result > 0;
}
@@ -684,6 +757,27 @@ public class UserWorkService {
}
}
/**
* 更新作品状态和结果URL级联更新时使用
*/
@Transactional
public void updateWorkStatusWithResult(String taskId, UserWork.WorkStatus status, String resultUrl) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime completedAt = (status == UserWork.WorkStatus.COMPLETED || status == UserWork.WorkStatus.FAILED) ? now : null;
// 先更新状态
int result = userWorkRepository.updateStatusByTaskId(taskId, status, now, completedAt);
// 如果有结果URL且状态为完成同时更新URL
if (resultUrl != null && !resultUrl.isEmpty() && status == UserWork.WorkStatus.COMPLETED) {
userWorkRepository.updateResultUrlByTaskId(taskId, resultUrl, now);
}
if (result > 0) {
logger.info("更新作品状态和结果成功: taskId={}, status={}, hasResultUrl={}", taskId, status, resultUrl != null);
}
}
/**
* 更新作品结果URL
*/

View File

@@ -21,10 +21,10 @@ spring.h2.console.enabled=false
# DB_PASSWORD=your_secure_password_here
# ============================================
spring.datasource.url=jdbc:mysql://43.156.12.172:3306/aigc_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
spring.datasource.url=jdbc:mysql://localhost:3306/aigc_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.username=aigc_platform
spring.datasource.password=jRbHPZbbkdm24yTT
spring.datasource.username=root
spring.datasource.password=177615
# 数据库连接池配置 (生产环境 - 支持50人并发)
spring.datasource.hikari.maximum-pool-size=30
@@ -41,57 +41,75 @@ spring.datasource.hikari.connection-test-query=SELECT 1
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
# 禁用 SQL 脚本自动运行
spring.sql.init.mode=never
spring.sql.init.continue-on-error=true
# Thymeleaf 可启用缓存
spring.thymeleaf.cache=true
# AI API配置 (生产环境)
# 文生视频、图生视频、分镜视频都使用Comfly API
ai.api.base-url=${AI_API_BASE_URL:https://ai.comfly.chat}
ai.api.key=${AI_API_KEY}
ai.api.base-url=https://ai.comfly.chat
ai.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
# 文生图使用Comfly API
ai.image.api.base-url=${AI_IMAGE_API_BASE_URL:https://ai.comfly.chat}
ai.image.api.key=${AI_IMAGE_API_KEY}
ai.image.api.base-url=https://ai.comfly.chat
ai.image.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
# 支付宝配置 (生产环境)
alipay.app-id=${ALIPAY_APP_ID}
alipay.private-key=${ALIPAY_PRIVATE_KEY}
alipay.public-key=${ALIPAY_PUBLIC_KEY}
alipay.app-id=2021006103624219
alipay.private-key=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCFsCSu7FVwLVCsqbZAaxv0jVrIErE45aYahKXutFeDOs7IWvUOzugL3RKMsh5Ndx0mNO6nbcL4AxEFCa4EfZIMgyyCeFnG29o0E8N7zpxH0VAES92yLpwQZHV2M1LraMsfhW7Hk9I0EkC+cElUEMBQL0LrcdjfZpspIC1utKQPpepSRZ5GpYADtgnyu+B6aOSmZk5j6+lZmn2K06H8PMZjop029uN4HfSNBNdl1NIBTEs5Kk4hw/PQm5KmC5u0CkKgmqGXt6hV4zRb3USrEYLGBvLVpCkXzpQXWTACWxy3qqVeE5OiQWkbpWIhhWqybq0gmmOJCv4cusdutEkiFdgfAgMBAAECggEAeaUCbAxt3anOG549ULZldIvey+h+S+hi0QRcPCzq6GTtXU+uZnAMoybgxxcYDaLR6j8F3WE5pBSeOvhI2Jst9qaxLHK4NgM8tGA7Yv9oIs0pww8JRiW1KhFO9GPVEpGDKkZuu7kc7vag5OglQRIQ+6VVfglUrkqd6rj1viMumXEgRKAken+g39lC7/pzkS+6J4/hpD55XZ1jEJq9mj0DTOozlbImg8y1RiyZ/Te3RVsvmF1EgggA7Z5R8+/HvFlh3KZdWWfZHvzYeu4DWJhJu1RNmskCfMIF07O1wkl1+RrrGIDTtWtVN7/Gayirx3w40LmOUrb6FSTfvUsPKN03OQKBgQD0euD2tc4CXtfQYddnvWAZXvhVPteScgigRqB9xqyv0lwASZiH313Mbf0JdQId+7glmyleXPwvDpKzfs2yl9ZUCn5LtFtZkyGMeXf39hTpSyeFf2dLVJd1VFAM53GjMXw5kWi6OUL5lFewE0rLkIqr2oYF5UwlZIDWXaNwmXAQzQKBgQCL/MzKuNsMS733/8XTjopr+HYI0nLgKopclgT7p3BJw+VWY/8BuXmA4dpAZBBIersMtRDa9acxrOUtiEwknj5fGF/NmOqGlRamP+2Gna1+DdqRWqgmSuMAiEJKiSCsCVxXdktHfP6UMa1FwhxaOvLNKGnx7dGRofAKGi9RwfpcmwKBgAEW2xG+VaClE4kWJoOL0HXMeobGtOcuIuOz7Nsim3pdEZPewBM654wVoV79anj/uh5Qxqpo96auBfFOy1PUYVwWf+GOeCm6AhhCIkq0iftQHmj13Fv1kIcxTPoBvfvgKJGJGFJcFvRNuOZL77Vge32wh5BXKTOxcvGBkUzbIiixAoGAO31sXn5egIQzsA/fNz+tLaNCLg+ZSBBsClqqtXN7sa1xadxHA6mZrB7PDGw5y0N0+Dp+dj7NFbw/DLGOgkVJhkoqdIoWqKj1HiOuwnWBxD8I8pqPOO68N36whVJvMw0rU/Pum+vPmJTf6PRL7kB87JjPJUQGupgSFYj5MQp5Zh8CgYEAvNGL16QNKdAHadefwfZaGKNc9Yw66BsKEoG2HshjD2BBUABwmrF2DFB6n5erBChyQM9t2DBnxy4py7zAIArxFWhuDRDvP66D3kZR10r7PuIFzlBomEQDQ8JBHW1m4JEfpR3xlNGqemKsWTliKYMx3kHATAsq+XHw/anMGQq1J1c=
alipay.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlVXTDpQUaXKlYY3980lSH/4O6p/dSu71upk8SF9FKB5FCJgDgoOiIm4QJdAF1JSXNj11Q0CE+JKCc3e0Dq1Scc4pPL93SBGbGckukddQbRBLmblKtkTBnFc4zxakE2moJuGVoWthQIj6nJ2Y+Q63W8jb3mQPCxKLMhlDcWvgcx9zjr+ueWIP2OBEtOv8a4AIrNujG74CgRg7SEfxTo3LHicbr1hCX3w0BBDNiEH1pCsHxBNSsBqMmPwb3klj+/huleyhUPa2wv0EZsaZvKpp9omaLfec5myrfMXa5xxXyLAX+yT8GvkEk85U6pQ1L3VGFQQ+ExnjEQgzZDJ1+huOAwIDAQAB
alipay.server-url=https://openapi.alipay.com/gateway.do
alipay.gateway-url=https://openapi.alipay.com/gateway.do
alipay.charset=UTF-8
alipay.sign-type=RSA2
alipay.domain=${ALIPAY_DOMAIN:https://vionow.com}
alipay.notify-url=${ALIPAY_NOTIFY_URL:https://vionow.com/api/payments/alipay/notify}
alipay.return-url=${ALIPAY_RETURN_URL:https://vionow.com/payment/success}
alipay.domain=https://vionow.com
alipay.notify-url=https://vionow.com/api/payments/alipay/notify
alipay.return-url=https://vionow.com/payment/success
# JWT配置 - 使用环境变量
jwt.secret=${JWT_SECRET}
jwt.expiration=${JWT_EXPIRATION:604800000}
# JWT配置
jwt.secret=mySecretKey123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
jwt.expiration=604800000
# 腾讯云SES配置 (生产环境)
tencent.ses.secret-id=${TENCENT_SES_SECRET_ID}
tencent.ses.secret-key=${TENCENT_SES_SECRET_KEY}
tencent.ses.secret-id=AKIDoaEjFbqxxqZAcv8EE6oZCg2IQPG1fCxm
tencent.ses.secret-key=nR83I79FOSpGcqNo7JXkqnU8g7SjsxuG
tencent.ses.region=ap-hongkong
tencent.ses.from-email=${TENCENT_SES_FROM_EMAIL}
tencent.ses.from-email=newletter@vionow.com
tencent.ses.from-name=AIGC平台
# 邮件模板ID在腾讯云SES控制台创建模板后获取
# 如果未配置或为0将使用开发模式仅记录日志
tencent.ses.template-id=${TENCENT_SES_TEMPLATE_ID}
tencent.ses.template-id=154360
# PayPal配置 (生产环境)
paypal.client-id=Adpi67TvppjhyyWhrALWwJhLFzv5S_vXoUHzWQchqZe48NaONSryg7QHKBubf0PRmkeJoaxGEKV5v9lT
paypal.client-secret=EDzZl-hddwtt2pNt5RpBIICdlrUS8QtcmAttU_kuANL8Vd937SC4xel_K2hArTovVqEtyL2ZS5IcQcQV
paypal.mode=sandbox
paypal.success-url=https://vionow.com/api/payment/paypal/success
paypal.cancel-url=https://vionow.com/api/payment/paypal/cancel
# Tomcat线程池配置 (生产环境 - 支持50人并发)
server.port=8080
server.tomcat.threads.max=150
server.tomcat.threads.min-spare=20
server.tomcat.max-connections=500
server.tomcat.accept-count=100
server.tomcat.connection-timeout=20000
server.tomcat.max-http-post-size=600MB
# 文件上传配置
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=500MB
spring.servlet.multipart.max-request-size=600MB
# 生产环境日志配置
logging.level.root=INFO
logging.level.com.example.demo=INFO
logging.level.com.example.demo.scheduler=WARN
logging.level.com.example.demo.scheduler.OrderScheduler=WARN
logging.level.org.springframework.security=WARN
logging.level.org.springframework.scheduling=WARN
# 关闭 Hibernate SQL 日志
logging.level.org.hibernate.SQL=WARN
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
@@ -114,40 +132,42 @@ app.ffmpeg.path=${FFMPEG_PATH:ffmpeg}
app.upload.path=${UPLOAD_PATH:./uploads}
# SpringDoc OpenAPI (Swagger) 配置
# 生产环境建议禁用或限制访问
springdoc.api-docs.path=/v3/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.enabled=true
springdoc.swagger-ui.operationsSorter=method
springdoc.swagger-ui.tagsSorter=alpha
springdoc.swagger-ui.tryItOutEnabled=true
springdoc.swagger-ui.filter=true
springdoc.swagger-ui.display-request-duration=true
springdoc.swagger-ui.doc-expansion=none
# 生产环境禁用以提高安全性和性能
springdoc.api-docs.enabled=false
springdoc.swagger-ui.enabled=false
# ============================================
# 腾讯云 Redis 配置(生产环境)
# Redis 配置(生产环境 - 已禁用
# ============================================
# 腾讯云 Redis 内网地址(在云数据库 Redis 控制台查看)
spring.data.redis.host=crs-xxxxxxxx.sql.tencentcdb.com
spring.data.redis.port=6379
# Redis 密码(格式可能是:账号:密码 或 仅密码,取决于是否开启免密)
spring.data.redis.password=你的Redis密码
# 不使用 RedisToken 存储依赖 JWT 本身的验证
# 如需启用 Redis设置 redis.enabled=true 并配置连接信息
redis.enabled=false
spring.data.redis.database=0
# spring.data.redis.host=your-redis-host
# spring.data.redis.port=6379
# spring.data.redis.password=your-redis-password
# spring.data.redis.database=0
# 连接池配置
spring.data.redis.lettuce.pool.max-active=16
spring.data.redis.lettuce.pool.max-idle=8
spring.data.redis.lettuce.pool.min-idle=2
spring.data.redis.lettuce.pool.max-wait=3000ms
# 连接超时
spring.data.redis.timeout=5000ms
spring.data.redis.connect-timeout=5000ms
# 禁用 Redis 自动配置
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration
# Token过期时间
redis.token.expire-seconds=86400
# ============================================
# 腾讯云COS对象存储配置 (生产环境)
# ============================================
tencent.cos.enabled=true
# 腾讯云SecretId
tencent.cos.secret-id=AKIDeLqCNODrKafXSAqPrRtCSp9NRwU0Ok5G
# 腾讯云SecretKey
tencent.cos.secret-key=4uZ1Hcu0xiHiy1ucAYnsoZ8WhqqlW5RZ
# COS区域
tencent.cos.region=ap-hongkong
# COS存储桶名称
tencent.cos.bucket-name=aigc-1393834230
# COS文件夹前缀
tencent.cos.prefix=aigc

View File

@@ -1,7 +1,7 @@
spring.application.name=demo
spring.messages.basename=messages
spring.thymeleaf.cache=false
spring.profiles.active=dev
spring.profiles.active=prod
# 服务器配置
server.address=0.0.0.0

View File

@@ -12,4 +12,4 @@
UPDATE users
SET role = 'ROLE_SUPER_ADMIN',
updated_at = CURRENT_TIMESTAMP
WHERE email = '984523799@qq.com';
WHERE email = 'shanghairuiyi2026@163.com';

View File

@@ -0,0 +1,5 @@
-- V14: 为user_works表添加uploaded_images字段
-- 用于存储用户上传的参考图片,支持"做同款"功能恢复原始参考图
ALTER TABLE user_works
ADD COLUMN IF NOT EXISTS uploaded_images LONGTEXT COMMENT '用户上传的参考图片JSON数组用于做同款功能恢复';

View File

@@ -1,15 +0,0 @@
# 支付配置
# 支付宝沙箱配置
alipay.app-id=9021000157616562
alipay.private-key=MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCH7wPeptkJlJuoKwDqxvfJJLTOAWVkHa/TLh+wiy1tEtmwcrOwEU3GuqfkUlhij71WJIZi8KBytCwbax1QGZA/oLXvTCGJJrYrsEL624X5gGCCPKWwHRDhewsQ5W8jFxaaMXxth8GKlSW61PZD2cOQClRVEm2xnWFZ+6/7WBI7082g7ayzGCD2eowXsJyWyuEBCUSbHXkSgxVhqj5wUGIXhr8ly+pdUlJmDX5K8UG2rjJYx+0AU5UZJbOAND7d3iyDsOulHDvth50t8MOWDnDCVJ2aAgUB5FZKtOFxOmzNTsMjvzYldFztF0khbypeeMVL2cxgioIgTvjBkUwd55hZAgMBAAECggEAUjk3pARUoEDt7skkYt87nsW/QCUECY0Tf7AUpxtovON8Hgkju8qbuyvIxokwwV2k72hkiZB33Soyy9r8/iiYYoR5yGfKmUV7R+30df03ivYmamD48BCE138v8GZ31Ufv+hEY7MADSCpzihGrbNtaOdSlslfVVmyWKHHfvy9EyD6yHJGYswLpHXC/QX1TuLRRxk6Uup8qENOG/6zjGWMfxoRZFwTt80ml1mKy32YZGyJqDaQpJcdYwAHOPcnJl1emw4E+oVjiLyksl643npuTkgnZXs1iWcWSS8ojF1w/0kVDzcNh9toLg+HDuQlIHOis01VQ7lYcG4oiMOnhX1QHIQKBgQC9fgBuILjBhuCI9fHvLRdzoNC9heD54YK7xGvEV/mv90k8xcmNx+Yg5C57ASaMRtOq3b7muPiCv5wOtMT4tUCcMIwSrTNlcBM6EoTagnaGfpzOMaHGMXO4vbaw+MIynHnvXFj1rZjG1lzkV/9K36LAaHD9ZKVJaBQ9mK+0CIq/3QKBgQC3pL5GbvXj6/4ahTraXzNDQQpPGVgbHxcOioEXL4ibaOPC58puTW8HDbRvVuhl/4EEOBRVX81BSgkN8XHwTSiZdih2iOqByg+o9kixs7nlFn3Iw9BBP2/g+Wqiyi2N+9g17kfWXXVOKYz/eMXLBeOo4KhQE9wqNGyZldYzX2ywrQKBgApJmvBfqmgnUG1fHOFlS06lvm9ro0ktqxFSmp8wP4gEHt/DxSuDXMUQXk2jRFp9ReSS4VhZVnSSvoA15DO0c2uHXzNsX8v0B7cxZjEOwCyRFyZCn4vJB4VSF2cIOlLRF/Wcx9+eqxqwbJ6hAGUqOwXDJc879ZVEp0So03EsvYupAoGAAnI+Wp/VxLB7FQ1bSFdmTmoKYh1bUBks7HOp3o4yiqduCUWfK7L6XKSxF56Xv+wUYuMAWlbJXCpJTpc9xk6w0MKDLXkLbqkrZjvJohxbyJJxIICDQKtAqUWJRxvcWXzWV3mSGWfrTRw+lZSdReQRMUm01EQ/dYx3OeCGFu8Zeo0CgYAlH5YSYdJxZSoDCJeoTrkxUlFoOg8UQ7SrsaLYLwpwcwpuiWJaTrg6jwFocj+XhjQ9RtRbSBHz2wKSLdl+pXbTbqECKk85zMFl6zG3etXtTJU/dD750Ty4i8zt3+JGhvglPrQBY1CfItgml2oXa/VUVMnLCUS0WSZuPRmPYZD8dg==
alipay.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAksEwzuR3ASrKtTzaANqdQYKOoD44itA1TWG/6onvQr8PHNEMgcguLuJNrdeuT2PDg23byzZ9qKfEM2D5U4zbpt0/uCYLfZQyAnAWWyMvnKPoSIgrtBjnxYK6HE6fuQV3geJTcZxvP/z8dGZB0V0s6a53rzbKSLh0p4w0hWfVXlQihq3Xh4vSKB+ojdhEkIblhpWPT42NPbjVNdwPzIhUGpRy3/nsgNqVBu+ZacQ5/rCvzXU1RE0allBbjcvjymKQTS7bAE0i1Mgo1eX8njvElsfQUv5P7xQdrvZagqtIuTdP19cmsSNGdIC9Z5Po3j0z3KWPR7MrKgDuJfzkWtJR4wIDAQAB
alipay.server-url=https://openapi-sandbox.dl.alipaydev.com/gateway.do
alipay.gateway-url=https://openapi-sandbox.dl.alipaydev.com/gateway.do
alipay.domain=https://vionow.com
alipay.charset=UTF-8
alipay.sign-type=RSA2
alipay.notify-url=https://vionow.com/api/payments/alipay/notify
alipay.return-url=https://vionow.com/api/payments/alipay/return
alipay.app-cert-path=classpath:cert/alipay/appCertPublicKey.crt
alipay.ali-pay-cert-path=classpath:cert/alipay/alipayCertPublicKey_RSA2.crt
alipay.ali-pay-root-cert-path=classpath:cert/alipay/alipayRootCert.crt

View File

@@ -0,0 +1,64 @@
package com.example.demo;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
public class KeyPairTest {
public static void main(String[] args) throws Exception {
// 生产环境应用私钥(新的)
String privateKeyStr = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC2OS65zDqk7/Z6izOrq+y2S6vojWvfibm7I3MYnh03UaS1zrZqCnxVaAOt4iw3s1ucWDsD/aDRZf4uYVeGrFokfaab0V7x011JjaMM5CFG2rKDeEvGttp0QKE4G+1qNcsZcl760M0LGh9JmK8o7gZhppV4ouszrJFzLLR5VzTXRdIm4AHAd3BLqEfW4tMU+JOmOt+36hrXpVAygPfUIlx43oHenL8E8cPXqtnZyBwR7zsO2MQpxFxIHnzj8aWYBVDyuXaI5+FPQJgkBya2EbEEoqvSlCh4rPd1Aao+zOpXwbym9NVRQqHwf0nQWDsf8F3McBkIMRnhVub2GnDqiBB/AgMBAAECggEAIpM5BXH10qPhXaEZ/cHSWUiEZsymojSMtDBmv04I9x0bpo+BVx+ENeRVhmG6yBrVEBZBpGE0aWbz0rMPm3MKa5AX08rnO/VB5xnjzSdgFQScCIwDvMGnM5WrwWyzIIrybXKhCPAjZ67eLuW0noyDU3X7OHeZLyXYN0VNPRTJ5up6+GEw+7znFkRMu1bMbNTlHrktWjt2iro1F0+SIHpkM2Uq0mjM6+NiOsLVuDwxmKbc8cmHJ3ud8bxDebUPJPcLS4KNr3v/l5MOIwd2XIRP4G1OV+D3ooMG+LGMdF7FuBoI+mMcWSGTsT/wutC4ZT4d91tmmqh6kyTFzhBccBCTKQKBgQD04BVa+RIYcXio4W2i0zScWpM5mnCiqDVt1SqrLd4Sr6wL9V/dekr6wdUzAJkDfDZmP7vvYok93C+fZ3uaaB4HpPcN3usP85V6nx2WPwxRgwWBIT0nEMxVqptW+BIyWd4t2ln8I0RnAETe98e1mVWFHmvB0+jBoD/rxDcNl5nUzQKBgQC+gHPenMhz7fZTwwIxr11HXc55eVZ0nqyO2lP8EhyVGQawI9YaiHFBuPHIy4QVNjWOU3+MGxDtWvxTJEvdJ2rvaMXSgFNtC9GJZMN9phb3hscLqguv/qS2tns31d3RW4thvv64ur+RHo5+zMJGseOOOiqChKcaJqDVTCdltAoaewKBgF+t2spD/Z0NYS7jfhob2jepcFMWlCIKBW5X1ycxcc7tUxUNGBsKuJdH+0zFVAQ3mW3UQ1Nites0yGrJXVNUkT89ZsbFriT6cRKLb10QX3jN7+2nliRYfO6QDcgqf7mqwZQ69+P7x7NTOJXCTQcY1YCBBWujqBNX0QIHFde6v4GVAoGADBhZwvuPWpT0O9M403C/6mURU/MZQyRD7nn8NRftSqYhH5PW5y5cioC0kTwisboUYmn4wiuBwqAxPxIh4vO6vWKYlMnjAhxq/F8ybkraUHL1Nk9gmJcBXDxvzFa+06kNS3J198KboRogj/puJ1DqEsCsrEbB2U40jfZc1f89WPsCgYEAwH3i/1dxKrlT1d3de5WgKpMjEYcoaz63tZnC2IdWZCEEPtwPa0uqflN5w2hHlb46g4o9d/f7oOaGiRKdYbaDQKbjKMM4ucGe+hTn76J8yo5bLUcvOMPjAUKpYriOe7/MnyzDPgDTw1wLOvIHsSFwv9zDX3PpISiWQ70tqRO0Rk0=";
// 生产环境应用公钥(新的,用于验证密钥对是否匹配)
String publicKeyStr = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtjkuucw6pO/2eoszq6vstkur6I1r34m5uyNzGJ4dN1Gktc62agp8VWgDreIsN7NbnFg7A/2g0WX+LmFXhqxaJH2mm9Fe8dNdSY2jDOQhRtqyg3hLxrbadEChOBvtajXLGXJe+tDNCxofSZivKO4GYaaVeKLrM6yRcyy0eVc010XSJuABwHdwS6hH1uLTFPiTpjrft+oa16VQMoD31CJceN6B3py/BPHD16rZ2cgcEe87DtjEKcRcSB584/GlmAVQ8rl2iOfhT0CYJAcmthGxBKKr0pQoeKz3dQGqPszqV8G8pvTVUUKh8H9J0Fg7H/BdzHAZCDEZ4Vbm9hpw6ogQfwIDAQAB";
System.out.println("=== 测试生产环境密钥 ===");
System.out.println("私钥长度: " + privateKeyStr.length());
System.out.println("公钥长度: " + publicKeyStr.length());
// 解析私钥
byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyStr);
PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);
System.out.println("✅ 私钥解析成功");
// 解析公钥
byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyStr);
X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(publicKeyBytes);
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
System.out.println("✅ 公钥解析成功");
// 测试签名和验签
String testData = "test data for signature verification";
// 用私钥签名
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(privateKey);
signer.update(testData.getBytes());
byte[] signature = signer.sign();
System.out.println("✅ 签名成功");
// 用公钥验签
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(testData.getBytes());
boolean verified = verifier.verify(signature);
System.out.println("\n=== 密钥对验证结果 ===");
System.out.println("私钥和公钥是否匹配: " + verified);
if (verified) {
System.out.println("✅ 密钥对匹配!");
} else {
System.out.println("❌ 密钥对不匹配!");
System.out.println("注意:支付宝公钥是支付宝返回的,不是你的应用公钥");
System.out.println("支付宝公钥用于验证支付宝的响应签名,不是用于验证你的请求签名");
}
}
}