feat: 线程池扩容、会员注册和过期逻辑优化、API管理页面显示当前配置
- 线程池扩容:TaskQueueService 10->20, AsyncConfig 核心5->10/最大20->40/队列50->100 - 新用户注册自动创建免费会员记录(永久有效到2099年) - 付费会员过期自动降级为免费会员并清零积分 - API管理页面显示当前API密钥(脱敏)和端点 - 修复StoryboardVideoCreate.vue语法错误
This commit is contained in:
@@ -47,6 +47,11 @@ export const handleAlipayCallback = (params) => {
|
||||
return api.post('/payments/alipay/callback', params)
|
||||
}
|
||||
|
||||
// 噜噜支付(彩虹易支付)API
|
||||
export const createLuluPayment = (paymentData) => {
|
||||
return api.post('/payments/lulupay/create', paymentData)
|
||||
}
|
||||
|
||||
// PayPal支付API
|
||||
export const createPayPalPayment = (paymentData) => {
|
||||
return api.post('/payment/paypal/create', paymentData)
|
||||
|
||||
@@ -7,6 +7,14 @@ export const createStoryboardTask = async (data) => {
|
||||
return api.post('/storyboard-video/create', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接使用上传的分镜图创建视频任务(跳过分镜图生成)
|
||||
* @param {object} data - 包含 storyboardImage, prompt, aspectRatio, hdMode, duration, referenceImages
|
||||
*/
|
||||
export const createVideoDirectTask = async (data) => {
|
||||
return api.post('/storyboard-video/create-video-direct', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,20 @@ export const getMyWorks = (params = {}) => {
|
||||
params: {
|
||||
page: params.page || 0,
|
||||
size: params.size || 10,
|
||||
includeProcessing: params.includeProcessing !== false // 默认包含正在处理中的作品
|
||||
includeProcessing: params.includeProcessing !== false, // 默认包含正在处理中的作品
|
||||
workType: params.workType || null // 按作品类型筛选
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 按类型获取我的作品(用于历史记录)
|
||||
export const getMyWorksByType = (workType, params = {}) => {
|
||||
return api.get('/works/my-works', {
|
||||
params: {
|
||||
page: params.page || 0,
|
||||
size: params.size || 1000,
|
||||
includeProcessing: true,
|
||||
workType: workType // TEXT_TO_VIDEO, IMAGE_TO_VIDEO, STORYBOARD_VIDEO, STORYBOARD_IMAGE
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<!-- 金额显示 -->
|
||||
<div class="amount-section">
|
||||
<div class="amount-label">金额</div>
|
||||
<div class="amount-value">${{ amount }}</div>
|
||||
<div class="amount-value">${{ formatAmount(amount) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 支付宝支付区域 -->
|
||||
@@ -128,6 +128,12 @@ const loading = ref(false)
|
||||
const currentPaymentId = ref(null)
|
||||
let paymentPollingTimer = null
|
||||
let isPaymentStarted = false // 防止重复调用
|
||||
|
||||
// 格式化金额,保留两位小数
|
||||
const formatAmount = (amount) => {
|
||||
if (amount === null || amount === undefined) return '0.00'
|
||||
return Number(amount).toFixed(2)
|
||||
}
|
||||
let lastPlanType = '' // 记录上一次的套餐类型
|
||||
|
||||
// 从orderId中提取套餐类型(如 SUB_standard_xxx -> standard)
|
||||
|
||||
@@ -348,6 +348,7 @@ export default {
|
||||
serverError: 'Server error, please try again later',
|
||||
networkError: 'Network error, please check your connection',
|
||||
storyboardImage: 'Storyboard',
|
||||
referenceImage: 'Reference Image',
|
||||
noStoryboard: 'No storyboard yet',
|
||||
hdMode: 'HD Mode (1080P)',
|
||||
hdCost: 'Costs 20 points when enabled',
|
||||
@@ -361,11 +362,13 @@ export default {
|
||||
progress: 'Progress: {progress}%',
|
||||
generatingStoryboardText: 'Generating storyboard, please wait...',
|
||||
generatingVideoText: 'Generating video, please wait...',
|
||||
generatingText: 'Generating',
|
||||
startCreating: 'Start creating your first work!',
|
||||
noDescription: 'No description',
|
||||
queuing: 'Queuing',
|
||||
subscribeToSpeed: 'Subscribe to improve generation speed',
|
||||
noResult: 'No result yet',
|
||||
noStoryboardImage: 'Please wait for storyboard generation to complete, or upload a storyboard image',
|
||||
uploadOrGenerateFirst: 'Please upload or generate storyboard first',
|
||||
uploadOrInputPrompt: 'Please upload storyboard or enter prompt',
|
||||
startGenerateVideo: 'Start Generate Video',
|
||||
@@ -476,6 +479,8 @@ export default {
|
||||
bulkDeleteSuccess: 'Selected items deleted',
|
||||
filtersReset: 'Filters reset',
|
||||
processing: 'Processing...',
|
||||
queuing: 'Queuing...',
|
||||
pleaseWait: 'Please wait, video is being generated',
|
||||
noPreview: 'No Preview',
|
||||
videoLoadFailed: 'Video Load Failed',
|
||||
videoFileNotExist: 'Video file may not exist or has been deleted',
|
||||
|
||||
@@ -351,6 +351,7 @@ export default {
|
||||
serverError: '服务器错误,请稍后重试',
|
||||
networkError: '网络错误,请检查网络连接',
|
||||
storyboardImage: '分镜图',
|
||||
referenceImage: '参考图',
|
||||
noStoryboard: '暂无分镜图',
|
||||
hdMode: '高清模式 (1080P)',
|
||||
hdCost: '开启消耗20积分',
|
||||
@@ -364,11 +365,13 @@ export default {
|
||||
progress: '进度: {progress}%',
|
||||
generatingStoryboardText: '正在生成分镜图,请稍候...',
|
||||
generatingVideoText: '正在生成视频,请稍候...',
|
||||
generatingText: '生成中',
|
||||
startCreating: '开始创作您的第一个作品吧!',
|
||||
noDescription: '无描述',
|
||||
queuing: '排队中',
|
||||
subscribeToSpeed: '订阅套餐以提升生成速度',
|
||||
noResult: '暂无结果',
|
||||
noStoryboardImage: '请等待分镜图生成完成,或上传一张分镜图',
|
||||
uploadOrGenerateFirst: '请先上传参考图片或输入描述生成分镜图',
|
||||
uploadOrInputPrompt: '请上传参考图片或输入提示词',
|
||||
startGenerateVideo: '开始生成视频',
|
||||
@@ -490,6 +493,7 @@ export default {
|
||||
filtersReset: '筛选器已重置',
|
||||
processing: '生成中...',
|
||||
queuing: '排队中...',
|
||||
pleaseWait: '请耐心等待,视频正在生成中',
|
||||
noPreview: '无预览',
|
||||
videoLoadFailed: '视频加载失败',
|
||||
videoFileNotExist: '视频文件可能不存在或已被删除',
|
||||
@@ -589,7 +593,10 @@ export default {
|
||||
qrCodeGenerationError: '二维码生成失败:{message}',
|
||||
pleaseTryAgain: '请重试',
|
||||
refreshPage: '请刷新页面重试',
|
||||
paymentSuccess: '支付成功!正在更新信息...',
|
||||
paymentSuccess: '支付成功!积分已到账',
|
||||
paymentPending: '支付处理中,请稍候...',
|
||||
paymentCancelled: '支付已取消',
|
||||
paymentError: '支付处理异常,如有疑问请联系客服',
|
||||
infoUpdated: '信息已更新!',
|
||||
paymentProcessingFailed: '支付成功但处理订单失败,请联系客服',
|
||||
paymentFailed: '支付失败,请重试',
|
||||
|
||||
@@ -76,7 +76,25 @@
|
||||
<h2>{{ $t('apiManagement.title') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- 当前配置展示 -->
|
||||
<div class="current-config">
|
||||
<h3>当前配置</h3>
|
||||
<div class="config-item">
|
||||
<span class="config-label">API密钥:</span>
|
||||
<span class="config-value">{{ currentMaskedKey || '未配置' }}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">API端点:</span>
|
||||
<span class="config-value">{{ currentApiBaseUrl || '未配置' }}</span>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="config-label">Token过期时间:</span>
|
||||
<span class="config-value">{{ apiForm.tokenExpireHours ? apiForm.tokenExpireHours + ' 小时' : '未配置' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="api-form-container">
|
||||
<h3 style="margin-bottom: 16px; color: #374151;">修改配置</h3>
|
||||
<el-form :model="apiForm" label-width="120px" class="api-form">
|
||||
<el-form-item :label="$t('apiManagement.apiKey')">
|
||||
<el-input
|
||||
@@ -157,6 +175,7 @@ const apiForm = reactive({
|
||||
tokenExpireHours: null // 从数据库加载
|
||||
})
|
||||
const currentApiBaseUrl = ref('')
|
||||
const currentMaskedKey = ref('')
|
||||
|
||||
// 导航功能
|
||||
const goToDashboard = () => {
|
||||
@@ -214,6 +233,7 @@ const loadApiKey = async () => {
|
||||
try {
|
||||
const response = await api.get('/api-key')
|
||||
if (response.data?.maskedKey) {
|
||||
currentMaskedKey.value = response.data.maskedKey
|
||||
console.log('当前API密钥已配置')
|
||||
}
|
||||
// 加载当前API基础URL
|
||||
@@ -498,6 +518,49 @@ const fetchSystemStats = async () => {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.current-config {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.current-config h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(186, 230, 253, 0.5);
|
||||
}
|
||||
|
||||
.config-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.config-value {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.api-form {
|
||||
background: #f9fafb;
|
||||
padding: 32px;
|
||||
|
||||
@@ -148,9 +148,10 @@
|
||||
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
|
||||
<div class="progress-fill-indeterminate"></div>
|
||||
</div>
|
||||
<!-- 生成中:确定进度条 -->
|
||||
<div v-else class="progress-bar-large">
|
||||
<div class="progress-fill-large animated" :style="{ width: taskProgress + '%' }"></div>
|
||||
<!-- 生成中:动态进度条(进度为0时也显示动画) -->
|
||||
<div v-else class="progress-bar-large" :class="{ 'indeterminate': taskProgress === 0 }">
|
||||
<div v-if="taskProgress === 0" class="progress-fill-indeterminate"></div>
|
||||
<div v-else class="progress-fill-large animated" :style="{ width: taskProgress + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">{{ taskProgress }}%</div>
|
||||
</div>
|
||||
@@ -262,11 +263,11 @@
|
||||
<div v-if="task.status === 'PENDING'" class="history-progress-bar indeterminate">
|
||||
<div class="progress-fill-indeterminate"></div>
|
||||
</div>
|
||||
<!-- 生成中:确定进度条 -->
|
||||
<div v-else class="history-progress-bar">
|
||||
<div class="progress-fill-large animated" :style="{ width: (task.progress || 0) + '%' }"></div>
|
||||
<!-- 生成中:动态进度条(进度为0时也显示动画) -->
|
||||
<div v-else class="history-progress-bar" :class="{ 'indeterminate': (task.progress || 0) === 0 }">
|
||||
<div v-if="(task.progress || 0) === 0" class="progress-fill-indeterminate"></div>
|
||||
<div v-else class="progress-fill-large animated" :style="{ width: (task.progress || 0) + '%' }"></div>
|
||||
</div>
|
||||
<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">
|
||||
@@ -366,7 +367,7 @@ import { useUserStore } from '@/stores/user'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
import { getProcessingWorks } from '@/api/userWorks'
|
||||
import { getProcessingWorks, getMyWorksByType } from '@/api/userWorks'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -720,7 +721,7 @@ const startGenerate = async () => {
|
||||
currentTask.value = response.data.data
|
||||
inProgress.value = true
|
||||
taskProgress.value = 0
|
||||
taskStatus.value = 'PENDING'
|
||||
taskStatus.value = 'PROCESSING' // 直接显示生成中
|
||||
ElMessage.success(t('video.imageToVideo.taskCreatedSuccess'))
|
||||
setTimeout(() => userStore.fetchCurrentUser(), 0)
|
||||
startPollingTask()
|
||||
@@ -776,7 +777,7 @@ const startGenerate = async () => {
|
||||
currentTask.value = response.data.data
|
||||
inProgress.value = true
|
||||
taskProgress.value = 0
|
||||
taskStatus.value = 'PENDING'
|
||||
taskStatus.value = 'PROCESSING' // 直接显示生成中
|
||||
|
||||
ElMessage.success(t('video.imageToVideo.taskCreatedSuccess'))
|
||||
|
||||
@@ -1116,7 +1117,7 @@ const retryTask = async () => {
|
||||
currentTask.value = response.data.data
|
||||
inProgress.value = true
|
||||
taskProgress.value = 0
|
||||
taskStatus.value = 'PENDING'
|
||||
taskStatus.value = 'PROCESSING' // 直接显示生成中
|
||||
|
||||
ElMessage.success('重试任务已提交')
|
||||
|
||||
@@ -1183,6 +1184,10 @@ const deleteWork = async () => {
|
||||
// 处理历史记录URL
|
||||
const processHistoryUrl = (url) => {
|
||||
if (!url) return ''
|
||||
// data: 协议(Base64 图片等)直接返回,避免被当成相对路径错误加前缀
|
||||
if (url.startsWith('data:')) {
|
||||
return url
|
||||
}
|
||||
// 如果是相对路径,确保格式正确
|
||||
if (url.startsWith('/') || !url.startsWith('http')) {
|
||||
if (!url.startsWith('/uploads/') && !url.startsWith('/api/')) {
|
||||
@@ -1192,7 +1197,7 @@ const processHistoryUrl = (url) => {
|
||||
return url
|
||||
}
|
||||
|
||||
// 加载历史记录
|
||||
// 加载历史记录(从user_works表获取IMAGE_TO_VIDEO类型的作品)
|
||||
const loadHistory = async () => {
|
||||
// 只有登录用户才能查看历史记录
|
||||
if (!userStore.isAuthenticated) {
|
||||
@@ -1201,20 +1206,24 @@ const loadHistory = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// 请求全部已完成的任务,不限制数量
|
||||
const response = await imageToVideoApi.getTasks(0, 1000)
|
||||
// 从user_works表获取IMAGE_TO_VIDEO类型的作品
|
||||
const response = await getMyWorksByType('IMAGE_TO_VIDEO', { page: 0, size: 1000 })
|
||||
console.log('历史记录API响应:', response.data)
|
||||
if (response.data && response.data.success) {
|
||||
// 只显示已完成的任务,不显示失败的任务
|
||||
const tasks = (response.data.data || []).filter(task =>
|
||||
task.status === 'COMPLETED'
|
||||
)
|
||||
|
||||
// 处理URL,确保相对路径正确
|
||||
historyTasks.value = tasks.map(task => ({
|
||||
...task,
|
||||
resultUrl: task.resultUrl ? processHistoryUrl(task.resultUrl) : null,
|
||||
firstFrameUrl: task.firstFrameUrl ? processHistoryUrl(task.firstFrameUrl) : null
|
||||
// 转换数据格式,适配历史记录展示
|
||||
const works = response.data.data || []
|
||||
|
||||
historyTasks.value = works.map(work => ({
|
||||
taskId: work.taskId,
|
||||
prompt: work.prompt,
|
||||
aspectRatio: work.aspectRatio,
|
||||
duration: work.duration ? parseInt(work.duration) : 5,
|
||||
hdMode: work.quality === 'HD',
|
||||
status: work.status,
|
||||
resultUrl: work.resultUrl ? processHistoryUrl(work.resultUrl) : null,
|
||||
firstFrameUrl: work.thumbnailUrl ? processHistoryUrl(work.thumbnailUrl) : null,
|
||||
createdAt: work.createdAt,
|
||||
progress: work.status === 'PROCESSING' ? 50 : (work.status === 'COMPLETED' ? 100 : 0)
|
||||
}))
|
||||
|
||||
console.log('历史记录加载成功:', historyTasks.value.length, '条')
|
||||
@@ -1375,47 +1384,47 @@ const restoreProcessingTask = async () => {
|
||||
// 取最新的一个任务
|
||||
const work = imageToVideoWorks[0]
|
||||
|
||||
// 恢复任务状态
|
||||
currentTask.value = {
|
||||
taskId: work.taskId,
|
||||
prompt: work.prompt,
|
||||
aspectRatio: work.aspectRatio,
|
||||
duration: work.duration,
|
||||
resultUrl: work.resultUrl,
|
||||
createdAt: work.createdAt
|
||||
}
|
||||
|
||||
// 恢复输入参数
|
||||
if (work.prompt) {
|
||||
inputText.value = work.prompt
|
||||
}
|
||||
if (work.aspectRatio) {
|
||||
aspectRatio.value = work.aspectRatio
|
||||
}
|
||||
if (work.duration) {
|
||||
duration.value = String(work.duration)
|
||||
}
|
||||
|
||||
// 恢复首帧图片(从 thumbnailUrl 或结果中获取)
|
||||
if (work.thumbnailUrl) {
|
||||
const imageUrl = processHistoryUrl(work.thumbnailUrl)
|
||||
firstFrameImage.value = imageUrl
|
||||
console.log('[Task Restore] 已恢复首帧图片:', work.thumbnailUrl)
|
||||
// 尝试从URL加载图片并转换为File对象,以便重新生成时可以提交
|
||||
try {
|
||||
const response = await fetch(imageUrl)
|
||||
const blob = await response.blob()
|
||||
const fileName = `first_frame_${work.taskId || Date.now()}.${blob.type.split('/')[1] || 'png'}`
|
||||
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
|
||||
console.log('[Task Restore] 首帧图片已转换为文件对象:', fileName)
|
||||
} catch (error) {
|
||||
console.warn('[Task Restore] 无法从URL加载首帧图片:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有真正在进行中的任务才恢复和显示消息
|
||||
// 只有真正在进行中的任务才恢复
|
||||
const workStatus = work.status || 'PROCESSING'
|
||||
if (workStatus === 'PROCESSING' || workStatus === 'PENDING') {
|
||||
// 恢复任务状态
|
||||
currentTask.value = {
|
||||
taskId: work.taskId,
|
||||
prompt: work.prompt,
|
||||
aspectRatio: work.aspectRatio,
|
||||
duration: work.duration,
|
||||
resultUrl: work.resultUrl,
|
||||
createdAt: work.createdAt
|
||||
}
|
||||
|
||||
// 恢复输入参数
|
||||
if (work.prompt) {
|
||||
inputText.value = work.prompt
|
||||
}
|
||||
if (work.aspectRatio) {
|
||||
aspectRatio.value = work.aspectRatio
|
||||
}
|
||||
if (work.duration) {
|
||||
duration.value = String(work.duration)
|
||||
}
|
||||
|
||||
// 恢复首帧图片(从 thumbnailUrl 或结果中获取)
|
||||
if (work.thumbnailUrl) {
|
||||
const imageUrl = processHistoryUrl(work.thumbnailUrl)
|
||||
firstFrameImage.value = imageUrl
|
||||
console.log('[Task Restore] 已恢复首帧图片:', work.thumbnailUrl)
|
||||
// 尝试从URL加载图片并转换为File对象,以便重新生成时可以提交
|
||||
try {
|
||||
const imgResponse = await fetch(imageUrl)
|
||||
const blob = await imgResponse.blob()
|
||||
const fileName = `first_frame_${work.taskId || Date.now()}.${blob.type.split('/')[1] || 'png'}`
|
||||
firstFrameFile.value = new File([blob], fileName, { type: blob.type })
|
||||
console.log('[Task Restore] 首帧图片已转换为文件对象:', fileName)
|
||||
} catch (error) {
|
||||
console.warn('[Task Restore] 无法从URL加载首帧图片:', error)
|
||||
}
|
||||
}
|
||||
|
||||
inProgress.value = true
|
||||
taskStatus.value = workStatus
|
||||
taskProgress.value = 50 // 初始进度设为50%
|
||||
@@ -1430,7 +1439,7 @@ const restoreProcessingTask = async () => {
|
||||
startPollingTask()
|
||||
return true
|
||||
} else {
|
||||
// 如果任务已失败或取消,不显示恢复消息,让 checkLastTaskStatus 处理
|
||||
// 如果任务已完成、失败或取消,不恢复任何参数
|
||||
console.log('[Task Skip]', work.taskId, 'Status:', workStatus, '- 不是进行中状态')
|
||||
return false
|
||||
}
|
||||
@@ -1492,13 +1501,19 @@ const checkLastTaskStatus = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 标记是否已完成首次加载(用于 watch 判断)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
// 强制刷新用户信息,确保获取管理员修改后的最新数据
|
||||
await userStore.fetchCurrentUser()
|
||||
|
||||
// 标记是否从"做同款"或"生视频"进入(有路由参数)
|
||||
const isFromExternalEntry = !!(route.query.prompt || route.query.referenceImage)
|
||||
|
||||
// 处理"做同款"传递的路由参数
|
||||
if (route.query.prompt || route.query.referenceImage) {
|
||||
console.log('[做同款] 接收参数:', route.query)
|
||||
if (isFromExternalEntry) {
|
||||
console.log('[做同款/生视频] 接收参数:', route.query)
|
||||
|
||||
if (route.query.prompt) {
|
||||
inputText.value = route.query.prompt
|
||||
@@ -1513,7 +1528,7 @@ onMounted(async () => {
|
||||
// 处理参考图
|
||||
if (route.query.referenceImage) {
|
||||
firstFrameImage.value = route.query.referenceImage
|
||||
console.log('[做同款] 设置参考图:', route.query.referenceImage)
|
||||
console.log('[做同款/生视频] 设置参考图:', route.query.referenceImage)
|
||||
// 注意:firstFrameFile 为 null,用户需要重新上传或点击生成时会提示
|
||||
}
|
||||
|
||||
@@ -1524,17 +1539,62 @@ onMounted(async () => {
|
||||
|
||||
loadHistory()
|
||||
// 延迟恢复任务,避免与创建任务冲突
|
||||
// 注意:如果是从"做同款"或"生视频"进入,不恢复正在进行的任务,让用户开始新任务
|
||||
setTimeout(async () => {
|
||||
if (!isCreatingTask.value) {
|
||||
if (!isCreatingTask.value && !isFromExternalEntry) {
|
||||
const restored = await restoreProcessingTask()
|
||||
// 如果没有恢复进行中的任务,则检查最近一条是否失败
|
||||
if (!restored) {
|
||||
checkLastTaskStatus()
|
||||
}
|
||||
}
|
||||
// 标记初始化完成
|
||||
isInitialized.value = true
|
||||
}, 500)
|
||||
})
|
||||
|
||||
// 监听路由参数变化(同一页面内跳转时触发)
|
||||
watch(() => route.query._t, (newT, oldT) => {
|
||||
// 只在初始化完成后且有新的时间戳时触发
|
||||
if (!isInitialized.value || !newT || newT === oldT) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[路由参数变化] 检测到新参数:', route.query)
|
||||
|
||||
// 停止当前轮询(如果有)
|
||||
if (stopPolling.value) {
|
||||
stopPolling.value()
|
||||
stopPolling.value = null
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
inProgress.value = false
|
||||
taskId.value = ''
|
||||
taskStatus.value = ''
|
||||
resultUrl.value = ''
|
||||
|
||||
// 应用新参数
|
||||
if (route.query.prompt) {
|
||||
inputText.value = route.query.prompt
|
||||
}
|
||||
if (route.query.aspectRatio) {
|
||||
aspectRatio.value = route.query.aspectRatio
|
||||
}
|
||||
if (route.query.duration) {
|
||||
duration.value = route.query.duration
|
||||
}
|
||||
if (route.query.referenceImage) {
|
||||
firstFrameImage.value = route.query.referenceImage
|
||||
console.log('[路由参数变化] 设置参考图')
|
||||
}
|
||||
|
||||
// 清除URL中的query参数
|
||||
router.replace({ path: route.path })
|
||||
|
||||
ElMessage.success(t('works.readyToGenerateVideo'))
|
||||
})
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
// 停止轮询
|
||||
|
||||
@@ -264,8 +264,19 @@
|
||||
<div class="detail-content" :class="{ 'vertical-content': isVerticalVideo }" v-if="selectedItem">
|
||||
<div class="detail-left" :class="{ 'vertical-left': isVerticalVideo }">
|
||||
<div class="video-container" :class="{ 'vertical-container': isVerticalVideo }">
|
||||
<!-- 视频加载失败提示 -->
|
||||
<div v-if="detailVideoError" class="video-error-overlay">
|
||||
<!-- 生成中状态提示 -->
|
||||
<div v-if="selectedItem.status === 'PROCESSING' || selectedItem.status === 'PENDING'" class="video-processing-overlay">
|
||||
<div class="processing-content">
|
||||
<el-icon class="processing-icon is-loading" :size="48"><Loading /></el-icon>
|
||||
<h3>{{ selectedItem.status === 'PROCESSING' ? t('works.processing') : t('works.queuing') }}</h3>
|
||||
<p>{{ t('works.pleaseWait') }}</p>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-animated"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 视频加载失败提示(仅对已完成的作品显示) -->
|
||||
<div v-else-if="detailVideoError && selectedItem.status === 'COMPLETED'" class="video-error-overlay">
|
||||
<div class="error-content">
|
||||
<el-icon class="error-icon" :size="48"><VideoCamera /></el-icon>
|
||||
<h3>{{ t('works.videoLoadFailed') }}</h3>
|
||||
@@ -284,7 +295,7 @@
|
||||
</div>
|
||||
|
||||
<video
|
||||
v-if="selectedItem.type === 'video'"
|
||||
v-if="selectedItem.type === 'video' && selectedItem.status === 'COMPLETED'"
|
||||
ref="detailVideoRef"
|
||||
class="detail-video"
|
||||
:src="selectedItem.resultUrl || selectedItem.cover"
|
||||
@@ -295,15 +306,22 @@
|
||||
>
|
||||
{{ t('profile.browserNotSupport') }}
|
||||
</video>
|
||||
<!-- 生成中状态显示封面图 -->
|
||||
<img
|
||||
v-else
|
||||
v-else-if="selectedItem.status === 'PROCESSING' || selectedItem.status === 'PENDING'"
|
||||
class="detail-image processing-preview"
|
||||
:src="selectedItem.cover || '/images/backgrounds/video-placeholder.jpg'"
|
||||
:alt="selectedItem.title"
|
||||
/>
|
||||
<img
|
||||
v-else-if="selectedItem.type !== 'video'"
|
||||
class="detail-image"
|
||||
:src="selectedItem.cover"
|
||||
:alt="selectedItem.title"
|
||||
/>
|
||||
|
||||
<!-- 悬浮操作按钮 -->
|
||||
<div class="overlay-actions">
|
||||
<!-- 悬浮操作按钮(仅对已完成的作品显示) -->
|
||||
<div class="overlay-actions" v-if="selectedItem.status === 'COMPLETED'">
|
||||
<button class="icon-btn" @click="downloadWork" :title="t('common.download')">
|
||||
<el-icon><Download /></el-icon>
|
||||
</button>
|
||||
@@ -1102,8 +1120,8 @@ const createSimilar = (item) => {
|
||||
}
|
||||
|
||||
// 根据作品类别跳转到对应的创建页面,并携带参数
|
||||
// 注意:不传递 taskId,因为"做同款"是创建新任务,不是继续原任务
|
||||
const query = {
|
||||
taskId: item.taskId,
|
||||
prompt: item.prompt || '',
|
||||
aspectRatio: item.aspectRatio || '',
|
||||
duration: item.duration || '',
|
||||
@@ -1130,6 +1148,9 @@ const createSimilar = (item) => {
|
||||
// 传递分镜图阶段的参考图
|
||||
if (item.uploadedImages) {
|
||||
query.uploadedImages = item.uploadedImages
|
||||
console.log('[做同款-分镜图] 传递参考图:', item.uploadedImages)
|
||||
} else {
|
||||
console.log('[做同款-分镜图] 无参考图数据, item:', item)
|
||||
}
|
||||
router.push({ path: '/storyboard-video/create', query })
|
||||
} else if (item.category === '分镜视频') {
|
||||
@@ -1139,6 +1160,14 @@ const createSimilar = (item) => {
|
||||
if (item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
|
||||
query.storyboardImage = item.cover
|
||||
}
|
||||
// 传递视频提示词(videoPrompt)
|
||||
if (item.videoPrompt) {
|
||||
query.prompt = item.videoPrompt
|
||||
} else if (item.imagePrompt) {
|
||||
query.prompt = item.imagePrompt
|
||||
} else if (item.prompt) {
|
||||
query.prompt = item.prompt
|
||||
}
|
||||
// 传递视频阶段的参考图(videoReferenceImages),不是分镜图阶段的参考图
|
||||
if (item.videoReferenceImages) {
|
||||
query.videoReferenceImages = item.videoReferenceImages
|
||||
@@ -1160,7 +1189,8 @@ const goToGenerateVideo = (item) => {
|
||||
const query = {
|
||||
aspectRatio: item.aspectRatio || '',
|
||||
duration: item.duration || '',
|
||||
hdMode: item.quality === 'HD' ? 'true' : 'false'
|
||||
hdMode: item.quality === 'HD' ? 'true' : 'false',
|
||||
_t: Date.now() // 添加时间戳确保路由参数变化被检测到
|
||||
}
|
||||
|
||||
// 使用分镜图作为参考图
|
||||
@@ -2638,6 +2668,114 @@ onActivated(() => {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* 详情弹窗中的生成中状态 */
|
||||
.video-processing-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 15;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.video-processing-overlay .processing-content {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.video-processing-overlay .processing-icon {
|
||||
color: #409eff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.video-processing-overlay h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-processing-overlay p {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.video-processing-overlay .progress-bar-container {
|
||||
width: 200px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.video-processing-overlay .progress-bar-animated {
|
||||
height: 100%;
|
||||
width: 30%;
|
||||
background: linear-gradient(90deg, #409eff, #66b1ff);
|
||||
border-radius: 2px;
|
||||
animation: progressSlide 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progressSlide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
||||
/* 视频加载失败样式 */
|
||||
.video-error-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 15;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.video-error-overlay .error-content {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.video-error-overlay .error-icon {
|
||||
color: #f56c6c;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.video-error-overlay h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-error-overlay p {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.video-error-overlay .error-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 生成中预览图样式 */
|
||||
.processing-preview {
|
||||
opacity: 0.5;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
@@ -567,6 +567,14 @@ const createSimilar = (item) => {
|
||||
if (item.cover && item.cover !== '/images/backgrounds/welcome.jpg') {
|
||||
query.storyboardImage = item.cover
|
||||
}
|
||||
// 传递视频提示词(videoPrompt)
|
||||
if (item.videoPrompt) {
|
||||
query.prompt = item.videoPrompt
|
||||
} else if (item.imagePrompt) {
|
||||
query.prompt = item.imagePrompt
|
||||
} else if (item.prompt) {
|
||||
query.prompt = item.prompt
|
||||
}
|
||||
// 传递视频阶段的参考图(videoReferenceImages),不是分镜图阶段的参考图
|
||||
if (item.videoReferenceImages) {
|
||||
query.videoReferenceImages = item.videoReferenceImages
|
||||
@@ -590,7 +598,8 @@ const goToGenerateVideo = (item) => {
|
||||
const query = {
|
||||
aspectRatio: item.aspectRatio || '',
|
||||
duration: item.duration || '',
|
||||
hdMode: item.quality === 'HD' ? 'true' : 'false'
|
||||
hdMode: item.quality === 'HD' ? 'true' : 'false',
|
||||
_t: Date.now() // 添加时间戳确保路由参数变化被检测到
|
||||
}
|
||||
|
||||
// 使用分镜图作为参考图
|
||||
|
||||
@@ -85,27 +85,18 @@
|
||||
|
||||
<!-- 分镜步骤标签 - SVG版本 -->
|
||||
<div class="storyboard-steps-svg">
|
||||
<!-- 左侧点击容器 - 生成分镜图 -->
|
||||
<div class="step-click-container step-1" @click="switchToGenerateStep">
|
||||
</div>
|
||||
<!-- 右侧点击容器 - 生成视频 -->
|
||||
<div class="step-click-container step-2" @click="switchToVideoStep">
|
||||
</div>
|
||||
<svg width="244" height="43" viewBox="0 0 244 43" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- 左侧底部指示条 - 生成分镜图激活时显示 -->
|
||||
<rect v-if="currentStep === 'generate'" x="80" y="41" width="2" height="80" rx="1" transform="rotate(90 80 41)" fill="white"/>
|
||||
<!-- 右侧底部指示条 - 生成分镜视频激活时显示 -->
|
||||
<rect v-if="currentStep === 'video'" x="244" y="41" width="2" height="80" rx="1" transform="rotate(90 244 41)" fill="white"/>
|
||||
|
||||
<!-- 左侧点击区域 - 生成分镜图 -->
|
||||
<rect
|
||||
x="0" y="0" width="122" height="43"
|
||||
fill="transparent"
|
||||
style="cursor: pointer;"
|
||||
@click="switchToGenerateStep"
|
||||
/>
|
||||
<!-- 右侧点击区域 - 生成分镜视频 -->
|
||||
<rect
|
||||
x="122" y="0" width="122" height="43"
|
||||
fill="transparent"
|
||||
style="cursor: pointer;"
|
||||
@click="switchToVideoStep"
|
||||
/>
|
||||
|
||||
<!-- STEP1 生成分镜图 文字 -->
|
||||
<path :opacity="currentStep === 'generate' ? '1' : '0.4'" d="M4.048 22.232C3.424 23.64 2.624 24.84 1.648 25.8L0.544 24.408C1.984 22.904 2.944 20.888 3.44 18.344L5.152 18.664C5.008 19.352 4.848 20.008 4.656 20.616H7.44V17.992H9.12V20.616H14.192V22.232H9.12V25.368H13.888V26.984H9.12V30.472H15.232V32.136H0.992V30.472H7.44V26.984H2.928V25.368H7.44V22.232H4.048ZM28.144 17.72C28.864 18.264 29.472 18.824 29.984 19.384L29.248 20.136H31.152V21.72H26.448C26.576 23.576 26.768 25.016 27.024 26.008C27.072 26.232 27.136 26.44 27.2 26.664C27.84 25.544 28.368 24.248 28.784 22.808L30.304 23.464C29.68 25.496 28.88 27.224 27.904 28.648C28.048 28.968 28.208 29.256 28.368 29.496C28.816 30.184 29.168 30.536 29.408 30.536C29.568 30.536 29.76 29.768 29.968 28.232L31.472 29.048C31.056 31.304 30.464 32.44 29.664 32.44C28.96 32.44 28.224 31.96 27.44 31.016C27.216 30.728 26.992 30.408 26.8 30.056C25.744 31.208 24.528 32.088 23.152 32.696L22.192 31.288C23.696 30.616 24.976 29.64 26.032 28.36C25.808 27.8 25.616 27.192 25.44 26.536C25.088 25.256 24.864 23.64 24.736 21.72H19.744V23.944H23.664C23.616 26.92 23.456 28.776 23.184 29.512C22.928 30.184 22.368 30.52 21.504 30.536C21.12 30.536 20.688 30.504 20.208 30.456L19.696 28.936C20.336 28.984 20.864 29.016 21.312 29.016C21.568 29 21.744 28.84 21.824 28.52C21.92 28.152 21.968 27.144 22 25.512H19.744C19.664 28.488 19.008 30.872 17.776 32.68L16.512 31.544C17.504 30.088 18.016 28.056 18.064 25.464V20.136H24.656C24.624 19.4 24.624 18.616 24.624 17.816H26.32C26.32 18.632 26.336 19.4 26.368 20.136H28.624C28.224 19.688 27.712 19.208 27.088 18.728L28.144 17.72ZM37.52 18.136L38.96 19.016C38.224 20.328 37.472 21.416 36.72 22.248C35.984 23.08 34.928 23.992 33.52 25L32.608 23.512C33.6 22.968 34.56 22.2 35.456 21.192C36.176 20.36 36.864 19.352 37.52 18.136ZM37.952 25.656H35.072V24.04H44.8C44.736 26.12 44.64 27.784 44.48 29.064C44.32 30.344 44.064 31.224 43.68 31.736C43.312 32.216 42.72 32.488 41.904 32.536H38.832L38.384 30.888C39.328 30.92 40.224 30.952 41.056 30.952C41.84 30.952 42.352 30.616 42.608 29.976C42.864 29.304 43.04 27.864 43.104 25.656H39.648C39.28 27.288 38.784 28.584 38.144 29.56C37.312 30.728 36.08 31.784 34.448 32.712L33.36 31.32C34.8 30.552 35.888 29.704 36.624 28.776C37.184 27.976 37.632 26.936 37.952 25.656ZM42.528 17.944C43.392 20.328 45.008 22.216 47.376 23.592L46.24 24.92C43.776 23.336 42.048 21.288 41.072 18.76L42.528 17.944ZM54.896 18.936H57.776C57.632 18.6 57.488 18.28 57.344 17.992L59.024 17.704C59.152 18.072 59.264 18.488 59.392 18.936H62.608V20.312H61.424C61.312 20.76 61.184 21.176 61.024 21.592H63.12V22.984H54.416V21.592H56.544C56.384 21.128 56.24 20.696 56.096 20.312H54.896V18.936ZM59.44 21.592C59.616 21.176 59.76 20.76 59.888 20.312H57.68C57.792 20.712 57.904 21.144 58.032 21.592H59.44ZM55.216 23.592H62.224V28.968H60.624V30.552C60.624 30.936 60.8 31.128 61.168 31.128H61.52C61.712 31.128 61.872 31.048 61.968 30.92C62.08 30.76 62.16 30.264 62.192 29.464L63.536 29.88C63.408 31.16 63.232 31.912 62.976 32.152C62.704 32.376 62.304 32.488 61.776 32.488H60.736C59.616 32.488 59.056 31.944 59.056 30.888V28.968H58.112C57.92 29.848 57.584 30.584 57.12 31.16C56.576 31.832 55.6 32.376 54.208 32.776L53.632 31.384C54.656 31.112 55.408 30.728 55.872 30.248C56.176 29.896 56.4 29.48 56.56 28.968H55.216V23.592ZM60.72 27.656V26.872H56.736V27.656H60.72ZM56.736 25.672H60.72V24.888H56.736V25.672ZM50.352 17.832L51.952 18.184C51.84 18.632 51.728 19.08 51.616 19.496H54.032V21.08H51.12C50.912 21.672 50.688 22.232 50.448 22.744H53.904V24.232H52.416V25.736H54.272V27.272H52.416V30.312C52.848 30.088 53.28 29.816 53.712 29.496L54 30.92C53.104 31.48 52.08 31.944 50.928 32.296L50.288 30.936C50.624 30.808 50.8 30.584 50.8 30.296V27.272H48.976V25.736H50.8V24.232H50.144V23.368C50.032 23.56 49.936 23.752 49.84 23.928L48.464 23.064C49.36 21.432 49.984 19.688 50.352 17.832ZM70.208 20.04L71.696 20.232C71.6 20.456 71.504 20.68 71.392 20.888H75.472V21.992C75.088 22.744 74.48 23.416 73.68 24.008C74.656 24.424 75.744 24.76 76.96 25.032L76.096 26.28C74.64 25.896 73.376 25.416 72.304 24.84C71.104 25.448 69.632 25.944 67.872 26.328L67.152 25C68.64 24.76 69.904 24.44 70.96 24.024C70.512 23.704 70.096 23.368 69.744 23.016C69.312 23.4 68.832 23.752 68.32 24.072L67.408 22.856C68.784 22.088 69.728 21.144 70.208 20.04ZM72.336 23.352C72.944 22.968 73.44 22.536 73.808 22.072H70.624C70.608 22.088 70.592 22.104 70.592 22.12C71.104 22.568 71.68 22.984 72.336 23.352ZM69.28 27.672C71.376 28.04 73.392 28.552 75.328 29.224L74.784 30.584C72.8 29.864 70.752 29.304 68.624 28.904L69.28 27.672ZM70.88 25.528C72.176 25.896 73.36 26.328 74.432 26.84L73.664 28.024C72.512 27.416 71.344 26.952 70.144 26.616L70.88 25.528ZM78.752 18.408V32.664H77.12V32.136H66.88V32.664H65.248V18.408H78.752ZM66.88 30.664H77.12V19.88H66.88V30.664Z" fill="white"/>
|
||||
|
||||
@@ -361,12 +352,9 @@
|
||||
<!-- 分镜图生成中(只有在分镜图还没生成完成时才显示) -->
|
||||
<div v-if="inProgress && !generatedImageUrl" class="generating-container">
|
||||
<div class="generating-placeholder">
|
||||
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : t('video.storyboard.generatingStoryboardText') }}</div>
|
||||
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
|
||||
<div class="progress-fill-indeterminate"></div>
|
||||
</div>
|
||||
<div v-else class="progress-bar-large">
|
||||
<div class="progress-fill-large animated" :style="{ width: '50%' }"></div>
|
||||
<div class="generating-text">{{ t('video.storyboard.generatingStoryboardText') }}</div>
|
||||
<div class="progress-bar-large" :class="{ indeterminate: taskStatus === 'PENDING' }">
|
||||
<div :class="taskStatus === 'PENDING' ? 'progress-fill-indeterminate' : 'progress-fill-large animated'" :style="taskStatus !== 'PENDING' ? { width: '50%' } : {}"></div>
|
||||
</div>
|
||||
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
|
||||
</div>
|
||||
@@ -438,12 +426,9 @@
|
||||
<!-- 视频生成中,显示简洁的进度条(与文生视频一致) -->
|
||||
<div v-else-if="inProgress" class="generating-container">
|
||||
<div class="generating-placeholder">
|
||||
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : t('video.storyboard.generatingVideoText') }}</div>
|
||||
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
|
||||
<div class="progress-fill-indeterminate"></div>
|
||||
</div>
|
||||
<div v-else class="progress-bar-large">
|
||||
<div class="progress-fill-large animated" :style="{ width: '50%' }"></div>
|
||||
<div class="generating-text">{{ t('video.storyboard.generatingVideoText') }}</div>
|
||||
<div class="progress-bar-large" :class="{ indeterminate: taskStatus === 'PENDING' }">
|
||||
<div :class="taskStatus === 'PENDING' ? 'progress-fill-indeterminate' : 'progress-fill-large animated'" :style="taskStatus !== 'PENDING' ? { width: '50%' } : {}"></div>
|
||||
</div>
|
||||
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
|
||||
</div>
|
||||
@@ -488,12 +473,9 @@
|
||||
<!-- 分镜图生成中 -->
|
||||
<div v-if="inProgress && !generatedImageUrl" class="generating-container">
|
||||
<div class="generating-placeholder">
|
||||
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : t('video.storyboard.generatingStoryboardText') }}</div>
|
||||
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
|
||||
<div class="progress-fill-indeterminate"></div>
|
||||
</div>
|
||||
<div v-else class="progress-bar-large">
|
||||
<div class="progress-fill-large animated" :style="{ width: '50%' }"></div>
|
||||
<div class="generating-text">{{ t('video.storyboard.generatingStoryboardText') }}</div>
|
||||
<div class="progress-bar-large" :class="{ indeterminate: taskStatus === 'PENDING' }">
|
||||
<div :class="taskStatus === 'PENDING' ? 'progress-fill-indeterminate' : 'progress-fill-large animated'" :style="taskStatus !== 'PENDING' ? { width: '50%' } : {}"></div>
|
||||
</div>
|
||||
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
|
||||
</div>
|
||||
@@ -513,12 +495,9 @@
|
||||
<!-- 视频生成中 -->
|
||||
<div v-else-if="inProgress" class="generating-container">
|
||||
<div class="generating-placeholder">
|
||||
<div class="generating-text">{{ taskStatus === 'PENDING' ? t('video.storyboard.queuing') : t('video.storyboard.generatingVideoText') }}</div>
|
||||
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
|
||||
<div class="progress-fill-indeterminate"></div>
|
||||
</div>
|
||||
<div v-else class="progress-bar-large">
|
||||
<div class="progress-fill-large animated" :style="{ width: '50%' }"></div>
|
||||
<div class="generating-text">{{ t('video.storyboard.generatingVideoText') }}</div>
|
||||
<div class="progress-bar-large" :class="{ indeterminate: taskStatus === 'PENDING' }">
|
||||
<div :class="taskStatus === 'PENDING' ? 'progress-fill-indeterminate' : 'progress-fill-large animated'" :style="taskStatus !== 'PENDING' ? { width: '50%' } : {}"></div>
|
||||
</div>
|
||||
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">50%</div>
|
||||
</div>
|
||||
@@ -548,7 +527,7 @@
|
||||
|
||||
<!-- 头部信息 -->
|
||||
<div class="history-item-header">
|
||||
<span class="history-type">{{ t('home.storyboardVideo') }}</span>
|
||||
<span class="history-type">{{ currentStep === 'generate' ? t('video.storyboard.storyboardImage') : t('home.storyboardVideo') }}</span>
|
||||
<span class="history-date">{{ formatDate(task.createdAt) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -558,8 +537,7 @@
|
||||
<!-- 预览区域 -->
|
||||
<div class="history-preview">
|
||||
<div v-if="task.status === 'PENDING' || task.status === 'PROCESSING'" class="history-placeholder">
|
||||
<div class="queue-text">{{ t('video.storyboard.queuing') }}</div>
|
||||
<div class="queue-link">{{ t('video.storyboard.subscribeToSpeed') }}</div>
|
||||
<div class="queue-text">{{ t('video.storyboard.generatingText') }}</div>
|
||||
<button class="cancel-btn" @click="cancelTask(task.taskId)">{{ t('common.cancel') }}</button>
|
||||
</div>
|
||||
<div v-else-if="task.status === 'COMPLETED' && task.resultUrl && !isImageUrl(task.resultUrl)" class="history-video-thumbnail">
|
||||
@@ -633,7 +611,7 @@ import { useUserStore } from '@/stores/user'
|
||||
import { createStoryboardTask, getStoryboardTask, getUserStoryboardTasks, retryStoryboardTask } from '@/api/storyboardVideo'
|
||||
import { imageToVideoApi } from '@/api/imageToVideo'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
import { getProcessingWorks, getMyWorks } from '@/api/userWorks'
|
||||
import { getProcessingWorks, getMyWorksByType } from '@/api/userWorks'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -1760,12 +1738,24 @@ const pollTaskStatus = async (taskId) => {
|
||||
generatedImageUrl.value = taskResultUrl
|
||||
isAIGeneratedImage.value = true // AI生成的分镜图
|
||||
mainReferenceImage.value = taskResultUrl // 同时填入左侧分镜图框
|
||||
|
||||
// 分镜图生成完成后,填充 videoPrompt 到视频提示词框
|
||||
if (task.videoPrompt && task.videoPrompt.trim()) {
|
||||
videoPrompt.value = task.videoPrompt
|
||||
console.log('[轮询] 分镜图生成完成,已填充 videoPrompt:', task.videoPrompt.substring(0, 100))
|
||||
}
|
||||
|
||||
// 只有在分镜图生成阶段才设置 inProgress = false
|
||||
// 如果是视频生成阶段(currentStep === 'video'),保持 inProgress = true
|
||||
// 因为用户可能正在等待视频生成
|
||||
if (currentStep.value !== 'video') {
|
||||
inProgress.value = false
|
||||
console.log('[轮询] 分镜图生成完成,设置 inProgress = false')
|
||||
|
||||
// 分镜图生成完成后,清空左侧的输入参数,让用户从空白状态开始新任务
|
||||
inputText.value = ''
|
||||
uploadedImages.value = []
|
||||
console.log('[轮询] 分镜图生成完成,已清空左侧输入参数')
|
||||
} else {
|
||||
console.log('[轮询] 当前在视频步骤,保持 inProgress =', inProgress.value, ', progress:', taskProgress)
|
||||
}
|
||||
@@ -1773,19 +1763,15 @@ const pollTaskStatus = async (taskId) => {
|
||||
// 不再将生成的分镜图添加到参考图数组中,只显示在右侧预览区域
|
||||
}
|
||||
|
||||
// 每次轮询都尝试填充优化后的提示词
|
||||
// 填充 imagePrompt 到生图提示词框(替换用户原始输入)
|
||||
if (task.imagePrompt && task.imagePrompt.trim()) {
|
||||
if (inputText.value !== task.imagePrompt) {
|
||||
inputText.value = task.imagePrompt
|
||||
console.log('[DEBUG] 已自动填充 imagePrompt:', task.imagePrompt.substring(0, 100))
|
||||
}
|
||||
}
|
||||
// 填充 videoPrompt 到视频提示词框
|
||||
if (task.videoPrompt && task.videoPrompt.trim()) {
|
||||
if (videoPrompt.value !== task.videoPrompt) {
|
||||
videoPrompt.value = task.videoPrompt
|
||||
console.log('[DEBUG] 已自动填充 videoPrompt:', task.videoPrompt.substring(0, 100))
|
||||
// 分镜图生成完成后,不再自动填充提示词到左侧输入框
|
||||
// 只在视频步骤时填充 videoPrompt
|
||||
if (currentStep.value === 'video') {
|
||||
// 填充 videoPrompt 到视频提示词框
|
||||
if (task.videoPrompt && task.videoPrompt.trim()) {
|
||||
if (videoPrompt.value !== task.videoPrompt) {
|
||||
videoPrompt.value = task.videoPrompt
|
||||
console.log('[DEBUG] 已自动填充 videoPrompt:', task.videoPrompt.substring(0, 100))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1990,12 +1976,23 @@ const startVideoGenerate = async () => {
|
||||
console.log('[生成视频] 第一张参考图前50字符:', referenceImages[0].substring(0, 50))
|
||||
}
|
||||
|
||||
// 确定要使用的分镜图:优先使用AI生成的分镜图,其次使用用户上传的参考图
|
||||
const effectiveStoryboardImage = generatedImageUrl.value || mainReferenceImage.value
|
||||
|
||||
if (!effectiveStoryboardImage) {
|
||||
ElMessage.warning(t('video.storyboard.noStoryboardImage'))
|
||||
inProgress.value = false
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[生成视频] 使用的分镜图:', effectiveStoryboardImage.substring(0, 80) + '...')
|
||||
|
||||
const response = await startVideoGeneration(taskId.value, {
|
||||
duration: parseInt(duration.value),
|
||||
aspectRatio: aspectRatio.value,
|
||||
hdMode: hdMode.value,
|
||||
referenceImages: referenceImages, // 只传递视频阶段的参考图
|
||||
storyboardImage: generatedImageUrl.value // 传递分镜图URL,用于恢复被覆盖的分镜图
|
||||
storyboardImage: effectiveStoryboardImage // 传递分镜图URL(AI生成或用户上传)
|
||||
})
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
@@ -2098,15 +2095,22 @@ const startVideoGenerate = async () => {
|
||||
|
||||
ElMessage.info(t('video.storyboard.startingVideoGenerate'))
|
||||
|
||||
const { createStoryboardTask, startVideoGeneration } = await import('@/api/storyboardVideo')
|
||||
// 使用新的 API 直接创建视频任务(跳过分镜图生成)
|
||||
const { createVideoDirectTask } = await import('@/api/storyboardVideo')
|
||||
|
||||
// 第一步:创建任务(传入上传的分镜图)
|
||||
const response = await createStoryboardTask({
|
||||
prompt: prompt || '根据图片生成分镜',
|
||||
// 收集视频参考图
|
||||
let referenceImages = videoReferenceImages.value
|
||||
.filter(img => img && img.url)
|
||||
.map(img => img.url)
|
||||
|
||||
// 直接创建视频任务
|
||||
const response = await createVideoDirectTask({
|
||||
storyboardImage: imageUrl, // 用户上传的分镜图
|
||||
prompt: prompt || '根据分镜图生成视频',
|
||||
aspectRatio: aspectRatio.value,
|
||||
duration: parseInt(duration.value),
|
||||
hdMode: hdMode.value,
|
||||
imageUrl: imageUrl // 传入上传的分镜图
|
||||
referenceImages: referenceImages
|
||||
})
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
@@ -2117,23 +2121,9 @@ const startVideoGenerate = async () => {
|
||||
// 设置分镜图URL(用于显示)
|
||||
generatedImageUrl.value = imageUrl
|
||||
|
||||
// 第二步:立即开始视频生成
|
||||
const videoResponse = await startVideoGeneration(newTaskId, {
|
||||
duration: parseInt(duration.value),
|
||||
aspectRatio: aspectRatio.value,
|
||||
hdMode: hdMode.value,
|
||||
storyboardImage: imageUrl
|
||||
})
|
||||
|
||||
if (videoResponse.data && videoResponse.data.success) {
|
||||
ElMessage.success(t('video.storyboard.videoTaskStarted'))
|
||||
refreshUserPoints()
|
||||
pollTaskStatus(newTaskId)
|
||||
} else {
|
||||
const errorMsg = videoResponse.data?.message || t('video.storyboard.videoStartFailed')
|
||||
handleInsufficientPointsError(errorMsg, errorMsg)
|
||||
inProgress.value = false
|
||||
}
|
||||
ElMessage.success(t('video.storyboard.videoTaskStarted'))
|
||||
refreshUserPoints()
|
||||
pollTaskStatus(newTaskId)
|
||||
} else {
|
||||
const errorMsg = response.data?.message || t('video.storyboard.createTaskFailed')
|
||||
handleInsufficientPointsError(errorMsg, errorMsg)
|
||||
@@ -2222,6 +2212,10 @@ const pollVideoTaskStatus = async (taskId) => {
|
||||
// 处理历史记录URL
|
||||
const processHistoryUrl = (url) => {
|
||||
if (!url) return ''
|
||||
// data: 协议(Base64 图片等)直接返回,避免被当成相对路径错误加前缀
|
||||
if (url.startsWith('data:')) {
|
||||
return url
|
||||
}
|
||||
// 如果是相对路径,确保格式正确
|
||||
if (url.startsWith('/') || !url.startsWith('http')) {
|
||||
if (!url.startsWith('/uploads/') && !url.startsWith('/api/')) {
|
||||
@@ -2231,7 +2225,7 @@ const processHistoryUrl = (url) => {
|
||||
return url
|
||||
}
|
||||
|
||||
// 加载历史记录(同时加载分镜图和视频两种历史记录)
|
||||
// 加载历史记录(从user_works表获取分镜图和分镜视频)
|
||||
const loadHistory = async () => {
|
||||
// 只有登录用户才能查看历史记录
|
||||
if (!userStore.isAuthenticated) {
|
||||
@@ -2244,17 +2238,29 @@ const loadHistory = async () => {
|
||||
// 同时加载分镜图和视频历史记录
|
||||
|
||||
// 1. 加载视频历史记录:从 user_works 获取 STORYBOARD_VIDEO 类型的作品
|
||||
const videoResponse = await getMyWorks({ page: 0, size: 1000 })
|
||||
const videoResponse = await getMyWorksByType('STORYBOARD_VIDEO', { page: 0, size: 1000 })
|
||||
|
||||
if (videoResponse.data && videoResponse.data.success) {
|
||||
const works = (videoResponse.data.data || []).filter(work =>
|
||||
work.workType === 'STORYBOARD_VIDEO' && work.status === 'COMPLETED'
|
||||
)
|
||||
const works = videoResponse.data.data || []
|
||||
|
||||
console.log(`[历史记录-视频] 结果数量: ${works.length}`)
|
||||
// 过滤掉 resultUrl 是图片的记录(这些是分镜图完成但视频还没生成的)
|
||||
const videoWorks = works.filter(work => {
|
||||
// 如果是 PROCESSING/PENDING 状态,保留
|
||||
if (work.status === 'PROCESSING' || work.status === 'PENDING') {
|
||||
return true
|
||||
}
|
||||
// 如果是 COMPLETED 状态,只保留 resultUrl 是视频的
|
||||
if (work.status === 'COMPLETED' && work.resultUrl) {
|
||||
const isImage = /\.(png|jpg|jpeg|gif|webp|bmp)(\?|$)/i.test(work.resultUrl)
|
||||
return !isImage // 只保留视频
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
console.log(`[历史记录-视频] 原始数量: ${works.length}, 过滤后: ${videoWorks.length}`)
|
||||
|
||||
// 转换为任务格式 - 分镜视频用 resultUrl 显示视频
|
||||
videoHistoryTasks.value = works.map(work => ({
|
||||
videoHistoryTasks.value = videoWorks.map(work => ({
|
||||
taskId: work.taskId || work.id?.toString(),
|
||||
prompt: work.prompt,
|
||||
resultUrl: work.resultUrl, // 视频URL(用于播放)
|
||||
@@ -2263,6 +2269,7 @@ const loadHistory = async () => {
|
||||
videoResultUrl: work.resultUrl, // 视频URL(用于下载)
|
||||
imageUrl: work.imageUrl,
|
||||
uploadedImages: work.uploadedImages, // 用户上传的参考图,用于做同款
|
||||
videoReferenceImages: work.videoReferenceImages, // 视频阶段用户上传的参考图
|
||||
imagePrompt: work.imagePrompt,
|
||||
videoPrompt: work.videoPrompt,
|
||||
aspectRatio: work.aspectRatio,
|
||||
@@ -2271,37 +2278,36 @@ const loadHistory = async () => {
|
||||
quality: work.quality,
|
||||
status: work.status,
|
||||
workType: work.workType,
|
||||
createdAt: work.createdAt
|
||||
createdAt: work.createdAt,
|
||||
progress: work.status === 'PROCESSING' ? 50 : (work.status === 'COMPLETED' ? 100 : 0)
|
||||
}))
|
||||
} else {
|
||||
videoHistoryTasks.value = []
|
||||
}
|
||||
|
||||
// 2. 加载分镜图历史记录:从 storyboard_video_tasks 获取分镜图任务
|
||||
const storyboardResponse = await getUserStoryboardTasks(0, 1000)
|
||||
// 2. 加载分镜图历史记录:从 user_works 获取 STORYBOARD_IMAGE 类型的作品
|
||||
const storyboardResponse = await getMyWorksByType('STORYBOARD_IMAGE', { page: 0, size: 1000 })
|
||||
|
||||
if (storyboardResponse.data && storyboardResponse.data.success) {
|
||||
// 显示有分镜图的任务
|
||||
const tasks = (storyboardResponse.data.data || []).filter(task => {
|
||||
if (task.status !== 'COMPLETED') return false
|
||||
// 检查 resultUrl 是否存在且是图片
|
||||
const resultUrl = task.resultUrl || ''
|
||||
if (!resultUrl) return false
|
||||
const isVideo = /\.(mp4|webm|mov|avi)(\?|$)/i.test(resultUrl)
|
||||
return !isVideo // 只要不是视频就显示
|
||||
})
|
||||
const works = storyboardResponse.data.data || []
|
||||
|
||||
console.log(`[历史记录-分镜图] 结果数量: ${tasks.length}`)
|
||||
console.log(`[历史记录-分镜图] 结果数量: ${works.length}`)
|
||||
|
||||
// 处理URL
|
||||
storyboardHistoryTasks.value = tasks.map(task => ({
|
||||
...task,
|
||||
resultUrl: task.resultUrl && !task.resultUrl.startsWith('data:') && !task.resultUrl.startsWith('http')
|
||||
? processHistoryUrl(task.resultUrl)
|
||||
: task.resultUrl,
|
||||
imageUrl: task.imageUrl && !task.imageUrl.startsWith('data:') && !task.imageUrl.startsWith('http')
|
||||
? processHistoryUrl(task.imageUrl)
|
||||
: task.imageUrl
|
||||
// 转换为任务格式
|
||||
storyboardHistoryTasks.value = works.map(work => ({
|
||||
taskId: work.taskId?.replace('_image', '') || work.id?.toString(), // 移除_image后缀
|
||||
prompt: work.prompt,
|
||||
resultUrl: work.resultUrl ? processHistoryUrl(work.resultUrl) : null,
|
||||
imageUrl: work.resultUrl ? processHistoryUrl(work.resultUrl) : null,
|
||||
uploadedImages: work.uploadedImages,
|
||||
imagePrompt: work.imagePrompt,
|
||||
aspectRatio: work.aspectRatio,
|
||||
hdMode: work.quality === 'HD',
|
||||
quality: work.quality,
|
||||
status: work.status,
|
||||
workType: work.workType,
|
||||
createdAt: work.createdAt,
|
||||
progress: work.status === 'PROCESSING' ? 50 : (work.status === 'COMPLETED' ? 100 : 0)
|
||||
}))
|
||||
} else {
|
||||
storyboardHistoryTasks.value = []
|
||||
@@ -2360,39 +2366,31 @@ const createSimilarFromHistory = (task) => {
|
||||
// 清空 Step 1 的 inputText,避免混淆
|
||||
inputText.value = ''
|
||||
|
||||
// 恢复用户上传的参考图片(显示在下方3个上传框中)
|
||||
// 恢复视频阶段用户上传的参考图片(显示在下方3个上传框中)
|
||||
// 注意:使用 videoReferenceImages(视频阶段参考图),不是 uploadedImages(分镜图阶段参考图)
|
||||
videoReferenceImages.value = [null, null, null] // 先清空
|
||||
// 优先从 uploadedImages 恢复(JSON数组)
|
||||
if (task.uploadedImages) {
|
||||
// 优先从 videoReferenceImages 恢复(JSON数组)- 这是视频阶段的参考图
|
||||
if (task.videoReferenceImages) {
|
||||
try {
|
||||
const parsedImages = typeof task.uploadedImages === 'string'
|
||||
? JSON.parse(task.uploadedImages)
|
||||
: task.uploadedImages
|
||||
const parsedImages = typeof task.videoReferenceImages === 'string'
|
||||
? JSON.parse(task.videoReferenceImages)
|
||||
: task.videoReferenceImages
|
||||
if (Array.isArray(parsedImages)) {
|
||||
parsedImages.filter(img => img && img !== 'null').forEach((img, idx) => {
|
||||
if (idx < 3) {
|
||||
videoReferenceImages.value[idx] = {
|
||||
url: img,
|
||||
file: null,
|
||||
name: `参考图片${idx + 1}`
|
||||
name: `视频参考图${idx + 1}`
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('[做同款-视频] 恢复用户上传图片(uploadedImages):', parsedImages.length, '张')
|
||||
console.log('[做同款-视频] 恢复视频阶段参考图(videoReferenceImages):', parsedImages.length, '张')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[做同款-视频] 解析 uploadedImages 失败:', e)
|
||||
console.warn('[做同款-视频] 解析 videoReferenceImages 失败:', e)
|
||||
}
|
||||
}
|
||||
// 兼容旧字段 imageUrl
|
||||
if (!videoReferenceImages.value[0] && task.imageUrl) {
|
||||
videoReferenceImages.value[0] = {
|
||||
url: task.imageUrl,
|
||||
file: null,
|
||||
name: '参考图片'
|
||||
}
|
||||
console.log('[做同款-视频] 恢复用户上传图片(imageUrl):', task.imageUrl)
|
||||
}
|
||||
|
||||
// 清空 Step 1 的参考图
|
||||
uploadedImages.value = []
|
||||
@@ -2473,6 +2471,12 @@ const goToVideoStepWithStoryboard = (task) => {
|
||||
// 切换到视频生成步骤
|
||||
currentStep.value = 'video'
|
||||
|
||||
// 更新 taskId 为选中任务的 ID(重要:确保后续操作针对正确的任务)
|
||||
if (task.taskId) {
|
||||
taskId.value = task.taskId
|
||||
console.log('[生成视频] 切换到任务:', task.taskId)
|
||||
}
|
||||
|
||||
// 设置分镜图
|
||||
if (task.resultUrl && isImageUrl(task.resultUrl)) {
|
||||
generatedImageUrl.value = task.resultUrl
|
||||
@@ -2490,27 +2494,28 @@ const goToVideoStepWithStoryboard = (task) => {
|
||||
videoPrompt.value = task.prompt
|
||||
}
|
||||
|
||||
// 恢复用户上传的参考图片到视频参考图框
|
||||
// 恢复视频阶段用户上传的参考图片到视频参考图框
|
||||
// 注意:使用 videoReferenceImages(视频阶段参考图),不是 uploadedImages(分镜图阶段参考图)
|
||||
videoReferenceImages.value = [null, null, null]
|
||||
if (task.uploadedImages) {
|
||||
if (task.videoReferenceImages) {
|
||||
try {
|
||||
const parsedImages = typeof task.uploadedImages === 'string'
|
||||
? JSON.parse(task.uploadedImages)
|
||||
: task.uploadedImages
|
||||
const parsedImages = typeof task.videoReferenceImages === 'string'
|
||||
? JSON.parse(task.videoReferenceImages)
|
||||
: task.videoReferenceImages
|
||||
if (Array.isArray(parsedImages)) {
|
||||
parsedImages.filter(img => img && img !== 'null').forEach((img, idx) => {
|
||||
if (idx < 3) {
|
||||
videoReferenceImages.value[idx] = {
|
||||
url: img,
|
||||
file: null,
|
||||
name: `参考图片${idx + 1}`
|
||||
name: `视频参考图${idx + 1}`
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('[生成视频] 恢复用户上传图片:', parsedImages.length, '张')
|
||||
console.log('[生成视频] 恢复视频阶段参考图:', parsedImages.length, '张')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[生成视频] 解析 uploadedImages 失败:', e)
|
||||
console.warn('[生成视频] 解析 videoReferenceImages 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2626,7 +2631,7 @@ const formatDate = (dateString) => {
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
'PENDING': t('video.storyboard.statusPending'),
|
||||
'PENDING': t('video.storyboard.statusProcessing'), // 统一显示为"生成中"
|
||||
'PROCESSING': t('video.storyboard.statusProcessing'),
|
||||
'COMPLETED': t('video.completed'),
|
||||
'FAILED': t('video.failed'),
|
||||
@@ -2640,7 +2645,7 @@ const getDisplayStatusText = () => {
|
||||
// 分镜图创作页面
|
||||
if (currentStep.value === 'generate') {
|
||||
if (inProgress.value) {
|
||||
return taskStatus.value === 'PENDING' ? t('video.storyboard.statusPending') : t('video.storyboard.statusProcessing')
|
||||
return t('video.storyboard.statusProcessing') // 统一显示为"生成中"
|
||||
}
|
||||
if (generatedImageUrl.value && isAIGeneratedImage.value) {
|
||||
return t('video.storyboard.statusCompleted') // AI生成的分镜图已完成
|
||||
@@ -2787,10 +2792,17 @@ const restoreProcessingTask = async () => {
|
||||
if (detailResponse.data && detailResponse.data.success) {
|
||||
const taskDetail = detailResponse.data.data
|
||||
|
||||
// 判断任务状态,如果已完成则不恢复任何参数
|
||||
const detailStatus = taskDetail.status || 'PROCESSING'
|
||||
if (detailStatus === 'COMPLETED') {
|
||||
console.log('[恢复任务] 任务已完成,不恢复参数')
|
||||
return false // 不需要恢复
|
||||
}
|
||||
|
||||
currentTask.value = taskDetail
|
||||
taskId.value = taskDetail.taskId
|
||||
|
||||
// 恢复输入参数
|
||||
// 只有 PROCESSING/PENDING 状态才恢复输入参数
|
||||
if (taskDetail.prompt) {
|
||||
inputText.value = taskDetail.prompt
|
||||
}
|
||||
@@ -2810,43 +2822,19 @@ const restoreProcessingTask = async () => {
|
||||
// 判断任务进度,决定恢复到哪个步骤
|
||||
const taskProgress = Number(taskDetail.progress) || 0
|
||||
const taskResultUrl = taskDetail.resultUrl || ''
|
||||
const detailStatus = taskDetail.status || 'PROCESSING'
|
||||
|
||||
// 1. 如果分镜图生成任务已完成(有resultUrl且状态是COMPLETED),不应该恢复
|
||||
if (taskResultUrl && isImageUrl(taskResultUrl) && detailStatus === 'COMPLETED') {
|
||||
// 设置分镜图URL,但不启动任务
|
||||
generatedImageUrl.value = taskResultUrl
|
||||
isAIGeneratedImage.value = true // AI生成的分镜图
|
||||
mainReferenceImage.value = taskResultUrl // 填充到分镜图框
|
||||
|
||||
// 恢复 videoPrompt
|
||||
if (taskDetail.videoPrompt) {
|
||||
videoPrompt.value = taskDetail.videoPrompt
|
||||
} else if (taskDetail.prompt) {
|
||||
videoPrompt.value = taskDetail.prompt
|
||||
}
|
||||
|
||||
// 将分镜图添加到上传列表
|
||||
const alreadyInList = uploadedImages.value.some(img => img.url === taskResultUrl)
|
||||
if (!alreadyInList) {
|
||||
uploadedImages.value.unshift({
|
||||
url: taskResultUrl,
|
||||
file: null,
|
||||
name: '生成的分镜图'
|
||||
})
|
||||
}
|
||||
currentStep.value = 'video' // 切换到视频生成步骤
|
||||
inProgress.value = false // 不显示"生成中"
|
||||
console.log('[恢复任务] 分镜图已完成,填充分镜图框和videoPrompt')
|
||||
return false // 不需要恢复轮询
|
||||
}
|
||||
console.log('[恢复任务] taskResultUrl:', taskResultUrl ? taskResultUrl.substring(0, 100) : '空')
|
||||
console.log('[恢复任务] isImageUrl:', isImageUrl(taskResultUrl))
|
||||
console.log('[恢复任务] detailStatus:', detailStatus)
|
||||
console.log('[恢复任务] taskProgress:', taskProgress)
|
||||
|
||||
// 2. 如果分镜图已生成(有resultUrl),状态是PROCESSING
|
||||
// 1. 如果分镜图已生成(有resultUrl),状态是PROCESSING
|
||||
// 说明视频正在生成中(分镜图完成后,状态仍为PROCESSING表示视频生成中)
|
||||
if (taskResultUrl && isImageUrl(taskResultUrl) && detailStatus === 'PROCESSING') {
|
||||
generatedImageUrl.value = taskResultUrl
|
||||
isAIGeneratedImage.value = true // AI生成的分镜图
|
||||
mainReferenceImage.value = taskResultUrl // 填充到分镜图框
|
||||
console.log('[恢复任务] 已设置 mainReferenceImage:', taskResultUrl.substring(0, 50))
|
||||
|
||||
// 恢复 videoPrompt
|
||||
if (taskDetail.videoPrompt) {
|
||||
@@ -2885,15 +2873,37 @@ const restoreProcessingTask = async () => {
|
||||
pollTaskStatus(taskDetail.taskId)
|
||||
return true
|
||||
}
|
||||
// 3. 如果分镜图还在生成中(没有resultUrl,状态是PROCESSING)
|
||||
// 2. 如果分镜图还在生成中(没有resultUrl,状态是PROCESSING)
|
||||
else if (!taskResultUrl && detailStatus === 'PROCESSING') {
|
||||
// 恢复用户上传的参考图片
|
||||
if (taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
|
||||
// 恢复用户上传的参考图片(分镜图阶段)
|
||||
// 优先从 uploadedImages 恢复(JSON数组,支持多张图片)
|
||||
if (taskDetail.uploadedImages) {
|
||||
try {
|
||||
const parsedImages = typeof taskDetail.uploadedImages === 'string'
|
||||
? JSON.parse(taskDetail.uploadedImages)
|
||||
: taskDetail.uploadedImages
|
||||
if (Array.isArray(parsedImages) && parsedImages.length > 0) {
|
||||
uploadedImages.value = parsedImages
|
||||
.filter(img => img && img !== 'null')
|
||||
.map((url, idx) => ({
|
||||
url: url,
|
||||
file: null,
|
||||
name: `参考图片${idx + 1}`
|
||||
}))
|
||||
console.log('[恢复任务-分镜图生成中] 恢复参考图:', uploadedImages.value.length, '张')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[恢复任务] 解析 uploadedImages 失败:', e)
|
||||
}
|
||||
}
|
||||
// 兼容旧字段 imageUrl(单张图片)
|
||||
if (uploadedImages.value.length === 0 && taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
|
||||
uploadedImages.value = [{
|
||||
url: taskDetail.imageUrl,
|
||||
file: null,
|
||||
name: '参考图片'
|
||||
}]
|
||||
console.log('[恢复任务-分镜图生成中] 恢复参考图(imageUrl):', taskDetail.imageUrl)
|
||||
}
|
||||
|
||||
currentStep.value = 'generate'
|
||||
@@ -2904,8 +2914,29 @@ const restoreProcessingTask = async () => {
|
||||
}
|
||||
// 4. 其他情况(PENDING等),也恢复
|
||||
else if (detailStatus === 'PENDING') {
|
||||
// 恢复用户上传的参考图片
|
||||
if (taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
|
||||
// 恢复用户上传的参考图片(分镜图阶段)
|
||||
// 优先从 uploadedImages 恢复(JSON数组,支持多张图片)
|
||||
if (taskDetail.uploadedImages) {
|
||||
try {
|
||||
const parsedImages = typeof taskDetail.uploadedImages === 'string'
|
||||
? JSON.parse(taskDetail.uploadedImages)
|
||||
: taskDetail.uploadedImages
|
||||
if (Array.isArray(parsedImages) && parsedImages.length > 0) {
|
||||
uploadedImages.value = parsedImages
|
||||
.filter(img => img && img !== 'null')
|
||||
.map((url, idx) => ({
|
||||
url: url,
|
||||
file: null,
|
||||
name: `参考图片${idx + 1}`
|
||||
}))
|
||||
console.log('[恢复任务-PENDING] 恢复参考图:', uploadedImages.value.length, '张')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[恢复任务] 解析 uploadedImages 失败:', e)
|
||||
}
|
||||
}
|
||||
// 兼容旧字段 imageUrl(单张图片)
|
||||
if (uploadedImages.value.length === 0 && taskDetail.imageUrl && isImageUrl(taskDetail.imageUrl)) {
|
||||
uploadedImages.value = [{
|
||||
url: taskDetail.imageUrl,
|
||||
file: null,
|
||||
@@ -2946,13 +2977,25 @@ const restoreProcessingTask = async () => {
|
||||
taskId.value = work.taskId
|
||||
inputText.value = work.prompt || ''
|
||||
|
||||
// 恢复参考图片(从 thumbnailUrl 获取)
|
||||
if (work.thumbnailUrl && isImageUrl(work.thumbnailUrl)) {
|
||||
uploadedImages.value = [{
|
||||
url: work.thumbnailUrl,
|
||||
file: null,
|
||||
name: '参考图片'
|
||||
}]
|
||||
// 恢复参考图片(优先从 uploadedImages 恢复)
|
||||
if (work.uploadedImages) {
|
||||
try {
|
||||
const parsedImages = typeof work.uploadedImages === 'string'
|
||||
? JSON.parse(work.uploadedImages)
|
||||
: work.uploadedImages
|
||||
if (Array.isArray(parsedImages) && parsedImages.length > 0) {
|
||||
uploadedImages.value = parsedImages
|
||||
.filter(img => img && img !== 'null')
|
||||
.map((url, idx) => ({
|
||||
url: url,
|
||||
file: null,
|
||||
name: `参考图片${idx + 1}`
|
||||
}))
|
||||
console.log('[恢复任务-兜底] 恢复参考图:', uploadedImages.value.length, '张')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[恢复任务-兜底] 解析 uploadedImages 失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
inProgress.value = true
|
||||
@@ -2980,43 +3023,10 @@ const checkLastTaskStatus = async () => {
|
||||
if (response.data && response.data.success && response.data.data && response.data.data.length > 0) {
|
||||
const lastTask = response.data.data[0]
|
||||
|
||||
// 检查是否是"分镜图已完成但视频未生成"的任务
|
||||
// 条件:状态是 COMPLETED,有分镜图结果(resultUrl),但没有视频结果(videoUrls)
|
||||
const hasStoryboard = lastTask.resultUrl && isImageUrl(lastTask.resultUrl)
|
||||
const hasVideo = lastTask.videoUrls || lastTask.videoUrl
|
||||
if (lastTask.status === 'COMPLETED' && hasStoryboard && !hasVideo) {
|
||||
// 这是分镜图已完成的任务,恢复到 Step 2
|
||||
const taskResultUrl = processHistoryUrl(lastTask.resultUrl)
|
||||
generatedImageUrl.value = taskResultUrl
|
||||
isAIGeneratedImage.value = true // AI生成的分镜图
|
||||
mainReferenceImage.value = taskResultUrl // 填充到分镜图框
|
||||
|
||||
// 恢复 taskId(关键!用于生成视频时识别任务)
|
||||
taskId.value = lastTask.taskId || ''
|
||||
|
||||
// 恢复 videoPrompt
|
||||
if (lastTask.videoPrompt) {
|
||||
videoPrompt.value = lastTask.videoPrompt
|
||||
} else if (lastTask.prompt) {
|
||||
videoPrompt.value = lastTask.prompt
|
||||
}
|
||||
|
||||
// 恢复提示词
|
||||
if (lastTask.prompt) {
|
||||
inputText.value = lastTask.prompt
|
||||
}
|
||||
|
||||
// 恢复其他参数
|
||||
if (lastTask.aspectRatio) {
|
||||
aspectRatio.value = lastTask.aspectRatio
|
||||
}
|
||||
if (lastTask.hdMode !== undefined) {
|
||||
hdMode.value = lastTask.hdMode
|
||||
}
|
||||
|
||||
// 切换到视频生成步骤
|
||||
currentStep.value = 'video'
|
||||
console.log('[恢复任务] 分镜图已完成(从历史记录),taskId:', lastTask.taskId, '切换到视频生成步骤')
|
||||
// 如果最近的任务是完全完成的(COMPLETED 状态),不恢复任何参数
|
||||
// 让用户从空白状态开始新任务
|
||||
if (lastTask.status === 'COMPLETED') {
|
||||
console.log('[checkLastTaskStatus] 最近任务已完成,不恢复参数')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3536,6 +3546,7 @@ onBeforeUnmount(() => {
|
||||
padding: 0;
|
||||
margin: 8px 0;
|
||||
margin-left: 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.storyboard-steps-svg svg {
|
||||
@@ -3551,6 +3562,25 @@ onBeforeUnmount(() => {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 步骤点击容器 */
|
||||
.step-click-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.step-click-container.step-1 {
|
||||
left: 0;
|
||||
width: 122px; /* SVG宽度的一半 */
|
||||
}
|
||||
|
||||
.step-click-container.step-2 {
|
||||
left: 122px;
|
||||
width: 122px; /* SVG宽度的一半 */
|
||||
}
|
||||
|
||||
/* 生成的图片预览 */
|
||||
.generated-image-preview {
|
||||
width: 100%;
|
||||
@@ -4784,6 +4814,15 @@ onBeforeUnmount(() => {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.step-click-container.step-1,
|
||||
.step-click-container.step-2 {
|
||||
width: 110px; /* 220px的一半 */
|
||||
}
|
||||
|
||||
.step-click-container.step-2 {
|
||||
left: 110px;
|
||||
}
|
||||
|
||||
.step {
|
||||
text-align: left;
|
||||
}
|
||||
@@ -5522,18 +5561,63 @@ onBeforeUnmount(() => {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 200% 50%; }
|
||||
}
|
||||
|
||||
/* 视频完成后的操作按钮区域 */
|
||||
.result-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.result-actions .action-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.result-actions .action-btn.primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.result-actions .action-btn.primary:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.result-actions .action-icons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-actions .icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.result-actions .icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.result-actions .icon-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
C:\Users\UI\Desktop\AIGC\demo>java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev
|
||||
Error: Unable to access jarfile target/demo-0.0.1-SNAPSHOT.jar
|
||||
|
||||
C:\Users\UI\Desktop\AIGC\demo>
|
||||
@@ -127,7 +127,7 @@
|
||||
<div class="package-header">
|
||||
<h4 class="package-title">{{ $t('subscription.free') }}</h4>
|
||||
</div>
|
||||
<div class="package-price">¥{{ membershipPrices.free }}/{{ $t('subscription.perMonth') }}</div>
|
||||
<div class="package-price">¥{{ formatPrice(membershipPrices.free) }}/{{ $t('subscription.perMonth') }}</div>
|
||||
<div class="points-box points-box-placeholder"> </div>
|
||||
<button class="package-button current">{{ $t('subscription.currentPackage') }}</button>
|
||||
<div class="package-features">
|
||||
@@ -160,9 +160,8 @@
|
||||
<h4 class="package-title">{{ $t('subscription.standard') }}</h4>
|
||||
<div class="discount-tag">{{ $t('subscription.firstPurchaseDiscount') }}</div>
|
||||
</div>
|
||||
<div class="package-price">¥{{ membershipPrices.standard }}/{{ Math.floor((membershipPoints.standard || 0) / 30) }}{{ $t('subscription.items') }}</div>
|
||||
<div class="points-box" v-if="membershipPoints.standard !== null">{{ membershipPoints.standard }}{{ $t('subscription.points') }}</div>
|
||||
<div class="points-box" v-else>{{ $t('subscription.loading') }}</div>
|
||||
<div class="package-price">¥{{ formatPrice(membershipPrices.standard) }}/{{ membershipPoints.standard || 0 }}{{ $t('subscription.points') }}</div>
|
||||
<div class="points-box points-box-placeholder"> </div>
|
||||
<button class="package-button subscribe" @click.stop="handleSubscribe('standard')">{{ $t('subscription.subscribe') }}</button>
|
||||
<div class="package-features">
|
||||
<div class="feature-item">
|
||||
@@ -202,9 +201,8 @@
|
||||
<h4 class="package-title">{{ $t('subscription.professional') }}</h4>
|
||||
<div class="value-tag">{{ $t('subscription.bestValue') }}</div>
|
||||
</div>
|
||||
<div class="package-price">¥{{ membershipPrices.premium }}/{{ Math.floor((membershipPoints.premium || 0) / 30) }}{{ $t('subscription.items') }}</div>
|
||||
<div class="points-box" v-if="membershipPoints.premium !== null">{{ membershipPoints.premium }}{{ $t('subscription.points') }}</div>
|
||||
<div class="points-box" v-else>{{ $t('subscription.loading') }}</div>
|
||||
<div class="package-price">¥{{ formatPrice(membershipPrices.premium) }}/{{ membershipPoints.premium || 0 }}{{ $t('subscription.points') }}</div>
|
||||
<div class="points-box points-box-placeholder"> </div>
|
||||
<button class="package-button premium" @click.stop="handleSubscribe('premium')">{{ $t('subscription.subscribe') }}</button>
|
||||
<div class="package-features">
|
||||
<div class="feature-item">
|
||||
@@ -414,7 +412,7 @@ const subscriptionInfo = ref({
|
||||
|
||||
// 套餐名称映射函数:将后端返回的中文套餐名映射到国际化key
|
||||
const mapPlanNameToI18nKey = (planName) => {
|
||||
if (!planName) return t('subscription.free')
|
||||
if (!planName || planName.trim() === '') return t('subscription.free')
|
||||
|
||||
// 移除"会员"、"套餐"等后缀,并转为小写
|
||||
const planLower = planName.replace(/会员|套餐|版本/g, '').toLowerCase()
|
||||
@@ -636,6 +634,12 @@ const loadMembershipPrices = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化价格,保留两位小数
|
||||
const formatPrice = (price) => {
|
||||
if (price === null || price === undefined) return '--'
|
||||
return Number(price).toFixed(2)
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
// 先加载会员等级价格配置(不需要登录)
|
||||
@@ -662,6 +666,29 @@ onMounted(async () => {
|
||||
|
||||
// 然后从API获取完整的订阅信息
|
||||
await loadUserSubscriptionInfo()
|
||||
|
||||
// 检查URL参数,处理支付回调
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const paymentStatus = urlParams.get('paymentStatus')
|
||||
const orderId = urlParams.get('orderId')
|
||||
|
||||
if (paymentStatus) {
|
||||
// 清除URL参数,避免刷新时重复显示
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
|
||||
if (paymentStatus === 'success') {
|
||||
ElMessage.success(t('subscription.paymentSuccess'))
|
||||
// 刷新用户积分信息
|
||||
await userStore.fetchCurrentUser()
|
||||
await loadUserSubscriptionInfo()
|
||||
} else if (paymentStatus === 'pending') {
|
||||
ElMessage.info(t('subscription.paymentPending'))
|
||||
} else if (paymentStatus === 'cancelled') {
|
||||
ElMessage.warning(t('subscription.paymentCancelled'))
|
||||
} else if (paymentStatus === 'error') {
|
||||
ElMessage.error(t('subscription.paymentError'))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 路由守卫应该已经处理了跳转,但这里作为双重保险
|
||||
console.warn('用户未登录,路由守卫应该已处理跳转')
|
||||
@@ -1323,10 +1350,10 @@ const createSubscriptionOrder = async (planType, planInfo) => {
|
||||
|
||||
/* 套餐选择 */
|
||||
.subscription-packages {
|
||||
padding: 0 30px 30px; /* 与顶部盒子保持一致的左右留白 */
|
||||
margin: 0 auto; /* 居中显示,与上方盒子对齐 */
|
||||
max-width: calc(100% - 80px); /* 限制最大宽度,与上方盒子一致 */
|
||||
margin-left: 15px; /* 增加左边距,与上方盒子更精确对齐 */
|
||||
padding: 0 30px 30px;
|
||||
margin: 0 auto;
|
||||
max-width: calc(100% - 80px);
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.subscription-packages .section-title {
|
||||
@@ -1354,9 +1381,9 @@ const createSubscriptionOrder = async (planType, planInfo) => {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
max-width: 1440px;
|
||||
margin: 0 30px 0 0; /* 右侧留白与左侧 padding(30px) 保持一致 */
|
||||
margin: 0 30px 0 0;
|
||||
width: 100%;
|
||||
align-items: stretch; /* 卡片等高 */
|
||||
min-height: calc(100vh - 450px); /* 调整高度 */
|
||||
}
|
||||
|
||||
.subscription-packages .package-card {
|
||||
@@ -1366,7 +1393,6 @@ const createSubscriptionOrder = async (planType, planInfo) => {
|
||||
border: 1px solid #333;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 700px; /* 进一步拉长 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
<h3>{{ level.name }}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="price">¥{{ level.price || 0 }}/{{ Math.floor((level.resourcePoints || level.pointsBonus || 0) / 30) }}{{ $t('subscription.items') }}</p>
|
||||
<p class="price">¥{{ formatPrice(level.price) }}/{{ Math.floor((level.resourcePoints || level.pointsBonus || 0) / 30) }}{{ $t('subscription.items') }}</p>
|
||||
<p class="description">{{ level.resourcePoints || level.pointsBonus || 0 }}{{ $t('subscription.points') }}</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
@@ -554,8 +554,14 @@ const handleCloseEditDialog = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化价格,保留两位小数
|
||||
const formatPrice = (price) => {
|
||||
if (price === null || price === undefined) return '0.00'
|
||||
return Number(price).toFixed(2)
|
||||
}
|
||||
|
||||
const handlePriceInput = (value) => {
|
||||
// 确保只输入数字
|
||||
// 确保只输入数字和小数点
|
||||
editForm.price = value.replace(/[^\d.]/g, '')
|
||||
}
|
||||
|
||||
@@ -564,8 +570,8 @@ const saveEdit = async () => {
|
||||
if (!valid) return
|
||||
|
||||
try {
|
||||
const priceInt = parseInt(editForm.price)
|
||||
if (Number.isNaN(priceInt) || priceInt < 0) {
|
||||
const priceValue = parseFloat(editForm.price)
|
||||
if (Number.isNaN(priceValue) || priceValue < 0) {
|
||||
ElMessage.error(t('systemSettings.enterValidNumber'))
|
||||
return
|
||||
}
|
||||
@@ -576,8 +582,8 @@ const saveEdit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 直接更新membership_levels表
|
||||
const updateData = { price: priceInt, pointsBonus: pointsInt }
|
||||
// 直接更新membership_levels表(价格支持两位小数)
|
||||
const updateData = { price: priceValue, pointsBonus: pointsInt }
|
||||
console.log('准备更新会员等级:', editForm.id, updateData)
|
||||
const response = await api.put(`/members/levels/${editForm.id}`, updateData)
|
||||
console.log('会员等级更新响应:', response.data)
|
||||
|
||||
@@ -118,9 +118,10 @@
|
||||
<div v-if="taskStatus === 'PENDING'" class="progress-bar-large indeterminate">
|
||||
<div class="progress-fill-indeterminate"></div>
|
||||
</div>
|
||||
<!-- 生成中:动态进度条 -->
|
||||
<div v-else class="progress-bar-large">
|
||||
<div class="progress-fill-large animated" :style="{ width: taskProgress + '%' }"></div>
|
||||
<!-- 生成中:动态进度条(进度为0时也显示动画) -->
|
||||
<div v-else class="progress-bar-large" :class="{ 'indeterminate': taskProgress === 0 }">
|
||||
<div v-if="taskProgress === 0" class="progress-fill-indeterminate"></div>
|
||||
<div v-else class="progress-fill-large animated" :style="{ width: taskProgress + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-percentage" v-if="taskStatus !== 'PENDING'">{{ taskProgress }}%</div>
|
||||
</div>
|
||||
@@ -232,11 +233,11 @@
|
||||
<div v-if="task.status === 'PENDING'" class="history-progress-bar indeterminate">
|
||||
<div class="progress-fill-indeterminate"></div>
|
||||
</div>
|
||||
<!-- 生成中:动态进度条 -->
|
||||
<div v-else class="history-progress-bar">
|
||||
<div class="progress-fill-large animated" :style="{ width: (task.progress || 0) + '%' }"></div>
|
||||
<!-- 生成中:动态进度条(进度为0时也显示动画) -->
|
||||
<div v-else class="history-progress-bar" :class="{ 'indeterminate': (task.progress || 0) === 0 }">
|
||||
<div v-if="(task.progress || 0) === 0" class="progress-fill-indeterminate"></div>
|
||||
<div v-else class="progress-fill-large animated" :style="{ width: (task.progress || 0) + '%' }"></div>
|
||||
</div>
|
||||
<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">
|
||||
@@ -333,7 +334,7 @@ import { useUserStore } from '@/stores/user'
|
||||
import { User, VideoCamera, Star, Setting, SwitchButton, Lock, Document, Warning } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
|
||||
import { optimizePrompt } from '@/api/promptOptimizer'
|
||||
import { getProcessingWorks } from '@/api/userWorks'
|
||||
import { getProcessingWorks, getMyWorksByType } from '@/api/userWorks'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -517,7 +518,7 @@ const startGenerate = async () => {
|
||||
currentTask.value = response.data.data
|
||||
inProgress.value = true
|
||||
taskProgress.value = 0
|
||||
taskStatus.value = 'PENDING'
|
||||
taskStatus.value = 'PROCESSING' // 直接显示生成中
|
||||
|
||||
ElMessage.success(t('video.textToVideo.taskCreated'))
|
||||
|
||||
@@ -837,7 +838,7 @@ const retryTask = async () => {
|
||||
currentTask.value = response.data.data
|
||||
inProgress.value = true
|
||||
taskProgress.value = 0
|
||||
taskStatus.value = 'PENDING'
|
||||
taskStatus.value = 'PROCESSING' // 直接显示生成中
|
||||
|
||||
ElMessage.success('重试任务已提交')
|
||||
|
||||
@@ -882,6 +883,10 @@ const deleteWork = () => {
|
||||
// 处理历史记录URL
|
||||
const processHistoryUrl = (url) => {
|
||||
if (!url) return ''
|
||||
// data: 协议(Base64 图片等)直接返回,避免被当成相对路径错误加前缀
|
||||
if (url.startsWith('data:')) {
|
||||
return url
|
||||
}
|
||||
// 如果是相对路径,确保格式正确
|
||||
if (url.startsWith('/') || !url.startsWith('http')) {
|
||||
if (!url.startsWith('/uploads/') && !url.startsWith('/api/')) {
|
||||
@@ -891,7 +896,7 @@ const processHistoryUrl = (url) => {
|
||||
return url
|
||||
}
|
||||
|
||||
// 加载历史记录
|
||||
// 加载历史记录(从user_works表获取TEXT_TO_VIDEO类型的作品)
|
||||
const loadHistory = async () => {
|
||||
// 只有登录用户才能查看历史记录
|
||||
if (!userStore.isAuthenticated) {
|
||||
@@ -900,18 +905,22 @@ const loadHistory = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// 请求全部已完成的任务,不限制数量
|
||||
const response = await textToVideoApi.getTasks(0, 1000)
|
||||
// 从user_works表获取TEXT_TO_VIDEO类型的作品
|
||||
const response = await getMyWorksByType('TEXT_TO_VIDEO', { page: 0, size: 1000 })
|
||||
if (response.data && response.data.success) {
|
||||
// 只显示已完成的任务,排队中和处理中的任务在主创作区显示
|
||||
const tasks = (response.data.data || []).filter(task =>
|
||||
task.status === 'COMPLETED'
|
||||
)
|
||||
// 转换数据格式,适配历史记录展示
|
||||
const works = response.data.data || []
|
||||
|
||||
// 处理URL,确保相对路径正确
|
||||
historyTasks.value = tasks.map(task => ({
|
||||
...task,
|
||||
resultUrl: task.resultUrl ? processHistoryUrl(task.resultUrl) : null
|
||||
historyTasks.value = works.map(work => ({
|
||||
taskId: work.taskId,
|
||||
prompt: work.prompt,
|
||||
aspectRatio: work.aspectRatio,
|
||||
duration: work.duration ? parseInt(work.duration) : 5,
|
||||
hdMode: work.quality === 'HD',
|
||||
status: work.status,
|
||||
resultUrl: work.resultUrl ? processHistoryUrl(work.resultUrl) : null,
|
||||
createdAt: work.createdAt,
|
||||
progress: work.status === 'PROCESSING' ? 50 : (work.status === 'COMPLETED' ? 100 : 0)
|
||||
}))
|
||||
|
||||
console.log('[History Load]', t('video.textToVideo.historyLoadSuccess', { count: historyTasks.value.length }))
|
||||
@@ -1060,30 +1069,30 @@ const restoreProcessingTask = async () => {
|
||||
// 取最新的一个任务
|
||||
const work = textToVideoWorks[0]
|
||||
|
||||
// 恢复任务状态
|
||||
currentTask.value = {
|
||||
taskId: work.taskId,
|
||||
prompt: work.prompt,
|
||||
aspectRatio: work.aspectRatio,
|
||||
duration: work.duration,
|
||||
resultUrl: work.resultUrl,
|
||||
createdAt: work.createdAt
|
||||
}
|
||||
|
||||
// 恢复输入参数
|
||||
if (work.prompt) {
|
||||
inputText.value = work.prompt
|
||||
}
|
||||
if (work.aspectRatio) {
|
||||
aspectRatio.value = work.aspectRatio
|
||||
}
|
||||
if (work.duration) {
|
||||
duration.value = parseInt(work.duration) || 10
|
||||
}
|
||||
|
||||
// 只有真正在进行中的任务才恢复和显示消息
|
||||
// 只有真正在进行中的任务才恢复
|
||||
const workStatus = work.status || 'PROCESSING'
|
||||
if (workStatus === 'PROCESSING' || workStatus === 'PENDING') {
|
||||
// 恢复任务状态
|
||||
currentTask.value = {
|
||||
taskId: work.taskId,
|
||||
prompt: work.prompt,
|
||||
aspectRatio: work.aspectRatio,
|
||||
duration: work.duration,
|
||||
resultUrl: work.resultUrl,
|
||||
createdAt: work.createdAt
|
||||
}
|
||||
|
||||
// 恢复输入参数
|
||||
if (work.prompt) {
|
||||
inputText.value = work.prompt
|
||||
}
|
||||
if (work.aspectRatio) {
|
||||
aspectRatio.value = work.aspectRatio
|
||||
}
|
||||
if (work.duration) {
|
||||
duration.value = parseInt(work.duration) || 10
|
||||
}
|
||||
|
||||
inProgress.value = true
|
||||
taskStatus.value = workStatus
|
||||
taskProgress.value = 50 // 初始进度设为50%
|
||||
@@ -1098,7 +1107,7 @@ const restoreProcessingTask = async () => {
|
||||
startPollingTask()
|
||||
return true
|
||||
} else {
|
||||
// 如果任务已失败或取消,不显示恢复消息,让 checkLastTaskStatus 处理
|
||||
// 如果任务已完成、失败或取消,不恢复任何参数
|
||||
console.log('[Task Skip]', work.taskId, 'Status:', workStatus, '- 不是进行中状态')
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -422,12 +422,12 @@ CREATE TABLE IF NOT EXISTS storyboard_video_tasks (
|
||||
-- - 排序规则:utf8mb4_unicode_ci
|
||||
|
||||
-- ============================================
|
||||
-- 管理员权限设置
|
||||
-- 超级管理员权限设置
|
||||
-- ============================================
|
||||
-- 将 984523799@qq.com 设置为管理员
|
||||
-- 如果该用户存在,则更新其角色为管理员
|
||||
-- 将 shanghairuiyi2026@163.com 设置为超级管理员
|
||||
-- 如果该用户存在,则更新其角色为超级管理员
|
||||
UPDATE users
|
||||
SET role = 'ROLE_ADMIN',
|
||||
SET role = 'ROLE_SUPER_ADMIN',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE email = 'shanghairuiyi2026@163.com';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* 异步执行器配置
|
||||
* 支持50人并发处理异步任务(如视频生成、图片处理等)
|
||||
* 支持100-200人并发处理异步任务(如视频生成、图片处理等)
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
@@ -25,11 +25,11 @@ public class AsyncConfig {
|
||||
public Executor taskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
// 核心线程数:保持活跃的最小线程数
|
||||
executor.setCorePoolSize(5);
|
||||
executor.setCorePoolSize(10);
|
||||
// 最大线程数:最大并发执行的任务数
|
||||
executor.setMaxPoolSize(20);
|
||||
executor.setMaxPoolSize(40);
|
||||
// 队列容量:等待执行的任务数
|
||||
executor.setQueueCapacity(50);
|
||||
executor.setQueueCapacity(100);
|
||||
// 线程名前缀
|
||||
executor.setThreadNamePrefix("async-task-");
|
||||
// 拒绝策略:当线程池和队列都满时,使用调用者线程执行(保证任务不丢失)
|
||||
|
||||
@@ -19,8 +19,8 @@ public class PollingConfig implements SchedulingConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureTasks(@NonNull ScheduledTaskRegistrar taskRegistrar) {
|
||||
// 使用自定义线程池执行定时任务(支持50人并发)
|
||||
ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
|
||||
// 使用自定义线程池执行定时任务(支持100-200人并发)
|
||||
ScheduledExecutorService executor = Executors.newScheduledThreadPool(10);
|
||||
taskRegistrar.setScheduler(executor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/orders/stats").permitAll()
|
||||
.requestMatchers("/api/orders/**").authenticated()
|
||||
.requestMatchers("/api/payments/alipay/notify", "/api/payments/alipay/return").permitAll()
|
||||
.requestMatchers("/api/payments/lulupay/notify", "/api/payments/lulupay/return").permitAll()
|
||||
.requestMatchers("/api/payment/paypal/success", "/api/payment/paypal/cancel").permitAll()
|
||||
.requestMatchers("/api/payments/**").authenticated()
|
||||
.requestMatchers("/api/image-to-video/**").authenticated()
|
||||
.requestMatchers("/api/text-to-video/**").authenticated()
|
||||
|
||||
@@ -17,16 +17,23 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.model.SystemSettings;
|
||||
import com.example.demo.model.UserMembership;
|
||||
import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.service.RedisTokenService;
|
||||
import com.example.demo.service.SystemSettingsService;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.service.VerificationCodeService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
import com.example.demo.util.UserIdGenerator;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthApiController {
|
||||
@@ -48,6 +55,12 @@ public class AuthApiController {
|
||||
@Autowired
|
||||
private SystemSettingsService systemSettingsService;
|
||||
|
||||
@Autowired
|
||||
private UserMembershipRepository userMembershipRepository;
|
||||
|
||||
@Autowired
|
||||
private MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
/**
|
||||
* 用户登录(已禁用,仅支持邮箱验证码登录)
|
||||
* 为了向后兼容,保留此接口但返回提示信息
|
||||
@@ -251,6 +264,9 @@ public class AuthApiController {
|
||||
|
||||
// 保存用户(@PrePersist 会自动设置 createdAt 等字段)
|
||||
user = userService.save(user);
|
||||
|
||||
// 为新用户创建默认会员记录(标准会员,到期时间为1年后)
|
||||
createDefaultMembershipForUser(user);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("自动注册用户失败: {}", email, e);
|
||||
return ResponseEntity.badRequest()
|
||||
@@ -594,6 +610,35 @@ public class AuthApiController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为新用户创建默认会员记录(免费会员,永久有效)
|
||||
*/
|
||||
private void createDefaultMembershipForUser(User user) {
|
||||
try {
|
||||
// 查找免费会员等级
|
||||
Optional<MembershipLevel> freeLevel = membershipLevelRepository.findByName("free");
|
||||
if (freeLevel.isEmpty()) {
|
||||
logger.warn("未找到免费会员等级(free),跳过创建会员记录");
|
||||
return;
|
||||
}
|
||||
|
||||
UserMembership membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setMembershipLevelId(freeLevel.get().getId());
|
||||
membership.setStartDate(LocalDateTime.now());
|
||||
membership.setEndDate(LocalDateTime.of(2099, 12, 31, 23, 59, 59)); // 免费会员永久有效
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setAutoRenew(false);
|
||||
membership.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
userMembershipRepository.save(membership);
|
||||
logger.info("✅ 为新用户创建默认会员记录: userId={}, level=免费会员(永久有效)", user.getId());
|
||||
} catch (Exception e) {
|
||||
logger.error("创建默认会员记录失败: userId={}", user.getId(), e);
|
||||
// 不抛出异常,允许用户注册成功
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> createErrorResponse(String message) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.example.demo.service.LuluPayService;
|
||||
import com.example.demo.service.PaymentService;
|
||||
|
||||
/**
|
||||
* 噜噜支付(彩虹易支付)回调控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/payments/lulupay")
|
||||
public class LuluPayCallbackController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LuluPayCallbackController.class);
|
||||
|
||||
@Autowired
|
||||
private LuluPayService luluPayService;
|
||||
|
||||
@Autowired
|
||||
private PaymentService paymentService;
|
||||
|
||||
@Value("${app.frontend-url:}")
|
||||
private String frontendUrl;
|
||||
|
||||
/**
|
||||
* 异步通知接口
|
||||
* 支付平台会通过GET方式调用此接口通知支付结果
|
||||
*/
|
||||
@GetMapping(value = "/notify", produces = MediaType.TEXT_PLAIN_VALUE)
|
||||
public String notifyGet(@RequestParam Map<String, String> params) {
|
||||
return handleNotify(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步通知接口(POST方式)
|
||||
*/
|
||||
@PostMapping(value = "/notify", produces = MediaType.TEXT_PLAIN_VALUE)
|
||||
public String notifyPost(@RequestParam Map<String, String> params) {
|
||||
return handleNotify(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理异步通知
|
||||
*/
|
||||
private String handleNotify(Map<String, String> params) {
|
||||
logger.info("========== 收到噜噜支付异步通知 ==========");
|
||||
logger.info("参数: {}", params);
|
||||
|
||||
try {
|
||||
boolean success = luluPayService.handleNotify(params);
|
||||
|
||||
if (success) {
|
||||
String tradeStatus = params.get("trade_status");
|
||||
String outTradeNo = params.get("out_trade_no");
|
||||
String tradeNo = params.get("trade_no");
|
||||
|
||||
logger.info("噜噜支付验签成功: tradeStatus={}, outTradeNo={}, tradeNo={}", tradeStatus, outTradeNo, tradeNo);
|
||||
|
||||
// 如果支付成功,调用统一的支付确认方法
|
||||
if ("TRADE_SUCCESS".equals(tradeStatus)) {
|
||||
try {
|
||||
// 查找支付记录并确认
|
||||
logger.info("开始查找支付记录: outTradeNo={}", outTradeNo);
|
||||
var paymentOpt = paymentService.findByOrderId(outTradeNo);
|
||||
if (paymentOpt.isPresent()) {
|
||||
var payment = paymentOpt.get();
|
||||
logger.info("找到支付记录: paymentId={}, status={}, orderId={}",
|
||||
payment.getId(), payment.getStatus(), payment.getOrderId());
|
||||
|
||||
// 调用确认方法(内部会检查是否已处理,避免重复增加积分)
|
||||
// 即使状态已经是SUCCESS,也要确保会员信息已更新
|
||||
logger.info("开始调用confirmPaymentSuccess: paymentId={}", payment.getId());
|
||||
paymentService.confirmPaymentSuccess(payment.getId(), tradeNo);
|
||||
logger.info("✅ 支付确认处理完成: 订单号={}", outTradeNo);
|
||||
} else {
|
||||
logger.error("❌ 未找到支付记录: outTradeNo={}", outTradeNo);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ 支付确认失败: outTradeNo={}, error={}", outTradeNo, e.getMessage(), e);
|
||||
}
|
||||
} else {
|
||||
logger.info("交易状态不是TRADE_SUCCESS,不处理积分: tradeStatus={}", tradeStatus);
|
||||
}
|
||||
|
||||
logger.info("========== 噜噜支付异步通知处理成功 ==========");
|
||||
return "success";
|
||||
} else {
|
||||
logger.warn("========== 噜噜支付异步通知处理失败(验签失败) ==========");
|
||||
return "fail";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("处理噜噜支付异步通知异常: ", e);
|
||||
return "fail";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步返回接口
|
||||
* 用户支付完成后跳转到会员订阅页面
|
||||
*/
|
||||
@GetMapping("/return")
|
||||
public ResponseEntity<Void> returnUrl(@RequestParam Map<String, String> params) {
|
||||
logger.info("========== 收到噜噜支付同步返回 ==========");
|
||||
logger.info("参数: {}", params);
|
||||
|
||||
try {
|
||||
boolean success = luluPayService.handleReturn(params);
|
||||
String tradeStatus = params.get("trade_status");
|
||||
String orderId = params.get("out_trade_no");
|
||||
|
||||
logger.info("支付同步返回处理结果: success={}, tradeStatus={}, orderId={}", success, tradeStatus, orderId);
|
||||
|
||||
// 构建重定向URL,跳转到会员订阅页面
|
||||
String redirectUrl = (frontendUrl != null && !frontendUrl.isEmpty() ? frontendUrl : "")
|
||||
+ "/subscription?paymentStatus=" + (success && "TRADE_SUCCESS".equals(tradeStatus) ? "success" : "pending")
|
||||
+ "&orderId=" + (orderId != null ? orderId : "");
|
||||
|
||||
logger.info("重定向到: {}", redirectUrl);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header("Location", redirectUrl)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理噜噜支付同步返回异常: ", e);
|
||||
|
||||
// 出错也跳转到订阅页面
|
||||
String redirectUrl = (frontendUrl != null && !frontendUrl.isEmpty() ? frontendUrl : "")
|
||||
+ "/subscription?paymentStatus=error";
|
||||
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header("Location", redirectUrl)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,61 +247,105 @@ public class MemberApiController {
|
||||
|
||||
logger.info("更新会员等级: userId={}, levelName={}, expiryDate={}", id, levelName, expiryDateStr);
|
||||
|
||||
if (levelName != null) {
|
||||
Optional<MembershipLevel> levelOpt = membershipLevelRepository
|
||||
.findByDisplayName(levelName);
|
||||
// 只要有会员等级或到期时间参数,就需要更新会员信息
|
||||
if (levelName != null || (expiryDateStr != null && !expiryDateStr.isEmpty())) {
|
||||
// 查找或创建会员信息(按到期时间降序,返回最新的)
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository
|
||||
.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
|
||||
logger.info("查找会员等级结果: levelName={}, found={}", levelName, levelOpt.isPresent());
|
||||
UserMembership membership;
|
||||
MembershipLevel level = null;
|
||||
|
||||
if (levelOpt.isPresent()) {
|
||||
MembershipLevel level = levelOpt.get();
|
||||
// 如果传入了会员等级,查找对应的等级
|
||||
if (levelName != null) {
|
||||
// 先尝试精确匹配 displayName
|
||||
Optional<MembershipLevel> levelOpt = membershipLevelRepository.findByDisplayName(levelName);
|
||||
|
||||
// 查找或创建会员信息(按到期时间降序,返回最新的)
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository
|
||||
.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
|
||||
UserMembership membership;
|
||||
if (membershipOpt.isPresent()) {
|
||||
membership = membershipOpt.get();
|
||||
} else {
|
||||
// 创建新的会员记录
|
||||
membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setStartDate(java.time.LocalDateTime.now());
|
||||
// 根据会员等级的 durationDays 计算到期时间
|
||||
int durationDays = level.getDurationDays() != null ? level.getDurationDays() : 365;
|
||||
membership.setEndDate(java.time.LocalDateTime.now().plusDays(durationDays));
|
||||
}
|
||||
|
||||
membership.setMembershipLevelId(level.getId());
|
||||
|
||||
// 更新到期时间
|
||||
if (expiryDateStr != null && !expiryDateStr.isEmpty()) {
|
||||
try {
|
||||
// 尝试解析带时间的格式 (如 2025-12-11T17:03:16)
|
||||
java.time.LocalDateTime expiryDateTime = java.time.LocalDateTime.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDateTime);
|
||||
} catch (Exception e1) {
|
||||
try {
|
||||
// 尝试解析仅日期格式 (如 2025-12-11)
|
||||
java.time.LocalDate expiryDate = java.time.LocalDate.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDate.atStartOfDay());
|
||||
} catch (Exception e2) {
|
||||
logger.warn("日期格式错误: {}", expiryDateStr);
|
||||
// 如果找不到,尝试模糊匹配
|
||||
if (!levelOpt.isPresent()) {
|
||||
List<MembershipLevel> allLevels = membershipLevelRepository.findAll();
|
||||
for (MembershipLevel lvl : allLevels) {
|
||||
String name = lvl.getName();
|
||||
String displayName = lvl.getDisplayName();
|
||||
|
||||
// 匹配 "专业会员" -> "professional" 或 "专业版"
|
||||
if (levelName.contains("专业") && "professional".equalsIgnoreCase(name)) {
|
||||
levelOpt = Optional.of(lvl);
|
||||
break;
|
||||
}
|
||||
// 匹配 "标准会员" -> "standard" 或 "标准会员"
|
||||
if (levelName.contains("标准") && "standard".equalsIgnoreCase(name)) {
|
||||
levelOpt = Optional.of(lvl);
|
||||
break;
|
||||
}
|
||||
// 匹配 "免费会员" -> "free" 或 "免费版"
|
||||
if (levelName.contains("免费") && "free".equalsIgnoreCase(name)) {
|
||||
levelOpt = Optional.of(lvl);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
membership.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
UserMembership saved = userMembershipRepository.save(membership);
|
||||
logger.info("✅ 会员等级已保存: userId={}, membershipId={}, levelId={}, levelName={}",
|
||||
user.getId(), saved.getId(), saved.getMembershipLevelId(), level.getDisplayName());
|
||||
} else {
|
||||
logger.warn("❌ 未找到会员等级: levelName={}", levelName);
|
||||
logger.info("查找会员等级结果: levelName={}, found={}", levelName, levelOpt.isPresent());
|
||||
if (levelOpt.isPresent()) {
|
||||
level = levelOpt.get();
|
||||
} else {
|
||||
logger.warn("❌ 未找到会员等级: levelName={}", levelName);
|
||||
}
|
||||
}
|
||||
|
||||
if (membershipOpt.isPresent()) {
|
||||
membership = membershipOpt.get();
|
||||
logger.info("找到现有会员记录: membershipId={}, currentEndDate={}", membership.getId(), membership.getEndDate());
|
||||
} else {
|
||||
// 创建新的会员记录
|
||||
membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setStartDate(java.time.LocalDateTime.now());
|
||||
// 默认到期时间为1年后
|
||||
membership.setEndDate(java.time.LocalDateTime.now().plusDays(365));
|
||||
logger.info("创建新会员记录");
|
||||
|
||||
// 如果没有指定等级,默认使用标准会员
|
||||
if (level == null) {
|
||||
Optional<MembershipLevel> defaultLevel = membershipLevelRepository.findByName("standard");
|
||||
if (defaultLevel.isPresent()) {
|
||||
level = defaultLevel.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新会员等级
|
||||
if (level != null) {
|
||||
membership.setMembershipLevelId(level.getId());
|
||||
}
|
||||
|
||||
// 更新到期时间
|
||||
if (expiryDateStr != null && !expiryDateStr.isEmpty()) {
|
||||
try {
|
||||
// 尝试解析带时间的格式 (如 2025-12-11T17:03:16)
|
||||
java.time.LocalDateTime expiryDateTime = java.time.LocalDateTime.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDateTime);
|
||||
logger.info("设置到期时间(带时间格式): {}", expiryDateTime);
|
||||
} catch (Exception e1) {
|
||||
try {
|
||||
// 尝试解析仅日期格式 (如 2025-12-11)
|
||||
java.time.LocalDate expiryDate = java.time.LocalDate.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDate.atTime(23, 59, 59));
|
||||
logger.info("设置到期时间(日期格式): {}", expiryDate.atTime(23, 59, 59));
|
||||
} catch (Exception e2) {
|
||||
logger.warn("日期格式错误: {}", expiryDateStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
membership.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
UserMembership saved = userMembershipRepository.save(membership);
|
||||
logger.info("✅ 会员信息已保存: userId={}, membershipId={}, levelId={}, endDate={}",
|
||||
user.getId(), saved.getId(), saved.getMembershipLevelId(), saved.getEndDate());
|
||||
} else {
|
||||
logger.info("未传入会员等级参数,跳过会员等级更新");
|
||||
logger.info("未传入会员等级和到期时间参数,跳过会员信息更新");
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
@@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
@@ -35,6 +36,9 @@ public class PayPalController {
|
||||
@Autowired
|
||||
private PaymentService paymentService;
|
||||
|
||||
@Value("${app.frontend-url:https://www.vionow.com}")
|
||||
private String frontendUrl;
|
||||
|
||||
/**
|
||||
* 创建PayPal支付
|
||||
* 支持两种模式:
|
||||
@@ -71,6 +75,14 @@ public class PayPalController {
|
||||
return ResponseEntity.badRequest().body(errorResponse);
|
||||
}
|
||||
payment = existingPayment.get();
|
||||
|
||||
// 检查支付状态,如果已成功则不允许重复创建
|
||||
if (payment.getStatus() == PaymentStatus.SUCCESS) {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", "该支付已完成,请勿重复支付");
|
||||
return ResponseEntity.badRequest().body(errorResponse);
|
||||
}
|
||||
} else {
|
||||
// 旧模式:创建新的支付记录
|
||||
String username = (String) request.get("username");
|
||||
@@ -152,11 +164,17 @@ public class PayPalController {
|
||||
logger.info("Payer ID: {}", payerId);
|
||||
logger.info("Token: {}", token);
|
||||
|
||||
// 检查PayPal服务是否可用
|
||||
if (payPalService == null) {
|
||||
logger.error("PayPal服务未配置");
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=error&message=PayPal service not configured");
|
||||
}
|
||||
|
||||
// 获取支付记录
|
||||
Optional<Payment> paymentOpt = paymentService.findById(paymentId);
|
||||
if (!paymentOpt.isPresent()) {
|
||||
logger.error("支付记录不存在: {}", paymentId);
|
||||
return new RedirectView("/payment/error?message=Payment not found");
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=error&message=Payment not found");
|
||||
}
|
||||
|
||||
Payment payment = paymentOpt.get();
|
||||
@@ -173,15 +191,15 @@ public class PayPalController {
|
||||
logger.info("✅ PayPal支付确认成功");
|
||||
|
||||
// 重定向到前端会员订阅页面(支付成功)
|
||||
return new RedirectView("https://vionow.com/subscription?paymentSuccess=true&paymentId=" + paymentId);
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=success&paymentId=" + paymentId);
|
||||
} else {
|
||||
logger.error("PayPal支付执行失败");
|
||||
return new RedirectView("https://vionow.com/?paymentError=true&message=Payment execution failed");
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=error&message=Payment execution failed");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ PayPal支付成功回调处理失败", e);
|
||||
return new RedirectView("https://vionow.com/?paymentError=true&message=" + e.getMessage());
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=error&message=" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,12 +218,12 @@ public class PayPalController {
|
||||
|
||||
logger.info("✅ PayPal支付已取消");
|
||||
|
||||
// 重定向到前端首页(支付已取消)
|
||||
return new RedirectView("https://vionow.com/?paymentCancelled=true&paymentId=" + paymentId);
|
||||
// 重定向到前端会员订阅页面(支付已取消)
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=cancelled&paymentId=" + paymentId);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ PayPal支付取消回调处理失败", e);
|
||||
return new RedirectView("https://vionow.com/?paymentError=true&message=" + e.getMessage());
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=error&message=" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.example.demo.repository.PaymentRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.service.AlipayService;
|
||||
import com.example.demo.service.LuluPayService;
|
||||
import com.example.demo.service.OrderService;
|
||||
import com.example.demo.service.PaymentService;
|
||||
import com.example.demo.service.UserService;
|
||||
@@ -51,6 +52,9 @@ public class PaymentApiController {
|
||||
@Autowired
|
||||
private AlipayService alipayService;
|
||||
|
||||
@Autowired
|
||||
private LuluPayService luluPayService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@@ -591,7 +595,8 @@ public class PaymentApiController {
|
||||
@PostMapping("/alipay/create")
|
||||
public ResponseEntity<Map<String, Object>> createAlipayPayment(
|
||||
@RequestBody Map<String, Object> paymentData,
|
||||
Authentication authentication) {
|
||||
Authentication authentication,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
String username;
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
@@ -614,8 +619,12 @@ public class PaymentApiController {
|
||||
.body(createErrorResponse("无权限操作此支付记录"));
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
String clientIp = getClientIp(request);
|
||||
logger.info("创建支付宝支付,客户端IP: {}", clientIp);
|
||||
|
||||
// 调用支付宝接口创建支付
|
||||
Map<String, Object> paymentResult = alipayService.createPayment(payment);
|
||||
Map<String, Object> paymentResult = alipayService.createPayment(payment, clientIp);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
@@ -635,6 +644,62 @@ public class PaymentApiController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建噜噜支付(彩虹易支付)
|
||||
* 支持多种支付方式:alipay/wxpay/qqpay/bank
|
||||
*/
|
||||
@PostMapping("/lulupay/create")
|
||||
public ResponseEntity<Map<String, Object>> createLuluPayment(
|
||||
@RequestBody Map<String, Object> paymentData,
|
||||
Authentication authentication,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
String username;
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
username = authentication.getName();
|
||||
} else {
|
||||
logger.warn("用户未认证,拒绝支付请求");
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("请先登录后再创建支付"));
|
||||
}
|
||||
|
||||
Long paymentId = Long.valueOf(paymentData.get("paymentId").toString());
|
||||
String payType = (String) paymentData.getOrDefault("payType", "alipay");
|
||||
|
||||
Payment payment = paymentService.findById(paymentId)
|
||||
.orElseThrow(() -> new RuntimeException("支付记录不存在"));
|
||||
|
||||
// 检查权限
|
||||
if (!payment.getUser().getUsername().equals(username)) {
|
||||
logger.warn("用户{}无权限操作支付记录{}", username, paymentId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(createErrorResponse("无权限操作此支付记录"));
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
String clientIp = getClientIp(request);
|
||||
logger.info("创建噜噜支付,客户端IP: {}", clientIp);
|
||||
|
||||
// 调用噜噜支付接口创建支付
|
||||
Map<String, Object> paymentResult = luluPayService.createPayment(payment, payType, clientIp);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "支付创建成功");
|
||||
response.put("data", paymentResult);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("创建噜噜支付失败", e);
|
||||
String errorMsg = e.getMessage();
|
||||
if (errorMsg != null && errorMsg.length() > 100) {
|
||||
errorMsg = errorMsg.substring(0, 100) + "...";
|
||||
}
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("创建噜噜支付失败: " + errorMsg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除支付记录(仅管理员)
|
||||
*/
|
||||
@@ -713,4 +778,28 @@ public class PaymentApiController {
|
||||
response.put("message", message);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实IP地址
|
||||
*/
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
// 如果是多个代理,取第一个IP
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public class PaymentController {
|
||||
public String createPayment(@Valid @ModelAttribute Payment payment,
|
||||
BindingResult result,
|
||||
Authentication authentication,
|
||||
HttpServletRequest request,
|
||||
Model model) {
|
||||
|
||||
if (result.hasErrors()) {
|
||||
@@ -77,9 +78,12 @@ public class PaymentController {
|
||||
payment.setCurrency("CNY");
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
String clientIp = getClientIp(request);
|
||||
|
||||
// 根据支付方式创建支付
|
||||
if (payment.getPaymentMethod() == PaymentMethod.ALIPAY) {
|
||||
Map<String, Object> paymentResult = alipayService.createPayment(payment);
|
||||
Map<String, Object> paymentResult = alipayService.createPayment(payment, clientIp);
|
||||
if (paymentResult.containsKey("qrCode")) {
|
||||
// 对于二维码支付,重定向到支付页面显示二维码
|
||||
return "redirect:/payment/qr?qrCode=" + paymentResult.get("qrCode");
|
||||
@@ -253,4 +257,28 @@ public class PaymentController {
|
||||
return "payment/detail";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实IP地址
|
||||
*/
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
// 如果是多个代理,取第一个IP
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,12 @@ public class PublicApiController {
|
||||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少professional会员等级配置"));
|
||||
|
||||
int standardPrice = standardLevel.getPrice().intValue();
|
||||
Double standardPrice = standardLevel.getPrice();
|
||||
int standardPoints = standardLevel.getPointsBonus();
|
||||
int proPrice = proLevel.getPrice().intValue();
|
||||
Double proPrice = proLevel.getPrice();
|
||||
int proPoints = proLevel.getPointsBonus();
|
||||
|
||||
// 套餐价格配置(从membership_levels表读取)
|
||||
// 套餐价格配置(从membership_levels表读取,保留两位小数)
|
||||
config.put("standardPriceCny", standardPrice);
|
||||
config.put("proPriceCny", proPrice);
|
||||
config.put("standardPoints", standardPoints);
|
||||
|
||||
@@ -104,6 +104,82 @@ public class StoryboardVideoApiController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接使用上传的分镜图创建视频任务(跳过分镜图生成)
|
||||
* 用户在STEP2上传分镜图后直接生成视频时调用
|
||||
*/
|
||||
@PostMapping("/create-video-direct")
|
||||
public ResponseEntity<?> createVideoDirectly(
|
||||
@RequestBody Map<String, Object> request,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
// 检查用户是否已认证
|
||||
if (authentication == null) {
|
||||
logger.warn("直接创建视频任务失败: 用户未登录");
|
||||
return ResponseEntity.status(401)
|
||||
.body(Map.of("success", false, "message", "用户未登录,请先登录"));
|
||||
}
|
||||
String username = authentication.getName();
|
||||
logger.info("收到直接创建视频任务请求,用户: {}", username);
|
||||
|
||||
// 从请求中提取参数
|
||||
String storyboardImage = (String) request.get("storyboardImage"); // 用户上传的分镜图
|
||||
String prompt = (String) request.getOrDefault("prompt", "根据分镜图生成视频");
|
||||
String aspectRatio = (String) request.getOrDefault("aspectRatio", "16:9");
|
||||
Boolean hdMode = (Boolean) request.getOrDefault("hdMode", false);
|
||||
|
||||
// 提取视频参考图
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> referenceImages = (List<String>) request.get("referenceImages");
|
||||
|
||||
// 提取duration参数
|
||||
Integer duration = 10;
|
||||
Object durationObj = request.get("duration");
|
||||
if (durationObj instanceof Number) {
|
||||
duration = ((Number) durationObj).intValue();
|
||||
} else if (durationObj instanceof String) {
|
||||
try {
|
||||
duration = Integer.parseInt((String) durationObj);
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warn("无效的duration参数: {}, 使用默认值10", durationObj);
|
||||
}
|
||||
}
|
||||
|
||||
if (storyboardImage == null || storyboardImage.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "分镜图不能为空"));
|
||||
}
|
||||
|
||||
logger.info("直接创建视频任务参数 - duration: {}, aspectRatio: {}, hdMode: {}, referenceImages: {}",
|
||||
duration, aspectRatio, hdMode, referenceImages != null ? referenceImages.size() : 0);
|
||||
|
||||
// 调用服务层方法直接创建视频任务
|
||||
StoryboardVideoTask task = storyboardVideoService.createVideoDirectTask(
|
||||
username, prompt, storyboardImage, aspectRatio, hdMode != null && hdMode, duration, referenceImages
|
||||
);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "视频任务创建成功");
|
||||
response.put("data", Map.of(
|
||||
"taskId", task.getTaskId(),
|
||||
"status", task.getStatus(),
|
||||
"progress", task.getProgress()
|
||||
));
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("参数错误: {}", e.getMessage(), e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("直接创建视频任务失败", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "创建任务失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
*/
|
||||
|
||||
@@ -47,6 +47,9 @@ public class TaskStatusApiController {
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private com.example.demo.repository.StoryboardVideoTaskRepository storyboardVideoTaskRepository;
|
||||
|
||||
/**
|
||||
* 获取任务状态
|
||||
*/
|
||||
@@ -83,6 +86,19 @@ public class TaskStatusApiController {
|
||||
response.put("pollCount", taskStatus.getPollCount());
|
||||
response.put("maxPolls", taskStatus.getMaxPolls());
|
||||
|
||||
// 如果是分镜视频任务,额外返回 videoPrompt、imagePrompt、shotList
|
||||
if (taskId.startsWith("sb_")) {
|
||||
try {
|
||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(storyboardTask -> {
|
||||
response.put("videoPrompt", storyboardTask.getVideoPrompt());
|
||||
response.put("imagePrompt", storyboardTask.getImagePrompt());
|
||||
response.put("shotList", storyboardTask.getShotList());
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("获取分镜视频任务额外信息失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -89,13 +89,15 @@ public class UserWorkApiController {
|
||||
/**
|
||||
* 获取我的作品列表
|
||||
* @param includeProcessing 是否包含正在排队和生成中的作品,默认为true
|
||||
* @param workType 作品类型筛选(可选):TEXT_TO_VIDEO, IMAGE_TO_VIDEO, STORYBOARD_VIDEO, STORYBOARD_IMAGE
|
||||
*/
|
||||
@GetMapping("/my-works")
|
||||
public ResponseEntity<Map<String, Object>> getMyWorks(
|
||||
@RequestHeader(value = "Authorization", required = false) String token,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "1000") int size,
|
||||
@RequestParam(defaultValue = "true") boolean includeProcessing) {
|
||||
@RequestParam(defaultValue = "true") boolean includeProcessing,
|
||||
@RequestParam(required = false) String workType) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
@@ -111,9 +113,24 @@ public class UserWorkApiController {
|
||||
if (page < 0) page = 0;
|
||||
if (size <= 0) size = 1000; // 不设上限,默认1000条
|
||||
|
||||
// 解析作品类型
|
||||
UserWork.WorkType filterType = null;
|
||||
if (workType != null && !workType.isEmpty()) {
|
||||
try {
|
||||
filterType = UserWork.WorkType.valueOf(workType.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn("无效的作品类型: {}", workType);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据参数决定是否包含正在进行中的作品
|
||||
Page<UserWork> works;
|
||||
if (includeProcessing) {
|
||||
if (filterType != null) {
|
||||
// 按类型筛选
|
||||
works = userWorkService.getUserWorksByType(username, filterType, includeProcessing, page, size);
|
||||
logger.info("获取作品列表(按类型): username={}, type={}, total={}",
|
||||
username, filterType, works.getTotalElements());
|
||||
} else if (includeProcessing) {
|
||||
works = userWorkService.getAllUserWorks(username, page, size);
|
||||
// 调试日志:检查是否有 PROCESSING 状态的作品
|
||||
long processingCount = works.getContent().stream()
|
||||
|
||||
@@ -48,7 +48,7 @@ public class ImageToVideoTask {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false)
|
||||
private TaskStatus status = TaskStatus.PENDING;
|
||||
private TaskStatus status = TaskStatus.PROCESSING; // 默认生成中
|
||||
|
||||
@Column(name = "progress")
|
||||
private Integer progress = 0;
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.example.demo.model;
|
||||
|
||||
public enum PaymentMethod {
|
||||
ALIPAY("支付宝"),
|
||||
WECHAT("微信支付"),
|
||||
QQPAY("QQ钱包"),
|
||||
BANK("云闪付"),
|
||||
PAYPAL("PayPal");
|
||||
|
||||
private final String displayName;
|
||||
|
||||
@@ -27,9 +27,10 @@ public interface PointsFreezeRecordRepository extends JpaRepository<PointsFreeze
|
||||
/**
|
||||
* 根据任务ID查找冻结记录(带悲观写锁,用于防止并发重复扣除)
|
||||
* 使用悲观锁确保在高并发场景下不会重复扣除积分
|
||||
* 只返回状态为 FROZEN 的第一条记录,避免重复记录导致的问题
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT pfr FROM PointsFreezeRecord pfr WHERE pfr.taskId = :taskId")
|
||||
@Query("SELECT pfr FROM PointsFreezeRecord pfr WHERE pfr.taskId = :taskId AND pfr.status = 'FROZEN' ORDER BY pfr.createdAt ASC LIMIT 1")
|
||||
Optional<PointsFreezeRecord> findByTaskIdWithLock(@Param("taskId") String taskId);
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,19 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status = :status ORDER BY uw.createdAt DESC")
|
||||
List<UserWork> findByUsernameAndStatusOrderByCreatedAtDesc(@Param("username") String username, @Param("status") UserWork.WorkStatus status);
|
||||
|
||||
/**
|
||||
* 根据用户名和作品类型查找作品(包括所有状态,用于历史记录)
|
||||
* 排除 DELETED 和 FAILED 状态
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.workType = :workType AND uw.status NOT IN ('DELETED', 'FAILED') ORDER BY uw.createdAt DESC")
|
||||
Page<UserWork> findByUsernameAndWorkTypeOrderByCreatedAtDesc(@Param("username") String username, @Param("workType") UserWork.WorkType workType, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 根据用户名、作品类型和状态查找作品
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.workType = :workType AND uw.status = :status ORDER BY uw.createdAt DESC")
|
||||
Page<UserWork> findByUsernameAndWorkTypeAndStatusOrderByCreatedAtDesc(@Param("username") String username, @Param("workType") UserWork.WorkType workType, @Param("status") UserWork.WorkStatus status, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 根据用户名查找正在进行中和排队中的作品
|
||||
*/
|
||||
|
||||
@@ -58,6 +58,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
"/api/email/",
|
||||
"/api/payments/alipay/notify",
|
||||
"/api/payments/alipay/return",
|
||||
"/api/payments/lulupay/notify",
|
||||
"/api/payments/lulupay/return",
|
||||
"/api/payment/paypal/success",
|
||||
"/api/payment/paypal/cancel",
|
||||
"/swagger-ui",
|
||||
"/v3/api-docs",
|
||||
"/api-docs"
|
||||
|
||||
@@ -7,22 +7,15 @@ import java.util.UUID;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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;
|
||||
import com.example.demo.model.PaymentMethod;
|
||||
import com.example.demo.model.PaymentStatus;
|
||||
import com.example.demo.repository.PaymentRepository;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ijpay.alipay.AliPayApi;
|
||||
import com.ijpay.alipay.AliPayApiConfig;
|
||||
import com.ijpay.alipay.AliPayApiConfigKit;
|
||||
import com.example.demo.config.PaymentConfig.AliPayApiConfigHolder;
|
||||
|
||||
@Service
|
||||
public class AlipayService {
|
||||
@@ -31,21 +24,15 @@ public class AlipayService {
|
||||
|
||||
private final PaymentRepository paymentRepository;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
@Autowired
|
||||
private LuluPayService luluPayService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private UserErrorLogService userErrorLogService;
|
||||
|
||||
@Value("${alipay.app-id}")
|
||||
private String appId;
|
||||
|
||||
@Value("${alipay.private-key}")
|
||||
private String privateKey;
|
||||
|
||||
@Value("${alipay.public-key}")
|
||||
private String publicKey;
|
||||
|
||||
@Value("${alipay.gateway-url}")
|
||||
private String gatewayUrl;
|
||||
|
||||
@Value("${alipay.charset}")
|
||||
private String charset;
|
||||
|
||||
@@ -58,50 +45,38 @@ public class AlipayService {
|
||||
@Value("${alipay.return-url}")
|
||||
private String returnUrl;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AlipayService(PaymentRepository paymentRepository) {
|
||||
this.paymentRepository = paymentRepository;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付宝支付订单(电脑网站支付)
|
||||
* 创建支付宝支付订单
|
||||
* 内部调用噜噜支付(彩虹易支付)
|
||||
*/
|
||||
public Map<String, Object> createPayment(Payment payment) {
|
||||
public Map<String, Object> createPayment(Payment payment, String clientIp) {
|
||||
try {
|
||||
logger.info("开始创建支付宝支付订单,订单号:{},金额:{}", payment.getOrderId(), payment.getAmount());
|
||||
logger.info("开始创建支付订单(使用噜噜支付),订单号:{},金额:{},客户端IP:{}", payment.getOrderId(), payment.getAmount(), clientIp);
|
||||
|
||||
// 设置支付状态
|
||||
payment.setStatus(PaymentStatus.PENDING);
|
||||
payment.setPaymentMethod(PaymentMethod.ALIPAY);
|
||||
if (payment.getOrderId() == null || payment.getOrderId().isEmpty()) {
|
||||
payment.setOrderId(generateOrderId());
|
||||
}
|
||||
payment.setCallbackUrl(notifyUrl);
|
||||
payment.setReturnUrl(returnUrl);
|
||||
|
||||
// 保存支付记录
|
||||
paymentRepository.save(payment);
|
||||
logger.info("支付记录已保存,ID:{}", payment.getId());
|
||||
|
||||
// 调用电脑网站支付API
|
||||
return callPagePayAPI(payment);
|
||||
// 调用噜噜支付创建支付宝订单
|
||||
return luluPayService.createPayment(payment, "alipay", clientIp);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("创建支付订单时发生异常,订单号:{},错误:{}", payment.getOrderId(), e.getMessage(), e);
|
||||
payment.setStatus(PaymentStatus.FAILED);
|
||||
paymentRepository.save(payment);
|
||||
|
||||
// 记录支付错误
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
null,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.PAYMENT_ERROR,
|
||||
"支付宝支付创建失败: " + e.getMessage(),
|
||||
"AlipayService",
|
||||
payment.getOrderId(),
|
||||
"ALIPAY"
|
||||
);
|
||||
if (userErrorLogService != null) {
|
||||
userErrorLogService.logErrorAsync(
|
||||
null,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.PAYMENT_ERROR,
|
||||
"支付创建失败: " + e.getMessage(),
|
||||
"AlipayService",
|
||||
payment.getOrderId(),
|
||||
"ALIPAY"
|
||||
);
|
||||
}
|
||||
} catch (Exception logEx) {
|
||||
logger.warn("记录支付错误日志失败: {}", logEx.getMessage());
|
||||
}
|
||||
@@ -109,299 +84,26 @@ 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获取并设置
|
||||
* 根据IJPay源码,正确的方法是 putApiConfig
|
||||
*/
|
||||
private void ensureAliPayConfigSet() {
|
||||
try {
|
||||
// 从AliPayApiConfigHolder获取配置
|
||||
AliPayApiConfig config = AliPayApiConfigHolder.getConfig();
|
||||
if (config != null) {
|
||||
// 根据IJPay源码,使用 putApiConfig 方法设置配置
|
||||
try {
|
||||
AliPayApiConfigKit.putApiConfig(config);
|
||||
logger.debug("IJPay配置已动态设置到AliPayApiConfigKit");
|
||||
} catch (Exception e) {
|
||||
logger.warn("动态设置IJPay配置到AliPayApiConfigKit时发生异常: {}", e.getMessage());
|
||||
}
|
||||
} else {
|
||||
logger.warn("AliPayApiConfigHolder中没有配置,IJPay配置可能未初始化");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("动态设置IJPay配置时发生异常: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用真实的支付宝API(使用IJPay)
|
||||
*/
|
||||
private Map<String, Object> callRealAlipayAPI(Payment payment) throws Exception {
|
||||
// 记录配置信息
|
||||
logger.info("=== 使用IJPay调用支付宝API ===");
|
||||
logger.info("网关地址: {}", gatewayUrl);
|
||||
logger.info("应用ID: {}", appId);
|
||||
logger.info("字符集: {}", charset);
|
||||
logger.info("签名类型: {}", signType);
|
||||
logger.info("通知URL: {}", notifyUrl);
|
||||
logger.info("返回URL: {}", returnUrl);
|
||||
|
||||
// 设置业务参数
|
||||
AlipayTradePrecreateModel model = new AlipayTradePrecreateModel();
|
||||
model.setOutTradeNo(payment.getOrderId());
|
||||
|
||||
// 如果是美元,按汇率转换为人民币(支付宝只支持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"); // USD -> CNY 汇率
|
||||
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("5m");
|
||||
|
||||
logger.info("调用支付宝预创建API(IJPay),订单号:{},金额:{},商品名称:{}",
|
||||
model.getOutTradeNo(), model.getTotalAmount(), model.getSubject());
|
||||
|
||||
// 使用IJPay调用API,增加重试机制
|
||||
String qrCode = null;
|
||||
int maxRetries = 5; // 最大重试次数
|
||||
int retryCount = 0;
|
||||
|
||||
// 在调用前确保配置已设置
|
||||
ensureAliPayConfigSet();
|
||||
|
||||
// 验证配置中的网关地址
|
||||
try {
|
||||
AliPayApiConfig config = AliPayApiConfigKit.getAliPayApiConfig();
|
||||
logger.info("=== API调用前配置验证 ===");
|
||||
logger.info("配置中的serviceUrl: {}", config.getServiceUrl());
|
||||
logger.info("配置中的appId: {}", config.getAppId());
|
||||
logger.info("配置中的charset: {}", config.getCharset());
|
||||
logger.info("配置中的signType: {}", config.getSignType());
|
||||
} catch (Exception e) {
|
||||
logger.warn("获取IJPay配置时发生异常: {}", e.getMessage());
|
||||
}
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
logger.info("正在调用支付宝API(IJPay)... (第{}次尝试,共{}次)", retryCount + 1, maxRetries);
|
||||
logger.info("API方法: alipay.trade.precreate (由AlipayTradePrecreateRequest自动设置)");
|
||||
logger.info("通知URL: {}", notifyUrl);
|
||||
|
||||
// 使用IJPay的AliPayApi调用预创建接口
|
||||
// AlipayTradePrecreateRequest会自动设置method参数为"alipay.trade.precreate"
|
||||
String responseBody = AliPayApi.tradePrecreatePayToResponse(model, notifyUrl).getBody();
|
||||
|
||||
if (responseBody == null || responseBody.isEmpty()) {
|
||||
throw new RuntimeException("IJPay API响应为空");
|
||||
}
|
||||
|
||||
logger.info("IJPay API响应: {}", responseBody);
|
||||
|
||||
// 解析JSON响应
|
||||
// IJPay返回的响应体是JSON字符串,格式为: {"alipay_trade_precreate_response":{"code":"10000","msg":"Success","qr_code":"..."},"sign":"..."}
|
||||
try {
|
||||
Map<String, Object> responseMap = objectMapper.readValue(
|
||||
responseBody,
|
||||
new TypeReference<Map<String, Object>>() {}
|
||||
);
|
||||
Map<String, Object> precreateResponse = (Map<String, Object>) responseMap.get("alipay_trade_precreate_response");
|
||||
|
||||
if (precreateResponse != null) {
|
||||
String code = (String) precreateResponse.get("code");
|
||||
String msg = (String) precreateResponse.get("msg");
|
||||
|
||||
if ("10000".equals(code)) {
|
||||
qrCode = (String) precreateResponse.get("qr_code");
|
||||
if (qrCode != null && !qrCode.isEmpty()) {
|
||||
logger.info("支付宝二维码生成成功(IJPay),订单号:{},二维码:{}", payment.getOrderId(), qrCode);
|
||||
break; // 成功则跳出循环
|
||||
} else {
|
||||
throw new RuntimeException("二维码为空,响应消息:" + msg);
|
||||
}
|
||||
} else {
|
||||
String subCode = (String) precreateResponse.get("sub_code");
|
||||
String subMsg = (String) precreateResponse.get("sub_msg");
|
||||
|
||||
// 如果交易已经成功,需要检查是否是当前支付记录
|
||||
// 避免新创建的支付被误判为成功
|
||||
if ("ACQ.TRADE_HAS_SUCCESS".equals(subCode)) {
|
||||
logger.warn("支付宝返回交易已成功,订单号:{},支付ID:{},当前状态:{}",
|
||||
payment.getOrderId(), payment.getId(), payment.getStatus());
|
||||
|
||||
// 检查当前支付记录的状态
|
||||
// 如果当前支付记录已经是成功状态,说明是重复通知,可以返回成功
|
||||
// 如果当前支付记录是PENDING状态,说明可能是订单号冲突,需要生成新的订单号
|
||||
if (payment.getStatus() == PaymentStatus.SUCCESS) {
|
||||
logger.info("支付记录已经是成功状态,返回已支付信息");
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("alreadyPaid", true);
|
||||
result.put("message", "该订单已支付成功");
|
||||
return result;
|
||||
} else {
|
||||
// 当前支付是PENDING状态,但支付宝说已成功
|
||||
// 这可能是订单号冲突(之前的支付使用了相同的订单号)
|
||||
// 不应该直接将新支付标记为成功,而是抛出错误让前端重新创建
|
||||
logger.error("⚠️ 订单号冲突:支付宝返回交易已成功,但当前支付记录状态为PENDING。订单号:{},支付ID:{}",
|
||||
payment.getOrderId(), payment.getId());
|
||||
logger.error("这可能是订单号冲突导致的,建议生成新的订单号重新创建支付");
|
||||
throw new RuntimeException("订单号冲突:该订单号已被使用,请重新创建支付订单");
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException("二维码生成失败:" + msg + (subMsg != null ? " - " + subMsg : "") + " (code: " + code + (subCode != null ? ", sub_code: " + subCode : "") + ")");
|
||||
}
|
||||
} else {
|
||||
logger.error("无法解析支付宝响应,响应体:{}", responseBody);
|
||||
throw new RuntimeException("支付宝响应格式异常,请稍后重试");
|
||||
}
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
||||
logger.error("JSON解析失败,响应体:{}", responseBody, e);
|
||||
throw new RuntimeException("支付宝响应解析失败,请稍后重试");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
retryCount++;
|
||||
String errorType = e.getClass().getSimpleName();
|
||||
String errorMessage = e.getMessage();
|
||||
|
||||
logger.warn("支付宝API调用失败(IJPay),第{}次尝试失败", retryCount);
|
||||
logger.warn("错误类型: {}", errorType);
|
||||
logger.warn("错误信息: {}", errorMessage);
|
||||
|
||||
// 根据错误类型提供诊断建议和决定重试策略
|
||||
boolean isTimeoutError = false;
|
||||
if (errorMessage != null) {
|
||||
if (errorMessage.contains("Read timed out") || errorMessage.contains("timeout")
|
||||
|| errorMessage.contains("SocketTimeoutException")) {
|
||||
isTimeoutError = true;
|
||||
logger.error("=== 网络超时错误诊断 ===");
|
||||
logger.error("可能的原因:");
|
||||
logger.error("1. 网络连接不稳定或延迟过高");
|
||||
logger.error("2. 支付宝沙箱环境响应慢(openapi-sandbox.dl.alipaydev.com)");
|
||||
logger.error("3. 防火墙或代理服务器阻止连接");
|
||||
logger.error("4. ngrok隧道可能已过期或不可用");
|
||||
logger.error("解决方案:");
|
||||
logger.error("1. 检查网络连接,尝试ping openapi-sandbox.dl.alipaydev.com");
|
||||
logger.error("2. 检查ngrok是否正常运行: {}", notifyUrl);
|
||||
logger.error("3. 考虑使用代理服务器或VPN");
|
||||
} else if (errorMessage.contains("Connection refused") || errorMessage.contains("ConnectException")) {
|
||||
logger.error("=== 连接拒绝错误诊断 ===");
|
||||
logger.error("无法连接到支付宝服务器: {}", gatewayUrl);
|
||||
logger.error("请检查网络连接和防火墙设置");
|
||||
} else if (errorMessage.contains("UnknownHostException")) {
|
||||
logger.error("=== DNS解析错误诊断 ===");
|
||||
logger.error("无法解析域名: {}", gatewayUrl);
|
||||
logger.error("请检查DNS设置和网络连接");
|
||||
}
|
||||
}
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
logger.error("支付宝API调用失败(IJPay),已达到最大重试次数({}次)", maxRetries);
|
||||
logger.error("最终失败原因: {}", errorMessage);
|
||||
throw new RuntimeException("支付宝API调用失败(IJPay),已重试" + maxRetries + "次:" + errorMessage);
|
||||
}
|
||||
|
||||
// 根据错误类型决定等待时间:超时错误等待更长时间
|
||||
int waitTime = isTimeoutError ? 5000 : 3000; // 超时错误等待5秒,其他错误等待3秒
|
||||
logger.info("等待{}秒后重试...", waitTime / 1000);
|
||||
try {
|
||||
Thread.sleep(waitTime);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("重试被中断", ie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查二维码是否为空
|
||||
if (qrCode == null || qrCode.isEmpty()) {
|
||||
logger.error("支付宝API调用失败(IJPay),二维码为空");
|
||||
payment.setStatus(PaymentStatus.FAILED);
|
||||
paymentRepository.save(payment);
|
||||
throw new RuntimeException("支付宝API调用失败(IJPay),二维码为空");
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("qrCode", qrCode);
|
||||
result.put("outTradeNo", payment.getOrderId());
|
||||
result.put("success", true);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 处理支付宝异步通知
|
||||
* 处理支付宝异步通知(保留兼容旧的支付宝直连回调)
|
||||
* 新的支付会走噜噜支付回调 /api/payments/lulupay/notify
|
||||
*/
|
||||
public boolean handleNotify(Map<String, String> params) {
|
||||
try {
|
||||
// 验证签名
|
||||
boolean signVerified = AlipaySignature.rsaCheckV1(
|
||||
params, publicKey, charset, signType);
|
||||
// 尝试验证支付宝签名(兼容旧订单)
|
||||
boolean signVerified = false;
|
||||
try {
|
||||
signVerified = AlipaySignature.rsaCheckV1(
|
||||
params, publicKey, charset, signType);
|
||||
} catch (Exception e) {
|
||||
logger.warn("支付宝签名验证异常,可能是噜噜支付回调: {}", e.getMessage());
|
||||
}
|
||||
|
||||
if (!signVerified) {
|
||||
logger.warn("支付宝异步通知签名验证失败");
|
||||
logger.warn("支付宝异步通知签名验证失败,尝试作为噜噜支付处理");
|
||||
// 可能是噜噜支付的回调,交给LuluPayService处理
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -447,19 +149,10 @@ public class AlipayService {
|
||||
*/
|
||||
public boolean handleReturn(Map<String, String> params) {
|
||||
try {
|
||||
// 验证签名
|
||||
boolean signVerified = AlipaySignature.rsaCheckV1(
|
||||
params, publicKey, charset, signType);
|
||||
|
||||
if (!signVerified) {
|
||||
logger.warn("支付宝同步返回签名验证失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
String outTradeNo = params.get("out_trade_no");
|
||||
String tradeNo = params.get("trade_no");
|
||||
|
||||
logger.info("收到支付宝同步返回,订单号:{},交易号:{}", outTradeNo, tradeNo);
|
||||
logger.info("收到支付同步返回,订单号:{},交易号:{}", outTradeNo, tradeNo);
|
||||
|
||||
// 查找支付记录
|
||||
Payment payment = paymentRepository.findByOrderId(outTradeNo)
|
||||
@@ -475,7 +168,7 @@ public class AlipayService {
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理支付宝同步返回异常:", e);
|
||||
logger.error("处理支付同步返回异常:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -486,5 +179,4 @@ public class AlipayService {
|
||||
private String generateOrderId() {
|
||||
return "ALI" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -787,7 +787,7 @@ public class ImageToVideoService {
|
||||
*/
|
||||
@Transactional
|
||||
public ImageToVideoTask retryTask(String taskId, String username) {
|
||||
logger.info("重试失败任务: taskId={}, username={}", taskId, username);
|
||||
logger.info("重试任务: taskId={}, username={}", taskId, username);
|
||||
|
||||
// 获取任务
|
||||
ImageToVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
@@ -798,9 +798,13 @@ public class ImageToVideoService {
|
||||
throw new RuntimeException("无权操作此任务");
|
||||
}
|
||||
|
||||
// 验证任务状态必须是 FAILED
|
||||
if (task.getStatus() != ImageToVideoTask.TaskStatus.FAILED) {
|
||||
throw new RuntimeException("只能重试失败的任务,当前状态: " + task.getStatus());
|
||||
// 允许重试:失败的任务、完成但没有结果的任务、或者正在处理中的任务(可能卡住了)
|
||||
boolean canRetry = task.getStatus() == ImageToVideoTask.TaskStatus.FAILED ||
|
||||
task.getStatus() == ImageToVideoTask.TaskStatus.COMPLETED ||
|
||||
task.getStatus() == ImageToVideoTask.TaskStatus.PROCESSING;
|
||||
|
||||
if (!canRetry) {
|
||||
throw new RuntimeException("无法重试此任务,当前状态: " + task.getStatus());
|
||||
}
|
||||
|
||||
// 验证图片URL存在
|
||||
@@ -808,12 +812,17 @@ public class ImageToVideoService {
|
||||
throw new RuntimeException("任务缺少首帧图片,无法重试");
|
||||
}
|
||||
|
||||
// 重置任务状态为 PENDING
|
||||
task.setStatus(ImageToVideoTask.TaskStatus.PENDING);
|
||||
logger.info("允许重试任务: taskId={}, 原状态={}, 原结果URL={}",
|
||||
taskId, task.getStatus(), task.getResultUrl());
|
||||
|
||||
// 重置任务状态为 PROCESSING(直接生成中)
|
||||
task.setStatus(ImageToVideoTask.TaskStatus.PROCESSING);
|
||||
task.setErrorMessage(null);
|
||||
task.setRealTaskId(null); // 清除旧的外部任务ID
|
||||
task.setResultUrl(null); // 清除旧的结果URL
|
||||
task.setProgress(0);
|
||||
task.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
task.setCompletedAt(null);
|
||||
taskRepository.save(task);
|
||||
|
||||
// 重新添加到任务队列
|
||||
|
||||
345
demo/src/main/java/com/example/demo/service/LuluPayService.java
Normal file
345
demo/src/main/java/com/example/demo/service/LuluPayService.java
Normal file
@@ -0,0 +1,345 @@
|
||||
package com.example.demo.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import com.example.demo.model.Payment;
|
||||
import com.example.demo.model.PaymentMethod;
|
||||
import com.example.demo.model.PaymentStatus;
|
||||
import com.example.demo.repository.PaymentRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* 噜噜支付(彩虹易支付)服务
|
||||
*
|
||||
* 签名方式:RSA(商户私钥,SHA256WithRSA)
|
||||
* 验签方式:RSA(平台公钥,SHA256WithRSA)
|
||||
*/
|
||||
@Service
|
||||
public class LuluPayService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LuluPayService.class);
|
||||
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${lulupay.api-url:}")
|
||||
private String apiUrl;
|
||||
|
||||
@Value("${lulupay.pid:}")
|
||||
private String pid;
|
||||
|
||||
@Value("${lulupay.merchant-key:}")
|
||||
private String merchantKey; // MD5签名密钥
|
||||
|
||||
@Value("${lulupay.platform-public-key:}")
|
||||
private String platformPublicKey; // RSA验签公钥
|
||||
|
||||
@Value("${lulupay.notify-url:}")
|
||||
private String notifyUrl;
|
||||
|
||||
@Value("${lulupay.return-url:}")
|
||||
private String returnUrl;
|
||||
|
||||
@Autowired(required = false)
|
||||
private UserErrorLogService userErrorLogService;
|
||||
|
||||
public LuluPayService(PaymentRepository paymentRepository) {
|
||||
this.paymentRepository = paymentRepository;
|
||||
this.restTemplate = new RestTemplate();
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付订单
|
||||
* 调用API接口:https://api.dulupay.com/api/pay/create
|
||||
*/
|
||||
public Map<String, Object> createPayment(Payment payment, String payType, String clientIp) {
|
||||
try {
|
||||
logger.info("开始创建噜噜支付订单,订单号:{},金额:{},支付方式:{}",
|
||||
payment.getOrderId(), payment.getAmount(), payType);
|
||||
|
||||
// 设置支付状态和方式
|
||||
payment.setStatus(PaymentStatus.PENDING);
|
||||
if ("wxpay".equals(payType)) {
|
||||
payment.setPaymentMethod(PaymentMethod.WECHAT);
|
||||
} else {
|
||||
payment.setPaymentMethod(PaymentMethod.ALIPAY);
|
||||
}
|
||||
|
||||
if (payment.getOrderId() == null || payment.getOrderId().isEmpty()) {
|
||||
payment.setOrderId(generateOrderId());
|
||||
}
|
||||
payment.setCallbackUrl(notifyUrl);
|
||||
payment.setReturnUrl(returnUrl);
|
||||
|
||||
// 保存支付记录
|
||||
paymentRepository.save(payment);
|
||||
logger.info("支付记录已保存,ID:{}", payment.getId());
|
||||
|
||||
// 调用API创建支付
|
||||
return callCreateApi(payment, payType, clientIp);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("创建支付订单异常,订单号:{},错误:{}", payment.getOrderId(), e.getMessage(), e);
|
||||
payment.setStatus(PaymentStatus.FAILED);
|
||||
paymentRepository.save(payment);
|
||||
logPaymentError(payment.getOrderId(), payType, e.getMessage());
|
||||
throw new RuntimeException("支付服务异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用统一下单API(使用submit方式,返回表单直接跳转)
|
||||
*/
|
||||
private Map<String, Object> callCreateApi(Payment payment, String payType, String clientIp) throws Exception {
|
||||
String apiEndpoint = apiUrl + "api/pay/submit";
|
||||
|
||||
// 构建请求参数(按SDK方式,不含method和clientip)
|
||||
Map<String, String> params = new TreeMap<>();
|
||||
params.put("pid", pid);
|
||||
params.put("type", payType);
|
||||
params.put("out_trade_no", payment.getOrderId());
|
||||
params.put("notify_url", notifyUrl);
|
||||
params.put("return_url", returnUrl);
|
||||
params.put("name", payment.getDescription() != null ? payment.getDescription() : "VIP会员");
|
||||
params.put("money", payment.getAmount().setScale(2, BigDecimal.ROUND_HALF_UP).toString());
|
||||
params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
|
||||
|
||||
// 生成MD5签名
|
||||
String sign = generateMd5Sign(params);
|
||||
params.put("sign", sign);
|
||||
params.put("sign_type", "MD5");
|
||||
|
||||
logger.info("调用噜噜支付API: {},参数: {}", apiEndpoint, params);
|
||||
|
||||
// 构建跳转表单(SDK方式)
|
||||
StringBuilder formHtml = new StringBuilder();
|
||||
formHtml.append("<form id=\"lulupay_form\" action=\"").append(escapeHtml(apiEndpoint)).append("\" method=\"post\">");
|
||||
for (Map.Entry<String, String> entry : params.entrySet()) {
|
||||
formHtml.append("<input type=\"hidden\" name=\"").append(entry.getKey())
|
||||
.append("\" value=\"").append(escapeHtml(entry.getValue())).append("\"/>");
|
||||
}
|
||||
formHtml.append("<input type=\"submit\" value=\"正在跳转...\"/>");
|
||||
formHtml.append("</form>");
|
||||
formHtml.append("<script>document.getElementById('lulupay_form').submit();</script>");
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("outTradeNo", payment.getOrderId());
|
||||
result.put("payForm", formHtml.toString());
|
||||
result.put("payType", "PAGE_PAY");
|
||||
|
||||
logger.info("支付表单已生成,订单号:{}", payment.getOrderId());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理异步通知回调
|
||||
* 使用RSA验签(平台公钥)
|
||||
*/
|
||||
public boolean handleNotify(Map<String, String> params) {
|
||||
try {
|
||||
logger.info("收到噜噜支付异步通知: {}", params);
|
||||
|
||||
// 使用RSA验签
|
||||
if (!verifyRsaSign(params)) {
|
||||
logger.warn("噜噜支付异步通知RSA签名验证失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
String tradeStatus = params.get("trade_status");
|
||||
String outTradeNo = params.get("out_trade_no");
|
||||
String tradeNo = params.get("trade_no");
|
||||
String type = params.get("type");
|
||||
String money = params.get("money");
|
||||
|
||||
logger.info("订单号:{},交易号:{},交易状态:{},支付方式:{},金额:{}",
|
||||
outTradeNo, tradeNo, tradeStatus, type, money);
|
||||
|
||||
// 查找支付记录
|
||||
Payment payment = paymentRepository.findByOrderId(outTradeNo)
|
||||
.orElseThrow(() -> new RuntimeException("支付记录不存在:" + outTradeNo));
|
||||
|
||||
// 注意:这里只做验签和基本状态记录,不更新支付状态为SUCCESS
|
||||
// 支付状态的更新和积分增加由 LuluPayCallbackController 调用 PaymentService.confirmPaymentSuccess 统一处理
|
||||
// 这样可以确保积分增加逻辑被正确触发
|
||||
if ("TRADE_CLOSED".equals(tradeStatus)) {
|
||||
payment.setStatus(PaymentStatus.CANCELLED);
|
||||
paymentRepository.save(payment);
|
||||
logger.info("交易关闭,订单号:{}", outTradeNo);
|
||||
}
|
||||
// TRADE_SUCCESS 的处理交给 Controller 层的 confirmPaymentSuccess
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理噜噜支付异步通知异常:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理同步返回
|
||||
*/
|
||||
public boolean handleReturn(Map<String, String> params) {
|
||||
try {
|
||||
logger.info("收到噜噜支付同步返回: {}", params);
|
||||
|
||||
// 使用RSA验签
|
||||
if (!verifyRsaSign(params)) {
|
||||
logger.warn("噜噜支付同步返回RSA签名验证失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
String outTradeNo = params.get("out_trade_no");
|
||||
String tradeNo = params.get("trade_no");
|
||||
String tradeStatus = params.get("trade_status");
|
||||
|
||||
logger.info("同步返回,订单号:{},交易号:{},状态:{}", outTradeNo, tradeNo, tradeStatus);
|
||||
|
||||
// 同步返回只做验签和记录日志,不更新状态
|
||||
// 状态更新由异步通知处理,避免重复处理
|
||||
// 这里只返回验签结果,让前端知道支付是否成功
|
||||
|
||||
return "TRADE_SUCCESS".equals(tradeStatus);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理噜噜支付同步返回异常:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成MD5签名(用于请求)
|
||||
* 签名规则:参数按key排序,拼接成 key1=value1&key2=value2&key=商户密钥,然后MD5
|
||||
*/
|
||||
private String generateMd5Sign(Map<String, String> params) throws Exception {
|
||||
String signContent = getSignContent(params) + merchantKey;
|
||||
logger.debug("MD5待签名字符串: {}", signContent);
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(signContent.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
|
||||
String sign = sb.toString();
|
||||
logger.debug("MD5签名结果: {}", sign);
|
||||
return sign;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证RSA签名(用于回调验签)
|
||||
* 使用平台公钥验证 SHA256WithRSA 签名
|
||||
*/
|
||||
private boolean verifyRsaSign(Map<String, String> params) {
|
||||
try {
|
||||
String sign = params.get("sign");
|
||||
if (sign == null || sign.isEmpty()) {
|
||||
logger.warn("签名为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
String signContent = getSignContent(params);
|
||||
logger.debug("RSA验签字符串: {}", signContent);
|
||||
|
||||
// 解析公钥
|
||||
byte[] keyBytes = Base64.getDecoder().decode(platformPublicKey.replaceAll("\\s+", ""));
|
||||
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
PublicKey publicKey = keyFactory.generatePublic(keySpec);
|
||||
|
||||
// SHA256WithRSA验签
|
||||
Signature signature = Signature.getInstance("SHA256withRSA");
|
||||
signature.initVerify(publicKey);
|
||||
signature.update(signContent.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
boolean result = signature.verify(Base64.getDecoder().decode(sign));
|
||||
if (!result) {
|
||||
logger.warn("RSA签名验证失败");
|
||||
}
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("RSA验签异常: {}", e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待签名字符串(按key的ASCII码升序排序)
|
||||
*/
|
||||
private String getSignContent(Map<String, String> params) {
|
||||
TreeMap<String, String> sortedParams = new TreeMap<>(params);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
|
||||
String k = entry.getKey();
|
||||
String v = entry.getValue();
|
||||
|
||||
// 跳过sign、sign_type和空值
|
||||
if ("sign".equals(k) || "sign_type".equals(k) || v == null || v.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sb.length() > 0) {
|
||||
sb.append("&");
|
||||
}
|
||||
sb.append(k).append("=").append(v);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String escapeHtml(String str) {
|
||||
if (str == null) return "";
|
||||
return str.replace("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace("\"", """).replace("'", "'");
|
||||
}
|
||||
|
||||
private String generateOrderId() {
|
||||
return "LULU" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 6).toUpperCase();
|
||||
}
|
||||
|
||||
private void logPaymentError(String orderId, String payType, String errorMsg) {
|
||||
try {
|
||||
if (userErrorLogService != null) {
|
||||
userErrorLogService.logErrorAsync(null,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.PAYMENT_ERROR,
|
||||
"噜噜支付创建失败: " + errorMsg, "LuluPayService", orderId, payType);
|
||||
}
|
||||
} catch (Exception logEx) {
|
||||
logger.warn("记录支付错误日志失败: {}", logEx.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,41 +240,49 @@ public class OrderService {
|
||||
*/
|
||||
private void handlePointsForStatusChange(Order order, OrderStatus oldStatus, OrderStatus newStatus) {
|
||||
if (order == null || order.getUser() == null) {
|
||||
logger.warn("handlePointsForStatusChange: order或user为空");
|
||||
return;
|
||||
}
|
||||
|
||||
User user = order.getUser();
|
||||
|
||||
// 根据订单描述获取会员等级和对应积分
|
||||
int points = getPointsFromOrderMembershipLevel(order);
|
||||
|
||||
if (points <= 0) {
|
||||
logger.info("无法从订单识别会员等级,不处理积分: orderId={}, description={}", order.getId(), order.getDescription());
|
||||
return;
|
||||
}
|
||||
logger.info("handlePointsForStatusChange: userId={}, orderId={}, oldStatus={}, newStatus={}",
|
||||
user.getId(), order.getId(), oldStatus, newStatus);
|
||||
|
||||
// 从非PAID状态变为PAID状态:增加积分并更新会员等级
|
||||
if (newStatus == OrderStatus.PAID && oldStatus != OrderStatus.PAID) {
|
||||
// 先更新会员等级和到期时间(无论积分是否为0都要更新)
|
||||
try {
|
||||
userService.addPoints(user.getId(), points);
|
||||
logger.info("✅ 订单状态变更增加积分: userId={}, orderId={}, points={}, {} -> {}",
|
||||
user.getId(), order.getId(), points, oldStatus, newStatus);
|
||||
|
||||
// 更新会员等级和到期时间
|
||||
updateMembershipForOrder(order, user);
|
||||
} catch (Exception e) {
|
||||
logger.error("增加积分失败: userId={}, orderId={}, points={}", user.getId(), order.getId(), points, e);
|
||||
logger.error("更新会员等级失败: userId={}, orderId={}", user.getId(), order.getId(), e);
|
||||
}
|
||||
|
||||
// 再处理积分
|
||||
int points = getPointsFromOrderMembershipLevel(order);
|
||||
if (points > 0) {
|
||||
try {
|
||||
userService.addPoints(user.getId(), points);
|
||||
logger.info("订单状态变更增加积分: userId={}, orderId={}, points={}",
|
||||
user.getId(), order.getId(), points);
|
||||
} catch (Exception e) {
|
||||
logger.error("增加积分失败: userId={}, orderId={}, points={}", user.getId(), order.getId(), points, e);
|
||||
}
|
||||
} else {
|
||||
logger.warn("积分为0,跳过积分增加: userId={}, orderId={}", user.getId(), order.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// 从PAID状态变为CANCELLED或REFUNDED状态:扣除积分
|
||||
if ((newStatus == OrderStatus.CANCELLED || newStatus == OrderStatus.REFUNDED) && oldStatus == OrderStatus.PAID) {
|
||||
try {
|
||||
userService.deductPoints(user.getId(), points);
|
||||
logger.info("✅ 订单状态变更扣除积分: userId={}, orderId={}, points={}, {} -> {}",
|
||||
user.getId(), order.getId(), points, oldStatus, newStatus);
|
||||
} catch (Exception e) {
|
||||
logger.error("扣除积分失败: userId={}, orderId={}, points={}", user.getId(), order.getId(), points, e);
|
||||
int points = getPointsFromOrderMembershipLevel(order);
|
||||
if (points > 0) {
|
||||
try {
|
||||
userService.deductPoints(user.getId(), points);
|
||||
logger.info("订单状态变更扣除积分: userId={}, orderId={}, points={}",
|
||||
user.getId(), order.getId(), points);
|
||||
} catch (Exception e) {
|
||||
logger.error("扣除积分失败: userId={}, orderId={}, points={}", user.getId(), order.getId(), points, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,18 +405,37 @@ public class OrderService {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("开始匹配会员等级,订单描述: {}", description);
|
||||
|
||||
// 从数据库获取所有会员等级,动态匹配
|
||||
MembershipLevel level = null;
|
||||
List<MembershipLevel> allLevels = membershipLevelRepository.findAll();
|
||||
String descLower = description.toLowerCase();
|
||||
|
||||
// 优先匹配中文关键词(更精确)
|
||||
for (MembershipLevel lvl : allLevels) {
|
||||
String name = lvl.getName();
|
||||
String displayName = lvl.getDisplayName();
|
||||
if (name == null) continue;
|
||||
|
||||
if ((name != null && descLower.contains(name.toLowerCase())) ||
|
||||
(displayName != null && descLower.contains(displayName.toLowerCase()))) {
|
||||
// 跳过免费版
|
||||
if ("free".equalsIgnoreCase(name)) continue;
|
||||
|
||||
// 匹配中文关键词
|
||||
if ("standard".equalsIgnoreCase(name) && description.contains("标准")) {
|
||||
level = lvl;
|
||||
logger.info("匹配到标准版会员: levelId={}, name={}", lvl.getId(), name);
|
||||
break;
|
||||
}
|
||||
if ("professional".equalsIgnoreCase(name) && (description.contains("专业") || description.contains("Pro"))) {
|
||||
level = lvl;
|
||||
logger.info("匹配到专业版会员: levelId={}, name={}", lvl.getId(), name);
|
||||
break;
|
||||
}
|
||||
|
||||
// 匹配 displayName
|
||||
String displayName = lvl.getDisplayName();
|
||||
if (displayName != null && description.contains(displayName)) {
|
||||
level = lvl;
|
||||
logger.info("通过displayName匹配到会员: levelId={}, displayName={}", lvl.getId(), displayName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -418,6 +445,7 @@ public class OrderService {
|
||||
return;
|
||||
}
|
||||
int durationDays = level.getDurationDays();
|
||||
logger.info("会员等级匹配成功: level={}, durationDays={}", level.getName(), durationDays);
|
||||
|
||||
// 查找或创建用户会员信息(按到期时间降序,返回最新的)
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
@@ -427,6 +455,8 @@ public class OrderService {
|
||||
|
||||
if (membershipOpt.isPresent()) {
|
||||
membership = membershipOpt.get();
|
||||
logger.info("找到现有会员记录: membershipId={}, currentEndDate={}", membership.getId(), membership.getEndDate());
|
||||
|
||||
// 从数据库获取当前会员等级的价格,用价格判断等级高低(价格越高等级越高)
|
||||
// 会员等级只能升不能降:专业版 > 标准版 > 免费版
|
||||
Optional<MembershipLevel> currentLevelOpt = membershipLevelRepository.findById(membership.getMembershipLevelId());
|
||||
@@ -440,27 +470,33 @@ public class OrderService {
|
||||
}
|
||||
// 延长到期时间
|
||||
LocalDateTime currentEndDate = membership.getEndDate();
|
||||
if (currentEndDate.isAfter(now)) {
|
||||
LocalDateTime newEndDate;
|
||||
if (currentEndDate != null && currentEndDate.isAfter(now)) {
|
||||
// 如果还没过期,在当前到期时间基础上延长
|
||||
membership.setEndDate(currentEndDate.plusDays(durationDays));
|
||||
newEndDate = currentEndDate.plusDays(durationDays);
|
||||
logger.info("会员未过期,在现有基础上延长: {} + {}天 = {}", currentEndDate, durationDays, newEndDate);
|
||||
} else {
|
||||
// 如果已过期,从现在开始计算
|
||||
membership.setEndDate(now.plusDays(durationDays));
|
||||
newEndDate = now.plusDays(durationDays);
|
||||
logger.info("会员已过期或无到期时间,从现在开始计算: {} + {}天 = {}", now, durationDays, newEndDate);
|
||||
}
|
||||
membership.setEndDate(newEndDate);
|
||||
} else {
|
||||
// 创建新的会员记录
|
||||
logger.info("未找到现有会员记录,创建新记录");
|
||||
membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setMembershipLevelId(level.getId());
|
||||
membership.setStartDate(now);
|
||||
membership.setEndDate(now.plusDays(durationDays));
|
||||
membership.setStatus("ACTIVE");
|
||||
logger.info("新会员记录: startDate={}, endDate={}", now, membership.getEndDate());
|
||||
}
|
||||
|
||||
membership.setUpdatedAt(now);
|
||||
userMembershipRepository.save(membership);
|
||||
|
||||
logger.info("✅ 更新用户会员信息: userId={}, level={}, endDate={}",
|
||||
logger.info("✅ 更新用户会员信息成功: userId={}, level={}, endDate={}",
|
||||
user.getId(), level.getName(), membership.getEndDate());
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -468,6 +504,31 @@ public class OrderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保订单的会员信息已更新(用于处理订单状态已经是PAID但会员信息未更新的情况)
|
||||
* 这个方法不会重复增加积分,只会更新会员等级和到期时间
|
||||
*/
|
||||
public void ensureMembershipUpdated(Long orderId) {
|
||||
try {
|
||||
Order order = orderRepository.findByIdWithUser(orderId)
|
||||
.orElseThrow(() -> new RuntimeException("订单不存在:" + orderId));
|
||||
|
||||
if (order.getUser() == null) {
|
||||
logger.warn("ensureMembershipUpdated: 订单没有关联用户, orderId={}", orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("ensureMembershipUpdated: 检查并确保会员信息已更新, orderId={}, userId={}",
|
||||
orderId, order.getUser().getId());
|
||||
|
||||
// 直接调用更新会员信息的方法
|
||||
updateMembershipForOrder(order, order.getUser());
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("ensureMembershipUpdated失败: orderId={}", orderId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
|
||||
@@ -84,27 +84,28 @@ public class PaymentService {
|
||||
Payment payment = paymentRepository.findByIdWithUserAndOrder(paymentId)
|
||||
.orElseThrow(() -> new RuntimeException("Not found"));
|
||||
|
||||
// 检查是否已经处理过(防止重复增加积分和重复创建订单)
|
||||
if (payment.getStatus() == PaymentStatus.SUCCESS) {
|
||||
logger.info("支付记录已经是成功状态,跳过重复处理: paymentId={}", paymentId);
|
||||
return payment;
|
||||
boolean alreadySuccess = payment.getStatus() == PaymentStatus.SUCCESS;
|
||||
|
||||
if (alreadySuccess) {
|
||||
logger.info("支付记录已经是成功状态: paymentId={}", paymentId);
|
||||
} else {
|
||||
// 首次确认支付成功
|
||||
payment.setStatus(PaymentStatus.SUCCESS);
|
||||
payment.setPaidAt(LocalDateTime.now());
|
||||
payment.setExternalTransactionId(externalTransactionId);
|
||||
payment = paymentRepository.save(payment);
|
||||
logger.info("支付状态更新为SUCCESS: paymentId={}", paymentId);
|
||||
}
|
||||
|
||||
payment.setStatus(PaymentStatus.SUCCESS);
|
||||
payment.setPaidAt(LocalDateTime.now());
|
||||
payment.setExternalTransactionId(externalTransactionId);
|
||||
Payment savedPayment = paymentRepository.save(payment);
|
||||
|
||||
// 支付成功后更新订单状态为已支付
|
||||
// 注意:积分添加逻辑已移至 OrderService.handlePointsForStatusChange
|
||||
// 当订单状态变为 PAID 时会自动添加积分,避免重复添加
|
||||
// 无论是否已经是SUCCESS状态,都要确保订单状态和会员信息已更新
|
||||
// 这样可以处理同步返回先更新状态但没有更新会员信息的情况
|
||||
try {
|
||||
updateOrderStatusForPayment(savedPayment);
|
||||
updateOrderStatusForPayment(payment);
|
||||
} catch (Exception e) {
|
||||
logger.error("支付成功但更新订单状态失败: paymentId={}, error={}", paymentId, e.getMessage(), e);
|
||||
}
|
||||
|
||||
return savedPayment;
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,16 +120,38 @@ public class PaymentService {
|
||||
// 检查是否已经关联了订单
|
||||
Order order = payment.getOrder();
|
||||
if (order == null) {
|
||||
logger.warn("支付记录未关联订单,无法更新状态: paymentId={}", payment.getId());
|
||||
return;
|
||||
// 如果没有关联订单,需要创建一个订单并关联
|
||||
logger.warn("支付记录未关联订单,尝试创建订单: paymentId={}", payment.getId());
|
||||
try {
|
||||
order = createOrderForPayment(payment);
|
||||
payment.setOrder(order);
|
||||
paymentRepository.save(payment);
|
||||
logger.info("✅ 为支付记录创建并关联订单成功: paymentId={}, orderId={}", payment.getId(), order.getId());
|
||||
} catch (Exception e) {
|
||||
logger.error("为支付记录创建订单失败: paymentId={}, error={}", payment.getId(), e.getMessage(), e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 直接调用orderService.updateOrderStatus,不要在这里修改order状态
|
||||
// orderService.updateOrderStatus会正确处理状态变更和积分添加
|
||||
orderService.updateOrderStatus(order.getId(), OrderStatus.PAID);
|
||||
// 检查订单当前状态
|
||||
OrderStatus currentStatus = order.getStatus();
|
||||
logger.info("当前订单状态: orderId={}, status={}", order.getId(), currentStatus);
|
||||
|
||||
logger.info("✅ 订单状态更新为已支付: orderId={}, orderNumber={}, paymentId={}",
|
||||
if (currentStatus == OrderStatus.PAID) {
|
||||
// 订单已经是PAID状态,但可能会员信息没有更新
|
||||
// 直接调用handlePointsForStatusChange来确保会员信息已更新
|
||||
logger.info("订单已是PAID状态,检查并确保会员信息已更新: orderId={}", order.getId());
|
||||
// 重新触发会员更新逻辑(通过设置相同状态不会重复增加积分,因为oldStatus == newStatus)
|
||||
// 但这样不会触发handlePointsForStatusChange,所以我们需要直接调用
|
||||
// 为了避免重复增加积分,我们在OrderService中添加一个专门的方法
|
||||
orderService.ensureMembershipUpdated(order.getId());
|
||||
} else {
|
||||
// 订单不是PAID状态,正常更新
|
||||
orderService.updateOrderStatus(order.getId(), OrderStatus.PAID);
|
||||
}
|
||||
|
||||
logger.info("✅ 订单状态处理完成: orderId={}, orderNumber={}, paymentId={}",
|
||||
order.getId(), order.getOrderNumber(), payment.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -138,6 +161,22 @@ public class PaymentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为没有关联订单的支付记录创建订单
|
||||
*/
|
||||
private Order createOrderForPayment(Payment payment) {
|
||||
Order order = new Order();
|
||||
order.setUser(payment.getUser());
|
||||
order.setOrderNumber("ORD" + System.currentTimeMillis());
|
||||
order.setTotalAmount(payment.getAmount());
|
||||
order.setCurrency(payment.getCurrency() != null ? payment.getCurrency() : "CNY");
|
||||
order.setStatus(OrderStatus.PENDING); // 待支付状态,后面会更新为PAID
|
||||
order.setOrderType(OrderType.SUBSCRIPTION);
|
||||
order.setDescription(payment.getDescription() != null ? payment.getDescription() : "会员订阅");
|
||||
|
||||
return orderService.createOrder(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付金额增加用户积分
|
||||
* 从 system_settings 读取配置的价格来判断套餐类型
|
||||
@@ -245,8 +284,15 @@ public class PaymentService {
|
||||
// 验证用户和金额是否匹配
|
||||
if (existingPayment.getUser().getUsername().equals(username) &&
|
||||
existingPayment.getAmount().compareTo(new BigDecimal(amountStr)) == 0) {
|
||||
logger.info("复用已存在的PENDING支付记录: orderId={}, paymentId={}", orderId, existingPayment.getId());
|
||||
return existingPayment;
|
||||
// 检查是否已关联订单,如果没有则需要补充关联
|
||||
if (existingPayment.getOrder() == null) {
|
||||
logger.warn("复用的PENDING支付记录未关联订单,需要创建订单并关联: orderId={}, paymentId={}", orderId, existingPayment.getId());
|
||||
// 不复用,创建新的支付记录和订单
|
||||
orderId = orderId + "_" + System.currentTimeMillis();
|
||||
} else {
|
||||
logger.info("复用已存在的PENDING支付记录: orderId={}, paymentId={}", orderId, existingPayment.getId());
|
||||
return existingPayment;
|
||||
}
|
||||
} else {
|
||||
// 用户或金额不匹配,生成新的 orderId
|
||||
logger.warn("已存在相同orderId的PENDING支付,但用户或金额不匹配,生成新orderId: {}", orderId);
|
||||
|
||||
@@ -571,10 +571,10 @@ public class RealAIService {
|
||||
}
|
||||
|
||||
} catch (UnirestException e) {
|
||||
logger.error("查询任务状态异常: {}", taskId, e);
|
||||
logger.error("查询任务状态异常: taskId={}", taskId, e);
|
||||
throw new RuntimeException("查询任务状态失败: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("查询任务状态异常: {}", taskId, e);
|
||||
logger.error("查询任务状态异常: taskId={}", taskId, e);
|
||||
throw new RuntimeException("查询任务状态失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -1334,18 +1334,34 @@ public class RealAIService {
|
||||
* @return 优化后的提示词
|
||||
*/
|
||||
public String optimizePromptWithImages(String prompt, String type, List<String> imageUrls) {
|
||||
return optimizePromptWithImages(prompt, type, imageUrls, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多模态提示词优化(支持自定义系统提示词)
|
||||
*
|
||||
* @param prompt 原始提示词
|
||||
* @param type 优化类型
|
||||
* @param imageUrls 参考图片列表(Base64 或 URL)
|
||||
* @param customSystemPrompt 自定义系统提示词(如果为null则使用默认)
|
||||
* @return 优化后的提示词
|
||||
*/
|
||||
public String optimizePromptWithImages(String prompt, String type, List<String> imageUrls, String customSystemPrompt) {
|
||||
// 如果没有图片,直接调用普通方法
|
||||
if (imageUrls == null || imageUrls.isEmpty()) {
|
||||
return optimizePrompt(prompt, type);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("开始多模态提示词优化: prompt长度={}, type={}, 图片数量={}",
|
||||
prompt.length(), type, imageUrls.size());
|
||||
logger.info("开始多模态提示词优化: prompt长度={}, type={}, 图片数量={}, 自定义系统提示词={}",
|
||||
prompt.length(), type, imageUrls.size(), customSystemPrompt != null ? "有" : "无");
|
||||
|
||||
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
|
||||
String systemPrompt = getOptimizationPrompt(type);
|
||||
// 优先使用自定义系统提示词,否则使用默认
|
||||
String systemPrompt = (customSystemPrompt != null && !customSystemPrompt.trim().isEmpty())
|
||||
? customSystemPrompt
|
||||
: getOptimizationPrompt(type);
|
||||
|
||||
// 提示词优化使用统一的 API base URL
|
||||
String apiUrl = getEffectiveApiBaseUrl();
|
||||
@@ -1376,10 +1392,16 @@ public class RealAIService {
|
||||
// 构建 content 数组(多模态格式)
|
||||
List<Map<String, Object>> contentArray = new java.util.ArrayList<>();
|
||||
|
||||
// 添加文本部分
|
||||
// 添加文本部分(如果有自定义系统提示词,直接使用用户提示词;否则添加默认前缀)
|
||||
Map<String, Object> textContent = new HashMap<>();
|
||||
textContent.put("type", "text");
|
||||
textContent.put("text", "请根据以下用户需求和参考图片,优化生成专业的分镜提示词:\n\n" + prompt);
|
||||
if (customSystemPrompt != null && !customSystemPrompt.trim().isEmpty()) {
|
||||
// 使用自定义系统提示词时,直接发送用户提示词
|
||||
textContent.put("text", prompt);
|
||||
} else {
|
||||
// 使用默认系统提示词时,添加引导前缀
|
||||
textContent.put("text", "请根据以下用户需求和参考图片,优化生成专业的分镜提示词:\n\n" + prompt);
|
||||
}
|
||||
contentArray.add(textContent);
|
||||
|
||||
// 添加图片部分(最多3张)
|
||||
|
||||
@@ -125,7 +125,7 @@ public class StoryboardVideoService {
|
||||
// 创建任务
|
||||
StoryboardVideoTask task = new StoryboardVideoTask(username, prompt.trim(), aspectRatio, hdMode, duration);
|
||||
task.setTaskId(taskId);
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.PENDING);
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.PROCESSING); // 直接设为生成中
|
||||
task.setProgress(0);
|
||||
|
||||
// 设置图像生成模型
|
||||
@@ -229,6 +229,171 @@ public class StoryboardVideoService {
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接使用上传的分镜图创建视频任务(跳过分镜图生成)
|
||||
* 用户在STEP2上传分镜图后直接生成视频时调用
|
||||
* 只冻结视频生成积分(30积分),不冻结分镜图生成积分
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public StoryboardVideoTask createVideoDirectTask(String username, String prompt, String storyboardImage,
|
||||
String aspectRatio, boolean hdMode, Integer duration, List<String> referenceImages) {
|
||||
// 验证参数
|
||||
if (username == null || username.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("用户名不能为空");
|
||||
}
|
||||
if (storyboardImage == null || storyboardImage.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("分镜图不能为空");
|
||||
}
|
||||
|
||||
// 检查用户所有类型任务的总数
|
||||
userWorkService.checkMaxConcurrentTasks(username);
|
||||
|
||||
// 生成任务ID
|
||||
String taskId = generateTaskId();
|
||||
|
||||
// 只冻结视频生成积分(30积分),不冻结分镜图生成积分
|
||||
try {
|
||||
userService.freezePoints(username, taskId + "_vid",
|
||||
com.example.demo.model.PointsFreezeRecord.TaskType.STORYBOARD_VIDEO,
|
||||
30, "分镜视频生成(直接上传分镜图)");
|
||||
logger.info("视频积分冻结成功: taskId={}, username={}, points=30", taskId, username);
|
||||
} catch (Exception e) {
|
||||
logger.error("视频积分冻结失败: taskId={}, username={}", taskId, username, e);
|
||||
throw new RuntimeException("积分不足,无法创建任务: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
StoryboardVideoTask task = new StoryboardVideoTask(username, prompt != null ? prompt.trim() : "根据分镜图生成视频", aspectRatio, hdMode, duration);
|
||||
task.setTaskId(taskId);
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.PROCESSING); // 直接设为生成中
|
||||
task.setProgress(50); // 分镜图已完成,从50%开始
|
||||
|
||||
// 处理分镜图:如果是Base64,上传到COS
|
||||
String finalStoryboardImage = storyboardImage;
|
||||
if (storyboardImage.startsWith("data:image") && cosService.isEnabled()) {
|
||||
try {
|
||||
String cosUrl = cosService.uploadBase64Image(storyboardImage, "storyboard_" + taskId + ".png");
|
||||
if (cosUrl != null && !cosUrl.isEmpty()) {
|
||||
finalStoryboardImage = cosUrl;
|
||||
logger.info("分镜图上传COS成功: taskId={}", taskId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("分镜图上传COS异常: taskId={}", taskId, e);
|
||||
}
|
||||
}
|
||||
task.setResultUrl(finalStoryboardImage); // 设置分镜图URL
|
||||
|
||||
// 处理视频参考图
|
||||
if (referenceImages != null && !referenceImages.isEmpty()) {
|
||||
try {
|
||||
List<String> processedImages = new ArrayList<>();
|
||||
int imageIndex = 0;
|
||||
for (String img : referenceImages) {
|
||||
if (img == null || img.isEmpty() || "null".equals(img)) {
|
||||
continue;
|
||||
}
|
||||
if (img.startsWith("data:image") && cosService.isEnabled()) {
|
||||
try {
|
||||
String cosUrl = cosService.uploadBase64Image(img, "videoref_" + taskId + "_" + imageIndex + ".png");
|
||||
if (cosUrl != null && !cosUrl.isEmpty()) {
|
||||
processedImages.add(cosUrl);
|
||||
} else {
|
||||
processedImages.add(img);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
processedImages.add(img);
|
||||
}
|
||||
} else {
|
||||
processedImages.add(img);
|
||||
}
|
||||
imageIndex++;
|
||||
}
|
||||
|
||||
if (!processedImages.isEmpty()) {
|
||||
String videoRefImagesJson = objectMapper.writeValueAsString(processedImages);
|
||||
task.setVideoReferenceImages(videoRefImagesJson);
|
||||
logger.info("保存视频参考图: taskId={}, 数量={}", taskId, processedImages.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("处理视频参考图失败: taskId={}", taskId, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存任务
|
||||
task = taskRepository.save(task);
|
||||
logger.info("直接视频任务创建成功: {}, 用户: {}", taskId, username);
|
||||
|
||||
// 创建PROCESSING状态的UserWork
|
||||
try {
|
||||
userWorkService.createProcessingStoryboardVideoWork(task);
|
||||
logger.info("创建PROCESSING状态分镜视频作品成功: {}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.warn("创建PROCESSING状态作品失败(不影响任务执行): {}", taskId, e);
|
||||
}
|
||||
|
||||
// 事务提交后,直接开始视频生成(跳过分镜图生成)
|
||||
final String finalTaskId = taskId;
|
||||
final StoryboardVideoService self = applicationContext.getBean(StoryboardVideoService.class);
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
// 直接调用视频生成,跳过分镜图生成
|
||||
self.processVideoGenerationAsync(finalTaskId);
|
||||
}
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接处理视频生成(异步)- 跳过分镜图生成
|
||||
* 用于用户直接上传分镜图后生成视频的场景
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
public void processVideoGenerationAsync(String taskId) {
|
||||
try {
|
||||
logger.info("开始直接视频生成(跳过分镜图生成): taskId={}", taskId);
|
||||
|
||||
// 短暂等待确保数据库同步完成
|
||||
Thread.sleep(500);
|
||||
|
||||
// 将任务添加到队列并直接处理
|
||||
StoryboardVideoTask task = loadTaskInfoWithTransactionTemplate(taskId);
|
||||
if (task == null) {
|
||||
logger.error("任务不存在: taskId={}", taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到任务队列
|
||||
try {
|
||||
taskQueueService.addStoryboardVideoTask(task.getUsername(), taskId);
|
||||
logger.info("视频任务已添加到队列: taskId={}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.error("添加视频任务到队列失败: taskId={}", taskId, e);
|
||||
// 返还积分
|
||||
try {
|
||||
userService.returnFrozenPoints(taskId + "_vid");
|
||||
} catch (Exception re) {
|
||||
logger.warn("返还积分失败: taskId={}_vid", taskId);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 直接处理视频生成
|
||||
taskQueueService.processStoryboardVideoTaskDirectly(taskId);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("直接视频生成失败: taskId={}", taskId, e);
|
||||
// 更新任务状态为失败
|
||||
try {
|
||||
updateTaskStatusToFailed(taskId, "视频生成失败: " + e.getMessage());
|
||||
} catch (Exception ue) {
|
||||
logger.error("更新任务状态失败: taskId={}", taskId, ue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用真实API处理任务(异步)
|
||||
* 注意:此方法明确禁用事务,因为长时间运行的外部API调用会占用数据库连接
|
||||
@@ -288,13 +453,13 @@ public class StoryboardVideoService {
|
||||
String storyboardSystemPrompt = settings.getStoryboardSystemPrompt();
|
||||
|
||||
if (storyboardSystemPrompt != null && !storyboardSystemPrompt.trim().isEmpty()) {
|
||||
// 组合提示词并调用优化 API
|
||||
String combinedPrompt = storyboardSystemPrompt.trim() + "\n\n用户需求:" + prompt;
|
||||
// 使用后台配置的系统引导词作为系统消息,用户原始提示词作为用户消息
|
||||
logger.info("开始调用提示词优化API,获取 JSON 格式结果...");
|
||||
|
||||
// 调用多模态提示词优化(传递用户上传的图片)
|
||||
// 调用多模态提示词优化(传递用户上传的图片和自定义系统提示词)
|
||||
// 期望返回 JSON(包含 shotList、imagePrompt、videoPrompt)
|
||||
String optimizedResult = realAIService.optimizePromptWithImages(combinedPrompt, "storyboard", userUploadedImages);
|
||||
String userPrompt = "用户需求:" + prompt;
|
||||
String optimizedResult = realAIService.optimizePromptWithImages(userPrompt, "storyboard", userUploadedImages, storyboardSystemPrompt);
|
||||
|
||||
// 尝试解析 JSON 响应
|
||||
try {
|
||||
@@ -360,6 +525,14 @@ public class StoryboardVideoService {
|
||||
final String finalShotList = shotListResult;
|
||||
final String finalImagePrompt = finalPrompt;
|
||||
final String finalVideoPrompt = videoPromptResult;
|
||||
|
||||
// 调试日志:检查即将保存的提示词
|
||||
logger.info("【调试】即将保存提示词: taskId={}, shotList长度={}, imagePrompt长度={}, videoPrompt长度={}",
|
||||
taskId,
|
||||
finalShotList != null ? finalShotList.length() : 0,
|
||||
finalImagePrompt != null ? finalImagePrompt.length() : 0,
|
||||
finalVideoPrompt != null ? finalVideoPrompt.length() : 0);
|
||||
|
||||
asyncTransactionTemplate.executeWithoutResult(status -> {
|
||||
StoryboardVideoTask t = taskRepository.findByTaskId(taskId).orElse(null);
|
||||
if (t != null) {
|
||||
@@ -367,21 +540,24 @@ public class StoryboardVideoService {
|
||||
t.setImagePrompt(finalImagePrompt);
|
||||
t.setVideoPrompt(finalVideoPrompt);
|
||||
taskRepository.save(t);
|
||||
logger.info("【调试】提示词已保存到数据库: taskId={}", taskId);
|
||||
} else {
|
||||
logger.error("【调试】保存提示词失败:任务不存在: taskId={}", taskId);
|
||||
}
|
||||
});
|
||||
logger.info("已保存优化后的提示词到任务: taskId={}", taskId);
|
||||
|
||||
} catch (Exception jsonException) {
|
||||
// JSON 解析失败,使用原始优化结果作为 imagePrompt 和 videoPrompt
|
||||
logger.warn("JSON 解析失败,使用原始优化结果: {}", jsonException.getMessage());
|
||||
finalPrompt = optimizedResult;
|
||||
// 保存优化结果到 imagePrompt 和 videoPrompt
|
||||
final String optimizedPrompt = optimizedResult;
|
||||
// JSON 解析失败,使用用户原始提示词(不是AI返回的完整响应)
|
||||
logger.warn("JSON 解析失败,使用用户原始提示词: {}", jsonException.getMessage());
|
||||
// 不要使用 optimizedResult,因为它可能包含系统引导词
|
||||
// 使用用户的原始提示词
|
||||
final String originalPrompt = prompt;
|
||||
asyncTransactionTemplate.executeWithoutResult(status -> {
|
||||
StoryboardVideoTask t = taskRepository.findByTaskId(taskId).orElse(null);
|
||||
if (t != null) {
|
||||
t.setImagePrompt(optimizedPrompt);
|
||||
t.setVideoPrompt(optimizedPrompt);
|
||||
t.setImagePrompt(originalPrompt);
|
||||
t.setVideoPrompt(originalPrompt);
|
||||
taskRepository.save(t);
|
||||
}
|
||||
});
|
||||
@@ -969,7 +1145,12 @@ public class StoryboardVideoService {
|
||||
}
|
||||
|
||||
if (effectiveStoryboardUrl == null || effectiveStoryboardUrl.isEmpty()) {
|
||||
throw new RuntimeException("分镜图尚未生成,无法生成视频");
|
||||
// 根据任务状态给出更友好的提示
|
||||
if (task.getStatus() == StoryboardVideoTask.TaskStatus.PROCESSING) {
|
||||
throw new RuntimeException("分镜图正在生成中,请等待生成完成后再生成视频");
|
||||
} else {
|
||||
throw new RuntimeException("请先上传或生成分镜图,再生成视频");
|
||||
}
|
||||
}
|
||||
|
||||
// 更新任务的resultUrl为有效的分镜图URL(确保后续处理能获取到)
|
||||
|
||||
@@ -115,7 +115,7 @@ public class TaskQueueService {
|
||||
* 配合 MAX_TASKS_PER_USER=3,理论上可支持5个用户同时处理任务(最多15个并发任务)
|
||||
* 可根据服务器性能调整此值以支持更多并发
|
||||
*/
|
||||
private static final int CONSUMER_THREAD_COUNT = 10;
|
||||
private static final int CONSUMER_THREAD_COUNT = 20;
|
||||
|
||||
/**
|
||||
* 是否正在运行
|
||||
@@ -304,40 +304,70 @@ public class TaskQueueService {
|
||||
} else if (isMaxCheckReached) {
|
||||
failReason = "任务超时(检查次数已达上限)";
|
||||
} else {
|
||||
// 未超时,查询外部API状态
|
||||
try {
|
||||
logger.info("系统重启:查询任务 {} 的外部API状态(realTaskId={})",
|
||||
taskQueue.getTaskId(), taskQueue.getRealTaskId());
|
||||
|
||||
Map<String, Object> statusResponse = realAIService.getTaskStatus(taskQueue.getRealTaskId());
|
||||
|
||||
if (statusResponse != null && statusResponse.containsKey("data")) {
|
||||
Object data = statusResponse.get("data");
|
||||
if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> dataMap = (Map<String, Object>) data;
|
||||
String externalStatus = String.valueOf(dataMap.get("status"));
|
||||
|
||||
// 只有外部API状态为"进行中"才恢复
|
||||
if ("Processing".equalsIgnoreCase(externalStatus) ||
|
||||
"Pending".equalsIgnoreCase(externalStatus) ||
|
||||
"Running".equalsIgnoreCase(externalStatus) ||
|
||||
"Queued".equalsIgnoreCase(externalStatus)) {
|
||||
shouldRecover = true;
|
||||
logger.info("系统重启:任务 {} 外部API状态为{},恢复轮询",
|
||||
taskQueue.getTaskId(), externalStatus);
|
||||
} else {
|
||||
failReason = "外部API状态: " + externalStatus;
|
||||
logger.warn("系统重启:任务 {} 外部API状态为{},不恢复",
|
||||
taskQueue.getTaskId(), externalStatus);
|
||||
// 未超时,查询外部API状态(最多重试3次)
|
||||
int maxRetries = 3;
|
||||
int retryCount = 0;
|
||||
boolean apiQuerySuccess = false;
|
||||
String lastError = null;
|
||||
|
||||
while (retryCount < maxRetries && !apiQuerySuccess) {
|
||||
retryCount++;
|
||||
try {
|
||||
logger.info("系统重启:查询任务 {} 的外部API状态(realTaskId={},第{}次尝试)",
|
||||
taskQueue.getTaskId(), taskQueue.getRealTaskId(), retryCount);
|
||||
|
||||
Map<String, Object> statusResponse = realAIService.getTaskStatus(taskQueue.getRealTaskId());
|
||||
|
||||
if (statusResponse != null && statusResponse.containsKey("data")) {
|
||||
Object data = statusResponse.get("data");
|
||||
if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> dataMap = (Map<String, Object>) data;
|
||||
String externalStatus = String.valueOf(dataMap.get("status"));
|
||||
|
||||
apiQuerySuccess = true; // API查询成功
|
||||
|
||||
// 只有外部API状态为"进行中"或"已完成"才恢复
|
||||
if ("Processing".equalsIgnoreCase(externalStatus) ||
|
||||
"Pending".equalsIgnoreCase(externalStatus) ||
|
||||
"Running".equalsIgnoreCase(externalStatus) ||
|
||||
"Queued".equalsIgnoreCase(externalStatus) ||
|
||||
"Success".equalsIgnoreCase(externalStatus) ||
|
||||
"Completed".equalsIgnoreCase(externalStatus)) {
|
||||
shouldRecover = true;
|
||||
logger.info("系统重启:任务 {} 外部API状态为{},恢复轮询",
|
||||
taskQueue.getTaskId(), externalStatus);
|
||||
} else {
|
||||
failReason = "外部API状态: " + externalStatus;
|
||||
logger.warn("系统重启:任务 {} 外部API状态为{},不恢复",
|
||||
taskQueue.getTaskId(), externalStatus);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastError = "无法获取外部API状态";
|
||||
logger.warn("系统重启:查询任务 {} 外部状态返回空(第{}次)", taskQueue.getTaskId(), retryCount);
|
||||
}
|
||||
} else {
|
||||
failReason = "无法获取外部API状态";
|
||||
} catch (Exception e) {
|
||||
lastError = e.getMessage();
|
||||
logger.warn("系统重启:查询任务 {} 外部状态失败(第{}次): {}", taskQueue.getTaskId(), retryCount, e.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
failReason = "查询外部API失败: " + e.getMessage();
|
||||
logger.warn("系统重启:查询任务 {} 外部状态失败: {}", taskQueue.getTaskId(), e.getMessage());
|
||||
|
||||
// 如果还需要重试,等待1秒
|
||||
if (!apiQuerySuccess && retryCount < maxRetries) {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果3次查询都失败,仍然恢复任务(让后续轮询继续处理)
|
||||
if (!apiQuerySuccess) {
|
||||
shouldRecover = true; // 查询失败时也恢复,让轮询服务继续处理
|
||||
logger.warn("系统重启:任务 {} 连续{}次查询外部API失败,但仍恢复任务继续轮询(最后错误: {})",
|
||||
taskQueue.getTaskId(), maxRetries, lastError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1122,6 +1152,14 @@ public class TaskQueueService {
|
||||
// 使用只读事务快速查询任务信息
|
||||
StoryboardVideoTask task = getStoryboardVideoTaskWithTransaction(taskQueue.getTaskId());
|
||||
|
||||
// 调试日志:检查从数据库加载的提示词字段
|
||||
logger.info("【调试】任务提示词字段: taskId={}, prompt={}, shotList={}, imagePrompt={}, videoPrompt={}",
|
||||
task.getTaskId(),
|
||||
task.getPrompt() != null ? task.getPrompt().substring(0, Math.min(50, task.getPrompt().length())) + "..." : "null",
|
||||
task.getShotList() != null ? "长度:" + task.getShotList().length() : "null",
|
||||
task.getImagePrompt() != null ? "长度:" + task.getImagePrompt().length() : "null",
|
||||
task.getVideoPrompt() != null ? "长度:" + task.getVideoPrompt().length() : "null");
|
||||
|
||||
// 获取6张分镜图片
|
||||
List<String> images = null;
|
||||
String storyboardImagesJson = task.getStoryboardImages();
|
||||
@@ -1255,9 +1293,14 @@ public class TaskQueueService {
|
||||
if (videoPromptBuilder.length() > 0) {
|
||||
videoPromptForApi = videoPromptBuilder.toString();
|
||||
logger.info("组合后的视频提示词长度: {}", videoPromptForApi.length());
|
||||
// 调试:显示实际使用的提示词前200字符
|
||||
logger.info("【调试】实际使用的视频提示词: {}",
|
||||
videoPromptForApi.length() > 200 ? videoPromptForApi.substring(0, 200) + "..." : videoPromptForApi);
|
||||
} else {
|
||||
videoPromptForApi = task.getPrompt();
|
||||
logger.info("使用原始提示词生成视频");
|
||||
logger.warn("【警告】shotList 和 videoPrompt 都为空,使用用户原始提示词: {}",
|
||||
videoPromptForApi != null ? videoPromptForApi.substring(0, Math.min(100, videoPromptForApi.length())) : "null");
|
||||
}
|
||||
|
||||
// 使用拼好的图片调用图生视频接口
|
||||
@@ -1550,7 +1593,8 @@ public class TaskQueueService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskQueue.getRealTaskId() == null) {
|
||||
if (taskQueue.getRealTaskId() == null || taskQueue.getRealTaskId().isEmpty()
|
||||
|| "<no value>".equals(taskQueue.getRealTaskId())) {
|
||||
// 增加时间窗口保护:只有任务创建超过5分钟后 realTaskId 仍为空才标记为失败
|
||||
// 避免任务刚创建还在等待外部API响应时就被错误地标记为失败
|
||||
java.time.LocalDateTime fiveMinutesAgo = java.time.LocalDateTime.now().minusMinutes(5);
|
||||
|
||||
@@ -126,70 +126,97 @@ public class TaskStatusPollingService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 有外部任务ID,查询外部API状态
|
||||
try {
|
||||
String url = dynamicApiConfig.getApiBaseUrl() + "/v2/videos/generations/" + task.getExternalTaskId();
|
||||
HttpResponse<String> response = Unirest.get(url)
|
||||
.header("Authorization", "Bearer " + dynamicApiConfig.getApiKey())
|
||||
.asString();
|
||||
|
||||
if (response.getStatus() == 200) {
|
||||
JsonNode responseJson = objectMapper.readTree(response.getBody());
|
||||
String status = responseJson.path("status").asText();
|
||||
String resultUrl = responseJson.path("data").path("output").asText();
|
||||
// 有外部任务ID,查询外部API状态(最多重试3次)
|
||||
int maxRetries = 3;
|
||||
int retryCount = 0;
|
||||
boolean apiQuerySuccess = false;
|
||||
String lastError = null;
|
||||
|
||||
while (retryCount < maxRetries && !apiQuerySuccess) {
|
||||
retryCount++;
|
||||
try {
|
||||
logger.info("恢复检查任务 {} 外部API状态(第{}次尝试)", task.getTaskId(), retryCount);
|
||||
|
||||
logger.info("外部API返回: taskId={}, status={}, resultUrl={}",
|
||||
task.getTaskId(), status, resultUrl);
|
||||
String url = dynamicApiConfig.getApiBaseUrl() + "/v2/videos/generations/" + task.getExternalTaskId();
|
||||
HttpResponse<String> response = Unirest.get(url)
|
||||
.header("Authorization", "Bearer " + dynamicApiConfig.getApiKey())
|
||||
.asString();
|
||||
|
||||
if ("SUCCESS".equalsIgnoreCase(status) && resultUrl != null && !resultUrl.isEmpty()) {
|
||||
// 任务已完成,标记成功并同步更新所有表
|
||||
task.markAsCompleted(resultUrl);
|
||||
taskStatusRepository.save(task);
|
||||
logger.info("任务 {} 恢复为已完成状态,resultUrl={}", task.getTaskId(), resultUrl);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskCompletionByTaskId(task.getTaskId(), resultUrl);
|
||||
} else if ("FAILED".equalsIgnoreCase(status) || "FAILURE".equalsIgnoreCase(status) || "ERROR".equalsIgnoreCase(status)) {
|
||||
// 任务失败
|
||||
String failReason = responseJson.path("fail_reason").asText("外部API返回失败");
|
||||
task.markAsFailed(failReason);
|
||||
taskStatusRepository.save(task);
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(task.getTaskId())) {
|
||||
logger.warn("任务 {} 恢复为失败状态,原因: {}", task.getTaskId(), failReason);
|
||||
}
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), failReason);
|
||||
} else {
|
||||
// 任务仍在处理中,检查是否超时
|
||||
if (isTaskTimeout(task)) {
|
||||
if (response.getStatus() == 200) {
|
||||
apiQuerySuccess = true;
|
||||
JsonNode responseJson = objectMapper.readTree(response.getBody());
|
||||
String status = responseJson.path("status").asText();
|
||||
String resultUrl = responseJson.path("data").path("output").asText();
|
||||
|
||||
logger.info("外部API返回: taskId={}, status={}, resultUrl={}",
|
||||
task.getTaskId(), status, resultUrl);
|
||||
|
||||
if ("SUCCESS".equalsIgnoreCase(status) && resultUrl != null && !resultUrl.isEmpty()) {
|
||||
// 任务已完成,标记成功并同步更新所有表
|
||||
task.markAsCompleted(resultUrl);
|
||||
taskStatusRepository.save(task);
|
||||
logger.info("任务 {} 恢复为已完成状态,resultUrl={}", task.getTaskId(), resultUrl);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskCompletionByTaskId(task.getTaskId(), resultUrl);
|
||||
} else if ("FAILED".equalsIgnoreCase(status) || "FAILURE".equalsIgnoreCase(status) || "ERROR".equalsIgnoreCase(status)) {
|
||||
// 任务失败
|
||||
String failReason = responseJson.path("fail_reason").asText("外部API返回失败");
|
||||
task.markAsFailed(failReason);
|
||||
taskStatusRepository.save(task);
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(task.getTaskId())) {
|
||||
logger.warn("任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
logger.warn("任务 {} 恢复为失败状态,原因: {}", task.getTaskId(), failReason);
|
||||
}
|
||||
task.markAsFailed("任务超时");
|
||||
taskStatusRepository.save(task);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时");
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), failReason);
|
||||
} else {
|
||||
logger.info("任务 {} 仍在处理中且未超时,保持现状", task.getTaskId());
|
||||
// 任务仍在处理中,检查是否超时
|
||||
if (isTaskTimeout(task)) {
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(task.getTaskId())) {
|
||||
logger.warn("任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
}
|
||||
task.markAsFailed("任务超时");
|
||||
taskStatusRepository.save(task);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时");
|
||||
} else {
|
||||
logger.info("任务 {} 仍在处理中且未超时,保持现状", task.getTaskId());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastError = "HTTP状态码: " + response.getStatus();
|
||||
logger.warn("查询任务状态失败(第{}次): taskId={}, status={}", retryCount, task.getTaskId(), response.getStatus());
|
||||
}
|
||||
} else {
|
||||
logger.warn("查询任务状态失败: taskId={}, status={}", task.getTaskId(), response.getStatus());
|
||||
// 检查是否超时
|
||||
if (isTaskTimeout(task)) {
|
||||
task.markAsFailed("任务超时:无法获取外部状态");
|
||||
taskStatusRepository.save(task);
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时:无法获取外部状态");
|
||||
} catch (Exception e) {
|
||||
lastError = e.getMessage();
|
||||
logger.warn("查询外部API失败(第{}次): taskId={}, error={}", retryCount, task.getTaskId(), e.getMessage());
|
||||
}
|
||||
|
||||
// 如果还需要重试,等待1秒
|
||||
if (!apiQuerySuccess && retryCount < maxRetries) {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("查询外部API失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
|
||||
}
|
||||
|
||||
// 如果3次查询都失败
|
||||
if (!apiQuerySuccess) {
|
||||
// 检查是否超时
|
||||
if (isTaskTimeout(task)) {
|
||||
task.markAsFailed("任务超时:查询外部API异常");
|
||||
logger.warn("任务 {} 连续{}次查询外部API失败且已超时,标记为失败(最后错误: {})",
|
||||
task.getTaskId(), maxRetries, lastError);
|
||||
task.markAsFailed("任务超时:连续" + maxRetries + "次查询外部API失败");
|
||||
taskStatusRepository.save(task);
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时:查询外部API异常");
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时:连续" + maxRetries + "次查询外部API失败");
|
||||
} else {
|
||||
// 未超时,保持现状让后续轮询继续处理
|
||||
logger.warn("任务 {} 连续{}次查询外部API失败,但未超时,保持现状继续轮询(最后错误: {})",
|
||||
task.getTaskId(), maxRetries, lastError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,9 +304,17 @@ public class TaskStatusPollingService {
|
||||
boolean isVideoTask = taskId != null && (taskId.startsWith("txt2vid_") || taskId.startsWith("img2vid_"));
|
||||
|
||||
if (isVideoTask) {
|
||||
// 增加时间窗口保护:只有任务创建超过5分钟后 externalTaskId 仍为空才标记为失败
|
||||
// 避免任务刚创建还在等待提交到外部API时就被错误地标记为失败
|
||||
java.time.LocalDateTime fiveMinutesAgo = java.time.LocalDateTime.now().minusMinutes(5);
|
||||
if (task.getCreatedAt() != null && task.getCreatedAt().isAfter(fiveMinutesAgo)) {
|
||||
logger.debug("任务 {} 的 externalTaskId 为空,但创建时间未超过5分钟,跳过本次轮询", taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(taskId)) {
|
||||
logger.warn("视频任务 {} 的 externalTaskId 为空,标记为失败并返还积分", taskId);
|
||||
logger.warn("视频任务 {} 的 externalTaskId 为空且已超过5分钟,标记为失败并返还积分", taskId);
|
||||
}
|
||||
String errorMessage = "任务提交失败:未能成功提交到外部API,请检查网络或稍后重试";
|
||||
markTaskFailed(taskId, errorMessage);
|
||||
|
||||
@@ -76,7 +76,7 @@ public class TextToVideoService {
|
||||
// 创建任务
|
||||
TextToVideoTask task = new TextToVideoTask(username, prompt.trim(), aspectRatio, duration, hdMode);
|
||||
task.setTaskId(taskId);
|
||||
task.setStatus(TextToVideoTask.TaskStatus.PENDING);
|
||||
task.setStatus(TextToVideoTask.TaskStatus.PROCESSING); // 直接设为生成中
|
||||
task.setProgress(0);
|
||||
|
||||
// 保存任务
|
||||
@@ -595,7 +595,7 @@ public class TextToVideoService {
|
||||
*/
|
||||
@Transactional
|
||||
public TextToVideoTask retryTask(String taskId, String username) {
|
||||
logger.info("重试失败任务: taskId={}, username={}", taskId, username);
|
||||
logger.info("重试任务: taskId={}, username={}", taskId, username);
|
||||
|
||||
// 获取任务
|
||||
TextToVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
@@ -606,17 +606,26 @@ public class TextToVideoService {
|
||||
throw new RuntimeException("无权操作此任务");
|
||||
}
|
||||
|
||||
// 验证任务状态必须是 FAILED
|
||||
if (task.getStatus() != TextToVideoTask.TaskStatus.FAILED) {
|
||||
throw new RuntimeException("只能重试失败的任务,当前状态: " + task.getStatus());
|
||||
// 允许重试:失败的任务、完成的任务、或者正在处理中的任务(可能卡住了)
|
||||
boolean canRetry = task.getStatus() == TextToVideoTask.TaskStatus.FAILED ||
|
||||
task.getStatus() == TextToVideoTask.TaskStatus.COMPLETED ||
|
||||
task.getStatus() == TextToVideoTask.TaskStatus.PROCESSING;
|
||||
|
||||
if (!canRetry) {
|
||||
throw new RuntimeException("无法重试此任务,当前状态: " + task.getStatus());
|
||||
}
|
||||
|
||||
// 重置任务状态为 PENDING
|
||||
task.setStatus(TextToVideoTask.TaskStatus.PENDING);
|
||||
logger.info("允许重试任务: taskId={}, 原状态={}, 原结果URL={}",
|
||||
taskId, task.getStatus(), task.getResultUrl());
|
||||
|
||||
// 重置任务状态为 PROCESSING(直接生成中)
|
||||
task.setStatus(TextToVideoTask.TaskStatus.PROCESSING);
|
||||
task.setErrorMessage(null);
|
||||
task.setRealTaskId(null); // 清除旧的外部任务ID
|
||||
task.setResultUrl(null); // 清除旧的结果URL
|
||||
task.setProgress(0);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setCompletedAt(null);
|
||||
taskRepository.save(task);
|
||||
|
||||
// 重新添加到任务队列
|
||||
|
||||
@@ -20,9 +20,11 @@ import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.repository.PointsFreezeRecordRepository;
|
||||
import com.example.demo.repository.UserRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import com.example.demo.util.UserIdGenerator;
|
||||
import com.example.demo.model.UserMembership;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
@@ -36,13 +38,15 @@ public class UserService {
|
||||
private final com.example.demo.repository.PaymentRepository paymentRepository;
|
||||
private final CacheManager cacheManager;
|
||||
private final MembershipLevelRepository membershipLevelRepository;
|
||||
private final UserMembershipRepository userMembershipRepository;
|
||||
|
||||
public UserService(UserRepository userRepository, @Lazy PasswordEncoder passwordEncoder,
|
||||
PointsFreezeRecordRepository pointsFreezeRecordRepository,
|
||||
com.example.demo.repository.OrderRepository orderRepository,
|
||||
com.example.demo.repository.PaymentRepository paymentRepository,
|
||||
CacheManager cacheManager,
|
||||
MembershipLevelRepository membershipLevelRepository) {
|
||||
MembershipLevelRepository membershipLevelRepository,
|
||||
UserMembershipRepository userMembershipRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.pointsFreezeRecordRepository = pointsFreezeRecordRepository;
|
||||
@@ -50,6 +54,7 @@ public class UserService {
|
||||
this.paymentRepository = paymentRepository;
|
||||
this.cacheManager = cacheManager;
|
||||
this.membershipLevelRepository = membershipLevelRepository;
|
||||
this.userMembershipRepository = userMembershipRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -76,7 +81,41 @@ public class UserService {
|
||||
user.setPasswordHash(passwordEncoder.encode(rawPassword));
|
||||
// 注册时默认为普通用户
|
||||
user.setRole("ROLE_USER");
|
||||
return userRepository.save(user);
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
// 自动创建默认会员记录(标准会员,到期时间为1年后)
|
||||
createDefaultMembership(savedUser);
|
||||
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为新用户创建默认会员记录
|
||||
*/
|
||||
private void createDefaultMembership(User user) {
|
||||
try {
|
||||
// 查找免费会员等级
|
||||
Optional<MembershipLevel> freeLevel = membershipLevelRepository.findByName("free");
|
||||
if (freeLevel.isEmpty()) {
|
||||
logger.warn("未找到免费会员等级(free),跳过创建会员记录");
|
||||
return;
|
||||
}
|
||||
|
||||
UserMembership membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setMembershipLevelId(freeLevel.get().getId());
|
||||
membership.setStartDate(LocalDateTime.now());
|
||||
membership.setEndDate(LocalDateTime.of(2099, 12, 31, 23, 59, 59)); // 免费会员永久有效
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setAutoRenew(false);
|
||||
membership.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
userMembershipRepository.save(membership);
|
||||
logger.info("✅ 为新用户创建默认会员记录: userId={}, level=免费会员(永久有效)", user.getId());
|
||||
} catch (Exception e) {
|
||||
logger.error("创建默认会员记录失败: userId={}", user.getId(), e);
|
||||
// 不抛出异常,允许用户注册成功
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -303,6 +342,13 @@ public class UserService {
|
||||
*/
|
||||
@Transactional
|
||||
public PointsFreezeRecord freezePoints(String username, String taskId, PointsFreezeRecord.TaskType taskType, Integer points, String reason) {
|
||||
// 检查是否已经存在相同 taskId 的冻结记录(防止重复冻结)
|
||||
Optional<PointsFreezeRecord> existingRecord = pointsFreezeRecordRepository.findByTaskId(taskId);
|
||||
if (existingRecord.isPresent()) {
|
||||
logger.info("积分已冻结,跳过重复冻结: taskId={}, status={}", taskId, existingRecord.get().getStatus());
|
||||
return existingRecord.get();
|
||||
}
|
||||
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||||
|
||||
@@ -754,7 +800,8 @@ public class UserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理过期会员:将状态改为EXPIRED,清零积分
|
||||
* 处理过期会员:付费会员过期后降级为免费会员,清零积分
|
||||
* 免费会员不会过期(到期时间设为2099年)
|
||||
* @return 处理的过期会员数量
|
||||
*/
|
||||
@Transactional
|
||||
@@ -763,11 +810,34 @@ public class UserService {
|
||||
java.util.List<com.example.demo.model.UserMembership> expiredMemberships =
|
||||
userMembershipRepository.findByStatusAndEndDateBefore("ACTIVE", now);
|
||||
|
||||
// 查找免费会员等级
|
||||
Optional<MembershipLevel> freeLevelOpt = membershipLevelRepository.findByName("free");
|
||||
if (freeLevelOpt.isEmpty()) {
|
||||
logger.error("未找到免费会员等级(free),无法处理过期会员");
|
||||
return 0;
|
||||
}
|
||||
Long freeLevelId = freeLevelOpt.get().getId();
|
||||
|
||||
// 永久有效的到期时间
|
||||
LocalDateTime permanentEndDate = LocalDateTime.of(2099, 12, 31, 23, 59, 59);
|
||||
|
||||
int processedCount = 0;
|
||||
for (com.example.demo.model.UserMembership membership : expiredMemberships) {
|
||||
try {
|
||||
// 更新会员状态为EXPIRED
|
||||
membership.setStatus("EXPIRED");
|
||||
// 跳过免费会员(免费会员不应该过期,但以防万一)
|
||||
if (membership.getMembershipLevelId().equals(freeLevelId)) {
|
||||
// 免费会员如果意外过期,直接延长到2099年
|
||||
membership.setEndDate(permanentEndDate);
|
||||
membership.setUpdatedAt(now);
|
||||
userMembershipRepository.save(membership);
|
||||
logger.info("✅ 免费会员到期时间已延长: userId={}", membership.getUserId());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 付费会员过期:降级为免费会员
|
||||
membership.setMembershipLevelId(freeLevelId);
|
||||
membership.setEndDate(permanentEndDate); // 免费会员永久有效
|
||||
membership.setStatus("ACTIVE"); // 保持ACTIVE状态
|
||||
membership.setUpdatedAt(now);
|
||||
userMembershipRepository.save(membership);
|
||||
|
||||
@@ -778,20 +848,22 @@ public class UserService {
|
||||
int oldPoints = user.getPoints();
|
||||
if (oldPoints > 0) {
|
||||
user.setPoints(0);
|
||||
user.setFrozenPoints(0); // 同时清零冻结积分
|
||||
userRepository.save(user);
|
||||
logger.info("✅ 会员过期积分清零: userId={}, 原积分={}", membership.getUserId(), oldPoints);
|
||||
logger.info("✅ 付费会员过期积分清零: userId={}, 原积分={}", membership.getUserId(), oldPoints);
|
||||
}
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
logger.info("✅ 处理过期会员: userId={}, endDate={}", membership.getUserId(), membership.getEndDate());
|
||||
logger.info("✅ 付费会员过期已降级为免费会员: userId={}, 原到期时间={}",
|
||||
membership.getUserId(), membership.getEndDate());
|
||||
} catch (Exception e) {
|
||||
logger.error("处理过期会员失败: userId={}", membership.getUserId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
if (processedCount > 0) {
|
||||
logger.info("✅ 共处理 {} 个过期会员", processedCount);
|
||||
logger.info("✅ 共处理 {} 个过期付费会员,已降级为免费会员", processedCount);
|
||||
}
|
||||
|
||||
return processedCount;
|
||||
|
||||
@@ -283,7 +283,12 @@ public class UserWorkService {
|
||||
work.setWorkType(UserWork.WorkType.STORYBOARD_VIDEO);
|
||||
work.setTitle(generateTitle(task.getPrompt()));
|
||||
work.setDescription("分镜视频作品");
|
||||
work.setPrompt(task.getPrompt());
|
||||
// 优先使用 videoPrompt,如果没有则使用原始 prompt
|
||||
String displayPrompt = task.getVideoPrompt();
|
||||
if (displayPrompt == null || displayPrompt.trim().isEmpty()) {
|
||||
displayPrompt = task.getPrompt();
|
||||
}
|
||||
work.setPrompt(displayPrompt);
|
||||
work.setResultUrl(resultUrl);
|
||||
work.setThumbnailUrl(task.getResultUrl()); // 保存分镜图作为缩略图
|
||||
work.setImagePrompt(task.getImagePrompt()); // 保存优化后的分镜图提示词
|
||||
@@ -442,6 +447,24 @@ public class UserWorkService {
|
||||
return userWorkRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型获取用户作品列表(用于创作页面历史记录)
|
||||
* @param username 用户名
|
||||
* @param workType 作品类型
|
||||
* @param includeProcessing 是否包含正在处理中的作品
|
||||
* @param page 页码
|
||||
* @param size 每页数量
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Page<UserWork> getUserWorksByType(String username, UserWork.WorkType workType, boolean includeProcessing, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
if (includeProcessing) {
|
||||
return userWorkRepository.findByUsernameAndWorkTypeOrderByCreatedAtDesc(username, workType, pageable);
|
||||
} else {
|
||||
return userWorkRepository.findByUsernameAndWorkTypeAndStatusOrderByCreatedAtDesc(username, workType, UserWork.WorkStatus.COMPLETED, pageable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户正在进行中的作品(包括PROCESSING和PENDING状态)
|
||||
*/
|
||||
|
||||
@@ -62,3 +62,12 @@ tencent.ses.region=ap-hongkong
|
||||
tencent.ses.secret-id=AKIDoaEjFbqxxqZAcv8EE6oZCg2IQPG1fCxm
|
||||
tencent.ses.secret-key=nR83I79FOSpGcqNo7JXkqnU8g7SjsxuG
|
||||
tencent.ses.template-id=154360
|
||||
|
||||
|
||||
# 噜噜支付(彩虹易支付)配置
|
||||
lulupay.api-url=https://api.dulupay.com/
|
||||
lulupay.pid=1516
|
||||
lulupay.merchant-key=lqzsNGKPxNssG1Dxa19GgPsPSpxGKXg9
|
||||
lulupay.platform-public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsNrpsDeXhrPC+h3Y9FEUilZjhMI9MPOEcFvJgy4PGCEMoxCpQHps3F2sGf7MBuz4zbTYDY1tMFIQHDWRnE5I+JLttA3k5GXcXMysbcbnXcXrlMQ4h5yJOs8n73Q+K9vYLgBxjp+7MWzcwAANyZoE2mdw3ihRs8Rwz3ErcCjVdFePwLNQ2K9YXREOo/+QDh6wDyq3u7pU62Ja3dAWfEwj1LCibhRqGcQui7PBseokieL6X3++nKgrzI+C5uxgUlAr7uRwDqBWUEY0zveQMVT8o37zKH0BWumW7H5iZFAZ/OAYjH8zljR6AxFATtg2GdVRUsyhr2IBqBfXVN1Pmpjv9wIDAQAB
|
||||
lulupay.notify-url=https://vionow.com/api/payments/lulupay/notify
|
||||
lulupay.return-url=https://vionow.com/api/payments/lulupay/return
|
||||
|
||||
@@ -104,8 +104,14 @@ spring.servlet.multipart.max-file-size=500MB
|
||||
spring.servlet.multipart.max-request-size=600MB
|
||||
|
||||
# 生产环境日志配置
|
||||
# 日志文件路径 - 使用绝对路径确保日志目录正确
|
||||
logging.file.path=/www/wwwroot/logs
|
||||
logging.level.root=INFO
|
||||
logging.level.com.example.demo=INFO
|
||||
logging.level.com.example.demo.service.OrderService=DEBUG
|
||||
logging.level.com.example.demo.service.PaymentService=DEBUG
|
||||
logging.level.com.example.demo.service.LuluPayService=DEBUG
|
||||
logging.level.com.example.demo.controller.LuluPayCallbackController=DEBUG
|
||||
logging.level.com.example.demo.scheduler=WARN
|
||||
logging.level.com.example.demo.scheduler.OrderScheduler=WARN
|
||||
logging.level.org.springframework.security=WARN
|
||||
@@ -115,7 +121,8 @@ logging.level.org.hibernate.SQL=WARN
|
||||
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
|
||||
logging.level.org.hibernate.orm.jdbc.bind=WARN
|
||||
logging.file.name=${LOG_FILE_PATH:./logs/application.log}
|
||||
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
|
||||
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
|
||||
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
|
||||
|
||||
# 视频处理配置
|
||||
# 临时文件目录(相对路径:基于应用运行目录,或绝对路径)
|
||||
@@ -170,3 +177,16 @@ tencent.cos.prefix=aigc
|
||||
|
||||
|
||||
|
||||
|
||||
# ============================================
|
||||
# 噜噜支付(彩虹易支付)配置 (生产环境)
|
||||
# ============================================
|
||||
lulupay.api-url=https://api.dulupay.com/
|
||||
lulupay.pid=1516
|
||||
lulupay.merchant-key=lqzsNGKPxNssG1Dxa19GgPsPSpxGKXg9
|
||||
lulupay.platform-public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsNrpsDeXhrPC+h3Y9FEUilZjhMI9MPOEcFvJgy4PGCEMoxCpQHps3F2sGf7MBuz4zbTYDY1tMFIQHDWRnE5I+JLttA3k5GXcXMysbcbnXcXrlMQ4h5yJOs8n73Q+K9vYLgBxjp+7MWzcwAANyZoE2mdw3ihRs8Rwz3ErcCjVdFePwLNQ2K9YXREOo/+QDh6wDyq3u7pU62Ja3dAWfEwj1LCibhRqGcQui7PBseokieL6X3++nKgrzI+C5uxgUlAr7uRwDqBWUEY0zveQMVT8o37zKH0BWumW7H5iZFAZ/OAYjH8zljR6AxFATtg2GdVRUsyhr2IBqBfXVN1Pmpjv9wIDAQAB
|
||||
lulupay.notify-url=https://www.vionow.com/api/payments/lulupay/notify
|
||||
lulupay.return-url=https://www.vionow.com/api/payments/lulupay/return
|
||||
|
||||
# 前端URL配置(支付成功后跳转)
|
||||
app.frontend-url=https://www.vionow.com
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-- ============================================
|
||||
-- 超级管理员权限自动设置
|
||||
-- ============================================
|
||||
-- 应用启动时自动将 984523799@qq.com 设置为超级管理员
|
||||
-- 应用启动时自动将 shanghairuiyi2026@163.com 设置为超级管理员
|
||||
-- 如果该用户存在,则更新其角色为超级管理员
|
||||
UPDATE users
|
||||
SET role = 'ROLE_SUPER_ADMIN',
|
||||
|
||||
74
demo/src/main/resources/logback-spring.xml
Normal file
74
demo/src/main/resources/logback-spring.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<!-- 日志目录,从环境变量或属性读取,默认为当前目录下的logs -->
|
||||
<springProperty scope="context" name="LOG_PATH" source="logging.file.path" defaultValue="./logs"/>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/application.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/application.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/error.log</file>
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>ERROR</level>
|
||||
</filter>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/error.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="PAYMENT_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/payment.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/payment.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="com.example.demo.service.OrderService" level="DEBUG" additivity="true">
|
||||
<appender-ref ref="PAYMENT_FILE"/>
|
||||
</logger>
|
||||
<logger name="com.example.demo.service.PaymentService" level="DEBUG" additivity="true">
|
||||
<appender-ref ref="PAYMENT_FILE"/>
|
||||
</logger>
|
||||
<logger name="com.example.demo.service.LuluPayService" level="DEBUG" additivity="true">
|
||||
<appender-ref ref="PAYMENT_FILE"/>
|
||||
</logger>
|
||||
<logger name="com.example.demo.controller.LuluPayCallbackController" level="DEBUG" additivity="true">
|
||||
<appender-ref ref="PAYMENT_FILE"/>
|
||||
</logger>
|
||||
|
||||
<logger name="com.example.demo" level="INFO"/>
|
||||
<logger name="org.hibernate.SQL" level="WARN"/>
|
||||
<logger name="org.springframework.security" level="WARN"/>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FILE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
76
demo/src/test/java/com/example/demo/LuluPaySignTest.java
Normal file
76
demo/src/test/java/com/example/demo/LuluPaySignTest.java
Normal file
@@ -0,0 +1,76 @@
|
||||
package com.example.demo;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* 噜噜支付签名测试
|
||||
*/
|
||||
public class LuluPaySignTest {
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
// 商户密钥
|
||||
String merchantKey = "lqzsNGKPxNssG1Dxa19GgPsPSpxGKXg9";
|
||||
|
||||
// 模拟请求参数(不含sign和sign_type)
|
||||
Map<String, String> params = new TreeMap<>();
|
||||
params.put("pid", "1516");
|
||||
params.put("type", "alipay");
|
||||
params.put("out_trade_no", "TEST" + System.currentTimeMillis());
|
||||
params.put("notify_url", "https://www.vionow.com/api/payments/lulupay/notify");
|
||||
params.put("return_url", "https://www.vionow.com/api/payments/lulupay/return");
|
||||
params.put("name", "测试商品");
|
||||
params.put("money", "0.01");
|
||||
params.put("clientip", "127.0.0.1");
|
||||
params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
|
||||
|
||||
// 生成待签名字符串
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : params.entrySet()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append("&");
|
||||
}
|
||||
sb.append(entry.getKey()).append("=").append(entry.getValue());
|
||||
}
|
||||
String signContent = sb.toString();
|
||||
|
||||
System.out.println("=== 噜噜支付签名测试 ===");
|
||||
System.out.println("待签名字符串: " + signContent);
|
||||
System.out.println("待签名字符串+密钥: " + signContent + merchantKey);
|
||||
|
||||
// MD5签名
|
||||
String sign = md5(signContent + merchantKey);
|
||||
System.out.println("MD5签名结果: " + sign);
|
||||
|
||||
// 完整请求参数
|
||||
System.out.println("\n=== 完整请求参数 ===");
|
||||
params.put("sign", sign);
|
||||
params.put("sign_type", "MD5");
|
||||
for (Map.Entry<String, String> entry : params.entrySet()) {
|
||||
System.out.println(entry.getKey() + "=" + entry.getValue());
|
||||
}
|
||||
|
||||
// 生成测试URL
|
||||
StringBuilder url = new StringBuilder("https://api.dulupay.com/api/pay/create?");
|
||||
for (Map.Entry<String, String> entry : params.entrySet()) {
|
||||
if (url.charAt(url.length() - 1) != '?') {
|
||||
url.append("&");
|
||||
}
|
||||
url.append(entry.getKey()).append("=").append(java.net.URLEncoder.encode(entry.getValue(), "UTF-8"));
|
||||
}
|
||||
System.out.println("\n=== 测试URL ===");
|
||||
System.out.println(url.toString());
|
||||
}
|
||||
|
||||
private static String md5(String str) throws Exception {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(str.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user