feat: 添加用户错误日志功能, 禁用Redis缓存, userId自动生成5位随机字符
This commit is contained in:
55
demo/database_migration_user_error_log.sql
Normal file
55
demo/database_migration_user_error_log.sql
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- 用户错误日志表
|
||||||
|
-- 用于记录和统计用户操作过程中产生的错误
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_error_log (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(100) COMMENT '用户名(可为空,未登录用户)',
|
||||||
|
error_type VARCHAR(50) NOT NULL COMMENT '错误类型枚举',
|
||||||
|
error_code VARCHAR(50) COMMENT '错误代码',
|
||||||
|
error_message TEXT COMMENT '错误消息',
|
||||||
|
error_source VARCHAR(100) NOT NULL COMMENT '错误来源(服务类名或接口路径)',
|
||||||
|
task_id VARCHAR(100) COMMENT '关联的任务ID',
|
||||||
|
task_type VARCHAR(50) COMMENT '任务类型',
|
||||||
|
request_path VARCHAR(500) COMMENT '请求路径',
|
||||||
|
request_method VARCHAR(10) COMMENT '请求方法 GET/POST等',
|
||||||
|
request_params TEXT COMMENT '请求参数(JSON格式)',
|
||||||
|
stack_trace TEXT COMMENT '堆栈跟踪',
|
||||||
|
ip_address VARCHAR(50) COMMENT 'IP地址',
|
||||||
|
user_agent VARCHAR(500) COMMENT '用户代理',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
|
||||||
|
INDEX idx_username (username),
|
||||||
|
INDEX idx_error_type (error_type),
|
||||||
|
INDEX idx_created_at (created_at),
|
||||||
|
INDEX idx_error_source (error_source),
|
||||||
|
INDEX idx_task_id (task_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户错误日志表';
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- 错误类型说明:
|
||||||
|
-- TASK_SUBMIT_ERROR - 任务提交失败
|
||||||
|
-- TASK_PROCESSING_ERROR - 任务处理失败
|
||||||
|
-- TASK_TIMEOUT - 任务超时
|
||||||
|
-- TASK_CANCELLED - 任务取消
|
||||||
|
-- API_CALL_ERROR - API调用失败
|
||||||
|
-- API_RESPONSE_ERROR - API响应异常
|
||||||
|
-- API_TIMEOUT - API超时
|
||||||
|
-- PAYMENT_ERROR - 支付失败
|
||||||
|
-- PAYMENT_CALLBACK_ERROR- 支付回调异常
|
||||||
|
-- REFUND_ERROR - 退款失败
|
||||||
|
-- AUTH_ERROR - 认证失败
|
||||||
|
-- TOKEN_EXPIRED - Token过期
|
||||||
|
-- PERMISSION_DENIED - 权限不足
|
||||||
|
-- DATA_VALIDATION_ERROR - 数据验证失败
|
||||||
|
-- DATA_NOT_FOUND - 数据未找到
|
||||||
|
-- DATA_CONFLICT - 数据冲突
|
||||||
|
-- FILE_UPLOAD_ERROR - 文件上传失败
|
||||||
|
-- FILE_DOWNLOAD_ERROR - 文件下载失败
|
||||||
|
-- FILE_PROCESS_ERROR - 文件处理失败
|
||||||
|
-- SYSTEM_ERROR - 系统错误
|
||||||
|
-- DATABASE_ERROR - 数据库错误
|
||||||
|
-- NETWORK_ERROR - 网络错误
|
||||||
|
-- UNKNOWN - 未知错误
|
||||||
|
-- =====================================================
|
||||||
@@ -60,6 +60,8 @@ BEGIN
|
|||||||
UPDATE text_to_video_tasks
|
UPDATE text_to_video_tasks
|
||||||
SET status = CASE WHEN NEW.status = 'TIMEOUT' THEN 'FAILED' ELSE NEW.status END,
|
SET status = CASE WHEN NEW.status = 'TIMEOUT' THEN 'FAILED' ELSE NEW.status END,
|
||||||
updated_at = NOW(),
|
updated_at = NOW(),
|
||||||
|
progress = CASE WHEN NEW.status = 'COMPLETED' THEN 100 ELSE progress END,
|
||||||
|
completed_at = CASE WHEN NEW.status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMEOUT') THEN NOW() ELSE completed_at END,
|
||||||
error_message = CASE WHEN NEW.status IN ('FAILED', 'TIMEOUT') THEN COALESCE(NEW.error_message, '任务超时') ELSE error_message END,
|
error_message = CASE WHEN NEW.status IN ('FAILED', 'TIMEOUT') THEN COALESCE(NEW.error_message, '任务超时') ELSE error_message END,
|
||||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||||
WHERE task_id = NEW.task_id;
|
WHERE task_id = NEW.task_id;
|
||||||
@@ -70,6 +72,8 @@ BEGIN
|
|||||||
UPDATE image_to_video_tasks
|
UPDATE image_to_video_tasks
|
||||||
SET status = CASE WHEN NEW.status = 'TIMEOUT' THEN 'FAILED' ELSE NEW.status END,
|
SET status = CASE WHEN NEW.status = 'TIMEOUT' THEN 'FAILED' ELSE NEW.status END,
|
||||||
updated_at = NOW(),
|
updated_at = NOW(),
|
||||||
|
progress = CASE WHEN NEW.status = 'COMPLETED' THEN 100 ELSE progress END,
|
||||||
|
completed_at = CASE WHEN NEW.status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMEOUT') THEN NOW() ELSE completed_at END,
|
||||||
error_message = CASE WHEN NEW.status IN ('FAILED', 'TIMEOUT') THEN COALESCE(NEW.error_message, '任务超时') ELSE error_message END,
|
error_message = CASE WHEN NEW.status IN ('FAILED', 'TIMEOUT') THEN COALESCE(NEW.error_message, '任务超时') ELSE error_message END,
|
||||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||||
WHERE task_id = NEW.task_id;
|
WHERE task_id = NEW.task_id;
|
||||||
@@ -80,6 +84,8 @@ BEGIN
|
|||||||
UPDATE storyboard_video_tasks
|
UPDATE storyboard_video_tasks
|
||||||
SET status = CASE WHEN NEW.status = 'TIMEOUT' THEN 'FAILED' ELSE NEW.status END,
|
SET status = CASE WHEN NEW.status = 'TIMEOUT' THEN 'FAILED' ELSE NEW.status END,
|
||||||
updated_at = NOW(),
|
updated_at = NOW(),
|
||||||
|
progress = CASE WHEN NEW.status = 'COMPLETED' THEN 100 ELSE progress END,
|
||||||
|
completed_at = CASE WHEN NEW.status IN ('COMPLETED', 'FAILED', 'CANCELLED', 'TIMEOUT') THEN NOW() ELSE completed_at END,
|
||||||
error_message = CASE WHEN NEW.status IN ('FAILED', 'TIMEOUT') THEN COALESCE(NEW.error_message, '任务超时') ELSE error_message END,
|
error_message = CASE WHEN NEW.status IN ('FAILED', 'TIMEOUT') THEN COALESCE(NEW.error_message, '任务超时') ELSE error_message END,
|
||||||
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
|
||||||
WHERE task_id = NEW.task_id;
|
WHERE task_id = NEW.task_id;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ api.interceptors.request.use(
|
|||||||
|
|
||||||
if (!isLoginRequest) {
|
if (!isLoginRequest) {
|
||||||
// 非登录请求才添加Authorization头
|
// 非登录请求才添加Authorization头
|
||||||
const token = sessionStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (token && token !== 'null' && token.trim() !== '') {
|
if (token && token !== 'null' && token.trim() !== '') {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
console.log('请求拦截器:添加Authorization头,token长度:', token.length)
|
console.log('请求拦截器:添加Authorization头,token长度:', token.length)
|
||||||
@@ -70,8 +70,8 @@ api.interceptors.response.use(
|
|||||||
|
|
||||||
if (!isLoginRequest) {
|
if (!isLoginRequest) {
|
||||||
// 清除无效的token并跳转到登录页
|
// 清除无效的token并跳转到登录页
|
||||||
sessionStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
sessionStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
// 避免重复跳转
|
// 避免重复跳转
|
||||||
if (router.currentRoute.value.path !== '/login') {
|
if (router.currentRoute.value.path !== '/login') {
|
||||||
ElMessage.error('认证失败,请重新登录')
|
ElMessage.error('认证失败,请重新登录')
|
||||||
@@ -91,8 +91,8 @@ api.interceptors.response.use(
|
|||||||
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
|
const isLoginRequest = loginUrls.some(url => response.config.url.includes(url))
|
||||||
|
|
||||||
if (!isLoginRequest) {
|
if (!isLoginRequest) {
|
||||||
sessionStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
sessionStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
if (router.currentRoute.value.path !== '/login') {
|
if (router.currentRoute.value.path !== '/login') {
|
||||||
ElMessage.error('认证失败,请重新登录')
|
ElMessage.error('认证失败,请重新登录')
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -117,8 +117,8 @@ api.interceptors.response.use(
|
|||||||
const isLoginRequest = loginUrls.some(url => error.config.url.includes(url))
|
const isLoginRequest = loginUrls.some(url => error.config.url.includes(url))
|
||||||
|
|
||||||
if (!isLoginRequest) {
|
if (!isLoginRequest) {
|
||||||
sessionStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
sessionStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
if (router.currentRoute.value.path !== '/login') {
|
if (router.currentRoute.value.path !== '/login') {
|
||||||
ElMessage.error('认证失败,请重新登录')
|
ElMessage.error('认证失败,请重新登录')
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -136,8 +136,8 @@ api.interceptors.response.use(
|
|||||||
|
|
||||||
if (!isLoginRequest) {
|
if (!isLoginRequest) {
|
||||||
// 302也可能是认证失败导致的
|
// 302也可能是认证失败导致的
|
||||||
sessionStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
sessionStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
if (router.currentRoute.value.path !== '/login') {
|
if (router.currentRoute.value.path !== '/login') {
|
||||||
ElMessage.error('认证失败,请重新登录')
|
ElMessage.error('认证失败,请重新登录')
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
|
|||||||
@@ -38,3 +38,11 @@ export const startVideoGeneration = async (taskId, params = {}) => {
|
|||||||
export const mergeImagesToGrid = async (images, cols = 3) => {
|
export const mergeImagesToGrid = async (images, cols = 3) => {
|
||||||
return api.post('/image-grid/merge', { images, cols })
|
return api.post('/image-grid/merge', { images, cols })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试失败的分镜视频任务
|
||||||
|
* @param {string} taskId - 任务ID
|
||||||
|
*/
|
||||||
|
export const retryStoryboardTask = async (taskId) => {
|
||||||
|
return api.post(`/storyboard-video/task/${taskId}/retry`)
|
||||||
|
}
|
||||||
@@ -310,13 +310,15 @@ export default {
|
|||||||
generateStoryboard: 'Generate Storyboard',
|
generateStoryboard: 'Generate Storyboard',
|
||||||
generateVideo: 'Generate Video',
|
generateVideo: 'Generate Video',
|
||||||
uploadStoryboard: 'Upload Storyboard (can generate video directly)',
|
uploadStoryboard: 'Upload Storyboard (can generate video directly)',
|
||||||
uploadHint: 'Upload 1-9 storyboard images, can generate video directly without text description',
|
uploadImage: 'Upload Image',
|
||||||
|
imageLabel: 'Image ',
|
||||||
|
uploadHint: 'Upload reference images, AI will generate 6-grid storyboard based on images',
|
||||||
addMore: 'Add More',
|
addMore: 'Add More',
|
||||||
uploadedCount: 'Uploaded {count}/9',
|
uploadedCount: 'Uploaded {count}/9',
|
||||||
uploadLimit: 'Limit reached',
|
uploadLimit: 'Limit reached',
|
||||||
uploadedImage: 'Uploaded image {index}',
|
uploadedImage: 'Uploaded image {index}',
|
||||||
maxImages: 'Maximum 9 images allowed',
|
maxImages: 'Maximum 3 images allowed',
|
||||||
maxImagesWarning: 'Maximum 9 images allowed, you have uploaded {current}, you can upload {remaining} more',
|
maxImagesWarning: 'Maximum 3 images allowed',
|
||||||
fileSizeLimit: 'Image file size cannot exceed 100MB',
|
fileSizeLimit: 'Image file size cannot exceed 100MB',
|
||||||
invalidFileType: 'Please select valid image files',
|
invalidFileType: 'Please select valid image files',
|
||||||
uploadSuccess: 'Successfully uploaded {count} images',
|
uploadSuccess: 'Successfully uploaded {count} images',
|
||||||
@@ -365,6 +367,7 @@ export default {
|
|||||||
generateVideoWithUpload: 'Generate Video with Uploaded Image',
|
generateVideoWithUpload: 'Generate Video with Uploaded Image',
|
||||||
startGenerateStoryboard: 'Start Generate Storyboard',
|
startGenerateStoryboard: 'Start Generate Storyboard',
|
||||||
startGenerate: 'Start Generate',
|
startGenerate: 'Start Generate',
|
||||||
|
generating: 'Generating...',
|
||||||
enterDescription: 'Please enter description',
|
enterDescription: 'Please enter description',
|
||||||
enterDescriptionForImage: 'Please enter description, AI will generate storyboard based on reference image and description',
|
enterDescriptionForImage: 'Please enter description, AI will generate storyboard based on reference image and description',
|
||||||
startingGenerate: 'Starting to generate storyboard...',
|
startingGenerate: 'Starting to generate storyboard...',
|
||||||
@@ -375,6 +378,7 @@ export default {
|
|||||||
storyboardCompleted: 'Storyboard generation completed! Please click "Start Generate" button to generate video',
|
storyboardCompleted: 'Storyboard generation completed! Please click "Start Generate" button to generate video',
|
||||||
videoCompleted: 'Video generation completed!',
|
videoCompleted: 'Video generation completed!',
|
||||||
taskFailed: 'Task failed',
|
taskFailed: 'Task failed',
|
||||||
|
checkInputOrRetry: 'Please check input or retry',
|
||||||
unknownError: 'Unknown error',
|
unknownError: 'Unknown error',
|
||||||
startingVideoGenerate: 'Starting to generate video...',
|
startingVideoGenerate: 'Starting to generate video...',
|
||||||
videoTaskStarted: 'Video generation task started, please wait...',
|
videoTaskStarted: 'Video generation task started, please wait...',
|
||||||
@@ -390,7 +394,17 @@ export default {
|
|||||||
taskCompleted: 'Task completed!',
|
taskCompleted: 'Task completed!',
|
||||||
resumingVideoTask: 'Detected unfinished video generation task, resuming...',
|
resumingVideoTask: 'Detected unfinished video generation task, resuming...',
|
||||||
resumingStoryboardTask: 'Detected unfinished storyboard generation task, resuming...',
|
resumingStoryboardTask: 'Detected unfinished storyboard generation task, resuming...',
|
||||||
resumingTask: 'Detected unfinished task, resuming...'
|
resumingTask: 'Detected unfinished task, resuming...',
|
||||||
|
storyboardReady: 'Storyboard generated, click button below to generate video',
|
||||||
|
regenerate: 'Regenerate',
|
||||||
|
regenerateConfirm: 'Regenerating will consume points and create a new task. Continue?',
|
||||||
|
regenerateTitle: 'Regenerate Storyboard',
|
||||||
|
statusPending: 'Pending',
|
||||||
|
statusProcessing: 'Processing',
|
||||||
|
statusCompleted: 'Completed',
|
||||||
|
statusFailed: 'Failed',
|
||||||
|
statusCancelled: 'Cancelled',
|
||||||
|
statusUnknown: 'Unknown Status'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -426,6 +440,7 @@ export default {
|
|||||||
popular: 'Popular',
|
popular: 'Popular',
|
||||||
searchPlaceholder: 'Name/Prompt/ID',
|
searchPlaceholder: 'Name/Prompt/ID',
|
||||||
selectItems: 'Select {count} items',
|
selectItems: 'Select {count} items',
|
||||||
|
selectAll: 'Select All',
|
||||||
selectedCount: '{count} selected',
|
selectedCount: '{count} selected',
|
||||||
favorite: 'Favorite',
|
favorite: 'Favorite',
|
||||||
downloadWithWatermark: 'Download with Watermark',
|
downloadWithWatermark: 'Download with Watermark',
|
||||||
@@ -788,9 +803,6 @@ export default {
|
|||||||
promptOptimizationModelTip: 'Enter the model name for prompt optimization, e.g., gpt-4o, gemini-pro',
|
promptOptimizationModelTip: 'Enter the model name for prompt optimization, e.g., gpt-4o, gemini-pro',
|
||||||
storyboardSystemPrompt: 'Storyboard System Prompt',
|
storyboardSystemPrompt: 'Storyboard System Prompt',
|
||||||
storyboardSystemPromptTip: 'This prompt will be prepended to user prompts for consistent storyboard generation style',
|
storyboardSystemPromptTip: 'This prompt will be prepended to user prompts for consistent storyboard generation style',
|
||||||
storyboardSystemPromptPlaceholder: 'E.g., high quality cinematic shot, professional photography, film tone...',
|
storyboardSystemPromptPlaceholder: 'E.g., high quality cinematic shot, professional photography, film tone...'
|
||||||
promptOptimizationSystemPrompt: 'Prompt Optimization System Instruction',
|
|
||||||
promptOptimizationSystemPromptTip: 'Custom system instruction for AI prompt optimization. Leave empty to use default. This instruction determines how AI understands and optimizes user prompts',
|
|
||||||
promptOptimizationSystemPromptPlaceholder: 'E.g., You are a professional AI prompt optimization expert, transform user descriptions into detailed, professional English prompts...'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,13 +313,15 @@ export default {
|
|||||||
generateStoryboard: '生成分镜图',
|
generateStoryboard: '生成分镜图',
|
||||||
generateVideo: '生成视频',
|
generateVideo: '生成视频',
|
||||||
uploadStoryboard: '上传参考图片',
|
uploadStoryboard: '上传参考图片',
|
||||||
uploadHint: '上传一张参考图片,AI将根据图片生成6宫格分镜图',
|
uploadImage: '上传图片',
|
||||||
|
imageLabel: '图',
|
||||||
|
uploadHint: '上传参考图片,AI将根据图片生成6宫格分镜图',
|
||||||
addMore: '重新上传',
|
addMore: '重新上传',
|
||||||
uploadedCount: '已上传图片',
|
uploadedCount: '已上传图片',
|
||||||
uploadLimit: '已上传',
|
uploadLimit: '已上传',
|
||||||
uploadedImage: '参考图片',
|
uploadedImage: '参考图片',
|
||||||
maxImages: '只能上传1张参考图片',
|
maxImages: '最多只能上传3张参考图片',
|
||||||
maxImagesWarning: '只能上传1张参考图片',
|
maxImagesWarning: '最多只能上传3张参考图片',
|
||||||
fileSizeLimit: '图片文件大小不能超过100MB',
|
fileSizeLimit: '图片文件大小不能超过100MB',
|
||||||
invalidFileType: '请选择有效的图片文件',
|
invalidFileType: '请选择有效的图片文件',
|
||||||
uploadSuccess: '成功上传 {count} 张图片',
|
uploadSuccess: '成功上传 {count} 张图片',
|
||||||
@@ -369,6 +371,7 @@ export default {
|
|||||||
generateStoryboardWithImage: '使用图片生成分镜图',
|
generateStoryboardWithImage: '使用图片生成分镜图',
|
||||||
startGenerateStoryboard: '开始生成分镜图',
|
startGenerateStoryboard: '开始生成分镜图',
|
||||||
startGenerate: '开始生成',
|
startGenerate: '开始生成',
|
||||||
|
generating: '生成中...',
|
||||||
enterDescription: '请输入描述文字',
|
enterDescription: '请输入描述文字',
|
||||||
enterDescriptionForImage: '请输入描述文字,AI将根据参考图和描述生成分镜图',
|
enterDescriptionForImage: '请输入描述文字,AI将根据参考图和描述生成分镜图',
|
||||||
startingGenerate: '开始生成分镜图...',
|
startingGenerate: '开始生成分镜图...',
|
||||||
@@ -379,6 +382,7 @@ export default {
|
|||||||
storyboardCompleted: '分镜图生成完成!请点击"开始生成"按钮生成视频',
|
storyboardCompleted: '分镜图生成完成!请点击"开始生成"按钮生成视频',
|
||||||
videoCompleted: '视频生成完成!',
|
videoCompleted: '视频生成完成!',
|
||||||
taskFailed: '任务失败',
|
taskFailed: '任务失败',
|
||||||
|
checkInputOrRetry: '请检查输入或重新尝试',
|
||||||
unknownError: '未知错误',
|
unknownError: '未知错误',
|
||||||
startingVideoGenerate: '开始生成视频...',
|
startingVideoGenerate: '开始生成视频...',
|
||||||
videoTaskStarted: '视频生成任务已启动,请稍候...',
|
videoTaskStarted: '视频生成任务已启动,请稍候...',
|
||||||
@@ -395,7 +399,16 @@ export default {
|
|||||||
resumingVideoTask: '检测到未完成的视频生成任务,继续处理中...',
|
resumingVideoTask: '检测到未完成的视频生成任务,继续处理中...',
|
||||||
resumingStoryboardTask: '检测到未完成的分镜图生成任务,继续处理中...',
|
resumingStoryboardTask: '检测到未完成的分镜图生成任务,继续处理中...',
|
||||||
resumingTask: '检测到未完成的任务,继续处理中...',
|
resumingTask: '检测到未完成的任务,继续处理中...',
|
||||||
storyboardReady: '分镜图已生成,点击下方按钮生成视频'
|
storyboardReady: '分镜图已生成,点击下方按钮生成视频',
|
||||||
|
regenerate: '重新生成',
|
||||||
|
regenerateConfirm: '重新生成将消耗积分并创建新任务,确定要继续吗?',
|
||||||
|
regenerateTitle: '重新生成分镜图',
|
||||||
|
statusPending: '排队中',
|
||||||
|
statusProcessing: '生成中',
|
||||||
|
statusCompleted: '已完成',
|
||||||
|
statusFailed: '生成失败',
|
||||||
|
statusCancelled: '已取消',
|
||||||
|
statusUnknown: '未知状态'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -439,6 +452,7 @@ export default {
|
|||||||
popular: '热门',
|
popular: '热门',
|
||||||
searchPlaceholder: '名字/提示词/ID',
|
searchPlaceholder: '名字/提示词/ID',
|
||||||
selectItems: '选择{count}个项目',
|
selectItems: '选择{count}个项目',
|
||||||
|
selectAll: '全选',
|
||||||
selectedCount: '已选 {count} 个项目',
|
selectedCount: '已选 {count} 个项目',
|
||||||
favorite: '收藏',
|
favorite: '收藏',
|
||||||
downloadWithWatermark: '带水印下载',
|
downloadWithWatermark: '带水印下载',
|
||||||
@@ -766,9 +780,6 @@ export default {
|
|||||||
promptOptimizationModelTip: '输入用于优化提示词的模型名称,如 gpt-4o、gemini-pro 等',
|
promptOptimizationModelTip: '输入用于优化提示词的模型名称,如 gpt-4o、gemini-pro 等',
|
||||||
storyboardSystemPrompt: '分镜图系统引导词',
|
storyboardSystemPrompt: '分镜图系统引导词',
|
||||||
storyboardSystemPromptTip: '此引导词会自动添加到用户提示词前面,用于统一分镜图生成风格',
|
storyboardSystemPromptTip: '此引导词会自动添加到用户提示词前面,用于统一分镜图生成风格',
|
||||||
storyboardSystemPromptPlaceholder: '例如:高质量电影级画面,专业摄影,电影色调...',
|
storyboardSystemPromptPlaceholder: '例如:高质量电影级画面,专业摄影,电影色调...'
|
||||||
promptOptimizationSystemPrompt: '优化提示词系统指令',
|
|
||||||
promptOptimizationSystemPromptTip: '自定义AI优化提示词的系统指令,留空则使用默认指令。该指令决定了AI如何理解和优化用户输入的提示词',
|
|
||||||
promptOptimizationSystemPromptPlaceholder: '例如:你是一个专业的AI提示词优化专家,将用户描述优化为详细、专业的英文提示词...'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,21 @@ import { ref, computed } from 'vue'
|
|||||||
import { login, register, logout, getCurrentUser } from '@/api/auth'
|
import { login, register, logout, getCurrentUser } from '@/api/auth'
|
||||||
|
|
||||||
export const useUserStore = defineStore('user', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
// 状态 - 从 sessionStorage 尝试恢复用户信息
|
// 状态 - 从 localStorage 尝试恢复用户信息
|
||||||
const user = ref(null)
|
const user = ref(null)
|
||||||
const token = ref(null)
|
const token = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const initialized = ref(false)
|
const initialized = ref(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cachedUser = sessionStorage.getItem('user')
|
const cachedUser = localStorage.getItem('user')
|
||||||
const cachedToken = sessionStorage.getItem('token')
|
const cachedToken = localStorage.getItem('token')
|
||||||
if (cachedUser && cachedToken) {
|
if (cachedUser && cachedToken) {
|
||||||
user.value = JSON.parse(cachedUser)
|
user.value = JSON.parse(cachedUser)
|
||||||
token.value = cachedToken
|
token.value = cachedToken
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore sessionStorage parse errors
|
// ignore localStorage parse errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
@@ -45,9 +45,9 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
user.value = response.data.user
|
user.value = response.data.user
|
||||||
token.value = response.data.token
|
token.value = response.data.token
|
||||||
|
|
||||||
// 保存到sessionStorage,关闭页面时自动清除
|
// 保存到localStorage,关闭浏览器后仍保持登录
|
||||||
sessionStorage.setItem('token', response.data.token)
|
localStorage.setItem('token', response.data.token)
|
||||||
sessionStorage.setItem('user', JSON.stringify(user.value))
|
localStorage.setItem('user', JSON.stringify(user.value))
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} else {
|
} else {
|
||||||
return { success: false, message: response.message }
|
return { success: false, message: response.message }
|
||||||
@@ -82,11 +82,11 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
// 登出
|
// 登出
|
||||||
const logoutUser = async () => {
|
const logoutUser = async () => {
|
||||||
try {
|
try {
|
||||||
// JWT无状态,直接清除sessionStorage即可
|
// JWT无状态,直接清除localStorage即可
|
||||||
token.value = null
|
token.value = null
|
||||||
user.value = null
|
user.value = null
|
||||||
sessionStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
sessionStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error)
|
console.error('Logout error:', error)
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
user.value = data.data
|
user.value = data.data
|
||||||
sessionStorage.setItem('user', JSON.stringify(user.value))
|
localStorage.setItem('user', JSON.stringify(user.value))
|
||||||
} else {
|
} else {
|
||||||
console.warn('获取用户信息失败:', data.message)
|
console.warn('获取用户信息失败:', data.message)
|
||||||
// 不要立即清除用户数据,保持当前登录状态
|
// 不要立即清除用户数据,保持当前登录状态
|
||||||
@@ -117,9 +117,9 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
const clearUserData = () => {
|
const clearUserData = () => {
|
||||||
token.value = null
|
token.value = null
|
||||||
user.value = null
|
user.value = null
|
||||||
// 清除 sessionStorage 中的用户数据
|
// 清除 localStorage 中的用户数据
|
||||||
sessionStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
sessionStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
@@ -128,9 +128,9 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从sessionStorage恢复用户状态
|
// 从localStorage恢复用户状态
|
||||||
const savedToken = sessionStorage.getItem('token')
|
const savedToken = localStorage.getItem('token')
|
||||||
const savedUser = sessionStorage.getItem('user')
|
const savedUser = localStorage.getItem('user')
|
||||||
|
|
||||||
if (savedToken && savedUser) {
|
if (savedToken && savedUser) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ const fetchSystemStats = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/online-stats', {
|
const response = await fetch('/api/admin/online-stats', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|||||||
@@ -654,7 +654,7 @@ const fetchSystemStats = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/online-stats', {
|
const response = await fetch('/api/admin/online-stats', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ const fetchSystemStats = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/online-stats', {
|
const response = await fetch('/api/admin/online-stats', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const loading = ref(false)
|
|||||||
|
|
||||||
// 判断是否首次设置密码
|
// 判断是否首次设置密码
|
||||||
const isFirstTimeSetup = computed(() => {
|
const isFirstTimeSetup = computed(() => {
|
||||||
return sessionStorage.getItem('needSetPassword') === '1'
|
return localStorage.getItem('needSetPassword') === '1'
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -162,7 +162,7 @@ const handleSubmit = async () => {
|
|||||||
ElMessage.success('密码修改成功')
|
ElMessage.success('密码修改成功')
|
||||||
|
|
||||||
// 清除首次设置标记
|
// 清除首次设置标记
|
||||||
sessionStorage.removeItem('needSetPassword')
|
localStorage.removeItem('needSetPassword')
|
||||||
|
|
||||||
// 跳转到首页或之前的页面
|
// 跳转到首页或之前的页面
|
||||||
const redirect = route.query.redirect || '/profile'
|
const redirect = route.query.redirect || '/profile'
|
||||||
|
|||||||
@@ -522,7 +522,7 @@ const handleDelete = async (task) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 调用后端API删除
|
// 调用后端API删除
|
||||||
const token = sessionStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (!token) {
|
if (!token) {
|
||||||
ElMessage.error('请先登录')
|
ElMessage.error('请先登录')
|
||||||
return
|
return
|
||||||
@@ -574,7 +574,7 @@ const handleBatchDelete = async () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 调用后端API批量删除
|
// 调用后端API批量删除
|
||||||
const token = sessionStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
if (!token) {
|
if (!token) {
|
||||||
ElMessage.error('请先登录')
|
ElMessage.error('请先登录')
|
||||||
return
|
return
|
||||||
@@ -759,7 +759,7 @@ const fetchSystemStats = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/online-stats', {
|
const response = await fetch('/api/admin/online-stats', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
|
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
|
||||||
<el-icon><Film /></el-icon>
|
<el-icon><Film /></el-icon>
|
||||||
<span>分镜视频</span>
|
<span>分镜视频</span>
|
||||||
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1457,7 +1457,7 @@ onUnmounted(() => {
|
|||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 400px 1fr;
|
grid-template-columns: 520px 1fr;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100vh - 100px);
|
height: calc(100vh - 100px);
|
||||||
}
|
}
|
||||||
@@ -1505,33 +1505,38 @@ onUnmounted(() => {
|
|||||||
/* 创作模式标签 */
|
/* 创作模式标签 */
|
||||||
.creation-tabs {
|
.creation-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
background: #0a0a0a;
|
padding: 0;
|
||||||
padding: 4px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
flex: 1;
|
width: 155px;
|
||||||
padding: 12px 16px;
|
height: 44px;
|
||||||
border-radius: 8px;
|
padding: 0;
|
||||||
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
background: #3b82f6;
|
background: #313338;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover:not(.active) {
|
.tab:hover:not(.active) {
|
||||||
background: #2a2a2a;
|
background: rgba(49, 51, 56, 0.5);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图片输入区域 */
|
/* 图片输入区域 */
|
||||||
@@ -2598,7 +2603,7 @@ onUnmounted(() => {
|
|||||||
/* 响应式设计 */
|
/* 响应式设计 */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.main-content {
|
.main-content {
|
||||||
grid-template-columns: 350px 1fr;
|
grid-template-columns: 420px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-panel {
|
.left-panel {
|
||||||
|
|||||||
@@ -308,17 +308,17 @@ const handleLogin = async () => {
|
|||||||
const loginToken = response.data.data.token
|
const loginToken = response.data.data.token
|
||||||
const needsPasswordChange = response.data.data.needsPasswordChange // 后端直接返回是否需要修改密码
|
const needsPasswordChange = response.data.data.needsPasswordChange // 后端直接返回是否需要修改密码
|
||||||
|
|
||||||
sessionStorage.setItem('token', loginToken)
|
localStorage.setItem('token', loginToken)
|
||||||
sessionStorage.setItem('user', JSON.stringify(loginUser))
|
localStorage.setItem('user', JSON.stringify(loginUser))
|
||||||
userStore.user = loginUser
|
userStore.user = loginUser
|
||||||
userStore.token = loginToken
|
userStore.token = loginToken
|
||||||
|
|
||||||
// 根据后端返回的标记设置是否需要修改密码
|
// 根据后端返回的标记设置是否需要修改密码
|
||||||
if (needsPasswordChange) {
|
if (needsPasswordChange) {
|
||||||
sessionStorage.setItem('needSetPassword', '1')
|
localStorage.setItem('needSetPassword', '1')
|
||||||
console.log('新用户首次登录,需要设置密码')
|
console.log('新用户首次登录,需要设置密码')
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem('needSetPassword')
|
localStorage.removeItem('needSetPassword')
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('登录成功,用户信息:', userStore.user, '需要设置密码:', needsPasswordChange)
|
console.log('登录成功,用户信息:', userStore.user, '需要设置密码:', needsPasswordChange)
|
||||||
@@ -328,7 +328,7 @@ const handleLogin = async () => {
|
|||||||
await new Promise(resolve => setTimeout(resolve, 200))
|
await new Promise(resolve => setTimeout(resolve, 200))
|
||||||
|
|
||||||
// 如果需要设置密码,跳转到设置密码页面
|
// 如果需要设置密码,跳转到设置密码页面
|
||||||
const needSetPassword = sessionStorage.getItem('needSetPassword') === '1'
|
const needSetPassword = localStorage.getItem('needSetPassword') === '1'
|
||||||
const redirectPath = needSetPassword ? '/set-password' : (route.query.redirect || '/profile')
|
const redirectPath = needSetPassword ? '/set-password' : (route.query.redirect || '/profile')
|
||||||
console.log('准备跳转到:', redirectPath, '需要设置密码:', needSetPassword)
|
console.log('准备跳转到:', redirectPath, '需要设置密码:', needSetPassword)
|
||||||
|
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ const currentPage = ref(1)
|
|||||||
const pageSize = ref(10)
|
const pageSize = ref(10)
|
||||||
const totalMembers = ref(50)
|
const totalMembers = ref(50)
|
||||||
|
|
||||||
// 当前用户角色(从 sessionStorage 或 API 获取)
|
// 当前用户角色(从 localStorage 或 API 获取)
|
||||||
const currentUserRole = ref('')
|
const currentUserRole = ref('')
|
||||||
const isSuperAdmin = computed(() => currentUserRole.value === 'ROLE_SUPER_ADMIN')
|
const isSuperAdmin = computed(() => currentUserRole.value === 'ROLE_SUPER_ADMIN')
|
||||||
|
|
||||||
@@ -702,7 +702,7 @@ const toggleRole = async (member) => {
|
|||||||
const fetchCurrentUserRole = () => {
|
const fetchCurrentUserRole = () => {
|
||||||
try {
|
try {
|
||||||
// 登录时保存的是 'user' 而不是 'userInfo'
|
// 登录时保存的是 'user' 而不是 'userInfo'
|
||||||
const userStr = sessionStorage.getItem('user')
|
const userStr = localStorage.getItem('user')
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
const user = JSON.parse(userStr)
|
const user = JSON.parse(userStr)
|
||||||
currentUserRole.value = user.role || 'ROLE_USER'
|
currentUserRole.value = user.role || 'ROLE_USER'
|
||||||
@@ -727,7 +727,7 @@ const fetchSystemStats = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/online-stats', {
|
const response = await fetch('/api/admin/online-stats', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
<div class="nav-item" @click="goToStoryboardVideo">
|
<div class="nav-item" @click="goToStoryboardVideo">
|
||||||
<el-icon><Film /></el-icon>
|
<el-icon><Film /></el-icon>
|
||||||
<span>{{ t('works.storyboardVideo') }}</span>
|
<span>{{ t('works.storyboardVideo') }}</span>
|
||||||
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -133,7 +132,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="select-row">
|
<div class="select-row">
|
||||||
<el-checkbox v-model="multiSelect" size="small">{{ t('works.selectItems', { count: selectedIds.size || 6 }) }}</el-checkbox>
|
<el-checkbox :model-value="isAllSelected" @change="toggleSelectAll" size="small">{{ t('works.selectAll') }}</el-checkbox>
|
||||||
<template v-if="multiSelect && selectedIds.size">
|
<template v-if="multiSelect && selectedIds.size">
|
||||||
<el-tag type="success" size="small">{{ t('works.selectedCount', { count: selectedIds.size }) }}</el-tag>
|
<el-tag type="success" size="small">{{ t('works.selectedCount', { count: selectedIds.size }) }}</el-tag>
|
||||||
<el-button size="small" type="primary" @click="bulkDownload" plain>{{ t('video.download') }}</el-button>
|
<el-button size="small" type="primary" @click="bulkDownload" plain>{{ t('video.download') }}</el-button>
|
||||||
@@ -227,6 +226,7 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<el-space size="small">
|
<el-space size="small">
|
||||||
<el-button text size="small" @click.stop="download(item)">{{ t('video.download') }}</el-button>
|
<el-button text size="small" @click.stop="download(item)">{{ t('video.download') }}</el-button>
|
||||||
|
<el-button text size="small" type="danger" @click.stop="handleDeleteWork(item)">{{ t('common.delete') || '删除' }}</el-button>
|
||||||
</el-space>
|
</el-space>
|
||||||
</template>
|
</template>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -489,7 +489,7 @@ const isVerticalVideo = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(100)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const hasMore = ref(true)
|
const hasMore = ref(true)
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
@@ -663,12 +663,10 @@ const loadList = async () => {
|
|||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
let filtered = [...items.value]
|
let filtered = [...items.value]
|
||||||
|
|
||||||
// 过滤掉加载失败的作品(URL失效的作品自动隐藏)
|
// 过滤掉加载失败的作品
|
||||||
filtered = filtered.filter(item => {
|
filtered = filtered.filter(item => {
|
||||||
// 检查 resultUrl 和 cover 是否在失败集合中
|
|
||||||
const resultUrlFailed = item.resultUrl && failedUrls.value.has(item.resultUrl)
|
const resultUrlFailed = item.resultUrl && failedUrls.value.has(item.resultUrl)
|
||||||
const coverFailed = item.cover && failedUrls.value.has(item.cover)
|
const coverFailed = item.cover && failedUrls.value.has(item.cover)
|
||||||
// 如果任一URL失败,则不显示该作品
|
|
||||||
return !resultUrlFailed && !coverFailed
|
return !resultUrlFailed && !coverFailed
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1054,7 +1052,7 @@ const download = async (item) => {
|
|||||||
|
|
||||||
// 备用方案:使用后端代理
|
// 备用方案:使用后端代理
|
||||||
const downloadUrl = getWorkFileUrl(item.id, true)
|
const downloadUrl = getWorkFileUrl(item.id, true)
|
||||||
const token = sessionStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
console.log('开始下载:', downloadUrl)
|
console.log('开始下载:', downloadUrl)
|
||||||
|
|
||||||
@@ -1151,6 +1149,36 @@ const moreCommand = async (cmd, item) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除单个作品
|
||||||
|
const handleDeleteWork = async (item) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
t('works.deleteWorkConfirm') || `确定要删除作品"${item.title}"吗?`,
|
||||||
|
t('works.deleteConfirmTitle') || '删除确认',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: t('common.delete') || '删除',
|
||||||
|
cancelButtonText: t('common.cancel') || '取消'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 执行删除
|
||||||
|
const response = await deleteWork(item.id)
|
||||||
|
if (response.data.success) {
|
||||||
|
ElMessage.success(t('works.deleteSuccess') || '删除成功')
|
||||||
|
// 从列表中移除
|
||||||
|
items.value = items.value.filter(i => i.id !== item.id)
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.message || t('works.deleteFailed') || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('删除作品失败:', error)
|
||||||
|
ElMessage.error(error.message || t('works.deleteFailed') || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleSelect = (id) => {
|
const toggleSelect = (id) => {
|
||||||
const next = new Set(selectedIds.value)
|
const next = new Set(selectedIds.value)
|
||||||
if (next.has(id)) next.delete(id)
|
if (next.has(id)) next.delete(id)
|
||||||
@@ -1158,6 +1186,24 @@ const toggleSelect = (id) => {
|
|||||||
selectedIds.value = next
|
selectedIds.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全选/取消全选
|
||||||
|
const isAllSelected = computed(() => {
|
||||||
|
if (filteredItems.value.length === 0) return false
|
||||||
|
return filteredItems.value.every(item => selectedIds.value.has(item.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (isAllSelected.value) {
|
||||||
|
// 取消全选
|
||||||
|
selectedIds.value = new Set()
|
||||||
|
multiSelect.value = false
|
||||||
|
} else {
|
||||||
|
// 全选
|
||||||
|
multiSelect.value = true
|
||||||
|
selectedIds.value = new Set(filteredItems.value.map(item => item.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bulkDownload = async () => {
|
const bulkDownload = async () => {
|
||||||
if (selectedIds.value.size === 0) {
|
if (selectedIds.value.size === 0) {
|
||||||
ElMessage.warning(t('works.noItemsSelected'))
|
ElMessage.warning(t('works.noItemsSelected'))
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<el-icon><Film /></el-icon>
|
<el-icon><Film /></el-icon>
|
||||||
<span @click="goToStoryboardVideo">{{ t('home.storyboardVideo') }}</span>
|
<span @click="goToStoryboardVideo">{{ t('home.storyboardVideo') }}</span>
|
||||||
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -106,16 +105,22 @@
|
|||||||
muted
|
muted
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
@loadedmetadata="onVideoLoaded"
|
@loadedmetadata="onVideoLoaded"
|
||||||
|
@error="onVideoError($event, video)"
|
||||||
></video>
|
></video>
|
||||||
<!-- 如果有封面图(thumbnailUrl),使用图片 -->
|
<!-- 如果有封面图,使用图片 -->
|
||||||
<img
|
<img
|
||||||
v-else-if="video.cover && video.cover !== video.resultUrl"
|
v-else-if="video.cover"
|
||||||
:src="video.cover"
|
:src="video.cover"
|
||||||
:alt="video.title"
|
:alt="video.title"
|
||||||
class="video-cover-img"
|
class="video-cover-img"
|
||||||
|
@error="onImageError"
|
||||||
/>
|
/>
|
||||||
<!-- 否则使用占位符 -->
|
<!-- 否则使用视频图标占位符 -->
|
||||||
<div v-else class="figure"></div>
|
<div v-else class="video-placeholder">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="rgba(255,255,255,0.5)">
|
||||||
|
<path d="M8 5v14l11-7z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-action">
|
<div class="video-action">
|
||||||
<el-button v-if="index === 0 && video.status === 'COMPLETED'" type="primary" size="small" @click.stop="createSimilar(video)">{{ t('profile.createSimilar') }}</el-button>
|
<el-button v-if="index === 0 && video.status === 'COMPLETED'" type="primary" size="small" @click.stop="createSimilar(video)">{{ t('profile.createSimilar') }}</el-button>
|
||||||
@@ -549,8 +554,8 @@ const loadUserInfo = async () => {
|
|||||||
console.log('设置后的用户信息:', userInfo.value)
|
console.log('设置后的用户信息:', userInfo.value)
|
||||||
|
|
||||||
// 检查用户是否需要设置密码(只有数据库中真正没有密码时才弹窗,且只弹一次)
|
// 检查用户是否需要设置密码(只有数据库中真正没有密码时才弹窗,且只弹一次)
|
||||||
const needSetPasswordFlag = sessionStorage.getItem('needSetPassword') === '1'
|
const needSetPasswordFlag = localStorage.getItem('needSetPassword') === '1'
|
||||||
const hasShownPasswordDialog = sessionStorage.getItem('hasShownPasswordDialog') === '1'
|
const hasShownPasswordDialog = localStorage.getItem('hasShownPasswordDialog') === '1'
|
||||||
|
|
||||||
// 检查后端返回的用户密码状态
|
// 检查后端返回的用户密码状态
|
||||||
const hasNoPassword = !user.passwordHash || String(user.passwordHash).trim() === ''
|
const hasNoPassword = !user.passwordHash || String(user.passwordHash).trim() === ''
|
||||||
@@ -563,12 +568,12 @@ const loadUserInfo = async () => {
|
|||||||
console.log('检测到用户没有设置密码,弹出修改密码弹窗')
|
console.log('检测到用户没有设置密码,弹出修改密码弹窗')
|
||||||
openChangePasswordDialog()
|
openChangePasswordDialog()
|
||||||
// 清除标记,确保只弹一次
|
// 清除标记,确保只弹一次
|
||||||
sessionStorage.removeItem('needSetPassword')
|
localStorage.removeItem('needSetPassword')
|
||||||
sessionStorage.setItem('hasShownPasswordDialog', '1')
|
localStorage.setItem('hasShownPasswordDialog', '1')
|
||||||
} else if (needSetPasswordFlag && !hasNoPassword) {
|
} else if (needSetPasswordFlag && !hasNoPassword) {
|
||||||
// 如果有标记但用户已经有密码了,清除标记
|
// 如果有标记但用户已经有密码了,清除标记
|
||||||
console.log('用户已有密码,清除 needSetPassword 标记')
|
console.log('用户已有密码,清除 needSetPassword 标记')
|
||||||
sessionStorage.removeItem('needSetPassword')
|
localStorage.removeItem('needSetPassword')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('获取用户信息失败:', response?.data?.message || '未知错误')
|
console.error('获取用户信息失败:', response?.data?.message || '未知错误')
|
||||||
@@ -640,6 +645,19 @@ const onVideoLoaded = (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 视频加载失败处理
|
||||||
|
const onVideoError = (event, video) => {
|
||||||
|
console.warn('视频加载失败:', video.resultUrl)
|
||||||
|
// 隐藏video元素,显示占位符
|
||||||
|
event.target.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片加载失败处理
|
||||||
|
const onImageError = (event) => {
|
||||||
|
console.warn('图片加载失败:', event.target.src)
|
||||||
|
event.target.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
loadUserInfo()
|
loadUserInfo()
|
||||||
@@ -1062,6 +1080,15 @@ onUnmounted(() => {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.figure {
|
.figure {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ const handleSubmit = async () => {
|
|||||||
ElMessage.success('密码设置成功')
|
ElMessage.success('密码设置成功')
|
||||||
|
|
||||||
// 清除首次设置标记
|
// 清除首次设置标记
|
||||||
sessionStorage.removeItem('needSetPassword')
|
localStorage.removeItem('needSetPassword')
|
||||||
|
|
||||||
// 跳转到首页或之前的页面
|
// 跳转到首页或之前的页面
|
||||||
const redirect = route.query.redirect || '/'
|
const redirect = route.query.redirect || '/'
|
||||||
@@ -159,7 +159,7 @@ const handleSubmit = async () => {
|
|||||||
// 跳过
|
// 跳过
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
// 清除首次设置标记
|
// 清除首次设置标记
|
||||||
sessionStorage.removeItem('needSetPassword')
|
localStorage.removeItem('needSetPassword')
|
||||||
|
|
||||||
// 跳转到首页
|
// 跳转到首页
|
||||||
const redirect = route.query.redirect || '/'
|
const redirect = route.query.redirect || '/'
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideoCreate">
|
<div class="nav-item storyboard-item" @click="goToStoryboardVideoCreate">
|
||||||
<el-icon><Film /></el-icon>
|
<el-icon><Film /></el-icon>
|
||||||
<span>分镜视频</span>
|
<span>分镜视频</span>
|
||||||
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -41,7 +41,6 @@
|
|||||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
|
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
|
||||||
<el-icon><Film /></el-icon>
|
<el-icon><Film /></el-icon>
|
||||||
<span>{{ $t('home.storyboardVideo') }}</span>
|
<span>{{ $t('home.storyboardVideo') }}</span>
|
||||||
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -409,7 +408,7 @@ const loadUserSubscriptionInfo = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查token是否存在
|
// 检查token是否存在
|
||||||
const token = userStore.token || sessionStorage.getItem('token')
|
const token = userStore.token || localStorage.getItem('token')
|
||||||
if (!token || token === 'null' || token.trim() === '') {
|
if (!token || token === 'null' || token.trim() === '') {
|
||||||
console.warn('未找到有效的token,跳转到登录页')
|
console.warn('未找到有效的token,跳转到登录页')
|
||||||
ElMessage.warning(t('subscription.pleaseLogin'))
|
ElMessage.warning(t('subscription.pleaseLogin'))
|
||||||
|
|||||||
@@ -268,16 +268,6 @@
|
|||||||
</el-input>
|
</el-input>
|
||||||
<div class="model-tip">{{ $t('systemSettings.storyboardSystemPromptTip') }}</div>
|
<div class="model-tip">{{ $t('systemSettings.storyboardSystemPromptTip') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="$t('systemSettings.promptOptimizationSystemPrompt')">
|
|
||||||
<el-input
|
|
||||||
v-model="promptOptimizationSystemPrompt"
|
|
||||||
type="textarea"
|
|
||||||
:rows="6"
|
|
||||||
style="width: 500px;"
|
|
||||||
:placeholder="$t('systemSettings.promptOptimizationSystemPromptPlaceholder')">
|
|
||||||
</el-input>
|
|
||||||
<div class="model-tip">{{ $t('systemSettings.promptOptimizationSystemPromptTip') }}</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -510,7 +500,6 @@ const cleanupConfig = reactive({
|
|||||||
const promptOptimizationModel = ref('gpt-5.1-thinking')
|
const promptOptimizationModel = ref('gpt-5.1-thinking')
|
||||||
const promptOptimizationApiUrl = ref('https://ai.comfly.chat')
|
const promptOptimizationApiUrl = ref('https://ai.comfly.chat')
|
||||||
const storyboardSystemPrompt = ref('')
|
const storyboardSystemPrompt = ref('')
|
||||||
const promptOptimizationSystemPrompt = ref('')
|
|
||||||
const savingAiModel = ref(false)
|
const savingAiModel = ref(false)
|
||||||
|
|
||||||
const goToDashboard = () => {
|
const goToDashboard = () => {
|
||||||
@@ -664,23 +653,24 @@ const loadMembershipLevels = async () => {
|
|||||||
|
|
||||||
// 任务清理相关方法
|
// 任务清理相关方法
|
||||||
const getAuthHeaders = () => {
|
const getAuthHeaders = () => {
|
||||||
const token = sessionStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
return token ? { 'Authorization': `Bearer ${token}` } : {}
|
return token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshStats = async () => {
|
const refreshStats = async (showMessage = true) => {
|
||||||
loadingStats.value = true
|
loadingStats.value = true
|
||||||
try {
|
try {
|
||||||
const response = await cleanupApi.getCleanupStats()
|
const response = await cleanupApi.getCleanupStats()
|
||||||
cleanupStats.value = response.data
|
cleanupStats.value = response.data
|
||||||
ElMessage.success(t('systemSettings.statsRefreshSuccess'))
|
if (showMessage) {
|
||||||
|
ElMessage.success(t('systemSettings.statsRefreshSuccess'))
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get statistics failed:', error)
|
console.error('Get statistics failed:', error)
|
||||||
ElMessage.error(t('systemSettings.statsRefreshFailed'))
|
ElMessage.error(t('systemSettings.statsRefreshFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
loadingStats.value = false
|
loadingStats.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const performFullCleanup = async () => {
|
const performFullCleanup = async () => {
|
||||||
@@ -779,7 +769,7 @@ const loadAiModelSettings = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/settings', {
|
const response = await fetch('/api/admin/settings', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -793,9 +783,6 @@ const loadAiModelSettings = async () => {
|
|||||||
if (data.storyboardSystemPrompt !== undefined) {
|
if (data.storyboardSystemPrompt !== undefined) {
|
||||||
storyboardSystemPrompt.value = data.storyboardSystemPrompt
|
storyboardSystemPrompt.value = data.storyboardSystemPrompt
|
||||||
}
|
}
|
||||||
if (data.promptOptimizationSystemPrompt !== undefined) {
|
|
||||||
promptOptimizationSystemPrompt.value = data.promptOptimizationSystemPrompt
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Load AI model settings failed:', error)
|
console.error('Load AI model settings failed:', error)
|
||||||
@@ -810,13 +797,12 @@ const saveAiModelSettings = async () => {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
promptOptimizationModel: promptOptimizationModel.value,
|
promptOptimizationModel: promptOptimizationModel.value,
|
||||||
promptOptimizationApiUrl: promptOptimizationApiUrl.value,
|
promptOptimizationApiUrl: promptOptimizationApiUrl.value,
|
||||||
storyboardSystemPrompt: storyboardSystemPrompt.value,
|
storyboardSystemPrompt: storyboardSystemPrompt.value
|
||||||
promptOptimizationSystemPrompt: promptOptimizationSystemPrompt.value
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -834,7 +820,7 @@ const saveAiModelSettings = async () => {
|
|||||||
|
|
||||||
// 页面加载时获取统计信息和会员等级配置
|
// 页面加载时获取统计信息和会员等级配置
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
refreshStats()
|
refreshStats(false) // 初始加载不显示成功提示
|
||||||
loadMembershipLevels()
|
loadMembershipLevels()
|
||||||
fetchSystemStats()
|
fetchSystemStats()
|
||||||
loadAiModelSettings()
|
loadAiModelSettings()
|
||||||
@@ -845,7 +831,7 @@ const fetchSystemStats = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/online-stats', {
|
const response = await fetch('/api/admin/online-stats', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
|
<div class="nav-item storyboard-item" @click="goToStoryboardVideo">
|
||||||
<el-icon><Film /></el-icon>
|
<el-icon><Film /></el-icon>
|
||||||
<span>分镜视频</span>
|
<span>分镜视频</span>
|
||||||
<el-tag size="small" type="primary" class="sora-tag">Sora2.0</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1283,7 +1283,7 @@ onUnmounted(() => {
|
|||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 400px 1fr;
|
grid-template-columns: 520px 1fr;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100vh - 100px);
|
height: calc(100vh - 100px);
|
||||||
}
|
}
|
||||||
@@ -1302,33 +1302,38 @@ onUnmounted(() => {
|
|||||||
/* 创作模式标签 */
|
/* 创作模式标签 */
|
||||||
.creation-tabs {
|
.creation-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
background: #0a0a0a;
|
padding: 0;
|
||||||
padding: 4px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
flex: 1;
|
width: 155px;
|
||||||
padding: 12px 16px;
|
height: 44px;
|
||||||
border-radius: 8px;
|
padding: 0;
|
||||||
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
background: #3b82f6;
|
background: #313338;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover:not(.active) {
|
.tab:hover:not(.active) {
|
||||||
background: #2a2a2a;
|
background: rgba(49, 51, 56, 0.5);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 文本输入区域 */
|
/* 文本输入区域 */
|
||||||
@@ -1607,7 +1612,7 @@ onUnmounted(() => {
|
|||||||
/* 响应式设计 */
|
/* 响应式设计 */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.main-content {
|
.main-content {
|
||||||
grid-template-columns: 350px 1fr;
|
grid-template-columns: 420px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-panel {
|
.left-panel {
|
||||||
|
|||||||
@@ -115,6 +115,12 @@
|
|||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Redis 缓存支持 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- PayPal SDK -->
|
<!-- PayPal SDK -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.paypal.sdk</groupId>
|
<groupId>com.paypal.sdk</groupId>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package com.example.demo;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication(exclude = {RedisAutoConfiguration.class, RedisRepositoriesAutoConfiguration.class})
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class DemoApplication {
|
public class DemoApplication {
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
|||||||
|
|
||||||
import com.example.demo.security.JwtAuthenticationFilter;
|
import com.example.demo.security.JwtAuthenticationFilter;
|
||||||
import com.example.demo.security.PlainTextPasswordEncoder;
|
import com.example.demo.security.PlainTextPasswordEncoder;
|
||||||
|
import com.example.demo.service.RedisTokenService;
|
||||||
import com.example.demo.service.UserService;
|
import com.example.demo.service.UserService;
|
||||||
import com.example.demo.util.JwtUtils;
|
import com.example.demo.util.JwtUtils;
|
||||||
|
|
||||||
@@ -34,7 +35,8 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtUtils jwtUtils, UserService userService) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtUtils jwtUtils, UserService userService,
|
||||||
|
RedisTokenService redisTokenService) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
@@ -81,6 +83,7 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/payments/**").authenticated() // 其他支付接口需要认证
|
.requestMatchers("/api/payments/**").authenticated() // 其他支付接口需要认证
|
||||||
.requestMatchers("/api/image-to-video/**").authenticated() // 图生视频接口需要认证
|
.requestMatchers("/api/image-to-video/**").authenticated() // 图生视频接口需要认证
|
||||||
.requestMatchers("/api/text-to-video/**").authenticated() // 文生视频接口需要认证
|
.requestMatchers("/api/text-to-video/**").authenticated() // 文生视频接口需要认证
|
||||||
|
.requestMatchers("/api/storyboard-video/**").authenticated() // 分镜视频接口需要认证
|
||||||
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 仪表盘API需要管理员权限
|
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 仪表盘API需要管理员权限
|
||||||
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 管理员API需要管理员权限
|
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 管理员API需要管理员权限
|
||||||
.requestMatchers("/settings", "/settings/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
|
.requestMatchers("/settings", "/settings/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
|
||||||
@@ -98,7 +101,7 @@ public class SecurityConfig {
|
|||||||
.logout(Customizer.withDefaults());
|
.logout(Customizer.withDefaults());
|
||||||
|
|
||||||
// 添加JWT过滤器
|
// 添加JWT过滤器
|
||||||
http.addFilterBefore(jwtAuthenticationFilter(jwtUtils, userService), UsernamePasswordAuthenticationFilter.class);
|
http.addFilterBefore(jwtAuthenticationFilter(jwtUtils, userService, redisTokenService), UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
// H2 控制台需要以下设置
|
// H2 控制台需要以下设置
|
||||||
http.headers(headers -> headers.frameOptions(frame -> frame.disable()));
|
http.headers(headers -> headers.frameOptions(frame -> frame.disable()));
|
||||||
@@ -181,8 +184,9 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtils jwtUtils, UserService userService) {
|
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtils jwtUtils, UserService userService,
|
||||||
return new JwtAuthenticationFilter(jwtUtils, userService);
|
com.example.demo.service.RedisTokenService redisTokenService) {
|
||||||
|
return new JwtAuthenticationFilter(jwtUtils, userService, redisTokenService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -404,13 +404,13 @@ public class AdminController {
|
|||||||
|
|
||||||
response.put("promptOptimizationModel", settings.getPromptOptimizationModel());
|
response.put("promptOptimizationModel", settings.getPromptOptimizationModel());
|
||||||
response.put("promptOptimizationApiUrl", settings.getPromptOptimizationApiUrl());
|
response.put("promptOptimizationApiUrl", settings.getPromptOptimizationApiUrl());
|
||||||
response.put("promptOptimizationSystemPrompt", settings.getPromptOptimizationSystemPrompt());
|
|
||||||
response.put("storyboardSystemPrompt", settings.getStoryboardSystemPrompt());
|
response.put("storyboardSystemPrompt", settings.getStoryboardSystemPrompt());
|
||||||
response.put("siteName", settings.getSiteName());
|
response.put("siteName", settings.getSiteName());
|
||||||
response.put("siteSubtitle", settings.getSiteSubtitle());
|
response.put("siteSubtitle", settings.getSiteSubtitle());
|
||||||
response.put("registrationOpen", settings.getRegistrationOpen());
|
response.put("registrationOpen", settings.getRegistrationOpen());
|
||||||
response.put("maintenanceMode", settings.getMaintenanceMode());
|
response.put("maintenanceMode", settings.getMaintenanceMode());
|
||||||
response.put("contactEmail", settings.getContactEmail());
|
response.put("contactEmail", settings.getContactEmail());
|
||||||
|
response.put("tokenExpireHours", settings.getTokenExpireHours());
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
@@ -454,11 +454,25 @@ public class AdminController {
|
|||||||
logger.info("更新分镜图系统引导词");
|
logger.info("更新分镜图系统引导词");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新优化提示词系统提示词
|
// 更新Token过期时间(小时)
|
||||||
if (settingsData.containsKey("promptOptimizationSystemPrompt")) {
|
if (settingsData.containsKey("tokenExpireHours")) {
|
||||||
String prompt = (String) settingsData.get("promptOptimizationSystemPrompt");
|
Object value = settingsData.get("tokenExpireHours");
|
||||||
settings.setPromptOptimizationSystemPrompt(prompt);
|
Integer hours = null;
|
||||||
logger.info("更新优化提示词系统提示词");
|
if (value instanceof Number) {
|
||||||
|
hours = ((Number) value).intValue();
|
||||||
|
} else if (value instanceof String) {
|
||||||
|
try {
|
||||||
|
hours = Integer.parseInt((String) value);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
logger.warn("无效的Token过期时间值: {}", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hours != null && hours >= 1 && hours <= 720) {
|
||||||
|
settings.setTokenExpireHours(hours);
|
||||||
|
logger.info("更新Token过期时间为: {} 小时", hours);
|
||||||
|
} else {
|
||||||
|
logger.warn("Token过期时间超出范围(1-720小时): {}", hours);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
systemSettingsService.update(settings);
|
systemSettingsService.update(settings);
|
||||||
@@ -508,8 +522,8 @@ public class AdminController {
|
|||||||
return ResponseEntity.status(403).body(response);
|
return ResponseEntity.status(403).body(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找并删除任务
|
// 查找并删除任务(使用 findFirst 处理可能的重复记录)
|
||||||
var taskOpt = taskStatusRepository.findByTaskId(taskId);
|
var taskOpt = taskStatusRepository.findFirstByTaskIdOrderByIdDesc(taskId);
|
||||||
if (taskOpt.isPresent()) {
|
if (taskOpt.isPresent()) {
|
||||||
taskStatusRepository.delete(taskOpt.get());
|
taskStatusRepository.delete(taskOpt.get());
|
||||||
logger.info("管理员 {} 删除了任务: {}", username, taskId);
|
logger.info("管理员 {} 删除了任务: {}", username, taskId);
|
||||||
@@ -566,7 +580,7 @@ public class AdminController {
|
|||||||
// 批量删除任务
|
// 批量删除任务
|
||||||
int deletedCount = 0;
|
int deletedCount = 0;
|
||||||
for (String taskId : taskIds) {
|
for (String taskId : taskIds) {
|
||||||
var taskOpt = taskStatusRepository.findByTaskId(taskId);
|
var taskOpt = taskStatusRepository.findFirstByTaskIdOrderByIdDesc(taskId);
|
||||||
if (taskOpt.isPresent()) {
|
if (taskOpt.isPresent()) {
|
||||||
taskStatusRepository.delete(taskOpt.get());
|
taskStatusRepository.delete(taskOpt.get());
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import com.example.demo.model.User;
|
import com.example.demo.model.User;
|
||||||
|
import com.example.demo.model.SystemSettings;
|
||||||
|
import com.example.demo.service.RedisTokenService;
|
||||||
|
import com.example.demo.service.SystemSettingsService;
|
||||||
import com.example.demo.service.UserService;
|
import com.example.demo.service.UserService;
|
||||||
import com.example.demo.service.VerificationCodeService;
|
import com.example.demo.service.VerificationCodeService;
|
||||||
import com.example.demo.util.JwtUtils;
|
import com.example.demo.util.JwtUtils;
|
||||||
@@ -39,6 +42,12 @@ public class AuthApiController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private VerificationCodeService verificationCodeService;
|
private VerificationCodeService verificationCodeService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisTokenService redisTokenService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SystemSettingsService systemSettingsService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户登录(已禁用,仅支持邮箱验证码登录)
|
* 用户登录(已禁用,仅支持邮箱验证码登录)
|
||||||
* 为了向后兼容,保留此接口但返回提示信息
|
* 为了向后兼容,保留此接口但返回提示信息
|
||||||
@@ -76,8 +85,17 @@ public class AuthApiController {
|
|||||||
return ResponseEntity.badRequest().body(createErrorResponse("邮箱/用户名或密码不正确"));
|
return ResponseEntity.badRequest().body(createErrorResponse("邮箱/用户名或密码不正确"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取动态配置的过期时间
|
||||||
|
SystemSettings settings = systemSettingsService.getOrCreate();
|
||||||
|
int expireHours = settings.getTokenExpireHours() != null ? settings.getTokenExpireHours() : 24;
|
||||||
|
long expireMs = expireHours * 60L * 60L * 1000L; // 转换为毫秒
|
||||||
|
long expireSeconds = expireHours * 60L * 60L; // 转换为秒
|
||||||
|
|
||||||
// 生成JWT Token(为了兼容系统中其他逻辑,使用 user.getUsername() 作为 token subject)
|
// 生成JWT Token(为了兼容系统中其他逻辑,使用 user.getUsername() 作为 token subject)
|
||||||
String token = jwtUtils.generateToken(user.getUsername(), user.getRole(), user.getId());
|
String token = jwtUtils.generateToken(user.getUsername(), user.getRole(), user.getId(), expireMs);
|
||||||
|
|
||||||
|
// 将 token 保存到 Redis
|
||||||
|
redisTokenService.saveToken(user.getUsername(), token, expireSeconds);
|
||||||
|
|
||||||
Map<String, Object> body = new HashMap<>();
|
Map<String, Object> body = new HashMap<>();
|
||||||
body.put("success", true);
|
body.put("success", true);
|
||||||
@@ -216,8 +234,17 @@ public class AuthApiController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取动态配置的过期时间
|
||||||
|
SystemSettings settings = systemSettingsService.getOrCreate();
|
||||||
|
int expireHours = settings.getTokenExpireHours() != null ? settings.getTokenExpireHours() : 24;
|
||||||
|
long expireMs = expireHours * 60L * 60L * 1000L; // 转换为毫秒
|
||||||
|
long expireSeconds = expireHours * 60L * 60L; // 转换为秒
|
||||||
|
|
||||||
// 生成JWT Token
|
// 生成JWT Token
|
||||||
String token = jwtUtils.generateToken(user.getUsername(), user.getRole(), user.getId());
|
String token = jwtUtils.generateToken(user.getUsername(), user.getRole(), user.getId(), expireMs);
|
||||||
|
|
||||||
|
// 将 token 保存到 Redis
|
||||||
|
redisTokenService.saveToken(user.getUsername(), token, expireSeconds);
|
||||||
|
|
||||||
// 检查是否需要设置密码(首次登录的用户密码为空)
|
// 检查是否需要设置密码(首次登录的用户密码为空)
|
||||||
boolean needsPasswordChange = user.getPasswordHash() == null || user.getPasswordHash().isEmpty();
|
boolean needsPasswordChange = user.getPasswordHash() == null || user.getPasswordHash().isEmpty();
|
||||||
@@ -309,12 +336,71 @@ public class AuthApiController {
|
|||||||
* 用户登出
|
* 用户登出
|
||||||
*/
|
*/
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ResponseEntity<Map<String, Object>> logout() {
|
public ResponseEntity<Map<String, Object>> logout(Authentication authentication, HttpServletRequest request) {
|
||||||
Map<String, Object> response = new HashMap<>();
|
try {
|
||||||
response.put("success", true);
|
String username = null;
|
||||||
response.put("message", "登出成功");
|
String token = null;
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
// 从请求头获取 token
|
||||||
|
String authHeader = request.getHeader("Authorization");
|
||||||
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
|
token = authHeader.substring(7);
|
||||||
|
// 从 token 中获取用户名
|
||||||
|
try {
|
||||||
|
username = jwtUtils.getUsernameFromToken(token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("从 token 获取用户名失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果从 token 获取失败,尝试从 Authentication 获取
|
||||||
|
if (username == null && authentication != null && authentication.isAuthenticated()) {
|
||||||
|
username = authentication.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Redis 删除 token
|
||||||
|
if (username != null && token != null) {
|
||||||
|
redisTokenService.removeToken(username, token);
|
||||||
|
logger.info("用户登出成功,token 已从 Redis 删除: username={}", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "登出成功");
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("登出失败:", e);
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true); // 即使出错也返回成功,因为客户端无论如何都会清除本地 token
|
||||||
|
response.put("message", "登出成功");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制登出所有设备
|
||||||
|
* 删除用户在 Redis 中的所有 token
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout/all")
|
||||||
|
public ResponseEntity<Map<String, Object>> logoutAll(Authentication authentication) {
|
||||||
|
try {
|
||||||
|
if (authentication != null && authentication.isAuthenticated()) {
|
||||||
|
String username = authentication.getName();
|
||||||
|
redisTokenService.removeAllTokens(username);
|
||||||
|
logger.info("用户所有设备登出成功: username={}", username);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "所有设备已登出");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.badRequest().body(createErrorResponse("用户未登录"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("强制登出所有设备失败:", e);
|
||||||
|
return ResponseEntity.badRequest().body(createErrorResponse("登出失败:" + e.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ public class StoryboardVideoApiController {
|
|||||||
@RequestBody Map<String, Object> request,
|
@RequestBody Map<String, Object> request,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
try {
|
try {
|
||||||
|
// 检查用户是否已认证
|
||||||
|
if (authentication == null) {
|
||||||
|
logger.warn("创建分镜视频任务失败: 用户未登录");
|
||||||
|
return ResponseEntity.status(401)
|
||||||
|
.body(Map.of("success", false, "message", "用户未登录,请先登录"));
|
||||||
|
}
|
||||||
String username = authentication.getName();
|
String username = authentication.getName();
|
||||||
logger.info("收到创建分镜视频任务请求,用户: {}", username);
|
logger.info("收到创建分镜视频任务请求,用户: {}", username);
|
||||||
|
|
||||||
@@ -47,6 +53,10 @@ public class StoryboardVideoApiController {
|
|||||||
String imageUrl = (String) request.get("imageUrl");
|
String imageUrl = (String) request.get("imageUrl");
|
||||||
String imageModel = (String) request.getOrDefault("imageModel", "nano-banana");
|
String imageModel = (String) request.getOrDefault("imageModel", "nano-banana");
|
||||||
|
|
||||||
|
// 提取用户上传的多张图片(新增)
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> uploadedImages = (List<String>) request.get("uploadedImages");
|
||||||
|
|
||||||
// 提取duration参数,支持多种类型
|
// 提取duration参数,支持多种类型
|
||||||
Integer duration = 10; // 默认10秒
|
Integer duration = 10; // 默认10秒
|
||||||
Object durationObj = request.get("duration");
|
Object durationObj = request.get("duration");
|
||||||
@@ -59,16 +69,17 @@ public class StoryboardVideoApiController {
|
|||||||
logger.warn("无效的duration参数: {}, 使用默认值10", durationObj);
|
logger.warn("无效的duration参数: {}, 使用默认值10", durationObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info("任务参数 - duration: {}, aspectRatio: {}, hdMode: {}, imageModel: {}", duration, aspectRatio, hdMode, imageModel);
|
logger.info("任务参数 - duration: {}, aspectRatio: {}, hdMode: {}, imageModel: {}, uploadedImages: {}",
|
||||||
|
duration, aspectRatio, hdMode, imageModel, uploadedImages != null ? uploadedImages.size() : 0);
|
||||||
|
|
||||||
if (prompt == null || prompt.trim().isEmpty()) {
|
if (prompt == null || prompt.trim().isEmpty()) {
|
||||||
return ResponseEntity.badRequest()
|
return ResponseEntity.badRequest()
|
||||||
.body(Map.of("success", false, "message", "提示词不能为空"));
|
.body(Map.of("success", false, "message", "提示词不能为空"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建任务
|
// 创建任务(传递上传的图片列表)
|
||||||
StoryboardVideoTask task = storyboardVideoService.createTask(
|
StoryboardVideoTask task = storyboardVideoService.createTask(
|
||||||
username, prompt, aspectRatio, hdMode != null && hdMode, imageUrl, duration, imageModel
|
username, prompt, aspectRatio, hdMode != null && hdMode, imageUrl, duration, imageModel, uploadedImages
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
@@ -118,7 +129,8 @@ public class StoryboardVideoApiController {
|
|||||||
taskData.put("status", task.getStatus());
|
taskData.put("status", task.getStatus());
|
||||||
taskData.put("progress", task.getProgress());
|
taskData.put("progress", task.getProgress());
|
||||||
taskData.put("resultUrl", task.getResultUrl());
|
taskData.put("resultUrl", task.getResultUrl());
|
||||||
taskData.put("imageUrl", task.getImageUrl()); // 参考图片
|
taskData.put("imageUrl", task.getImageUrl()); // 参考图片(旧字段)
|
||||||
|
taskData.put("uploadedImages", task.getUploadedImages()); // 用户上传的参考图(JSON数组)
|
||||||
taskData.put("prompt", task.getPrompt());
|
taskData.put("prompt", task.getPrompt());
|
||||||
taskData.put("aspectRatio", task.getAspectRatio());
|
taskData.put("aspectRatio", task.getAspectRatio());
|
||||||
taskData.put("hdMode", task.isHdMode());
|
taskData.put("hdMode", task.isHdMode());
|
||||||
@@ -127,6 +139,10 @@ public class StoryboardVideoApiController {
|
|||||||
taskData.put("createdAt", task.getCreatedAt());
|
taskData.put("createdAt", task.getCreatedAt());
|
||||||
taskData.put("updatedAt", task.getUpdatedAt());
|
taskData.put("updatedAt", task.getUpdatedAt());
|
||||||
taskData.put("completedAt", task.getCompletedAt());
|
taskData.put("completedAt", task.getCompletedAt());
|
||||||
|
// 大模型优化后的提示词字段
|
||||||
|
taskData.put("shotList", task.getShotList());
|
||||||
|
taskData.put("imagePrompt", task.getImagePrompt());
|
||||||
|
taskData.put("videoPrompt", task.getVideoPrompt());
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"success", true,
|
"success", true,
|
||||||
@@ -195,6 +211,7 @@ public class StoryboardVideoApiController {
|
|||||||
Integer duration = null;
|
Integer duration = null;
|
||||||
String aspectRatio = null;
|
String aspectRatio = null;
|
||||||
Boolean hdMode = null;
|
Boolean hdMode = null;
|
||||||
|
java.util.List<String> referenceImages = null;
|
||||||
|
|
||||||
if (requestBody != null) {
|
if (requestBody != null) {
|
||||||
if (requestBody.containsKey("duration")) {
|
if (requestBody.containsKey("duration")) {
|
||||||
@@ -214,10 +231,17 @@ public class StoryboardVideoApiController {
|
|||||||
hdMode = (Boolean) requestBody.get("hdMode");
|
hdMode = (Boolean) requestBody.get("hdMode");
|
||||||
logger.info("高清模式参数: {}", hdMode);
|
logger.info("高清模式参数: {}", hdMode);
|
||||||
}
|
}
|
||||||
|
if (requestBody.containsKey("referenceImages")) {
|
||||||
|
Object refImagesObj = requestBody.get("referenceImages");
|
||||||
|
if (refImagesObj instanceof java.util.List) {
|
||||||
|
referenceImages = (java.util.List<String>) refImagesObj;
|
||||||
|
logger.info("参考图数量: {}", referenceImages.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始生成视频,传递参数
|
// 开始生成视频,传递参数(包括参考图)
|
||||||
storyboardVideoService.startVideoGeneration(taskId, duration, aspectRatio, hdMode);
|
storyboardVideoService.startVideoGeneration(taskId, duration, aspectRatio, hdMode, referenceImages);
|
||||||
|
|
||||||
return ResponseEntity.ok(Map.of(
|
return ResponseEntity.ok(Map.of(
|
||||||
"success", true,
|
"success", true,
|
||||||
@@ -234,4 +258,56 @@ public class StoryboardVideoApiController {
|
|||||||
.body(Map.of("success", false, "message", "启动视频生成失败"));
|
.body(Map.of("success", false, "message", "启动视频生成失败"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试失败的分镜视频任务
|
||||||
|
*/
|
||||||
|
@PostMapping("/task/{taskId}/retry")
|
||||||
|
public ResponseEntity<?> retryTask(
|
||||||
|
@PathVariable String taskId,
|
||||||
|
Authentication authentication) {
|
||||||
|
|
||||||
|
logger.info("收到重试任务请求,任务ID: {}", taskId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查用户是否已认证
|
||||||
|
if (authentication == null) {
|
||||||
|
return ResponseEntity.status(401)
|
||||||
|
.body(Map.of("success", false, "message", "请先登录"));
|
||||||
|
}
|
||||||
|
String username = authentication.getName();
|
||||||
|
|
||||||
|
// 验证任务是否存在且属于该用户
|
||||||
|
StoryboardVideoTask task = storyboardVideoService.getTask(taskId);
|
||||||
|
if (!task.getUsername().equals(username)) {
|
||||||
|
logger.warn("用户 {} 尝试重试任务 {},但任务属于用户 {}", username, taskId, task.getUsername());
|
||||||
|
return ResponseEntity.status(403)
|
||||||
|
.body(Map.of("success", false, "message", "无权操作此任务"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证任务状态必须是 FAILED
|
||||||
|
if (task.getStatus() != StoryboardVideoTask.TaskStatus.FAILED) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(Map.of("success", false, "message", "只能重试失败的任务"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用重试服务
|
||||||
|
storyboardVideoService.retryTask(taskId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"message", "重试任务已提交",
|
||||||
|
"data", Map.of("taskId", taskId)
|
||||||
|
));
|
||||||
|
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.error("重试任务失败: {}", e.getMessage());
|
||||||
|
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", "重试任务失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package com.example.demo.controller;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
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.model.UserErrorLog;
|
||||||
|
import com.example.demo.model.UserErrorLog.ErrorType;
|
||||||
|
import com.example.demo.service.UserErrorLogService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户错误日志API控制器
|
||||||
|
* 提供错误日志的查询和管理接口
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/error-logs")
|
||||||
|
public class UserErrorLogController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(UserErrorLogController.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserErrorLogService userErrorLogService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取错误日志列表(分页)
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> getErrorLogs(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
try {
|
||||||
|
Page<UserErrorLog> errors = userErrorLogService.getAllErrors(page, size);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", errors.getContent());
|
||||||
|
response.put("totalElements", errors.getTotalElements());
|
||||||
|
response.put("totalPages", errors.getTotalPages());
|
||||||
|
response.put("currentPage", page);
|
||||||
|
response.put("pageSize", size);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取错误日志列表失败: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "获取错误日志失败: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的错误日志
|
||||||
|
*/
|
||||||
|
@GetMapping("/user/{username}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getUserErrorLogs(
|
||||||
|
@PathVariable String username,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
try {
|
||||||
|
Page<UserErrorLog> errors = userErrorLogService.getUserErrors(username, page, size);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", errors.getContent());
|
||||||
|
response.put("totalElements", errors.getTotalElements());
|
||||||
|
response.put("totalPages", errors.getTotalPages());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取用户错误日志失败: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "获取错误日志失败: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按错误类型查询
|
||||||
|
*/
|
||||||
|
@GetMapping("/type/{errorType}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getErrorsByType(
|
||||||
|
@PathVariable String errorType,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
try {
|
||||||
|
ErrorType type = ErrorType.valueOf(errorType.toUpperCase());
|
||||||
|
Page<UserErrorLog> errors = userErrorLogService.getErrorsByType(type, page, size);
|
||||||
|
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("data", errors.getContent());
|
||||||
|
response.put("totalElements", errors.getTotalElements());
|
||||||
|
response.put("totalPages", errors.getTotalPages());
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "无效的错误类型: " + errorType
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("按类型查询错误日志失败: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "查询失败: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最近的错误
|
||||||
|
*/
|
||||||
|
@GetMapping("/recent")
|
||||||
|
public ResponseEntity<Map<String, Object>> getRecentErrors(
|
||||||
|
@RequestParam(defaultValue = "10") int limit) {
|
||||||
|
try {
|
||||||
|
List<UserErrorLog> errors = userErrorLogService.getRecentErrors(limit);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"data", errors
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取最近错误失败: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "获取失败: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按任务ID查询错误
|
||||||
|
*/
|
||||||
|
@GetMapping("/task/{taskId}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getErrorsByTaskId(@PathVariable String taskId) {
|
||||||
|
try {
|
||||||
|
List<UserErrorLog> errors = userErrorLogService.getErrorsByTaskId(taskId);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"data", errors
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("按任务ID查询错误失败: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "查询失败: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按日期范围查询
|
||||||
|
*/
|
||||||
|
@GetMapping("/date-range")
|
||||||
|
public ResponseEntity<Map<String, Object>> getErrorsByDateRange(
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) {
|
||||||
|
try {
|
||||||
|
List<UserErrorLog> errors = userErrorLogService.getErrorsByDateRange(startDate, endDate);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"data", errors,
|
||||||
|
"count", errors.size()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("按日期范围查询错误失败: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "查询失败: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取错误统计
|
||||||
|
*/
|
||||||
|
@GetMapping("/statistics")
|
||||||
|
public ResponseEntity<Map<String, Object>> getErrorStatistics(
|
||||||
|
@RequestParam(defaultValue = "7") int days) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> stats = userErrorLogService.getErrorStatistics(days);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"data", stats,
|
||||||
|
"period", days + " days"
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取错误统计失败: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "统计失败: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户错误统计
|
||||||
|
*/
|
||||||
|
@GetMapping("/statistics/user/{username}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getUserErrorStatistics(@PathVariable String username) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> stats = userErrorLogService.getUserErrorStatistics(username);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"data", stats
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("获取用户错误统计失败: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(Map.of(
|
||||||
|
"success", false,
|
||||||
|
"message", "统计失败: " + e.getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有错误类型
|
||||||
|
*/
|
||||||
|
@GetMapping("/types")
|
||||||
|
public ResponseEntity<Map<String, Object>> getErrorTypes() {
|
||||||
|
Map<String, String> types = new HashMap<>();
|
||||||
|
for (ErrorType type : ErrorType.values()) {
|
||||||
|
types.put(type.name(), type.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"data", types
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -118,16 +118,6 @@ public class UserWorkApiController {
|
|||||||
}
|
}
|
||||||
Map<String, Object> workStats = userWorkService.getUserWorkStats(username);
|
Map<String, Object> workStats = userWorkService.getUserWorkStats(username);
|
||||||
|
|
||||||
// 调试日志:检查返回的作品数据
|
|
||||||
logger.info("获取用户作品列表: username={}, page={}, size={}, total={}",
|
|
||||||
username, page, size, works.getTotalElements());
|
|
||||||
works.getContent().forEach(work -> {
|
|
||||||
logger.info("作品详情: id={}, taskId={}, status={}, resultUrl={}, workType={}",
|
|
||||||
work.getId(), work.getTaskId(), work.getStatus(),
|
|
||||||
work.getResultUrl() != null ? work.getResultUrl().substring(0, Math.min(50, work.getResultUrl().length())) : "NULL",
|
|
||||||
work.getWorkType());
|
|
||||||
});
|
|
||||||
|
|
||||||
response.put("success", true);
|
response.put("success", true);
|
||||||
response.put("data", works.getContent());
|
response.put("data", works.getContent());
|
||||||
response.put("totalElements", works.getTotalElements());
|
response.put("totalElements", works.getTotalElements());
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ public class StoryboardVideoTask {
|
|||||||
@Column(name = "image_model", length = 50)
|
@Column(name = "image_model", length = 50)
|
||||||
private String imageModel = "nano-banana"; // 图像生成模型:nano-banana, nano-banana2
|
private String imageModel = "nano-banana"; // 图像生成模型:nano-banana, nano-banana2
|
||||||
|
|
||||||
|
@Column(name = "uploaded_images", columnDefinition = "LONGTEXT")
|
||||||
|
private String uploadedImages; // 用户上传的多张参考图片(JSON数组,最多3张Base64格式)
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false, length = 20)
|
@Column(nullable = false, length = 20)
|
||||||
private TaskStatus status;
|
private TaskStatus status;
|
||||||
@@ -71,6 +74,16 @@ public class StoryboardVideoTask {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String errorMessage;
|
private String errorMessage;
|
||||||
|
|
||||||
|
// 大模型优化后的提示词字段
|
||||||
|
@Column(name = "shot_list", columnDefinition = "TEXT")
|
||||||
|
private String shotList; // 镜头列表描述
|
||||||
|
|
||||||
|
@Column(name = "image_prompt", columnDefinition = "TEXT")
|
||||||
|
private String imagePrompt; // 生成分镜图的提示词
|
||||||
|
|
||||||
|
@Column(name = "video_prompt", columnDefinition = "TEXT")
|
||||||
|
private String videoPrompt; // 生成视频的提示词
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private int costPoints; // 消耗积分
|
private int costPoints; // 消耗积分
|
||||||
|
|
||||||
@@ -173,6 +186,8 @@ public class StoryboardVideoTask {
|
|||||||
public void setDuration(Integer duration) { this.duration = duration; }
|
public void setDuration(Integer duration) { this.duration = duration; }
|
||||||
public String getImageModel() { return imageModel; }
|
public String getImageModel() { return imageModel; }
|
||||||
public void setImageModel(String imageModel) { this.imageModel = imageModel; }
|
public void setImageModel(String imageModel) { this.imageModel = imageModel; }
|
||||||
|
public String getUploadedImages() { return uploadedImages; }
|
||||||
|
public void setUploadedImages(String uploadedImages) { this.uploadedImages = uploadedImages; }
|
||||||
public TaskStatus getStatus() { return status; }
|
public TaskStatus getStatus() { return status; }
|
||||||
public void setStatus(TaskStatus status) { this.status = status; }
|
public void setStatus(TaskStatus status) { this.status = status; }
|
||||||
public int getProgress() { return progress; }
|
public int getProgress() { return progress; }
|
||||||
@@ -199,4 +214,10 @@ public class StoryboardVideoTask {
|
|||||||
public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; }
|
public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; }
|
||||||
public SourceType getSourceType() { return sourceType; }
|
public SourceType getSourceType() { return sourceType; }
|
||||||
public void setSourceType(SourceType sourceType) { this.sourceType = sourceType; }
|
public void setSourceType(SourceType sourceType) { this.sourceType = sourceType; }
|
||||||
|
public String getShotList() { return shotList; }
|
||||||
|
public void setShotList(String shotList) { this.shotList = shotList; }
|
||||||
|
public String getImagePrompt() { return imagePrompt; }
|
||||||
|
public void setImagePrompt(String imagePrompt) { this.imagePrompt = imagePrompt; }
|
||||||
|
public String getVideoPrompt() { return videoPrompt; }
|
||||||
|
public void setVideoPrompt(String videoPrompt) { this.videoPrompt = videoPrompt; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,9 +78,11 @@ public class SystemSettings {
|
|||||||
@Column(length = 2000)
|
@Column(length = 2000)
|
||||||
private String storyboardSystemPrompt = "";
|
private String storyboardSystemPrompt = "";
|
||||||
|
|
||||||
/** 优化提示词功能的系统提示词(指导AI如何优化) */
|
/** Token过期时间(小时),范围1-720小时,默认24小时 */
|
||||||
@Column(length = 4000)
|
@NotNull
|
||||||
private String promptOptimizationSystemPrompt = "";
|
@Min(1)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer tokenExpireHours = 24;
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
@@ -194,12 +196,15 @@ public class SystemSettings {
|
|||||||
this.storyboardSystemPrompt = storyboardSystemPrompt;
|
this.storyboardSystemPrompt = storyboardSystemPrompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getPromptOptimizationSystemPrompt() {
|
public Integer getTokenExpireHours() {
|
||||||
return promptOptimizationSystemPrompt;
|
return tokenExpireHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setPromptOptimizationSystemPrompt(String promptOptimizationSystemPrompt) {
|
public void setTokenExpireHours(Integer tokenExpireHours) {
|
||||||
this.promptOptimizationSystemPrompt = promptOptimizationSystemPrompt;
|
// 限制范围在1-720小时(1小时到30天)
|
||||||
|
if (tokenExpireHours != null && tokenExpireHours >= 1 && tokenExpireHours <= 720) {
|
||||||
|
this.tokenExpireHours = tokenExpireHours;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ public class User {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
|
// 自动生成业务用户ID(如果未设置)
|
||||||
|
if (userId == null || userId.isEmpty()) {
|
||||||
|
userId = com.example.demo.util.UserIdGenerator.generate();
|
||||||
|
}
|
||||||
if (createdAt == null) {
|
if (createdAt == null) {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
}
|
}
|
||||||
|
|||||||
292
demo/src/main/java/com/example/demo/model/UserErrorLog.java
Normal file
292
demo/src/main/java/com/example/demo/model/UserErrorLog.java
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package com.example.demo.model;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Index;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户错误日志实体
|
||||||
|
* 用于记录和统计用户操作过程中产生的错误
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "user_error_log", indexes = {
|
||||||
|
@Index(name = "idx_username", columnList = "username"),
|
||||||
|
@Index(name = "idx_error_type", columnList = "error_type"),
|
||||||
|
@Index(name = "idx_created_at", columnList = "created_at"),
|
||||||
|
@Index(name = "idx_error_source", columnList = "error_source")
|
||||||
|
})
|
||||||
|
public class UserErrorLog {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "username", length = 100)
|
||||||
|
private String username; // 可能为空(未登录用户)
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "error_type", nullable = false, length = 50)
|
||||||
|
private ErrorType errorType;
|
||||||
|
|
||||||
|
@Column(name = "error_code", length = 50)
|
||||||
|
private String errorCode;
|
||||||
|
|
||||||
|
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
@Column(name = "error_source", nullable = false, length = 100)
|
||||||
|
private String errorSource; // 错误来源(服务类名或接口路径)
|
||||||
|
|
||||||
|
@Column(name = "task_id", length = 100)
|
||||||
|
private String taskId; // 关联的任务ID(如果有)
|
||||||
|
|
||||||
|
@Column(name = "task_type", length = 50)
|
||||||
|
private String taskType; // 任务类型
|
||||||
|
|
||||||
|
@Column(name = "request_path", length = 500)
|
||||||
|
private String requestPath; // 请求路径
|
||||||
|
|
||||||
|
@Column(name = "request_method", length = 10)
|
||||||
|
private String requestMethod; // 请求方法 GET/POST等
|
||||||
|
|
||||||
|
@Column(name = "request_params", columnDefinition = "TEXT")
|
||||||
|
private String requestParams; // 请求参数(JSON格式,敏感信息需脱敏)
|
||||||
|
|
||||||
|
@Column(name = "stack_trace", columnDefinition = "TEXT")
|
||||||
|
private String stackTrace; // 堆栈跟踪(可选,用于调试)
|
||||||
|
|
||||||
|
@Column(name = "ip_address", length = 50)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
@Column(name = "user_agent", length = 500)
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误类型枚举
|
||||||
|
*/
|
||||||
|
public enum ErrorType {
|
||||||
|
// 任务相关错误
|
||||||
|
TASK_SUBMIT_ERROR("任务提交失败"),
|
||||||
|
TASK_PROCESSING_ERROR("任务处理失败"),
|
||||||
|
TASK_TIMEOUT("任务超时"),
|
||||||
|
TASK_CANCELLED("任务取消"),
|
||||||
|
|
||||||
|
// API相关错误
|
||||||
|
API_CALL_ERROR("API调用失败"),
|
||||||
|
API_RESPONSE_ERROR("API响应异常"),
|
||||||
|
API_TIMEOUT("API超时"),
|
||||||
|
|
||||||
|
// 支付相关错误
|
||||||
|
PAYMENT_ERROR("支付失败"),
|
||||||
|
PAYMENT_CALLBACK_ERROR("支付回调异常"),
|
||||||
|
REFUND_ERROR("退款失败"),
|
||||||
|
|
||||||
|
// 认证相关错误
|
||||||
|
AUTH_ERROR("认证失败"),
|
||||||
|
TOKEN_EXPIRED("Token过期"),
|
||||||
|
PERMISSION_DENIED("权限不足"),
|
||||||
|
|
||||||
|
// 数据相关错误
|
||||||
|
DATA_VALIDATION_ERROR("数据验证失败"),
|
||||||
|
DATA_NOT_FOUND("数据未找到"),
|
||||||
|
DATA_CONFLICT("数据冲突"),
|
||||||
|
|
||||||
|
// 文件相关错误
|
||||||
|
FILE_UPLOAD_ERROR("文件上传失败"),
|
||||||
|
FILE_DOWNLOAD_ERROR("文件下载失败"),
|
||||||
|
FILE_PROCESS_ERROR("文件处理失败"),
|
||||||
|
|
||||||
|
// 系统错误
|
||||||
|
SYSTEM_ERROR("系统错误"),
|
||||||
|
DATABASE_ERROR("数据库错误"),
|
||||||
|
NETWORK_ERROR("网络错误"),
|
||||||
|
|
||||||
|
// 其他
|
||||||
|
UNKNOWN("未知错误");
|
||||||
|
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
ErrorType(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造函数
|
||||||
|
public UserErrorLog() {
|
||||||
|
this.createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserErrorLog(String username, ErrorType errorType, String errorMessage, String errorSource) {
|
||||||
|
this();
|
||||||
|
this.username = username;
|
||||||
|
this.errorType = errorType;
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
this.errorSource = errorSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静态工厂方法 - 快速创建任务错误日志
|
||||||
|
public static UserErrorLog createTaskError(String username, String taskId, String taskType,
|
||||||
|
ErrorType errorType, String errorMessage) {
|
||||||
|
UserErrorLog log = new UserErrorLog(username, errorType, errorMessage, "TaskService");
|
||||||
|
log.setTaskId(taskId);
|
||||||
|
log.setTaskType(taskType);
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静态工厂方法 - 快速创建API错误日志
|
||||||
|
public static UserErrorLog createApiError(String username, String requestPath,
|
||||||
|
String errorMessage, String errorCode) {
|
||||||
|
UserErrorLog log = new UserErrorLog(username, ErrorType.API_CALL_ERROR, errorMessage, "ApiService");
|
||||||
|
log.setRequestPath(requestPath);
|
||||||
|
log.setErrorCode(errorCode);
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsername(String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ErrorType getErrorType() {
|
||||||
|
return errorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorType(ErrorType errorType) {
|
||||||
|
this.errorType = errorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorCode() {
|
||||||
|
return errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorCode(String errorCode) {
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorMessage() {
|
||||||
|
return errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorMessage(String errorMessage) {
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorSource() {
|
||||||
|
return errorSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorSource(String errorSource) {
|
||||||
|
this.errorSource = errorSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTaskId() {
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskId(String taskId) {
|
||||||
|
this.taskId = taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTaskType() {
|
||||||
|
return taskType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskType(String taskType) {
|
||||||
|
this.taskType = taskType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequestPath() {
|
||||||
|
return requestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequestPath(String requestPath) {
|
||||||
|
this.requestPath = requestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequestMethod() {
|
||||||
|
return requestMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequestMethod(String requestMethod) {
|
||||||
|
this.requestMethod = requestMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRequestParams() {
|
||||||
|
return requestParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequestParams(String requestParams) {
|
||||||
|
this.requestParams = requestParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStackTrace() {
|
||||||
|
return stackTrace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStackTrace(String stackTrace) {
|
||||||
|
this.stackTrace = stackTrace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIpAddress() {
|
||||||
|
return ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIpAddress(String ipAddress) {
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserAgent() {
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserAgent(String userAgent) {
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "UserErrorLog{" +
|
||||||
|
"id=" + id +
|
||||||
|
", username='" + username + '\'' +
|
||||||
|
", errorType=" + errorType +
|
||||||
|
", errorCode='" + errorCode + '\'' +
|
||||||
|
", errorSource='" + errorSource + '\'' +
|
||||||
|
", createdAt=" + createdAt +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,12 @@ public class UserWork {
|
|||||||
@Column(name = "prompt", columnDefinition = "TEXT")
|
@Column(name = "prompt", columnDefinition = "TEXT")
|
||||||
private String prompt; // 生成提示词
|
private String prompt; // 生成提示词
|
||||||
|
|
||||||
|
@Column(name = "image_prompt", columnDefinition = "TEXT")
|
||||||
|
private String imagePrompt; // 优化后的分镜图提示词
|
||||||
|
|
||||||
|
@Column(name = "video_prompt", columnDefinition = "TEXT")
|
||||||
|
private String videoPrompt; // 优化后的视频提示词
|
||||||
|
|
||||||
@Column(name = "result_url", columnDefinition = "LONGTEXT")
|
@Column(name = "result_url", columnDefinition = "LONGTEXT")
|
||||||
private String resultUrl; // 结果视频URL
|
private String resultUrl; // 结果视频URL
|
||||||
|
|
||||||
@@ -252,6 +258,22 @@ public class UserWork {
|
|||||||
this.prompt = prompt;
|
this.prompt = prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getImagePrompt() {
|
||||||
|
return imagePrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setImagePrompt(String imagePrompt) {
|
||||||
|
this.imagePrompt = imagePrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVideoPrompt() {
|
||||||
|
return videoPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVideoPrompt(String videoPrompt) {
|
||||||
|
this.videoPrompt = videoPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
public String getResultUrl() {
|
public String getResultUrl() {
|
||||||
return resultUrl;
|
return resultUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ public interface TaskStatusRepository extends JpaRepository<TaskStatus, Long> {
|
|||||||
*/
|
*/
|
||||||
Optional<TaskStatus> findByTaskId(String taskId);
|
Optional<TaskStatus> findByTaskId(String taskId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据任务ID查找状态(取第一条,处理重复记录情况)
|
||||||
|
*/
|
||||||
|
Optional<TaskStatus> findFirstByTaskIdOrderByIdDesc(String taskId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据用户名查找所有任务状态
|
* 根据用户名查找所有任务状态
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.example.demo.repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import com.example.demo.model.UserErrorLog;
|
||||||
|
import com.example.demo.model.UserErrorLog.ErrorType;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface UserErrorLogRepository extends JpaRepository<UserErrorLog, Long> {
|
||||||
|
|
||||||
|
// 按用户名查询
|
||||||
|
List<UserErrorLog> findByUsernameOrderByCreatedAtDesc(String username);
|
||||||
|
|
||||||
|
Page<UserErrorLog> findByUsername(String username, Pageable pageable);
|
||||||
|
|
||||||
|
// 按错误类型查询
|
||||||
|
List<UserErrorLog> findByErrorTypeOrderByCreatedAtDesc(ErrorType errorType);
|
||||||
|
|
||||||
|
Page<UserErrorLog> findByErrorType(ErrorType errorType, Pageable pageable);
|
||||||
|
|
||||||
|
// 按时间范围查询
|
||||||
|
List<UserErrorLog> findByCreatedAtBetweenOrderByCreatedAtDesc(
|
||||||
|
LocalDateTime startTime, LocalDateTime endTime);
|
||||||
|
|
||||||
|
// 按用户和时间范围查询
|
||||||
|
List<UserErrorLog> findByUsernameAndCreatedAtBetweenOrderByCreatedAtDesc(
|
||||||
|
String username, LocalDateTime startTime, LocalDateTime endTime);
|
||||||
|
|
||||||
|
// 按错误来源查询
|
||||||
|
List<UserErrorLog> findByErrorSourceOrderByCreatedAtDesc(String errorSource);
|
||||||
|
|
||||||
|
// 按任务ID查询
|
||||||
|
List<UserErrorLog> findByTaskIdOrderByCreatedAtDesc(String taskId);
|
||||||
|
|
||||||
|
// 统计:按错误类型分组统计数量
|
||||||
|
@Query("SELECT e.errorType, COUNT(e) FROM UserErrorLog e " +
|
||||||
|
"WHERE e.createdAt >= :startTime " +
|
||||||
|
"GROUP BY e.errorType ORDER BY COUNT(e) DESC")
|
||||||
|
List<Object[]> countByErrorType(@Param("startTime") LocalDateTime startTime);
|
||||||
|
|
||||||
|
// 统计:按用户分组统计错误数量
|
||||||
|
@Query("SELECT e.username, COUNT(e) FROM UserErrorLog e " +
|
||||||
|
"WHERE e.createdAt >= :startTime AND e.username IS NOT NULL " +
|
||||||
|
"GROUP BY e.username ORDER BY COUNT(e) DESC")
|
||||||
|
List<Object[]> countByUsername(@Param("startTime") LocalDateTime startTime);
|
||||||
|
|
||||||
|
// 统计:按错误来源分组统计
|
||||||
|
@Query("SELECT e.errorSource, COUNT(e) FROM UserErrorLog e " +
|
||||||
|
"WHERE e.createdAt >= :startTime " +
|
||||||
|
"GROUP BY e.errorSource ORDER BY COUNT(e) DESC")
|
||||||
|
List<Object[]> countByErrorSource(@Param("startTime") LocalDateTime startTime);
|
||||||
|
|
||||||
|
// 统计:按日期分组统计错误数量
|
||||||
|
@Query("SELECT FUNCTION('DATE', e.createdAt), COUNT(e) FROM UserErrorLog e " +
|
||||||
|
"WHERE e.createdAt >= :startTime " +
|
||||||
|
"GROUP BY FUNCTION('DATE', e.createdAt) ORDER BY FUNCTION('DATE', e.createdAt) DESC")
|
||||||
|
List<Object[]> countByDate(@Param("startTime") LocalDateTime startTime);
|
||||||
|
|
||||||
|
// 统计:指定时间范围内的错误总数
|
||||||
|
long countByCreatedAtBetween(LocalDateTime startTime, LocalDateTime endTime);
|
||||||
|
|
||||||
|
// 统计:指定用户的错误总数
|
||||||
|
long countByUsername(String username);
|
||||||
|
|
||||||
|
// 删除:清理指定时间之前的错误(用于日志清理)
|
||||||
|
void deleteByCreatedAtBefore(LocalDateTime beforeTime);
|
||||||
|
|
||||||
|
// 最近N条错误
|
||||||
|
List<UserErrorLog> findTop10ByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
List<UserErrorLog> findTop50ByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
// 用户最近的错误
|
||||||
|
List<UserErrorLog> findTop10ByUsernameOrderByCreatedAtDesc(String username);
|
||||||
|
}
|
||||||
@@ -21,9 +21,11 @@ import com.example.demo.model.UserWork;
|
|||||||
public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
|
public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据用户名查找作品
|
* 根据用户名查找作品(必须有可显示的内容)
|
||||||
|
* - 分镜视频(STORYBOARD_VIDEO):必须有视频URL(排除只有图片URL的)
|
||||||
|
* - 其他类型:必须有 resultUrl
|
||||||
*/
|
*/
|
||||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status NOT IN ('DELETED', 'FAILED') ORDER BY uw.createdAt DESC")
|
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status NOT IN ('DELETED', 'FAILED') AND uw.resultUrl IS NOT NULL AND uw.resultUrl != '' AND (uw.workType != 'STORYBOARD_VIDEO' OR uw.resultUrl NOT LIKE '%.png') ORDER BY uw.createdAt DESC")
|
||||||
Page<UserWork> findByUsernameOrderByCreatedAtDesc(@Param("username") String username, Pageable pageable);
|
Page<UserWork> findByUsernameOrderByCreatedAtDesc(@Param("username") String username, Pageable pageable);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.springframework.stereotype.Component;
|
|||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
import com.example.demo.model.User;
|
import com.example.demo.model.User;
|
||||||
|
import com.example.demo.service.RedisTokenService;
|
||||||
import com.example.demo.service.UserService;
|
import com.example.demo.service.UserService;
|
||||||
import com.example.demo.util.JwtUtils;
|
import com.example.demo.util.JwtUtils;
|
||||||
|
|
||||||
@@ -31,10 +32,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
private final JwtUtils jwtUtils;
|
private final JwtUtils jwtUtils;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final RedisTokenService redisTokenService;
|
||||||
|
|
||||||
public JwtAuthenticationFilter(JwtUtils jwtUtils, UserService userService) {
|
public JwtAuthenticationFilter(JwtUtils jwtUtils, UserService userService, RedisTokenService redisTokenService) {
|
||||||
this.jwtUtils = jwtUtils;
|
this.jwtUtils = jwtUtils;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
|
this.redisTokenService = redisTokenService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -82,6 +85,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
String username = jwtUtils.getUsernameFromToken(token);
|
String username = jwtUtils.getUsernameFromToken(token);
|
||||||
|
|
||||||
if (username != null && jwtUtils.validateToken(token, username)) {
|
if (username != null && jwtUtils.validateToken(token, username)) {
|
||||||
|
// Redis 验证已降级:isTokenValid 总是返回 true
|
||||||
|
// 主要依赖 JWT 本身的有效性验证
|
||||||
User user = userService.findByUsername(username);
|
User user = userService.findByUsername(username);
|
||||||
|
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.io.IOException;
|
|||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -335,7 +336,7 @@ public class ImageGridService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在指定区域内居中绘制图片(保持比例)
|
* 在指定区域内居中绘制图片(保持比例,不裁切)
|
||||||
*/
|
*/
|
||||||
private void drawImageCentered(Graphics2D g, BufferedImage img, int x, int y, int width, int height) {
|
private void drawImageCentered(Graphics2D g, BufferedImage img, int x, int y, int width, int height) {
|
||||||
double scaleX = (double) width / img.getWidth();
|
double scaleX = (double) width / img.getWidth();
|
||||||
@@ -347,6 +348,32 @@ public class ImageGridService {
|
|||||||
int imgX = x + (width - scaledWidth) / 2;
|
int imgX = x + (width - scaledWidth) / 2;
|
||||||
int imgY = y + (height - scaledHeight) / 2;
|
int imgY = y + (height - scaledHeight) / 2;
|
||||||
|
|
||||||
|
logger.debug("drawImageCentered: 原图={}x{}, 目标区域={}x{}, 缩放后={}x{}, 位置=({},{})",
|
||||||
|
img.getWidth(), img.getHeight(), width, height, scaledWidth, scaledHeight, imgX, imgY);
|
||||||
|
|
||||||
|
g.drawImage(img, imgX, imgY, scaledWidth, scaledHeight, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定区域内填充绘制图片(拉伸填充,可能变形)
|
||||||
|
*/
|
||||||
|
private void drawImageFill(Graphics2D g, BufferedImage img, int x, int y, int width, int height) {
|
||||||
|
g.drawImage(img, x, y, width, height, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定区域内裁切填充绘制图片(保持比例,裁切多余部分)
|
||||||
|
*/
|
||||||
|
private void drawImageCover(Graphics2D g, BufferedImage img, int x, int y, int width, int height) {
|
||||||
|
double scaleX = (double) width / img.getWidth();
|
||||||
|
double scaleY = (double) height / img.getHeight();
|
||||||
|
double scale = Math.max(scaleX, scaleY); // 使用较大的缩放比例
|
||||||
|
|
||||||
|
int scaledWidth = (int) (img.getWidth() * scale);
|
||||||
|
int scaledHeight = (int) (img.getHeight() * scale);
|
||||||
|
int imgX = x + (width - scaledWidth) / 2;
|
||||||
|
int imgY = y + (height - scaledHeight) / 2;
|
||||||
|
|
||||||
g.drawImage(img, imgX, imgY, scaledWidth, scaledHeight, null);
|
g.drawImage(img, imgX, imgY, scaledWidth, scaledHeight, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,5 +398,204 @@ public class ImageGridService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分镜图布局
|
||||||
|
*
|
||||||
|
* 规则(注意:画布比例与用户选择相反):
|
||||||
|
* - 用户选择16:9 → 创建9:16竖屏画布,上下2:1分割
|
||||||
|
* - 无上传图:banana生成9:16分镜图,填充整个画布
|
||||||
|
* - 有上传图:banana生成4:3分镜图放上侧,下侧从左到右放用户图片(每份比例9:16)
|
||||||
|
* - 用户选择9:16 → 创建16:9横屏画布,左右3:1分割
|
||||||
|
* - 无上传图:banana生成16:9分镜图,填充整个画布
|
||||||
|
* - 有上传图:banana生成4:3分镜图放左侧,右侧从上到下放用户图片(每份比例16:9)
|
||||||
|
*
|
||||||
|
* @param generatedImage 生成的分镜图Base64
|
||||||
|
* @param uploadedImages 用户上传的图片列表(最多3张)
|
||||||
|
* @param aspectRatio 用户选择的比例(16:9 或 9:16)
|
||||||
|
* @return 合成后的图片Base64
|
||||||
|
*/
|
||||||
|
public String createStoryboardLayout(String generatedImage, List<String> uploadedImages, String aspectRatio) {
|
||||||
|
try {
|
||||||
|
// 画布比例与用户选择一致(修复:之前是相反的,导致Sora2裁剪时丢失参考图)
|
||||||
|
// 用户选择16:9 → 画布是16:9横屏;用户选择9:16 → 画布是9:16竖屏
|
||||||
|
boolean userSelected16x9 = "16:9".equals(aspectRatio);
|
||||||
|
boolean canvasIsHorizontal = userSelected16x9; // 修复:与用户选择一致
|
||||||
|
|
||||||
|
// 基础画布尺寸 - 使用标准尺寸
|
||||||
|
int baseWidth, baseHeight;
|
||||||
|
if (canvasIsHorizontal) {
|
||||||
|
// 16:9 横屏画布:1920x1080
|
||||||
|
baseWidth = 1920;
|
||||||
|
baseHeight = 1080;
|
||||||
|
} else {
|
||||||
|
// 9:16 竖屏画布:1080x1920
|
||||||
|
baseWidth = 1080;
|
||||||
|
baseHeight = 1920;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤掉null和空字符串
|
||||||
|
List<String> validUploads = new ArrayList<>();
|
||||||
|
if (uploadedImages != null) {
|
||||||
|
for (String img : uploadedImages) {
|
||||||
|
if (img != null && !img.isEmpty() && !"null".equals(img)) {
|
||||||
|
validUploads.add(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boolean hasUploads = !validUploads.isEmpty();
|
||||||
|
|
||||||
|
logger.info("创建分镜图布局: 用户选择={}, 画布比例={}, hasUploads={}, uploadCount={}, 画布尺寸={}x{}",
|
||||||
|
aspectRatio, canvasIsHorizontal ? "16:9横屏" : "9:16竖屏", hasUploads, validUploads.size(), baseWidth, baseHeight);
|
||||||
|
|
||||||
|
// 加载生成的分镜图
|
||||||
|
BufferedImage storyboardImg = loadImageFromUrl(generatedImage);
|
||||||
|
if (storyboardImg == null) {
|
||||||
|
throw new RuntimeException("无法加载生成的分镜图");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建白色底图
|
||||||
|
BufferedImage canvas = new BufferedImage(baseWidth, baseHeight, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g = canvas.createGraphics();
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
|
||||||
|
// 填充白色背景
|
||||||
|
g.setColor(java.awt.Color.WHITE);
|
||||||
|
g.fillRect(0, 0, baseWidth, baseHeight);
|
||||||
|
|
||||||
|
if (!hasUploads) {
|
||||||
|
// 无上传图片:分镜图保持比例居中填充整个画布
|
||||||
|
drawImageCentered(g, storyboardImg, 0, 0, baseWidth, baseHeight);
|
||||||
|
logger.info("无上传图片,分镜图居中填充整个画布");
|
||||||
|
} else if (canvasIsHorizontal) {
|
||||||
|
// 用户选择16:9 → 16:9横屏画布,有上传图:左右布局(3:1)
|
||||||
|
int leftWidth = baseWidth * 3 / 4; // 左侧占3/4
|
||||||
|
int rightWidth = baseWidth - leftWidth; // 右侧占1/4
|
||||||
|
|
||||||
|
// 左侧放分镜图(保持比例居中)
|
||||||
|
drawImageCentered(g, storyboardImg, 0, 0, leftWidth, baseHeight);
|
||||||
|
|
||||||
|
// 右侧从上到下放用户图片(最多3张,每份比例16:9)
|
||||||
|
int userImageCount = Math.min(validUploads.size(), 3);
|
||||||
|
int cellHeight = baseHeight / 3; // 每张图片区域高度
|
||||||
|
int cellWidth = rightWidth; // 每张图片区域宽度
|
||||||
|
|
||||||
|
for (int i = 0; i < userImageCount; i++) {
|
||||||
|
BufferedImage userImg = loadImageFromUrl(validUploads.get(i));
|
||||||
|
if (userImg != null) {
|
||||||
|
int y = i * cellHeight;
|
||||||
|
// 在单元格内居中绘制用户图片(带白色边框效果)
|
||||||
|
drawUserImageWithBorder(g, userImg, leftWidth, y, cellWidth, cellHeight, "16:9");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("用户选16:9→16:9横屏布局完成: 左侧={}px, 右侧={}px, 用户图片数={}", leftWidth, rightWidth, userImageCount);
|
||||||
|
} else {
|
||||||
|
// 用户选择9:16 → 9:16竖屏画布,有上传图:上下布局(2:1)
|
||||||
|
int topHeight = baseHeight * 2 / 3; // 上侧占2/3
|
||||||
|
int bottomHeight = baseHeight - topHeight; // 下侧占1/3
|
||||||
|
|
||||||
|
// 上侧放分镜图(保持比例居中)
|
||||||
|
drawImageCentered(g, storyboardImg, 0, 0, baseWidth, topHeight);
|
||||||
|
|
||||||
|
// 下侧从左到右放用户图片(最多3张,每份比例9:16)
|
||||||
|
int userImageCount = Math.min(validUploads.size(), 3);
|
||||||
|
int cellWidth = baseWidth / 3; // 每张图片区域宽度
|
||||||
|
int cellHeight = bottomHeight; // 每张图片区域高度
|
||||||
|
|
||||||
|
for (int i = 0; i < userImageCount; i++) {
|
||||||
|
BufferedImage userImg = loadImageFromUrl(validUploads.get(i));
|
||||||
|
if (userImg != null) {
|
||||||
|
int x = i * cellWidth;
|
||||||
|
// 在单元格内居中绘制用户图片(带白色边框效果)
|
||||||
|
drawUserImageWithBorder(g, userImg, x, topHeight, cellWidth, cellHeight, "9:16");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("用户选9:16→9:16竖屏布局完成: 上侧={}px, 下侧={}px, 用户图片数={}", topHeight, bottomHeight, userImageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
g.dispose();
|
||||||
|
|
||||||
|
// 压缩并转换为Base64
|
||||||
|
BufferedImage compressedImage = compressGridImage(canvas, 2048);
|
||||||
|
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
javax.imageio.ImageWriter writer = javax.imageio.ImageIO.getImageWritersByFormatName("jpg").next();
|
||||||
|
javax.imageio.ImageWriteParam param = writer.getDefaultWriteParam();
|
||||||
|
if (param.canWriteCompressed()) {
|
||||||
|
param.setCompressionMode(javax.imageio.ImageWriteParam.MODE_EXPLICIT);
|
||||||
|
param.setCompressionQuality(0.85f);
|
||||||
|
}
|
||||||
|
javax.imageio.IIOImage iioImage = new javax.imageio.IIOImage(compressedImage, null, null);
|
||||||
|
writer.setOutput(javax.imageio.ImageIO.createImageOutputStream(baos));
|
||||||
|
writer.write(null, iioImage, param);
|
||||||
|
writer.dispose();
|
||||||
|
|
||||||
|
byte[] imageBytes = baos.toByteArray();
|
||||||
|
String base64 = Base64.getEncoder().encodeToString(imageBytes);
|
||||||
|
|
||||||
|
logger.info("分镜图布局完成: 最终尺寸={}x{}, 大小={} KB",
|
||||||
|
compressedImage.getWidth(), compressedImage.getHeight(), imageBytes.length / 1024);
|
||||||
|
|
||||||
|
return "data:image/jpeg;base64," + base64;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("创建分镜图布局失败", e);
|
||||||
|
throw new RuntimeException("创建分镜图布局失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在单元格内绘制用户图片,并确保图片+白色边框的比例符合要求
|
||||||
|
* @param g 画布
|
||||||
|
* @param img 用户图片
|
||||||
|
* @param x 单元格X坐标
|
||||||
|
* @param y 单元格Y坐标
|
||||||
|
* @param cellWidth 单元格宽度
|
||||||
|
* @param cellHeight 单元格高度
|
||||||
|
* @param targetRatio 目标比例(16:9 或 9:16)
|
||||||
|
*/
|
||||||
|
private void drawUserImageWithBorder(Graphics2D g, BufferedImage img, int x, int y,
|
||||||
|
int cellWidth, int cellHeight, String targetRatio) {
|
||||||
|
// 在单元格内留白边框(10%边距)
|
||||||
|
int margin = Math.min(cellWidth, cellHeight) / 20;
|
||||||
|
int innerX = x + margin;
|
||||||
|
int innerY = y + margin;
|
||||||
|
int innerWidth = cellWidth - margin * 2;
|
||||||
|
int innerHeight = cellHeight - margin * 2;
|
||||||
|
|
||||||
|
// 绘制白色底图(单元格背景已经是白色)
|
||||||
|
// 在内部区域居中绘制用户图片
|
||||||
|
drawImageCentered(g, img, innerX, innerY, innerWidth, innerHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据是否有上传图片,返回生成分镜图时应使用的比例
|
||||||
|
*
|
||||||
|
* 规则(画布比例与用户选择相反):
|
||||||
|
* - 用户选择16:9 → 画布是9:16 → 无上传图时生成9:16分镜图
|
||||||
|
* - 用户选择9:16 → 画布是16:9 → 无上传图时生成16:9分镜图
|
||||||
|
* - 有上传图时:统一使用4:3
|
||||||
|
*
|
||||||
|
* @param targetAspectRatio 用户选择的比例(16:9 或 9:16)
|
||||||
|
* @param hasUploads 是否有用户上传的图片
|
||||||
|
* @return 生成分镜图应使用的比例
|
||||||
|
*/
|
||||||
|
public String getGenerationAspectRatio(String targetAspectRatio, boolean hasUploads) {
|
||||||
|
if (!hasUploads) {
|
||||||
|
// 无上传图片:使用与用户选择相反的比例(因为画布比例相反)
|
||||||
|
if ("16:9".equals(targetAspectRatio)) {
|
||||||
|
return "9:16"; // 用户选16:9 → 画布是9:16 → 生成9:16分镜图
|
||||||
|
} else {
|
||||||
|
return "16:9"; // 用户选9:16 → 画布是16:9 → 生成16:9分镜图
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 有上传图片:使用4:3生成分镜图
|
||||||
|
return "4:3";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ public class ImageToVideoService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private CosService cosService;
|
private CosService cosService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TaskStatusPollingService taskStatusPollingService;
|
||||||
|
|
||||||
@Value("${app.upload.path:/uploads}")
|
@Value("${app.upload.path:/uploads}")
|
||||||
private String uploadPath;
|
private String uploadPath;
|
||||||
|
|
||||||
@@ -243,13 +246,25 @@ public class ImageToVideoService {
|
|||||||
taskRepository.save(currentTask);
|
taskRepository.save(currentTask);
|
||||||
logger.info("真实任务ID已保存: {} -> {}", task.getTaskId(), realTaskId);
|
logger.info("真实任务ID已保存: {} -> {}", task.getTaskId(), realTaskId);
|
||||||
} else {
|
} else {
|
||||||
// 如果没有找到任务ID,说明任务提交失败
|
// 如果没有找到任务ID,说明任务提交失败(尝试通过 task_status 触发级联)
|
||||||
logger.error("任务提交失败:未从API响应中获取到任务ID");
|
logger.error("任务提交失败:未从API响应中获取到任务ID");
|
||||||
currentTask = taskRepository.findByTaskId(task.getTaskId())
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务提交失败:API未返回有效的任务ID");
|
||||||
.orElseThrow(() -> new RuntimeException("任务不存在: " + task.getTaskId()));
|
if (!updated) {
|
||||||
currentTask.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
// 回退:直接更新业务表
|
||||||
currentTask.setErrorMessage("任务提交失败:API未返回有效的任务ID");
|
currentTask = taskRepository.findByTaskId(task.getTaskId())
|
||||||
taskRepository.save(currentTask);
|
.orElseThrow(() -> new RuntimeException("任务不存在: " + task.getTaskId()));
|
||||||
|
currentTask.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||||
|
currentTask.setErrorMessage("任务提交失败:API未返回有效的任务ID");
|
||||||
|
currentTask.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(currentTask);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatus(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
// 返还冻结积分
|
// 返还冻结积分
|
||||||
returnFrozenPointsSafely(task.getTaskId());
|
returnFrozenPointsSafely(task.getTaskId());
|
||||||
return CompletableFuture.completedFuture(null); // 直接返回,不进行轮询
|
return CompletableFuture.completedFuture(null); // 直接返回,不进行轮询
|
||||||
@@ -266,10 +281,25 @@ public class ImageToVideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 更新状态为失败
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), e.getMessage());
|
||||||
task.setErrorMessage(e.getMessage());
|
if (!updated) {
|
||||||
taskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
ImageToVideoTask currentTask = taskRepository.findByTaskId(task.getTaskId()).orElse(null);
|
||||||
|
if (currentTask != null) {
|
||||||
|
currentTask.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||||
|
currentTask.setErrorMessage(e.getMessage());
|
||||||
|
currentTask.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(currentTask);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatus(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception ue) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// 返还冻结积分
|
// 返还冻结积分
|
||||||
returnFrozenPointsSafely(task.getTaskId());
|
returnFrozenPointsSafely(task.getTaskId());
|
||||||
} catch (Exception saveException) {
|
} catch (Exception saveException) {
|
||||||
@@ -334,28 +364,44 @@ public class ImageToVideoService {
|
|||||||
String resultUrl = (String) taskData.get("resultUrl");
|
String resultUrl = (String) taskData.get("resultUrl");
|
||||||
String errorMessage = (String) taskData.get("errorMessage");
|
String errorMessage = (String) taskData.get("errorMessage");
|
||||||
|
|
||||||
// 更新任务状态
|
// 更新任务状态(通过 task_status 表触发级联)
|
||||||
if ("completed".equals(status) || "success".equals(status)) {
|
if ("completed".equals(status) || "success".equals(status)) {
|
||||||
task.setResultUrl(resultUrl);
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.updateStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
boolean updated = taskStatusPollingService.markTaskCompleted(task.getTaskId(), resultUrl);
|
||||||
task.updateProgress(100);
|
if (!updated) {
|
||||||
taskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.setResultUrl(resultUrl);
|
||||||
// 同步更新 UserWork 表的状态和结果URL
|
task.updateStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
||||||
try {
|
task.updateProgress(100);
|
||||||
userWorkService.updateWorkOnComplete(task.getTaskId(), resultUrl,
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
taskRepository.save(task);
|
||||||
logger.info("图生视频任务完成,UserWork已更新: {}", task.getTaskId());
|
// 更新 UserWork
|
||||||
} catch (Exception e) {
|
try {
|
||||||
logger.warn("更新UserWork状态失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
|
userWorkService.updateWorkOnComplete(task.getTaskId(), resultUrl,
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("图生视频任务完成: {}", task.getTaskId());
|
logger.info("图生视频任务完成: {}", task.getTaskId());
|
||||||
return;
|
return;
|
||||||
} else if ("failed".equals(status) || "error".equals(status)) {
|
} else if ("failed".equals(status) || "error".equals(status)) {
|
||||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.setErrorMessage(errorMessage);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), errorMessage);
|
||||||
taskRepository.save(task);
|
if (!updated) {
|
||||||
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatus(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
// 返还冻结积分
|
// 返还冻结积分
|
||||||
returnFrozenPointsSafely(task.getTaskId());
|
returnFrozenPointsSafely(task.getTaskId());
|
||||||
logger.error("图生视频任务失败: {}", task.getTaskId());
|
logger.error("图生视频任务失败: {}", task.getTaskId());
|
||||||
@@ -383,10 +429,22 @@ public class ImageToVideoService {
|
|||||||
Thread.sleep(2000); // 每2秒轮询一次
|
Thread.sleep(2000); // 每2秒轮询一次
|
||||||
}
|
}
|
||||||
|
|
||||||
// 超时处理
|
// 超时处理(尝试通过 task_status 触发级联)
|
||||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务处理超时");
|
||||||
task.setErrorMessage("任务处理超时");
|
if (!updated) {
|
||||||
taskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage("任务处理超时");
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatus(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
// 返还冻结积分
|
// 返还冻结积分
|
||||||
returnFrozenPointsSafely(task.getTaskId());
|
returnFrozenPointsSafely(task.getTaskId());
|
||||||
logger.error("图生视频任务超时: {}", task.getTaskId());
|
logger.error("图生视频任务超时: {}", task.getTaskId());
|
||||||
@@ -582,22 +640,27 @@ public class ImageToVideoService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新任务状态为失败
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "图生视频任务超时:任务创建后超过1小时仍未完成");
|
||||||
task.setErrorMessage("图生视频任务超时:任务创建后超过1小时仍未完成");
|
if (!updated) {
|
||||||
taskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage("图生视频任务超时:任务创建后超过1小时仍未完成");
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 返还冻结积分
|
// 返还冻结积分
|
||||||
returnFrozenPointsSafely(task.getTaskId());
|
returnFrozenPointsSafely(task.getTaskId());
|
||||||
|
|
||||||
// 同步更新 UserWork 表的状态
|
|
||||||
try {
|
|
||||||
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
|
|
||||||
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.warn("更新UserWork状态失败: taskId={}", task.getTaskId());
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn("图生视频任务超时,已标记为失败: taskId={}", task.getTaskId());
|
logger.warn("图生视频任务超时,已标记为失败: taskId={}", task.getTaskId());
|
||||||
|
|
||||||
handledCount++;
|
handledCount++;
|
||||||
|
|||||||
@@ -1011,8 +1011,33 @@ public class RealAIService {
|
|||||||
// 模型选择
|
// 模型选择
|
||||||
String model = "nano-banana";
|
String model = "nano-banana";
|
||||||
|
|
||||||
// 将 Base64 转换为二进制数据
|
// 处理图片数据:如果是 URL,先下载转换为 base64
|
||||||
String base64Data = imageBase64;
|
String base64Data = imageBase64;
|
||||||
|
if (imageBase64.startsWith("http://") || imageBase64.startsWith("https://")) {
|
||||||
|
// 是 URL,需要下载图片
|
||||||
|
logger.info("检测到图片URL,开始下载: {}", imageBase64.substring(0, Math.min(100, imageBase64.length())));
|
||||||
|
try {
|
||||||
|
java.net.URL imageUrl = new java.net.URL(imageBase64);
|
||||||
|
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) imageUrl.openConnection();
|
||||||
|
conn.setConnectTimeout(30000);
|
||||||
|
conn.setReadTimeout(60000);
|
||||||
|
try (java.io.InputStream is = conn.getInputStream();
|
||||||
|
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream()) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int bytesRead;
|
||||||
|
while ((bytesRead = is.read(buffer)) != -1) {
|
||||||
|
baos.write(buffer, 0, bytesRead);
|
||||||
|
}
|
||||||
|
byte[] imageData = baos.toByteArray();
|
||||||
|
base64Data = Base64.getEncoder().encodeToString(imageData);
|
||||||
|
logger.info("图片下载成功,大小: {} KB", imageData.length / 1024);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("下载图片失败: {}", e.getMessage());
|
||||||
|
throw new RuntimeException("下载参考图片失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 去除 data:image/xxx;base64, 前缀
|
// 去除 data:image/xxx;base64, 前缀
|
||||||
if (base64Data.contains(",")) {
|
if (base64Data.contains(",")) {
|
||||||
base64Data = base64Data.substring(base64Data.indexOf(",") + 1);
|
base64Data = base64Data.substring(base64Data.indexOf(",") + 1);
|
||||||
@@ -1144,24 +1169,9 @@ public class RealAIService {
|
|||||||
// 从系统设置获取优化提示词的API端点和模型
|
// 从系统设置获取优化提示词的API端点和模型
|
||||||
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
|
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
|
||||||
|
|
||||||
// 获取系统提示词:
|
// 使用默认系统提示词
|
||||||
// - 分镜(storyboard):优先使用后台设置的自定义系统提示词,否则使用默认
|
String systemPrompt = getOptimizationPrompt(type);
|
||||||
// - 文生视频和图生视频:始终使用默认指令
|
logger.info("{}优化:使用默认系统提示词", type);
|
||||||
String systemPrompt;
|
|
||||||
if ("storyboard".equals(type)) {
|
|
||||||
// 分镜优化:可以使用自定义系统提示词
|
|
||||||
systemPrompt = settings.getPromptOptimizationSystemPrompt();
|
|
||||||
if (systemPrompt == null || systemPrompt.trim().isEmpty()) {
|
|
||||||
systemPrompt = getOptimizationPrompt(type);
|
|
||||||
logger.info("分镜优化:使用默认系统提示词");
|
|
||||||
} else {
|
|
||||||
logger.info("分镜优化:使用自定义系统提示词");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 文生视频和图生视频:始终使用默认指令
|
|
||||||
systemPrompt = getOptimizationPrompt(type);
|
|
||||||
logger.info("{}优化:使用默认系统提示词", type);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是分镜图类型,将系统引导词拼接到用户提示词前面一起优化
|
// 如果是分镜图类型,将系统引导词拼接到用户提示词前面一起优化
|
||||||
String promptToOptimize = prompt;
|
String promptToOptimize = prompt;
|
||||||
@@ -1202,16 +1212,18 @@ public class RealAIService {
|
|||||||
|
|
||||||
requestBody.put("messages", messages);
|
requestBody.put("messages", messages);
|
||||||
requestBody.put("temperature", 0.7);
|
requestBody.put("temperature", 0.7);
|
||||||
requestBody.put("max_tokens", 800); // 增加token限制,允许更详细的优化
|
// 分镜类型需要更多 tokens(JSON 包含 12 个镜头描述)
|
||||||
|
int maxTokens = "storyboard".equals(type) ? 4000 : 800;
|
||||||
|
requestBody.put("max_tokens", maxTokens);
|
||||||
|
|
||||||
String requestBodyJson = objectMapper.writeValueAsString(requestBody);
|
String requestBodyJson = objectMapper.writeValueAsString(requestBody);
|
||||||
|
|
||||||
// 设置超时时间(30秒)
|
// 设置超时时间(60秒)
|
||||||
HttpResponse<String> response = Unirest.post(url)
|
HttpResponse<String> response = Unirest.post(url)
|
||||||
.header("Authorization", "Bearer " + fallbackApiKey)
|
.header("Authorization", "Bearer " + fallbackApiKey)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.socketTimeout(30000)
|
.socketTimeout(60000)
|
||||||
.connectTimeout(10000)
|
.connectTimeout(15000)
|
||||||
.body(requestBodyJson)
|
.body(requestBodyJson)
|
||||||
.asString();
|
.asString();
|
||||||
|
|
||||||
@@ -1292,6 +1304,145 @@ public class RealAIService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多模态提示词优化(支持图片)
|
||||||
|
* 将用户提示词和参考图片一起发送给大模型进行优化
|
||||||
|
*
|
||||||
|
* @param prompt 原始提示词
|
||||||
|
* @param type 优化类型
|
||||||
|
* @param imageUrls 参考图片列表(Base64 或 URL)
|
||||||
|
* @return 优化后的提示词
|
||||||
|
*/
|
||||||
|
public String optimizePromptWithImages(String prompt, String type, List<String> imageUrls) {
|
||||||
|
// 如果没有图片,直接调用普通方法
|
||||||
|
if (imageUrls == null || imageUrls.isEmpty()) {
|
||||||
|
return optimizePrompt(prompt, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("开始多模态提示词优化: prompt长度={}, type={}, 图片数量={}",
|
||||||
|
prompt.length(), type, imageUrls.size());
|
||||||
|
|
||||||
|
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
|
||||||
|
|
||||||
|
String systemPrompt = getOptimizationPrompt(type);
|
||||||
|
|
||||||
|
String apiUrl = settings.getPromptOptimizationApiUrl();
|
||||||
|
if (apiUrl == null || apiUrl.isEmpty()) {
|
||||||
|
apiUrl = getEffectiveApiBaseUrl();
|
||||||
|
}
|
||||||
|
String url = apiUrl + "/v1/chat/completions";
|
||||||
|
|
||||||
|
// 使用支持视觉的模型
|
||||||
|
String optimizationModel = settings.getPromptOptimizationModel();
|
||||||
|
if (optimizationModel == null || optimizationModel.isEmpty()) {
|
||||||
|
optimizationModel = "gpt-4o-mini"; // 使用支持视觉的模型
|
||||||
|
}
|
||||||
|
logger.info("使用多模态模型: {}", optimizationModel);
|
||||||
|
|
||||||
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
|
requestBody.put("model", optimizationModel);
|
||||||
|
|
||||||
|
List<Map<String, Object>> messages = new java.util.ArrayList<>();
|
||||||
|
|
||||||
|
// 系统消息
|
||||||
|
Map<String, Object> systemMessage = new HashMap<>();
|
||||||
|
systemMessage.put("role", "system");
|
||||||
|
systemMessage.put("content", systemPrompt);
|
||||||
|
messages.add(systemMessage);
|
||||||
|
|
||||||
|
// 用户消息(多模态格式:文本+图片)
|
||||||
|
Map<String, Object> userMessage = new HashMap<>();
|
||||||
|
userMessage.put("role", "user");
|
||||||
|
|
||||||
|
// 构建 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);
|
||||||
|
contentArray.add(textContent);
|
||||||
|
|
||||||
|
// 添加图片部分(最多3张)
|
||||||
|
int imageCount = Math.min(imageUrls.size(), 3);
|
||||||
|
for (int i = 0; i < imageCount; i++) {
|
||||||
|
String imageUrl = imageUrls.get(i);
|
||||||
|
Map<String, Object> imageContent = new HashMap<>();
|
||||||
|
imageContent.put("type", "image_url");
|
||||||
|
|
||||||
|
Map<String, Object> imageUrlObj = new HashMap<>();
|
||||||
|
if (imageUrl.startsWith("data:image")) {
|
||||||
|
// Base64 格式
|
||||||
|
imageUrlObj.put("url", imageUrl);
|
||||||
|
} else if (imageUrl.startsWith("http")) {
|
||||||
|
// URL 格式
|
||||||
|
imageUrlObj.put("url", imageUrl);
|
||||||
|
} else {
|
||||||
|
// 假设是纯 Base64,添加前缀
|
||||||
|
imageUrlObj.put("url", "data:image/png;base64," + imageUrl);
|
||||||
|
}
|
||||||
|
imageContent.put("image_url", imageUrlObj);
|
||||||
|
contentArray.add(imageContent);
|
||||||
|
logger.info("添加参考图片 {}/{}", i + 1, imageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
userMessage.put("content", contentArray);
|
||||||
|
messages.add(userMessage);
|
||||||
|
|
||||||
|
requestBody.put("messages", messages);
|
||||||
|
requestBody.put("temperature", 0.7);
|
||||||
|
int maxTokens = "storyboard".equals(type) ? 4000 : 800;
|
||||||
|
requestBody.put("max_tokens", maxTokens);
|
||||||
|
|
||||||
|
String requestBodyJson = objectMapper.writeValueAsString(requestBody);
|
||||||
|
|
||||||
|
// 多模态需要更长的超时时间(120秒)
|
||||||
|
HttpResponse<String> response = Unirest.post(url)
|
||||||
|
.header("Authorization", "Bearer " + fallbackApiKey)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.socketTimeout(120000)
|
||||||
|
.connectTimeout(30000)
|
||||||
|
.body(requestBodyJson)
|
||||||
|
.asString();
|
||||||
|
|
||||||
|
int statusCode = response.getStatus();
|
||||||
|
logger.info("多模态提示词优化API响应状态: {}", statusCode);
|
||||||
|
|
||||||
|
if (statusCode == 200 && response.getBody() != null) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> responseBody = objectMapper.readValue(response.getBody(), Map.class);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> choices = (List<Map<String, Object>>) responseBody.get("choices");
|
||||||
|
if (choices != null && !choices.isEmpty()) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> choice = choices.get(0);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> message = (Map<String, Object>) choice.get("message");
|
||||||
|
if (message != null) {
|
||||||
|
String optimizedPrompt = (String) message.get("content");
|
||||||
|
if (optimizedPrompt != null && !optimizedPrompt.trim().isEmpty()) {
|
||||||
|
optimizedPrompt = optimizedPrompt.trim();
|
||||||
|
optimizedPrompt = optimizedPrompt.replaceAll("^\"+|\"+$", "");
|
||||||
|
logger.info("多模态提示词优化成功: 原始长度={}, 优化后长度={}",
|
||||||
|
prompt.length(), optimizedPrompt.length());
|
||||||
|
return optimizedPrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error("多模态提示词优化失败: status={}", statusCode);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("多模态提示词优化异常", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失败时回退到普通方法
|
||||||
|
logger.warn("多模态优化失败,回退到普通文本优化");
|
||||||
|
return optimizePrompt(prompt, type);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据类型获取优化提示词的系统指令
|
* 根据类型获取优化提示词的系统指令
|
||||||
* 参考Comfly项目的优化策略,使用更智能的提示词优化
|
* 参考Comfly项目的优化策略,使用更智能的提示词优化
|
||||||
@@ -1421,4 +1572,5 @@ public class RealAIService {
|
|||||||
""";
|
""";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package com.example.demo.service;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis Token 服务
|
||||||
|
* 用于管理用户 JWT Token 的存储、验证和删除
|
||||||
|
* 当 redis.enabled=false 时,所有方法将返回默认值,不访问 Redis
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class RedisTokenService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(RedisTokenService.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token 存储的 key 前缀
|
||||||
|
* 格式: token:{username}:{tokenId}
|
||||||
|
*/
|
||||||
|
private static final String TOKEN_PREFIX = "token:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户 Token 集合的 key 前缀
|
||||||
|
* 格式: user_tokens:{username}
|
||||||
|
* 存储该用户所有有效的 token
|
||||||
|
*/
|
||||||
|
private static final String USER_TOKENS_PREFIX = "user_tokens:";
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
|
private StringRedisTemplate redisTemplate;
|
||||||
|
|
||||||
|
@Value("${redis.token.expire-seconds:86400}")
|
||||||
|
private long tokenExpireSeconds;
|
||||||
|
|
||||||
|
@Value("${redis.enabled:true}")
|
||||||
|
private boolean redisEnabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 Token 到 Redis(使用默认过期时间)
|
||||||
|
*
|
||||||
|
* @param username 用户名
|
||||||
|
* @param token JWT Token
|
||||||
|
*/
|
||||||
|
public void saveToken(String username, String token) {
|
||||||
|
saveToken(username, token, tokenExpireSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 Token 到 Redis(使用指定过期时间)
|
||||||
|
*
|
||||||
|
* @param username 用户名
|
||||||
|
* @param token JWT Token
|
||||||
|
* @param expireSeconds 过期时间(秒)
|
||||||
|
*/
|
||||||
|
public void saveToken(String username, String token, long expireSeconds) {
|
||||||
|
if (!redisEnabled || redisTemplate == null) {
|
||||||
|
logger.debug("Redis 已禁用,跳过 Token 保存: username={}", username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String tokenKey = getTokenKey(username, token);
|
||||||
|
String userTokensKey = getUserTokensKey(username);
|
||||||
|
|
||||||
|
// 保存 token -> username 映射
|
||||||
|
redisTemplate.opsForValue().set(tokenKey, username, expireSeconds, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
// 将 token 添加到用户的 token 集合中
|
||||||
|
redisTemplate.opsForSet().add(userTokensKey, token);
|
||||||
|
redisTemplate.expire(userTokensKey, expireSeconds, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
logger.info("Token 已保存到 Redis: username={}, expireSeconds={}", username, expireSeconds);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("保存 Token 到 Redis 失败: username={}, error={}", username, e.getMessage());
|
||||||
|
// 不抛出异常,允许在 Redis 不可用时降级运行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Token 是否有效(存在于 Redis 中)
|
||||||
|
*
|
||||||
|
* @param username 用户名
|
||||||
|
* @param token JWT Token
|
||||||
|
* @return true 如果 token 有效
|
||||||
|
*/
|
||||||
|
public boolean isTokenValid(String username, String token) {
|
||||||
|
if (!redisEnabled || redisTemplate == null) {
|
||||||
|
// Redis 禁用时,直接返回 true,依赖 JWT 本身的验证
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String tokenKey = getTokenKey(username, token);
|
||||||
|
Boolean exists = redisTemplate.hasKey(tokenKey);
|
||||||
|
if (Boolean.TRUE.equals(exists)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Token 不在 Redis 中,可能是登录时未保存或 Redis 数据丢失
|
||||||
|
// 降级为允许通过(依赖 JWT 本身的验证)
|
||||||
|
logger.debug("Token 不在 Redis 中,降级为允许通过: username={}", username);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("验证 Token 时 Redis 访问失败,降级为允许通过: username={}, error={}", username, e.getMessage());
|
||||||
|
// Redis 不可用时降级为允许通过(依赖 JWT 本身的验证)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定 Token(用户登出)
|
||||||
|
*
|
||||||
|
* @param username 用户名
|
||||||
|
* @param token JWT Token
|
||||||
|
*/
|
||||||
|
public void removeToken(String username, String token) {
|
||||||
|
if (!redisEnabled || redisTemplate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String tokenKey = getTokenKey(username, token);
|
||||||
|
String userTokensKey = getUserTokensKey(username);
|
||||||
|
|
||||||
|
// 删除 token
|
||||||
|
redisTemplate.delete(tokenKey);
|
||||||
|
|
||||||
|
// 从用户 token 集合中移除
|
||||||
|
redisTemplate.opsForSet().remove(userTokensKey, token);
|
||||||
|
|
||||||
|
logger.info("Token 已从 Redis 删除: username={}", username);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("从 Redis 删除 Token 失败: username={}, error={}", username, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户的所有 Token(强制登出所有设备)
|
||||||
|
*
|
||||||
|
* @param username 用户名
|
||||||
|
*/
|
||||||
|
public void removeAllTokens(String username) {
|
||||||
|
if (!redisEnabled || redisTemplate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String userTokensKey = getUserTokensKey(username);
|
||||||
|
|
||||||
|
// 获取用户所有 token
|
||||||
|
Set<String> tokens = redisTemplate.opsForSet().members(userTokensKey);
|
||||||
|
|
||||||
|
if (tokens != null && !tokens.isEmpty()) {
|
||||||
|
// 删除所有 token
|
||||||
|
for (String token : tokens) {
|
||||||
|
String tokenKey = getTokenKey(username, token);
|
||||||
|
redisTemplate.delete(tokenKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户 token 集合
|
||||||
|
redisTemplate.delete(userTokensKey);
|
||||||
|
|
||||||
|
logger.info("用户所有 Token 已从 Redis 删除: username={}, count={}",
|
||||||
|
username, tokens != null ? tokens.size() : 0);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("从 Redis 删除用户所有 Token 失败: username={}, error={}", username, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新 Token 过期时间
|
||||||
|
*
|
||||||
|
* @param username 用户名
|
||||||
|
* @param token JWT Token
|
||||||
|
*/
|
||||||
|
public void refreshTokenExpiration(String username, String token) {
|
||||||
|
if (!redisEnabled || redisTemplate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String tokenKey = getTokenKey(username, token);
|
||||||
|
String userTokensKey = getUserTokensKey(username);
|
||||||
|
|
||||||
|
// 刷新 token 过期时间
|
||||||
|
redisTemplate.expire(tokenKey, tokenExpireSeconds, TimeUnit.SECONDS);
|
||||||
|
redisTemplate.expire(userTokensKey, tokenExpireSeconds, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
logger.debug("Token 过期时间已刷新: username={}", username);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("刷新 Token 过期时间失败: username={}, error={}", username, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户当前有效的 Token 数量
|
||||||
|
*
|
||||||
|
* @param username 用户名
|
||||||
|
* @return Token 数量
|
||||||
|
*/
|
||||||
|
public long getTokenCount(String username) {
|
||||||
|
if (!redisEnabled || redisTemplate == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String userTokensKey = getUserTokensKey(username);
|
||||||
|
Long count = redisTemplate.opsForSet().size(userTokensKey);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("获取用户 Token 数量失败: username={}, error={}", username, e.getMessage());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Redis 是否可用
|
||||||
|
*
|
||||||
|
* @return true 如果 Redis 可用
|
||||||
|
*/
|
||||||
|
public boolean isRedisAvailable() {
|
||||||
|
if (!redisEnabled || redisTemplate == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
redisTemplate.opsForValue().get("health_check");
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Redis 不可用: {}", e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Redis 是否启用
|
||||||
|
*/
|
||||||
|
public boolean isRedisEnabled() {
|
||||||
|
return redisEnabled && redisTemplate != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Token 存储的 key
|
||||||
|
*/
|
||||||
|
private String getTokenKey(String username, String token) {
|
||||||
|
// 使用 token 的哈希值作为 key 的一部分,避免 key 过长
|
||||||
|
return TOKEN_PREFIX + username + ":" + token.hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成用户 Token 集合的 key
|
||||||
|
*/
|
||||||
|
private String getUserTokensKey(String username) {
|
||||||
|
return USER_TOKENS_PREFIX + username;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,9 @@ public class StoryboardVideoService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private SystemSettingsService systemSettingsService;
|
private SystemSettingsService systemSettingsService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private java.util.concurrent.Executor taskExecutor;
|
||||||
|
|
||||||
// 默认生成1张分镜图
|
// 默认生成1张分镜图
|
||||||
private static final int DEFAULT_STORYBOARD_IMAGES = 1;
|
private static final int DEFAULT_STORYBOARD_IMAGES = 1;
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ public class StoryboardVideoService {
|
|||||||
* 事务提交后,异步方法在事务外执行
|
* 事务提交后,异步方法在事务外执行
|
||||||
*/
|
*/
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
public StoryboardVideoTask createTask(String username, String prompt, String aspectRatio, boolean hdMode, String imageUrl, Integer duration, String imageModel) {
|
public StoryboardVideoTask createTask(String username, String prompt, String aspectRatio, boolean hdMode, String imageUrl, Integer duration, String imageModel, List<String> uploadedImages) {
|
||||||
// 验证参数
|
// 验证参数
|
||||||
if (username == null || username.trim().isEmpty()) {
|
if (username == null || username.trim().isEmpty()) {
|
||||||
throw new IllegalArgumentException("用户名不能为空");
|
throw new IllegalArgumentException("用户名不能为空");
|
||||||
@@ -119,6 +122,17 @@ public class StoryboardVideoService {
|
|||||||
task.setImageModel(imageModel);
|
task.setImageModel(imageModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存用户上传的多张图片(JSON数组)
|
||||||
|
if (uploadedImages != null && !uploadedImages.isEmpty()) {
|
||||||
|
try {
|
||||||
|
String uploadedImagesJson = objectMapper.writeValueAsString(uploadedImages);
|
||||||
|
task.setUploadedImages(uploadedImagesJson);
|
||||||
|
logger.info("保存用户上传的图片: taskId={}, 数量={}", taskId, uploadedImages.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("序列化上传图片失败: taskId={}", taskId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 上传用户参考图片到COS
|
// 上传用户参考图片到COS
|
||||||
if (imageUrl != null && !imageUrl.isEmpty()) {
|
if (imageUrl != null && !imageUrl.isEmpty()) {
|
||||||
String finalImageUrl = imageUrl;
|
String finalImageUrl = imageUrl;
|
||||||
@@ -178,34 +192,154 @@ public class StoryboardVideoService {
|
|||||||
// 在异步方法中使用 TransactionTemplate 手动管理事务,确保事务正确关闭
|
// 在异步方法中使用 TransactionTemplate 手动管理事务,确保事务正确关闭
|
||||||
StoryboardVideoTask taskInfo = loadTaskInfoWithTransactionTemplate(taskId);
|
StoryboardVideoTask taskInfo = loadTaskInfoWithTransactionTemplate(taskId);
|
||||||
String prompt = taskInfo.getPrompt();
|
String prompt = taskInfo.getPrompt();
|
||||||
String aspectRatio = taskInfo.getAspectRatio();
|
String targetAspectRatio = taskInfo.getAspectRatio(); // 用户选择的目标比例
|
||||||
boolean hdMode = taskInfo.isHdMode();
|
boolean hdMode = taskInfo.isHdMode();
|
||||||
String imageUrl = taskInfo.getImageUrl(); // 获取参考图片
|
String imageUrl = taskInfo.getImageUrl(); // 获取参考图片(兼容旧逻辑)
|
||||||
String imageModel = taskInfo.getImageModel(); // 获取图像生成模型
|
String imageModel = taskInfo.getImageModel(); // 获取图像生成模型
|
||||||
|
|
||||||
// 判断是否有参考图片
|
// 解析用户上传的多张图片
|
||||||
|
List<String> userUploadedImages = new ArrayList<>();
|
||||||
|
String uploadedImagesJson = taskInfo.getUploadedImages();
|
||||||
|
if (uploadedImagesJson != null && !uploadedImagesJson.isEmpty()) {
|
||||||
|
try {
|
||||||
|
List<String> parsedImages = objectMapper.readValue(uploadedImagesJson,
|
||||||
|
objectMapper.getTypeFactory().constructCollectionType(List.class, String.class));
|
||||||
|
// 过滤掉 null 和空字符串
|
||||||
|
for (String img : parsedImages) {
|
||||||
|
if (img != null && !img.isEmpty() && !"null".equals(img)) {
|
||||||
|
userUploadedImages.add(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info("解析用户上传图片: 有效数量={}", userUploadedImages.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("解析上传图片失败: taskId={}", taskId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否有用户上传的图片
|
||||||
|
boolean hasUserUploads = !userUploadedImages.isEmpty();
|
||||||
|
|
||||||
|
// 判断是否有参考图片(兼容旧逻辑)
|
||||||
boolean hasReferenceImage = imageUrl != null && !imageUrl.isEmpty();
|
boolean hasReferenceImage = imageUrl != null && !imageUrl.isEmpty();
|
||||||
|
|
||||||
// 从系统设置读取分镜图系统引导词,并拼接到用户提示词前面
|
// 根据是否有上传图片,决定生成分镜图使用的比例
|
||||||
String finalPrompt = prompt;
|
// 有上传图片:使用4:3;无上传图片:使用目标比例
|
||||||
|
String generationAspectRatio = imageGridService.getGenerationAspectRatio(targetAspectRatio, hasUserUploads);
|
||||||
|
|
||||||
|
// 第一步:调用提示词优化 API,获取 shotList、imagePrompt、videoPrompt
|
||||||
|
// 系统引导词配置为返回 JSON 格式数据
|
||||||
|
String finalPrompt = prompt; // 用于生成分镜图的提示词
|
||||||
|
String shotListResult = "";
|
||||||
|
String videoPromptResult = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
|
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
|
||||||
String storyboardSystemPrompt = settings.getStoryboardSystemPrompt();
|
String storyboardSystemPrompt = settings.getStoryboardSystemPrompt();
|
||||||
|
|
||||||
if (storyboardSystemPrompt != null && !storyboardSystemPrompt.trim().isEmpty()) {
|
if (storyboardSystemPrompt != null && !storyboardSystemPrompt.trim().isEmpty()) {
|
||||||
finalPrompt = storyboardSystemPrompt.trim() + ", " + prompt;
|
// 组合提示词并调用优化 API
|
||||||
logger.info("已添加系统引导词,最终提示词长度: {}", finalPrompt.length());
|
String combinedPrompt = storyboardSystemPrompt.trim() + "\n\n用户需求:" + prompt;
|
||||||
|
logger.info("开始调用提示词优化API,获取 JSON 格式结果...");
|
||||||
|
|
||||||
|
// 调用多模态提示词优化(传递用户上传的图片)
|
||||||
|
// 期望返回 JSON(包含 shotList、imagePrompt、videoPrompt)
|
||||||
|
String optimizedResult = realAIService.optimizePromptWithImages(combinedPrompt, "storyboard", userUploadedImages);
|
||||||
|
|
||||||
|
// 尝试解析 JSON 响应
|
||||||
|
try {
|
||||||
|
// 清理可能的 markdown 代码块
|
||||||
|
String jsonContent = optimizedResult.trim();
|
||||||
|
if (jsonContent.startsWith("```json")) {
|
||||||
|
jsonContent = jsonContent.substring(7);
|
||||||
|
} else if (jsonContent.startsWith("```")) {
|
||||||
|
jsonContent = jsonContent.substring(3);
|
||||||
|
}
|
||||||
|
if (jsonContent.endsWith("```")) {
|
||||||
|
jsonContent = jsonContent.substring(0, jsonContent.length() - 3);
|
||||||
|
}
|
||||||
|
jsonContent = jsonContent.trim();
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> jsonResult = objectMapper.readValue(jsonContent, Map.class);
|
||||||
|
|
||||||
|
// 提取字段(按照系统引导词返回的 JSON 格式)
|
||||||
|
logger.info("解析 JSON 字段,可用 keys: {}", jsonResult.keySet());
|
||||||
|
|
||||||
|
// shotList(可能是数组或字符串)或 shotListMarkdown
|
||||||
|
if (jsonResult.containsKey("shotList")) {
|
||||||
|
Object shotListObj = jsonResult.get("shotList");
|
||||||
|
if (shotListObj instanceof List) {
|
||||||
|
// 如果是数组,转换为 JSON 字符串保存
|
||||||
|
shotListResult = objectMapper.writeValueAsString(shotListObj);
|
||||||
|
} else {
|
||||||
|
shotListResult = String.valueOf(shotListObj);
|
||||||
|
}
|
||||||
|
logger.info("提取 shotList 成功,长度: {}", shotListResult.length());
|
||||||
|
}
|
||||||
|
if (jsonResult.containsKey("shotListMarkdown")) {
|
||||||
|
String markdown = String.valueOf(jsonResult.get("shotListMarkdown"));
|
||||||
|
if (shotListResult.isEmpty()) {
|
||||||
|
shotListResult = markdown;
|
||||||
|
}
|
||||||
|
logger.info("提取 shotListMarkdown 成功,长度: {}", markdown.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
// imagePrompt(优先使用,用于生成分镜图)
|
||||||
|
if (jsonResult.containsKey("imagePrompt")) {
|
||||||
|
finalPrompt = String.valueOf(jsonResult.get("imagePrompt"));
|
||||||
|
logger.info("提取 imagePrompt 成功,长度: {}", finalPrompt.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
// videoPrompt(用于生成视频)
|
||||||
|
if (jsonResult.containsKey("videoPrompt")) {
|
||||||
|
Object videoPromptObj = jsonResult.get("videoPrompt");
|
||||||
|
if (videoPromptObj != null && !"null".equals(String.valueOf(videoPromptObj))) {
|
||||||
|
videoPromptResult = String.valueOf(videoPromptObj);
|
||||||
|
logger.info("提取 videoPrompt 成功,长度: {}", videoPromptResult.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有 videoPrompt,使用 imagePrompt 作为备选
|
||||||
|
if (videoPromptResult.isEmpty() && !finalPrompt.equals(prompt)) {
|
||||||
|
videoPromptResult = finalPrompt;
|
||||||
|
logger.info("videoPrompt 为空,使用 imagePrompt 作为备选");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到任务(使用事务模板避免连接泄漏)
|
||||||
|
final String finalShotList = shotListResult;
|
||||||
|
final String finalImagePrompt = finalPrompt;
|
||||||
|
final String finalVideoPrompt = videoPromptResult;
|
||||||
|
asyncTransactionTemplate.executeWithoutResult(status -> {
|
||||||
|
StoryboardVideoTask t = taskRepository.findByTaskId(taskId).orElse(null);
|
||||||
|
if (t != null) {
|
||||||
|
t.setShotList(finalShotList);
|
||||||
|
t.setImagePrompt(finalImagePrompt);
|
||||||
|
t.setVideoPrompt(finalVideoPrompt);
|
||||||
|
taskRepository.save(t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.info("已保存优化后的提示词到任务: taskId={}", taskId);
|
||||||
|
|
||||||
|
} catch (Exception jsonException) {
|
||||||
|
// JSON 解析失败,使用原始优化结果作为 imagePrompt
|
||||||
|
logger.warn("JSON 解析失败,使用原始优化结果: {}", jsonException.getMessage());
|
||||||
|
finalPrompt = optimizedResult;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info("未配置系统引导词,使用原始提示词");
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.warn("获取系统引导词失败,使用原始提示词: {}", e.getMessage());
|
logger.warn("提示词优化失败,使用原始提示词: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
logger.info("任务参数 - 原始提示词: {}, 最终提示词长度: {}, aspectRatio: {}, hdMode: {}, 有参考图片: {}, imageModel: {}",
|
|
||||||
prompt, finalPrompt.length(), aspectRatio, hdMode, hasReferenceImage, imageModel);
|
logger.info("任务参数 - 原始提示词: {}, imagePrompt长度: {}, 目标比例: {}, 生成比例: {}, hdMode: {}, 有用户上传: {}, imageModel: {}",
|
||||||
|
prompt, finalPrompt.length(), targetAspectRatio, generationAspectRatio, hdMode, hasUserUploads, imageModel);
|
||||||
|
|
||||||
// 更新任务状态为处理中(使用 TransactionTemplate 确保事务正确关闭)
|
// 更新任务状态为处理中(使用 TransactionTemplate 确保事务正确关闭)
|
||||||
updateTaskStatusWithTransactionTemplate(taskId);
|
updateTaskStatusWithTransactionTemplate(taskId);
|
||||||
|
|
||||||
// 调用AI生图API,生成分镜图
|
// 调用AI生图API,生成分镜图
|
||||||
logger.info("开始生成分镜图({}模式)...", hasReferenceImage ? "图生图" : "文生图");
|
logger.info("开始生成分镜图({}模式)...", hasReferenceImage || hasUserUploads ? "图生图" : "文生图");
|
||||||
|
|
||||||
// 收集所有图片URL
|
// 收集所有图片URL
|
||||||
List<String> imageUrls = new ArrayList<>();
|
List<String> imageUrls = new ArrayList<>();
|
||||||
@@ -228,21 +362,22 @@ public class StoryboardVideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 根据是否有参考图片,调用不同的API
|
// 根据是否有参考图片,调用不同的API
|
||||||
// 使用拼接后的提示词 finalPrompt
|
// 使用系统引导词 + 用户提示词 (finalPrompt)
|
||||||
Map<String, Object> apiResponse;
|
Map<String, Object> apiResponse;
|
||||||
if (hasReferenceImage) {
|
if (hasReferenceImage || hasUserUploads) {
|
||||||
// 有参考图片,使用图生图API(bananaAPI)
|
// 有参考图片,使用图生图API(bananaAPI)
|
||||||
|
String refImage = hasUserUploads ? userUploadedImages.get(0) : imageUrl;
|
||||||
apiResponse = realAIService.submitImageToImageTask(
|
apiResponse = realAIService.submitImageToImageTask(
|
||||||
finalPrompt,
|
finalPrompt,
|
||||||
imageUrl,
|
refImage,
|
||||||
aspectRatio,
|
generationAspectRatio,
|
||||||
hdMode
|
hdMode
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 无参考图片,使用文生图API
|
// 无参考图片,使用文生图API
|
||||||
apiResponse = realAIService.submitTextToImageTask(
|
apiResponse = realAIService.submitTextToImageTask(
|
||||||
finalPrompt,
|
finalPrompt,
|
||||||
aspectRatio,
|
generationAspectRatio,
|
||||||
1, // 每次生成1张图片
|
1, // 每次生成1张图片
|
||||||
hdMode,
|
hdMode,
|
||||||
imageModel // 使用用户选择的图像生成模型
|
imageModel // 使用用户选择的图像生成模型
|
||||||
@@ -353,35 +488,18 @@ public class StoryboardVideoService {
|
|||||||
throw new RuntimeException(errorMsg);
|
throw new RuntimeException(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理分镜图
|
// 生成分镜图阶段:不做拼图处理,直接返回原始 AI 生成的分镜图给用户预览
|
||||||
String mergedImageUrl;
|
// 拼图处理(分镜图 + 用户上传图片)在生成视频时进行(TaskQueueService)
|
||||||
long mergeStartTime = System.currentTimeMillis();
|
String generatedImage = validatedImages.get(0);
|
||||||
|
logger.info("分镜图生成完成,直接返回原始图片供用户预览(拼图在生成视频时进行)");
|
||||||
|
|
||||||
// 如果有参考图片,将原图和生成图拼接在一起
|
// 检查生成的图片是否有效
|
||||||
if (hasReferenceImage && validatedImages.size() >= 1) {
|
if (generatedImage == null || generatedImage.isEmpty()) {
|
||||||
String generatedImage = validatedImages.get(0);
|
throw new RuntimeException("分镜图生成失败: 返回的图片为空");
|
||||||
logger.info("有参考图片,开始拼接原图和生成图...");
|
|
||||||
// 根据原图比例决定拼接方式:
|
|
||||||
// - 16:9横向图:创建9:16画布,左原图右生成图
|
|
||||||
// - 9:16纵向图:创建16:9画布,上原图下生成图
|
|
||||||
mergedImageUrl = imageGridService.mergeStoryboardImages(imageUrl, generatedImage);
|
|
||||||
logger.info("原图和生成图拼接完成");
|
|
||||||
} else if (validatedImages.size() == 1) {
|
|
||||||
// 无参考图片,只有1张图片,直接使用
|
|
||||||
mergedImageUrl = validatedImages.get(0);
|
|
||||||
logger.info("只有1张分镜图,直接使用");
|
|
||||||
} else {
|
|
||||||
// 多张图片,拼接成网格
|
|
||||||
logger.info("开始拼接{}张图片成分镜图网格...", validatedImages.size());
|
|
||||||
mergedImageUrl = imageGridService.mergeImagesToGrid(validatedImages, 0);
|
|
||||||
}
|
}
|
||||||
long mergeTime = System.currentTimeMillis() - mergeStartTime;
|
|
||||||
logger.info("分镜图处理完成,耗时: {}ms", mergeTime);
|
|
||||||
|
|
||||||
// 检查拼接后的图片URL是否有效
|
// 直接使用原始分镜图作为预览图
|
||||||
if (mergedImageUrl == null || mergedImageUrl.isEmpty()) {
|
String mergedImageUrl = generatedImage;
|
||||||
throw new RuntimeException("图片拼接失败: 返回的图片URL为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存单独的分镜图片(Base64数组,参考sora2实现)
|
// 保存单独的分镜图片(Base64数组,参考sora2实现)
|
||||||
// validatedImages 已在上面定义并验证
|
// validatedImages 已在上面定义并验证
|
||||||
@@ -401,8 +519,8 @@ public class StoryboardVideoService {
|
|||||||
long saveTime = System.currentTimeMillis() - saveStartTime;
|
long saveTime = System.currentTimeMillis() - saveStartTime;
|
||||||
|
|
||||||
long totalElapsed = System.currentTimeMillis() - startTime;
|
long totalElapsed = System.currentTimeMillis() - startTime;
|
||||||
logger.info("✓ 分镜图生成完成: taskId={}, 共{}张图片,已拼接完成,总耗时: {}ms (生成: {}ms, 拼接: {}ms, 保存: {}ms)",
|
logger.info("✓ 分镜图生成完成: taskId={}, 共{}张图片,总耗时: {}ms (生成: {}ms, 保存: {}ms)",
|
||||||
taskId, validatedImages.size(), totalElapsed, totalTime, mergeTime, saveTime);
|
taskId, validatedImages.size(), totalElapsed, totalTime, saveTime);
|
||||||
|
|
||||||
// 不再自动生成视频,等待用户点击"开始生成"按钮
|
// 不再自动生成视频,等待用户点击"开始生成"按钮
|
||||||
|
|
||||||
@@ -452,29 +570,9 @@ public class StoryboardVideoService {
|
|||||||
// 不抛出异常,避免影响主流程
|
// 不抛出异常,避免影响主流程
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建初始的 UserWork 记录(PROCESSING状态),以便前端可以恢复任务
|
// 注意:不在此处创建 STORYBOARD_VIDEO 的 UserWork 记录
|
||||||
try {
|
// 只有在视频真正生成完成后才创建,避免显示没有视频的空作品
|
||||||
// 检查是否已存在 UserWork
|
// 分镜图作品在 saveStoryboardImageResultWithTransactionTemplate 中创建
|
||||||
if (!userWorkService.getWorkByTaskId(taskId).isPresent()) {
|
|
||||||
com.example.demo.model.UserWork work = new com.example.demo.model.UserWork();
|
|
||||||
work.setUserId(getUserIdByUsername(task.getUsername()));
|
|
||||||
work.setUsername(task.getUsername());
|
|
||||||
work.setTaskId(taskId);
|
|
||||||
work.setWorkType(com.example.demo.model.UserWork.WorkType.STORYBOARD_VIDEO);
|
|
||||||
work.setTitle(generateTitle(task.getPrompt()));
|
|
||||||
work.setDescription("分镜视频生成中");
|
|
||||||
work.setPrompt(task.getPrompt());
|
|
||||||
work.setAspectRatio(task.getAspectRatio());
|
|
||||||
work.setQuality(task.isHdMode() ? "HD" : "SD");
|
|
||||||
work.setPointsCost(0); // 任务进行中,暂时不计算积分
|
|
||||||
work.setStatus(com.example.demo.model.UserWork.WorkStatus.PROCESSING);
|
|
||||||
userWorkService.createWork(work);
|
|
||||||
logger.info("初始UserWork记录已创建: taskId={}", taskId);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("创建初始UserWork记录失败: {}", taskId, e);
|
|
||||||
// 不抛出异常,避免影响主流程
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("更新任务状态失败: {}", taskId, e);
|
logger.error("更新任务状态失败: {}", taskId, e);
|
||||||
status.setRollbackOnly();
|
status.setRollbackOnly();
|
||||||
@@ -517,19 +615,25 @@ public class StoryboardVideoService {
|
|||||||
task.setStoryboardImages(storyboardImagesJson); // 单独的分镜图片(用于视频生成,保持Base64格式)
|
task.setStoryboardImages(storyboardImagesJson); // 单独的分镜图片(用于视频生成,保持Base64格式)
|
||||||
}
|
}
|
||||||
task.updateProgress(50); // 分镜图生成完成,进度50%
|
task.updateProgress(50); // 分镜图生成完成,进度50%
|
||||||
|
task.setStatus(StoryboardVideoTask.TaskStatus.COMPLETED); // 分镜图完成
|
||||||
taskRepository.save(task);
|
taskRepository.save(task);
|
||||||
|
logger.info("分镜图生成完成,任务状态已更新为 COMPLETED: taskId={}", taskId);
|
||||||
|
|
||||||
// 更新 TaskStatus 为完成状态
|
// 更新 TaskStatus 为 COMPLETED(分镜图生成完成,停止轮询)
|
||||||
|
// 用户点击"生成视频"时,会重新将状态改为 PROCESSING 并添加 externalTaskId
|
||||||
try {
|
try {
|
||||||
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
|
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
|
||||||
if (taskStatus != null) {
|
if (taskStatus != null) {
|
||||||
taskStatus.markAsCompleted(imageUrlForDb);
|
taskStatus.setResultUrl(imageUrlForDb);
|
||||||
taskStatus.setProgress(50); // 分镜图完成,进度50%
|
taskStatus.setProgress(50); // 分镜图完成,进度50%
|
||||||
|
taskStatus.setStatus(TaskStatus.Status.COMPLETED); // 标记为完成,停止轮询
|
||||||
|
taskStatus.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskStatus.setUpdatedAt(LocalDateTime.now());
|
||||||
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
|
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
|
||||||
logger.info("TaskStatus 已更新为完成: taskId={}", taskId);
|
logger.info("TaskStatus 已标记为 COMPLETED(分镜图完成): taskId={}", taskId);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("更新 TaskStatus 为完成失败: {}", taskId, e);
|
logger.error("更新 TaskStatus 状态失败: {}", taskId, e);
|
||||||
// 不抛出异常,避免影响主流程
|
// 不抛出异常,避免影响主流程
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,45 +663,38 @@ public class StoryboardVideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在异步方法中更新任务状态为失败(使用配置好的异步事务模板,超时3秒,确保快速完成)
|
* 在异步方法中更新任务状态为失败(通过 task_status 触发级联更新)
|
||||||
*/
|
*/
|
||||||
private void updateTaskStatusToFailedWithTransactionTemplate(String taskId, String errorMessage) {
|
private void updateTaskStatusToFailedWithTransactionTemplate(String taskId, String errorMessage) {
|
||||||
try {
|
try {
|
||||||
asyncTransactionTemplate.executeWithoutResult(status -> {
|
// 通过更新 task_status 触发级联更新(自动同步到业务表和 user_works)
|
||||||
try {
|
boolean updated = taskStatusPollingService.markTaskFailed(taskId, errorMessage);
|
||||||
StoryboardVideoTask task = taskRepository.findByTaskId(taskId)
|
if (updated) {
|
||||||
.orElseThrow(() -> new RuntimeException("任务未找到: " + taskId));
|
logger.info("任务状态已更新为失败(级联更新已触发): taskId={}", taskId);
|
||||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
} else {
|
||||||
task.setErrorMessage(errorMessage);
|
// 如果 task_status 记录不存在,回退到直接更新业务表
|
||||||
taskRepository.save(task);
|
logger.warn("task_status 记录不存在,直接更新业务表: taskId={}", taskId);
|
||||||
|
asyncTransactionTemplate.executeWithoutResult(status -> {
|
||||||
// 更新 TaskStatus 为失败状态
|
|
||||||
try {
|
try {
|
||||||
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
|
StoryboardVideoTask task = taskRepository.findByTaskId(taskId)
|
||||||
if (taskStatus != null) {
|
.orElseThrow(() -> new RuntimeException("任务未找到: " + taskId));
|
||||||
taskStatus.markAsFailed(errorMessage);
|
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||||
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
|
task.setErrorMessage(errorMessage);
|
||||||
logger.info("TaskStatus 已更新为失败: taskId={}", taskId);
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatusByTaskId(taskId,
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception ue) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", taskId);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("更新 TaskStatus 为失败失败: {}", taskId, e);
|
logger.error("直接更新业务表失败: {}", taskId, e);
|
||||||
// 不抛出异常,避免影响主流程
|
status.setRollbackOnly();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// 同步更新 UserWork 状态为失败
|
}
|
||||||
try {
|
|
||||||
userWorkService.updateWorkStatusByTaskId(taskId,
|
|
||||||
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
|
||||||
logger.info("UserWork 已更新为失败: taskId={}", taskId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.warn("更新 UserWork 状态失败: taskId={}", taskId);
|
|
||||||
// 不抛出异常,避免影响主流程
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("更新任务失败状态失败: {}", taskId, e);
|
|
||||||
status.setRollbackOnly();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("执行更新失败状态事务失败: {}", taskId, e);
|
logger.error("执行更新失败状态事务失败: {}", taskId, e);
|
||||||
}
|
}
|
||||||
@@ -649,16 +746,30 @@ public class StoryboardVideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新任务状态为失败(单独的事务方法)
|
* 更新任务状态为失败(通过 task_status 触发级联更新)
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateTaskStatusToFailed(String taskId, String errorMessage) {
|
public void updateTaskStatusToFailed(String taskId, String errorMessage) {
|
||||||
try {
|
try {
|
||||||
StoryboardVideoTask task = taskRepository.findByTaskId(taskId)
|
// 通过更新 task_status 触发级联更新
|
||||||
.orElseThrow(() -> new RuntimeException("任务未找到: " + taskId));
|
boolean updated = taskStatusPollingService.markTaskFailed(taskId, errorMessage);
|
||||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
if (!updated) {
|
||||||
task.setErrorMessage(errorMessage);
|
// 如果 task_status 记录不存在,回退到直接更新业务表
|
||||||
taskRepository.save(task);
|
logger.warn("task_status 记录不存在,直接更新业务表: taskId={}", taskId);
|
||||||
|
StoryboardVideoTask task = taskRepository.findByTaskId(taskId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("任务未找到: " + taskId));
|
||||||
|
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatusByTaskId(taskId,
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception ue) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
logger.error("更新任务失败状态失败: {}", taskId, ex);
|
logger.error("更新任务失败状态失败: {}", taskId, ex);
|
||||||
}
|
}
|
||||||
@@ -689,9 +800,10 @@ public class StoryboardVideoService {
|
|||||||
* @param duration 视频时长(可选,如果为null则使用任务中已有的值)
|
* @param duration 视频时长(可选,如果为null则使用任务中已有的值)
|
||||||
* @param aspectRatio 宽高比(可选,如果为null则使用任务中已有的值)
|
* @param aspectRatio 宽高比(可选,如果为null则使用任务中已有的值)
|
||||||
* @param hdMode 高清模式(可选,如果为null则使用任务中已有的值)
|
* @param hdMode 高清模式(可选,如果为null则使用任务中已有的值)
|
||||||
|
* @param referenceImages 参考图列表(可选)
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void startVideoGeneration(String taskId, Integer duration, String aspectRatio, Boolean hdMode) {
|
public void startVideoGeneration(String taskId, Integer duration, String aspectRatio, Boolean hdMode, java.util.List<String> referenceImages) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// 重新加载任务
|
// 重新加载任务
|
||||||
@@ -729,6 +841,19 @@ public class StoryboardVideoService {
|
|||||||
paramsUpdated = true;
|
paramsUpdated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新参考图(如果提供了新的参考图)
|
||||||
|
if (referenceImages != null && !referenceImages.isEmpty()) {
|
||||||
|
try {
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
String uploadedImagesJson = mapper.writeValueAsString(referenceImages);
|
||||||
|
task.setUploadedImages(uploadedImagesJson);
|
||||||
|
logger.info("更新任务 {} 的参考图: {} 张", taskId, referenceImages.size());
|
||||||
|
paramsUpdated = true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("序列化参考图失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是 COMPLETED 或 FAILED 状态,更新为 PROCESSING(表示正在生成视频)
|
// 如果是 COMPLETED 或 FAILED 状态,更新为 PROCESSING(表示正在生成视频)
|
||||||
if (task.getStatus() == StoryboardVideoTask.TaskStatus.COMPLETED ||
|
if (task.getStatus() == StoryboardVideoTask.TaskStatus.COMPLETED ||
|
||||||
task.getStatus() == StoryboardVideoTask.TaskStatus.FAILED) {
|
task.getStatus() == StoryboardVideoTask.TaskStatus.FAILED) {
|
||||||
@@ -739,25 +864,56 @@ public class StoryboardVideoService {
|
|||||||
logger.info("任务状态已更新: {} -> PROCESSING (开始视频生成)", taskId);
|
logger.info("任务状态已更新: {} -> PROCESSING (开始视频生成)", taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 无论任务状态如何,都确保 TaskStatus 为 PROCESSING,启用轮询
|
||||||
|
try {
|
||||||
|
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
|
||||||
|
if (taskStatus != null && taskStatus.getStatus() != TaskStatus.Status.PROCESSING) {
|
||||||
|
taskStatus.setStatus(TaskStatus.Status.PROCESSING);
|
||||||
|
taskStatus.setProgress(50);
|
||||||
|
taskStatus.setCompletedAt(null); // 清除完成时间
|
||||||
|
taskStatus.setUpdatedAt(LocalDateTime.now());
|
||||||
|
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
|
||||||
|
logger.info("TaskStatus 已更新为 PROCESSING(开始视频生成): taskId={}", taskId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("更新 TaskStatus 状态失败: {}", taskId, e);
|
||||||
|
}
|
||||||
|
|
||||||
// 如果有任何参数更新,保存任务
|
// 如果有任何参数更新,保存任务
|
||||||
if (paramsUpdated) {
|
if (paramsUpdated) {
|
||||||
taskRepository.save(task);
|
taskRepository.save(task);
|
||||||
logger.info("任务参数已更新并保存: {}", taskId);
|
logger.info("任务参数已更新并保存: {}", taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已经添加过视频生成任务(避免重复添加)
|
// 将视频生成任务添加到任务队列
|
||||||
// 这里可以通过检查任务队列来判断,但为了简单,我们直接添加
|
|
||||||
// 如果已经存在,TaskQueueService 会处理重复的情况
|
|
||||||
|
|
||||||
// 将视频生成任务添加到任务队列,由队列异步处理
|
|
||||||
try {
|
try {
|
||||||
taskQueueService.addStoryboardVideoTask(task.getUsername(), taskId);
|
taskQueueService.addStoryboardVideoTask(task.getUsername(), taskId);
|
||||||
// 任务状态保持 PROCESSING,等待视频生成完成
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("添加分镜视频任务到队列失败: {}", taskId, e);
|
logger.error("添加分镜视频任务到队列失败: {}", taskId, e);
|
||||||
throw new RuntimeException("添加视频生成任务失败: " + e.getMessage());
|
throw new RuntimeException("添加视频生成任务失败: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 在事务提交后异步触发视频生成
|
||||||
|
// 队列消费者已禁用对分镜视频的处理,由这里直接执行
|
||||||
|
final String finalTaskId = taskId;
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(
|
||||||
|
new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
taskExecutor.execute(() -> {
|
||||||
|
try {
|
||||||
|
// 短暂等待确保数据库同步完成
|
||||||
|
Thread.sleep(500);
|
||||||
|
logger.info("开始异步视频生成: taskId={}", finalTaskId);
|
||||||
|
taskQueueService.processStoryboardVideoTaskDirectly(finalTaskId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("异步视频生成失败: taskId={}", finalTaskId, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("开始生成视频失败: {}", taskId, e);
|
logger.error("开始生成视频失败: {}", taskId, e);
|
||||||
throw new RuntimeException("开始生成视频失败: " + e.getMessage());
|
throw new RuntimeException("开始生成视频失败: " + e.getMessage());
|
||||||
@@ -1071,23 +1227,44 @@ public class StoryboardVideoService {
|
|||||||
|
|
||||||
for (StoryboardVideoTask task : storyboardTimeoutTasks) {
|
for (StoryboardVideoTask task : storyboardTimeoutTasks) {
|
||||||
try {
|
try {
|
||||||
// 如果有resultUrl,说明分镜图已生成,跳过此阶段的超时检查
|
// 如果有resultUrl,说明分镜图已生成
|
||||||
if (task.getResultUrl() != null && !task.getResultUrl().isEmpty()) {
|
if (task.getResultUrl() != null && !task.getResultUrl().isEmpty()) {
|
||||||
|
// 检查是否请求了视频生成
|
||||||
|
String videoTaskIds = task.getVideoTaskIds();
|
||||||
|
if (videoTaskIds == null || videoTaskIds.isEmpty() || "[]".equals(videoTaskIds)) {
|
||||||
|
// 用户只生成了分镜图,未请求视频,标记为完成
|
||||||
|
task.updateStatus(StoryboardVideoTask.TaskStatus.COMPLETED);
|
||||||
|
task.setProgress(100);
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
// 更新 task_status
|
||||||
|
taskStatusPollingService.updateTaskStatusWithCascade(
|
||||||
|
task.getTaskId(),
|
||||||
|
com.example.demo.model.TaskStatus.Status.COMPLETED,
|
||||||
|
task.getResultUrl(),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
logger.info("分镜图任务完成(用户未请求视频): taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
storyboardSkippedCount++;
|
storyboardSkippedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分镜图生成超时,标记为失败
|
// 分镜图生成超时,尝试通过 task_status 触发级联更新
|
||||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "分镜图生成超时:任务创建后超过10分钟仍未完成");
|
||||||
task.setErrorMessage("分镜图生成超时:任务创建后超过10分钟仍未完成");
|
if (!updated) {
|
||||||
taskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||||
// 同步更新 UserWork 表的状态
|
task.setErrorMessage("分镜图生成超时:任务创建后超过10分钟仍未完成");
|
||||||
try {
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
|
taskRepository.save(task);
|
||||||
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
// 更新 UserWork
|
||||||
} catch (Exception e) {
|
try {
|
||||||
logger.warn("更新UserWork状态失败: taskId={}", task.getTaskId());
|
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception ue) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返还冻结积分
|
// 返还冻结积分
|
||||||
@@ -1132,17 +1309,28 @@ public class StoryboardVideoService {
|
|||||||
continue; // 已完成,不处理
|
continue; // 已完成,不处理
|
||||||
}
|
}
|
||||||
|
|
||||||
// 视频生成超时,标记为失败
|
// 只有当用户确实请求了视频生成(videoTaskIds不为空)时才检查超时
|
||||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
// 如果只生成了分镜图但未请求视频,不算超时
|
||||||
task.setErrorMessage("视频生成超时:任务创建后超过1小时仍未完成");
|
String videoTaskIds = task.getVideoTaskIds();
|
||||||
taskRepository.save(task);
|
if (videoTaskIds == null || videoTaskIds.isEmpty() || "[]".equals(videoTaskIds)) {
|
||||||
|
continue; // 用户未请求视频生成,跳过
|
||||||
|
}
|
||||||
|
|
||||||
// 同步更新 UserWork 表的状态
|
// 视频生成超时,尝试通过 task_status 触发级联更新
|
||||||
try {
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "视频生成超时:任务创建后超过1小时仍未完成");
|
||||||
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
|
if (!updated) {
|
||||||
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
// 回退:直接更新业务表
|
||||||
} catch (Exception e) {
|
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||||
logger.warn("更新UserWork状态失败: taskId={}", task.getTaskId());
|
task.setErrorMessage("视频生成超时:任务创建后超过1小时仍未完成");
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception ue) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返还冻结积分
|
// 返还冻结积分
|
||||||
@@ -1204,4 +1392,41 @@ public class StoryboardVideoService {
|
|||||||
}
|
}
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试失败的任务
|
||||||
|
* 复用原来的 task_id,重新提交生成任务
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void retryTask(String taskId) {
|
||||||
|
logger.info("重试失败任务: {}", taskId);
|
||||||
|
|
||||||
|
// 获取任务
|
||||||
|
StoryboardVideoTask task = taskRepository.findByTaskId(taskId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("任务不存在: " + taskId));
|
||||||
|
|
||||||
|
// 验证任务状态
|
||||||
|
if (task.getStatus() != StoryboardVideoTask.TaskStatus.FAILED) {
|
||||||
|
throw new RuntimeException("只能重试失败的任务");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置任务状态为 PROCESSING
|
||||||
|
task.setStatus(StoryboardVideoTask.TaskStatus.PROCESSING);
|
||||||
|
task.setErrorMessage(null);
|
||||||
|
task.setUpdatedAt(java.time.LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
|
||||||
|
// 使用事务钩子在事务提交后异步处理任务
|
||||||
|
final String finalTaskId = taskId;
|
||||||
|
final StoryboardVideoService self = applicationContext.getBean(StoryboardVideoService.class);
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
// 事务提交后,在事务外执行异步方法
|
||||||
|
self.processTaskAsync(finalTaskId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("任务 {} 已重新提交", taskId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ public class SystemSettingsService {
|
|||||||
defaults.setPromptOptimizationModel("gpt-5.1-thinking");
|
defaults.setPromptOptimizationModel("gpt-5.1-thinking");
|
||||||
defaults.setPromptOptimizationApiUrl("https://ai.comfly.chat");
|
defaults.setPromptOptimizationApiUrl("https://ai.comfly.chat");
|
||||||
defaults.setStoryboardSystemPrompt("");
|
defaults.setStoryboardSystemPrompt("");
|
||||||
defaults.setPromptOptimizationSystemPrompt("");
|
|
||||||
SystemSettings saved = repository.save(defaults);
|
SystemSettings saved = repository.save(defaults);
|
||||||
logger.info("Initialized default SystemSettings: std={}, pro={}, points={}",
|
logger.info("Initialized default SystemSettings: std={}, pro={}, points={}",
|
||||||
saved.getStandardPriceCny(), saved.getProPriceCny(), saved.getPointsPerGeneration());
|
saved.getStandardPriceCny(), saved.getProPriceCny(), saved.getPointsPerGeneration());
|
||||||
@@ -67,7 +66,6 @@ public class SystemSettingsService {
|
|||||||
current.setPromptOptimizationModel(updated.getPromptOptimizationModel());
|
current.setPromptOptimizationModel(updated.getPromptOptimizationModel());
|
||||||
current.setPromptOptimizationApiUrl(updated.getPromptOptimizationApiUrl());
|
current.setPromptOptimizationApiUrl(updated.getPromptOptimizationApiUrl());
|
||||||
current.setStoryboardSystemPrompt(updated.getStoryboardSystemPrompt());
|
current.setStoryboardSystemPrompt(updated.getStoryboardSystemPrompt());
|
||||||
current.setPromptOptimizationSystemPrompt(updated.getPromptOptimizationSystemPrompt());
|
|
||||||
return repository.save(current);
|
return repository.save(current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,6 +350,13 @@ public class TaskQueueService {
|
|||||||
taskQueue.setErrorMessage(failReason);
|
taskQueue.setErrorMessage(failReason);
|
||||||
taskQueueRepository.save(taskQueue);
|
taskQueueRepository.save(taskQueue);
|
||||||
|
|
||||||
|
// 更新业务表和 UserWork(通过级联或回退)
|
||||||
|
try {
|
||||||
|
updateRelatedTaskStatusWithError(taskQueue.getTaskId(), taskQueue.getTaskType(), failReason);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("系统重启:更新业务表状态失败: taskId={}", taskQueue.getTaskId());
|
||||||
|
}
|
||||||
|
|
||||||
// 返还冻结的积分
|
// 返还冻结的积分
|
||||||
try {
|
try {
|
||||||
userService.returnFrozenPoints(taskQueue.getTaskId());
|
userService.returnFrozenPoints(taskQueue.getTaskId());
|
||||||
@@ -373,10 +380,15 @@ public class TaskQueueService {
|
|||||||
// 检查是否超时
|
// 检查是否超时
|
||||||
boolean isTimeout = task.getCreatedAt() != null && task.getCreatedAt().isBefore(timeoutThreshold);
|
boolean isTimeout = task.getCreatedAt() != null && task.getCreatedAt().isBefore(timeoutThreshold);
|
||||||
if (isTimeout) {
|
if (isTimeout) {
|
||||||
// 超时任务标记为失败
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务超时(创建超过1小时)");
|
||||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
if (!updated) {
|
||||||
textToVideoTaskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
textToVideoTaskRepository.save(task);
|
||||||
|
}
|
||||||
businessTaskCleanedCount++;
|
businessTaskCleanedCount++;
|
||||||
logger.warn("系统重启:文生视频任务 {} 已超时,标记为失败", task.getTaskId());
|
logger.warn("系统重启:文生视频任务 {} 已超时,标记为失败", task.getTaskId());
|
||||||
} else {
|
} else {
|
||||||
@@ -393,10 +405,15 @@ public class TaskQueueService {
|
|||||||
// 检查是否超时
|
// 检查是否超时
|
||||||
boolean isTimeout = task.getCreatedAt() != null && task.getCreatedAt().isBefore(timeoutThreshold);
|
boolean isTimeout = task.getCreatedAt() != null && task.getCreatedAt().isBefore(timeoutThreshold);
|
||||||
if (isTimeout) {
|
if (isTimeout) {
|
||||||
// 超时任务标记为失败
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务超时(创建超过1小时)");
|
||||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
if (!updated) {
|
||||||
imageToVideoTaskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
imageToVideoTaskRepository.save(task);
|
||||||
|
}
|
||||||
businessTaskCleanedCount++;
|
businessTaskCleanedCount++;
|
||||||
logger.warn("系统重启:图生视频任务 {} 已超时,标记为失败", task.getTaskId());
|
logger.warn("系统重启:图生视频任务 {} 已超时,标记为失败", task.getTaskId());
|
||||||
} else {
|
} else {
|
||||||
@@ -415,17 +432,27 @@ public class TaskQueueService {
|
|||||||
boolean noRealTaskId = task.getRealTaskId() == null || task.getRealTaskId().isEmpty();
|
boolean noRealTaskId = task.getRealTaskId() == null || task.getRealTaskId().isEmpty();
|
||||||
|
|
||||||
if (isTimeout) {
|
if (isTimeout) {
|
||||||
// 超时任务标记为失败
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务超时(创建超过1小时)");
|
||||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
if (!updated) {
|
||||||
storyboardVideoTaskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
storyboardVideoTaskRepository.save(task);
|
||||||
|
}
|
||||||
businessTaskCleanedCount++;
|
businessTaskCleanedCount++;
|
||||||
logger.warn("系统重启:分镜视频任务 {} 已超时,标记为失败", task.getTaskId());
|
logger.warn("系统重启:分镜视频任务 {} 已超时,标记为失败", task.getTaskId());
|
||||||
} else if (noRealTaskId) {
|
} else if (noRealTaskId) {
|
||||||
// 没有提交到外部API的任务(分镜图生成阶段),重启后无法恢复,标记为失败
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "系统重启,分镜图生成任务已取消");
|
||||||
task.setErrorMessage("系统重启,分镜图生成任务已取消");
|
if (!updated) {
|
||||||
storyboardVideoTaskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage("系统重启,分镜图生成任务已取消");
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
storyboardVideoTaskRepository.save(task);
|
||||||
|
}
|
||||||
businessTaskCleanedCount++;
|
businessTaskCleanedCount++;
|
||||||
logger.warn("系统重启:分镜视频任务 {} 无外部任务ID,标记为失败", task.getTaskId());
|
logger.warn("系统重启:分镜视频任务 {} 无外部任务ID,标记为失败", task.getTaskId());
|
||||||
} else {
|
} else {
|
||||||
@@ -486,6 +513,7 @@ public class TaskQueueService {
|
|||||||
/**
|
/**
|
||||||
* 添加文生视频任务到队列
|
* 添加文生视频任务到队列
|
||||||
*/
|
*/
|
||||||
|
@Transactional
|
||||||
public TaskQueue addTextToVideoTask(String username, String taskId) {
|
public TaskQueue addTextToVideoTask(String username, String taskId) {
|
||||||
return addTaskToQueue(username, taskId, TaskQueue.TaskType.TEXT_TO_VIDEO);
|
return addTaskToQueue(username, taskId, TaskQueue.TaskType.TEXT_TO_VIDEO);
|
||||||
}
|
}
|
||||||
@@ -493,6 +521,7 @@ public class TaskQueueService {
|
|||||||
/**
|
/**
|
||||||
* 添加图生视频任务到队列
|
* 添加图生视频任务到队列
|
||||||
*/
|
*/
|
||||||
|
@Transactional
|
||||||
public TaskQueue addImageToVideoTask(String username, String taskId) {
|
public TaskQueue addImageToVideoTask(String username, String taskId) {
|
||||||
return addTaskToQueue(username, taskId, TaskQueue.TaskType.IMAGE_TO_VIDEO);
|
return addTaskToQueue(username, taskId, TaskQueue.TaskType.IMAGE_TO_VIDEO);
|
||||||
}
|
}
|
||||||
@@ -500,14 +529,15 @@ public class TaskQueueService {
|
|||||||
/**
|
/**
|
||||||
* 添加分镜视频任务到队列
|
* 添加分镜视频任务到队列
|
||||||
*/
|
*/
|
||||||
|
@Transactional
|
||||||
public TaskQueue addStoryboardVideoTask(String username, String taskId) {
|
public TaskQueue addStoryboardVideoTask(String username, String taskId) {
|
||||||
return addTaskToQueue(username, taskId, TaskQueue.TaskType.STORYBOARD_VIDEO);
|
return addTaskToQueue(username, taskId, TaskQueue.TaskType.STORYBOARD_VIDEO);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加任务到队列
|
* 添加任务到队列
|
||||||
|
* 注意:私有方法上的 @Transactional 不生效,事务由调用者(公共方法)管理
|
||||||
*/
|
*/
|
||||||
@Transactional
|
|
||||||
private TaskQueue addTaskToQueue(String username, String taskId, TaskQueue.TaskType taskType) {
|
private TaskQueue addTaskToQueue(String username, String taskId, TaskQueue.TaskType taskType) {
|
||||||
// 检查用户是否已有待处理任务
|
// 检查用户是否已有待处理任务
|
||||||
long pendingCount = taskQueueRepository.countPendingTasksByUsername(username);
|
long pendingCount = taskQueueRepository.countPendingTasksByUsername(username);
|
||||||
@@ -519,14 +549,31 @@ public class TaskQueueService {
|
|||||||
Optional<TaskQueue> existingTask = taskQueueRepository.findByTaskId(taskId);
|
Optional<TaskQueue> existingTask = taskQueueRepository.findByTaskId(taskId);
|
||||||
if (existingTask.isPresent()) {
|
if (existingTask.isPresent()) {
|
||||||
TaskQueue oldTask = existingTask.get();
|
TaskQueue oldTask = existingTask.get();
|
||||||
// 如果旧任务已失败或取消,删除旧记录允许重试
|
TaskQueue.QueueStatus oldStatus = oldTask.getStatus();
|
||||||
if (oldTask.getStatus() == TaskQueue.QueueStatus.FAILED ||
|
|
||||||
oldTask.getStatus() == TaskQueue.QueueStatus.CANCELLED) {
|
// COMPLETED/FAILED/CANCELLED/TIMEOUT: 直接删除旧记录,允许重新创建
|
||||||
logger.info("任务 {} 之前已失败/取消,删除旧记录允许重试", taskId);
|
if (oldStatus == TaskQueue.QueueStatus.FAILED ||
|
||||||
|
oldStatus == TaskQueue.QueueStatus.CANCELLED ||
|
||||||
|
oldStatus == TaskQueue.QueueStatus.COMPLETED ||
|
||||||
|
oldStatus == TaskQueue.QueueStatus.TIMEOUT) {
|
||||||
|
logger.info("任务 {} 之前状态为 {},删除旧记录允许重新创建", taskId, oldStatus);
|
||||||
taskQueueRepository.delete(oldTask);
|
taskQueueRepository.delete(oldTask);
|
||||||
taskQueueRepository.flush(); // 确保删除立即生效
|
taskQueueRepository.flush();
|
||||||
} else {
|
}
|
||||||
throw new RuntimeException("任务 " + taskId + " 已存在于队列中(状态: " + oldTask.getStatus() + ")");
|
// PROCESSING: 检查是否已超时(5分钟),超时才删除
|
||||||
|
else if (oldStatus == TaskQueue.QueueStatus.PROCESSING) {
|
||||||
|
java.time.LocalDateTime fiveMinutesAgo = java.time.LocalDateTime.now().minusMinutes(5);
|
||||||
|
if (oldTask.getUpdatedAt().isBefore(fiveMinutesAgo)) {
|
||||||
|
logger.info("任务 {} 处理超时(超过5分钟),删除旧记录允许重新创建", taskId);
|
||||||
|
taskQueueRepository.delete(oldTask);
|
||||||
|
taskQueueRepository.flush();
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("任务 " + taskId + " 正在处理中,请稍后再试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// PENDING: 任务正在排队,不允许重复创建
|
||||||
|
else if (oldStatus == TaskQueue.QueueStatus.PENDING) {
|
||||||
|
throw new RuntimeException("任务 " + taskId + " 正在排队中,请等待处理");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,7 +765,10 @@ public class TaskQueueService {
|
|||||||
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.IMAGE_TO_VIDEO) {
|
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.IMAGE_TO_VIDEO) {
|
||||||
apiResponse = processImageToVideoTask(taskQueue);
|
apiResponse = processImageToVideoTask(taskQueue);
|
||||||
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
|
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
|
||||||
apiResponse = processStoryboardVideoTask(taskQueue);
|
// 分镜视频任务由 StoryboardVideoService 的备份处理来执行
|
||||||
|
// 队列消费者跳过,避免并发问题
|
||||||
|
logger.info("分镜视频任务 {} 由备份处理执行,队列消费者跳过", taskQueue.getTaskId());
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
throw new RuntimeException("不支持的任务类型: " + taskQueue.getTaskType());
|
throw new RuntimeException("不支持的任务类型: " + taskQueue.getTaskType());
|
||||||
}
|
}
|
||||||
@@ -863,15 +913,18 @@ public class TaskQueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 增加检查次数(单独的事务方法,快速完成)
|
* 增加检查次数(单独的事务方法,快速完成)
|
||||||
|
* 注意:使用 TransactionTemplate 而非 @Transactional,因为私有方法上的注解不生效
|
||||||
*/
|
*/
|
||||||
@Transactional
|
|
||||||
private void incrementCheckCountWithTransaction(String taskId) {
|
private void incrementCheckCountWithTransaction(String taskId) {
|
||||||
Optional<TaskQueue> taskOpt = taskQueueRepository.findByTaskId(taskId);
|
transactionTemplate.execute(status -> {
|
||||||
if (taskOpt.isPresent()) {
|
Optional<TaskQueue> taskOpt = taskQueueRepository.findByTaskId(taskId);
|
||||||
TaskQueue taskQueue = taskOpt.get();
|
if (taskOpt.isPresent()) {
|
||||||
taskQueue.incrementCheckCount();
|
TaskQueue taskQueue = taskOpt.get();
|
||||||
taskQueueRepository.save(taskQueue);
|
taskQueue.incrementCheckCount();
|
||||||
}
|
taskQueueRepository.save(taskQueue);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -892,14 +945,16 @@ public class TaskQueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用只读事务获取文生视频任务(快速完成,避免连接泄漏)
|
* 使用只读事务获取文生视频任务(快速完成,避免连接泄漏)
|
||||||
|
* 注意:使用 TransactionTemplate 而非 @Transactional,因为私有方法上的注解不生效
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
|
||||||
private TextToVideoTask getTextToVideoTaskWithTransaction(String taskId) {
|
private TextToVideoTask getTextToVideoTaskWithTransaction(String taskId) {
|
||||||
Optional<TextToVideoTask> taskOpt = textToVideoTaskRepository.findByTaskId(taskId);
|
return readOnlyTransactionTemplate.execute(status -> {
|
||||||
if (!taskOpt.isPresent()) {
|
Optional<TextToVideoTask> taskOpt = textToVideoTaskRepository.findByTaskId(taskId);
|
||||||
throw new RuntimeException("找不到文生视频任务: " + taskId);
|
if (!taskOpt.isPresent()) {
|
||||||
}
|
throw new RuntimeException("找不到文生视频任务: " + taskId);
|
||||||
return taskOpt.get();
|
}
|
||||||
|
return taskOpt.get();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -924,14 +979,16 @@ public class TaskQueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用只读事务获取图生视频任务(快速完成,避免连接泄漏)
|
* 使用只读事务获取图生视频任务(快速完成,避免连接泄漏)
|
||||||
|
* 注意:使用 TransactionTemplate 而非 @Transactional,因为私有方法上的注解不生效
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
|
||||||
private ImageToVideoTask getImageToVideoTaskWithTransaction(String taskId) {
|
private ImageToVideoTask getImageToVideoTaskWithTransaction(String taskId) {
|
||||||
Optional<ImageToVideoTask> taskOpt = imageToVideoTaskRepository.findByTaskId(taskId);
|
return readOnlyTransactionTemplate.execute(status -> {
|
||||||
if (!taskOpt.isPresent()) {
|
Optional<ImageToVideoTask> taskOpt = imageToVideoTaskRepository.findByTaskId(taskId);
|
||||||
throw new RuntimeException("找不到图生视频任务: " + taskId);
|
if (!taskOpt.isPresent()) {
|
||||||
}
|
throw new RuntimeException("找不到图生视频任务: " + taskId);
|
||||||
return taskOpt.get();
|
}
|
||||||
|
return taskOpt.get();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -972,9 +1029,22 @@ public class TaskQueueService {
|
|||||||
// 如果只有网格图,直接使用(已经是拼接后的图片)
|
// 如果只有网格图,直接使用(已经是拼接后的图片)
|
||||||
logger.info("使用网格图生成视频(向后兼容)");
|
logger.info("使用网格图生成视频(向后兼容)");
|
||||||
|
|
||||||
|
// 组合视频提示词:shotList + videoPrompt
|
||||||
|
StringBuilder videoPromptBuilder = new StringBuilder();
|
||||||
|
if (task.getShotList() != null && !task.getShotList().trim().isEmpty()) {
|
||||||
|
videoPromptBuilder.append(task.getShotList().trim());
|
||||||
|
}
|
||||||
|
if (task.getVideoPrompt() != null && !task.getVideoPrompt().trim().isEmpty()) {
|
||||||
|
if (videoPromptBuilder.length() > 0) {
|
||||||
|
videoPromptBuilder.append("\n\n");
|
||||||
|
}
|
||||||
|
videoPromptBuilder.append(task.getVideoPrompt().trim());
|
||||||
|
}
|
||||||
|
String videoPromptForApi = videoPromptBuilder.length() > 0 ? videoPromptBuilder.toString() : task.getPrompt();
|
||||||
|
|
||||||
// 直接使用网格图调用图生视频接口
|
// 直接使用网格图调用图生视频接口
|
||||||
Map<String, Object> result = realAIService.submitImageToVideoTask(
|
Map<String, Object> result = realAIService.submitImageToVideoTask(
|
||||||
task.getPrompt(),
|
videoPromptForApi,
|
||||||
imageBase64,
|
imageBase64,
|
||||||
task.getAspectRatio(),
|
task.getAspectRatio(),
|
||||||
"10", // 默认10秒
|
"10", // 默认10秒
|
||||||
@@ -991,34 +1061,82 @@ public class TaskQueueService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保有6张图片
|
// 获取分镜图(取第一张)
|
||||||
if (images.size() < 6) {
|
String storyboardImage = images.get(0);
|
||||||
throw new RuntimeException("分镜图片数量不足,需要6张,当前只有" + images.size() + "张");
|
|
||||||
|
// 解析用户上传的图片
|
||||||
|
List<String> userUploadedImages = new ArrayList<>();
|
||||||
|
String uploadedImagesJson = task.getUploadedImages();
|
||||||
|
if (uploadedImagesJson != null && !uploadedImagesJson.isEmpty()) {
|
||||||
|
try {
|
||||||
|
List<String> parsedUploads = objectMapper.readValue(uploadedImagesJson,
|
||||||
|
objectMapper.getTypeFactory().constructCollectionType(List.class, String.class));
|
||||||
|
for (String img : parsedUploads) {
|
||||||
|
if (img != null && !img.isEmpty() && !"null".equals(img)) {
|
||||||
|
userUploadedImages.add(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info("解析用户上传图片,有效数量: {}", userUploadedImages.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("解析用户上传图片失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只取前6张图片
|
// 生成视频时进行拼图处理(分镜图 + 用户上传图片)
|
||||||
if (images.size() > 6) {
|
String imageForVideo;
|
||||||
logger.warn("分镜图片数量超过6张,只取前6张进行拼接");
|
if (!userUploadedImages.isEmpty()) {
|
||||||
images = images.subList(0, 6);
|
// 有用户上传图片,创建复合布局
|
||||||
|
logger.info("创建复合布局(分镜图 + {}张用户图片): 目标比例={}", userUploadedImages.size(), task.getAspectRatio());
|
||||||
|
try {
|
||||||
|
imageForVideo = imageGridService.createStoryboardLayout(storyboardImage, userUploadedImages, task.getAspectRatio());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("创建复合布局失败: {}", e.getMessage(), e);
|
||||||
|
throw new RuntimeException("创建复合布局失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无用户上传图片,创建简单布局(分镜图适配目标比例)
|
||||||
|
logger.info("创建简单布局(无用户图片): 目标比例={}", task.getAspectRatio());
|
||||||
|
try {
|
||||||
|
imageForVideo = imageGridService.createStoryboardLayout(storyboardImage, null, task.getAspectRatio());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("创建简单布局失败: {}", e.getMessage(), e);
|
||||||
|
throw new RuntimeException("创建简单布局失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("开始将6张分镜图拼接成一张图片...");
|
// 组合视频提示词:shotList + videoPrompt 组合成视频生成提示词
|
||||||
|
StringBuilder videoPromptBuilder = new StringBuilder();
|
||||||
|
|
||||||
// 将6张图片拼接成一张图片
|
// 添加 shotList(分镜描述)
|
||||||
String mergedImageBase64;
|
if (task.getShotList() != null && !task.getShotList().trim().isEmpty()) {
|
||||||
try {
|
videoPromptBuilder.append(task.getShotList().trim());
|
||||||
mergedImageBase64 = imageGridService.mergeImagesToGrid(images, 3); // 3列2行布局
|
logger.info("添加 shotList 到视频提示词,长度: {}", task.getShotList().length());
|
||||||
// 拼接完成
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("拼接分镜图失败: {}", e.getMessage(), e);
|
|
||||||
throw new RuntimeException("拼接分镜图失败: " + e.getMessage(), e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用拼接后的图片调用图生视频接口
|
// 添加 videoPrompt
|
||||||
logger.info("使用拼接后的图片调用图生视频接口...");
|
if (task.getVideoPrompt() != null && !task.getVideoPrompt().trim().isEmpty()) {
|
||||||
|
if (videoPromptBuilder.length() > 0) {
|
||||||
|
videoPromptBuilder.append("\n\n");
|
||||||
|
}
|
||||||
|
videoPromptBuilder.append(task.getVideoPrompt().trim());
|
||||||
|
logger.info("添加 videoPrompt 到视频提示词,长度: {}", task.getVideoPrompt().length());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果都没有,使用原始提示词
|
||||||
|
String videoPromptForApi;
|
||||||
|
if (videoPromptBuilder.length() > 0) {
|
||||||
|
videoPromptForApi = videoPromptBuilder.toString();
|
||||||
|
logger.info("组合后的视频提示词长度: {}", videoPromptForApi.length());
|
||||||
|
} else {
|
||||||
|
videoPromptForApi = task.getPrompt();
|
||||||
|
logger.info("使用原始提示词生成视频");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用拼好的图片调用图生视频接口
|
||||||
|
logger.info("使用拼好的图片调用图生视频接口,提示词长度: {}...", videoPromptForApi.length());
|
||||||
Map<String, Object> result = realAIService.submitImageToVideoTask(
|
Map<String, Object> result = realAIService.submitImageToVideoTask(
|
||||||
task.getPrompt(),
|
videoPromptForApi,
|
||||||
mergedImageBase64,
|
imageForVideo,
|
||||||
task.getAspectRatio(),
|
task.getAspectRatio(),
|
||||||
"10", // 默认10秒
|
"10", // 默认10秒
|
||||||
task.isHdMode()
|
task.isHdMode()
|
||||||
@@ -1080,14 +1198,16 @@ public class TaskQueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用只读事务获取分镜视频任务(快速完成,避免连接泄漏)
|
* 使用只读事务获取分镜视频任务(快速完成,避免连接泄漏)
|
||||||
|
* 注意:使用 TransactionTemplate 而非 @Transactional,因为私有方法上的注解不生效
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
|
||||||
private StoryboardVideoTask getStoryboardVideoTaskWithTransaction(String taskId) {
|
private StoryboardVideoTask getStoryboardVideoTaskWithTransaction(String taskId) {
|
||||||
Optional<StoryboardVideoTask> taskOpt = storyboardVideoTaskRepository.findByTaskId(taskId);
|
return readOnlyTransactionTemplate.execute(status -> {
|
||||||
if (!taskOpt.isPresent()) {
|
Optional<StoryboardVideoTask> taskOpt = storyboardVideoTaskRepository.findByTaskId(taskId);
|
||||||
throw new RuntimeException("找不到分镜视频任务: " + taskId);
|
if (!taskOpt.isPresent()) {
|
||||||
}
|
throw new RuntimeException("找不到分镜视频任务: " + taskId);
|
||||||
return taskOpt.get();
|
}
|
||||||
|
return taskOpt.get();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1297,7 +1417,27 @@ public class TaskQueueService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (taskQueue.getRealTaskId() == null) {
|
if (taskQueue.getRealTaskId() == null) {
|
||||||
logger.warn("任务 {} 的 realTaskId 为空,跳过轮询", taskId);
|
logger.warn("任务 {} 的 realTaskId 为空,标记为失败并返还积分", taskId);
|
||||||
|
|
||||||
|
// 标记任务为失败
|
||||||
|
String errorMessage = "任务提交失败:未能成功提交到外部API,请检查网络或稍后重试";
|
||||||
|
taskStatusPollingService.markTaskFailed(taskId, errorMessage);
|
||||||
|
|
||||||
|
// 更新业务表状态
|
||||||
|
fallbackUpdateBusinessTaskFailed(taskId, errorMessage);
|
||||||
|
|
||||||
|
// 返还积分
|
||||||
|
try {
|
||||||
|
userService.returnFrozenPoints(taskId);
|
||||||
|
logger.info("已返还任务 {} 的冻结积分", taskId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("返还积分失败(可能已处理): taskId={}", taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从队列中移除
|
||||||
|
taskQueue.setStatus(TaskQueue.QueueStatus.FAILED);
|
||||||
|
taskQueueRepository.save(taskQueue);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1831,94 +1971,130 @@ public class TaskQueueService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直接更新业务表和 UserWork(当 TaskQueue 不存在时使用)
|
* 通过 task_status 触发级联更新任务完成状态
|
||||||
|
* 如果 task_status 记录不存在,回退到直接更新业务表
|
||||||
*/
|
*/
|
||||||
private void updateBusinessTaskAndUserWork(String taskId, String resultUrl) {
|
private void updateBusinessTaskAndUserWork(String taskId, String resultUrl) {
|
||||||
try {
|
try {
|
||||||
transactionTemplate.executeWithoutResult(status -> {
|
// 尝试通过 task_status 触发级联更新
|
||||||
// 更新业务表
|
boolean updated = taskStatusPollingService.markTaskCompleted(taskId, resultUrl);
|
||||||
if (taskId.startsWith("img2vid_")) {
|
if (updated) {
|
||||||
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
logger.info("任务完成状态已更新(级联更新已触发): {}", taskId);
|
||||||
task.setStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
} else {
|
||||||
task.setResultUrl(resultUrl);
|
// 回退:直接更新业务表和 UserWork
|
||||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
logger.warn("task_status 记录不存在,回退到直接更新业务表: {}", taskId);
|
||||||
imageToVideoTaskRepository.save(task);
|
fallbackUpdateBusinessTaskCompleted(taskId, resultUrl);
|
||||||
logger.info("直接更新图生视频任务为完成: {}", taskId);
|
}
|
||||||
});
|
|
||||||
} else if (taskId.startsWith("storyboard_")) {
|
|
||||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
|
||||||
task.setStatus(StoryboardVideoTask.TaskStatus.COMPLETED);
|
|
||||||
task.setResultUrl(resultUrl);
|
|
||||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
|
||||||
storyboardVideoTaskRepository.save(task);
|
|
||||||
logger.info("直接更新分镜视频任务为完成: {}", taskId);
|
|
||||||
});
|
|
||||||
} else if (taskId.startsWith("txt2vid_")) {
|
|
||||||
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
|
||||||
task.setStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
|
||||||
task.setResultUrl(resultUrl);
|
|
||||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
|
||||||
textToVideoTaskRepository.save(task);
|
|
||||||
logger.info("直接更新文生视频任务为完成: {}", taskId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 UserWork
|
|
||||||
if (resultUrl != null && !resultUrl.isEmpty()) {
|
|
||||||
try {
|
|
||||||
userWorkService.createWorkFromTask(taskId, resultUrl);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.warn("更新 UserWork 失败: {}", taskId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("直接更新业务表失败: {}", taskId, e);
|
logger.error("更新任务完成状态失败: {}", taskId, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直接更新业务表和 UserWork 为失败状态
|
* 回退方法:直接更新业务表为完成状态
|
||||||
|
*/
|
||||||
|
private void fallbackUpdateBusinessTaskCompleted(String taskId, String resultUrl) {
|
||||||
|
transactionTemplate.executeWithoutResult(status -> {
|
||||||
|
if (taskId.startsWith("img2vid_")) {
|
||||||
|
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||||
|
task.setStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
||||||
|
task.setResultUrl(resultUrl);
|
||||||
|
task.setProgress(100);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
imageToVideoTaskRepository.save(task);
|
||||||
|
logger.info("回退更新图生视频任务为完成: {}", taskId);
|
||||||
|
});
|
||||||
|
} else if (taskId.startsWith("storyboard_")) {
|
||||||
|
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||||
|
task.setStatus(StoryboardVideoTask.TaskStatus.COMPLETED);
|
||||||
|
task.setResultUrl(resultUrl);
|
||||||
|
task.setProgress(100);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
storyboardVideoTaskRepository.save(task);
|
||||||
|
logger.info("回退更新分镜视频任务为完成: {}", taskId);
|
||||||
|
});
|
||||||
|
} else if (taskId.startsWith("txt2vid_")) {
|
||||||
|
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||||
|
task.setStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
||||||
|
task.setResultUrl(resultUrl);
|
||||||
|
task.setProgress(100);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
textToVideoTaskRepository.save(task);
|
||||||
|
logger.info("回退更新文生视频任务为完成: {}", taskId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 UserWork
|
||||||
|
if (resultUrl != null && !resultUrl.isEmpty()) {
|
||||||
|
try {
|
||||||
|
userWorkService.createWorkFromTask(taskId, resultUrl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新 UserWork 失败: {}", taskId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 task_status 触发级联更新任务失败状态
|
||||||
|
* 如果 task_status 记录不存在,回退到直接更新业务表
|
||||||
*/
|
*/
|
||||||
private void updateBusinessTaskAndUserWorkAsFailed(String taskId, String errorMessage) {
|
private void updateBusinessTaskAndUserWorkAsFailed(String taskId, String errorMessage) {
|
||||||
try {
|
try {
|
||||||
transactionTemplate.executeWithoutResult(status -> {
|
// 尝试通过 task_status 触发级联更新
|
||||||
// 更新业务表
|
boolean updated = taskStatusPollingService.markTaskFailed(taskId, errorMessage);
|
||||||
if (taskId.startsWith("img2vid_")) {
|
if (updated) {
|
||||||
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
logger.info("任务失败状态已更新(级联更新已触发): {}", taskId);
|
||||||
task.setStatus(ImageToVideoTask.TaskStatus.FAILED);
|
} else {
|
||||||
task.setErrorMessage(errorMessage);
|
// 回退:直接更新业务表和 UserWork
|
||||||
imageToVideoTaskRepository.save(task);
|
logger.warn("task_status 记录不存在,回退到直接更新业务表: {}", taskId);
|
||||||
logger.info("直接更新图生视频任务为失败: {}", taskId);
|
fallbackUpdateBusinessTaskFailed(taskId, errorMessage);
|
||||||
});
|
}
|
||||||
} else if (taskId.startsWith("storyboard_")) {
|
|
||||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
|
||||||
task.setStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
|
||||||
task.setErrorMessage(errorMessage);
|
|
||||||
storyboardVideoTaskRepository.save(task);
|
|
||||||
logger.info("直接更新分镜视频任务为失败: {}", taskId);
|
|
||||||
});
|
|
||||||
} else if (taskId.startsWith("txt2vid_")) {
|
|
||||||
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
|
||||||
task.setStatus(TextToVideoTask.TaskStatus.FAILED);
|
|
||||||
task.setErrorMessage(errorMessage);
|
|
||||||
textToVideoTaskRepository.save(task);
|
|
||||||
logger.info("直接更新文生视频任务为失败: {}", taskId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新 UserWork
|
|
||||||
try {
|
|
||||||
userWorkService.updateWorkStatus(taskId, UserWork.WorkStatus.FAILED);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.warn("更新 UserWork 状态为失败失败: {}", taskId, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("直接更新业务表为失败状态失败: {}", taskId, e);
|
logger.error("更新任务失败状态失败: {}", taskId, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回退方法:直接更新业务表为失败状态
|
||||||
|
*/
|
||||||
|
private void fallbackUpdateBusinessTaskFailed(String taskId, String errorMessage) {
|
||||||
|
transactionTemplate.executeWithoutResult(status -> {
|
||||||
|
if (taskId.startsWith("img2vid_")) {
|
||||||
|
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||||
|
task.setStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
imageToVideoTaskRepository.save(task);
|
||||||
|
logger.info("回退更新图生视频任务为失败: {}", taskId);
|
||||||
|
});
|
||||||
|
} else if (taskId.startsWith("storyboard_")) {
|
||||||
|
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||||
|
task.setStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
storyboardVideoTaskRepository.save(task);
|
||||||
|
logger.info("回退更新分镜视频任务为失败: {}", taskId);
|
||||||
|
});
|
||||||
|
} else if (taskId.startsWith("txt2vid_")) {
|
||||||
|
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||||
|
task.setStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
|
textToVideoTaskRepository.save(task);
|
||||||
|
logger.info("回退更新文生视频任务为失败: {}", taskId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatus(taskId, UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新 UserWork 状态失败: {}", taskId, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新任务为失败状态(使用独立事务,快速完成)
|
* 更新任务为失败状态(使用独立事务,快速完成)
|
||||||
* 使用 TransactionTemplate 确保事务正确执行(因为私有方法无法使用 @Transactional)
|
* 使用 TransactionTemplate 确保事务正确执行(因为私有方法无法使用 @Transactional)
|
||||||
@@ -2039,24 +2215,49 @@ public class TaskQueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新原始任务状态(使用独立事务,快速完成)
|
* 更新原始任务状态(使用独立事务,快速完成)
|
||||||
|
* 注意:COMPLETED 和 FAILED 状态会尝试通过 task_status 触发级联更新
|
||||||
*/
|
*/
|
||||||
private void updateOriginalTaskStatus(TaskQueue taskQueue, String status, String resultUrl, String errorMessage) {
|
private void updateOriginalTaskStatus(TaskQueue taskQueue, String status, String resultUrl, String errorMessage) {
|
||||||
logger.info("更新原始任务状态: taskId={}, taskType={}, status={}, resultUrl={}",
|
logger.info("更新原始任务状态: taskId={}, taskType={}, status={}, resultUrl={}",
|
||||||
taskQueue.getTaskId(), taskQueue.getTaskType(), status,
|
taskQueue.getTaskId(), taskQueue.getTaskType(), status,
|
||||||
resultUrl != null ? (resultUrl.length() > 50 ? resultUrl.substring(0, 50) + "..." : resultUrl) : "null");
|
resultUrl != null ? (resultUrl.length() > 50 ? resultUrl.substring(0, 50) + "..." : resultUrl) : "null");
|
||||||
try {
|
try {
|
||||||
|
String taskId = taskQueue.getTaskId();
|
||||||
|
|
||||||
|
// COMPLETED 和 FAILED 状态尝试通过 task_status 触发级联更新
|
||||||
|
if ("COMPLETED".equals(status)) {
|
||||||
|
boolean updated = taskStatusPollingService.markTaskCompleted(taskId, resultUrl);
|
||||||
|
if (updated) {
|
||||||
|
logger.info("任务完成状态已通过级联更新: taskId={}", taskId);
|
||||||
|
return; // 级联成功,不需要回退
|
||||||
|
}
|
||||||
|
// 级联失败,继续回退到直接更新
|
||||||
|
logger.warn("task_status 记录不存在,回退到直接更新业务表: taskId={}", taskId);
|
||||||
|
} else if ("FAILED".equals(status) || "CANCELLED".equals(status)) {
|
||||||
|
boolean updated = taskStatusPollingService.markTaskFailed(taskId, errorMessage);
|
||||||
|
if (updated) {
|
||||||
|
logger.info("任务失败状态已通过级联更新: taskId={}", taskId);
|
||||||
|
return; // 级联成功,不需要回退
|
||||||
|
}
|
||||||
|
// 级联失败,继续回退到直接更新
|
||||||
|
logger.warn("task_status 记录不存在,回退到直接更新业务表: taskId={}", taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退逻辑:直接更新业务表
|
||||||
if (taskQueue.getTaskType() == TaskQueue.TaskType.TEXT_TO_VIDEO) {
|
if (taskQueue.getTaskType() == TaskQueue.TaskType.TEXT_TO_VIDEO) {
|
||||||
Optional<TextToVideoTask> taskOpt = textToVideoTaskRepository.findByTaskId(taskQueue.getTaskId());
|
Optional<TextToVideoTask> taskOpt = textToVideoTaskRepository.findByTaskId(taskId);
|
||||||
if (taskOpt.isPresent()) {
|
if (taskOpt.isPresent()) {
|
||||||
TextToVideoTask task = taskOpt.get();
|
TextToVideoTask task = taskOpt.get();
|
||||||
if ("COMPLETED".equals(status)) {
|
if ("COMPLETED".equals(status)) {
|
||||||
task.setResultUrl(resultUrl);
|
task.setResultUrl(resultUrl);
|
||||||
task.updateStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
task.updateStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
||||||
task.updateProgress(100);
|
task.updateProgress(100);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
textToVideoTaskRepository.save(task);
|
textToVideoTaskRepository.save(task);
|
||||||
} else if ("FAILED".equals(status) || "CANCELLED".equals(status)) {
|
} else if ("FAILED".equals(status) || "CANCELLED".equals(status)) {
|
||||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||||
task.setErrorMessage(errorMessage);
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
textToVideoTaskRepository.save(task);
|
textToVideoTaskRepository.save(task);
|
||||||
} else if ("PROCESSING".equals(status)) {
|
} else if ("PROCESSING".equals(status)) {
|
||||||
// 处理中状态,更新resultUrl以显示进度
|
// 处理中状态,更新resultUrl以显示进度
|
||||||
@@ -2067,21 +2268,23 @@ public class TaskQueueService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.IMAGE_TO_VIDEO) {
|
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.IMAGE_TO_VIDEO) {
|
||||||
Optional<ImageToVideoTask> taskOpt = imageToVideoTaskRepository.findByTaskId(taskQueue.getTaskId());
|
Optional<ImageToVideoTask> taskOpt = imageToVideoTaskRepository.findByTaskId(taskId);
|
||||||
if (taskOpt.isPresent()) {
|
if (taskOpt.isPresent()) {
|
||||||
ImageToVideoTask task = taskOpt.get();
|
ImageToVideoTask task = taskOpt.get();
|
||||||
logger.info("找到ImageToVideoTask: taskId={}, 当前状态={}", taskQueue.getTaskId(), task.getStatus());
|
logger.info("找到ImageToVideoTask: taskId={}, 当前状态={}", taskId, task.getStatus());
|
||||||
if ("COMPLETED".equals(status)) {
|
if ("COMPLETED".equals(status)) {
|
||||||
task.setResultUrl(resultUrl);
|
task.setResultUrl(resultUrl);
|
||||||
task.updateStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
task.updateStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
||||||
task.updateProgress(100);
|
task.updateProgress(100);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
imageToVideoTaskRepository.save(task);
|
imageToVideoTaskRepository.save(task);
|
||||||
logger.info("ImageToVideoTask已更新为COMPLETED: taskId={}", taskQueue.getTaskId());
|
logger.info("ImageToVideoTask已更新为COMPLETED: taskId={}", taskId);
|
||||||
} else if ("FAILED".equals(status) || "CANCELLED".equals(status)) {
|
} else if ("FAILED".equals(status) || "CANCELLED".equals(status)) {
|
||||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||||
task.setErrorMessage(errorMessage);
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
imageToVideoTaskRepository.save(task);
|
imageToVideoTaskRepository.save(task);
|
||||||
logger.info("ImageToVideoTask已更新为FAILED: taskId={}", taskQueue.getTaskId());
|
logger.info("ImageToVideoTask已更新为FAILED: taskId={}", taskId);
|
||||||
} else if ("PROCESSING".equals(status)) {
|
} else if ("PROCESSING".equals(status)) {
|
||||||
// 处理中状态,更新resultUrl以显示进度
|
// 处理中状态,更新resultUrl以显示进度
|
||||||
if (resultUrl != null && !resultUrl.isEmpty()) {
|
if (resultUrl != null && !resultUrl.isEmpty()) {
|
||||||
@@ -2090,10 +2293,10 @@ public class TaskQueueService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn("ImageToVideoTask不存在: taskId={}", taskQueue.getTaskId());
|
logger.warn("ImageToVideoTask不存在: taskId={}", taskId);
|
||||||
}
|
}
|
||||||
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
|
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
|
||||||
Optional<StoryboardVideoTask> taskOpt = storyboardVideoTaskRepository.findByTaskId(taskQueue.getTaskId());
|
Optional<StoryboardVideoTask> taskOpt = storyboardVideoTaskRepository.findByTaskId(taskId);
|
||||||
if (taskOpt.isPresent()) {
|
if (taskOpt.isPresent()) {
|
||||||
StoryboardVideoTask task = taskOpt.get();
|
StoryboardVideoTask task = taskOpt.get();
|
||||||
if ("COMPLETED".equals(status)) {
|
if ("COMPLETED".equals(status)) {
|
||||||
@@ -2102,10 +2305,12 @@ public class TaskQueueService {
|
|||||||
task.setRealTaskId(taskQueue.getRealTaskId());
|
task.setRealTaskId(taskQueue.getRealTaskId());
|
||||||
task.updateStatus(StoryboardVideoTask.TaskStatus.COMPLETED);
|
task.updateStatus(StoryboardVideoTask.TaskStatus.COMPLETED);
|
||||||
task.updateProgress(100);
|
task.updateProgress(100);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
storyboardVideoTaskRepository.save(task);
|
storyboardVideoTaskRepository.save(task);
|
||||||
} else if ("FAILED".equals(status) || "CANCELLED".equals(status)) {
|
} else if ("FAILED".equals(status) || "CANCELLED".equals(status)) {
|
||||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||||
task.setErrorMessage(errorMessage);
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
storyboardVideoTaskRepository.save(task);
|
storyboardVideoTaskRepository.save(task);
|
||||||
} else if ("PROCESSING".equals(status)) {
|
} else if ("PROCESSING".equals(status)) {
|
||||||
// 处理中状态,更新resultUrl以显示进度
|
// 处理中状态,更新resultUrl以显示进度
|
||||||
@@ -2300,14 +2505,25 @@ public class TaskQueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新关联的具体任务状态(使用自定义错误信息)
|
* 更新关联的具体任务状态(使用自定义错误信息)
|
||||||
|
* 注意:通过 task_status 触发级联更新,如果失败则回退到直接更新
|
||||||
*/
|
*/
|
||||||
private void updateRelatedTaskStatusWithError(String taskId, TaskQueue.TaskType taskType, String errorMessage) {
|
private void updateRelatedTaskStatusWithError(String taskId, TaskQueue.TaskType taskType, String errorMessage) {
|
||||||
try {
|
try {
|
||||||
|
// 尝试通过 task_status 触发级联更新
|
||||||
|
boolean updated = taskStatusPollingService.markTaskFailed(taskId, errorMessage);
|
||||||
|
if (updated) {
|
||||||
|
logger.info("关联任务状态已通过级联更新为FAILED: taskId={}", taskId);
|
||||||
|
return; // 级联成功,不需要回退
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:直接更新业务表
|
||||||
|
logger.warn("task_status 记录不存在,回退到直接更新业务表: taskId={}", taskId);
|
||||||
switch (taskType) {
|
switch (taskType) {
|
||||||
case TEXT_TO_VIDEO:
|
case TEXT_TO_VIDEO:
|
||||||
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||||
task.setErrorMessage(errorMessage);
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
textToVideoTaskRepository.save(task);
|
textToVideoTaskRepository.save(task);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -2315,6 +2531,7 @@ public class TaskQueueService {
|
|||||||
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||||
task.setErrorMessage(errorMessage);
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
imageToVideoTaskRepository.save(task);
|
imageToVideoTaskRepository.save(task);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -2322,20 +2539,70 @@ public class TaskQueueService {
|
|||||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||||
task.setErrorMessage(errorMessage);
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||||
storyboardVideoTaskRepository.save(task);
|
storyboardVideoTaskRepository.save(task);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步更新 UserWork 状态为 FAILED,确保前端不会将失败任务当作进行中任务恢复
|
// 回退时也需要更新 UserWork
|
||||||
try {
|
try {
|
||||||
userWorkService.updateWorkStatus(taskId, UserWork.WorkStatus.FAILED);
|
userWorkService.updateWorkStatus(taskId, UserWork.WorkStatus.FAILED);
|
||||||
logger.info("已同步更新UserWork状态为FAILED: taskId={}", taskId);
|
logger.info("回退更新UserWork状态为FAILED: taskId={}", taskId);
|
||||||
} catch (Exception workException) {
|
} catch (Exception workException) {
|
||||||
logger.warn("更新UserWork状态为FAILED失败: taskId={}, error={}", taskId, workException.getMessage());
|
logger.warn("回退更新UserWork状态失败: taskId={}, error={}", taskId, workException.getMessage());
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.warn("更新关联任务状态失败: taskId={}, taskType={}", taskId, taskType, e);
|
logger.warn("更新关联任务状态失败: taskId={}, taskType={}", taskId, taskType, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接处理分镜视频任务(绕过队列消费者)
|
||||||
|
* 用于解决队列消费者状态检查问题
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
public void processStoryboardVideoTaskDirectly(String taskId) {
|
||||||
|
try {
|
||||||
|
// 获取任务队列记录
|
||||||
|
Optional<TaskQueue> taskQueueOpt = taskQueueRepository.findByTaskId(taskId);
|
||||||
|
if (!taskQueueOpt.isPresent()) {
|
||||||
|
logger.warn("直接处理分镜视频:任务不在队列中: {}", taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskQueue taskQueue = taskQueueOpt.get();
|
||||||
|
|
||||||
|
// 防重复检查:如果已经有 realTaskId,说明队列消费者已经处理了
|
||||||
|
if (taskQueue.getRealTaskId() != null && !taskQueue.getRealTaskId().isEmpty()) {
|
||||||
|
logger.info("任务 {} 已有 realTaskId,跳过直接处理(队列消费者已处理)", taskId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防重复检查:如果状态是 COMPLETED 或 FAILED,跳过
|
||||||
|
if (taskQueue.getStatus() == TaskQueue.QueueStatus.COMPLETED ||
|
||||||
|
taskQueue.getStatus() == TaskQueue.QueueStatus.FAILED) {
|
||||||
|
logger.info("任务 {} 状态为 {},跳过直接处理", taskId, taskQueue.getStatus());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 队列消费者已禁用对分镜视频的处理,不会有并发问题
|
||||||
|
// 直接调用视频生成逻辑
|
||||||
|
logger.info("直接处理分镜视频任务: taskId={}", taskId);
|
||||||
|
Map<String, Object> apiResponse = processStoryboardVideoTask(taskQueue);
|
||||||
|
|
||||||
|
// 提取真实任务ID
|
||||||
|
String realTaskId = extractRealTaskId(apiResponse);
|
||||||
|
if (realTaskId != null) {
|
||||||
|
saveRealTaskId(taskId, realTaskId);
|
||||||
|
logger.info("分镜视频任务提交成功: taskId={}, realTaskId={}", taskId, realTaskId);
|
||||||
|
} else {
|
||||||
|
logger.error("无法提取视频任务ID: taskId={}", taskId);
|
||||||
|
markTaskAsFailed(taskId, "API未返回有效的任务ID");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("直接处理分镜视频任务失败: taskId={}", taskId, e);
|
||||||
|
markTaskAsFailed(taskId, "视频生成失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.example.demo.service;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -39,6 +40,12 @@ public class TaskStatusPollingService {
|
|||||||
@org.springframework.context.annotation.Lazy
|
@org.springframework.context.annotation.Lazy
|
||||||
private TaskQueueService taskQueueService;
|
private TaskQueueService taskQueueService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.example.demo.repository.StoryboardVideoTaskRepository storyboardVideoTaskRepository;
|
||||||
|
|
||||||
@Value("${ai.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
|
@Value("${ai.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
|
||||||
private String apiKey;
|
private String apiKey;
|
||||||
|
|
||||||
@@ -239,6 +246,32 @@ public class TaskStatusPollingService {
|
|||||||
public void pollTaskStatus(TaskStatus task) {
|
public void pollTaskStatus(TaskStatus task) {
|
||||||
logger.info("轮询任务状态: taskId={}, externalTaskId={}", task.getTaskId(), task.getExternalTaskId());
|
logger.info("轮询任务状态: taskId={}, externalTaskId={}", task.getTaskId(), task.getExternalTaskId());
|
||||||
|
|
||||||
|
// 检查 externalTaskId 是否为空
|
||||||
|
if (task.getExternalTaskId() == null || task.getExternalTaskId().isEmpty()) {
|
||||||
|
// 对于分镜图任务(sb_ 开头),可能是同步处理的,不需要外部轮询
|
||||||
|
// 只对需要外部轮询的视频任务(txt2vid_, img2vid_)标记失败
|
||||||
|
String taskId = task.getTaskId();
|
||||||
|
boolean isVideoTask = taskId != null && (taskId.startsWith("txt2vid_") || taskId.startsWith("img2vid_"));
|
||||||
|
|
||||||
|
if (isVideoTask) {
|
||||||
|
logger.warn("视频任务 {} 的 externalTaskId 为空,标记为失败并返还积分", taskId);
|
||||||
|
String errorMessage = "任务提交失败:未能成功提交到外部API,请检查网络或稍后重试";
|
||||||
|
markTaskFailed(taskId, errorMessage);
|
||||||
|
|
||||||
|
// 返还积分
|
||||||
|
try {
|
||||||
|
userService.returnFrozenPoints(taskId);
|
||||||
|
logger.info("已返还任务 {} 的冻结积分", taskId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("返还积分失败(可能已处理): taskId={}", taskId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 分镜图任务跳过,由 StoryboardVideoService 自己处理
|
||||||
|
logger.debug("分镜任务 {} 无 externalTaskId,跳过轮询(由业务层处理)", taskId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String[] pendingAction = null;
|
String[] pendingAction = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -381,15 +414,32 @@ public class TaskStatusPollingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建新的任务状态记录
|
* 创建或更新任务状态记录(避免重复创建)
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public TaskStatus createTaskStatus(String taskId, String username, TaskStatus.TaskType taskType, String externalTaskId) {
|
public TaskStatus createTaskStatus(String taskId, String username, TaskStatus.TaskType taskType, String externalTaskId) {
|
||||||
|
// 先检查是否已存在
|
||||||
|
Optional<TaskStatus> existingOpt = taskStatusRepository.findFirstByTaskIdOrderByIdDesc(taskId);
|
||||||
|
if (existingOpt.isPresent()) {
|
||||||
|
TaskStatus existing = existingOpt.get();
|
||||||
|
// 如果已存在,更新 externalTaskId(如果提供了新的)
|
||||||
|
if (externalTaskId != null && !externalTaskId.isEmpty()) {
|
||||||
|
existing.setExternalTaskId(externalTaskId);
|
||||||
|
existing.setUpdatedAt(LocalDateTime.now());
|
||||||
|
logger.info("更新已存在的 TaskStatus: taskId={}, externalTaskId={}", taskId, externalTaskId);
|
||||||
|
return taskStatusRepository.save(existing);
|
||||||
|
}
|
||||||
|
logger.info("TaskStatus 已存在,跳过创建: taskId={}", taskId);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不存在则创建新的
|
||||||
TaskStatus taskStatus = new TaskStatus(taskId, username, taskType);
|
TaskStatus taskStatus = new TaskStatus(taskId, username, taskType);
|
||||||
taskStatus.setExternalTaskId(externalTaskId);
|
taskStatus.setExternalTaskId(externalTaskId);
|
||||||
taskStatus.setStatus(TaskStatus.Status.PROCESSING);
|
taskStatus.setStatus(TaskStatus.Status.PROCESSING);
|
||||||
taskStatus.setProgress(0);
|
taskStatus.setProgress(0);
|
||||||
|
|
||||||
|
logger.info("创建新的 TaskStatus: taskId={}, externalTaskId={}", taskId, externalTaskId);
|
||||||
return taskStatusRepository.save(taskStatus);
|
return taskStatusRepository.save(taskStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +447,7 @@ public class TaskStatusPollingService {
|
|||||||
* 根据任务ID获取状态
|
* 根据任务ID获取状态
|
||||||
*/
|
*/
|
||||||
public TaskStatus getTaskStatus(String taskId) {
|
public TaskStatus getTaskStatus(String taskId) {
|
||||||
return taskStatusRepository.findByTaskId(taskId).orElse(null);
|
return taskStatusRepository.findFirstByTaskIdOrderByIdDesc(taskId).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -412,7 +462,7 @@ public class TaskStatusPollingService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public boolean cancelTask(String taskId, String username) {
|
public boolean cancelTask(String taskId, String username) {
|
||||||
TaskStatus task = taskStatusRepository.findByTaskId(taskId).orElse(null);
|
TaskStatus task = taskStatusRepository.findFirstByTaskIdOrderByIdDesc(taskId).orElse(null);
|
||||||
|
|
||||||
if (task == null || !task.getUsername().equals(username)) {
|
if (task == null || !task.getUsername().equals(username)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -437,6 +487,120 @@ public class TaskStatusPollingService {
|
|||||||
return taskStatusRepository.save(taskStatus);
|
return taskStatusRepository.save(taskStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一更新任务状态(触发级联)
|
||||||
|
* 通过更新 task_status 表触发数据库触发器,自动级联更新其他表
|
||||||
|
*
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @param status 新状态
|
||||||
|
* @param resultUrl 结果URL(可为null)
|
||||||
|
* @param errorMessage 错误信息(可为null)
|
||||||
|
* @return 是否更新成功
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean updateTaskStatusWithCascade(String taskId, TaskStatus.Status status, String resultUrl, String errorMessage) {
|
||||||
|
// 使用 findFirst 处理可能的重复记录
|
||||||
|
TaskStatus taskStatus = taskStatusRepository.findFirstByTaskIdOrderByIdDesc(taskId).orElse(null);
|
||||||
|
if (taskStatus == null) {
|
||||||
|
logger.warn("任务状态记录不存在,无法触发级联更新: taskId={}", taskId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有状态变化时才更新(触发器条件)
|
||||||
|
if (taskStatus.getStatus() == status) {
|
||||||
|
logger.debug("任务状态未变化,跳过更新: taskId={}, status={}", taskId, status);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
taskStatus.setStatus(status);
|
||||||
|
taskStatus.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
if (resultUrl != null) {
|
||||||
|
taskStatus.setResultUrl(resultUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage != null) {
|
||||||
|
taskStatus.setErrorMessage(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == TaskStatus.Status.COMPLETED) {
|
||||||
|
taskStatus.setProgress(100);
|
||||||
|
taskStatus.setCompletedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
taskStatusRepository.save(taskStatus);
|
||||||
|
logger.info("任务状态已更新: taskId={}, status={}", taskId, status);
|
||||||
|
|
||||||
|
// 手动同步 StoryboardVideoTask 状态(避免依赖数据库触发器)
|
||||||
|
if (taskId != null && taskId.startsWith("sb_")) {
|
||||||
|
try {
|
||||||
|
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||||
|
com.example.demo.model.StoryboardVideoTask.TaskStatus newTaskStatus =
|
||||||
|
convertToStoryboardTaskStatus(status);
|
||||||
|
if (task.getStatus() != newTaskStatus) {
|
||||||
|
task.setStatus(newTaskStatus);
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
if (errorMessage != null) {
|
||||||
|
task.setErrorMessage(errorMessage);
|
||||||
|
}
|
||||||
|
if (resultUrl != null) {
|
||||||
|
task.setResultUrl(resultUrl);
|
||||||
|
}
|
||||||
|
if (status == TaskStatus.Status.COMPLETED) {
|
||||||
|
task.setProgress(100); // 完成时设置进度为100%
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
} else if (status == TaskStatus.Status.FAILED) {
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
storyboardVideoTaskRepository.save(task);
|
||||||
|
logger.info("已同步 StoryboardVideoTask 状态: taskId={}, status={}, progress={}", taskId, newTaskStatus, task.getProgress());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("同步 StoryboardVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 TaskStatus.Status 转换为 StoryboardVideoTask.TaskStatus
|
||||||
|
*/
|
||||||
|
private com.example.demo.model.StoryboardVideoTask.TaskStatus convertToStoryboardTaskStatus(TaskStatus.Status status) {
|
||||||
|
return switch (status) {
|
||||||
|
case PENDING -> com.example.demo.model.StoryboardVideoTask.TaskStatus.PENDING;
|
||||||
|
case PROCESSING -> com.example.demo.model.StoryboardVideoTask.TaskStatus.PROCESSING;
|
||||||
|
case COMPLETED -> com.example.demo.model.StoryboardVideoTask.TaskStatus.COMPLETED;
|
||||||
|
case FAILED -> com.example.demo.model.StoryboardVideoTask.TaskStatus.FAILED;
|
||||||
|
default -> com.example.demo.model.StoryboardVideoTask.TaskStatus.PROCESSING;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记任务为完成状态(触发级联)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean markTaskCompleted(String taskId, String resultUrl) {
|
||||||
|
return updateTaskStatusWithCascade(taskId, TaskStatus.Status.COMPLETED, resultUrl, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记任务为失败状态(触发级联)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean markTaskFailed(String taskId, String errorMessage) {
|
||||||
|
return updateTaskStatusWithCascade(taskId, TaskStatus.Status.FAILED, null, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记任务为处理中状态(触发级联)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public boolean markTaskProcessing(String taskId) {
|
||||||
|
return updateTaskStatusWithCascade(taskId, TaskStatus.Status.PROCESSING, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析进度字符串(如 "100%")为整数
|
* 解析进度字符串(如 "100%")为整数
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ public class TextToVideoService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserWorkService userWorkService;
|
private UserWorkService userWorkService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TaskStatusPollingService taskStatusPollingService;
|
||||||
|
|
||||||
@Value("${app.video.output.path:/outputs}")
|
@Value("${app.video.output.path:/outputs}")
|
||||||
private String outputPath;
|
private String outputPath;
|
||||||
|
|
||||||
@@ -174,13 +177,25 @@ public class TextToVideoService {
|
|||||||
taskRepository.save(currentTask);
|
taskRepository.save(currentTask);
|
||||||
logger.info("真实任务ID已保存: {} -> {}", task.getTaskId(), realTaskId);
|
logger.info("真实任务ID已保存: {} -> {}", task.getTaskId(), realTaskId);
|
||||||
} else {
|
} else {
|
||||||
// 如果没有找到任务ID,说明任务提交失败
|
// 如果没有找到任务ID,说明任务提交失败(尝试通过 task_status 触发级联)
|
||||||
logger.error("任务提交失败:未从API响应中获取到任务ID");
|
logger.error("任务提交失败:未从API响应中获取到任务ID");
|
||||||
currentTask = taskRepository.findByTaskId(task.getTaskId())
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务提交失败:API未返回有效的任务ID");
|
||||||
.orElseThrow(() -> new RuntimeException("任务不存在: " + task.getTaskId()));
|
if (!updated) {
|
||||||
currentTask.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
// 回退:直接更新业务表
|
||||||
currentTask.setErrorMessage("任务提交失败:API未返回有效的任务ID");
|
currentTask = taskRepository.findByTaskId(task.getTaskId())
|
||||||
taskRepository.save(currentTask);
|
.orElseThrow(() -> new RuntimeException("任务不存在: " + task.getTaskId()));
|
||||||
|
currentTask.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||||
|
currentTask.setErrorMessage("任务提交失败:API未返回有效的任务ID");
|
||||||
|
currentTask.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(currentTask);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatus(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
return CompletableFuture.completedFuture(null); // 直接返回,不进行轮询
|
return CompletableFuture.completedFuture(null); // 直接返回,不进行轮询
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,14 +210,24 @@ public class TextToVideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 重新加载任务以确保获取最新状态
|
// 尝试通过 task_status 触发级联更新
|
||||||
TextToVideoTask currentTask = taskRepository.findByTaskId(task.getTaskId())
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), e.getMessage());
|
||||||
.orElse(null);
|
if (!updated) {
|
||||||
if (currentTask != null) {
|
// 回退:直接更新业务表
|
||||||
// 更新状态为失败
|
TextToVideoTask currentTask = taskRepository.findByTaskId(task.getTaskId()).orElse(null);
|
||||||
currentTask.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
if (currentTask != null) {
|
||||||
currentTask.setErrorMessage(e.getMessage());
|
currentTask.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||||
taskRepository.save(currentTask);
|
currentTask.setErrorMessage(e.getMessage());
|
||||||
|
currentTask.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(currentTask);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatus(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception ue) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception saveException) {
|
} catch (Exception saveException) {
|
||||||
logger.error("保存失败状态时出错: {}", task.getTaskId(), saveException);
|
logger.error("保存失败状态时出错: {}", task.getTaskId(), saveException);
|
||||||
@@ -266,28 +291,44 @@ public class TextToVideoService {
|
|||||||
String resultUrl = (String) taskData.get("resultUrl");
|
String resultUrl = (String) taskData.get("resultUrl");
|
||||||
String errorMessage = (String) taskData.get("errorMessage");
|
String errorMessage = (String) taskData.get("errorMessage");
|
||||||
|
|
||||||
// 更新任务状态
|
// 更新任务状态(通过 task_status 表触发级联)
|
||||||
if ("completed".equals(status) || "success".equals(status)) {
|
if ("completed".equals(status) || "success".equals(status)) {
|
||||||
task.setResultUrl(resultUrl);
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.updateStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
boolean updated = taskStatusPollingService.markTaskCompleted(task.getTaskId(), resultUrl);
|
||||||
task.updateProgress(100);
|
if (!updated) {
|
||||||
taskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.setResultUrl(resultUrl);
|
||||||
// 同步更新 UserWork 表的状态和结果URL
|
task.updateStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
||||||
try {
|
task.updateProgress(100);
|
||||||
userWorkService.updateWorkOnComplete(task.getTaskId(), resultUrl,
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
taskRepository.save(task);
|
||||||
logger.info("文生视频任务完成,UserWork已更新: {}", task.getTaskId());
|
// 更新 UserWork
|
||||||
} catch (Exception e) {
|
try {
|
||||||
logger.warn("更新UserWork状态失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
|
userWorkService.updateWorkOnComplete(task.getTaskId(), resultUrl,
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("文生视频任务完成: {}", task.getTaskId());
|
logger.info("文生视频任务完成: {}", task.getTaskId());
|
||||||
return;
|
return;
|
||||||
} else if ("failed".equals(status) || "error".equals(status)) {
|
} else if ("failed".equals(status) || "error".equals(status)) {
|
||||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.setErrorMessage(errorMessage);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), errorMessage);
|
||||||
taskRepository.save(task);
|
if (!updated) {
|
||||||
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage(errorMessage);
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatus(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.error("文生视频任务失败: {}", task.getTaskId());
|
logger.error("文生视频任务失败: {}", task.getTaskId());
|
||||||
return;
|
return;
|
||||||
} else if ("processing".equals(status) || "pending".equals(status) || "running".equals(status)) {
|
} else if ("processing".equals(status) || "pending".equals(status) || "running".equals(status)) {
|
||||||
@@ -313,10 +354,22 @@ public class TextToVideoService {
|
|||||||
Thread.sleep(2000); // 每2秒轮询一次
|
Thread.sleep(2000); // 每2秒轮询一次
|
||||||
}
|
}
|
||||||
|
|
||||||
// 超时处理
|
// 超时处理(尝试通过 task_status 触发级联)
|
||||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务处理超时");
|
||||||
task.setErrorMessage("任务处理超时");
|
if (!updated) {
|
||||||
taskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage("任务处理超时");
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
// 更新 UserWork
|
||||||
|
try {
|
||||||
|
userWorkService.updateWorkStatus(task.getTaskId(),
|
||||||
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.error("文生视频任务超时: {}", task.getTaskId());
|
logger.error("文生视频任务超时: {}", task.getTaskId());
|
||||||
|
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
@@ -476,17 +529,22 @@ public class TextToVideoService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新任务状态为失败
|
// 尝试通过 task_status 触发级联更新
|
||||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "文生视频任务超时:任务创建后超过1小时仍未完成");
|
||||||
task.setErrorMessage("文生视频任务超时:任务创建后超过1小时仍未完成");
|
if (!updated) {
|
||||||
taskRepository.save(task);
|
// 回退:直接更新业务表
|
||||||
|
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||||
|
task.setErrorMessage("文生视频任务超时:任务创建后超过1小时仍未完成");
|
||||||
|
task.setCompletedAt(LocalDateTime.now());
|
||||||
|
taskRepository.save(task);
|
||||||
|
|
||||||
// 同步更新 UserWork 表的状态
|
// 更新 UserWork
|
||||||
try {
|
try {
|
||||||
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
|
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
|
||||||
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.warn("更新UserWork状态失败: taskId={}", task.getTaskId());
|
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn("文生视频任务超时,已标记为失败: taskId={}", task.getTaskId());
|
logger.warn("文生视频任务超时,已标记为失败: taskId={}", task.getTaskId());
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package com.example.demo.service;
|
||||||
|
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import com.example.demo.model.UserErrorLog;
|
||||||
|
import com.example.demo.model.UserErrorLog.ErrorType;
|
||||||
|
import com.example.demo.repository.UserErrorLogRepository;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户错误日志服务
|
||||||
|
* 负责记录、查询和统计用户错误
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class UserErrorLogService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(UserErrorLogService.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserErrorLogRepository userErrorLogRepository;
|
||||||
|
|
||||||
|
// ==================== 记录错误 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步记录错误日志(推荐使用,不阻塞主流程)
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void logErrorAsync(String username, ErrorType errorType, String errorMessage,
|
||||||
|
String errorSource, String taskId, String taskType) {
|
||||||
|
try {
|
||||||
|
logError(username, errorType, errorMessage, errorSource, taskId, taskType, null, null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("异步记录错误日志失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录错误日志(完整版本)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public UserErrorLog logError(String username, ErrorType errorType, String errorMessage,
|
||||||
|
String errorSource, String taskId, String taskType,
|
||||||
|
HttpServletRequest request, Exception exception) {
|
||||||
|
try {
|
||||||
|
UserErrorLog errorLog = new UserErrorLog(username, errorType, errorMessage, errorSource);
|
||||||
|
errorLog.setTaskId(taskId);
|
||||||
|
errorLog.setTaskType(taskType);
|
||||||
|
|
||||||
|
// 从请求中获取信息
|
||||||
|
if (request != null) {
|
||||||
|
errorLog.setRequestPath(request.getRequestURI());
|
||||||
|
errorLog.setRequestMethod(request.getMethod());
|
||||||
|
errorLog.setIpAddress(getClientIpAddress(request));
|
||||||
|
errorLog.setUserAgent(request.getHeader("User-Agent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录异常堆栈(截取前3000字符避免过长)
|
||||||
|
if (exception != null) {
|
||||||
|
String stackTrace = getStackTraceString(exception);
|
||||||
|
if (stackTrace.length() > 3000) {
|
||||||
|
stackTrace = stackTrace.substring(0, 3000) + "...[truncated]";
|
||||||
|
}
|
||||||
|
errorLog.setStackTrace(stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
UserErrorLog saved = userErrorLogRepository.save(errorLog);
|
||||||
|
logger.info("记录用户错误日志: id={}, username={}, type={}, source={}",
|
||||||
|
saved.getId(), username, errorType, errorSource);
|
||||||
|
return saved;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("保存错误日志失败: {}", e.getMessage(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷方法:记录任务错误
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void logTaskError(String username, String taskId, String taskType,
|
||||||
|
ErrorType errorType, String errorMessage) {
|
||||||
|
logError(username, errorType, errorMessage, "TaskService", taskId, taskType, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷方法:记录API错误
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void logApiError(String username, String apiPath, String errorMessage, Exception e) {
|
||||||
|
UserErrorLog log = new UserErrorLog(username, ErrorType.API_CALL_ERROR, errorMessage, "ApiService");
|
||||||
|
log.setRequestPath(apiPath);
|
||||||
|
if (e != null) {
|
||||||
|
log.setStackTrace(getStackTraceString(e));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
userErrorLogRepository.save(log);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
logger.error("记录API错误失败: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷方法:记录支付错误
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void logPaymentError(String username, String orderId, String errorMessage) {
|
||||||
|
UserErrorLog log = new UserErrorLog(username, ErrorType.PAYMENT_ERROR, errorMessage, "PaymentService");
|
||||||
|
log.setTaskId(orderId);
|
||||||
|
log.setTaskType("PAYMENT");
|
||||||
|
try {
|
||||||
|
userErrorLogRepository.save(log);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("记录支付错误失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快捷方法:记录认证错误
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void logAuthError(String username, ErrorType errorType, String errorMessage,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
logError(username, errorType, errorMessage, "AuthService", null, null, request, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 查询错误 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有错误日志(分页)
|
||||||
|
*/
|
||||||
|
public Page<UserErrorLog> getAllErrors(int page, int size) {
|
||||||
|
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
return userErrorLogRepository.findAll(pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的错误日志(分页)
|
||||||
|
*/
|
||||||
|
public Page<UserErrorLog> getUserErrors(String username, int page, int size) {
|
||||||
|
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
return userErrorLogRepository.findByUsername(username, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按错误类型查询(分页)
|
||||||
|
*/
|
||||||
|
public Page<UserErrorLog> getErrorsByType(ErrorType errorType, int page, int size) {
|
||||||
|
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
return userErrorLogRepository.findByErrorType(errorType, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最近的错误
|
||||||
|
*/
|
||||||
|
public List<UserErrorLog> getRecentErrors(int limit) {
|
||||||
|
if (limit <= 10) {
|
||||||
|
return userErrorLogRepository.findTop10ByOrderByCreatedAtDesc();
|
||||||
|
}
|
||||||
|
return userErrorLogRepository.findTop50ByOrderByCreatedAtDesc();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户最近的错误
|
||||||
|
*/
|
||||||
|
public List<UserErrorLog> getUserRecentErrors(String username) {
|
||||||
|
return userErrorLogRepository.findTop10ByUsernameOrderByCreatedAtDesc(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按任务ID查询错误
|
||||||
|
*/
|
||||||
|
public List<UserErrorLog> getErrorsByTaskId(String taskId) {
|
||||||
|
return userErrorLogRepository.findByTaskIdOrderByCreatedAtDesc(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按时间范围查询
|
||||||
|
*/
|
||||||
|
public List<UserErrorLog> getErrorsByDateRange(LocalDate startDate, LocalDate endDate) {
|
||||||
|
LocalDateTime startTime = startDate.atStartOfDay();
|
||||||
|
LocalDateTime endTime = endDate.atTime(LocalTime.MAX);
|
||||||
|
return userErrorLogRepository.findByCreatedAtBetweenOrderByCreatedAtDesc(startTime, endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 统计功能 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取错误统计概览
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getErrorStatistics(int days) {
|
||||||
|
Map<String, Object> stats = new HashMap<>();
|
||||||
|
LocalDateTime startTime = LocalDateTime.now().minusDays(days);
|
||||||
|
|
||||||
|
// 总错误数
|
||||||
|
long totalErrors = userErrorLogRepository.countByCreatedAtBetween(startTime, LocalDateTime.now());
|
||||||
|
stats.put("totalErrors", totalErrors);
|
||||||
|
|
||||||
|
// 按类型统计
|
||||||
|
List<Object[]> byType = userErrorLogRepository.countByErrorType(startTime);
|
||||||
|
Map<String, Long> errorsByType = new LinkedHashMap<>();
|
||||||
|
for (Object[] row : byType) {
|
||||||
|
ErrorType type = (ErrorType) row[0];
|
||||||
|
Long count = (Long) row[1];
|
||||||
|
errorsByType.put(type.name(), count);
|
||||||
|
}
|
||||||
|
stats.put("errorsByType", errorsByType);
|
||||||
|
|
||||||
|
// 按来源统计
|
||||||
|
List<Object[]> bySource = userErrorLogRepository.countByErrorSource(startTime);
|
||||||
|
Map<String, Long> errorsBySource = new LinkedHashMap<>();
|
||||||
|
for (Object[] row : bySource) {
|
||||||
|
String source = (String) row[0];
|
||||||
|
Long count = (Long) row[1];
|
||||||
|
errorsBySource.put(source, count);
|
||||||
|
}
|
||||||
|
stats.put("errorsBySource", errorsBySource);
|
||||||
|
|
||||||
|
// 按日期统计
|
||||||
|
List<Object[]> byDate = userErrorLogRepository.countByDate(startTime);
|
||||||
|
Map<String, Long> errorsByDate = new LinkedHashMap<>();
|
||||||
|
for (Object[] row : byDate) {
|
||||||
|
String date = row[0].toString();
|
||||||
|
Long count = (Long) row[1];
|
||||||
|
errorsByDate.put(date, count);
|
||||||
|
}
|
||||||
|
stats.put("errorsByDate", errorsByDate);
|
||||||
|
|
||||||
|
// 错误最多的用户(Top 10)
|
||||||
|
List<Object[]> byUser = userErrorLogRepository.countByUsername(startTime);
|
||||||
|
Map<String, Long> topErrorUsers = new LinkedHashMap<>();
|
||||||
|
int count = 0;
|
||||||
|
for (Object[] row : byUser) {
|
||||||
|
if (count >= 10) break;
|
||||||
|
String username = (String) row[0];
|
||||||
|
Long errorCount = (Long) row[1];
|
||||||
|
topErrorUsers.put(username, errorCount);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
stats.put("topErrorUsers", topErrorUsers);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户错误统计
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getUserErrorStatistics(String username) {
|
||||||
|
Map<String, Object> stats = new HashMap<>();
|
||||||
|
|
||||||
|
long totalErrors = userErrorLogRepository.countByUsername(username);
|
||||||
|
stats.put("totalErrors", totalErrors);
|
||||||
|
|
||||||
|
List<UserErrorLog> recentErrors = userErrorLogRepository.findTop10ByUsernameOrderByCreatedAtDesc(username);
|
||||||
|
stats.put("recentErrors", recentErrors);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时清理旧错误日志(保疙30天)
|
||||||
|
* 每天凌晨3点执行
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "0 0 3 * * ?")
|
||||||
|
@Transactional
|
||||||
|
public void cleanupOldErrors() {
|
||||||
|
try {
|
||||||
|
LocalDateTime beforeTime = LocalDateTime.now().minusDays(30);
|
||||||
|
userErrorLogRepository.deleteByCreatedAtBefore(beforeTime);
|
||||||
|
logger.info("清理30天前的错误日志完成");
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("清理旧错误日志失败: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 工具方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取客户端IP地址
|
||||||
|
*/
|
||||||
|
private String getClientIpAddress(HttpServletRequest request) {
|
||||||
|
String ip = request.getHeader("X-Forwarded-For");
|
||||||
|
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.getHeader("HTTP_CLIENT_IP");
|
||||||
|
}
|
||||||
|
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||||
|
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
|
||||||
|
}
|
||||||
|
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||||
|
ip = request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
// 多个代理时取第一个IP
|
||||||
|
if (ip != null && ip.contains(",")) {
|
||||||
|
ip = ip.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取异常堆栈字符串
|
||||||
|
*/
|
||||||
|
private String getStackTraceString(Exception e) {
|
||||||
|
StringWriter sw = new StringWriter();
|
||||||
|
PrintWriter pw = new PrintWriter(sw);
|
||||||
|
e.printStackTrace(pw);
|
||||||
|
return sw.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,7 +285,10 @@ public class UserWorkService {
|
|||||||
work.setDescription("分镜视频作品");
|
work.setDescription("分镜视频作品");
|
||||||
work.setPrompt(task.getPrompt());
|
work.setPrompt(task.getPrompt());
|
||||||
work.setResultUrl(resultUrl);
|
work.setResultUrl(resultUrl);
|
||||||
work.setDuration("10s"); // 分镜视频默认10秒
|
work.setThumbnailUrl(task.getResultUrl()); // 保存分镜图作为缩略图
|
||||||
|
work.setImagePrompt(task.getImagePrompt()); // 保存优化后的分镜图提示词
|
||||||
|
work.setVideoPrompt(task.getVideoPrompt()); // 保存优化后的视频提示词
|
||||||
|
work.setDuration(task.getDuration() != null ? task.getDuration() + "s" : "10s");
|
||||||
work.setAspectRatio(task.getAspectRatio());
|
work.setAspectRatio(task.getAspectRatio());
|
||||||
work.setQuality(task.isHdMode() ? "HD" : "SD");
|
work.setQuality(task.isHdMode() ? "HD" : "SD");
|
||||||
work.setPointsCost(task.getCostPoints());
|
work.setPointsCost(task.getCostPoints());
|
||||||
@@ -293,7 +296,7 @@ public class UserWorkService {
|
|||||||
work.setCompletedAt(LocalDateTime.now());
|
work.setCompletedAt(LocalDateTime.now());
|
||||||
|
|
||||||
work = userWorkRepository.save(work);
|
work = userWorkRepository.save(work);
|
||||||
logger.info("创建分镜视频作品成功: {}, 用户: {}", work.getId(), work.getUsername());
|
logger.info("创建分镜视频作品成功: {}, 用户: {}, 分镜图: {}", work.getId(), work.getUsername(), task.getResultUrl() != null ? "有" : "无");
|
||||||
return work;
|
return work;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,23 +24,37 @@ public class JwtUtils {
|
|||||||
private Long expiration;
|
private Long expiration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成JWT Token
|
* 生成JWT Token(使用默认过期时间)
|
||||||
*/
|
*/
|
||||||
public String generateToken(String username, String role, Long userId) {
|
public String generateToken(String username, String role, Long userId) {
|
||||||
|
return generateToken(username, role, userId, expiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成JWT Token(使用指定过期时间,单位毫秒)
|
||||||
|
*/
|
||||||
|
public String generateToken(String username, String role, Long userId, long expirationMs) {
|
||||||
Map<String, Object> claims = new HashMap<>();
|
Map<String, Object> claims = new HashMap<>();
|
||||||
claims.put("username", username);
|
claims.put("username", username);
|
||||||
claims.put("role", role);
|
claims.put("role", role);
|
||||||
claims.put("userId", userId);
|
claims.put("userId", userId);
|
||||||
|
|
||||||
return createToken(claims, username);
|
return createToken(claims, username, expirationMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建Token
|
* 创建Token
|
||||||
*/
|
*/
|
||||||
private String createToken(Map<String, Object> claims, String subject) {
|
private String createToken(Map<String, Object> claims, String subject) {
|
||||||
|
return createToken(claims, subject, expiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建Token(使用指定过期时间)
|
||||||
|
*/
|
||||||
|
private String createToken(Map<String, Object> claims, String subject, long expirationMs) {
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
Date expiryDate = new Date(now.getTime() + expiration);
|
Date expiryDate = new Date(now.getTime() + expirationMs);
|
||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.claims(claims)
|
.claims(claims)
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ public class UserIdGenerator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成用户ID
|
* 生成用户ID
|
||||||
* @return 格式: UID + yyMMdd + 4位随机字符 (共14位)
|
* @return 格式: UID + yyMMdd + 5位随机字符 (共15位)
|
||||||
*/
|
*/
|
||||||
public static String generate() {
|
public static String generate() {
|
||||||
String datePart = LocalDate.now().format(DATE_FORMAT);
|
String datePart = LocalDate.now().format(DATE_FORMAT);
|
||||||
String randomPart = generateRandomString(4);
|
String randomPart = generateRandomString(5);
|
||||||
return PREFIX + datePart + randomPart;
|
return PREFIX + datePart + randomPart;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ public class UserIdGenerator {
|
|||||||
* 验证用户ID格式是否正确
|
* 验证用户ID格式是否正确
|
||||||
*/
|
*/
|
||||||
public static boolean isValid(String userId) {
|
public static boolean isValid(String userId) {
|
||||||
if (userId == null || userId.length() != 14) {
|
if (userId == null || userId.length() != 15) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!userId.startsWith(PREFIX)) {
|
if (!userId.startsWith(PREFIX)) {
|
||||||
@@ -53,8 +53,8 @@ public class UserIdGenerator {
|
|||||||
if (!datePart.matches("\\d{6}")) {
|
if (!datePart.matches("\\d{6}")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 检查随机部分(4位字母数字)
|
// 检查随机部分(5位字母数字)
|
||||||
String randomPart = userId.substring(9);
|
String randomPart = userId.substring(9);
|
||||||
return randomPart.matches("[A-Z0-9]{4}");
|
return randomPart.matches("[A-Z0-9]{5}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#Updated by API Key Management
|
#Updated by API Key Management
|
||||||
#Sat Dec 06 10:23:35 CST 2025
|
#Tue Dec 09 15:23:38 CST 2025
|
||||||
ai.api.base-url=https\://ai.comfly.chat
|
ai.api.base-url=https\://ai.comfly.chat
|
||||||
ai.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
|
ai.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
|
||||||
ai.image.api.base-url=https\://ai.comfly.chat
|
ai.image.api.base-url=https\://ai.comfly.chat
|
||||||
ai.image.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
|
ai.image.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
|
||||||
alipay.app-id=9021000157616562
|
alipay.app-id=9021000157616562
|
||||||
alipay.charset=UTF-8
|
alipay.charset=UTF-8
|
||||||
|
alipay.domain=https\://vionow.com
|
||||||
alipay.gateway-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
|
alipay.gateway-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
|
||||||
alipay.notify-url=https\://vionow.com/api/payments/alipay/notify
|
alipay.notify-url=https\://vionow.com/api/payments/alipay/notify
|
||||||
alipay.private-key=MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCH7wPeptkJlJuoKwDqxvfJJLTOAWVkHa/TLh+wiy1tEtmwcrOwEU3GuqfkUlhij71WJIZi8KBytCwbax1QGZA/oLXvTCGJJrYrsEL624X5gGCCPKWwHRDhewsQ5W8jFxaaMXxth8GKlSW61PZD2cOQClRVEm2xnWFZ+6/7WBI7082g7ayzGCD2eowXsJyWyuEBCUSbHXkSgxVhqj5wUGIXhr8ly+pdUlJmDX5K8UG2rjJYx+0AU5UZJbOAND7d3iyDsOulHDvth50t8MOWDnDCVJ2aAgUB5FZKtOFxOmzNTsMjvzYldFztF0khbypeeMVL2cxgioIgTvjBkUwd55hZAgMBAAECggEAUjk3pARUoEDt7skkYt87nsW/QCUECY0Tf7AUpxtovON8Hgkju8qbuyvIxokwwV2k72hkiZB33Soyy9r8/iiYYoR5yGfKmUV7R+30df03ivYmamD48BCE138v8GZ31Ufv+hEY7MADSCpzihGrbNtaOdSlslfVVmyWKHHfvy9EyD6yHJGYswLpHXC/QX1TuLRRxk6Uup8qENOG/6zjGWMfxoRZFwTt80ml1mKy32YZGyJqDaQpJcdYwAHOPcnJl1emw4E+oVjiLyksl643npuTkgnZXs1iWcWSS8ojF1w/0kVDzcNh9toLg+HDuQlIHOis01VQ7lYcG4oiMOnhX1QHIQKBgQC9fgBuILjBhuCI9fHvLRdzoNC9heD54YK7xGvEV/mv90k8xcmNx+Yg5C57ASaMRtOq3b7muPiCv5wOtMT4tUCcMIwSrTNlcBM6EoTagnaGfpzOMaHGMXO4vbaw+MIynHnvXFj1rZjG1lzkV/9K36LAaHD9ZKVJaBQ9mK+0CIq/3QKBgQC3pL5GbvXj6/4ahTraXzNDQQpPGVgbHxcOioEXL4ibaOPC58puTW8HDbRvVuhl/4EEOBRVX81BSgkN8XHwTSiZdih2iOqByg+o9kixs7nlFn3Iw9BBP2/g+Wqiyi2N+9g17kfWXXVOKYz/eMXLBeOo4KhQE9wqNGyZldYzX2ywrQKBgApJmvBfqmgnUG1fHOFlS06lvm9ro0ktqxFSmp8wP4gEHt/DxSuDXMUQXk2jRFp9ReSS4VhZVnSSvoA15DO0c2uHXzNsX8v0B7cxZjEOwCyRFyZCn4vJB4VSF2cIOlLRF/Wcx9+eqxqwbJ6hAGUqOwXDJc879ZVEp0So03EsvYupAoGAAnI+Wp/VxLB7FQ1bSFdmTmoKYh1bUBks7HOp3o4yiqduCUWfK7L6XKSxF56Xv+wUYuMAWlbJXCpJTpc9xk6w0MKDLXkLbqkrZjvJohxbyJJxIICDQKtAqUWJRxvcWXzWV3mSGWfrTRw+lZSdReQRMUm01EQ/dYx3OeCGFu8Zeo0CgYAlH5YSYdJxZSoDCJeoTrkxUlFoOg8UQ7SrsaLYLwpwcwpuiWJaTrg6jwFocj+XhjQ9RtRbSBHz2wKSLdl+pXbTbqECKk85zMFl6zG3etXtTJU/dD750Ty4i8zt3+JGhvglPrQBY1CfItgml2oXa/VUVMnLCUS0WSZuPRmPYZD8dg\=\=
|
alipay.private-key=MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCH7wPeptkJlJuoKwDqxvfJJLTOAWVkHa/TLh+wiy1tEtmwcrOwEU3GuqfkUlhij71WJIZi8KBytCwbax1QGZA/oLXvTCGJJrYrsEL624X5gGCCPKWwHRDhewsQ5W8jFxaaMXxth8GKlSW61PZD2cOQClRVEm2xnWFZ+6/7WBI7082g7ayzGCD2eowXsJyWyuEBCUSbHXkSgxVhqj5wUGIXhr8ly+pdUlJmDX5K8UG2rjJYx+0AU5UZJbOAND7d3iyDsOulHDvth50t8MOWDnDCVJ2aAgUB5FZKtOFxOmzNTsMjvzYldFztF0khbypeeMVL2cxgioIgTvjBkUwd55hZAgMBAAECggEAUjk3pARUoEDt7skkYt87nsW/QCUECY0Tf7AUpxtovON8Hgkju8qbuyvIxokwwV2k72hkiZB33Soyy9r8/iiYYoR5yGfKmUV7R+30df03ivYmamD48BCE138v8GZ31Ufv+hEY7MADSCpzihGrbNtaOdSlslfVVmyWKHHfvy9EyD6yHJGYswLpHXC/QX1TuLRRxk6Uup8qENOG/6zjGWMfxoRZFwTt80ml1mKy32YZGyJqDaQpJcdYwAHOPcnJl1emw4E+oVjiLyksl643npuTkgnZXs1iWcWSS8ojF1w/0kVDzcNh9toLg+HDuQlIHOis01VQ7lYcG4oiMOnhX1QHIQKBgQC9fgBuILjBhuCI9fHvLRdzoNC9heD54YK7xGvEV/mv90k8xcmNx+Yg5C57ASaMRtOq3b7muPiCv5wOtMT4tUCcMIwSrTNlcBM6EoTagnaGfpzOMaHGMXO4vbaw+MIynHnvXFj1rZjG1lzkV/9K36LAaHD9ZKVJaBQ9mK+0CIq/3QKBgQC3pL5GbvXj6/4ahTraXzNDQQpPGVgbHxcOioEXL4ibaOPC58puTW8HDbRvVuhl/4EEOBRVX81BSgkN8XHwTSiZdih2iOqByg+o9kixs7nlFn3Iw9BBP2/g+Wqiyi2N+9g17kfWXXVOKYz/eMXLBeOo4KhQE9wqNGyZldYzX2ywrQKBgApJmvBfqmgnUG1fHOFlS06lvm9ro0ktqxFSmp8wP4gEHt/DxSuDXMUQXk2jRFp9ReSS4VhZVnSSvoA15DO0c2uHXzNsX8v0B7cxZjEOwCyRFyZCn4vJB4VSF2cIOlLRF/Wcx9+eqxqwbJ6hAGUqOwXDJc879ZVEp0So03EsvYupAoGAAnI+Wp/VxLB7FQ1bSFdmTmoKYh1bUBks7HOp3o4yiqduCUWfK7L6XKSxF56Xv+wUYuMAWlbJXCpJTpc9xk6w0MKDLXkLbqkrZjvJohxbyJJxIICDQKtAqUWJRxvcWXzWV3mSGWfrTRw+lZSdReQRMUm01EQ/dYx3OeCGFu8Zeo0CgYAlH5YSYdJxZSoDCJeoTrkxUlFoOg8UQ7SrsaLYLwpwcwpuiWJaTrg6jwFocj+XhjQ9RtRbSBHz2wKSLdl+pXbTbqECKk85zMFl6zG3etXtTJU/dD750Ty4i8zt3+JGhvglPrQBY1CfItgml2oXa/VUVMnLCUS0WSZuPRmPYZD8dg\=\=
|
||||||
@@ -15,7 +16,7 @@ alipay.server-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
|
|||||||
alipay.sign-type=RSA2
|
alipay.sign-type=RSA2
|
||||||
app.ffmpeg.path=C\:/Users/UI/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.0-full_build/bin/ffmpeg.exe
|
app.ffmpeg.path=C\:/Users/UI/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.0-full_build/bin/ffmpeg.exe
|
||||||
app.temp.dir=./temp
|
app.temp.dir=./temp
|
||||||
jwt.expiration=7200000
|
jwt.expiration=86400000
|
||||||
jwt.secret=mySecretKey123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
|
jwt.secret=mySecretKey123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
|
||||||
logging.level.com.example.demo=DEBUG
|
logging.level.com.example.demo=DEBUG
|
||||||
logging.level.org.hibernate.SQL=WARN
|
logging.level.org.hibernate.SQL=WARN
|
||||||
@@ -61,5 +62,3 @@ tencent.ses.region=ap-hongkong
|
|||||||
tencent.ses.secret-id=AKIDoaEjFbqxxqZAcv8EE6oZCg2IQPG1fCxm
|
tencent.ses.secret-id=AKIDoaEjFbqxxqZAcv8EE6oZCg2IQPG1fCxm
|
||||||
tencent.ses.secret-key=nR83I79FOSpGcqNo7JXkqnU8g7SjsxuG
|
tencent.ses.secret-key=nR83I79FOSpGcqNo7JXkqnU8g7SjsxuG
|
||||||
tencent.ses.template-id=154360
|
tencent.ses.template-id=154360
|
||||||
|
|
||||||
alipay.domain=https\://vionow.com
|
|
||||||
|
|||||||
@@ -125,7 +125,29 @@ springdoc.swagger-ui.filter=true
|
|||||||
springdoc.swagger-ui.display-request-duration=true
|
springdoc.swagger-ui.display-request-duration=true
|
||||||
springdoc.swagger-ui.doc-expansion=none
|
springdoc.swagger-ui.doc-expansion=none
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 腾讯云 Redis 配置(生产环境)
|
||||||
|
# ============================================
|
||||||
|
# 腾讯云 Redis 内网地址(在云数据库 Redis 控制台查看)
|
||||||
|
spring.data.redis.host=crs-xxxxxxxx.sql.tencentcdb.com
|
||||||
|
spring.data.redis.port=6379
|
||||||
|
# Redis 密码(格式可能是:账号:密码 或 仅密码,取决于是否开启免密)
|
||||||
|
spring.data.redis.password=你的Redis密码
|
||||||
|
|
||||||
|
spring.data.redis.database=0
|
||||||
|
|
||||||
|
# 连接池配置
|
||||||
|
spring.data.redis.lettuce.pool.max-active=16
|
||||||
|
spring.data.redis.lettuce.pool.max-idle=8
|
||||||
|
spring.data.redis.lettuce.pool.min-idle=2
|
||||||
|
spring.data.redis.lettuce.pool.max-wait=3000ms
|
||||||
|
|
||||||
|
# 连接超时
|
||||||
|
spring.data.redis.timeout=5000ms
|
||||||
|
spring.data.redis.connect-timeout=5000ms
|
||||||
|
|
||||||
|
# Token过期时间(秒)
|
||||||
|
redis.token.expire-seconds=86400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,25 @@ app.video.output.path=outputs
|
|||||||
jwt.secret=aigc-demo-secret-key-for-jwt-token-generation-2025
|
jwt.secret=aigc-demo-secret-key-for-jwt-token-generation-2025
|
||||||
jwt.expiration=86400000
|
jwt.expiration=86400000
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Redis配置
|
||||||
|
# ============================================
|
||||||
|
# 是否启用Redis缓存(设置为false则禁用Redis,Token验证仅依赖JWT)
|
||||||
|
redis.enabled=false
|
||||||
|
spring.data.redis.host=localhost
|
||||||
|
spring.data.redis.port=6379
|
||||||
|
spring.data.redis.password=
|
||||||
|
spring.data.redis.database=0
|
||||||
|
# 连接池配置
|
||||||
|
spring.data.redis.lettuce.pool.max-active=8
|
||||||
|
spring.data.redis.lettuce.pool.max-idle=8
|
||||||
|
spring.data.redis.lettuce.pool.min-idle=0
|
||||||
|
spring.data.redis.lettuce.pool.max-wait=-1ms
|
||||||
|
# Token在Redis中的过期时间(秒),与JWT过期时间一致
|
||||||
|
redis.token.expire-seconds=86400
|
||||||
|
# 禁用Redis自动配置(当redis.enabled=false时生效)
|
||||||
|
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration
|
||||||
|
|
||||||
# AI API配置
|
# AI API配置
|
||||||
ai.api.base-url=http://116.62.4.26:8081
|
ai.api.base-url=http://116.62.4.26:8081
|
||||||
ai.api.key=ak_5f13ec469e6047d5b8155c3cc91350e2
|
ai.api.key=ak_5f13ec469e6047d5b8155c3cc91350e2
|
||||||
|
|||||||
Reference in New Issue
Block a user