feat: 添加用户错误日志功能, 禁用Redis缓存, userId自动生成5位随机字符
This commit is contained in:
@@ -2,9 +2,11 @@ package com.example.demo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
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;
|
||||
|
||||
@SpringBootApplication
|
||||
@SpringBootApplication(exclude = {RedisAutoConfiguration.class, RedisRepositoriesAutoConfiguration.class})
|
||||
@EnableScheduling
|
||||
public class DemoApplication {
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import com.example.demo.security.JwtAuthenticationFilter;
|
||||
import com.example.demo.security.PlainTextPasswordEncoder;
|
||||
import com.example.demo.service.RedisTokenService;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
|
||||
@@ -34,7 +35,8 @@ public class SecurityConfig {
|
||||
}
|
||||
|
||||
@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
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
@@ -81,6 +83,7 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/payments/**").authenticated() // 其他支付接口需要认证
|
||||
.requestMatchers("/api/image-to-video/**").authenticated() // 图生视频接口需要认证
|
||||
.requestMatchers("/api/text-to-video/**").authenticated() // 文生视频接口需要认证
|
||||
.requestMatchers("/api/storyboard-video/**").authenticated() // 分镜视频接口需要认证
|
||||
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 仪表盘API需要管理员权限
|
||||
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 管理员API需要管理员权限
|
||||
.requestMatchers("/settings", "/settings/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
|
||||
@@ -98,7 +101,7 @@ public class SecurityConfig {
|
||||
.logout(Customizer.withDefaults());
|
||||
|
||||
// 添加JWT过滤器
|
||||
http.addFilterBefore(jwtAuthenticationFilter(jwtUtils, userService), UsernamePasswordAuthenticationFilter.class);
|
||||
http.addFilterBefore(jwtAuthenticationFilter(jwtUtils, userService, redisTokenService), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
// H2 控制台需要以下设置
|
||||
http.headers(headers -> headers.frameOptions(frame -> frame.disable()));
|
||||
@@ -181,8 +184,9 @@ public class SecurityConfig {
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtils jwtUtils, UserService userService) {
|
||||
return new JwtAuthenticationFilter(jwtUtils, userService);
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtils jwtUtils, UserService 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("promptOptimizationApiUrl", settings.getPromptOptimizationApiUrl());
|
||||
response.put("promptOptimizationSystemPrompt", settings.getPromptOptimizationSystemPrompt());
|
||||
response.put("storyboardSystemPrompt", settings.getStoryboardSystemPrompt());
|
||||
response.put("siteName", settings.getSiteName());
|
||||
response.put("siteSubtitle", settings.getSiteSubtitle());
|
||||
response.put("registrationOpen", settings.getRegistrationOpen());
|
||||
response.put("maintenanceMode", settings.getMaintenanceMode());
|
||||
response.put("contactEmail", settings.getContactEmail());
|
||||
response.put("tokenExpireHours", settings.getTokenExpireHours());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
@@ -454,11 +454,25 @@ public class AdminController {
|
||||
logger.info("更新分镜图系统引导词");
|
||||
}
|
||||
|
||||
// 更新优化提示词系统提示词
|
||||
if (settingsData.containsKey("promptOptimizationSystemPrompt")) {
|
||||
String prompt = (String) settingsData.get("promptOptimizationSystemPrompt");
|
||||
settings.setPromptOptimizationSystemPrompt(prompt);
|
||||
logger.info("更新优化提示词系统提示词");
|
||||
// 更新Token过期时间(小时)
|
||||
if (settingsData.containsKey("tokenExpireHours")) {
|
||||
Object value = settingsData.get("tokenExpireHours");
|
||||
Integer hours = null;
|
||||
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);
|
||||
@@ -508,8 +522,8 @@ public class AdminController {
|
||||
return ResponseEntity.status(403).body(response);
|
||||
}
|
||||
|
||||
// 查找并删除任务
|
||||
var taskOpt = taskStatusRepository.findByTaskId(taskId);
|
||||
// 查找并删除任务(使用 findFirst 处理可能的重复记录)
|
||||
var taskOpt = taskStatusRepository.findFirstByTaskIdOrderByIdDesc(taskId);
|
||||
if (taskOpt.isPresent()) {
|
||||
taskStatusRepository.delete(taskOpt.get());
|
||||
logger.info("管理员 {} 删除了任务: {}", username, taskId);
|
||||
@@ -566,7 +580,7 @@ public class AdminController {
|
||||
// 批量删除任务
|
||||
int deletedCount = 0;
|
||||
for (String taskId : taskIds) {
|
||||
var taskOpt = taskStatusRepository.findByTaskId(taskId);
|
||||
var taskOpt = taskStatusRepository.findFirstByTaskIdOrderByIdDesc(taskId);
|
||||
if (taskOpt.isPresent()) {
|
||||
taskStatusRepository.delete(taskOpt.get());
|
||||
deletedCount++;
|
||||
|
||||
@@ -16,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
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.VerificationCodeService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
@@ -39,6 +42,12 @@ public class AuthApiController {
|
||||
@Autowired
|
||||
private VerificationCodeService verificationCodeService;
|
||||
|
||||
@Autowired
|
||||
private RedisTokenService redisTokenService;
|
||||
|
||||
@Autowired
|
||||
private SystemSettingsService systemSettingsService;
|
||||
|
||||
/**
|
||||
* 用户登录(已禁用,仅支持邮箱验证码登录)
|
||||
* 为了向后兼容,保留此接口但返回提示信息
|
||||
@@ -76,8 +85,17 @@ public class AuthApiController {
|
||||
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)
|
||||
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<>();
|
||||
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
|
||||
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();
|
||||
@@ -309,12 +336,71 @@ public class AuthApiController {
|
||||
* 用户登出
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<Map<String, Object>> logout() {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "登出成功");
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
public ResponseEntity<Map<String, Object>> logout(Authentication authentication, HttpServletRequest request) {
|
||||
try {
|
||||
String username = null;
|
||||
String token = null;
|
||||
|
||||
// 从请求头获取 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,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
// 检查用户是否已认证
|
||||
if (authentication == null) {
|
||||
logger.warn("创建分镜视频任务失败: 用户未登录");
|
||||
return ResponseEntity.status(401)
|
||||
.body(Map.of("success", false, "message", "用户未登录,请先登录"));
|
||||
}
|
||||
String username = authentication.getName();
|
||||
logger.info("收到创建分镜视频任务请求,用户: {}", username);
|
||||
|
||||
@@ -47,6 +53,10 @@ public class StoryboardVideoApiController {
|
||||
String imageUrl = (String) request.get("imageUrl");
|
||||
String imageModel = (String) request.getOrDefault("imageModel", "nano-banana");
|
||||
|
||||
// 提取用户上传的多张图片(新增)
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> uploadedImages = (List<String>) request.get("uploadedImages");
|
||||
|
||||
// 提取duration参数,支持多种类型
|
||||
Integer duration = 10; // 默认10秒
|
||||
Object durationObj = request.get("duration");
|
||||
@@ -59,16 +69,17 @@ public class StoryboardVideoApiController {
|
||||
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()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "提示词不能为空"));
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
// 创建任务(传递上传的图片列表)
|
||||
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<>();
|
||||
@@ -118,7 +129,8 @@ public class StoryboardVideoApiController {
|
||||
taskData.put("status", task.getStatus());
|
||||
taskData.put("progress", task.getProgress());
|
||||
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("aspectRatio", task.getAspectRatio());
|
||||
taskData.put("hdMode", task.isHdMode());
|
||||
@@ -127,6 +139,10 @@ public class StoryboardVideoApiController {
|
||||
taskData.put("createdAt", task.getCreatedAt());
|
||||
taskData.put("updatedAt", task.getUpdatedAt());
|
||||
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(
|
||||
"success", true,
|
||||
@@ -195,6 +211,7 @@ public class StoryboardVideoApiController {
|
||||
Integer duration = null;
|
||||
String aspectRatio = null;
|
||||
Boolean hdMode = null;
|
||||
java.util.List<String> referenceImages = null;
|
||||
|
||||
if (requestBody != null) {
|
||||
if (requestBody.containsKey("duration")) {
|
||||
@@ -214,10 +231,17 @@ public class StoryboardVideoApiController {
|
||||
hdMode = (Boolean) requestBody.get("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(
|
||||
"success", true,
|
||||
@@ -234,4 +258,56 @@ public class StoryboardVideoApiController {
|
||||
.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);
|
||||
|
||||
// 调试日志:检查返回的作品数据
|
||||
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("data", works.getContent());
|
||||
response.put("totalElements", works.getTotalElements());
|
||||
|
||||
@@ -46,6 +46,9 @@ public class StoryboardVideoTask {
|
||||
@Column(name = "image_model", length = 50)
|
||||
private String imageModel = "nano-banana"; // 图像生成模型:nano-banana, nano-banana2
|
||||
|
||||
@Column(name = "uploaded_images", columnDefinition = "LONGTEXT")
|
||||
private String uploadedImages; // 用户上传的多张参考图片(JSON数组,最多3张Base64格式)
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private TaskStatus status;
|
||||
@@ -71,6 +74,16 @@ public class StoryboardVideoTask {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
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)
|
||||
private int costPoints; // 消耗积分
|
||||
|
||||
@@ -173,6 +186,8 @@ public class StoryboardVideoTask {
|
||||
public void setDuration(Integer duration) { this.duration = duration; }
|
||||
public String getImageModel() { return 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 void setStatus(TaskStatus status) { this.status = status; }
|
||||
public int getProgress() { return progress; }
|
||||
@@ -199,4 +214,10 @@ public class StoryboardVideoTask {
|
||||
public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; }
|
||||
public SourceType getSourceType() { return 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)
|
||||
private String storyboardSystemPrompt = "";
|
||||
|
||||
/** 优化提示词功能的系统提示词(指导AI如何优化) */
|
||||
@Column(length = 4000)
|
||||
private String promptOptimizationSystemPrompt = "";
|
||||
/** Token过期时间(小时),范围1-720小时,默认24小时 */
|
||||
@NotNull
|
||||
@Min(1)
|
||||
@Column(nullable = false)
|
||||
private Integer tokenExpireHours = 24;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
@@ -194,12 +196,15 @@ public class SystemSettings {
|
||||
this.storyboardSystemPrompt = storyboardSystemPrompt;
|
||||
}
|
||||
|
||||
public String getPromptOptimizationSystemPrompt() {
|
||||
return promptOptimizationSystemPrompt;
|
||||
public Integer getTokenExpireHours() {
|
||||
return tokenExpireHours;
|
||||
}
|
||||
|
||||
public void setPromptOptimizationSystemPrompt(String promptOptimizationSystemPrompt) {
|
||||
this.promptOptimizationSystemPrompt = promptOptimizationSystemPrompt;
|
||||
public void setTokenExpireHours(Integer tokenExpireHours) {
|
||||
// 限制范围在1-720小时(1小时到30天)
|
||||
if (tokenExpireHours != null && tokenExpireHours >= 1 && tokenExpireHours <= 720) {
|
||||
this.tokenExpireHours = tokenExpireHours;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ public class User {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
// 自动生成业务用户ID(如果未设置)
|
||||
if (userId == null || userId.isEmpty()) {
|
||||
userId = com.example.demo.util.UserIdGenerator.generate();
|
||||
}
|
||||
if (createdAt == null) {
|
||||
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")
|
||||
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")
|
||||
private String resultUrl; // 结果视频URL
|
||||
|
||||
@@ -252,6 +258,22 @@ public class UserWork {
|
||||
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() {
|
||||
return resultUrl;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ public interface TaskStatusRepository extends JpaRepository<TaskStatus, Long> {
|
||||
*/
|
||||
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> {
|
||||
|
||||
/**
|
||||
* 根据用户名查找作品
|
||||
* 根据用户名查找作品(必须有可显示的内容)
|
||||
* - 分镜视频(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);
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.service.RedisTokenService;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
|
||||
@@ -31,10 +32,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtUtils jwtUtils;
|
||||
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.userService = userService;
|
||||
this.redisTokenService = redisTokenService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -82,6 +85,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String username = jwtUtils.getUsernameFromToken(token);
|
||||
|
||||
if (username != null && jwtUtils.validateToken(token, username)) {
|
||||
// Redis 验证已降级:isTokenValid 总是返回 true
|
||||
// 主要依赖 JWT 本身的有效性验证
|
||||
User user = userService.findByUsername(username);
|
||||
|
||||
if (user != null) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
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) {
|
||||
double scaleX = (double) width / img.getWidth();
|
||||
@@ -347,6 +348,32 @@ public class ImageGridService {
|
||||
int imgX = x + (width - scaledWidth) / 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);
|
||||
}
|
||||
|
||||
@@ -371,5 +398,204 @@ public class ImageGridService {
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@ public class ImageToVideoService {
|
||||
|
||||
@Autowired
|
||||
private CosService cosService;
|
||||
|
||||
@Autowired
|
||||
private TaskStatusPollingService taskStatusPollingService;
|
||||
|
||||
@Value("${app.upload.path:/uploads}")
|
||||
private String uploadPath;
|
||||
@@ -243,13 +246,25 @@ public class ImageToVideoService {
|
||||
taskRepository.save(currentTask);
|
||||
logger.info("真实任务ID已保存: {} -> {}", task.getTaskId(), realTaskId);
|
||||
} else {
|
||||
// 如果没有找到任务ID,说明任务提交失败
|
||||
// 如果没有找到任务ID,说明任务提交失败(尝试通过 task_status 触发级联)
|
||||
logger.error("任务提交失败:未从API响应中获取到任务ID");
|
||||
currentTask = taskRepository.findByTaskId(task.getTaskId())
|
||||
.orElseThrow(() -> new RuntimeException("任务不存在: " + task.getTaskId()));
|
||||
currentTask.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
currentTask.setErrorMessage("任务提交失败:API未返回有效的任务ID");
|
||||
taskRepository.save(currentTask);
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务提交失败:API未返回有效的任务ID");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
currentTask = taskRepository.findByTaskId(task.getTaskId())
|
||||
.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());
|
||||
return CompletableFuture.completedFuture(null); // 直接返回,不进行轮询
|
||||
@@ -266,10 +281,25 @@ public class ImageToVideoService {
|
||||
}
|
||||
|
||||
try {
|
||||
// 更新状态为失败
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(e.getMessage());
|
||||
taskRepository.save(task);
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), e.getMessage());
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
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());
|
||||
} catch (Exception saveException) {
|
||||
@@ -334,28 +364,44 @@ public class ImageToVideoService {
|
||||
String resultUrl = (String) taskData.get("resultUrl");
|
||||
String errorMessage = (String) taskData.get("errorMessage");
|
||||
|
||||
// 更新任务状态
|
||||
// 更新任务状态(通过 task_status 表触发级联)
|
||||
if ("completed".equals(status) || "success".equals(status)) {
|
||||
task.setResultUrl(resultUrl);
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
||||
task.updateProgress(100);
|
||||
taskRepository.save(task);
|
||||
|
||||
// 同步更新 UserWork 表的状态和结果URL
|
||||
try {
|
||||
userWorkService.updateWorkOnComplete(task.getTaskId(), resultUrl,
|
||||
com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
||||
logger.info("图生视频任务完成,UserWork已更新: {}", task.getTaskId());
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新UserWork状态失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskCompleted(task.getTaskId(), resultUrl);
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
task.setResultUrl(resultUrl);
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
||||
task.updateProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
taskRepository.save(task);
|
||||
// 更新 UserWork
|
||||
try {
|
||||
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());
|
||||
return;
|
||||
} else if ("failed".equals(status) || "error".equals(status)) {
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
taskRepository.save(task);
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), errorMessage);
|
||||
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());
|
||||
logger.error("图生视频任务失败: {}", task.getTaskId());
|
||||
@@ -383,10 +429,22 @@ public class ImageToVideoService {
|
||||
Thread.sleep(2000); // 每2秒轮询一次
|
||||
}
|
||||
|
||||
// 超时处理
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("任务处理超时");
|
||||
taskRepository.save(task);
|
||||
// 超时处理(尝试通过 task_status 触发级联)
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务处理超时");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
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());
|
||||
logger.error("图生视频任务超时: {}", task.getTaskId());
|
||||
@@ -582,22 +640,27 @@ public class ImageToVideoService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新任务状态为失败
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("图生视频任务超时:任务创建后超过1小时仍未完成");
|
||||
taskRepository.save(task);
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "图生视频任务超时:任务创建后超过1小时仍未完成");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
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());
|
||||
|
||||
// 同步更新 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());
|
||||
|
||||
handledCount++;
|
||||
|
||||
@@ -1011,8 +1011,33 @@ public class RealAIService {
|
||||
// 模型选择
|
||||
String model = "nano-banana";
|
||||
|
||||
// 将 Base64 转换为二进制数据
|
||||
// 处理图片数据:如果是 URL,先下载转换为 base64
|
||||
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, 前缀
|
||||
if (base64Data.contains(",")) {
|
||||
base64Data = base64Data.substring(base64Data.indexOf(",") + 1);
|
||||
@@ -1144,24 +1169,9 @@ public class RealAIService {
|
||||
// 从系统设置获取优化提示词的API端点和模型
|
||||
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
|
||||
// 获取系统提示词:
|
||||
// - 分镜(storyboard):优先使用后台设置的自定义系统提示词,否则使用默认
|
||||
// - 文生视频和图生视频:始终使用默认指令
|
||||
String systemPrompt;
|
||||
if ("storyboard".equals(type)) {
|
||||
// 分镜优化:可以使用自定义系统提示词
|
||||
systemPrompt = settings.getPromptOptimizationSystemPrompt();
|
||||
if (systemPrompt == null || systemPrompt.trim().isEmpty()) {
|
||||
systemPrompt = getOptimizationPrompt(type);
|
||||
logger.info("分镜优化:使用默认系统提示词");
|
||||
} else {
|
||||
logger.info("分镜优化:使用自定义系统提示词");
|
||||
}
|
||||
} else {
|
||||
// 文生视频和图生视频:始终使用默认指令
|
||||
systemPrompt = getOptimizationPrompt(type);
|
||||
logger.info("{}优化:使用默认系统提示词", type);
|
||||
}
|
||||
// 使用默认系统提示词
|
||||
String systemPrompt = getOptimizationPrompt(type);
|
||||
logger.info("{}优化:使用默认系统提示词", type);
|
||||
|
||||
// 如果是分镜图类型,将系统引导词拼接到用户提示词前面一起优化
|
||||
String promptToOptimize = prompt;
|
||||
@@ -1202,16 +1212,18 @@ public class RealAIService {
|
||||
|
||||
requestBody.put("messages", messages);
|
||||
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);
|
||||
|
||||
// 设置超时时间(30秒)
|
||||
// 设置超时时间(60秒)
|
||||
HttpResponse<String> response = Unirest.post(url)
|
||||
.header("Authorization", "Bearer " + fallbackApiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.socketTimeout(30000)
|
||||
.connectTimeout(10000)
|
||||
.socketTimeout(60000)
|
||||
.connectTimeout(15000)
|
||||
.body(requestBodyJson)
|
||||
.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项目的优化策略,使用更智能的提示词优化
|
||||
@@ -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
|
||||
private SystemSettingsService systemSettingsService;
|
||||
|
||||
@Autowired
|
||||
private java.util.concurrent.Executor taskExecutor;
|
||||
|
||||
// 默认生成1张分镜图
|
||||
private static final int DEFAULT_STORYBOARD_IMAGES = 1;
|
||||
|
||||
@@ -93,7 +96,7 @@ public class StoryboardVideoService {
|
||||
* 事务提交后,异步方法在事务外执行
|
||||
*/
|
||||
@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()) {
|
||||
throw new IllegalArgumentException("用户名不能为空");
|
||||
@@ -119,6 +122,17 @@ public class StoryboardVideoService {
|
||||
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
|
||||
if (imageUrl != null && !imageUrl.isEmpty()) {
|
||||
String finalImageUrl = imageUrl;
|
||||
@@ -178,34 +192,154 @@ public class StoryboardVideoService {
|
||||
// 在异步方法中使用 TransactionTemplate 手动管理事务,确保事务正确关闭
|
||||
StoryboardVideoTask taskInfo = loadTaskInfoWithTransactionTemplate(taskId);
|
||||
String prompt = taskInfo.getPrompt();
|
||||
String aspectRatio = taskInfo.getAspectRatio();
|
||||
String targetAspectRatio = taskInfo.getAspectRatio(); // 用户选择的目标比例
|
||||
boolean hdMode = taskInfo.isHdMode();
|
||||
String imageUrl = taskInfo.getImageUrl(); // 获取参考图片
|
||||
String imageUrl = taskInfo.getImageUrl(); // 获取参考图片(兼容旧逻辑)
|
||||
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();
|
||||
|
||||
// 从系统设置读取分镜图系统引导词,并拼接到用户提示词前面
|
||||
String finalPrompt = prompt;
|
||||
// 根据是否有上传图片,决定生成分镜图使用的比例
|
||||
// 有上传图片:使用4:3;无上传图片:使用目标比例
|
||||
String generationAspectRatio = imageGridService.getGenerationAspectRatio(targetAspectRatio, hasUserUploads);
|
||||
|
||||
// 第一步:调用提示词优化 API,获取 shotList、imagePrompt、videoPrompt
|
||||
// 系统引导词配置为返回 JSON 格式数据
|
||||
String finalPrompt = prompt; // 用于生成分镜图的提示词
|
||||
String shotListResult = "";
|
||||
String videoPromptResult = "";
|
||||
|
||||
try {
|
||||
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
String storyboardSystemPrompt = settings.getStoryboardSystemPrompt();
|
||||
|
||||
if (storyboardSystemPrompt != null && !storyboardSystemPrompt.trim().isEmpty()) {
|
||||
finalPrompt = storyboardSystemPrompt.trim() + ", " + prompt;
|
||||
logger.info("已添加系统引导词,最终提示词长度: {}", finalPrompt.length());
|
||||
// 组合提示词并调用优化 API
|
||||
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) {
|
||||
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 确保事务正确关闭)
|
||||
updateTaskStatusWithTransactionTemplate(taskId);
|
||||
|
||||
// 调用AI生图API,生成分镜图
|
||||
logger.info("开始生成分镜图({}模式)...", hasReferenceImage ? "图生图" : "文生图");
|
||||
logger.info("开始生成分镜图({}模式)...", hasReferenceImage || hasUserUploads ? "图生图" : "文生图");
|
||||
|
||||
// 收集所有图片URL
|
||||
List<String> imageUrls = new ArrayList<>();
|
||||
@@ -228,21 +362,22 @@ public class StoryboardVideoService {
|
||||
}
|
||||
|
||||
// 根据是否有参考图片,调用不同的API
|
||||
// 使用拼接后的提示词 finalPrompt
|
||||
// 使用系统引导词 + 用户提示词 (finalPrompt)
|
||||
Map<String, Object> apiResponse;
|
||||
if (hasReferenceImage) {
|
||||
if (hasReferenceImage || hasUserUploads) {
|
||||
// 有参考图片,使用图生图API(bananaAPI)
|
||||
String refImage = hasUserUploads ? userUploadedImages.get(0) : imageUrl;
|
||||
apiResponse = realAIService.submitImageToImageTask(
|
||||
finalPrompt,
|
||||
imageUrl,
|
||||
aspectRatio,
|
||||
refImage,
|
||||
generationAspectRatio,
|
||||
hdMode
|
||||
);
|
||||
} else {
|
||||
// 无参考图片,使用文生图API
|
||||
apiResponse = realAIService.submitTextToImageTask(
|
||||
finalPrompt,
|
||||
aspectRatio,
|
||||
generationAspectRatio,
|
||||
1, // 每次生成1张图片
|
||||
hdMode,
|
||||
imageModel // 使用用户选择的图像生成模型
|
||||
@@ -353,35 +488,18 @@ public class StoryboardVideoService {
|
||||
throw new RuntimeException(errorMsg);
|
||||
}
|
||||
|
||||
// 处理分镜图
|
||||
String mergedImageUrl;
|
||||
long mergeStartTime = System.currentTimeMillis();
|
||||
// 生成分镜图阶段:不做拼图处理,直接返回原始 AI 生成的分镜图给用户预览
|
||||
// 拼图处理(分镜图 + 用户上传图片)在生成视频时进行(TaskQueueService)
|
||||
String generatedImage = validatedImages.get(0);
|
||||
logger.info("分镜图生成完成,直接返回原始图片供用户预览(拼图在生成视频时进行)");
|
||||
|
||||
// 如果有参考图片,将原图和生成图拼接在一起
|
||||
if (hasReferenceImage && validatedImages.size() >= 1) {
|
||||
String generatedImage = validatedImages.get(0);
|
||||
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);
|
||||
// 检查生成的图片是否有效
|
||||
if (generatedImage == null || generatedImage.isEmpty()) {
|
||||
throw new RuntimeException("分镜图生成失败: 返回的图片为空");
|
||||
}
|
||||
long mergeTime = System.currentTimeMillis() - mergeStartTime;
|
||||
logger.info("分镜图处理完成,耗时: {}ms", mergeTime);
|
||||
|
||||
// 检查拼接后的图片URL是否有效
|
||||
if (mergedImageUrl == null || mergedImageUrl.isEmpty()) {
|
||||
throw new RuntimeException("图片拼接失败: 返回的图片URL为空");
|
||||
}
|
||||
// 直接使用原始分镜图作为预览图
|
||||
String mergedImageUrl = generatedImage;
|
||||
|
||||
// 保存单独的分镜图片(Base64数组,参考sora2实现)
|
||||
// validatedImages 已在上面定义并验证
|
||||
@@ -401,8 +519,8 @@ public class StoryboardVideoService {
|
||||
long saveTime = System.currentTimeMillis() - saveStartTime;
|
||||
|
||||
long totalElapsed = System.currentTimeMillis() - startTime;
|
||||
logger.info("✓ 分镜图生成完成: taskId={}, 共{}张图片,已拼接完成,总耗时: {}ms (生成: {}ms, 拼接: {}ms, 保存: {}ms)",
|
||||
taskId, validatedImages.size(), totalElapsed, totalTime, mergeTime, saveTime);
|
||||
logger.info("✓ 分镜图生成完成: taskId={}, 共{}张图片,总耗时: {}ms (生成: {}ms, 保存: {}ms)",
|
||||
taskId, validatedImages.size(), totalElapsed, totalTime, saveTime);
|
||||
|
||||
// 不再自动生成视频,等待用户点击"开始生成"按钮
|
||||
|
||||
@@ -452,29 +570,9 @@ public class StoryboardVideoService {
|
||||
// 不抛出异常,避免影响主流程
|
||||
}
|
||||
|
||||
// 创建初始的 UserWork 记录(PROCESSING状态),以便前端可以恢复任务
|
||||
try {
|
||||
// 检查是否已存在 UserWork
|
||||
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);
|
||||
// 不抛出异常,避免影响主流程
|
||||
}
|
||||
// 注意:不在此处创建 STORYBOARD_VIDEO 的 UserWork 记录
|
||||
// 只有在视频真正生成完成后才创建,避免显示没有视频的空作品
|
||||
// 分镜图作品在 saveStoryboardImageResultWithTransactionTemplate 中创建
|
||||
} catch (Exception e) {
|
||||
logger.error("更新任务状态失败: {}", taskId, e);
|
||||
status.setRollbackOnly();
|
||||
@@ -517,19 +615,25 @@ public class StoryboardVideoService {
|
||||
task.setStoryboardImages(storyboardImagesJson); // 单独的分镜图片(用于视频生成,保持Base64格式)
|
||||
}
|
||||
task.updateProgress(50); // 分镜图生成完成,进度50%
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.COMPLETED); // 分镜图完成
|
||||
taskRepository.save(task);
|
||||
logger.info("分镜图生成完成,任务状态已更新为 COMPLETED: taskId={}", taskId);
|
||||
|
||||
// 更新 TaskStatus 为完成状态
|
||||
// 更新 TaskStatus 为 COMPLETED(分镜图生成完成,停止轮询)
|
||||
// 用户点击"生成视频"时,会重新将状态改为 PROCESSING 并添加 externalTaskId
|
||||
try {
|
||||
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
|
||||
if (taskStatus != null) {
|
||||
taskStatus.markAsCompleted(imageUrlForDb);
|
||||
taskStatus.setResultUrl(imageUrlForDb);
|
||||
taskStatus.setProgress(50); // 分镜图完成,进度50%
|
||||
taskStatus.setStatus(TaskStatus.Status.COMPLETED); // 标记为完成,停止轮询
|
||||
taskStatus.setCompletedAt(LocalDateTime.now());
|
||||
taskStatus.setUpdatedAt(LocalDateTime.now());
|
||||
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
|
||||
logger.info("TaskStatus 已更新为完成: taskId={}", taskId);
|
||||
logger.info("TaskStatus 已标记为 COMPLETED(分镜图完成): taskId={}", taskId);
|
||||
}
|
||||
} 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) {
|
||||
try {
|
||||
asyncTransactionTemplate.executeWithoutResult(status -> {
|
||||
try {
|
||||
StoryboardVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("任务未找到: " + taskId));
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
taskRepository.save(task);
|
||||
|
||||
// 更新 TaskStatus 为失败状态
|
||||
// 通过更新 task_status 触发级联更新(自动同步到业务表和 user_works)
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(taskId, errorMessage);
|
||||
if (updated) {
|
||||
logger.info("任务状态已更新为失败(级联更新已触发): taskId={}", taskId);
|
||||
} else {
|
||||
// 如果 task_status 记录不存在,回退到直接更新业务表
|
||||
logger.warn("task_status 记录不存在,直接更新业务表: taskId={}", taskId);
|
||||
asyncTransactionTemplate.executeWithoutResult(status -> {
|
||||
try {
|
||||
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
|
||||
if (taskStatus != null) {
|
||||
taskStatus.markAsFailed(errorMessage);
|
||||
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
|
||||
logger.info("TaskStatus 已更新为失败: 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 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) {
|
||||
logger.error("执行更新失败状态事务失败: {}", taskId, e);
|
||||
}
|
||||
@@ -649,16 +746,30 @@ public class StoryboardVideoService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务状态为失败(单独的事务方法)
|
||||
* 更新任务状态为失败(通过 task_status 触发级联更新)
|
||||
*/
|
||||
@Transactional
|
||||
public void updateTaskStatusToFailed(String taskId, String errorMessage) {
|
||||
try {
|
||||
StoryboardVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("任务未找到: " + taskId));
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
taskRepository.save(task);
|
||||
// 通过更新 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(taskId, errorMessage);
|
||||
if (!updated) {
|
||||
// 如果 task_status 记录不存在,回退到直接更新业务表
|
||||
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) {
|
||||
logger.error("更新任务失败状态失败: {}", taskId, ex);
|
||||
}
|
||||
@@ -689,9 +800,10 @@ public class StoryboardVideoService {
|
||||
* @param duration 视频时长(可选,如果为null则使用任务中已有的值)
|
||||
* @param aspectRatio 宽高比(可选,如果为null则使用任务中已有的值)
|
||||
* @param hdMode 高清模式(可选,如果为null则使用任务中已有的值)
|
||||
* @param referenceImages 参考图列表(可选)
|
||||
*/
|
||||
@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 {
|
||||
|
||||
// 重新加载任务
|
||||
@@ -729,6 +841,19 @@ public class StoryboardVideoService {
|
||||
paramsUpdated = true;
|
||||
}
|
||||
|
||||
// 更新参考图(如果提供了新的参考图)
|
||||
if (referenceImages != null && !referenceImages.isEmpty()) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
String uploadedImagesJson = mapper.writeValueAsString(referenceImages);
|
||||
task.setUploadedImages(uploadedImagesJson);
|
||||
logger.info("更新任务 {} 的参考图: {} 张", taskId, referenceImages.size());
|
||||
paramsUpdated = true;
|
||||
} catch (Exception e) {
|
||||
logger.warn("序列化参考图失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是 COMPLETED 或 FAILED 状态,更新为 PROCESSING(表示正在生成视频)
|
||||
if (task.getStatus() == StoryboardVideoTask.TaskStatus.COMPLETED ||
|
||||
task.getStatus() == StoryboardVideoTask.TaskStatus.FAILED) {
|
||||
@@ -739,25 +864,56 @@ public class StoryboardVideoService {
|
||||
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) {
|
||||
taskRepository.save(task);
|
||||
logger.info("任务参数已更新并保存: {}", taskId);
|
||||
}
|
||||
|
||||
// 检查是否已经添加过视频生成任务(避免重复添加)
|
||||
// 这里可以通过检查任务队列来判断,但为了简单,我们直接添加
|
||||
// 如果已经存在,TaskQueueService 会处理重复的情况
|
||||
|
||||
// 将视频生成任务添加到任务队列,由队列异步处理
|
||||
// 将视频生成任务添加到任务队列
|
||||
try {
|
||||
taskQueueService.addStoryboardVideoTask(task.getUsername(), taskId);
|
||||
// 任务状态保持 PROCESSING,等待视频生成完成
|
||||
} catch (Exception e) {
|
||||
logger.error("添加分镜视频任务到队列失败: {}", taskId, e);
|
||||
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) {
|
||||
logger.error("开始生成视频失败: {}", taskId, e);
|
||||
throw new RuntimeException("开始生成视频失败: " + e.getMessage());
|
||||
@@ -1071,23 +1227,44 @@ public class StoryboardVideoService {
|
||||
|
||||
for (StoryboardVideoTask task : storyboardTimeoutTasks) {
|
||||
try {
|
||||
// 如果有resultUrl,说明分镜图已生成,跳过此阶段的超时检查
|
||||
// 如果有resultUrl,说明分镜图已生成
|
||||
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++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 分镜图生成超时,标记为失败
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("分镜图生成超时:任务创建后超过10分钟仍未完成");
|
||||
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());
|
||||
// 分镜图生成超时,尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "分镜图生成超时:任务创建后超过10分钟仍未完成");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("分镜图生成超时:任务创建后超过10分钟仍未完成");
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
// 返还冻结积分
|
||||
@@ -1132,17 +1309,28 @@ public class StoryboardVideoService {
|
||||
continue; // 已完成,不处理
|
||||
}
|
||||
|
||||
// 视频生成超时,标记为失败
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("视频生成超时:任务创建后超过1小时仍未完成");
|
||||
taskRepository.save(task);
|
||||
// 只有当用户确实请求了视频生成(videoTaskIds不为空)时才检查超时
|
||||
// 如果只生成了分镜图但未请求视频,不算超时
|
||||
String videoTaskIds = task.getVideoTaskIds();
|
||||
if (videoTaskIds == null || videoTaskIds.isEmpty() || "[]".equals(videoTaskIds)) {
|
||||
continue; // 用户未请求视频生成,跳过
|
||||
}
|
||||
|
||||
// 同步更新 UserWork 表的状态
|
||||
try {
|
||||
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
|
||||
com.example.demo.model.UserWork.WorkStatus.FAILED);
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||
// 视频生成超时,尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "视频生成超时:任务创建后超过1小时仍未完成");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
task.updateStatus(StoryboardVideoTask.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 ue) {
|
||||
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
|
||||
}
|
||||
}
|
||||
|
||||
// 返还冻结积分
|
||||
@@ -1204,4 +1392,41 @@ public class StoryboardVideoService {
|
||||
}
|
||||
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.setPromptOptimizationApiUrl("https://ai.comfly.chat");
|
||||
defaults.setStoryboardSystemPrompt("");
|
||||
defaults.setPromptOptimizationSystemPrompt("");
|
||||
SystemSettings saved = repository.save(defaults);
|
||||
logger.info("Initialized default SystemSettings: std={}, pro={}, points={}",
|
||||
saved.getStandardPriceCny(), saved.getProPriceCny(), saved.getPointsPerGeneration());
|
||||
@@ -67,7 +66,6 @@ public class SystemSettingsService {
|
||||
current.setPromptOptimizationModel(updated.getPromptOptimizationModel());
|
||||
current.setPromptOptimizationApiUrl(updated.getPromptOptimizationApiUrl());
|
||||
current.setStoryboardSystemPrompt(updated.getStoryboardSystemPrompt());
|
||||
current.setPromptOptimizationSystemPrompt(updated.getPromptOptimizationSystemPrompt());
|
||||
return repository.save(current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +350,13 @@ public class TaskQueueService {
|
||||
taskQueue.setErrorMessage(failReason);
|
||||
taskQueueRepository.save(taskQueue);
|
||||
|
||||
// 更新业务表和 UserWork(通过级联或回退)
|
||||
try {
|
||||
updateRelatedTaskStatusWithError(taskQueue.getTaskId(), taskQueue.getTaskType(), failReason);
|
||||
} catch (Exception e) {
|
||||
logger.warn("系统重启:更新业务表状态失败: taskId={}", taskQueue.getTaskId());
|
||||
}
|
||||
|
||||
// 返还冻结的积分
|
||||
try {
|
||||
userService.returnFrozenPoints(taskQueue.getTaskId());
|
||||
@@ -373,10 +380,15 @@ public class TaskQueueService {
|
||||
// 检查是否超时
|
||||
boolean isTimeout = task.getCreatedAt() != null && task.getCreatedAt().isBefore(timeoutThreshold);
|
||||
if (isTimeout) {
|
||||
// 超时任务标记为失败
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||
textToVideoTaskRepository.save(task);
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务超时(创建超过1小时)");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
textToVideoTaskRepository.save(task);
|
||||
}
|
||||
businessTaskCleanedCount++;
|
||||
logger.warn("系统重启:文生视频任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
} else {
|
||||
@@ -393,10 +405,15 @@ public class TaskQueueService {
|
||||
// 检查是否超时
|
||||
boolean isTimeout = task.getCreatedAt() != null && task.getCreatedAt().isBefore(timeoutThreshold);
|
||||
if (isTimeout) {
|
||||
// 超时任务标记为失败
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||
imageToVideoTaskRepository.save(task);
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务超时(创建超过1小时)");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
imageToVideoTaskRepository.save(task);
|
||||
}
|
||||
businessTaskCleanedCount++;
|
||||
logger.warn("系统重启:图生视频任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
} else {
|
||||
@@ -415,17 +432,27 @@ public class TaskQueueService {
|
||||
boolean noRealTaskId = task.getRealTaskId() == null || task.getRealTaskId().isEmpty();
|
||||
|
||||
if (isTimeout) {
|
||||
// 超时任务标记为失败
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务超时(创建超过1小时)");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("任务超时(创建超过1小时)");
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
}
|
||||
businessTaskCleanedCount++;
|
||||
logger.warn("系统重启:分镜视频任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
} else if (noRealTaskId) {
|
||||
// 没有提交到外部API的任务(分镜图生成阶段),重启后无法恢复,标记为失败
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("系统重启,分镜图生成任务已取消");
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "系统重启,分镜图生成任务已取消");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("系统重启,分镜图生成任务已取消");
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
}
|
||||
businessTaskCleanedCount++;
|
||||
logger.warn("系统重启:分镜视频任务 {} 无外部任务ID,标记为失败", task.getTaskId());
|
||||
} else {
|
||||
@@ -486,6 +513,7 @@ public class TaskQueueService {
|
||||
/**
|
||||
* 添加文生视频任务到队列
|
||||
*/
|
||||
@Transactional
|
||||
public TaskQueue addTextToVideoTask(String username, String taskId) {
|
||||
return addTaskToQueue(username, taskId, TaskQueue.TaskType.TEXT_TO_VIDEO);
|
||||
}
|
||||
@@ -493,6 +521,7 @@ public class TaskQueueService {
|
||||
/**
|
||||
* 添加图生视频任务到队列
|
||||
*/
|
||||
@Transactional
|
||||
public TaskQueue addImageToVideoTask(String username, String taskId) {
|
||||
return addTaskToQueue(username, taskId, TaskQueue.TaskType.IMAGE_TO_VIDEO);
|
||||
}
|
||||
@@ -500,14 +529,15 @@ public class TaskQueueService {
|
||||
/**
|
||||
* 添加分镜视频任务到队列
|
||||
*/
|
||||
@Transactional
|
||||
public TaskQueue addStoryboardVideoTask(String username, String taskId) {
|
||||
return addTaskToQueue(username, taskId, TaskQueue.TaskType.STORYBOARD_VIDEO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务到队列
|
||||
* 注意:私有方法上的 @Transactional 不生效,事务由调用者(公共方法)管理
|
||||
*/
|
||||
@Transactional
|
||||
private TaskQueue addTaskToQueue(String username, String taskId, TaskQueue.TaskType taskType) {
|
||||
// 检查用户是否已有待处理任务
|
||||
long pendingCount = taskQueueRepository.countPendingTasksByUsername(username);
|
||||
@@ -519,14 +549,31 @@ public class TaskQueueService {
|
||||
Optional<TaskQueue> existingTask = taskQueueRepository.findByTaskId(taskId);
|
||||
if (existingTask.isPresent()) {
|
||||
TaskQueue oldTask = existingTask.get();
|
||||
// 如果旧任务已失败或取消,删除旧记录允许重试
|
||||
if (oldTask.getStatus() == TaskQueue.QueueStatus.FAILED ||
|
||||
oldTask.getStatus() == TaskQueue.QueueStatus.CANCELLED) {
|
||||
logger.info("任务 {} 之前已失败/取消,删除旧记录允许重试", taskId);
|
||||
TaskQueue.QueueStatus oldStatus = oldTask.getStatus();
|
||||
|
||||
// COMPLETED/FAILED/CANCELLED/TIMEOUT: 直接删除旧记录,允许重新创建
|
||||
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.flush(); // 确保删除立即生效
|
||||
} else {
|
||||
throw new RuntimeException("任务 " + taskId + " 已存在于队列中(状态: " + oldTask.getStatus() + ")");
|
||||
taskQueueRepository.flush();
|
||||
}
|
||||
// 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) {
|
||||
apiResponse = processImageToVideoTask(taskQueue);
|
||||
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
|
||||
apiResponse = processStoryboardVideoTask(taskQueue);
|
||||
// 分镜视频任务由 StoryboardVideoService 的备份处理来执行
|
||||
// 队列消费者跳过,避免并发问题
|
||||
logger.info("分镜视频任务 {} 由备份处理执行,队列消费者跳过", taskQueue.getTaskId());
|
||||
return;
|
||||
} else {
|
||||
throw new RuntimeException("不支持的任务类型: " + taskQueue.getTaskType());
|
||||
}
|
||||
@@ -863,15 +913,18 @@ public class TaskQueueService {
|
||||
|
||||
/**
|
||||
* 增加检查次数(单独的事务方法,快速完成)
|
||||
* 注意:使用 TransactionTemplate 而非 @Transactional,因为私有方法上的注解不生效
|
||||
*/
|
||||
@Transactional
|
||||
private void incrementCheckCountWithTransaction(String taskId) {
|
||||
Optional<TaskQueue> taskOpt = taskQueueRepository.findByTaskId(taskId);
|
||||
if (taskOpt.isPresent()) {
|
||||
TaskQueue taskQueue = taskOpt.get();
|
||||
taskQueue.incrementCheckCount();
|
||||
taskQueueRepository.save(taskQueue);
|
||||
}
|
||||
transactionTemplate.execute(status -> {
|
||||
Optional<TaskQueue> taskOpt = taskQueueRepository.findByTaskId(taskId);
|
||||
if (taskOpt.isPresent()) {
|
||||
TaskQueue taskQueue = taskOpt.get();
|
||||
taskQueue.incrementCheckCount();
|
||||
taskQueueRepository.save(taskQueue);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -892,14 +945,16 @@ public class TaskQueueService {
|
||||
|
||||
/**
|
||||
* 使用只读事务获取文生视频任务(快速完成,避免连接泄漏)
|
||||
* 注意:使用 TransactionTemplate 而非 @Transactional,因为私有方法上的注解不生效
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
private TextToVideoTask getTextToVideoTaskWithTransaction(String taskId) {
|
||||
Optional<TextToVideoTask> taskOpt = textToVideoTaskRepository.findByTaskId(taskId);
|
||||
if (!taskOpt.isPresent()) {
|
||||
throw new RuntimeException("找不到文生视频任务: " + taskId);
|
||||
}
|
||||
return taskOpt.get();
|
||||
return readOnlyTransactionTemplate.execute(status -> {
|
||||
Optional<TextToVideoTask> taskOpt = textToVideoTaskRepository.findByTaskId(taskId);
|
||||
if (!taskOpt.isPresent()) {
|
||||
throw new RuntimeException("找不到文生视频任务: " + taskId);
|
||||
}
|
||||
return taskOpt.get();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -924,14 +979,16 @@ public class TaskQueueService {
|
||||
|
||||
/**
|
||||
* 使用只读事务获取图生视频任务(快速完成,避免连接泄漏)
|
||||
* 注意:使用 TransactionTemplate 而非 @Transactional,因为私有方法上的注解不生效
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
private ImageToVideoTask getImageToVideoTaskWithTransaction(String taskId) {
|
||||
Optional<ImageToVideoTask> taskOpt = imageToVideoTaskRepository.findByTaskId(taskId);
|
||||
if (!taskOpt.isPresent()) {
|
||||
throw new RuntimeException("找不到图生视频任务: " + taskId);
|
||||
}
|
||||
return taskOpt.get();
|
||||
return readOnlyTransactionTemplate.execute(status -> {
|
||||
Optional<ImageToVideoTask> taskOpt = imageToVideoTaskRepository.findByTaskId(taskId);
|
||||
if (!taskOpt.isPresent()) {
|
||||
throw new RuntimeException("找不到图生视频任务: " + taskId);
|
||||
}
|
||||
return taskOpt.get();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -972,9 +1029,22 @@ public class TaskQueueService {
|
||||
// 如果只有网格图,直接使用(已经是拼接后的图片)
|
||||
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(
|
||||
task.getPrompt(),
|
||||
videoPromptForApi,
|
||||
imageBase64,
|
||||
task.getAspectRatio(),
|
||||
"10", // 默认10秒
|
||||
@@ -991,34 +1061,82 @@ public class TaskQueueService {
|
||||
}
|
||||
}
|
||||
|
||||
// 确保有6张图片
|
||||
if (images.size() < 6) {
|
||||
throw new RuntimeException("分镜图片数量不足,需要6张,当前只有" + images.size() + "张");
|
||||
// 获取分镜图(取第一张)
|
||||
String storyboardImage = images.get(0);
|
||||
|
||||
// 解析用户上传的图片
|
||||
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) {
|
||||
logger.warn("分镜图片数量超过6张,只取前6张进行拼接");
|
||||
images = images.subList(0, 6);
|
||||
// 生成视频时进行拼图处理(分镜图 + 用户上传图片)
|
||||
String imageForVideo;
|
||||
if (!userUploadedImages.isEmpty()) {
|
||||
// 有用户上传图片,创建复合布局
|
||||
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张图片拼接成一张图片
|
||||
String mergedImageBase64;
|
||||
try {
|
||||
mergedImageBase64 = imageGridService.mergeImagesToGrid(images, 3); // 3列2行布局
|
||||
// 拼接完成
|
||||
} catch (Exception e) {
|
||||
logger.error("拼接分镜图失败: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("拼接分镜图失败: " + e.getMessage(), e);
|
||||
// 添加 shotList(分镜描述)
|
||||
if (task.getShotList() != null && !task.getShotList().trim().isEmpty()) {
|
||||
videoPromptBuilder.append(task.getShotList().trim());
|
||||
logger.info("添加 shotList 到视频提示词,长度: {}", task.getShotList().length());
|
||||
}
|
||||
|
||||
// 使用拼接后的图片调用图生视频接口
|
||||
logger.info("使用拼接后的图片调用图生视频接口...");
|
||||
// 添加 videoPrompt
|
||||
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(
|
||||
task.getPrompt(),
|
||||
mergedImageBase64,
|
||||
videoPromptForApi,
|
||||
imageForVideo,
|
||||
task.getAspectRatio(),
|
||||
"10", // 默认10秒
|
||||
task.isHdMode()
|
||||
@@ -1080,14 +1198,16 @@ public class TaskQueueService {
|
||||
|
||||
/**
|
||||
* 使用只读事务获取分镜视频任务(快速完成,避免连接泄漏)
|
||||
* 注意:使用 TransactionTemplate 而非 @Transactional,因为私有方法上的注解不生效
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
private StoryboardVideoTask getStoryboardVideoTaskWithTransaction(String taskId) {
|
||||
Optional<StoryboardVideoTask> taskOpt = storyboardVideoTaskRepository.findByTaskId(taskId);
|
||||
if (!taskOpt.isPresent()) {
|
||||
throw new RuntimeException("找不到分镜视频任务: " + taskId);
|
||||
}
|
||||
return taskOpt.get();
|
||||
return readOnlyTransactionTemplate.execute(status -> {
|
||||
Optional<StoryboardVideoTask> taskOpt = storyboardVideoTaskRepository.findByTaskId(taskId);
|
||||
if (!taskOpt.isPresent()) {
|
||||
throw new RuntimeException("找不到分镜视频任务: " + taskId);
|
||||
}
|
||||
return taskOpt.get();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1297,7 +1417,27 @@ public class TaskQueueService {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1831,93 +1971,129 @@ public class TaskQueueService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接更新业务表和 UserWork(当 TaskQueue 不存在时使用)
|
||||
* 通过 task_status 触发级联更新任务完成状态
|
||||
* 如果 task_status 记录不存在,回退到直接更新业务表
|
||||
*/
|
||||
private void updateBusinessTaskAndUserWork(String taskId, String resultUrl) {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
// 更新业务表
|
||||
if (taskId.startsWith("img2vid_")) {
|
||||
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
task.setStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
||||
task.setResultUrl(resultUrl);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
imageToVideoTaskRepository.save(task);
|
||||
logger.info("直接更新图生视频任务为完成: {}", taskId);
|
||||
});
|
||||
} else if (taskId.startsWith("storyboard_")) {
|
||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.COMPLETED);
|
||||
task.setResultUrl(resultUrl);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
logger.info("直接更新分镜视频任务为完成: {}", taskId);
|
||||
});
|
||||
} else if (taskId.startsWith("txt2vid_")) {
|
||||
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
task.setStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
||||
task.setResultUrl(resultUrl);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
textToVideoTaskRepository.save(task);
|
||||
logger.info("直接更新文生视频任务为完成: {}", taskId);
|
||||
});
|
||||
}
|
||||
|
||||
// 更新 UserWork
|
||||
if (resultUrl != null && !resultUrl.isEmpty()) {
|
||||
try {
|
||||
userWorkService.createWorkFromTask(taskId, resultUrl);
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新 UserWork 失败: {}", taskId, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskCompleted(taskId, resultUrl);
|
||||
if (updated) {
|
||||
logger.info("任务完成状态已更新(级联更新已触发): {}", taskId);
|
||||
} else {
|
||||
// 回退:直接更新业务表和 UserWork
|
||||
logger.warn("task_status 记录不存在,回退到直接更新业务表: {}", taskId);
|
||||
fallbackUpdateBusinessTaskCompleted(taskId, resultUrl);
|
||||
}
|
||||
} 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) {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
// 更新业务表
|
||||
if (taskId.startsWith("img2vid_")) {
|
||||
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
task.setStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
imageToVideoTaskRepository.save(task);
|
||||
logger.info("直接更新图生视频任务为失败: {}", taskId);
|
||||
});
|
||||
} else if (taskId.startsWith("storyboard_")) {
|
||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
logger.info("直接更新分镜视频任务为失败: {}", taskId);
|
||||
});
|
||||
} else if (taskId.startsWith("txt2vid_")) {
|
||||
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
task.setStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
textToVideoTaskRepository.save(task);
|
||||
logger.info("直接更新文生视频任务为失败: {}", taskId);
|
||||
});
|
||||
}
|
||||
|
||||
// 更新 UserWork
|
||||
try {
|
||||
userWorkService.updateWorkStatus(taskId, UserWork.WorkStatus.FAILED);
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新 UserWork 状态为失败失败: {}", taskId, e);
|
||||
}
|
||||
});
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(taskId, errorMessage);
|
||||
if (updated) {
|
||||
logger.info("任务失败状态已更新(级联更新已触发): {}", taskId);
|
||||
} else {
|
||||
// 回退:直接更新业务表和 UserWork
|
||||
logger.warn("task_status 记录不存在,回退到直接更新业务表: {}", taskId);
|
||||
fallbackUpdateBusinessTaskFailed(taskId, errorMessage);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务为失败状态(使用独立事务,快速完成)
|
||||
@@ -2039,24 +2215,49 @@ public class TaskQueueService {
|
||||
|
||||
/**
|
||||
* 更新原始任务状态(使用独立事务,快速完成)
|
||||
* 注意:COMPLETED 和 FAILED 状态会尝试通过 task_status 触发级联更新
|
||||
*/
|
||||
private void updateOriginalTaskStatus(TaskQueue taskQueue, String status, String resultUrl, String errorMessage) {
|
||||
logger.info("更新原始任务状态: taskId={}, taskType={}, status={}, resultUrl={}",
|
||||
taskQueue.getTaskId(), taskQueue.getTaskType(), status,
|
||||
resultUrl != null ? (resultUrl.length() > 50 ? resultUrl.substring(0, 50) + "..." : resultUrl) : "null");
|
||||
try {
|
||||
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) {
|
||||
Optional<TextToVideoTask> taskOpt = textToVideoTaskRepository.findByTaskId(taskQueue.getTaskId());
|
||||
Optional<TextToVideoTask> taskOpt = textToVideoTaskRepository.findByTaskId(taskId);
|
||||
if (taskOpt.isPresent()) {
|
||||
TextToVideoTask task = taskOpt.get();
|
||||
if ("COMPLETED".equals(status)) {
|
||||
task.setResultUrl(resultUrl);
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
||||
task.updateProgress(100);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
textToVideoTaskRepository.save(task);
|
||||
} else if ("FAILED".equals(status) || "CANCELLED".equals(status)) {
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
textToVideoTaskRepository.save(task);
|
||||
} else if ("PROCESSING".equals(status)) {
|
||||
// 处理中状态,更新resultUrl以显示进度
|
||||
@@ -2067,21 +2268,23 @@ public class TaskQueueService {
|
||||
}
|
||||
}
|
||||
} 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()) {
|
||||
ImageToVideoTask task = taskOpt.get();
|
||||
logger.info("找到ImageToVideoTask: taskId={}, 当前状态={}", taskQueue.getTaskId(), task.getStatus());
|
||||
logger.info("找到ImageToVideoTask: taskId={}, 当前状态={}", taskId, task.getStatus());
|
||||
if ("COMPLETED".equals(status)) {
|
||||
task.setResultUrl(resultUrl);
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.COMPLETED);
|
||||
task.updateProgress(100);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
imageToVideoTaskRepository.save(task);
|
||||
logger.info("ImageToVideoTask已更新为COMPLETED: taskId={}", taskQueue.getTaskId());
|
||||
logger.info("ImageToVideoTask已更新为COMPLETED: taskId={}", taskId);
|
||||
} else if ("FAILED".equals(status) || "CANCELLED".equals(status)) {
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
imageToVideoTaskRepository.save(task);
|
||||
logger.info("ImageToVideoTask已更新为FAILED: taskId={}", taskQueue.getTaskId());
|
||||
logger.info("ImageToVideoTask已更新为FAILED: taskId={}", taskId);
|
||||
} else if ("PROCESSING".equals(status)) {
|
||||
// 处理中状态,更新resultUrl以显示进度
|
||||
if (resultUrl != null && !resultUrl.isEmpty()) {
|
||||
@@ -2090,10 +2293,10 @@ public class TaskQueueService {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn("ImageToVideoTask不存在: taskId={}", taskQueue.getTaskId());
|
||||
logger.warn("ImageToVideoTask不存在: taskId={}", taskId);
|
||||
}
|
||||
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
|
||||
Optional<StoryboardVideoTask> taskOpt = storyboardVideoTaskRepository.findByTaskId(taskQueue.getTaskId());
|
||||
Optional<StoryboardVideoTask> taskOpt = storyboardVideoTaskRepository.findByTaskId(taskId);
|
||||
if (taskOpt.isPresent()) {
|
||||
StoryboardVideoTask task = taskOpt.get();
|
||||
if ("COMPLETED".equals(status)) {
|
||||
@@ -2102,10 +2305,12 @@ public class TaskQueueService {
|
||||
task.setRealTaskId(taskQueue.getRealTaskId());
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.COMPLETED);
|
||||
task.updateProgress(100);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
} else if ("FAILED".equals(status) || "CANCELLED".equals(status)) {
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
} else if ("PROCESSING".equals(status)) {
|
||||
// 处理中状态,更新resultUrl以显示进度
|
||||
@@ -2300,14 +2505,25 @@ public class TaskQueueService {
|
||||
|
||||
/**
|
||||
* 更新关联的具体任务状态(使用自定义错误信息)
|
||||
* 注意:通过 task_status 触发级联更新,如果失败则回退到直接更新
|
||||
*/
|
||||
private void updateRelatedTaskStatusWithError(String taskId, TaskQueue.TaskType taskType, String errorMessage) {
|
||||
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) {
|
||||
case TEXT_TO_VIDEO:
|
||||
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
textToVideoTaskRepository.save(task);
|
||||
});
|
||||
break;
|
||||
@@ -2315,6 +2531,7 @@ public class TaskQueueService {
|
||||
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
imageToVideoTaskRepository.save(task);
|
||||
});
|
||||
break;
|
||||
@@ -2322,20 +2539,70 @@ public class TaskQueueService {
|
||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
task.setCompletedAt(java.time.LocalDateTime.now());
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// 同步更新 UserWork 状态为 FAILED,确保前端不会将失败任务当作进行中任务恢复
|
||||
// 回退时也需要更新 UserWork
|
||||
try {
|
||||
userWorkService.updateWorkStatus(taskId, UserWork.WorkStatus.FAILED);
|
||||
logger.info("已同步更新UserWork状态为FAILED: taskId={}", taskId);
|
||||
logger.info("回退更新UserWork状态为FAILED: taskId={}", taskId);
|
||||
} catch (Exception workException) {
|
||||
logger.warn("更新UserWork状态为FAILED失败: taskId={}, error={}", taskId, workException.getMessage());
|
||||
logger.warn("回退更新UserWork状态失败: taskId={}, error={}", taskId, workException.getMessage());
|
||||
}
|
||||
} catch (Exception 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.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -39,6 +40,12 @@ public class TaskStatusPollingService {
|
||||
@org.springframework.context.annotation.Lazy
|
||||
private TaskQueueService taskQueueService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private com.example.demo.repository.StoryboardVideoTaskRepository storyboardVideoTaskRepository;
|
||||
|
||||
@Value("${ai.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
|
||||
private String apiKey;
|
||||
|
||||
@@ -239,6 +246,32 @@ public class TaskStatusPollingService {
|
||||
public void pollTaskStatus(TaskStatus task) {
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -381,15 +414,32 @@ public class TaskStatusPollingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的任务状态记录
|
||||
* 创建或更新任务状态记录(避免重复创建)
|
||||
*/
|
||||
@Transactional
|
||||
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.setExternalTaskId(externalTaskId);
|
||||
taskStatus.setStatus(TaskStatus.Status.PROCESSING);
|
||||
taskStatus.setProgress(0);
|
||||
|
||||
logger.info("创建新的 TaskStatus: taskId={}, externalTaskId={}", taskId, externalTaskId);
|
||||
return taskStatusRepository.save(taskStatus);
|
||||
}
|
||||
|
||||
@@ -397,7 +447,7 @@ public class TaskStatusPollingService {
|
||||
* 根据任务ID获取状态
|
||||
*/
|
||||
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
|
||||
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)) {
|
||||
return false;
|
||||
@@ -437,6 +487,120 @@ public class TaskStatusPollingService {
|
||||
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%")为整数
|
||||
*/
|
||||
|
||||
@@ -40,6 +40,9 @@ public class TextToVideoService {
|
||||
|
||||
@Autowired
|
||||
private UserWorkService userWorkService;
|
||||
|
||||
@Autowired
|
||||
private TaskStatusPollingService taskStatusPollingService;
|
||||
|
||||
@Value("${app.video.output.path:/outputs}")
|
||||
private String outputPath;
|
||||
@@ -174,13 +177,25 @@ public class TextToVideoService {
|
||||
taskRepository.save(currentTask);
|
||||
logger.info("真实任务ID已保存: {} -> {}", task.getTaskId(), realTaskId);
|
||||
} else {
|
||||
// 如果没有找到任务ID,说明任务提交失败
|
||||
// 如果没有找到任务ID,说明任务提交失败(尝试通过 task_status 触发级联)
|
||||
logger.error("任务提交失败:未从API响应中获取到任务ID");
|
||||
currentTask = taskRepository.findByTaskId(task.getTaskId())
|
||||
.orElseThrow(() -> new RuntimeException("任务不存在: " + task.getTaskId()));
|
||||
currentTask.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
currentTask.setErrorMessage("任务提交失败:API未返回有效的任务ID");
|
||||
taskRepository.save(currentTask);
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务提交失败:API未返回有效的任务ID");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
currentTask = taskRepository.findByTaskId(task.getTaskId())
|
||||
.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); // 直接返回,不进行轮询
|
||||
}
|
||||
|
||||
@@ -195,14 +210,24 @@ public class TextToVideoService {
|
||||
}
|
||||
|
||||
try {
|
||||
// 重新加载任务以确保获取最新状态
|
||||
TextToVideoTask currentTask = taskRepository.findByTaskId(task.getTaskId())
|
||||
.orElse(null);
|
||||
if (currentTask != null) {
|
||||
// 更新状态为失败
|
||||
currentTask.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
currentTask.setErrorMessage(e.getMessage());
|
||||
taskRepository.save(currentTask);
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), e.getMessage());
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
TextToVideoTask currentTask = taskRepository.findByTaskId(task.getTaskId()).orElse(null);
|
||||
if (currentTask != null) {
|
||||
currentTask.updateStatus(TextToVideoTask.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());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception saveException) {
|
||||
logger.error("保存失败状态时出错: {}", task.getTaskId(), saveException);
|
||||
@@ -266,28 +291,44 @@ public class TextToVideoService {
|
||||
String resultUrl = (String) taskData.get("resultUrl");
|
||||
String errorMessage = (String) taskData.get("errorMessage");
|
||||
|
||||
// 更新任务状态
|
||||
// 更新任务状态(通过 task_status 表触发级联)
|
||||
if ("completed".equals(status) || "success".equals(status)) {
|
||||
task.setResultUrl(resultUrl);
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
||||
task.updateProgress(100);
|
||||
taskRepository.save(task);
|
||||
|
||||
// 同步更新 UserWork 表的状态和结果URL
|
||||
try {
|
||||
userWorkService.updateWorkOnComplete(task.getTaskId(), resultUrl,
|
||||
com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
||||
logger.info("文生视频任务完成,UserWork已更新: {}", task.getTaskId());
|
||||
} catch (Exception e) {
|
||||
logger.warn("更新UserWork状态失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskCompleted(task.getTaskId(), resultUrl);
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
task.setResultUrl(resultUrl);
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.COMPLETED);
|
||||
task.updateProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
taskRepository.save(task);
|
||||
// 更新 UserWork
|
||||
try {
|
||||
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());
|
||||
return;
|
||||
} else if ("failed".equals(status) || "error".equals(status)) {
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage(errorMessage);
|
||||
taskRepository.save(task);
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), errorMessage);
|
||||
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());
|
||||
return;
|
||||
} else if ("processing".equals(status) || "pending".equals(status) || "running".equals(status)) {
|
||||
@@ -313,10 +354,22 @@ public class TextToVideoService {
|
||||
Thread.sleep(2000); // 每2秒轮询一次
|
||||
}
|
||||
|
||||
// 超时处理
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("任务处理超时");
|
||||
taskRepository.save(task);
|
||||
// 超时处理(尝试通过 task_status 触发级联)
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "任务处理超时");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
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());
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
@@ -476,17 +529,22 @@ public class TextToVideoService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新任务状态为失败
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("文生视频任务超时:任务创建后超过1小时仍未完成");
|
||||
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());
|
||||
// 尝试通过 task_status 触发级联更新
|
||||
boolean updated = taskStatusPollingService.markTaskFailed(task.getTaskId(), "文生视频任务超时:任务创建后超过1小时仍未完成");
|
||||
if (!updated) {
|
||||
// 回退:直接更新业务表
|
||||
task.updateStatus(TextToVideoTask.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());
|
||||
}
|
||||
}
|
||||
|
||||
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.setPrompt(task.getPrompt());
|
||||
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.setQuality(task.isHdMode() ? "HD" : "SD");
|
||||
work.setPointsCost(task.getCostPoints());
|
||||
@@ -293,7 +296,7 @@ public class UserWorkService {
|
||||
work.setCompletedAt(LocalDateTime.now());
|
||||
|
||||
work = userWorkRepository.save(work);
|
||||
logger.info("创建分镜视频作品成功: {}, 用户: {}", work.getId(), work.getUsername());
|
||||
logger.info("创建分镜视频作品成功: {}, 用户: {}, 分镜图: {}", work.getId(), work.getUsername(), task.getResultUrl() != null ? "有" : "无");
|
||||
return work;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,23 +24,37 @@ public class JwtUtils {
|
||||
private Long expiration;
|
||||
|
||||
/**
|
||||
* 生成JWT Token
|
||||
* 生成JWT Token(使用默认过期时间)
|
||||
*/
|
||||
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<>();
|
||||
claims.put("username", username);
|
||||
claims.put("role", role);
|
||||
claims.put("userId", userId);
|
||||
|
||||
return createToken(claims, username);
|
||||
return createToken(claims, username, expirationMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Token
|
||||
*/
|
||||
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 expiryDate = new Date(now.getTime() + expiration);
|
||||
Date expiryDate = new Date(now.getTime() + expirationMs);
|
||||
|
||||
return Jwts.builder()
|
||||
.claims(claims)
|
||||
|
||||
@@ -19,11 +19,11 @@ public class UserIdGenerator {
|
||||
|
||||
/**
|
||||
* 生成用户ID
|
||||
* @return 格式: UID + yyMMdd + 4位随机字符 (共14位)
|
||||
* @return 格式: UID + yyMMdd + 5位随机字符 (共15位)
|
||||
*/
|
||||
public static String generate() {
|
||||
String datePart = LocalDate.now().format(DATE_FORMAT);
|
||||
String randomPart = generateRandomString(4);
|
||||
String randomPart = generateRandomString(5);
|
||||
return PREFIX + datePart + randomPart;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public class UserIdGenerator {
|
||||
* 验证用户ID格式是否正确
|
||||
*/
|
||||
public static boolean isValid(String userId) {
|
||||
if (userId == null || userId.length() != 14) {
|
||||
if (userId == null || userId.length() != 15) {
|
||||
return false;
|
||||
}
|
||||
if (!userId.startsWith(PREFIX)) {
|
||||
@@ -53,8 +53,8 @@ public class UserIdGenerator {
|
||||
if (!datePart.matches("\\d{6}")) {
|
||||
return false;
|
||||
}
|
||||
// 检查随机部分(4位字母数字)
|
||||
// 检查随机部分(5位字母数字)
|
||||
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
|
||||
#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.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
|
||||
ai.image.api.base-url=https\://ai.comfly.chat
|
||||
ai.image.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
|
||||
alipay.app-id=9021000157616562
|
||||
alipay.charset=UTF-8
|
||||
alipay.domain=https\://vionow.com
|
||||
alipay.gateway-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
|
||||
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\=\=
|
||||
@@ -15,7 +16,7 @@ alipay.server-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
|
||||
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.temp.dir=./temp
|
||||
jwt.expiration=7200000
|
||||
jwt.expiration=86400000
|
||||
jwt.secret=mySecretKey123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
|
||||
logging.level.com.example.demo=DEBUG
|
||||
logging.level.org.hibernate.SQL=WARN
|
||||
@@ -61,5 +62,3 @@ tencent.ses.region=ap-hongkong
|
||||
tencent.ses.secret-id=AKIDoaEjFbqxxqZAcv8EE6oZCg2IQPG1fCxm
|
||||
tencent.ses.secret-key=nR83I79FOSpGcqNo7JXkqnU8g7SjsxuG
|
||||
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.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.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.base-url=http://116.62.4.26:8081
|
||||
ai.api.key=ak_5f13ec469e6047d5b8155c3cc91350e2
|
||||
|
||||
Reference in New Issue
Block a user