feat: 完成管理员密码登录修复和项目清理
- 修复BCryptPasswordEncoder密码验证问题 - 实现密码设置提示弹窗功能(仅对无密码用户显示一次) - 优化修改密码逻辑和验证流程 - 更新Welcome页面背景样式 - 清理临时SQL文件和测试代码 - 移动数据库备份文件到database/backups目录 - 删除不必要的MD文档和临时文件
This commit is contained in:
62
demo/src/main/java/com/example/demo/config/CosConfig.java
Normal file
62
demo/src/main/java/com/example/demo/config/CosConfig.java
Normal file
@@ -0,0 +1,62 @@
|
||||
package com.example.demo.config;
|
||||
|
||||
import com.qcloud.cos.COSClient;
|
||||
import com.qcloud.cos.ClientConfig;
|
||||
import com.qcloud.cos.auth.BasicCOSCredentials;
|
||||
import com.qcloud.cos.auth.COSCredentials;
|
||||
import com.qcloud.cos.region.Region;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 腾讯云COS配置类
|
||||
*/
|
||||
@Configuration
|
||||
public class CosConfig {
|
||||
|
||||
@Value("${tencent.cos.secret-id:}")
|
||||
private String secretId;
|
||||
|
||||
@Value("${tencent.cos.secret-key:}")
|
||||
private String secretKey;
|
||||
|
||||
@Value("${tencent.cos.region:ap-guangzhou}")
|
||||
private String region;
|
||||
|
||||
@Value("${tencent.cos.bucket-name:}")
|
||||
private String bucketName;
|
||||
|
||||
@Value("${tencent.cos.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Bean
|
||||
public COSClient cosClient() {
|
||||
if (!enabled || secretId.isEmpty() || secretKey.isEmpty()) {
|
||||
// 如果未配置COS,返回null(服务层需要处理这种情况)
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1 初始化用户身份信息(secretId, secretKey)
|
||||
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
|
||||
|
||||
// 2 设置bucket的区域
|
||||
Region regionObj = new Region(region);
|
||||
ClientConfig clientConfig = new ClientConfig(regionObj);
|
||||
|
||||
// 3 生成cos客户端
|
||||
return new COSClient(cred, clientConfig);
|
||||
}
|
||||
|
||||
public String getBucketName() {
|
||||
return bucketName;
|
||||
}
|
||||
|
||||
public String getRegion() {
|
||||
return region;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new PlainTextPasswordEncoder();
|
||||
return new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -39,7 +39,7 @@ public class SecurityConfig {
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态,使用JWT
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 允许基于表单的账号密码登录后保持会话
|
||||
)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Swagger/OpenAPI 路径 - 必须放在最前面,完全公开访问
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.example.demo.config;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -12,12 +13,17 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
|
||||
import org.springframework.web.servlet.i18n.SessionLocaleResolver;
|
||||
|
||||
import com.example.demo.interceptor.UserActivityInterceptor;
|
||||
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${app.upload.path:uploads}")
|
||||
private String uploadPath;
|
||||
|
||||
@Autowired
|
||||
private UserActivityInterceptor userActivityInterceptor;
|
||||
|
||||
@Bean
|
||||
public LocaleResolver localeResolver() {
|
||||
SessionLocaleResolver slr = new SessionLocaleResolver();
|
||||
@@ -34,7 +40,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 语言切换拦截器
|
||||
registry.addInterceptor(localeChangeInterceptor());
|
||||
|
||||
// 用户活跃时间更新拦截器(用于统计在线用户)
|
||||
registry.addInterceptor(userActivityInterceptor)
|
||||
.addPathPatterns("/**") // 拦截所有请求
|
||||
.excludePathPatterns(
|
||||
"/css/**",
|
||||
"/js/**",
|
||||
"/images/**",
|
||||
"/uploads/**",
|
||||
"/favicon.ico",
|
||||
"/error"
|
||||
); // 排除静态资源
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,7 @@ public class AuthApiController {
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
try {
|
||||
logger.info("=== 开始处理密码登录请求 ===");
|
||||
// 支持使用邮箱+密码登录(账号就是邮箱)或用户名+密码
|
||||
String emailOrUsername = credentials.get("email");
|
||||
if (emailOrUsername == null || emailOrUsername.trim().isEmpty()) {
|
||||
@@ -54,24 +55,40 @@ public class AuthApiController {
|
||||
emailOrUsername = credentials.get("username");
|
||||
}
|
||||
String password = credentials.get("password");
|
||||
|
||||
logger.info("登录账号: {}", emailOrUsername);
|
||||
logger.info("密码长度: {}", password != null ? password.length() : 0);
|
||||
|
||||
if (emailOrUsername == null || emailOrUsername.trim().isEmpty() || password == null) {
|
||||
logger.warn("登录失败: 邮箱/用户名或密码为空");
|
||||
return ResponseEntity.badRequest().body(createErrorResponse("邮箱/用户名或密码不能为空"));
|
||||
}
|
||||
|
||||
// 先尝试按邮箱查找用户
|
||||
logger.info("尝试按邮箱查找用户: {}", emailOrUsername);
|
||||
com.example.demo.model.User user = userService.findByEmailOrNull(emailOrUsername);
|
||||
if (user == null) {
|
||||
// 再按用户名查找
|
||||
logger.info("邮箱未找到,尝试按用户名查找: {}", emailOrUsername);
|
||||
user = userService.findByUsernameOrNull(emailOrUsername);
|
||||
}
|
||||
|
||||
if (user == null) {
|
||||
logger.warn("登录失败: 用户不存在 - {}", emailOrUsername);
|
||||
return ResponseEntity.badRequest().body(createErrorResponse("用户不存在"));
|
||||
}
|
||||
|
||||
logger.info("找到用户: ID={}, 用户名={}, 角色={}", user.getId(), user.getUsername(), user.getRole());
|
||||
logger.info("密码哈希长度: {}", user.getPasswordHash() != null ? user.getPasswordHash().length() : 0);
|
||||
logger.info("密码哈希前缀: {}", user.getPasswordHash() != null && user.getPasswordHash().length() > 10 ? user.getPasswordHash().substring(0, 10) : "无");
|
||||
|
||||
// 检查密码
|
||||
if (!userService.checkPassword(password, user.getPasswordHash())) {
|
||||
logger.info("开始验证密码...");
|
||||
boolean passwordMatch = userService.checkPassword(password, user.getPasswordHash());
|
||||
logger.info("密码验证结果: {}", passwordMatch);
|
||||
|
||||
if (!passwordMatch) {
|
||||
logger.warn("登录失败: 密码不正确 - {}", emailOrUsername);
|
||||
return ResponseEntity.badRequest().body(createErrorResponse("邮箱/用户名或密码不正确"));
|
||||
}
|
||||
|
||||
@@ -396,6 +413,58 @@ public class AuthApiController {
|
||||
.body(createErrorResponse("获取用户信息失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改当前登录用户密码
|
||||
*/
|
||||
@PostMapping("/change-password")
|
||||
public ResponseEntity<Map<String, Object>> changePassword(@RequestBody Map<String, String> requestBody,
|
||||
Authentication authentication,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
String oldPassword = requestBody.get("oldPassword");
|
||||
String newPassword = requestBody.get("newPassword");
|
||||
|
||||
// 尝试从 Spring Security 上下文中获取当前用户名
|
||||
User user = null;
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
String username = authentication.getName();
|
||||
user = userService.findByUsernameOrNull(username);
|
||||
}
|
||||
|
||||
// 如果通过用户名未找到,再从JWT中解析用户ID
|
||||
if (user == null) {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
String token = jwtUtils.extractTokenFromHeader(authHeader);
|
||||
if (token != null) {
|
||||
Long userId = jwtUtils.getUserIdFromToken(token);
|
||||
if (userId != null) {
|
||||
user = userService.findById(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (user == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("用户未登录或会话已失效"));
|
||||
}
|
||||
|
||||
userService.changePassword(user.getId(), oldPassword, newPassword);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "密码修改成功");
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse(ex.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("修改密码失败:", e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("修改密码失败:" + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户名是否存在
|
||||
|
||||
@@ -44,6 +44,9 @@ public class DashboardApiController {
|
||||
@Autowired
|
||||
private MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
@Autowired
|
||||
private com.example.demo.service.UserService userService;
|
||||
|
||||
// 获取仪表盘概览数据
|
||||
@GetMapping("/overview")
|
||||
public ResponseEntity<Map<String, Object>> getDashboardOverview() {
|
||||
@@ -246,8 +249,8 @@ public class DashboardApiController {
|
||||
try {
|
||||
Map<String, Object> status = new HashMap<>();
|
||||
|
||||
// 当前在线用户(从session或redis获取)
|
||||
int onlineUsers = (int) (Math.random() * 50) + 50; // 50-100之间
|
||||
// 当前在线用户(基于最近10分钟内有活动的用户)
|
||||
long onlineUsers = userService.countOnlineUsers();
|
||||
status.put("onlineUsers", onlineUsers);
|
||||
|
||||
// 系统运行时间
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import com.example.demo.service.UserService;
|
||||
|
||||
@Controller
|
||||
public class HomeController {
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@GetMapping("/")
|
||||
public String home() {
|
||||
public String home(Model model) {
|
||||
// 统计在线用户数(最近10分钟内活跃的用户)
|
||||
long onlineUserCount = userService.countOnlineUsers();
|
||||
model.addAttribute("onlineUserCount", onlineUserCount);
|
||||
|
||||
return "home";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,20 @@ public class StoryboardVideoApiController {
|
||||
String aspectRatio = (String) request.getOrDefault("aspectRatio", "16:9");
|
||||
Boolean hdMode = (Boolean) request.getOrDefault("hdMode", false);
|
||||
String imageUrl = (String) request.get("imageUrl");
|
||||
|
||||
// 提取duration参数,支持多种类型
|
||||
Integer duration = 10; // 默认10秒
|
||||
Object durationObj = request.get("duration");
|
||||
if (durationObj instanceof Number) {
|
||||
duration = ((Number) durationObj).intValue();
|
||||
} else if (durationObj instanceof String) {
|
||||
try {
|
||||
duration = Integer.parseInt((String) durationObj);
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warn("无效的duration参数: {}, 使用默认值10", durationObj);
|
||||
}
|
||||
}
|
||||
logger.info("任务参数 - duration: {}, aspectRatio: {}, hdMode: {}", duration, aspectRatio, hdMode);
|
||||
|
||||
if (prompt == null || prompt.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
@@ -53,7 +67,7 @@ public class StoryboardVideoApiController {
|
||||
|
||||
// 创建任务
|
||||
StoryboardVideoTask task = storyboardVideoService.createTask(
|
||||
username, prompt, aspectRatio, hdMode != null && hdMode, imageUrl
|
||||
username, prompt, aspectRatio, hdMode != null && hdMode, imageUrl, duration
|
||||
);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
@@ -156,6 +170,7 @@ public class StoryboardVideoApiController {
|
||||
@PostMapping("/task/{taskId}/start-video")
|
||||
public ResponseEntity<?> startVideoGeneration(
|
||||
@PathVariable String taskId,
|
||||
@RequestBody(required = false) Map<String, Object> requestBody,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
String username = authentication.getName();
|
||||
@@ -169,8 +184,33 @@ public class StoryboardVideoApiController {
|
||||
.body(Map.of("success", false, "message", "无权访问此任务"));
|
||||
}
|
||||
|
||||
// 开始生成视频
|
||||
storyboardVideoService.startVideoGeneration(taskId);
|
||||
// 从请求体中提取参数(如果有)
|
||||
Integer duration = null;
|
||||
String aspectRatio = null;
|
||||
Boolean hdMode = null;
|
||||
|
||||
if (requestBody != null) {
|
||||
if (requestBody.containsKey("duration")) {
|
||||
Object durationObj = requestBody.get("duration");
|
||||
if (durationObj instanceof Number) {
|
||||
duration = ((Number) durationObj).intValue();
|
||||
} else if (durationObj instanceof String) {
|
||||
duration = Integer.parseInt((String) durationObj);
|
||||
}
|
||||
logger.info("视频时长参数: {}", duration);
|
||||
}
|
||||
if (requestBody.containsKey("aspectRatio")) {
|
||||
aspectRatio = (String) requestBody.get("aspectRatio");
|
||||
logger.info("视频宽高比参数: {}", aspectRatio);
|
||||
}
|
||||
if (requestBody.containsKey("hdMode")) {
|
||||
hdMode = (Boolean) requestBody.get("hdMode");
|
||||
logger.info("高清模式参数: {}", hdMode);
|
||||
}
|
||||
}
|
||||
|
||||
// 开始生成视频,传递参数
|
||||
storyboardVideoService.startVideoGeneration(taskId, duration, aspectRatio, hdMode);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -7,7 +16,11 @@ import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -72,13 +85,13 @@ public class UserWorkApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的作品列表
|
||||
* 获取我的作品列表(只返回有resultUrl的作品)
|
||||
*/
|
||||
@GetMapping("/my-works")
|
||||
public ResponseEntity<Map<String, Object>> getMyWorks(
|
||||
@RequestHeader("Authorization") String token,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
@RequestParam(defaultValue = "1000") int size) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
@@ -90,13 +103,23 @@ public class UserWorkApiController {
|
||||
return ResponseEntity.status(401).body(response);
|
||||
}
|
||||
|
||||
// 输入验证
|
||||
// 输入验证 - 移除size上限限制
|
||||
if (page < 0) page = 0;
|
||||
if (size <= 0 || size > 100) size = 10;
|
||||
if (size <= 0) size = 1000; // 不设上限,默认1000条
|
||||
|
||||
Page<UserWork> works = userWorkService.getUserWorks(username, page, size);
|
||||
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());
|
||||
@@ -275,7 +298,7 @@ public class UserWorkApiController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载作品
|
||||
* 下载作品(记录下载次数)
|
||||
*/
|
||||
@PostMapping("/{workId:\\d+}/download")
|
||||
public ResponseEntity<Map<String, Object>> downloadWork(
|
||||
@@ -314,6 +337,235 @@ public class UserWorkApiController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作品文件(实际下载)
|
||||
* 支持两种模式:
|
||||
* 1. 直接下载(download=true):返回文件流供用户下载
|
||||
* 2. 重定向(download=false):重定向到COS URL(默认)
|
||||
*/
|
||||
@GetMapping("/{workId:\\d+}/file")
|
||||
public ResponseEntity<?> downloadWorkFile(
|
||||
@PathVariable Long workId,
|
||||
@RequestParam(defaultValue = "false") boolean download,
|
||||
@RequestHeader("Authorization") String token) {
|
||||
|
||||
try {
|
||||
// 提取用户名(必须登录)
|
||||
String username = extractUsernameFromToken(token);
|
||||
if (username == null) {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", "用户未登录");
|
||||
return ResponseEntity.status(401).body(errorResponse);
|
||||
}
|
||||
|
||||
// 获取作品并验证权限(只允许作品所有者下载)
|
||||
UserWork work = userWorkService.getWorkForDownload(workId, username);
|
||||
String resultUrl = work.getResultUrl();
|
||||
|
||||
// 记录下载次数
|
||||
userWorkService.incrementDownloadCount(workId);
|
||||
|
||||
// 判断resultUrl类型
|
||||
if (resultUrl.startsWith("data:")) {
|
||||
// Base64编码的数据
|
||||
return handleBase64Download(work, resultUrl);
|
||||
} else if (resultUrl.startsWith("http://") || resultUrl.startsWith("https://")) {
|
||||
// 外部URL(如COS)
|
||||
if (download) {
|
||||
// 直接下载模式:代理下载
|
||||
return handleUrlDownload(work, resultUrl);
|
||||
} else {
|
||||
// 重定向模式(默认):重定向到COS URL
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header(HttpHeaders.LOCATION, resultUrl)
|
||||
.build();
|
||||
}
|
||||
} else {
|
||||
logger.error("不支持的resultUrl格式: {}", resultUrl);
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", "不支持的资源格式");
|
||||
return ResponseEntity.status(500).body(errorResponse);
|
||||
}
|
||||
|
||||
} catch (RuntimeException e) {
|
||||
logger.error("下载作品文件失败: workId={}", workId, e);
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", e.getMessage());
|
||||
return ResponseEntity.status(400).body(errorResponse);
|
||||
} catch (Exception e) {
|
||||
logger.error("下载作品文件失败: workId={}", workId, e);
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", "下载失败:" + e.getMessage());
|
||||
return ResponseEntity.status(500).body(errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理Base64数据下载
|
||||
*/
|
||||
private ResponseEntity<InputStreamResource> handleBase64Download(UserWork work, String dataUrl) {
|
||||
try {
|
||||
// 解析data URL格式:data:image/png;base64,xxxxx
|
||||
String[] parts = dataUrl.split(",", 2);
|
||||
if (parts.length != 2) {
|
||||
throw new RuntimeException("无效的Base64数据格式");
|
||||
}
|
||||
|
||||
// 提取MIME类型
|
||||
String mimeType = "application/octet-stream";
|
||||
if (parts[0].contains(":") && parts[0].contains(";")) {
|
||||
mimeType = parts[0].substring(parts[0].indexOf(":") + 1, parts[0].indexOf(";"));
|
||||
}
|
||||
|
||||
// 解码Base64数据
|
||||
byte[] fileBytes = Base64.getDecoder().decode(parts[1]);
|
||||
InputStream inputStream = new ByteArrayInputStream(fileBytes);
|
||||
|
||||
// 生成文件名
|
||||
String filename = generateFilename(work, mimeType);
|
||||
|
||||
// 设置响应头
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType(mimeType));
|
||||
headers.setContentLength(fileBytes.length);
|
||||
headers.setContentDispositionFormData("attachment",
|
||||
URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
|
||||
logger.info("下载Base64作品: workId={}, filename={}, size={}KB",
|
||||
work.getId(), filename, fileBytes.length / 1024);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.body(new InputStreamResource(inputStream));
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理Base64下载失败: workId={}", work.getId(), e);
|
||||
throw new RuntimeException("处理下载数据失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理URL下载(代理下载)
|
||||
*/
|
||||
private ResponseEntity<InputStreamResource> handleUrlDownload(UserWork work, String fileUrl) {
|
||||
try {
|
||||
logger.info("开始代理下载: workId={}, url={}", work.getId(), fileUrl);
|
||||
|
||||
// 打开连接
|
||||
URL url = new URL(fileUrl);
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(30000); // 30秒连接超时
|
||||
connection.setReadTimeout(300000); // 5分钟读取超时
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw new RuntimeException("下载失败,HTTP状态码:" + responseCode);
|
||||
}
|
||||
|
||||
// 获取内容信息
|
||||
String contentType = connection.getContentType();
|
||||
if (contentType == null) {
|
||||
contentType = "application/octet-stream";
|
||||
}
|
||||
long contentLength = connection.getContentLengthLong();
|
||||
|
||||
// 生成文件名
|
||||
String filename = generateFilename(work, contentType);
|
||||
|
||||
// 设置响应头
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType(contentType));
|
||||
if (contentLength > 0) {
|
||||
headers.setContentLength(contentLength);
|
||||
}
|
||||
headers.setContentDispositionFormData("attachment",
|
||||
URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"));
|
||||
|
||||
logger.info("下载URL作品: workId={}, filename={}, contentType={}, size={}MB",
|
||||
work.getId(), filename, contentType, contentLength / (1024 * 1024));
|
||||
|
||||
// 返回文件流
|
||||
InputStream inputStream = connection.getInputStream();
|
||||
return ResponseEntity.ok()
|
||||
.headers(headers)
|
||||
.body(new InputStreamResource(inputStream));
|
||||
|
||||
} catch (IOException e) {
|
||||
logger.error("处理URL下载失败: workId={}, url={}", work.getId(), fileUrl, e);
|
||||
throw new RuntimeException("下载文件失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下载文件名
|
||||
*/
|
||||
private String generateFilename(UserWork work, String mimeType) {
|
||||
// 使用作品标题作为文件名基础
|
||||
String basename = work.getTitle();
|
||||
if (basename == null || basename.isEmpty()) {
|
||||
basename = "work_" + work.getId();
|
||||
}
|
||||
|
||||
// 清理文件名中的非法字符
|
||||
basename = basename.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
|
||||
// 根据MIME类型确定扩展名
|
||||
String extension = getExtensionFromMimeType(mimeType);
|
||||
|
||||
// 添加工作类型信息
|
||||
String typePrefix = "";
|
||||
if (work.getWorkType() != null) {
|
||||
switch (work.getWorkType()) {
|
||||
case TEXT_TO_VIDEO:
|
||||
typePrefix = "文生视频_";
|
||||
break;
|
||||
case IMAGE_TO_VIDEO:
|
||||
typePrefix = "图生视频_";
|
||||
break;
|
||||
case STORYBOARD_VIDEO:
|
||||
typePrefix = "分镜视频_";
|
||||
break;
|
||||
case STORYBOARD_IMAGE:
|
||||
typePrefix = "分镜图_";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return typePrefix + basename + extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据MIME类型获取文件扩展名
|
||||
*/
|
||||
private String getExtensionFromMimeType(String mimeType) {
|
||||
if (mimeType == null) {
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
// 视频类型
|
||||
if (mimeType.contains("video/mp4")) return ".mp4";
|
||||
if (mimeType.contains("video/webm")) return ".webm";
|
||||
if (mimeType.contains("video/avi")) return ".avi";
|
||||
if (mimeType.contains("video/quicktime")) return ".mov";
|
||||
|
||||
// 图片类型
|
||||
if (mimeType.contains("image/png")) return ".png";
|
||||
if (mimeType.contains("image/jpeg") || mimeType.contains("image/jpg")) return ".jpg";
|
||||
if (mimeType.contains("image/gif")) return ".gif";
|
||||
if (mimeType.contains("image/webp")) return ".webp";
|
||||
|
||||
// 默认
|
||||
if (mimeType.contains("video")) return ".mp4";
|
||||
if (mimeType.contains("image")) return ".png";
|
||||
|
||||
return ".bin";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开作品列表
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.example.demo.interceptor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.service.UserService;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* 用户活跃时间拦截器
|
||||
* 每次用户请求时更新其最后活跃时间,用于统计在线用户数
|
||||
*/
|
||||
@Component
|
||||
public class UserActivityInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(UserActivityInterceptor.class);
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
try {
|
||||
// 获取当前认证用户
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (authentication != null && authentication.isAuthenticated()
|
||||
&& !"anonymousUser".equals(authentication.getPrincipal())) {
|
||||
|
||||
String username = authentication.getName();
|
||||
|
||||
// 异步更新用户活跃时间,避免阻塞请求
|
||||
updateUserActiveTimeAsync(username);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 不因活跃时间更新失败而影响正常请求
|
||||
logger.debug("更新用户活跃时间失败: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步更新用户活跃时间
|
||||
*/
|
||||
private void updateUserActiveTimeAsync(String username) {
|
||||
try {
|
||||
User user = userService.findByUsernameOrNull(username);
|
||||
if (user != null) {
|
||||
user.setLastActiveTime(LocalDateTime.now());
|
||||
userService.save(user);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.debug("异步更新用户活跃时间失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ public class StoryboardVideoTask {
|
||||
@Column(nullable = false)
|
||||
private boolean hdMode; // 是否高清模式
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer duration; // 视频时长(秒):5, 10, 15
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private TaskStatus status;
|
||||
@@ -87,16 +90,22 @@ public class StoryboardVideoTask {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public StoryboardVideoTask(String username, String prompt, String aspectRatio, boolean hdMode) {
|
||||
public StoryboardVideoTask(String username, String prompt, String aspectRatio, boolean hdMode, Integer duration) {
|
||||
this();
|
||||
this.username = username;
|
||||
this.prompt = prompt;
|
||||
this.aspectRatio = aspectRatio;
|
||||
this.hdMode = hdMode;
|
||||
this.duration = duration != null ? duration : 10; // 默认10秒
|
||||
|
||||
// 计算消耗积分
|
||||
this.costPoints = calculateCost();
|
||||
}
|
||||
|
||||
// 保留旧的构造函数以保持向后兼容
|
||||
public StoryboardVideoTask(String username, String prompt, String aspectRatio, boolean hdMode) {
|
||||
this(username, prompt, aspectRatio, hdMode, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算任务消耗积分
|
||||
@@ -144,6 +153,9 @@ public class StoryboardVideoTask {
|
||||
public void setAspectRatio(String aspectRatio) { this.aspectRatio = aspectRatio; }
|
||||
public boolean isHdMode() { return hdMode; }
|
||||
public void setHdMode(boolean hdMode) { this.hdMode = hdMode; }
|
||||
public boolean getHdMode() { return hdMode; } // 添加getHdMode方法以支持Boolean类型调用
|
||||
public Integer getDuration() { return duration; }
|
||||
public void setDuration(Integer duration) { this.duration = duration; }
|
||||
public TaskStatus getStatus() { return status; }
|
||||
public void setStatus(TaskStatus status) { this.status = status; }
|
||||
public int getProgress() { return progress; }
|
||||
|
||||
@@ -63,7 +63,7 @@ public class TaskStatus {
|
||||
@Column(name = "progress")
|
||||
private Integer progress = 0;
|
||||
|
||||
@Column(name = "result_url", columnDefinition = "TEXT")
|
||||
@Column(name = "result_url", columnDefinition = "LONGTEXT")
|
||||
private String resultUrl;
|
||||
|
||||
@Column(name = "error_message", columnDefinition = "TEXT")
|
||||
|
||||
@@ -74,6 +74,9 @@ public class User {
|
||||
@Column(name = "last_login_at")
|
||||
private LocalDateTime lastLoginAt;
|
||||
|
||||
@Column(name = "last_active_time")
|
||||
private LocalDateTime lastActiveTime;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -245,6 +248,14 @@ public class User {
|
||||
this.lastLoginAt = lastLoginAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getLastActiveTime() {
|
||||
return lastActiveTime;
|
||||
}
|
||||
|
||||
public void setLastActiveTime(LocalDateTime lastActiveTime) {
|
||||
this.lastActiveTime = lastActiveTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package com.example.demo.repository;
|
||||
|
||||
import com.example.demo.model.PointsFreezeRecord;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -22,6 +24,14 @@ public interface PointsFreezeRecordRepository extends JpaRepository<PointsFreeze
|
||||
*/
|
||||
Optional<PointsFreezeRecord> findByTaskId(String taskId);
|
||||
|
||||
/**
|
||||
* 根据任务ID查找冻结记录(带悲观写锁,用于防止并发重复扣除)
|
||||
* 使用悲观锁确保在高并发场景下不会重复扣除积分
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT pfr FROM PointsFreezeRecord pfr WHERE pfr.taskId = :taskId")
|
||||
Optional<PointsFreezeRecord> findByTaskIdWithLock(@Param("taskId") String taskId);
|
||||
|
||||
/**
|
||||
* 根据用户名查找冻结记录
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,11 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
boolean existsByEmail(String email);
|
||||
boolean existsByPhone(String phone);
|
||||
long countByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);
|
||||
|
||||
/**
|
||||
* 统计指定时间之后活跃的用户数量(用于在线用户统计)
|
||||
*/
|
||||
long countByLastActiveTimeAfter(LocalDateTime afterTime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status != 'DELETED' ORDER BY uw.createdAt DESC")
|
||||
Page<UserWork> findByUsernameOrderByCreatedAtDesc(@Param("username") String username, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 根据用户名查找作品(只返回有resultUrl的作品)
|
||||
* 用于创作页面历史记录,过滤掉没有resultUrl的作品
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status != 'DELETED' AND uw.resultUrl IS NOT NULL AND uw.resultUrl != '' ORDER BY uw.createdAt DESC")
|
||||
Page<UserWork> findByUsernameWithResultUrlOrderByCreatedAtDesc(@Param("username") String username, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 根据用户名和状态查找作品
|
||||
@@ -37,6 +44,18 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND (uw.status = 'PROCESSING' OR uw.status = 'PENDING') ORDER BY uw.createdAt DESC")
|
||||
List<UserWork> findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(@Param("username") String username);
|
||||
|
||||
/**
|
||||
* 查找所有PROCESSING状态的作品(用于系统重启清理)
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.status = 'PROCESSING'")
|
||||
List<UserWork> findAllByProcessingStatus();
|
||||
|
||||
/**
|
||||
* 查找所有FAILED状态的作品(用于系统重启修复)
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.status = 'FAILED' ORDER BY uw.updatedAt DESC")
|
||||
List<UserWork> findAllByFailedStatus();
|
||||
|
||||
/**
|
||||
* 根据任务ID查找作品
|
||||
|
||||
@@ -22,10 +22,10 @@ public class UserDetailsServiceImpl implements UserDetailsService {
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
// 支持通过用户名或邮箱登录:先按用户名查找,找不到时按邮箱查找
|
||||
java.util.Optional<com.example.demo.model.User> opt = userRepository.findByUsername(username);
|
||||
// 使用邮箱作为登录账户:优先按邮箱查找,如果找不到再尝试用户名(兼容旧数据)
|
||||
java.util.Optional<com.example.demo.model.User> opt = userRepository.findByEmail(username);
|
||||
if (opt.isEmpty()) {
|
||||
opt = userRepository.findByEmail(username);
|
||||
opt = userRepository.findByUsername(username);
|
||||
}
|
||||
|
||||
User user = opt.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
|
||||
|
||||
253
demo/src/main/java/com/example/demo/service/CosService.java
Normal file
253
demo/src/main/java/com/example/demo/service/CosService.java
Normal file
@@ -0,0 +1,253 @@
|
||||
package com.example.demo.service;
|
||||
|
||||
import com.qcloud.cos.COSClient;
|
||||
import com.qcloud.cos.model.ObjectMetadata;
|
||||
import com.qcloud.cos.model.PutObjectRequest;
|
||||
import com.qcloud.cos.model.PutObjectResult;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 腾讯云COS对象存储服务
|
||||
*/
|
||||
@Service
|
||||
public class CosService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CosService.class);
|
||||
|
||||
@Autowired(required = false)
|
||||
private COSClient cosClient;
|
||||
|
||||
@Autowired
|
||||
private com.example.demo.config.CosConfig cosConfig;
|
||||
|
||||
/**
|
||||
* 检查COS是否已启用
|
||||
*/
|
||||
public boolean isEnabled() {
|
||||
return cosConfig.isEnabled() && cosClient != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL下载视频并上传到COS
|
||||
*
|
||||
* @param videoUrl 视频URL
|
||||
* @param filename 文件名(可选,如果为null则自动生成)
|
||||
* @return COS中的文件URL,如果失败则返回null
|
||||
*/
|
||||
public String uploadVideoFromUrl(String videoUrl, String filename) {
|
||||
if (!isEnabled()) {
|
||||
logger.warn("COS未启用,跳过上传");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("开始从URL下载视频: {}", videoUrl);
|
||||
|
||||
// 下载视频到内存(适用于小于100MB的视频)
|
||||
URL url = new URL(videoUrl);
|
||||
URLConnection conn = url.openConnection();
|
||||
conn.setConnectTimeout(30000); // 30秒连接超时
|
||||
conn.setReadTimeout(120000); // 2分钟读取超时
|
||||
|
||||
// 获取内容类型和长度
|
||||
String contentType = conn.getContentType();
|
||||
int contentLength = conn.getContentLength();
|
||||
|
||||
logger.info("视频信息 - 类型: {}, 大小: {} bytes", contentType, contentLength);
|
||||
|
||||
// 检查文件大小(最大200MB)
|
||||
if (contentLength > 200 * 1024 * 1024) {
|
||||
logger.error("视频文件过大: {} MB,超过200MB限制", contentLength / (1024 * 1024));
|
||||
return null;
|
||||
}
|
||||
|
||||
// 读取视频内容
|
||||
byte[] videoBytes;
|
||||
try (InputStream inputStream = conn.getInputStream()) {
|
||||
videoBytes = inputStream.readAllBytes();
|
||||
}
|
||||
|
||||
logger.info("视频下载完成,大小: {} KB", videoBytes.length / 1024);
|
||||
|
||||
// 生成文件名
|
||||
if (filename == null || filename.isEmpty()) {
|
||||
String extension = getFileExtension(videoUrl, contentType);
|
||||
filename = generateFilename(extension);
|
||||
}
|
||||
|
||||
// 上传到COS
|
||||
return uploadBytes(videoBytes, filename, contentType);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("从URL上传视频到COS失败: {}", videoUrl, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传字节数组到COS
|
||||
*
|
||||
* @param bytes 文件字节数组
|
||||
* @param filename 文件名
|
||||
* @param contentType 内容类型
|
||||
* @return COS文件URL
|
||||
*/
|
||||
public String uploadBytes(byte[] bytes, String filename, String contentType) {
|
||||
if (!isEnabled()) {
|
||||
logger.warn("COS未启用,跳过上传");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 构建对象键(带日期目录结构)
|
||||
String key = buildObjectKey(filename);
|
||||
|
||||
// 设置元数据
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
metadata.setContentLength(bytes.length);
|
||||
if (contentType != null && !contentType.isEmpty()) {
|
||||
metadata.setContentType(contentType);
|
||||
} else {
|
||||
metadata.setContentType("video/mp4"); // 默认类型
|
||||
}
|
||||
|
||||
// 创建上传请求
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(
|
||||
cosConfig.getBucketName(),
|
||||
key,
|
||||
new ByteArrayInputStream(bytes),
|
||||
metadata
|
||||
);
|
||||
|
||||
// 执行上传
|
||||
logger.info("开始上传到COS: bucket={}, key={}", cosConfig.getBucketName(), key);
|
||||
PutObjectResult result = cosClient.putObject(putObjectRequest);
|
||||
logger.info("上传成功,ETag: {}", result.getETag());
|
||||
|
||||
// 生成访问URL
|
||||
String fileUrl = getPublicUrl(key);
|
||||
logger.info("文件URL: {}", fileUrl);
|
||||
|
||||
return fileUrl;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("上传文件到COS失败: {}", filename, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传本地文件到COS
|
||||
*
|
||||
* @param file 本地文件
|
||||
* @param filename 目标文件名
|
||||
* @return COS文件URL
|
||||
*/
|
||||
public String uploadFile(File file, String filename) {
|
||||
if (!isEnabled()) {
|
||||
logger.warn("COS未启用,跳过上传");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String key = buildObjectKey(filename);
|
||||
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(
|
||||
cosConfig.getBucketName(),
|
||||
key,
|
||||
file
|
||||
);
|
||||
|
||||
logger.info("开始上传文件到COS: {}", file.getAbsolutePath());
|
||||
PutObjectResult result = cosClient.putObject(putObjectRequest);
|
||||
logger.info("上传成功,ETag: {}", result.getETag());
|
||||
|
||||
return getPublicUrl(key);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("上传文件到COS失败: {}", file.getName(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成对象键(带日期目录结构)
|
||||
* 例如: videos/2025/01/14/uuid.mp4
|
||||
*/
|
||||
private String buildObjectKey(String filename) {
|
||||
LocalDate now = LocalDate.now();
|
||||
String datePath = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||
return "videos/" + datePath + "/" + filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一文件名
|
||||
*/
|
||||
private String generateFilename(String extension) {
|
||||
String uuid = UUID.randomUUID().toString().replace("-", "");
|
||||
return uuid + extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
*/
|
||||
private String getFileExtension(String url, String contentType) {
|
||||
// 优先从URL中提取扩展名
|
||||
if (url != null) {
|
||||
int lastDot = url.lastIndexOf('.');
|
||||
int lastSlash = url.lastIndexOf('/');
|
||||
int lastQuestion = url.lastIndexOf('?');
|
||||
|
||||
if (lastDot > lastSlash && lastDot > 0) {
|
||||
String ext = url.substring(lastDot, lastQuestion > lastDot ? lastQuestion : url.length());
|
||||
if (ext.length() <= 5) { // 合理的扩展名长度
|
||||
return ext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从Content-Type推断扩展名
|
||||
if (contentType != null) {
|
||||
if (contentType.contains("mp4")) return ".mp4";
|
||||
if (contentType.contains("webm")) return ".webm";
|
||||
if (contentType.contains("avi")) return ".avi";
|
||||
if (contentType.contains("mov")) return ".mov";
|
||||
}
|
||||
|
||||
// 默认扩展名
|
||||
return ".mp4";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成公共访问URL
|
||||
*/
|
||||
private String getPublicUrl(String key) {
|
||||
// COS公共读URL格式:https://{bucket-name}.cos.{region}.myqcloud.com/{key}
|
||||
return String.format("https://%s.cos.%s.myqcloud.com/%s",
|
||||
cosConfig.getBucketName(),
|
||||
cosConfig.getRegion(),
|
||||
key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭COS客户端
|
||||
*/
|
||||
public void shutdown() {
|
||||
if (cosClient != null) {
|
||||
cosClient.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,9 @@ public class ImageToVideoService {
|
||||
|
||||
@Autowired
|
||||
private TaskQueueService taskQueueService;
|
||||
|
||||
@Autowired
|
||||
private UserWorkService userWorkService;
|
||||
|
||||
@Value("${app.upload.path:/uploads}")
|
||||
private String uploadPath;
|
||||
@@ -86,6 +89,13 @@ public class ImageToVideoService {
|
||||
// 添加任务到队列
|
||||
taskQueueService.addImageToVideoTask(username, taskId);
|
||||
|
||||
// 创建PROCESSING状态的UserWork,以便用户刷新页面后能恢复任务
|
||||
try {
|
||||
userWorkService.createProcessingImageToVideoWork(task);
|
||||
} catch (Exception e) {
|
||||
logger.warn("创建PROCESSING状态作品失败(不影响任务执行): {}", taskId, e);
|
||||
}
|
||||
|
||||
logger.info("创建图生视频任务成功: taskId={}, username={}", taskId, username);
|
||||
return task;
|
||||
|
||||
|
||||
@@ -441,8 +441,17 @@ public class PaymentService {
|
||||
logger.info("✅ 用户 {} 支付 {} 元,成功获得 {} 积分",
|
||||
payment.getUser().getUsername(), amount, pointsToAdd);
|
||||
} else {
|
||||
logger.warn("⚠️ 用户 {} 支付 {} 元,但未获得积分(描述: {})",
|
||||
payment.getUser().getUsername(), amount, description);
|
||||
// 如果金额不在套餐范围内,给予基础积分(1元=1积分)
|
||||
// 这样可以避免用户支付后没有任何积分的情况
|
||||
int basePoints = amount.intValue(); // 1元=1积分
|
||||
if (basePoints > 0) {
|
||||
userService.addPoints(payment.getUser().getId(), basePoints);
|
||||
logger.info("✅ 用户 {} 支付 {} 元(非套餐金额),获得基础积分 {} 积分(按1元=1积分计算)",
|
||||
payment.getUser().getUsername(), amount, basePoints);
|
||||
} else {
|
||||
logger.warn("⚠️ 用户 {} 支付 {} 元,金额过小未获得积分(描述: {})",
|
||||
payment.getUser().getUsername(), amount, description);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -114,11 +114,7 @@ public class RealAIService {
|
||||
long requestBodySizeKB = requestBodySize / 1024;
|
||||
|
||||
if (retryCount > 0) {
|
||||
logger.info("分镜视频请求重试 (第{}次,共{}次): URL={}, 请求体大小={}KB ({}MB), model={}, 图片数量={}",
|
||||
retryCount + 1, maxRetries, url, requestBodySizeKB, requestBodySizeMB, modelName, validatedImages.size());
|
||||
} else {
|
||||
logger.info("分镜视频请求: URL={}, 请求体大小={}KB ({}MB, {}字节), model={}, aspectRatio={}, duration={}, 图片数量={}",
|
||||
url, requestBodySizeKB, requestBodySizeMB, requestBodySize, modelName, aspectRatio, duration, validatedImages.size());
|
||||
logger.info("分镜视频请求重试 (第{}次): model={}", retryCount + 1, modelName);
|
||||
}
|
||||
|
||||
HttpResponse<String> response = Unirest.post(url)
|
||||
@@ -129,10 +125,7 @@ public class RealAIService {
|
||||
.body(requestBody)
|
||||
.asString();
|
||||
|
||||
logger.info("API响应状态: {}", response.getStatus());
|
||||
String responseBodyStr = response.getBody();
|
||||
logger.info("API响应内容(前500字符): {}", responseBodyStr != null && responseBodyStr.length() > 500 ?
|
||||
responseBodyStr.substring(0, 500) : responseBodyStr);
|
||||
|
||||
if (response.getStatus() == 200 && responseBodyStr != null) {
|
||||
String trimmedResponse = responseBodyStr.trim();
|
||||
@@ -218,15 +211,13 @@ public class RealAIService {
|
||||
}
|
||||
try {
|
||||
Base64.getDecoder().decode(base64DataForValidation);
|
||||
logger.debug("Base64数据格式验证通过");
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("Base64数据格式错误: {}", e.getMessage());
|
||||
throw new RuntimeException("图片数据格式错误");
|
||||
}
|
||||
|
||||
// 根据分辨率选择size参数(用于日志记录)
|
||||
// 根据分辨率选择size参数
|
||||
String size = convertAspectRatioToSize(aspectRatio, hdMode);
|
||||
logger.debug("选择的尺寸参数: {}", size);
|
||||
|
||||
// 使用 Sora2 端点(与文生视频使用相同的端点,参考 Comfly.py 6297 行)
|
||||
String url = aiApiBaseUrl + "/v2/videos/generations";
|
||||
@@ -260,15 +251,7 @@ public class RealAIService {
|
||||
long requestBodySizeKB = requestBodySize / 1024;
|
||||
|
||||
if (retryCount > 0) {
|
||||
logger.info("图生视频请求重试 (第{}次,共{}次): URL={}, 请求体大小={}KB ({}MB), model={}",
|
||||
retryCount + 1, maxRetries, url, requestBodySizeKB, requestBodySizeMB, modelName);
|
||||
} else {
|
||||
logger.info("图生视频请求: URL={}, 请求体大小={}KB ({}MB, {}字节), model={}, aspectRatio={}, duration={}",
|
||||
url, requestBodySizeKB, requestBodySizeMB, requestBodySize, modelName, aspectRatio, duration);
|
||||
// 如果请求体太大,只记录前500字符
|
||||
if (requestBody.length() > 500) {
|
||||
logger.debug("请求体前500字符: {}", requestBody.substring(0, 500));
|
||||
}
|
||||
logger.info("图生视频请求重试 (第{}次): model={}", retryCount + 1, modelName);
|
||||
}
|
||||
|
||||
// 使用流式传输,避免一次性加载整个请求体到内存
|
||||
@@ -412,12 +395,7 @@ public class RealAIService {
|
||||
// 根据参数选择可用的模型
|
||||
String modelName = selectAvailableTextToVideoModel(aspectRatio, duration, hdMode);
|
||||
|
||||
// 添加调试日志
|
||||
logger.info("提交文生视频任务请求: model={}, prompt={}, aspectRatio={}, duration={}, hd={}",
|
||||
modelName, prompt, aspectRatio, duration, hdMode);
|
||||
logger.info("选择的模型: {}", modelName);
|
||||
logger.info("API端点: {}", aiApiBaseUrl + "/v2/videos/generations");
|
||||
logger.info("使用API密钥: {}", aiApiKey.substring(0, Math.min(10, aiApiKey.length())) + "...");
|
||||
// 提交文生视频任务
|
||||
|
||||
String url = aiApiBaseUrl + "/v2/videos/generations";
|
||||
|
||||
@@ -430,7 +408,9 @@ public class RealAIService {
|
||||
requestBodyMap.put("hd", hdMode);
|
||||
|
||||
String requestBody = objectMapper.writeValueAsString(requestBodyMap);
|
||||
logger.info("请求体: {}", requestBody);
|
||||
|
||||
logger.info("提交文生视频任务 - URL: {}", url);
|
||||
logger.info("提交文生视频任务 - 请求体: {}", requestBody);
|
||||
|
||||
HttpResponse<String> response = Unirest.post(url)
|
||||
.header("Authorization", "Bearer " + aiApiKey)
|
||||
@@ -481,7 +461,15 @@ public class RealAIService {
|
||||
}
|
||||
} else {
|
||||
logger.error("文生视频任务提交失败,HTTP状态: {}", response.getStatus());
|
||||
throw new RuntimeException("任务提交失败,HTTP状态: " + response.getStatus());
|
||||
logger.error("请求URL: {}", url);
|
||||
logger.error("请求体: {}", requestBody);
|
||||
logger.error("响应体: {}", responseBodyStr);
|
||||
logger.error("可能的原因:");
|
||||
logger.error("1. API服务内部错误(500错误)");
|
||||
logger.error("2. 请求参数格式不正确");
|
||||
logger.error("3. API密钥权限不足");
|
||||
logger.error("4. API服务暂时不可用");
|
||||
throw new RuntimeException("任务提交失败,HTTP状态: " + response.getStatus() + ", 响应: " + responseBodyStr);
|
||||
}
|
||||
|
||||
} catch (UnirestException e) {
|
||||
@@ -500,7 +488,6 @@ public class RealAIService {
|
||||
public Map<String, Object> getTaskStatus(String taskId) {
|
||||
try {
|
||||
String url = aiApiBaseUrl + "/v2/videos/generations/" + taskId;
|
||||
logger.debug("查询任务状态: {}", url);
|
||||
HttpResponse<String> response = Unirest.get(url)
|
||||
.header("Authorization", "Bearer " + aiApiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
@@ -508,15 +495,10 @@ public class RealAIService {
|
||||
|
||||
if (response.getStatus() == 200 && response.getBody() != null) {
|
||||
String responseBodyStr = response.getBody();
|
||||
logger.info("查询任务状态API响应(前500字符): {}",
|
||||
responseBodyStr != null && responseBodyStr.length() > 500 ?
|
||||
responseBodyStr.substring(0, 500) : responseBodyStr);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> responseBody = objectMapper.readValue(responseBodyStr, Map.class);
|
||||
|
||||
logger.info("解析后的任务状态响应: {}", responseBody);
|
||||
|
||||
// 参考Comfly项目,响应格式为 {"status": "SUCCESS", "data": {"output": "video_url"}, ...}
|
||||
// 转换为统一的响应格式
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
@@ -609,7 +591,6 @@ public class RealAIService {
|
||||
public Map<String, Object> getAvailableModels() {
|
||||
// 暂时不调用模型列表API,直接返回null让调用方使用默认逻辑
|
||||
// 因为Comfly项目直接使用sora-2或sora-2-pro,不需要查询模型列表
|
||||
logger.debug("跳过模型列表查询,直接使用默认模型选择逻辑");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -725,7 +706,6 @@ public class RealAIService {
|
||||
validatedImages.add(validatedImg);
|
||||
}
|
||||
|
||||
logger.debug("验证图片格式完成,原始数量: {}, 验证后数量: {}", images.size(), validatedImages.size());
|
||||
return validatedImages;
|
||||
}
|
||||
|
||||
@@ -787,16 +767,11 @@ public class RealAIService {
|
||||
|
||||
String requestBodyJson = objectMapper.writeValueAsString(requestBody);
|
||||
|
||||
logger.info("文生图请求URL: {}", url);
|
||||
logger.info("文生图请求体: {}", requestBodyJson);
|
||||
logger.info("使用的API密钥: {}", aiImageApiKey.substring(0, Math.min(10, aiImageApiKey.length())) + "...");
|
||||
|
||||
HttpResponse<String> response = Unirest.post(url)
|
||||
.header("Authorization", "Bearer " + aiImageApiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(requestBodyJson)
|
||||
.asString();
|
||||
|
||||
logger.info("文生图API响应状态: {}", response.getStatus());
|
||||
String responseBodyStr = response.getBody();
|
||||
logger.info("文生图API响应内容(前500字符): {}", responseBodyStr != null && responseBodyStr.length() > 500 ?
|
||||
@@ -941,8 +916,6 @@ public class RealAIService {
|
||||
|
||||
String requestBodyJson = objectMapper.writeValueAsString(requestBody);
|
||||
|
||||
logger.debug("提示词优化请求URL: {}", url);
|
||||
|
||||
// 设置超时时间(30秒)
|
||||
HttpResponse<String> response = Unirest.post(url)
|
||||
.header("Authorization", "Bearer " + aiApiKey)
|
||||
|
||||
@@ -87,7 +87,7 @@ public class StoryboardVideoService {
|
||||
* 事务提交后,异步方法在事务外执行
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public StoryboardVideoTask createTask(String username, String prompt, String aspectRatio, boolean hdMode, String imageUrl) {
|
||||
public StoryboardVideoTask createTask(String username, String prompt, String aspectRatio, boolean hdMode, String imageUrl, Integer duration) {
|
||||
// 验证参数
|
||||
if (username == null || username.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("用户名不能为空");
|
||||
@@ -100,7 +100,7 @@ public class StoryboardVideoService {
|
||||
String taskId = generateTaskId();
|
||||
|
||||
// 创建任务
|
||||
StoryboardVideoTask task = new StoryboardVideoTask(username, prompt.trim(), aspectRatio, hdMode);
|
||||
StoryboardVideoTask task = new StoryboardVideoTask(username, prompt.trim(), aspectRatio, hdMode, duration);
|
||||
task.setTaskId(taskId);
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.PENDING);
|
||||
task.setProgress(0);
|
||||
@@ -233,12 +233,7 @@ public class StoryboardVideoService {
|
||||
if (retryCount > maxRetriesPerImage) {
|
||||
logger.warn("生成第{}张分镜图失败,已重试{}次: {}, 继续生成其他图片",
|
||||
i + 1, maxRetriesPerImage, e.getMessage());
|
||||
// 记录详细错误信息(仅在debug级别)
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("生成第{}张分镜图失败详情", i + 1, e);
|
||||
}
|
||||
} else {
|
||||
logger.debug("生成第{}张分镜图失败,将重试: {}", i + 1, e.getMessage());
|
||||
// 已达到最大重试次数
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,15 +253,22 @@ public class StoryboardVideoService {
|
||||
throw new RuntimeException("未能从API响应中提取任何图片URL");
|
||||
}
|
||||
|
||||
// 必须生成6张图片才能继续,否则抛出异常
|
||||
if (imageUrls.size() < DEFAULT_STORYBOARD_IMAGES) {
|
||||
String errorMsg = String.format("只生成了%d张图片,少于预期的%d张,无法拼接分镜图",
|
||||
imageUrls.size(), DEFAULT_STORYBOARD_IMAGES);
|
||||
// 检查生成的图片数量(允许4-6张图片,提供更灵活的处理)
|
||||
final int MIN_STORYBOARD_IMAGES = 4; // 最少4张图片
|
||||
if (imageUrls.size() < MIN_STORYBOARD_IMAGES) {
|
||||
String errorMsg = String.format("只生成了%d张图片,少于最低要求的%d张,无法拼接分镜图。建议重试或检查API配置。",
|
||||
imageUrls.size(), MIN_STORYBOARD_IMAGES);
|
||||
logger.error(errorMsg);
|
||||
throw new RuntimeException(errorMsg);
|
||||
}
|
||||
|
||||
// 确保正好是6张图片(如果多于6张,只取前6张)
|
||||
// 如果图片数量不足6张但满足最低要求,记录警告但继续处理
|
||||
if (imageUrls.size() < DEFAULT_STORYBOARD_IMAGES) {
|
||||
logger.warn("生成了{}张图片,少于预期的{}张,但满足最低要求,将继续拼接",
|
||||
imageUrls.size(), DEFAULT_STORYBOARD_IMAGES);
|
||||
}
|
||||
|
||||
// 确保不超过6张图片(如果多于6张,只取前6张)
|
||||
if (imageUrls.size() > DEFAULT_STORYBOARD_IMAGES) {
|
||||
logger.warn("生成了{}张图片,多于预期的{}张,只取前{}张进行拼接",
|
||||
imageUrls.size(), DEFAULT_STORYBOARD_IMAGES, DEFAULT_STORYBOARD_IMAGES);
|
||||
@@ -278,21 +280,19 @@ public class StoryboardVideoService {
|
||||
|
||||
// 验证所有图片都是Base64格式(带data URI前缀)
|
||||
// 参考sora2实现:确保所有图片格式一致
|
||||
long validateStartTime = System.currentTimeMillis();
|
||||
List<String> validatedImages = validateAndNormalizeImages(imageUrls);
|
||||
long validateTime = System.currentTimeMillis() - validateStartTime;
|
||||
logger.debug("图片格式验证完成,耗时: {}ms", validateTime);
|
||||
|
||||
if (validatedImages.size() < DEFAULT_STORYBOARD_IMAGES) {
|
||||
String errorMsg = String.format("验证后只有%d张图片,少于预期的%d张,无法拼接分镜图",
|
||||
validatedImages.size(), DEFAULT_STORYBOARD_IMAGES);
|
||||
// 验证后的图片数量也要满足最低要求
|
||||
if (validatedImages.size() < MIN_STORYBOARD_IMAGES) {
|
||||
String errorMsg = String.format("验证后只有%d张图片,少于最低要求的%d张,无法拼接分镜图",
|
||||
validatedImages.size(), MIN_STORYBOARD_IMAGES);
|
||||
logger.error(errorMsg);
|
||||
throw new RuntimeException(errorMsg);
|
||||
}
|
||||
|
||||
logger.info("开始拼接{}张图片成分镜图网格...", validatedImages.size());
|
||||
|
||||
// 拼接多张图片成网格(此时确保有6张图片)
|
||||
// 拼接多张图片成网格(支持4-6张图片的灵活拼接)
|
||||
// 使用验证后的图片列表(都是Base64格式)
|
||||
long mergeStartTime = System.currentTimeMillis();
|
||||
String mergedImageUrl = imageGridService.mergeImagesToGrid(validatedImages, 0); // 0表示自动计算列数
|
||||
@@ -310,11 +310,6 @@ public class StoryboardVideoService {
|
||||
String storyboardImagesJson = null;
|
||||
try {
|
||||
storyboardImagesJson = objectMapper.writeValueAsString(validatedImages);
|
||||
logger.debug("分镜图片JSON长度: {} 字符", storyboardImagesJson.length());
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("分镜图片JSON前500字符: {}",
|
||||
storyboardImagesJson.length() > 500 ? storyboardImagesJson.substring(0, 500) + "..." : storyboardImagesJson);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("转换分镜图片为JSON失败: {}", taskId, e);
|
||||
// 如果转换失败,继续使用网格图
|
||||
@@ -327,8 +322,8 @@ public class StoryboardVideoService {
|
||||
long saveTime = System.currentTimeMillis() - saveStartTime;
|
||||
|
||||
long totalElapsed = System.currentTimeMillis() - startTime;
|
||||
logger.info("✓ 分镜图生成完成: taskId={}, 共{}张图片,已拼接完成,总耗时: {}ms (生成: {}ms, 验证: {}ms, 拼接: {}ms, 保存: {}ms)",
|
||||
taskId, validatedImages.size(), totalElapsed, totalTime, validateTime, mergeTime, saveTime);
|
||||
logger.info("✓ 分镜图生成完成: taskId={}, 共{}张图片,已拼接完成,总耗时: {}ms (生成: {}ms, 拼接: {}ms, 保存: {}ms)",
|
||||
taskId, validatedImages.size(), totalElapsed, totalTime, mergeTime, saveTime);
|
||||
|
||||
// 不再自动生成视频,等待用户点击"开始生成"按钮
|
||||
|
||||
@@ -424,7 +419,6 @@ public class StoryboardVideoService {
|
||||
}
|
||||
task.updateProgress(50); // 分镜图生成完成,进度50%
|
||||
taskRepository.save(task);
|
||||
logger.debug("分镜图结果已保存: taskId={}, 图片数量={}", taskId, validatedImageCount);
|
||||
|
||||
// 更新 TaskStatus 为完成状态
|
||||
try {
|
||||
@@ -439,6 +433,15 @@ public class StoryboardVideoService {
|
||||
logger.error("更新 TaskStatus 为完成失败: {}", taskId, e);
|
||||
// 不抛出异常,避免影响主流程
|
||||
}
|
||||
|
||||
// 创建分镜图作品记录
|
||||
try {
|
||||
userWorkService.createStoryboardImageWork(taskId, mergedImageUrl);
|
||||
logger.info("分镜图作品记录已创建: taskId={}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.error("创建分镜图作品记录失败: taskId={}", taskId, e);
|
||||
// 不抛出异常,避免影响主流程
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("保存分镜图结果失败: {}", taskId, e);
|
||||
status.setRollbackOnly();
|
||||
@@ -516,7 +519,6 @@ public class StoryboardVideoService {
|
||||
task.updateProgress(50); // 分镜图生成完成,进度50%
|
||||
// 状态保持 PROCESSING,等待用户点击"开始生成"按钮后再生成视频
|
||||
taskRepository.save(task);
|
||||
logger.debug("分镜图结果已保存: taskId={}, 图片数量={}", taskId, validatedImageCount);
|
||||
|
||||
// 创建分镜图作品记录
|
||||
try {
|
||||
@@ -566,11 +568,13 @@ public class StoryboardVideoService {
|
||||
/**
|
||||
* 开始生成视频(从分镜图生成视频)
|
||||
* 用户点击"开始生成"按钮后调用
|
||||
* @param duration 视频时长(可选,如果为null则使用任务中已有的值)
|
||||
* @param aspectRatio 宽高比(可选,如果为null则使用任务中已有的值)
|
||||
* @param hdMode 高清模式(可选,如果为null则使用任务中已有的值)
|
||||
*/
|
||||
@Transactional
|
||||
public void startVideoGeneration(String taskId) {
|
||||
public void startVideoGeneration(String taskId, Integer duration, String aspectRatio, Boolean hdMode) {
|
||||
try {
|
||||
logger.debug("收到开始生成视频请求,任务ID: {}", taskId);
|
||||
|
||||
// 重新加载任务
|
||||
StoryboardVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
@@ -581,17 +585,49 @@ public class StoryboardVideoService {
|
||||
throw new RuntimeException("分镜图尚未生成,无法生成视频");
|
||||
}
|
||||
|
||||
// 检查任务状态
|
||||
if (task.getStatus() != StoryboardVideoTask.TaskStatus.PROCESSING) {
|
||||
// 检查任务状态:允许从 PROCESSING 或 COMPLETED 状态生成视频
|
||||
if (task.getStatus() != StoryboardVideoTask.TaskStatus.PROCESSING &&
|
||||
task.getStatus() != StoryboardVideoTask.TaskStatus.COMPLETED) {
|
||||
throw new RuntimeException("任务状态不正确,无法生成视频。当前状态: " + task.getStatus());
|
||||
}
|
||||
|
||||
// 更新任务参数(如果提供了新的参数)
|
||||
boolean paramsUpdated = false;
|
||||
if (duration != null && !duration.equals(task.getDuration())) {
|
||||
logger.info("更新任务 {} 的时长参数: {} -> {}", taskId, task.getDuration(), duration);
|
||||
task.setDuration(duration);
|
||||
paramsUpdated = true;
|
||||
}
|
||||
if (aspectRatio != null && !aspectRatio.equals(task.getAspectRatio())) {
|
||||
logger.info("更新任务 {} 的宽高比参数: {} -> {}", taskId, task.getAspectRatio(), aspectRatio);
|
||||
task.setAspectRatio(aspectRatio);
|
||||
paramsUpdated = true;
|
||||
}
|
||||
if (hdMode != null && !hdMode.equals(task.getHdMode())) {
|
||||
logger.info("更新任务 {} 的高清模式参数: {} -> {}", taskId, task.getHdMode(), hdMode);
|
||||
task.setHdMode(hdMode);
|
||||
paramsUpdated = true;
|
||||
}
|
||||
|
||||
// 如果是 COMPLETED 状态,更新为 PROCESSING(表示正在生成视频)
|
||||
if (task.getStatus() == StoryboardVideoTask.TaskStatus.COMPLETED) {
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.PROCESSING);
|
||||
task.setProgress(50); // 分镜图完成,视频生成中
|
||||
paramsUpdated = true;
|
||||
logger.info("任务状态已更新: {} -> PROCESSING (开始视频生成)", taskId);
|
||||
}
|
||||
|
||||
// 如果有任何参数更新,保存任务
|
||||
if (paramsUpdated) {
|
||||
taskRepository.save(task);
|
||||
logger.info("任务参数已更新并保存: {}", taskId);
|
||||
}
|
||||
|
||||
// 检查是否已经添加过视频生成任务(避免重复添加)
|
||||
// 这里可以通过检查任务队列来判断,但为了简单,我们直接添加
|
||||
// 如果已经存在,TaskQueueService 会处理重复的情况
|
||||
|
||||
// 将视频生成任务添加到任务队列,由队列异步处理
|
||||
logger.debug("开始将视频生成任务添加到队列: {}", taskId);
|
||||
try {
|
||||
taskQueueService.addStoryboardVideoTask(task.getUsername(), taskId);
|
||||
// 任务状态保持 PROCESSING,等待视频生成完成
|
||||
@@ -667,9 +703,6 @@ public class StoryboardVideoService {
|
||||
int newWidth = (int) (originalWidth * scale);
|
||||
int newHeight = (int) (originalHeight * scale);
|
||||
|
||||
logger.debug("压缩图片: {}x{} -> {}x{} (缩放比例: {})",
|
||||
originalWidth, originalHeight, newWidth, newHeight, scale);
|
||||
|
||||
// 创建缩放后的图片
|
||||
BufferedImage compressedImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = compressedImage.createGraphics();
|
||||
@@ -714,7 +747,6 @@ public class StoryboardVideoService {
|
||||
|
||||
if (originalWidth <= maxSize && originalHeight <= maxSize && originalSize < 500 * 1024) {
|
||||
// 图片已经足够小,不需要压缩
|
||||
logger.debug("图片无需压缩: {}x{}, 大小: {} KB", originalWidth, originalHeight, originalSize / 1024);
|
||||
return base64Image;
|
||||
}
|
||||
|
||||
@@ -785,7 +817,6 @@ public class StoryboardVideoService {
|
||||
validatedImages.add(normalizedImg);
|
||||
}
|
||||
|
||||
logger.debug("验证并规范化图片完成,原始数量: {}, 验证后数量: {}", imageUrls.size(), validatedImages.size());
|
||||
return validatedImages;
|
||||
}
|
||||
|
||||
@@ -813,7 +844,6 @@ public class StoryboardVideoService {
|
||||
while (retryCount <= maxRetries) {
|
||||
try {
|
||||
if (retryCount > 0) {
|
||||
logger.debug("重试下载图片(第{}次): {}", retryCount, imageUrl);
|
||||
Thread.sleep(1000 * retryCount); // 重试延迟递增
|
||||
}
|
||||
|
||||
@@ -862,8 +892,6 @@ public class StoryboardVideoService {
|
||||
String base64 = Base64.getEncoder().encodeToString(imageBytes);
|
||||
|
||||
// 返回带data URI前缀的Base64字符串(使用JPEG格式以减小体积)
|
||||
logger.debug("成功下载并转换图片: {} (原始: {}x{}, 压缩后: {} KB)",
|
||||
imageUrl, image.getWidth(), image.getHeight(), imageBytes.length / 1024);
|
||||
return "data:image/jpeg;base64," + base64;
|
||||
|
||||
} catch (java.net.SocketTimeoutException | java.net.ConnectException e) {
|
||||
@@ -872,14 +900,12 @@ public class StoryboardVideoService {
|
||||
logger.error("下载图片超时或连接失败(已重试{}次): {}", maxRetries, imageUrl, e);
|
||||
return null;
|
||||
}
|
||||
logger.debug("下载图片超时,将重试: {}", imageUrl);
|
||||
} catch (IOException e) {
|
||||
retryCount++;
|
||||
if (retryCount > maxRetries) {
|
||||
logger.error("下载图片失败(已重试{}次): {}", maxRetries, imageUrl, e);
|
||||
return null;
|
||||
}
|
||||
logger.debug("下载图片失败,将重试: {}", imageUrl);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
logger.error("下载图片被中断: {}", imageUrl, e);
|
||||
@@ -930,7 +956,6 @@ public class StoryboardVideoService {
|
||||
// 检查任务是否已经有resultUrl(分镜图已生成)
|
||||
// 如果有resultUrl,说明分镜图已经成功生成,不应该被标记为超时失败
|
||||
if (task.getResultUrl() != null && !task.getResultUrl().isEmpty()) {
|
||||
logger.debug("任务 {} 已有resultUrl,分镜图已生成,跳过超时标记", task.getTaskId());
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@ public class TaskQueueService {
|
||||
|
||||
@Autowired
|
||||
private UserWorkService userWorkService;
|
||||
|
||||
@Autowired
|
||||
private com.example.demo.repository.UserWorkRepository userWorkRepository;
|
||||
|
||||
@Autowired
|
||||
private VideoConcatService videoConcatService;
|
||||
@@ -75,13 +78,16 @@ public class TaskQueueService {
|
||||
@Autowired
|
||||
private ImageGridService imageGridService;
|
||||
|
||||
@Autowired
|
||||
private CosService cosService;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${app.temp.dir:./temp}")
|
||||
private String tempDir;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${app.upload.path:uploads}")
|
||||
private String uploadPath;
|
||||
|
||||
private static final int MAX_TASKS_PER_USER = 3;
|
||||
private static final int MAX_TASKS_PER_USER = 1; // 限制每个用户同时只能有1个待处理任务
|
||||
|
||||
/**
|
||||
* 无界阻塞队列:用于内存中的任务处理
|
||||
@@ -91,12 +97,14 @@ public class TaskQueueService {
|
||||
|
||||
/**
|
||||
* 任务处理线程池:用于并发处理队列中的任务
|
||||
* 核心线程数:10,最大线程数:50
|
||||
* 使用固定大小的线程池,线程数量由 CONSUMER_THREAD_COUNT 决定
|
||||
*/
|
||||
private ExecutorService taskProcessorExecutor;
|
||||
|
||||
/**
|
||||
* 消费者线程数量(支持50人并发,每个用户最多3个任务)
|
||||
* 消费者线程数量
|
||||
* 配合 MAX_TASKS_PER_USER=1,理论上可支持5个用户同时处理任务
|
||||
* 可根据服务器性能调整此值以支持更多并发
|
||||
*/
|
||||
private static final int CONSUMER_THREAD_COUNT = 5;
|
||||
|
||||
@@ -111,6 +119,10 @@ public class TaskQueueService {
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
logger.info("初始化任务队列服务,启动 {} 个消费者线程", CONSUMER_THREAD_COUNT);
|
||||
|
||||
// 系统重启时,清理所有未完成的分镜图生成任务
|
||||
cleanupIncompleteStoryboardTasks();
|
||||
|
||||
running = true;
|
||||
taskProcessorExecutor = Executors.newFixedThreadPool(CONSUMER_THREAD_COUNT, r -> {
|
||||
Thread t = new Thread(r, "task-queue-consumer");
|
||||
@@ -151,6 +163,84 @@ public class TaskQueueService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修复UserWork状态和resultUrl
|
||||
* 检查所有FAILED状态或resultUrl为空的UserWork,如果对应的业务任务已完成,则更新UserWork状态和resultUrl
|
||||
*/
|
||||
private void repairUserWorkStatus() {
|
||||
try {
|
||||
logger.info("开始修复UserWork状态和resultUrl...");
|
||||
|
||||
// 1. 修复FAILED状态的作品
|
||||
List<com.example.demo.model.UserWork> failedWorks = userWorkRepository.findAllByFailedStatus();
|
||||
logger.info("找到 {} 个FAILED状态的作品", failedWorks.size());
|
||||
|
||||
// 2. 修复resultUrl为空的COMPLETED作品
|
||||
List<com.example.demo.model.UserWork> completedWorksWithoutUrl = userWorkRepository.findAll().stream()
|
||||
.filter(work -> work.getStatus() == com.example.demo.model.UserWork.WorkStatus.COMPLETED
|
||||
&& (work.getResultUrl() == null || work.getResultUrl().isEmpty()))
|
||||
.limit(100)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
logger.info("找到 {} 个COMPLETED但resultUrl为空的作品", completedWorksWithoutUrl.size());
|
||||
|
||||
// 合并两个列表
|
||||
List<com.example.demo.model.UserWork> allWorksToRepair = new java.util.ArrayList<>();
|
||||
allWorksToRepair.addAll(failedWorks);
|
||||
allWorksToRepair.addAll(completedWorksWithoutUrl);
|
||||
|
||||
java.util.concurrent.atomic.AtomicInteger repairedCount = new java.util.concurrent.atomic.AtomicInteger(0);
|
||||
|
||||
for (com.example.demo.model.UserWork work : allWorksToRepair) {
|
||||
String taskId = work.getTaskId();
|
||||
|
||||
// 根据workType检查对应的业务任务状态
|
||||
switch (work.getWorkType()) {
|
||||
case TEXT_TO_VIDEO:
|
||||
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
if (task.getStatus() == TextToVideoTask.TaskStatus.COMPLETED && task.getResultUrl() != null) {
|
||||
work.setStatus(com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
||||
work.setResultUrl(task.getResultUrl());
|
||||
work.setCompletedAt(task.getCompletedAt());
|
||||
userWorkRepository.save(work);
|
||||
repairedCount.incrementAndGet();
|
||||
logger.info("修复UserWork状态: {} -> COMPLETED", taskId);
|
||||
}
|
||||
});
|
||||
break;
|
||||
case IMAGE_TO_VIDEO:
|
||||
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
if (task.getStatus() == ImageToVideoTask.TaskStatus.COMPLETED && task.getResultUrl() != null) {
|
||||
work.setStatus(com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
||||
work.setResultUrl(task.getResultUrl());
|
||||
work.setCompletedAt(task.getCompletedAt());
|
||||
userWorkRepository.save(work);
|
||||
repairedCount.incrementAndGet();
|
||||
logger.info("修复UserWork状态: {} -> COMPLETED", taskId);
|
||||
}
|
||||
});
|
||||
break;
|
||||
case STORYBOARD_VIDEO:
|
||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
if (task.getStatus() == StoryboardVideoTask.TaskStatus.COMPLETED && task.getResultUrl() != null) {
|
||||
work.setStatus(com.example.demo.model.UserWork.WorkStatus.COMPLETED);
|
||||
work.setResultUrl(task.getResultUrl());
|
||||
work.setCompletedAt(task.getCompletedAt());
|
||||
userWorkRepository.save(work);
|
||||
repairedCount.incrementAndGet();
|
||||
logger.info("修复UserWork状态: {} -> COMPLETED", taskId);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("UserWork状态修复完成,共检查 {} 个记录({} 个FAILED,{} 个resultUrl为空),成功修复 {} 个",
|
||||
allWorksToRepair.size(), failedWorks.size(), completedWorksWithoutUrl.size(), repairedCount.get());
|
||||
} catch (Exception e) {
|
||||
logger.error("修复UserWork状态失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库加载待处理任务到内存队列
|
||||
*/
|
||||
@@ -161,12 +251,111 @@ public class TaskQueueService {
|
||||
for (TaskQueue task : pendingTasks) {
|
||||
taskBlockingQueue.offer(task); // 非阻塞添加
|
||||
}
|
||||
logger.info("任务加载完成,队列当前大小: {}", taskBlockingQueue.size());
|
||||
} catch (Exception e) {
|
||||
logger.error("加载待处理任务到队列失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理未完成的任务
|
||||
* 系统重启时调用,将所有PENDING和PROCESSING状态的任务标记为失败
|
||||
* 这样可以避免队列"满了"的问题,因为重启前的任务不应该继续占用队列
|
||||
*/
|
||||
@Transactional
|
||||
private void cleanupIncompleteStoryboardTasks() {
|
||||
try {
|
||||
logger.info("系统重启:开始清理未完成的任务");
|
||||
|
||||
int totalCleanedCount = 0;
|
||||
|
||||
// 1. 清理TaskQueue中所有PENDING和PROCESSING状态的任务
|
||||
List<TaskQueue> pendingQueues = taskQueueRepository.findByStatus(TaskQueue.QueueStatus.PENDING);
|
||||
List<TaskQueue> processingQueues = taskQueueRepository.findByStatus(TaskQueue.QueueStatus.PROCESSING);
|
||||
|
||||
for (TaskQueue taskQueue : pendingQueues) {
|
||||
taskQueue.updateStatus(TaskQueue.QueueStatus.FAILED);
|
||||
taskQueue.setErrorMessage("系统重启,任务已取消");
|
||||
taskQueueRepository.save(taskQueue);
|
||||
|
||||
// 返还冻结的积分
|
||||
try {
|
||||
userService.returnFrozenPoints(taskQueue.getTaskId());
|
||||
} catch (Exception e) {
|
||||
logger.debug("返还积分失败(可能未冻结): taskId={}", taskQueue.getTaskId());
|
||||
}
|
||||
|
||||
totalCleanedCount++;
|
||||
}
|
||||
|
||||
for (TaskQueue taskQueue : processingQueues) {
|
||||
taskQueue.updateStatus(TaskQueue.QueueStatus.FAILED);
|
||||
taskQueue.setErrorMessage("系统重启,任务已取消");
|
||||
taskQueueRepository.save(taskQueue);
|
||||
|
||||
// 返还冻结的积分
|
||||
try {
|
||||
userService.returnFrozenPoints(taskQueue.getTaskId());
|
||||
} catch (Exception e) {
|
||||
logger.debug("返还积分失败(可能未冻结): taskId={}", taskQueue.getTaskId());
|
||||
}
|
||||
|
||||
totalCleanedCount++;
|
||||
}
|
||||
|
||||
// 2. 清理所有业务任务表中的PENDING和PROCESSING状态任务
|
||||
// 注意:先清理业务任务,收集需要清理的taskId,然后再清理UserWork
|
||||
int businessTaskCleanedCount = 0;
|
||||
|
||||
// 2.1 清理文生视频任务
|
||||
List<TextToVideoTask> textToVideoTasks = textToVideoTaskRepository.findByStatus(TextToVideoTask.TaskStatus.PENDING);
|
||||
textToVideoTasks.addAll(textToVideoTaskRepository.findByStatus(TextToVideoTask.TaskStatus.PROCESSING));
|
||||
for (TextToVideoTask task : textToVideoTasks) {
|
||||
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("系统重启,任务已取消");
|
||||
textToVideoTaskRepository.save(task);
|
||||
businessTaskCleanedCount++;
|
||||
}
|
||||
|
||||
// 2.2 清理图生视频任务
|
||||
List<ImageToVideoTask> imageToVideoTasks = imageToVideoTaskRepository.findByStatus(ImageToVideoTask.TaskStatus.PENDING);
|
||||
imageToVideoTasks.addAll(imageToVideoTaskRepository.findByStatus(ImageToVideoTask.TaskStatus.PROCESSING));
|
||||
for (ImageToVideoTask task : imageToVideoTasks) {
|
||||
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("系统重启,任务已取消");
|
||||
imageToVideoTaskRepository.save(task);
|
||||
businessTaskCleanedCount++;
|
||||
}
|
||||
|
||||
// 2.3 清理分镜视频任务(只清理还在生成分镜图阶段的任务,realTaskId为空)
|
||||
List<StoryboardVideoTask> storyboardTasks = storyboardVideoTaskRepository.findByStatus(StoryboardVideoTask.TaskStatus.PENDING);
|
||||
storyboardTasks.addAll(storyboardVideoTaskRepository.findByStatus(StoryboardVideoTask.TaskStatus.PROCESSING));
|
||||
for (StoryboardVideoTask task : storyboardTasks) {
|
||||
// 只清理还在生成分镜图阶段的任务(realTaskId为空)
|
||||
// 如果已经有realTaskId,说明已经提交到外部API,应该继续处理
|
||||
if (task.getRealTaskId() == null || task.getRealTaskId().isEmpty()) {
|
||||
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
task.setErrorMessage("系统重启,任务已取消");
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
businessTaskCleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 修复UserWork状态
|
||||
// 检查所有FAILED状态的UserWork,如果对应的业务任务已完成,则更新UserWork状态
|
||||
repairUserWorkStatus();
|
||||
|
||||
if (totalCleanedCount > 0 || businessTaskCleanedCount > 0) {
|
||||
logger.warn("系统重启:共清理了 {} 个队列任务,{} 个业务任务",
|
||||
totalCleanedCount, businessTaskCleanedCount);
|
||||
} else {
|
||||
logger.info("系统重启:没有需要清理的未完成任务");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("清理未完成任务失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务消费者:从阻塞队列中取任务并处理
|
||||
*/
|
||||
@@ -180,7 +369,6 @@ public class TaskQueueService {
|
||||
TaskQueue task = taskBlockingQueue.poll(1, java.util.concurrent.TimeUnit.SECONDS);
|
||||
if (task != null) {
|
||||
try {
|
||||
logger.debug("消费者线程 {} 开始处理任务: {}", Thread.currentThread().getName(), task.getTaskId());
|
||||
processTask(task);
|
||||
} catch (Exception e) {
|
||||
logger.error("处理任务失败: {}", task.getTaskId(), e);
|
||||
@@ -231,10 +419,10 @@ public class TaskQueueService {
|
||||
*/
|
||||
@Transactional
|
||||
private TaskQueue addTaskToQueue(String username, String taskId, TaskQueue.TaskType taskType) {
|
||||
// 检查用户是否已有3个待处理任务
|
||||
// 检查用户是否已有待处理任务
|
||||
long pendingCount = taskQueueRepository.countPendingTasksByUsername(username);
|
||||
if (pendingCount >= MAX_TASKS_PER_USER) {
|
||||
throw new RuntimeException("用户 " + username + " 的队列已满,最多只能有 " + MAX_TASKS_PER_USER + " 个待处理任务");
|
||||
throw new RuntimeException("您当前有任务正在进行中,请等待任务完成后再创建新任务");
|
||||
}
|
||||
|
||||
// 检查任务是否已存在
|
||||
@@ -256,16 +444,23 @@ public class TaskQueueService {
|
||||
TaskQueue taskQueue = new TaskQueue(username, taskId, taskType);
|
||||
taskQueue = taskQueueRepository.save(taskQueue);
|
||||
|
||||
// 添加到内存阻塞队列(非阻塞,如果队列满会立即返回false,但无界队列不会满)
|
||||
boolean added = taskBlockingQueue.offer(taskQueue);
|
||||
if (added) {
|
||||
logger.debug("任务 {} 已添加到内存队列", taskId);
|
||||
} else {
|
||||
logger.warn("任务 {} 添加到内存队列失败(理论上不应该发生)", taskId);
|
||||
}
|
||||
// 注册事务提交后的回调,确保事务提交后才将任务加入内存队列
|
||||
// 这样可以避免消费者线程在事务提交前就开始处理任务,导致找不到数据的问题
|
||||
final TaskQueue finalTaskQueue = taskQueue;
|
||||
org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(
|
||||
new org.springframework.transaction.support.TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
// 添加到内存阻塞队列(非阻塞,如果队列满会立即返回false,但无界队列不会满)
|
||||
taskBlockingQueue.offer(finalTaskQueue);
|
||||
logger.info("任务 {} 已添加到内存队列,用户: {}, 类型: {}, 冻结积分: {}, 内存队列大小: {}",
|
||||
finalTaskQueue.getTaskId(), username, taskType.getDescription(), requiredPoints, taskBlockingQueue.size());
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("任务 {} 已添加到队列,用户: {}, 类型: {}, 冻结积分: {}, 内存队列大小: {}",
|
||||
taskId, username, taskType.getDescription(), requiredPoints, taskBlockingQueue.size());
|
||||
logger.info("任务 {} 已保存到数据库,用户: {}, 类型: {}, 冻结积分: {}",
|
||||
taskId, username, taskType.getDescription(), requiredPoints);
|
||||
return taskQueue;
|
||||
}
|
||||
|
||||
@@ -317,9 +512,7 @@ public class TaskQueueService {
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
if (addedCount > 0) {
|
||||
logger.debug("从数据库加载了 {} 个待处理任务到内存队列,当前队列大小: {}", addedCount, taskBlockingQueue.size());
|
||||
}
|
||||
// 静默加载
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,12 +553,9 @@ public class TaskQueueService {
|
||||
*/
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
private void processTask(TaskQueue taskQueue) {
|
||||
logger.debug("开始处理任务: {}, 类型: {}", taskQueue.getTaskId(), taskQueue.getTaskType());
|
||||
|
||||
// 更新状态为处理中(使用单独的事务方法)
|
||||
// 如果状态更新失败(任务已被其他线程处理),直接返回
|
||||
if (!updateTaskStatusToProcessing(taskQueue.getTaskId())) {
|
||||
logger.info("任务 {} 已被其他线程处理,跳过本次处理", taskQueue.getTaskId());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -388,7 +578,6 @@ public class TaskQueueService {
|
||||
if (realTaskId != null) {
|
||||
// 保存真实任务ID(使用单独的事务方法)
|
||||
saveRealTaskId(taskQueue.getTaskId(), realTaskId);
|
||||
logger.info("任务已提交到外部API: taskId={}, realTaskId={}", taskQueue.getTaskId(), realTaskId);
|
||||
} else {
|
||||
logger.error("无法提取任务ID: taskId={}", taskQueue.getTaskId());
|
||||
throw new RuntimeException("API未返回有效的任务ID");
|
||||
@@ -443,8 +632,7 @@ public class TaskQueueService {
|
||||
|
||||
// 更新状态为处理中
|
||||
taskQueue.updateStatus(TaskQueue.QueueStatus.PROCESSING);
|
||||
taskQueueRepository.save(taskQueue);
|
||||
logger.debug("任务 {} 状态已更新为PROCESSING(使用悲观锁)", taskId);
|
||||
taskQueueRepository.save(taskQueue);
|
||||
return Boolean.TRUE;
|
||||
} catch (jakarta.persistence.NoResultException e) {
|
||||
logger.warn("任务 {} 不存在,无法更新状态", taskId);
|
||||
@@ -473,8 +661,6 @@ public class TaskQueueService {
|
||||
}
|
||||
taskQueue.setRealTaskId(realTaskId);
|
||||
taskQueueRepository.save(taskQueue);
|
||||
logger.debug("已保存 realTaskId: taskId={}, realTaskId={}, status={}",
|
||||
taskId, realTaskId, taskQueue.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +788,6 @@ public class TaskQueueService {
|
||||
String videoTaskId = result.get("task_id").toString();
|
||||
// 保存视频任务ID到数据库
|
||||
saveVideoTaskId(taskQueue.getTaskId(), videoTaskId);
|
||||
logger.info("分镜视频任务提交成功,task_id: {}", videoTaskId);
|
||||
return result;
|
||||
} else {
|
||||
throw new RuntimeException("图生视频任务提交失败,未返回task_id");
|
||||
@@ -626,7 +811,7 @@ public class TaskQueueService {
|
||||
String mergedImageBase64;
|
||||
try {
|
||||
mergedImageBase64 = imageGridService.mergeImagesToGrid(images, 3); // 3列2行布局
|
||||
logger.info("6张分镜图拼接完成");
|
||||
// 拼接完成
|
||||
} catch (Exception e) {
|
||||
logger.error("拼接分镜图失败: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("拼接分镜图失败: " + e.getMessage(), e);
|
||||
@@ -664,7 +849,6 @@ public class TaskQueueService {
|
||||
// 保存为单个任务ID(不再使用videoTaskIds数组)
|
||||
currentTask.setRealTaskId(videoTaskId);
|
||||
storyboardVideoTaskRepository.save(currentTask);
|
||||
logger.info("已保存视频任务ID到数据库: {}", videoTaskId);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.error("保存视频任务ID失败: {}", e.getMessage(), e);
|
||||
@@ -706,15 +890,18 @@ public class TaskQueueService {
|
||||
imagePath = absolutePath;
|
||||
logger.info("找到图片文件: {}", absolutePath);
|
||||
} else {
|
||||
// 尝试其他可能的路径
|
||||
java.nio.file.Path altPath = java.nio.file.Paths.get("C:\\Users\\UI\\Desktop\\AIGC\\demo", imageUrl);
|
||||
logger.info("尝试备用路径: {}", altPath);
|
||||
// 尝试使用uploadPath配置作为基础路径
|
||||
java.nio.file.Path uploadBasePath = java.nio.file.Paths.get(currentDir, uploadPath, imageUrl);
|
||||
logger.info("尝试上传路径: {}", uploadBasePath);
|
||||
|
||||
if (java.nio.file.Files.exists(altPath)) {
|
||||
imagePath = altPath;
|
||||
logger.info("找到图片文件(备用路径): {}", altPath);
|
||||
if (java.nio.file.Files.exists(uploadBasePath)) {
|
||||
imagePath = uploadBasePath;
|
||||
logger.info("找到图片文件(上传路径): {}", uploadBasePath);
|
||||
} else {
|
||||
throw new RuntimeException("图片文件不存在: " + imageUrl + ", 绝对路径: " + absolutePath + ", 备用路径: " + altPath);
|
||||
throw new RuntimeException("图片文件不存在: " + imageUrl +
|
||||
", 当前目录: " + absolutePath +
|
||||
", 上传路径: " + uploadBasePath +
|
||||
". 请确保文件路径正确或使用完整URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -822,8 +1009,6 @@ public class TaskQueueService {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("检查 {} 个任务状态", tasksToCheck.size());
|
||||
|
||||
for (TaskQueue taskQueue : tasksToCheck) {
|
||||
try {
|
||||
checkTaskStatusInternal(taskQueue);
|
||||
@@ -871,7 +1056,6 @@ public class TaskQueueService {
|
||||
|
||||
// 检查是否正在查询此任务,如果是则跳过(防止重复查询)
|
||||
if (!checkingTasks.add(taskId)) {
|
||||
logger.debug("任务 {} 正在被其他线程查询,跳过本次查询", taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -917,131 +1101,21 @@ public class TaskQueueService {
|
||||
status = status.toUpperCase();
|
||||
}
|
||||
|
||||
// 提取结果URL - 支持多种格式
|
||||
// 提取结果URL - 只支持 sora2 格式:data.output
|
||||
String resultUrl = null;
|
||||
|
||||
// 0. 优先检查 sora2 格式:data.output
|
||||
Object dataField = taskData.get("data");
|
||||
if (dataField instanceof Map) {
|
||||
Map<?, ?> dataMap = (Map<?, ?>) dataField;
|
||||
Object output = dataMap.get("output");
|
||||
if (output != null) {
|
||||
String outputStr = output.toString();
|
||||
// 检查是否为空字符串或"null"字符串
|
||||
// 检查是否为有效的URL(不为空字符串且不为"null"字符串)
|
||||
if (!outputStr.trim().isEmpty() && !outputStr.equals("null")) {
|
||||
resultUrl = outputStr;
|
||||
logger.debug("从 data.output 提取到 resultUrl: taskId={}, resultUrl长度={}",
|
||||
taskQueue.getTaskId(), resultUrl.length());
|
||||
} else {
|
||||
logger.debug("data.output 为空或无效: taskId={}, output={}",
|
||||
taskQueue.getTaskId(), outputStr);
|
||||
}
|
||||
} else {
|
||||
logger.debug("data.output 为 null: taskId={}", taskQueue.getTaskId());
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 检查直接的task_result.videos[0].url(Kling API格式)
|
||||
if (resultUrl == null) {
|
||||
Object directTaskResult = taskData.get("task_result");
|
||||
if (directTaskResult instanceof Map) {
|
||||
Map<?, ?> directTaskResultMap = (Map<?, ?>) directTaskResult;
|
||||
Object directVideos = directTaskResultMap.get("videos");
|
||||
if (directVideos instanceof java.util.List && !((java.util.List<?>) directVideos).isEmpty()) {
|
||||
Object firstVideo = ((java.util.List<?>) directVideos).get(0);
|
||||
if (firstVideo instanceof Map) {
|
||||
Map<?, ?> videoMap = (Map<?, ?>) firstVideo;
|
||||
Object url = videoMap.get("url");
|
||||
if (url != null) {
|
||||
resultUrl = url.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查嵌套的data.data.task_result.videos[0].url(Kling API嵌套格式)
|
||||
if (resultUrl == null) {
|
||||
Object nestedData = taskData.get("data");
|
||||
if (nestedData instanceof Map) {
|
||||
Map<?, ?> nestedDataMap = (Map<?, ?>) nestedData;
|
||||
Object innerData = nestedDataMap.get("data");
|
||||
if (innerData instanceof Map) {
|
||||
Map<?, ?> innerDataMap = (Map<?, ?>) innerData;
|
||||
|
||||
// 检查 task_result.videos[0].url
|
||||
Object taskResult = innerDataMap.get("task_result");
|
||||
if (taskResult instanceof Map) {
|
||||
Map<?, ?> taskResultMap = (Map<?, ?>) taskResult;
|
||||
Object videos = taskResultMap.get("videos");
|
||||
if (videos instanceof java.util.List && !((java.util.List<?>) videos).isEmpty()) {
|
||||
Object firstVideo = ((java.util.List<?>) videos).get(0);
|
||||
if (firstVideo instanceof Map) {
|
||||
Map<?, ?> videoMap = (Map<?, ?>) firstVideo;
|
||||
Object url = videoMap.get("url");
|
||||
if (url != null) {
|
||||
resultUrl = url.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果上面没找到,检查output字段
|
||||
if (resultUrl == null) {
|
||||
Object output = innerDataMap.get("output");
|
||||
if (output != null) {
|
||||
resultUrl = output.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果深层嵌套没有找到,检查当前层的output
|
||||
if (resultUrl == null) {
|
||||
Object output = nestedDataMap.get("output");
|
||||
if (output != null) {
|
||||
resultUrl = output.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查直接的output字段(顶层output)
|
||||
if (resultUrl == null) {
|
||||
Object output = taskData.get("output");
|
||||
if (output != null) {
|
||||
resultUrl = output.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查其他可能的字段名(兼容旧格式)
|
||||
if (resultUrl == null) {
|
||||
Object resultUrlObj = taskData.get("resultUrl");
|
||||
if (resultUrlObj != null) {
|
||||
resultUrl = resultUrlObj.toString();
|
||||
}
|
||||
}
|
||||
if (resultUrl == null) {
|
||||
Object videoUrl = taskData.get("video_url");
|
||||
if (videoUrl != null) {
|
||||
resultUrl = videoUrl.toString();
|
||||
}
|
||||
}
|
||||
if (resultUrl == null) {
|
||||
Object url = taskData.get("url");
|
||||
if (url != null) {
|
||||
resultUrl = url.toString();
|
||||
}
|
||||
}
|
||||
if (resultUrl == null) {
|
||||
Object result = taskData.get("result");
|
||||
if (result != null) {
|
||||
resultUrl = result.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (resultUrl == null || resultUrl.trim().isEmpty()) {
|
||||
logger.debug("未能从API响应中提取到resultUrl,taskId={}", taskQueue.getTaskId());
|
||||
}
|
||||
|
||||
// 提取错误消息
|
||||
String errorMessage = (String) taskData.get("errorMessage");
|
||||
if (errorMessage == null) {
|
||||
@@ -1051,22 +1125,27 @@ public class TaskQueueService {
|
||||
errorMessage = (String) taskData.get("error");
|
||||
}
|
||||
|
||||
// 更新任务状态 - 只在状态变化时输出日志
|
||||
// 如果 resultUrl 不为空且不为空字符串,即使状态是 IN_PROGRESS,也认为任务已完成
|
||||
if ("COMPLETED".equals(status) || "SUCCESS".equals(status) ||
|
||||
(resultUrl != null && !resultUrl.trim().isEmpty() && !resultUrl.equals("null"))) {
|
||||
if (resultUrl != null && !resultUrl.trim().isEmpty() && !resultUrl.equals("null")) {
|
||||
logger.info("任务完成(通过resultUrl判断): taskId={}, status={}, resultUrl={}",
|
||||
taskQueue.getTaskId(), status, resultUrl.length() > 50 ? resultUrl.substring(0, 50) + "..." : resultUrl);
|
||||
} else {
|
||||
logger.info("任务完成: taskId={}, resultUrl={}", taskQueue.getTaskId(), resultUrl != null ? "已获取" : "未获取");
|
||||
}
|
||||
// 优化后的任务状态判断逻辑
|
||||
// 1. 任务完成:状态为COMPLETED/SUCCESS且有有效的resultUrl
|
||||
if (("COMPLETED".equals(status) || "SUCCESS".equals(status)) &&
|
||||
resultUrl != null && !resultUrl.trim().isEmpty()) {
|
||||
logger.info("任务完成: taskId={}, resultUrl={}", taskQueue.getTaskId(),
|
||||
resultUrl.length() > 100 ? resultUrl.substring(0, 100) + "..." : resultUrl);
|
||||
updateTaskAsCompleted(taskQueue, resultUrl);
|
||||
} else if ("FAILED".equals(status) || "ERROR".equals(status)) {
|
||||
}
|
||||
// 2. 任务失败:状态为FAILED/ERROR
|
||||
else if ("FAILED".equals(status) || "ERROR".equals(status)) {
|
||||
logger.warn("任务失败: taskId={}, error={}", taskQueue.getTaskId(), errorMessage);
|
||||
updateTaskAsFailed(taskQueue, errorMessage);
|
||||
}
|
||||
// 处理中状态不输出日志,避免重复
|
||||
// 3. 处理中状态:不输出日志,避免重复(静默轮询)
|
||||
else if ("PROCESSING".equals(status) || "IN_PROGRESS".equals(status)) {
|
||||
// 静默处理,等待下次轮询
|
||||
}
|
||||
// 4. 其他未知状态:记录警告
|
||||
else {
|
||||
logger.debug("任务状态未变化: taskId={}, status={}", taskQueue.getTaskId(), status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1112,7 +1191,6 @@ public class TaskQueueService {
|
||||
}
|
||||
|
||||
// 检查单个视频任务状态(使用图生视频任务的检查逻辑)
|
||||
logger.debug("检查分镜视频任务状态: taskId={}, realTaskId={}", taskQueue.getTaskId(), realTaskId);
|
||||
checkTaskStatusInternalForSingleTask(taskQueue);
|
||||
|
||||
// 增加检查次数
|
||||
@@ -1123,40 +1201,113 @@ public class TaskQueueService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任务数据中提取视频URL
|
||||
* 从任务数据中提取视频URL(支持多种API响应格式)
|
||||
*/
|
||||
private String extractVideoUrl(Map<?, ?> taskData) {
|
||||
// 检查 data.output
|
||||
String resultUrl = null;
|
||||
|
||||
// 0. 优先检查 sora2 格式:data.output
|
||||
Object dataField = taskData.get("data");
|
||||
if (dataField instanceof Map) {
|
||||
Map<?, ?> dataMap = (Map<?, ?>) dataField;
|
||||
Object output = dataMap.get("output");
|
||||
if (output != null) {
|
||||
String outputStr = output.toString();
|
||||
// 检查是否为空字符串或"null"字符串
|
||||
if (!outputStr.trim().isEmpty() && !outputStr.equals("null")) {
|
||||
return outputStr;
|
||||
resultUrl = outputStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 task_result.videos[0].url
|
||||
Object taskResult = taskData.get("task_result");
|
||||
if (taskResult instanceof Map) {
|
||||
Map<?, ?> taskResultMap = (Map<?, ?>) taskResult;
|
||||
Object videos = taskResultMap.get("videos");
|
||||
if (videos instanceof java.util.List && !((java.util.List<?>) videos).isEmpty()) {
|
||||
Object firstVideo = ((java.util.List<?>) videos).get(0);
|
||||
if (firstVideo instanceof Map) {
|
||||
Map<?, ?> videoMap = (Map<?, ?>) firstVideo;
|
||||
Object url = videoMap.get("url");
|
||||
if (url != null) {
|
||||
return url.toString();
|
||||
// 1. 检查直接的task_result.videos[0].url(Kling API格式)
|
||||
if (resultUrl == null) {
|
||||
Object directTaskResult = taskData.get("task_result");
|
||||
if (directTaskResult instanceof Map) {
|
||||
Map<?, ?> directTaskResultMap = (Map<?, ?>) directTaskResult;
|
||||
Object directVideos = directTaskResultMap.get("videos");
|
||||
if (directVideos instanceof java.util.List && !((java.util.List<?>) directVideos).isEmpty()) {
|
||||
Object firstVideo = ((java.util.List<?>) directVideos).get(0);
|
||||
if (firstVideo instanceof Map) {
|
||||
Map<?, ?> videoMap = (Map<?, ?>) firstVideo;
|
||||
Object url = videoMap.get("url");
|
||||
if (url != null) {
|
||||
resultUrl = url.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
// 2. 检查嵌套的data.data.task_result.videos[0].url(Kling API嵌套格式)
|
||||
if (resultUrl == null && dataField instanceof Map) {
|
||||
Map<?, ?> nestedDataMap = (Map<?, ?>) dataField;
|
||||
Object innerData = nestedDataMap.get("data");
|
||||
if (innerData instanceof Map) {
|
||||
Map<?, ?> innerDataMap = (Map<?, ?>) innerData;
|
||||
|
||||
// 检查 task_result.videos[0].url
|
||||
Object taskResult = innerDataMap.get("task_result");
|
||||
if (taskResult instanceof Map) {
|
||||
Map<?, ?> taskResultMap = (Map<?, ?>) taskResult;
|
||||
Object videos = taskResultMap.get("videos");
|
||||
if (videos instanceof java.util.List && !((java.util.List<?>) videos).isEmpty()) {
|
||||
Object firstVideo = ((java.util.List<?>) videos).get(0);
|
||||
if (firstVideo instanceof Map) {
|
||||
Map<?, ?> videoMap = (Map<?, ?>) firstVideo;
|
||||
Object url = videoMap.get("url");
|
||||
if (url != null) {
|
||||
resultUrl = url.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果上面没找到,检查output字段
|
||||
if (resultUrl == null) {
|
||||
Object output = innerDataMap.get("output");
|
||||
if (output != null && !output.toString().equals("null")) {
|
||||
resultUrl = output.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查直接的output字段(顶层output)
|
||||
if (resultUrl == null) {
|
||||
Object output = taskData.get("output");
|
||||
if (output != null && !output.toString().equals("null")) {
|
||||
resultUrl = output.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 检查其他可能的字段名(兼容旧格式)
|
||||
if (resultUrl == null) {
|
||||
Object resultUrlObj = taskData.get("resultUrl");
|
||||
if (resultUrlObj != null) {
|
||||
resultUrl = resultUrlObj.toString();
|
||||
}
|
||||
}
|
||||
if (resultUrl == null) {
|
||||
Object videoUrl = taskData.get("video_url");
|
||||
if (videoUrl != null) {
|
||||
resultUrl = videoUrl.toString();
|
||||
}
|
||||
}
|
||||
if (resultUrl == null) {
|
||||
Object url = taskData.get("url");
|
||||
if (url != null) {
|
||||
resultUrl = url.toString();
|
||||
}
|
||||
}
|
||||
if (resultUrl == null) {
|
||||
Object result = taskData.get("result");
|
||||
if (result != null) {
|
||||
resultUrl = result.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return resultUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1172,12 +1323,18 @@ public class TaskQueueService {
|
||||
// 查询外部API状态
|
||||
Map<String, Object> statusResponse = realAIService.getTaskStatus(taskQueue.getRealTaskId());
|
||||
|
||||
// ⭐ 添加详细日志:输出完整的API响应
|
||||
logger.info("🔍 [API响应] taskId={}, realTaskId={}, response={}",
|
||||
taskQueue.getTaskId(), taskQueue.getRealTaskId(), statusResponse);
|
||||
|
||||
// API调用成功后增加检查次数(使用独立事务,快速完成)
|
||||
incrementCheckCountWithTransaction(taskQueue.getTaskId());
|
||||
|
||||
if (statusResponse != null && statusResponse.containsKey("data")) {
|
||||
Object data = statusResponse.get("data");
|
||||
|
||||
logger.info("🔍 [data字段] taskId={}, data={}", taskQueue.getTaskId(), data);
|
||||
|
||||
Map<?, ?> taskData = null;
|
||||
|
||||
// 处理不同的响应格式
|
||||
@@ -1194,13 +1351,15 @@ public class TaskQueueService {
|
||||
}
|
||||
|
||||
if (taskData != null) {
|
||||
logger.info("🔍 [taskData内容] taskId={}, taskData={}", taskQueue.getTaskId(), taskData);
|
||||
|
||||
String status = (String) taskData.get("status");
|
||||
// 支持大小写不敏感的状态检查
|
||||
if (status != null) {
|
||||
status = status.toUpperCase();
|
||||
}
|
||||
|
||||
// 提取结果URL
|
||||
// 提取结果URL(只支持sora2格式)
|
||||
String resultUrl = extractVideoUrl(taskData);
|
||||
|
||||
// 提取错误消息
|
||||
@@ -1212,13 +1371,24 @@ public class TaskQueueService {
|
||||
errorMessage = (String) taskData.get("error");
|
||||
}
|
||||
|
||||
// 更新任务状态
|
||||
if ("COMPLETED".equals(status) || "SUCCESS".equals(status) ||
|
||||
(resultUrl != null && !resultUrl.trim().isEmpty() && !resultUrl.equals("null"))) {
|
||||
if (resultUrl != null && !resultUrl.trim().isEmpty() && !resultUrl.equals("null")) {
|
||||
updateTaskAsCompleted(taskQueue, resultUrl);
|
||||
}
|
||||
} else if ("FAILED".equals(status) || "ERROR".equals(status)) {
|
||||
// 优化后的任务状态判断逻辑
|
||||
// 1. 任务完成:状态为COMPLETED/SUCCESS且有有效的resultUrl
|
||||
if (("COMPLETED".equals(status) || "SUCCESS".equals(status)) &&
|
||||
resultUrl != null && !resultUrl.trim().isEmpty()) {
|
||||
logger.info("✅ 任务完成: taskId={}, resultUrl={}", taskQueue.getTaskId(),
|
||||
resultUrl.length() > 100 ? resultUrl.substring(0, 100) + "..." : resultUrl);
|
||||
updateTaskAsCompleted(taskQueue, resultUrl);
|
||||
}
|
||||
// 1b. 状态为完成但没有resultUrl - 记录警告并输出完整响应以便调试
|
||||
else if ("COMPLETED".equals(status) || "SUCCESS".equals(status)) {
|
||||
logger.warn("⚠️ 任务状态为COMPLETED但未找到有效的resultUrl! taskId={}, API响应: {}",
|
||||
taskQueue.getTaskId(), statusResponse);
|
||||
logger.warn("⚠️ taskData详细信息: {}", taskData);
|
||||
// 暂不标记为失败,继续轮询,可能是API延迟更新
|
||||
}
|
||||
// 2. 任务失败:状态为FAILED/ERROR
|
||||
else if ("FAILED".equals(status) || "ERROR".equals(status)) {
|
||||
logger.warn("❌ 任务失败: taskId={}, error={}", taskQueue.getTaskId(), errorMessage);
|
||||
updateTaskAsFailed(taskQueue, errorMessage);
|
||||
}
|
||||
}
|
||||
@@ -1250,7 +1420,6 @@ public class TaskQueueService {
|
||||
}
|
||||
|
||||
if (freshTaskQueue.getStatus() == TaskQueue.QueueStatus.COMPLETED) {
|
||||
logger.debug("任务 {} 已完成,跳过重复处理", taskQueue.getTaskId());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1261,21 +1430,39 @@ public class TaskQueueService {
|
||||
try {
|
||||
userService.deductFrozenPoints(taskQueue.getTaskId());
|
||||
} catch (Exception e) {
|
||||
logger.debug("扣除积分失败(可能已扣除): {}", taskQueue.getTaskId());
|
||||
// 积分扣除失败不影响任务完成状态
|
||||
}
|
||||
|
||||
// 更新原始任务状态
|
||||
updateOriginalTaskStatus(taskQueue, "COMPLETED", resultUrl, null);
|
||||
|
||||
// 创建用户作品 - 在最后执行,避免影响主要流程
|
||||
if (resultUrl != null && !resultUrl.isEmpty()) {
|
||||
|
||||
// 上传视频到COS(如果启用)
|
||||
String finalResultUrl = resultUrl;
|
||||
if (resultUrl != null && !resultUrl.isEmpty() && cosService.isEnabled()) {
|
||||
try {
|
||||
userWorkService.createWorkFromTask(taskQueue.getTaskId(), resultUrl);
|
||||
logger.info("开始上传视频到COS: taskId={}", taskQueue.getTaskId());
|
||||
String cosUrl = cosService.uploadVideoFromUrl(resultUrl, null);
|
||||
if (cosUrl != null && !cosUrl.isEmpty()) {
|
||||
finalResultUrl = cosUrl;
|
||||
// 更新任务的resultUrl为COS URL
|
||||
updateOriginalTaskStatus(taskQueue, "COMPLETED", cosUrl, null);
|
||||
} else {
|
||||
logger.warn("COS上传失败,使用原始URL: taskId={}", taskQueue.getTaskId());
|
||||
}
|
||||
} catch (Exception cosException) {
|
||||
logger.error("上传视频到COS失败,使用原始URL: taskId={}", taskQueue.getTaskId(), cosException);
|
||||
// COS上传失败不影响任务完成,继续使用原始URL
|
||||
}
|
||||
}
|
||||
|
||||
// 创建用户作品 - 在最后执行,避免影响主要流程
|
||||
if (finalResultUrl != null && !finalResultUrl.isEmpty()) {
|
||||
try {
|
||||
userWorkService.createWorkFromTask(taskQueue.getTaskId(), finalResultUrl);
|
||||
} catch (Exception workException) {
|
||||
// 如果是重复创建异常,静默处理
|
||||
if (workException.getMessage() == null ||
|
||||
(!workException.getMessage().contains("已存在") &&
|
||||
if (workException.getMessage() == null ||
|
||||
(!workException.getMessage().contains("已存在") &&
|
||||
!workException.getMessage().contains("Duplicate entry"))) {
|
||||
logger.warn("创建用户作品失败: {}", taskQueue.getTaskId());
|
||||
}
|
||||
@@ -1470,7 +1657,6 @@ public class TaskQueueService {
|
||||
// 更新原始任务状态
|
||||
updateOriginalTaskStatus(taskQueue, "CANCELLED", null, "用户取消了任务");
|
||||
|
||||
logger.info("任务 {} 已取消", taskId);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ public class TextToVideoService {
|
||||
|
||||
@Autowired
|
||||
private TaskQueueService taskQueueService;
|
||||
|
||||
@Autowired
|
||||
private UserWorkService userWorkService;
|
||||
|
||||
@Value("${app.video.output.path:/outputs}")
|
||||
private String outputPath;
|
||||
@@ -73,6 +76,13 @@ public class TextToVideoService {
|
||||
// 添加任务到队列
|
||||
taskQueueService.addTextToVideoTask(username, taskId);
|
||||
|
||||
// 创建PROCESSING状态的UserWork,以便用户刷新页面后能恢复任务
|
||||
try {
|
||||
userWorkService.createProcessingTextToVideoWork(task);
|
||||
} catch (Exception e) {
|
||||
logger.warn("创建PROCESSING状态作品失败(不影响任务执行): {}", taskId, e);
|
||||
}
|
||||
|
||||
logger.info("文生视频任务创建成功: {}, 用户: {}", taskId, username);
|
||||
return task;
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.example.demo.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
@@ -112,6 +114,39 @@ public class UserService {
|
||||
return passwordEncoder.matches(rawPassword, storedPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改指定用户的密码
|
||||
*
|
||||
* 如果用户已经有密码,则需要提供正确的原密码;
|
||||
* 如果用户当前没有设置密码(例如仅使用邮箱验证码登录),则可以直接设置新密码。
|
||||
*/
|
||||
@Transactional
|
||||
public void changePassword(Long userId, String oldPassword, String newPassword) {
|
||||
User user = findById(userId);
|
||||
|
||||
if (newPassword == null || newPassword.isBlank()) {
|
||||
throw new IllegalArgumentException("新密码不能为空");
|
||||
}
|
||||
if (newPassword.length() < 6) {
|
||||
throw new IllegalArgumentException("新密码长度不能少于6位");
|
||||
}
|
||||
|
||||
String currentPasswordHash = user.getPasswordHash();
|
||||
// 如果已经设置过密码,则需要校验原密码
|
||||
if (currentPasswordHash != null && !currentPasswordHash.isBlank()) {
|
||||
if (oldPassword == null || oldPassword.isBlank()) {
|
||||
throw new IllegalArgumentException("原密码不能为空");
|
||||
}
|
||||
if (!checkPassword(oldPassword, currentPasswordHash)) {
|
||||
throw new IllegalArgumentException("原密码不正确");
|
||||
}
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
userRepository.save(user);
|
||||
logger.info("用户 {} 修改密码成功", user.getUsername());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据邮箱查找用户
|
||||
*/
|
||||
@@ -207,13 +242,15 @@ public class UserService {
|
||||
|
||||
/**
|
||||
* 扣除冻结的积分(任务完成)
|
||||
* 使用悲观锁防止并发重复扣除
|
||||
*/
|
||||
@Transactional
|
||||
public void deductFrozenPoints(String taskId) {
|
||||
PointsFreezeRecord record = pointsFreezeRecordRepository.findByTaskId(taskId)
|
||||
// 使用悲观写锁查询,防止并发重复扣除
|
||||
PointsFreezeRecord record = pointsFreezeRecordRepository.findByTaskIdWithLock(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("找不到冻结记录: " + taskId));
|
||||
|
||||
// 如果已经扣除过,直接返回,避免重复扣除(防止多线程并发处理)
|
||||
// 如果已经扣除过,直接返回,避免重复扣除(双重检查,悲观锁已经保证了并发安全)
|
||||
if (record.getStatus() == PointsFreezeRecord.FreezeStatus.DEDUCTED) {
|
||||
logger.info("冻结记录 {} 已扣除,跳过重复扣除", taskId);
|
||||
return;
|
||||
@@ -582,4 +619,26 @@ public class UserService {
|
||||
|
||||
return pointsToAdd > 0 ? pointsToAdd : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计在线用户数(最近N分钟内有活动的用户)
|
||||
*
|
||||
* @param minutes 活跃时间范围(分钟),默认10分钟
|
||||
* @return 在线用户数量
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long countOnlineUsers(int minutes) {
|
||||
LocalDateTime activeAfter = LocalDateTime.now().minusMinutes(minutes);
|
||||
return userRepository.countByLastActiveTimeAfter(activeAfter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计在线用户数(默认10分钟内活跃)
|
||||
*
|
||||
* @return 在线用户数量
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public long countOnlineUsers() {
|
||||
return countOnlineUsers(10);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,12 @@ public class UserWorkService {
|
||||
// 注意:这个检查不是原子的,但配合外部的悲观锁应该能防止大部分并发问题
|
||||
Optional<UserWork> existingWork = userWorkRepository.findByTaskId(taskId);
|
||||
if (existingWork.isPresent()) {
|
||||
logger.info("作品已存在,跳过创建: taskId={}, workId={}", taskId, existingWork.get().getId());
|
||||
return existingWork.get();
|
||||
logger.info("作品已存在,更新resultUrl: taskId={}, workId={}", taskId, existingWork.get().getId());
|
||||
UserWork work = existingWork.get();
|
||||
work.setResultUrl(resultUrl);
|
||||
work.setStatus(UserWork.WorkStatus.COMPLETED);
|
||||
work.setCompletedAt(LocalDateTime.now());
|
||||
return userWorkRepository.save(work);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -99,6 +103,36 @@ public class UserWorkService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建PROCESSING状态的文生视频作品(任务开始时调用)
|
||||
*/
|
||||
public UserWork createProcessingTextToVideoWork(TextToVideoTask task) {
|
||||
// 检查是否已存在
|
||||
Optional<UserWork> existing = userWorkRepository.findByTaskId(task.getTaskId());
|
||||
if (existing.isPresent()) {
|
||||
logger.info("作品已存在,跳过创建: taskId={}", task.getTaskId());
|
||||
return existing.get();
|
||||
}
|
||||
|
||||
UserWork work = new UserWork();
|
||||
work.setUserId(getUserIdByUsername(task.getUsername()));
|
||||
work.setUsername(task.getUsername());
|
||||
work.setTaskId(task.getTaskId());
|
||||
work.setWorkType(UserWork.WorkType.TEXT_TO_VIDEO);
|
||||
work.setTitle(generateTitle(task.getPrompt()));
|
||||
work.setDescription("文生视频作品");
|
||||
work.setPrompt(task.getPrompt());
|
||||
work.setDuration(String.valueOf(task.getDuration()) + "s");
|
||||
work.setAspectRatio(task.getAspectRatio());
|
||||
work.setQuality(task.isHdMode() ? "HD" : "SD");
|
||||
work.setPointsCost(task.getCostPoints()); // 设置预估积分成本
|
||||
work.setStatus(UserWork.WorkStatus.PROCESSING); // 初始状态为PROCESSING
|
||||
|
||||
work = userWorkRepository.save(work);
|
||||
logger.info("创建PROCESSING状态文生视频作品: {}, 用户: {}", work.getId(), work.getUsername());
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文生视频作品
|
||||
*/
|
||||
@@ -124,6 +158,36 @@ public class UserWorkService {
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建PROCESSING状态的图生视频作品(任务开始时调用)
|
||||
*/
|
||||
public UserWork createProcessingImageToVideoWork(ImageToVideoTask task) {
|
||||
// 检查是否已存在
|
||||
Optional<UserWork> existing = userWorkRepository.findByTaskId(task.getTaskId());
|
||||
if (existing.isPresent()) {
|
||||
logger.info("作品已存在,跳过创建: taskId={}", task.getTaskId());
|
||||
return existing.get();
|
||||
}
|
||||
|
||||
UserWork work = new UserWork();
|
||||
work.setUserId(getUserIdByUsername(task.getUsername()));
|
||||
work.setUsername(task.getUsername());
|
||||
work.setTaskId(task.getTaskId());
|
||||
work.setWorkType(UserWork.WorkType.IMAGE_TO_VIDEO);
|
||||
work.setTitle(generateTitle(task.getPrompt()));
|
||||
work.setDescription("图生视频作品");
|
||||
work.setPrompt(task.getPrompt());
|
||||
work.setDuration(String.valueOf(task.getDuration()) + "s");
|
||||
work.setAspectRatio(task.getAspectRatio());
|
||||
work.setQuality(Boolean.TRUE.equals(task.getHdMode()) ? "HD" : "SD");
|
||||
work.setPointsCost(task.getCostPoints()); // 设置预估积分成本
|
||||
work.setStatus(UserWork.WorkStatus.PROCESSING); // 初始状态为PROCESSING
|
||||
|
||||
work = userWorkRepository.save(work);
|
||||
logger.info("创建PROCESSING状态图生视频作品: {}, 用户: {}", work.getId(), work.getUsername());
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建图生视频作品
|
||||
*/
|
||||
@@ -149,6 +213,36 @@ public class UserWorkService {
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建PROCESSING状态的分镜视频作品(任务开始时调用)
|
||||
*/
|
||||
public UserWork createProcessingStoryboardVideoWork(StoryboardVideoTask task) {
|
||||
// 检查是否已存在
|
||||
Optional<UserWork> existing = userWorkRepository.findByTaskId(task.getTaskId());
|
||||
if (existing.isPresent()) {
|
||||
logger.info("作品已存在,跳过创建: taskId={}", task.getTaskId());
|
||||
return existing.get();
|
||||
}
|
||||
|
||||
UserWork work = new UserWork();
|
||||
work.setUserId(getUserIdByUsername(task.getUsername()));
|
||||
work.setUsername(task.getUsername());
|
||||
work.setTaskId(task.getTaskId());
|
||||
work.setWorkType(UserWork.WorkType.STORYBOARD_VIDEO);
|
||||
work.setTitle(generateTitle(task.getPrompt()));
|
||||
work.setDescription("分镜视频作品");
|
||||
work.setPrompt(task.getPrompt());
|
||||
work.setDuration("10s"); // 分镜视频默认10秒
|
||||
work.setAspectRatio(task.getAspectRatio());
|
||||
work.setQuality(task.isHdMode() ? "HD" : "SD");
|
||||
work.setPointsCost(task.getCostPoints()); // 设置预估积分成本
|
||||
work.setStatus(UserWork.WorkStatus.PROCESSING); // 初始状态为PROCESSING
|
||||
|
||||
work = userWorkRepository.save(work);
|
||||
logger.info("创建PROCESSING状态分镜视频作品: {}, 用户: {}", work.getId(), work.getUsername());
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分镜视频作品
|
||||
*/
|
||||
@@ -248,12 +342,13 @@ public class UserWorkService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户作品列表
|
||||
* 获取用户作品列表(只返回有resultUrl的作品)
|
||||
* 用于创作页面历史记录,过滤掉没有resultUrl的作品
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Page<UserWork> getUserWorks(String username, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return userWorkRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
|
||||
return userWorkRepository.findByUsernameWithResultUrlOrderByCreatedAtDesc(username, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -519,4 +614,34 @@ public class UserWorkService {
|
||||
logger.info("更新作品结果URL成功: {} -> {}", taskId, resultUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作品用于下载(包含权限验证)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public UserWork getWorkForDownload(Long workId, String username) {
|
||||
Optional<UserWork> workOpt = userWorkRepository.findById(workId);
|
||||
if (workOpt.isEmpty()) {
|
||||
throw new RuntimeException("作品不存在");
|
||||
}
|
||||
|
||||
UserWork work = workOpt.get();
|
||||
|
||||
// 检查作品是否已完成
|
||||
if (work.getStatus() != UserWork.WorkStatus.COMPLETED) {
|
||||
throw new RuntimeException("作品尚未完成,无法下载");
|
||||
}
|
||||
|
||||
// 检查是否有resultUrl
|
||||
if (work.getResultUrl() == null || work.getResultUrl().isEmpty()) {
|
||||
throw new RuntimeException("作品资源不可用");
|
||||
}
|
||||
|
||||
// 权限验证:只允许作品所有者下载
|
||||
if (!work.getUsername().equals(username)) {
|
||||
throw new RuntimeException("无权下载该作品");
|
||||
}
|
||||
|
||||
return work;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +37,16 @@ springdoc.swagger-ui.display-request-duration=true
|
||||
springdoc.swagger-ui.doc-expansion=none
|
||||
springdoc.swagger-ui.default-models-expand-depth=1
|
||||
springdoc.swagger-ui.default-model-expand-depth=1
|
||||
|
||||
# 腾讯云COS对象存储配置
|
||||
# 是否启用COS(设置为true后需要配置下面的参数)
|
||||
tencent.cos.enabled=false
|
||||
# 腾讯云SecretId(从控制台获取:https://console.cloud.tencent.com/cam/capi)
|
||||
tencent.cos.secret-id=
|
||||
# 腾讯云SecretKey
|
||||
tencent.cos.secret-key=
|
||||
# COS区域(例如:ap-guangzhou、ap-shanghai、ap-beijing等)
|
||||
tencent.cos.region=ap-guangzhou
|
||||
# COS存储桶名称(例如:my-bucket-1234567890)
|
||||
tencent.cos.bucket-name=
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 添加最后活跃时间字段用于统计在线用户
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_active_time TIMESTAMP NULL;
|
||||
|
||||
-- 为已存在的用户设置初始活跃时间(使用最后登录时间或创建时间)
|
||||
UPDATE users
|
||||
SET last_active_time = COALESCE(last_login_at, created_at)
|
||||
WHERE last_active_time IS NULL;
|
||||
|
||||
-- 添加索引以提高查询效率
|
||||
CREATE INDEX IF NOT EXISTS idx_users_last_active_time ON users(last_active_time);
|
||||
@@ -179,10 +179,10 @@
|
||||
|
||||
<form th:action="@{/login}" method="post" id="loginForm">
|
||||
<div class="form-floating">
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
placeholder="用户名" required autocomplete="username">
|
||||
<label for="username">
|
||||
<i class="fas fa-user me-2"></i>用户名
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
placeholder="邮箱" required autocomplete="email">
|
||||
<label for="email">
|
||||
<i class="fas fa-envelope me-2"></i>邮箱
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -225,8 +225,8 @@
|
||||
<div class="demo-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
<strong>演示账户:</strong><br>
|
||||
用户名: demo, 密码: demo<br>
|
||||
用户名: admin, 密码: admin
|
||||
请使用注册时的邮箱登录<br>
|
||||
或使用演示邮箱(如已配置)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,12 +276,20 @@
|
||||
|
||||
// Form validation
|
||||
document.getElementById('loginForm').addEventListener('submit', function(e) {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const password = document.getElementById('password').value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
if (!email || !password) {
|
||||
e.preventDefault();
|
||||
alert('请填写用户名和密码');
|
||||
alert('请填写邮箱和密码');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
e.preventDefault();
|
||||
alert('请输入有效的邮箱地址');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -289,4 +297,3 @@
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user