feat: 添加用户错误日志功能, 禁用Redis缓存, userId自动生成5位随机字符

This commit is contained in:
AIGC Developer
2025-12-11 13:32:24 +08:00
parent 3c37006ebd
commit 0933031b59
58 changed files with 4932 additions and 1144 deletions

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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++;

View File

@@ -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()));
}
}
/**

View File

@@ -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", "重试任务失败"));
}
}
}

View File

@@ -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
));
}
}

View File

@@ -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());

View File

@@ -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; }
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}

View 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 +
'}';
}
}

View File

@@ -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;
}

View File

@@ -18,6 +18,11 @@ public interface TaskStatusRepository extends JpaRepository<TaskStatus, Long> {
*/
Optional<TaskStatus> findByTaskId(String taskId);
/**
* 根据任务ID查找状态取第一条处理重复记录情况
*/
Optional<TaskStatus> findFirstByTaskIdOrderByIdDesc(String taskId);
/**
* 根据用户名查找所有任务状态
*/

View File

@@ -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);
}

View File

@@ -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);
/**

View File

@@ -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) {

View File

@@ -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";
}
}
}

View File

@@ -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++;

View File

@@ -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限制允许更详细的优化
// 分镜类型需要更多 tokensJSON 包含 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 {
""";
};
}
}

View File

@@ -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;
}
}

View File

@@ -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) {
// 有参考图片使用图生图APIbananaAPI
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);
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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%")为整数
*/

View File

@@ -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());

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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}");
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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则禁用RedisToken验证仅依赖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