优化: Safari下载兼容、禁用生产Swagger、前端构建优化移除console、更新COS配置
This commit is contained in:
@@ -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 // 默认包含正在处理中的作品
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '升级',
|
||||
|
||||
193
demo/frontend/src/utils/download.js
Normal file
193
demo/frontend/src/utils/download.js
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 登录方式切换 -->
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
// 当页面被激活时(从其他页面返回时)刷新列表
|
||||
|
||||
@@ -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
@@ -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%;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()); // 视频URL(JSON数组)
|
||||
taskData.put("imageUrl", task.getImageUrl()); // 参考图片(旧字段)
|
||||
taskData.put("uploadedImages", task.getUploadedImages()); // 用户上传的参考图(JSON数组)
|
||||
taskData.put("prompt", task.getPrompt());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用电脑网页支付API(alipay.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获取并设置
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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://")) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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密码
|
||||
# 不使用 Redis,Token 存储依赖 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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- V14: 为user_works表添加uploaded_images字段
|
||||
-- 用于存储用户上传的参考图片,支持"做同款"功能恢复原始参考图
|
||||
|
||||
ALTER TABLE user_works
|
||||
ADD COLUMN IF NOT EXISTS uploaded_images LONGTEXT COMMENT '用户上传的参考图片(JSON数组),用于做同款功能恢复';
|
||||
@@ -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
|
||||
64
demo/src/test/java/com/example/demo/KeyPairTest.java
Normal file
64
demo/src/test/java/com/example/demo/KeyPairTest.java
Normal 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("支付宝公钥用于验证支付宝的响应签名,不是用于验证你的请求签名");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user