fix: PayPal payment_method column length issue; add image model selection for storyboard; remove task restore popups; sync UserWork status on task failure

This commit is contained in:
AIGC Developer
2025-12-05 09:57:09 +08:00
parent dbd06435cb
commit b4b0230ee1
484 changed files with 5238 additions and 5379 deletions

View File

@@ -0,0 +1,139 @@
package com.example.demo.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 动态API配置管理器
* 允许在运行时更新API密钥无需重启应用
*/
@Component
public class DynamicApiConfig {
private static final Logger logger = LoggerFactory.getLogger(DynamicApiConfig.class);
// 从配置文件读取初始值
@Value("${ai.api.key:}")
private String initialApiKey;
@Value("${ai.api.base-url:http://116.62.4.26:8081}")
private String initialApiBaseUrl;
@Value("${ai.image.api.key:}")
private String initialImageApiKey;
@Value("${ai.image.api.base-url:https://ai.comfly.chat}")
private String initialImageApiBaseUrl;
// 运行时可更新的值如果为null则使用初始值
private volatile String runtimeApiKey = null;
private volatile String runtimeApiBaseUrl = null;
private volatile String runtimeImageApiKey = null;
private volatile String runtimeImageApiBaseUrl = null;
/**
* 获取当前有效的AI API密钥
* 优先使用运行时设置的值,否则使用配置文件的值
*/
public String getApiKey() {
return runtimeApiKey != null ? runtimeApiKey : initialApiKey;
}
/**
* 获取当前有效的AI API基础URL
*/
public String getApiBaseUrl() {
return runtimeApiBaseUrl != null ? runtimeApiBaseUrl : initialApiBaseUrl;
}
/**
* 获取当前有效的图片API密钥
*/
public String getImageApiKey() {
return runtimeImageApiKey != null ? runtimeImageApiKey : initialImageApiKey;
}
/**
* 获取当前有效的图片API基础URL
*/
public String getImageApiBaseUrl() {
return runtimeImageApiBaseUrl != null ? runtimeImageApiBaseUrl : initialImageApiBaseUrl;
}
/**
* 动态更新API密钥立即生效无需重启
*/
public synchronized void updateApiKey(String newApiKey) {
if (newApiKey != null && !newApiKey.trim().isEmpty()) {
this.runtimeApiKey = newApiKey.trim();
logger.info("✅ API密钥已动态更新立即生效无需重启");
}
}
/**
* 动态更新API基础URL立即生效无需重启
*/
public synchronized void updateApiBaseUrl(String newBaseUrl) {
if (newBaseUrl != null && !newBaseUrl.trim().isEmpty()) {
this.runtimeApiBaseUrl = newBaseUrl.trim();
logger.info("✅ API基础URL已动态更新立即生效无需重启");
}
}
/**
* 动态更新图片API密钥立即生效无需重启
*/
public synchronized void updateImageApiKey(String newApiKey) {
if (newApiKey != null && !newApiKey.trim().isEmpty()) {
this.runtimeImageApiKey = newApiKey.trim();
logger.info("✅ 图片API密钥已动态更新立即生效无需重启");
}
}
/**
* 动态更新图片API基础URL立即生效无需重启
*/
public synchronized void updateImageApiBaseUrl(String newBaseUrl) {
if (newBaseUrl != null && !newBaseUrl.trim().isEmpty()) {
this.runtimeImageApiBaseUrl = newBaseUrl.trim();
logger.info("✅ 图片API基础URL已动态更新立即生效无需重启");
}
}
/**
* 重置为配置文件的初始值
*/
public synchronized void reset() {
this.runtimeApiKey = null;
this.runtimeApiBaseUrl = null;
this.runtimeImageApiKey = null;
this.runtimeImageApiBaseUrl = null;
logger.info("⚠️ API配置已重置为配置文件的初始值");
}
/**
* 获取配置状态信息
*/
public java.util.Map<String, Object> getConfigStatus() {
java.util.Map<String, Object> status = new java.util.HashMap<>();
status.put("apiKey", maskApiKey(getApiKey()));
status.put("apiBaseUrl", getApiBaseUrl());
status.put("imageApiKey", maskApiKey(getImageApiKey()));
status.put("imageApiBaseUrl", getImageApiBaseUrl());
status.put("usingRuntimeConfig", runtimeApiKey != null || runtimeApiBaseUrl != null ||
runtimeImageApiKey != null || runtimeImageApiBaseUrl != null);
return status;
}
/**
* 掩码API密钥只显示前4位和后4位
*/
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() <= 8) {
return "****";
}
return apiKey.substring(0, 4) + "****" + apiKey.substring(apiKey.length() - 4);
}
}

View File

@@ -0,0 +1,109 @@
package com.example.demo.config;
import com.paypal.base.rest.APIContext;
import com.paypal.base.rest.OAuthTokenCredential;
import com.paypal.base.rest.PayPalRESTException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
/**
* PayPal配置类
* 配置PayPal SDK和API上下文
*/
@Configuration
public class PayPalConfig {
private static final Logger logger = LoggerFactory.getLogger(PayPalConfig.class);
@Value("${paypal.client-id:}")
private String clientId;
@Value("${paypal.client-secret:}")
private String clientSecret;
@Value("${paypal.mode:sandbox}")
private String mode;
@Value("${paypal.success-url:http://localhost:8080/payment/paypal/success}")
private String successUrl;
@Value("${paypal.cancel-url:http://localhost:8080/payment/paypal/cancel}")
private String cancelUrl;
/**
* 创建PayPal API上下文
* @return API上下文对象
*/
@Bean
public APIContext apiContext() {
try {
// 验证配置
if (clientId == null || clientId.isEmpty()) {
logger.warn("PayPal Client ID未配置PayPal功能将不可用");
return null;
}
if (clientSecret == null || clientSecret.isEmpty()) {
logger.warn("PayPal Client Secret未配置PayPal功能将不可用");
return null;
}
logger.info("=== 初始化PayPal配置 ===");
logger.info("Client ID: {}...", clientId.substring(0, Math.min(10, clientId.length())));
logger.info("Mode: {}", mode);
logger.info("Success URL: {}", successUrl);
logger.info("Cancel URL: {}", cancelUrl);
// 创建PayPal配置
Map<String, String> configMap = new HashMap<>();
configMap.put("mode", mode);
// 创建OAuth凭证
OAuthTokenCredential credential = new OAuthTokenCredential(clientId, clientSecret, configMap);
// 创建API上下文
APIContext context = new APIContext(credential.getAccessToken());
context.setConfigurationMap(configMap);
logger.info("✅ PayPal配置初始化成功");
return context;
} catch (PayPalRESTException e) {
logger.error("❌ PayPal配置初始化失败", e);
logger.error("错误信息: {}", e.getMessage());
logger.error("详细错误: {}", e.getDetails());
return null;
} catch (Exception e) {
logger.error("❌ PayPal配置初始化失败", e);
return null;
}
}
// Getters
public String getClientId() {
return clientId;
}
public String getClientSecret() {
return clientSecret;
}
public String getMode() {
return mode;
}
public String getSuccessUrl() {
return successUrl;
}
public String getCancelUrl() {
return cancelUrl;
}
}

View File

@@ -0,0 +1,85 @@
package com.example.demo.config;
import kong.unirest.Unirest;
import org.apache.http.impl.client.DefaultHttpRequestRetryHandler;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.client.config.RequestConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;
/**
* Unirest HTTP客户端统一配置类
* 确保Unirest在应用启动时只配置一次避免重复配置警告
*/
@Configuration
public class UnirestConfig {
private static final Logger logger = LoggerFactory.getLogger(UnirestConfig.class);
// 静态标志,确保只配置一次
private static volatile boolean configured = false;
private static final Object lock = new Object();
@PostConstruct
public void init() {
configureUnirest();
}
/**
* 配置Unirest HTTP客户端
* 使用双重检查锁定确保线程安全且只配置一次
*/
public static void configureUnirest() {
if (!configured) {
synchronized (lock) {
if (!configured) {
try {
logger.info("正在初始化Unirest HTTP客户端配置...");
// 先重置,确保干净的状态
Unirest.config().reset();
// 配置连接池和超时
Unirest.config()
.connectTimeout(30000) // 30秒连接超时
.socketTimeout(300000) // 5分钟读取超时300秒
.retryAfter(false) // 禁用自动重试,使用自定义重试逻辑
.httpClient(HttpClients.custom()
.setRetryHandler(new DefaultHttpRequestRetryHandler(0, false)) // 禁用内部重试
.setMaxConnTotal(500) // 最大连接数
.setMaxConnPerRoute(100) // 每路由最大连接数
.setConnectionTimeToLive(30, TimeUnit.SECONDS)
.evictExpiredConnections()
.evictIdleConnections(30, TimeUnit.SECONDS)
.setDefaultRequestConfig(RequestConfig.custom()
.setConnectTimeout(30000)
.setSocketTimeout(300000)
.setConnectionRequestTimeout(30000)
.setContentCompressionEnabled(false) // 禁用压缩,避免额外开销
.build())
.build());
configured = true;
logger.info("Unirest HTTP客户端配置完成: 连接超时30秒, 读取超时300秒");
} catch (Exception e) {
logger.warn("Unirest配置异常: {}", e.getMessage());
// 即使配置失败,也标记为已配置,避免重复尝试
configured = true;
}
}
}
}
}
/**
* 检查是否已配置
*/
public static boolean isConfigured() {
return configured;
}
}

View File

@@ -21,7 +21,9 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.model.User;
import com.example.demo.model.SystemSettings;
import com.example.demo.service.UserService;
import com.example.demo.service.SystemSettingsService;
import com.example.demo.util.JwtUtils;
/**
@@ -40,6 +42,9 @@ public class AdminController {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private SystemSettingsService systemSettingsService;
/**
* 给用户增加积分
*/
@@ -378,5 +383,72 @@ public class AdminController {
return ResponseEntity.status(500).body(response);
}
}
/**
* 获取系统设置
*/
@GetMapping("/settings")
public ResponseEntity<Map<String, Object>> getSettings() {
Map<String, Object> response = new HashMap<>();
try {
SystemSettings settings = systemSettingsService.getOrCreate();
response.put("promptOptimizationModel", settings.getPromptOptimizationModel());
response.put("promptOptimizationApiUrl", settings.getPromptOptimizationApiUrl());
response.put("siteName", settings.getSiteName());
response.put("siteSubtitle", settings.getSiteSubtitle());
response.put("registrationOpen", settings.getRegistrationOpen());
response.put("maintenanceMode", settings.getMaintenanceMode());
response.put("contactEmail", settings.getContactEmail());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("获取系统设置失败", e);
response.put("error", "获取系统设置失败: " + e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
/**
* 更新系统设置
*/
@PutMapping("/settings")
public ResponseEntity<Map<String, Object>> updateSettings(
@RequestBody Map<String, Object> settingsData) {
Map<String, Object> response = new HashMap<>();
try {
SystemSettings settings = systemSettingsService.getOrCreate();
// 更新优化提示词模型
if (settingsData.containsKey("promptOptimizationModel")) {
String model = (String) settingsData.get("promptOptimizationModel");
settings.setPromptOptimizationModel(model);
logger.info("更新优化提示词模型为: {}", model);
}
// 更新优化提示词API端点
if (settingsData.containsKey("promptOptimizationApiUrl")) {
String apiUrl = (String) settingsData.get("promptOptimizationApiUrl");
settings.setPromptOptimizationApiUrl(apiUrl);
logger.info("更新优化提示词API端点为: {}", apiUrl);
}
systemSettingsService.update(settings);
response.put("success", true);
response.put("message", "系统设置更新成功");
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("更新系统设置失败", e);
response.put("success", false);
response.put("message", "更新系统设置失败: " + e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
}

View File

@@ -40,6 +40,9 @@ public class AlipayCallbackController {
@Autowired
private com.example.demo.config.PaymentConfig.AliPayConfig aliPayConfig;
@Autowired
private com.example.demo.service.PaymentService paymentService;
/**
* 支付宝异步通知接口
@@ -151,13 +154,24 @@ public class AlipayCallbackController {
break;
}
// 保存支付记录
// 保存支付记录并处理业务逻辑
if (shouldUpdateOrder) {
paymentRepository.save(payment);
logger.info("支付记录更新成功: 订单号={}, 状态={}", outTradeNo, payment.getStatus());
// TODO: 这里可以触发订单状态更新、发送通知等业务逻辑
// 例如:更新订单状态、扣除用户资源点、发送通知等
// 如果是支付成功,调用统一的支付确认方法(会自动创建订单、增加积分等)
if (payment.getStatus() == PaymentStatus.SUCCESS) {
try {
// 使用PaymentService的确认支付成功方法确保和PayPal一样的处理逻辑
paymentService.confirmPaymentSuccess(payment.getId(), tradeNo);
logger.info("✅ 支付确认成功,已创建订单并增加积分: 订单号={}", outTradeNo);
} catch (Exception e) {
logger.error("❌ 支付确认失败(但支付状态已更新): {}", e.getMessage());
// 即使确认失败,也要保存支付状态,避免丢失支付记录
paymentRepository.save(payment);
}
} else {
// 非成功状态,只保存支付记录
paymentRepository.save(payment);
logger.info("支付记录更新成功: 订单号={}, 状态={}", outTradeNo, payment.getStatus());
}
}
logger.info("========== 支付宝异步通知处理完成 ==========");

View File

@@ -13,6 +13,7 @@ import java.util.Properties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.ResponseEntity;
@@ -23,6 +24,8 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.config.DynamicApiConfig;
@RestController
@RequestMapping("/api/api-key")
@CrossOrigin(origins = "*")
@@ -38,6 +41,9 @@ public class ApiKeyController {
@Value("${jwt.expiration:86400000}")
private Long currentJwtExpiration;
@Autowired
private DynamicApiConfig dynamicApiConfig;
/**
* 获取当前API密钥和JWT配置仅显示部分用于验证
@@ -131,7 +137,12 @@ public class ApiKeyController {
if (newApiKey != null) {
props.setProperty("ai.api.key", newApiKey);
props.setProperty("ai.image.api.key", newApiKey); // 同时更新图片API密钥
logger.info("API密钥已更新");
logger.info("API密钥已更新到配置文件");
// ✅ 动态更新运行时配置,立即生效!
dynamicApiConfig.updateApiKey(newApiKey);
dynamicApiConfig.updateImageApiKey(newApiKey);
logger.info("✅ API密钥已立即生效无需重启应用");
}
// 更新JWT过期时间
@@ -156,7 +167,7 @@ public class ApiKeyController {
if (newJwtExpiration != null) {
message.append("JWT过期时间已更新。");
}
message.append("请重启应用以使配置生效");
message.append("配置已立即生效(无需重启)。如需永久保存,请重启应用加载配置文件");
response.put("message", message.toString());
return ResponseEntity.ok(response);

View File

@@ -47,7 +47,6 @@ public class AuthApiController {
HttpServletRequest request,
HttpServletResponse response) {
try {
logger.info("=== 开始处理密码登录请求 ===");
// 支持使用邮箱+密码登录(账号就是邮箱)或用户名+密码
String emailOrUsername = credentials.get("email");
if (emailOrUsername == null || emailOrUsername.trim().isEmpty()) {
@@ -55,40 +54,24 @@ 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) : "");
// 检查密码
logger.info("开始验证密码...");
boolean passwordMatch = userService.checkPassword(password, user.getPasswordHash());
logger.info("密码验证结果: {}", passwordMatch);
if (!passwordMatch) {
logger.warn("登录失败: 密码不正确 - {}", emailOrUsername);
if (!userService.checkPassword(password, user.getPasswordHash())) {
return ResponseEntity.badRequest().body(createErrorResponse("邮箱/用户名或密码不正确"));
}
@@ -105,7 +88,6 @@ public class AuthApiController {
data.put("token", token);
body.put("data", data);
logger.info("邮箱/用户名密码登录成功:{}", emailOrUsername);
return ResponseEntity.ok(body);
} catch (Exception e) {
logger.error("用户名密码登录出错:", e);
@@ -143,12 +125,10 @@ public class AuthApiController {
if (user == null) {
// 自动注册新用户
try {
logger.info("邮箱验证码登录 - 用户不存在,开始自动注册:{}", email);
// 从邮箱生成用户名(去掉@符号及后面的部分)
String emailPrefix = email.split("@")[0];
String emailDomain = email.contains("@") ? email.split("@")[1] : "";
logger.info("邮箱验证码登录 - 原始邮箱: '{}', 邮箱前缀: '{}', 邮箱域名: '{}'", email, emailPrefix, emailDomain);
// 清理邮箱前缀(移除特殊字符,只保留字母、数字、下划线)
String cleanPrefix = emailPrefix.replaceAll("[^a-zA-Z0-9_]", "_");
@@ -164,7 +144,6 @@ public class AuthApiController {
// 确保用户名长度不超过50个字符
if (baseUsername.length() > 50) {
baseUsername = baseUsername.substring(0, 50);
logger.info("邮箱验证码登录 - 用户名过长,截断为: '{}'", baseUsername);
}
// 确保用户名唯一
@@ -184,7 +163,6 @@ public class AuthApiController {
if (candidateUsername.length() <= 50 &&
userService.findByUsernameOrNull(candidateUsername) == null) {
username = candidateUsername;
logger.info("邮箱验证码登录 - 使用域名标识生成用户名: '{}'", username);
break;
}
}
@@ -207,8 +185,6 @@ public class AuthApiController {
logger.warn("邮箱验证码登录 - 用户名冲突过多,使用时间戳生成: '{}'", username);
}
logger.info("邮箱验证码登录 - 最终用户名: '{}' (原始邮箱: '{}')", username, email);
// 直接创建用户对象并设置所有必要字段
user = new User();
user.setUsername(username);
@@ -222,29 +198,22 @@ public class AuthApiController {
// 保存用户(@PrePersist 会自动设置 createdAt 等字段)
user = userService.save(user);
logger.info("✅ 用户已保存到数据库 - ID: {}, 用户名: {}, 邮箱: {}",
user.getId(), user.getUsername(), user.getEmail());
logger.info("✅ 自动注册新用户成功 - 邮箱: {}, 用户名: {}, ID: {}",
email, username, user.getId());
} catch (IllegalArgumentException e) {
logger.error("自动注册用户失败(参数错误):{} - {}", email, e.getMessage());
logger.error("自动注册用户失败: {}", email, e);
return ResponseEntity.badRequest()
.body(createErrorResponse("用户注册失败:" + e.getMessage()));
} catch (Exception e) {
logger.error("自动注册用户失败{}", email, e);
logger.error("自动注册用户失败: {}", email, e);
return ResponseEntity.badRequest()
.body(createErrorResponse("用户注册失败:" + e.getMessage()));
}
} else {
logger.info("✅ 找到现有用户 - 邮箱: {}, 用户名: {}, ID: {}",
email, user.getUsername(), user.getId());
}
// 生成JWT Token
String token = jwtUtils.generateToken(user.getUsername(), user.getRole(), user.getId());
logger.info("邮箱验证码登录 - 生成JWT token用户名: '{}', 用户ID: {}", user.getUsername(), user.getId());
// 检查是否需要设置密码(首次登录的用户密码为空)
boolean needsPasswordChange = user.getPasswordHash() == null || user.getPasswordHash().isEmpty();
Map<String, Object> body = new HashMap<>();
body.put("success", true);
@@ -253,9 +222,9 @@ public class AuthApiController {
Map<String, Object> data = new HashMap<>();
data.put("user", user);
data.put("token", token);
data.put("needsPasswordChange", needsPasswordChange); // 告诉前端是否需要修改密码
body.put("data", data);
logger.info("用户邮箱验证码登录成功:{}", email);
return ResponseEntity.ok(body);
} catch (Exception e) {
@@ -316,7 +285,6 @@ public class AuthApiController {
savedUser.setPasswordHash(null);
response.put("data", savedUser);
logger.info("用户注册成功:{}", username);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
@@ -339,7 +307,6 @@ public class AuthApiController {
response.put("success", true);
response.put("message", "登出成功");
logger.info("用户登出");
return ResponseEntity.ok(response);
}
@@ -350,12 +317,9 @@ public class AuthApiController {
public ResponseEntity<Map<String, Object>> getCurrentUser(Authentication authentication,
jakarta.servlet.http.HttpServletRequest request) {
try {
logger.info("获取当前用户信息 - authentication: {}", authentication);
if (authentication != null && authentication.isAuthenticated()) {
User user = null;
String username = authentication.getName();
logger.info("当前用户名: {}", username);
try {
// 首先尝试通过用户名查找
@@ -390,8 +354,6 @@ public class AuthApiController {
.body(createErrorResponse("用户不存在: " + username));
}
logger.info("找到用户: ID={}, 用户名={}, 邮箱={}", user.getId(), user.getUsername(), user.getEmail());
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", user);
@@ -403,7 +365,6 @@ public class AuthApiController {
}
}
logger.info("用户未认证");
return ResponseEntity.badRequest()
.body(createErrorResponse("用户未登录"));

View File

@@ -53,8 +53,6 @@ public class GlobalExceptionHandler {
}
}
ex.printStackTrace();
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "请求数据格式错误JSON 格式不正确");
@@ -74,6 +72,26 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
/**
* 处理资源未找到异常 (404)
* 通常是扫描器或错误的URL
*/
@ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class)
public ResponseEntity<Map<String, Object>> handleNoResourceFoundException(
org.springframework.web.servlet.resource.NoResourceFoundException e,
HttpServletRequest request) {
// 这种错误通常不需要 ERROR 级别WARN 即可,且不需要打印堆栈
logger.warn("资源未找到 (404): {} {}", request.getMethod(), request.getRequestURI());
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "未找到资源");
errorResponse.put("path", request.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
/**
* 处理其他所有异常
*/
@@ -87,7 +105,6 @@ public class GlobalExceptionHandler {
if (e.getCause() != null) {
logger.error("异常原因: {}", e.getCause().getMessage());
}
e.printStackTrace();
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);

View File

@@ -0,0 +1,153 @@
package com.example.demo.controller;
import com.example.demo.service.ImageGridService;
import com.example.demo.util.JwtUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.*;
/**
* 图片拼接API控制器
* 用于将多张图片拼接成网格图
*/
@RestController
@RequestMapping("/api/image-grid")
@CrossOrigin(origins = "*", maxAge = 3600)
public class ImageGridApiController {
private static final Logger logger = LoggerFactory.getLogger(ImageGridApiController.class);
@Autowired
private ImageGridService imageGridService;
@Autowired
private JwtUtils jwtUtils;
/**
* 拼接多张图片为六宫格2×3
* 接收多张图片文件或Base64字符串返回拼接后的Base64图片
*/
@PostMapping("/merge")
public ResponseEntity<Map<String, Object>> mergeImages(
@RequestParam(value = "images", required = false) MultipartFile[] imageFiles,
@RequestBody(required = false) Map<String, Object> requestBody,
@RequestHeader(value = "Authorization", required = false) String token) {
Map<String, Object> response = new HashMap<>();
try {
// 验证用户身份
String username = extractUsernameFromToken(token);
if (username == null) {
response.put("success", false);
response.put("message", "用户未登录或token无效");
logger.warn("图片拼接API调用失败: token无效");
return ResponseEntity.status(401).body(response);
}
logger.info("图片拼接API调用: username={}", username);
List<String> imageBase64List = new ArrayList<>();
// 处理文件上传形式
if (imageFiles != null && imageFiles.length > 0) {
logger.info("收到 {} 张图片文件进行拼接", imageFiles.length);
for (MultipartFile file : imageFiles) {
if (file.isEmpty()) {
continue;
}
// 转换为Base64
byte[] bytes = file.getBytes();
String base64 = Base64.getEncoder().encodeToString(bytes);
String contentType = file.getContentType();
if (contentType == null) {
contentType = "image/jpeg";
}
imageBase64List.add("data:" + contentType + ";base64," + base64);
}
}
// 处理JSON Body形式Base64数组
else if (requestBody != null && requestBody.containsKey("images")) {
@SuppressWarnings("unchecked")
List<String> images = (List<String>) requestBody.get("images");
logger.info("收到 {} 张Base64图片进行拼接", images.size());
imageBase64List.addAll(images);
}
// 验证图片数量
if (imageBase64List.isEmpty()) {
response.put("success", false);
response.put("message", "未提供图片");
return ResponseEntity.badRequest().body(response);
}
if (imageBase64List.size() > 6) {
logger.warn("图片数量超过6张只取前6张: {}", imageBase64List.size());
imageBase64List = imageBase64List.subList(0, 6);
}
// 确定网格列数
int cols = 3; // 默认3列2×3六宫格
if (requestBody != null && requestBody.containsKey("cols")) {
cols = (int) requestBody.get("cols");
}
logger.info("开始拼接图片: 数量={}, 列数={}", imageBase64List.size(), cols);
// 调用拼接服务
String mergedImage = imageGridService.mergeImagesToGrid(imageBase64List, cols);
logger.info("图片拼接成功返回Base64长度: {}", mergedImage.length());
response.put("success", true);
response.put("message", "图片拼接成功");
response.put("data", Map.of(
"mergedImage", mergedImage,
"imageCount", imageBase64List.size(),
"cols", cols
));
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("图片拼接失败", e);
response.put("success", false);
response.put("message", "图片拼接失败: " + e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
/**
* 从Token中提取用户名
*/
private String extractUsernameFromToken(String token) {
try {
if (token == null || !token.startsWith("Bearer ")) {
return null;
}
// 提取实际的token
String actualToken = jwtUtils.extractTokenFromHeader(token);
if (actualToken == null) {
return null;
}
// 验证token并获取用户名
String username = jwtUtils.getUsernameFromToken(actualToken);
if (username != null && !jwtUtils.isTokenExpired(actualToken)) {
return username;
}
return null;
} catch (Exception e) {
logger.error("解析token失败", e);
return null;
}
}
}

View File

@@ -8,6 +8,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -322,4 +323,46 @@ public class ImageToVideoApiController {
}
return false;
}
/**
* 删除任务
*/
@DeleteMapping("/tasks/{taskId}")
public ResponseEntity<Map<String, Object>> deleteTask(
@PathVariable String taskId,
@RequestHeader("Authorization") String token) {
Map<String, Object> response = new HashMap<>();
try {
// 验证用户身份
String username = extractUsernameFromToken(token);
if (username == null) {
response.put("success", false);
response.put("message", "用户未登录或token无效");
return ResponseEntity.status(401).body(response);
}
logger.info("删除图生视频任务: taskId={}, username={}", taskId, username);
// 删除任务
boolean deleted = imageToVideoService.deleteTask(taskId, username);
if (deleted) {
response.put("success", true);
response.put("message", "任务删除成功");
return ResponseEntity.ok(response);
} else {
response.put("success", false);
response.put("message", "任务不存在或无权删除");
return ResponseEntity.status(404).body(response);
}
} catch (Exception e) {
logger.error("删除图生视频任务失败: taskId={}", taskId, e);
response.put("success", false);
response.put("message", "删除失败: " + e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
}

View File

@@ -0,0 +1,274 @@
package com.example.demo.controller;
import com.example.demo.model.Payment;
import com.example.demo.model.PaymentStatus;
import com.example.demo.service.PayPalService;
import com.example.demo.service.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* PayPal支付控制器
* 处理PayPal支付相关的HTTP请求
*/
@RestController
@RequestMapping("/api/payment/paypal")
@Tag(name = "PayPal支付", description = "PayPal支付相关接口")
@CrossOrigin(origins = "*")
public class PayPalController {
private static final Logger logger = LoggerFactory.getLogger(PayPalController.class);
@Autowired(required = false)
private PayPalService payPalService;
@Autowired
private PaymentService paymentService;
/**
* 创建PayPal支付
*/
@PostMapping("/create")
@Operation(summary = "创建PayPal支付", description = "创建PayPal支付订单并返回支付URL")
public ResponseEntity<Map<String, Object>> createPayment(@RequestBody Map<String, String> request) {
try {
logger.info("=== 创建PayPal支付请求 ===");
logger.info("请求参数: {}", request);
if (payPalService == null) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "PayPal服务未配置");
return ResponseEntity.badRequest().body(errorResponse);
}
String username = request.get("username");
String orderId = request.get("orderId");
String amount = request.get("amount");
String method = request.get("method");
// 创建支付记录
Payment payment = paymentService.createPayment(username, orderId, amount, method);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("paymentId", payment.getId());
response.put("paymentUrl", payment.getPaymentUrl());
response.put("externalTransactionId", payment.getExternalTransactionId());
logger.info("✅ PayPal支付创建成功: {}", response);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("❌ 创建PayPal支付失败", e);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", e.getMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
}
/**
* PayPal支付成功回调用户同意支付后
*/
@GetMapping("/success")
@Operation(summary = "PayPal支付成功回调", description = "用户在PayPal完成支付后的回调")
public RedirectView paymentSuccess(
@RequestParam("paymentId") Long paymentId,
@RequestParam("PayerID") String payerId,
@RequestParam("token") String token) {
try {
logger.info("=== PayPal支付成功回调 ===");
logger.info("Payment ID: {}", paymentId);
logger.info("Payer ID: {}", payerId);
logger.info("Token: {}", token);
// 获取支付记录
Optional<Payment> paymentOpt = paymentService.findById(paymentId);
if (!paymentOpt.isPresent()) {
logger.error("支付记录不存在: {}", paymentId);
return new RedirectView("/payment/error?message=Payment not found");
}
Payment payment = paymentOpt.get();
String paypalPaymentId = payment.getExternalTransactionId();
// 执行PayPal支付
Map<String, Object> result = payPalService.executePayment(paypalPaymentId, payerId);
if (Boolean.TRUE.equals(result.get("success"))) {
// 更新支付状态为成功
String transactionId = (String) result.get("transactionId");
paymentService.confirmPaymentSuccess(paymentId, transactionId);
logger.info("✅ PayPal支付确认成功");
// 重定向到前端会员订阅页面(支付成功)
return new RedirectView("https://vionow.com/subscription?paymentSuccess=true&paymentId=" + paymentId);
} else {
logger.error("PayPal支付执行失败");
return new RedirectView("https://vionow.com/?paymentError=true&message=Payment execution failed");
}
} catch (Exception e) {
logger.error("❌ PayPal支付成功回调处理失败", e);
return new RedirectView("https://vionow.com/?paymentError=true&message=" + e.getMessage());
}
}
/**
* PayPal支付取消回调用户取消支付
*/
@GetMapping("/cancel")
@Operation(summary = "PayPal支付取消回调", description = "用户取消PayPal支付后的回调")
public RedirectView paymentCancel(@RequestParam("paymentId") Long paymentId) {
try {
logger.info("=== PayPal支付取消回调 ===");
logger.info("Payment ID: {}", paymentId);
// 更新支付状态为取消
paymentService.updatePaymentStatus(paymentId, PaymentStatus.CANCELLED);
logger.info("✅ PayPal支付已取消");
// 重定向到前端首页(支付已取消)
return new RedirectView("https://vionow.com/?paymentCancelled=true&paymentId=" + paymentId);
} catch (Exception e) {
logger.error("❌ PayPal支付取消回调处理失败", e);
return new RedirectView("https://vionow.com/?paymentError=true&message=" + e.getMessage());
}
}
/**
* 查询PayPal支付状态
*/
@GetMapping("/status/{paymentId}")
@Operation(summary = "查询PayPal支付状态", description = "查询指定支付的当前状态")
public ResponseEntity<Map<String, Object>> getPaymentStatus(@PathVariable Long paymentId) {
try {
logger.info("=== 查询PayPal支付状态 ===");
logger.info("Payment ID: {}", paymentId);
Optional<Payment> paymentOpt = paymentService.findById(paymentId);
if (!paymentOpt.isPresent()) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "支付记录不存在");
return ResponseEntity.badRequest().body(errorResponse);
}
Payment payment = paymentOpt.get();
String paypalPaymentId = payment.getExternalTransactionId();
if (paypalPaymentId == null || paypalPaymentId.isEmpty()) {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("status", payment.getStatus().name());
response.put("localStatus", true);
return ResponseEntity.ok(response);
}
// 从PayPal查询状态
Map<String, Object> paypalStatus = payPalService.getPaymentStatus(paypalPaymentId);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("localStatus", payment.getStatus().name());
response.put("paypalStatus", paypalStatus.get("state"));
response.put("paypalDetails", paypalStatus);
logger.info("✅ 查询成功: {}", response);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("❌ 查询PayPal支付状态失败", e);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", e.getMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
}
/**
* PayPal退款
*/
@PostMapping("/refund")
@Operation(summary = "PayPal退款", description = "对指定支付进行退款")
public ResponseEntity<Map<String, Object>> refundPayment(@RequestBody Map<String, String> request) {
try {
logger.info("=== PayPal退款请求 ===");
logger.info("请求参数: {}", request);
if (payPalService == null) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "PayPal服务未配置");
return ResponseEntity.badRequest().body(errorResponse);
}
Long paymentId = Long.parseLong(request.get("paymentId"));
// 获取支付记录
Optional<Payment> paymentOpt = paymentService.findById(paymentId);
if (!paymentOpt.isPresent()) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "支付记录不存在");
return ResponseEntity.badRequest().body(errorResponse);
}
Payment payment = paymentOpt.get();
String saleId = payment.getExternalTransactionId();
if (saleId == null || saleId.isEmpty()) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "无法获取交易ID");
return ResponseEntity.badRequest().body(errorResponse);
}
// 执行退款
Map<String, Object> result = payPalService.refundPayment(saleId);
if (Boolean.TRUE.equals(result.get("success"))) {
// 更新支付状态
paymentService.updatePaymentStatus(paymentId, PaymentStatus.REFUNDED);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("refundId", result.get("refundId"));
response.put("message", "退款成功");
logger.info("✅ PayPal退款成功");
return ResponseEntity.ok(response);
} else {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "退款失败");
return ResponseEntity.badRequest().body(errorResponse);
}
} catch (Exception e) {
logger.error("❌ PayPal退款失败", e);
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", e.getMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
}
}

View File

@@ -21,6 +21,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
import com.example.demo.model.Payment;
import com.example.demo.model.PaymentStatus;
import com.example.demo.model.User;
@@ -384,17 +386,13 @@ public class PaymentApiController {
Authentication authentication,
jakarta.servlet.http.HttpServletRequest request) {
try {
logger.info("=== 开始获取用户订阅信息 ===");
if (authentication == null || !authentication.isAuthenticated()) {
logger.warn("用户未认证");
return ResponseEntity.badRequest()
.body(createErrorResponse("请先登录"));
}
User user = null;
String username = authentication.getName();
logger.info("认证用户名: {}", username);
try {
// Principal 现在是用户名字符串,直接通过用户名查找
@@ -426,8 +424,6 @@ public class PaymentApiController {
logger.error("查找用户失败: {}", e.getMessage(), e);
}
logger.info("查找用户结果: {}", user != null ? "找到用户ID: " + user.getId() : "未找到用户");
if (user == null) {
logger.error("用户不存在: {}", username);
return ResponseEntity.badRequest()
@@ -435,7 +431,6 @@ public class PaymentApiController {
}
// 获取用户最近一次成功的支付记录(包括所有充值记录,不仅仅是订阅)
logger.info("开始查询用户支付记录用户ID: {}", user.getId());
List<Payment> allPayments;
try {
// 获取所有成功支付的记录,按支付时间倒序
@@ -448,7 +443,6 @@ public class PaymentApiController {
return time2.compareTo(time1); // 倒序
})
.collect(java.util.stream.Collectors.toList());
logger.info("用户 {} (ID: {}) 的成功支付记录数量: {}", username, user.getId(), allPayments.size());
} catch (Exception e) {
logger.error("查询支付记录失败用户ID: {}", user.getId(), e);
// 如果查询失败,使用空列表
@@ -469,9 +463,6 @@ public class PaymentApiController {
paidAt = latestPayment.getPaidAt() != null ?
latestPayment.getPaidAt() : latestPayment.getCreatedAt();
logger.info("使用最近的支付记录ID: {}, 描述: {}, 金额: {}, 支付时间: {}",
latestPayment.getId(), description, latestPayment.getAmount(), paidAt);
// 从描述或金额中识别套餐类型
if (description != null) {
if (description.contains("标准版") || description.contains("standard") ||
@@ -490,12 +481,10 @@ public class PaymentApiController {
if (amount.compareTo(new java.math.BigDecimal("59.00")) >= 0 &&
amount.compareTo(new java.math.BigDecimal("259.00")) < 0) {
currentPlan = "标准版会员";
logger.info("根据金额 {} 判断为标准版会员", amount);
}
// 专业版订阅 (259元以上) - 1000积分
else if (amount.compareTo(new java.math.BigDecimal("259.00")) >= 0) {
currentPlan = "专业版会员";
logger.info("根据金额 {} 判断为专业版会员", amount);
}
}
}
@@ -512,9 +501,9 @@ public class PaymentApiController {
}
}
// 计算到期时间(假设订阅有效期为30天
// 计算到期时间(订阅有效期为1个月
if (paidAt != null && !currentPlan.equals("免费版")) {
LocalDateTime expiryDateTime = paidAt.plusDays(30);
LocalDateTime expiryDateTime = paidAt.plusMonths(1);
LocalDateTime now = LocalDateTime.now();
if (expiryDateTime.isAfter(now)) {
@@ -538,35 +527,78 @@ public class PaymentApiController {
subscriptionInfo.put("email", user.getEmail());
subscriptionInfo.put("nickname", user.getNickname());
logger.info("=== 用户订阅信息 ===");
logger.info("当前套餐: {}", currentPlan);
logger.info("到期时间: {}", expiryTime);
logger.info("支付时间: {}", paidAt);
logger.info("积分: {}", user.getPoints());
logger.info("成功支付记录数: {}", allPayments.size());
if (!allPayments.isEmpty()) {
logger.info("最近支付记录: ID={}, 描述={}, 金额={}, 时间={}",
allPayments.get(0).getId(),
allPayments.get(0).getDescription(),
allPayments.get(0).getAmount(),
allPayments.get(0).getPaidAt() != null ? allPayments.get(0).getPaidAt() : allPayments.get(0).getCreatedAt());
}
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("data", subscriptionInfo);
logger.info("=== 用户订阅信息获取成功,返回数据: {} ===", subscriptionInfo);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("获取用户订阅信息失败", e);
logger.error("异常堆栈: ", e);
e.printStackTrace();
return ResponseEntity.status(500)
.body(createErrorResponse("获取用户订阅信息失败: " + e.getMessage()));
}
}
@PostMapping("/alipay/notify")
public String alipayNotify(HttpServletRequest request) {
logger.info("========== [API] 收到支付宝回调请求 ==========");
logger.info("请求方法: {}", request.getMethod());
logger.info("请求URL: {}", request.getRequestURL());
logger.info("Content-Type: {}", request.getContentType());
try {
Map<String, String> params = new java.util.HashMap<>();
request.getParameterMap().forEach((key, values) -> {
if (values != null && values.length > 0) {
params.put(key, values[0]);
}
});
if ("POST".equalsIgnoreCase(request.getMethod())) {
try {
StringBuilder body = new StringBuilder();
try (java.io.BufferedReader reader = request.getReader()) {
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
}
if (body.length() > 0) {
String bodyStr = body.toString();
if (bodyStr.contains("=")) {
String[] pairs = bodyStr.split("&");
for (String pair : pairs) {
String[] keyValue = pair.split("=", 2);
if (keyValue.length == 2) {
try {
params.put(
java.net.URLDecoder.decode(keyValue[0], "UTF-8"),
java.net.URLDecoder.decode(keyValue[1], "UTF-8")
);
} catch (Exception e) {
logger.warn("解析参数失败: {}", pair, e);
}
}
}
}
}
} catch (Exception e) {
logger.warn("读取请求体失败", e);
}
}
logger.info("解析到的参数: {}", params);
boolean success = alipayService.handleNotify(params);
logger.info("处理结果: {}", success ? "success" : "fail");
logger.info("========== [API] 支付宝回调处理完成 ==========");
return success ? "success" : "fail";
} catch (Exception e) {
logger.error("========== [API] 处理支付宝异步通知失败 ==========", e);
return "fail";
}
}
/**
* 创建支付宝支付
*/
@@ -575,12 +607,9 @@ public class PaymentApiController {
@RequestBody Map<String, Object> paymentData,
Authentication authentication) {
try {
logger.info("收到创建支付宝支付请求,数据:{}", paymentData);
String username;
if (authentication != null && authentication.isAuthenticated()) {
username = authentication.getName();
logger.info("用户已认证:{}", username);
} else {
logger.warn("用户未认证,拒绝支付请求");
return ResponseEntity.badRequest()
@@ -588,7 +617,6 @@ public class PaymentApiController {
}
Long paymentId = Long.valueOf(paymentData.get("paymentId").toString());
logger.info("查找支付记录ID{}", paymentId);
Payment payment = paymentService.findById(paymentId)
.orElseThrow(() -> new RuntimeException("支付记录不存在"));
@@ -600,13 +628,9 @@ public class PaymentApiController {
.body(createErrorResponse("无权限操作此支付记录"));
}
logger.info("开始调用支付宝服务创建支付,订单号:{},金额:{}", payment.getOrderId(), payment.getAmount());
// 调用支付宝接口创建支付
Map<String, Object> paymentResult = alipayService.createPayment(payment);
logger.info("支付宝二维码生成成功,订单号:{}", payment.getOrderId());
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "支付宝二维码生成成功");

View File

@@ -45,6 +45,7 @@ public class StoryboardVideoApiController {
String aspectRatio = (String) request.getOrDefault("aspectRatio", "16:9");
Boolean hdMode = (Boolean) request.getOrDefault("hdMode", false);
String imageUrl = (String) request.get("imageUrl");
String imageModel = (String) request.getOrDefault("imageModel", "nano-banana");
// 提取duration参数支持多种类型
Integer duration = 10; // 默认10秒
@@ -58,7 +59,7 @@ public class StoryboardVideoApiController {
logger.warn("无效的duration参数: {}, 使用默认值10", durationObj);
}
}
logger.info("任务参数 - duration: {}, aspectRatio: {}, hdMode: {}", duration, aspectRatio, hdMode);
logger.info("任务参数 - duration: {}, aspectRatio: {}, hdMode: {}, imageModel: {}", duration, aspectRatio, hdMode, imageModel);
if (prompt == null || prompt.trim().isEmpty()) {
return ResponseEntity.badRequest()
@@ -67,7 +68,7 @@ public class StoryboardVideoApiController {
// 创建任务
StoryboardVideoTask task = storyboardVideoService.createTask(
username, prompt, aspectRatio, hdMode != null && hdMode, imageUrl, duration
username, prompt, aspectRatio, hdMode != null && hdMode, imageUrl, duration, imageModel
);
Map<String, Object> response = new HashMap<>();
@@ -87,7 +88,6 @@ public class StoryboardVideoApiController {
.body(Map.of("success", false, "message", e.getMessage()));
} catch (Exception e) {
logger.error("创建分镜视频任务失败", e);
e.printStackTrace(); // 打印完整堆栈
return ResponseEntity.internalServerError()
.body(Map.of("success", false, "message", "创建任务失败: " + e.getMessage()));
}
@@ -116,6 +116,11 @@ public class StoryboardVideoApiController {
taskData.put("status", task.getStatus());
taskData.put("progress", task.getProgress());
taskData.put("resultUrl", task.getResultUrl());
taskData.put("imageUrl", task.getImageUrl()); // 参考图片
taskData.put("prompt", task.getPrompt());
taskData.put("aspectRatio", task.getAspectRatio());
taskData.put("hdMode", task.isHdMode());
taskData.put("duration", task.getDuration());
taskData.put("errorMessage", task.getErrorMessage());
taskData.put("createdAt", task.getCreatedAt());
taskData.put("updatedAt", task.getUpdatedAt());

View File

@@ -7,12 +7,14 @@ import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.model.TaskQueue;
@@ -35,7 +37,7 @@ public class TaskQueueApiController {
private JwtUtils jwtUtils;
/**
* 获取用户的任务队列
* 获取用户的任务队列(仅待处理任务)
*/
@GetMapping("/user-tasks")
public ResponseEntity<Map<String, Object>> getUserTaskQueue(
@@ -68,6 +70,46 @@ public class TaskQueueApiController {
return ResponseEntity.status(500).body(response);
}
}
/**
* 分页获取用户的所有任务队列(包括所有状态)
*/
@GetMapping("/all-tasks")
public ResponseEntity<Map<String, Object>> getUserAllTasks(
@RequestHeader("Authorization") String token,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Map<String, Object> response = new HashMap<>();
try {
String username = extractUsernameFromToken(token);
if (username == null) {
response.put("success", false);
response.put("message", "用户未登录");
return ResponseEntity.status(401).body(response);
}
Page<TaskQueue> taskPage = taskQueueService.getUserAllTasks(username, page, size);
response.put("success", true);
response.put("data", taskPage.getContent());
response.put("total", taskPage.getTotalElements());
response.put("totalPages", taskPage.getTotalPages());
response.put("currentPage", page);
response.put("pageSize", size);
response.put("hasNext", taskPage.hasNext());
response.put("hasPrevious", taskPage.hasPrevious());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("获取用户所有任务失败", e);
response.put("success", false);
response.put("message", "获取任务列表失败:" + e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
/**
* 取消队列中的任务
@@ -258,6 +300,41 @@ public class TaskQueueApiController {
return null;
}
}
/**
* 检查任务是否在队列中(用于前端决定是否需要轮询)
*/
@GetMapping("/check/{taskId}")
public ResponseEntity<Map<String, Object>> checkTaskInQueue(
@PathVariable String taskId,
@RequestHeader("Authorization") String token) {
Map<String, Object> response = new HashMap<>();
try {
String username = extractUsernameFromToken(token);
if (username == null) {
response.put("success", false);
response.put("message", "用户未登录");
return ResponseEntity.status(401).body(response);
}
// 检查任务是否在队列中
boolean inQueue = taskQueueService.isTaskInQueue(taskId);
response.put("success", true);
response.put("inQueue", inQueue);
response.put("taskId", taskId);
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("检查任务队列状态失败", e);
response.put("success", false);
response.put("message", "检查失败:" + e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
}

View File

@@ -44,7 +44,6 @@ public class UserActivityInterceptor implements HandlerInterceptor {
}
} catch (Exception e) {
// 不因活跃时间更新失败而影响正常请求
logger.debug("更新用户活跃时间失败: {}", e.getMessage());
}
return true;
@@ -61,7 +60,7 @@ public class UserActivityInterceptor implements HandlerInterceptor {
userService.save(user);
}
} catch (Exception e) {
logger.debug("异步更新用户活跃时间失败: {}", e.getMessage());
// 忽略更新失败
}
}
}

View File

@@ -29,11 +29,11 @@ public class Payment {
private String currency;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Column(nullable = false, length = 20)
private PaymentMethod paymentMethod;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Column(nullable = false, length = 20)
private PaymentStatus status;
@Column(length = 500)

View File

@@ -1,7 +1,8 @@
package com.example.demo.model;
public enum PaymentMethod {
ALIPAY("支付宝");
ALIPAY("支付宝"),
PAYPAL("PayPal");
private final String displayName;

View File

@@ -31,8 +31,8 @@ public class StoryboardVideoTask {
@Column(columnDefinition = "TEXT")
private String prompt; // 文本描述
@Column(name = "image_url", columnDefinition = "TEXT")
private String imageUrl; // 上传的参考图片URL可选
@Column(name = "image_url", columnDefinition = "LONGTEXT")
private String imageUrl; // 上传的参考图片URL可选支持Base64格式
@Column(nullable = false, length = 10)
private String aspectRatio; // 16:9, 4:3, 1:1, 3:4, 9:16
@@ -43,6 +43,9 @@ public class StoryboardVideoTask {
@Column(nullable = false)
private Integer duration; // 视频时长5, 10, 15
@Column(name = "image_model", length = 50)
private String imageModel = "nano-banana"; // 图像生成模型nano-banana, nano-banana2
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TaskStatus status;
@@ -80,10 +83,22 @@ public class StoryboardVideoTask {
@Column
private LocalDateTime completedAt;
/**
* 任务来源类型AI_GENERATED = AI生成分镜图UPLOADED = 直接上传图片
*/
@Enumerated(EnumType.STRING)
@Column(name = "source_type", length = 20)
private SourceType sourceType = SourceType.AI_GENERATED;
public enum TaskStatus {
PENDING, PROCESSING, COMPLETED, FAILED, CANCELLED
}
public enum SourceType {
AI_GENERATED, // AI生成分镜图
UPLOADED // 直接上传图片
}
// 构造函数
public StoryboardVideoTask() {
this.createdAt = LocalDateTime.now();
@@ -156,6 +171,8 @@ public class StoryboardVideoTask {
public boolean getHdMode() { return hdMode; } // 添加getHdMode方法以支持Boolean类型调用
public Integer getDuration() { return duration; }
public void setDuration(Integer duration) { this.duration = duration; }
public String getImageModel() { return imageModel; }
public void setImageModel(String imageModel) { this.imageModel = imageModel; }
public TaskStatus getStatus() { return status; }
public void setStatus(TaskStatus status) { this.status = status; }
public int getProgress() { return progress; }
@@ -180,4 +197,6 @@ public class StoryboardVideoTask {
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getCompletedAt() { return completedAt; }
public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; }
public SourceType getSourceType() { return sourceType; }
public void setSourceType(SourceType sourceType) { this.sourceType = sourceType; }
}

View File

@@ -66,6 +66,14 @@ public class SystemSettings {
@Column(length = 120)
private String contactEmail = "support@example.com";
/** 优化提示词使用的模型 */
@Column(length = 50)
private String promptOptimizationModel = "gpt-5.1-thinking";
/** 优化提示词API端点 */
@Column(length = 200)
private String promptOptimizationApiUrl = "https://ai.comfly.chat";
public Long getId() {
return id;
}
@@ -153,6 +161,22 @@ public class SystemSettings {
public void setContactEmail(String contactEmail) {
this.contactEmail = contactEmail;
}
public String getPromptOptimizationModel() {
return promptOptimizationModel;
}
public void setPromptOptimizationModel(String promptOptimizationModel) {
this.promptOptimizationModel = promptOptimizationModel;
}
public String getPromptOptimizationApiUrl() {
return promptOptimizationApiUrl;
}
public void setPromptOptimizationApiUrl(String promptOptimizationApiUrl) {
this.promptOptimizationApiUrl = promptOptimizationApiUrl;
}
}

View File

@@ -22,6 +22,9 @@ public class User {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", unique = true, length = 20)
private String userId; // 业务用户ID格式: UID + yyMMdd + 4位随机字符
@NotBlank
@Size(min = 3, max = 50)
@Column(nullable = false, unique = true, length = 50)
@@ -113,6 +116,14 @@ public class User {
this.id = id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}

View File

@@ -45,10 +45,10 @@ public class UserWork {
@Column(name = "prompt", columnDefinition = "TEXT")
private String prompt; // 生成提示词
@Column(name = "result_url", columnDefinition = "TEXT")
@Column(name = "result_url", columnDefinition = "LONGTEXT")
private String resultUrl; // 结果视频URL
@Column(name = "thumbnail_url", columnDefinition = "TEXT")
@Column(name = "thumbnail_url", columnDefinition = "LONGTEXT")
private String thumbnailUrl; // 缩略图URL
@Column(name = "duration", length = 10)

View File

@@ -196,4 +196,10 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
*/
@Query("SELECT o FROM Order o ORDER BY o.createdAt DESC")
List<Order> findRecentOrders(org.springframework.data.domain.Pageable pageable);
/**
* 统计指定时间范围内有订单的不同用户数(日活用户)
*/
@Query("SELECT COUNT(DISTINCT o.user.id) FROM Order o WHERE o.createdAt BETWEEN :startTime AND :endTime")
long countDistinctUsersByCreatedAtBetween(@Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime);
}

View File

@@ -16,6 +16,12 @@ public interface PaymentRepository extends JpaRepository<Payment, Long> {
Optional<Payment> findByOrderId(String orderId);
/**
* 根据ID查询Payment并立即加载User避免LazyInitializationException
*/
@Query("SELECT p FROM Payment p LEFT JOIN FETCH p.user WHERE p.id = :id")
Optional<Payment> findByIdWithUser(@Param("id") Long id);
Optional<Payment> findByExternalTransactionId(String externalTransactionId);
List<Payment> findByUserId(Long userId);
@@ -72,4 +78,16 @@ public interface PaymentRepository extends JpaRepository<Payment, Long> {
"AND (p.description LIKE '%标准版%' OR p.description LIKE '%专业版%' OR p.description LIKE '%会员%') " +
"ORDER BY p.paidAt DESC, p.createdAt DESC")
List<Payment> findLatestSuccessfulSubscriptionByUserId(@Param("userId") Long userId, @Param("status") PaymentStatus status);
/**
* 统计成功支付的不同用户数(付费用户数)
*/
@Query("SELECT COUNT(DISTINCT p.user.id) FROM Payment p WHERE p.status = :status")
long countDistinctUsersByStatus(@Param("status") PaymentStatus status);
/**
* 获取今日成功支付的总金额
*/
@Query("SELECT COALESCE(SUM(p.amount), 0) FROM Payment p WHERE p.status = :status AND p.paidAt BETWEEN :startTime AND :endTime")
java.math.BigDecimal sumAmountByStatusAndPaidAtBetween(@Param("status") PaymentStatus status, @Param("startTime") java.time.LocalDateTime startTime, @Param("endTime") java.time.LocalDateTime endTime);
}

View File

@@ -26,11 +26,11 @@ public interface StoryboardVideoTaskRepository extends JpaRepository<StoryboardV
/**
* 查找超时的分镜视频任务
* 条件状态为PROCESSINGrealTaskId为空说明还在生成分镜图阶段且创建时间超过10分钟
* 条件状态为PROCESSING且更新时间超过指定时间
* 使用updatedAt而非createdAt因为视频生成可能在分镜图生成后很久才开始
*/
@Query("SELECT t FROM StoryboardVideoTask t WHERE t.status = :status " +
"AND (t.realTaskId IS NULL OR t.realTaskId = '') " +
"AND t.createdAt < :timeoutTime")
"AND t.updatedAt < :timeoutTime")
List<StoryboardVideoTask> findTimeoutTasks(
@Param("status") StoryboardVideoTask.TaskStatus status,
@Param("timeoutTime") LocalDateTime timeoutTime

View File

@@ -154,5 +154,12 @@ public interface TaskQueueRepository extends JpaRepository<TaskQueue, Long> {
* 查找创建时间在指定时间之后的任务
*/
List<TaskQueue> findByCreatedAtAfter(LocalDateTime dateTime);
/**
* 查找超时的任务(基于创建时间)
* 状态为PENDING或PROCESSING且创建时间早于指定时间
*/
@Query("SELECT tq FROM TaskQueue tq WHERE tq.status IN ('PENDING', 'PROCESSING') AND tq.createdAt < :timeoutTime")
List<TaskQueue> findTimeoutTasksByCreatedTime(@Param("timeoutTime") LocalDateTime timeoutTime);
}

View File

@@ -11,15 +11,22 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
Optional<User> findByPhone(String phone);
Optional<User> findByUserId(String userId);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
boolean existsByPhone(String phone);
boolean existsByUserId(String userId);
long countByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);
/**
* 统计指定时间之后活跃的用户数量(用于在线用户统计)
*/
long countByLastActiveTimeAfter(LocalDateTime afterTime);
/**
* 统计今日新增用户数
*/
long countByCreatedAtAfter(LocalDateTime startTime);
}

View File

@@ -41,9 +41,10 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
/**
* 根据用户名查找正在进行中和排队中的作品
* 增加时间限制只返回最近24小时内的任务避免返回陈旧的僵尸任务
*/
@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);
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND (uw.status = 'PROCESSING' OR uw.status = 'PENDING') AND uw.createdAt > :afterTime ORDER BY uw.createdAt DESC")
List<UserWork> findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(@Param("username") String username, @Param("afterTime") LocalDateTime afterTime);
/**
* 查找所有PROCESSING状态的作品用于系统重启清理

View File

@@ -51,8 +51,10 @@ public class TaskQueueScheduler {
@Scheduled(fixedRate = 120000) // 2分钟 = 120000毫秒
public void processPendingTasks() {
try {
logger.debug("开始处理待处理任务");
taskQueueService.processPendingTasks();
int count = taskQueueService.processPendingTasks();
if (count > 0) {
logger.info("加载了 {} 个待处理任务到队列", count);
}
} catch (Exception e) {
logger.error("处理待处理任务失败", e);
}
@@ -90,26 +92,26 @@ public class TaskQueueScheduler {
}
/**
* 检查分镜图生成超时任务
* 每2分钟执行一次检查长时间处于PROCESSING状态但没有realTaskId的分镜视频任务
* 如果创建时间超过10分钟则标记为超时失败
* 检查分镜视频超时任务
* 每2分钟执行一次分两个阶段检查:
* 1. 分镜图生成阶段10分钟超时
* 2. 视频生成阶段1小时超时
*/
@Scheduled(fixedRate = 120000) // 2分钟 = 120000毫秒
public void checkStoryboardImageGenerationTimeout() {
try {
int handledCount = storyboardVideoService.checkAndHandleTimeoutTasks();
if (handledCount > 0) {
logger.warn("处理了 {} 个超时的分镜图生成任务", handledCount);
logger.warn("处理了 {} 个超时的分镜视频任务", handledCount);
}
} catch (Exception e) {
logger.error("检查分镜图生成超时任务失败", e);
logger.error("检查分镜视频超时任务失败", e);
}
}
/**
* 检查文生视频超时任务
* 每2分钟执行一次检查长时间处于PROCESSING状态的文生视频任务
* 如果创建时间超过10分钟则标记为超时失败
* 每2分钟执行一次超时时间1小时
*/
@Scheduled(fixedRate = 120000) // 2分钟 = 120000毫秒
public void checkTextToVideoTimeout() {
@@ -125,8 +127,7 @@ public class TaskQueueScheduler {
/**
* 检查图生视频超时任务
* 每2分钟执行一次检查长时间处于PROCESSING状态的图生视频任务
* 如果创建时间超过10分钟则标记为超时失败
* 每2分钟执行一次超时时间1小时
*/
@Scheduled(fixedRate = 120000) // 2分钟 = 120000毫秒
public void checkImageToVideoTimeout() {
@@ -140,6 +141,23 @@ public class TaskQueueScheduler {
}
}
/**
* 检查任务队列超时
* 每2分钟执行一次超时时间1小时
* 超过1小时仍在PENDING或PROCESSING状态的队列任务将被标记为失败
*/
@Scheduled(fixedRate = 120000) // 2分钟 = 120000毫秒
public void checkQueueTimeout() {
try {
int handledCount = taskQueueService.checkAndHandleQueueTimeoutTasks();
if (handledCount > 0) {
logger.warn("处理了 {} 个超时的队列任务", handledCount);
}
} catch (Exception e) {
logger.error("检查队列超时任务失败", e);
}
}
/**
* 清理过期任务
* 每天凌晨2点执行一次

View File

@@ -42,7 +42,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@NonNull FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
logger.info("JWT过滤器处理请求: {}", requestURI);
// 定义不需要JWT验证的路径
String[] publicPaths = {
@@ -64,7 +63,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
for (String publicPath : publicPaths) {
if (requestURI.contains(publicPath)) {
isPublicPath = true;
logger.info("JWT过滤器: 跳过公开路径 {}", requestURI);
break;
}
}
@@ -77,19 +75,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
try {
String authHeader = request.getHeader("Authorization");
logger.debug("Authorization头: {}", authHeader);
String token = jwtUtils.extractTokenFromHeader(authHeader);
logger.debug("提取的token: {}", token != null ? "存在" : "不存在");
if (token != null && !token.equals("null") && !token.trim().isEmpty()) {
String username = jwtUtils.getUsernameFromToken(token);
logger.debug("从token获取用户名: {}", username);
if (username != null && jwtUtils.validateToken(token, username)) {
logger.info("JWT认证 - 从token获取的用户名: '{}'", username);
User user = userService.findByUsername(username);
logger.info("JWT认证 - 数据库查找结果: {}", user != null ? "找到用户 '" + user.getUsername() + "'" : "未找到用户");
if (user != null) {
// 创建用户权限列表
@@ -102,15 +95,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
new UsernamePasswordAuthenticationToken(user.getUsername(), null, authorities);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
logger.debug("JWT认证成功用户: {}, 角色: {}", username, user.getRole());
} else {
logger.warn("JWT中的用户不存在: {}", username);
}
} else {
logger.debug("JWT验证失败用户: {}", username);
}
} else {
logger.debug("没有token");
}
} catch (Exception e) {
logger.error("JWT认证过程中发生异常: {}", e.getMessage(), e);

View File

@@ -22,31 +22,10 @@ public class ApiResponseHandler {
private static final Logger logger = LoggerFactory.getLogger(ApiResponseHandler.class);
private final ObjectMapper objectMapper;
private static boolean unirestConfigured = false;
public ApiResponseHandler() {
this.objectMapper = new ObjectMapper();
// 只配置一次Unirest
configureUnirest();
}
/**
* 配置Unirest - 使用同步锁确保只配置一次
*/
private synchronized void configureUnirest() {
if (!unirestConfigured) {
try {
// 设置Unirest超时配置
Unirest.config()
.connectTimeout(30000) // 30秒连接超时
.socketTimeout(300000); // 5分钟读取超时
unirestConfigured = true;
logger.info("Unirest配置完成");
} catch (Exception e) {
// 如果配置失败(例如已经配置过),则忽略错误
logger.warn("Unirest配置异常可能已经配置过: {}", e.getMessage());
}
}
// Unirest配置已移至 UnirestConfig 统一管理,避免重复配置警告
}
/**

View File

@@ -38,40 +38,24 @@ public class DashboardService {
long totalUsers = userRepository.count();
overview.put("totalUsers", totalUsers);
// 付费用户数(有成功支付记录的不同用户数)
long payingUsers = paymentRepository.findAll().stream()
.filter(payment -> payment.getStatus() == PaymentStatus.SUCCESS)
.map(payment -> payment.getUser().getId())
.distinct()
.count();
// 付费用户数(有成功支付记录的不同用户数)- 优化:直接在数据库层面统计
long payingUsers = paymentRepository.countDistinctUsersByStatus(PaymentStatus.SUCCESS);
overview.put("payingUsers", payingUsers);
// 今日收入(今日成功支付的金额)
// 今日收入(今日成功支付的金额)- 优化:直接在数据库层面求和
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime todayEnd = LocalDate.now().atTime(23, 59, 59);
List<Payment> todayPayments = paymentRepository.findAll().stream()
.filter(payment -> payment.getStatus() == PaymentStatus.SUCCESS)
.filter(payment -> payment.getPaidAt() != null &&
payment.getPaidAt().isAfter(todayStart) &&
payment.getPaidAt().isBefore(todayEnd))
.toList();
BigDecimal todayRevenue = todayPayments.stream()
.map(Payment::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal todayRevenue = paymentRepository.sumAmountByStatusAndPaidAtBetween(
PaymentStatus.SUCCESS, todayStart, todayEnd);
overview.put("todayRevenue", todayRevenue);
// 转化率(付费用户数 / 总用户数)
double conversionRate = totalUsers > 0 ? (double) payingUsers / totalUsers * 100 : 0;
overview.put("conversionRate", Math.round(conversionRate * 100.0) / 100.0);
// 今日新增用户
long todayNewUsers = userRepository.findAll().stream()
.filter(user -> user.getCreatedAt() != null &&
user.getCreatedAt().isAfter(todayStart) &&
user.getCreatedAt().isBefore(todayEnd))
.count();
// 今日新增用户 - 优化:直接在数据库层面统计
long todayNewUsers = userRepository.countByCreatedAtBetween(todayStart, todayEnd);
overview.put("todayNewUsers", todayNewUsers);
return overview;
@@ -91,14 +75,8 @@ public class DashboardService {
LocalDateTime startOfDay = date.atStartOfDay();
LocalDateTime endOfDay = date.atTime(23, 59, 59);
// 统计当天有订单的用户数作为日活
long dailyActiveUsers = orderRepository.findAll().stream()
.filter(order -> order.getCreatedAt() != null &&
order.getCreatedAt().isAfter(startOfDay) &&
order.getCreatedAt().isBefore(endOfDay))
.map(Order::getUser)
.distinct()
.count();
// 统计当天有订单的用户数作为日活 - 优化:直接在数据库层面统计
long dailyActiveUsers = orderRepository.countDistinctUsersByCreatedAtBetween(startOfDay, endOfDay);
Map<String, Object> dayData = new HashMap<>();
dayData.put("date", date.format(DateTimeFormatter.ofPattern("MM-dd")));
@@ -124,14 +102,9 @@ public class DashboardService {
LocalDateTime startOfDay = date.atStartOfDay();
LocalDateTime endOfDay = date.atTime(23, 59, 59);
// 统计当天成功支付的金额
BigDecimal dailyRevenue = paymentRepository.findAll().stream()
.filter(payment -> payment.getStatus() == PaymentStatus.SUCCESS)
.filter(payment -> payment.getPaidAt() != null &&
payment.getPaidAt().isAfter(startOfDay) &&
payment.getPaidAt().isBefore(endOfDay))
.map(Payment::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 统计当天成功支付的金额 - 优化:直接在数据库层面求和
BigDecimal dailyRevenue = paymentRepository.sumAmountByStatusAndPaidAtBetween(
PaymentStatus.SUCCESS, startOfDay, endOfDay);
Map<String, Object> dayData = new HashMap<>();
dayData.put("date", date.format(DateTimeFormatter.ofPattern("MM-dd")));

View File

@@ -72,9 +72,22 @@ public class ImageGridService {
}
}
// 创建网格图片,每张图片使用统一尺寸
// 创建网格图片
// 对于6宫格3列2行强制整体比例为16:9
int cellWidth = maxWidth;
int cellHeight = maxHeight;
// 6宫格特殊处理3列2行整体16:9
// 16:9 = (3*cellWidth):(2*cellHeight)
// cellWidth:cellHeight = 32:27
if (imageCount == 6 && gridCols == 3 && gridRows == 2) {
// 基于最大尺寸计算16:9整体比例的单元格尺寸
int baseSize = Math.max(maxWidth, maxHeight);
cellWidth = (int)(baseSize * 32.0 / 27.0); // 32:27比例
cellHeight = baseSize;
logger.info("6宫格使用16:9比例单元格尺寸: {}x{}", cellWidth, cellHeight);
}
BufferedImage gridImage = new BufferedImage(
gridCols * cellWidth,
gridRows * cellHeight,
@@ -82,7 +95,9 @@ public class ImageGridService {
);
Graphics2D g = gridImage.createGraphics();
g.setColor(java.awt.Color.WHITE);
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.setColor(java.awt.Color.BLACK); // 黑色背景
g.fillRect(0, 0, gridImage.getWidth(), gridImage.getHeight());
// 将图片放置到网格中
@@ -93,11 +108,16 @@ public class ImageGridService {
int y = row * cellHeight;
BufferedImage img = images[i];
// 如果图片尺寸不同,居中放置
int imgX = x + (cellWidth - img.getWidth()) / 2;
int imgY = y + (cellHeight - img.getHeight()) / 2;
// 缩放图片以适应单元格,保持比例居中
double scaleX = (double) cellWidth / img.getWidth();
double scaleY = (double) cellHeight / img.getHeight();
double scale = Math.min(scaleX, scaleY);
int scaledWidth = (int)(img.getWidth() * scale);
int scaledHeight = (int)(img.getHeight() * scale);
int imgX = x + (cellWidth - scaledWidth) / 2;
int imgY = y + (cellHeight - scaledHeight) / 2;
g.drawImage(img, imgX, imgY, null);
g.drawImage(img, imgX, imgY, scaledWidth, scaledHeight, null);
}
g.dispose();

View File

@@ -45,6 +45,9 @@ public class ImageToVideoService {
@Autowired
private UserWorkService userWorkService;
@Autowired
private UserService userService;
@Value("${app.upload.path:/uploads}")
private String uploadPath;
@@ -242,6 +245,8 @@ public class ImageToVideoService {
currentTask.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
currentTask.setErrorMessage("任务提交失败API未返回有效的任务ID");
taskRepository.save(currentTask);
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
return CompletableFuture.completedFuture(null); // 直接返回,不进行轮询
}
@@ -260,6 +265,8 @@ public class ImageToVideoService {
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
task.setErrorMessage(e.getMessage());
taskRepository.save(task);
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
} catch (Exception saveException) {
logger.error("保存失败状态时出错: {}", task.getTaskId(), saveException);
}
@@ -334,6 +341,8 @@ public class ImageToVideoService {
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
task.setErrorMessage(errorMessage);
taskRepository.save(task);
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
logger.error("图生视频任务失败: {}", task.getTaskId());
return;
} else if ("processing".equals(status) || "pending".equals(status) || "running".equals(status)) {
@@ -363,6 +372,8 @@ public class ImageToVideoService {
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
task.setErrorMessage("任务处理超时");
taskRepository.save(task);
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
logger.error("图生视频任务超时: {}", task.getTaskId());
} catch (InterruptedException e) {
@@ -490,16 +501,16 @@ public class ImageToVideoService {
/**
* 检查并处理超时的图生视频任务
* 如果任务状态为PROCESSING且创建时间超过10分钟则标记为超时
* 注意如果任务已经有resultUrl视频已生成即使超时也不标记为失败,因为视频已经成功生成
* 超时时间1小时视频生成需要较长时间
* 注意如果任务已经有resultUrl视频已生成即使超时也不标记为失败
*/
@Transactional
public int checkAndHandleTimeoutTasks() {
try {
// 计算超时时间点10分钟前)
LocalDateTime timeoutTime = LocalDateTime.now().minusMinutes(10);
// 计算超时时间点1小时前)
LocalDateTime timeoutTime = LocalDateTime.now().minusMinutes(60);
// 查找超时的任务状态为PROCESSING创建时间超过10分钟
// 查找超时的任务状态为PROCESSING创建时间超过1小时
List<ImageToVideoTask> timeoutTasks = taskRepository.findTimeoutTasks(
ImageToVideoTask.TaskStatus.PROCESSING,
timeoutTime
@@ -525,9 +536,20 @@ public class ImageToVideoService {
// 更新任务状态为失败
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
task.setErrorMessage("图生视频任务超时任务创建后超过10分钟仍未完成");
task.setErrorMessage("图生视频任务超时任务创建后超过1小时仍未完成");
taskRepository.save(task);
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
// 同步更新 UserWork 表的状态
try {
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
com.example.demo.model.UserWork.WorkStatus.FAILED);
} catch (Exception e) {
logger.warn("更新UserWork状态失败: taskId={}", task.getTaskId());
}
logger.warn("图生视频任务超时,已标记为失败: taskId={}", task.getTaskId());
handledCount++;
@@ -547,4 +569,51 @@ public class ImageToVideoService {
return 0;
}
}
/**
* 删除任务
* @param taskId 任务ID
* @param username 用户名(用于权限验证)
* @return 是否删除成功
*/
@Transactional
public boolean deleteTask(String taskId, String username) {
try {
// 查找任务
ImageToVideoTask task = taskRepository.findByTaskId(taskId).orElse(null);
if (task == null) {
logger.warn("删除任务失败:任务不存在, taskId={}", taskId);
return false;
}
// 验证权限
if (!task.getUsername().equals(username)) {
logger.warn("删除任务失败:无权限, taskId={}, taskUsername={}, requestUsername={}",
taskId, task.getUsername(), username);
return false;
}
// 删除任务
taskRepository.delete(task);
logger.info("图生视频任务删除成功: taskId={}, username={}", taskId, username);
return true;
} catch (Exception e) {
logger.error("删除图生视频任务失败: taskId={}", taskId, e);
throw new RuntimeException("删除任务失败: " + e.getMessage());
}
}
/**
* 安全返还冻结积分(捕获异常,避免影响主流程)
*/
private void returnFrozenPointsSafely(String taskId) {
try {
userService.returnFrozenPoints(taskId);
logger.info("已返还冻结积分: taskId={}", taskId);
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能未冻结): taskId={}, error={}", taskId, e.getMessage());
}
}
}

View File

@@ -0,0 +1,307 @@
package com.example.demo.service;
import com.example.demo.config.PayPalConfig;
import com.example.demo.model.Payment;
import com.paypal.api.payments.*;
import com.paypal.base.rest.APIContext;
import com.paypal.base.rest.PayPalRESTException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
/**
* PayPal支付服务
* 处理PayPal支付创建、执行和验证
*/
@Service
public class PayPalService {
private static final Logger logger = LoggerFactory.getLogger(PayPalService.class);
@Autowired(required = false)
private APIContext apiContext;
@Autowired
private PayPalConfig payPalConfig;
/**
* 创建PayPal支付
* @param payment 支付记录
* @return 包含支付URL的Map
*/
public Map<String, Object> createPayment(Payment payment) {
try {
if (apiContext == null) {
throw new RuntimeException("PayPal未配置或配置无效");
}
logger.info("=== 创建PayPal支付 ===");
logger.info("订单ID: {}", payment.getOrderId());
logger.info("金额: {} {}", payment.getAmount(), payment.getCurrency());
// 创建支付金额
Amount amount = new Amount();
amount.setCurrency(convertCurrency(payment.getCurrency()));
amount.setTotal(formatAmount(payment.getAmount()));
// 创建交易
Transaction transaction = new Transaction();
transaction.setDescription(payment.getDescription() != null ?
payment.getDescription() : "订单支付 - " + payment.getOrderId());
transaction.setAmount(amount);
// 可选:添加商品详情
ItemList itemList = new ItemList();
List<Item> items = new ArrayList<>();
Item item = new Item();
item.setName(payment.getDescription() != null ? payment.getDescription() : "订单");
item.setCurrency(convertCurrency(payment.getCurrency()));
item.setPrice(formatAmount(payment.getAmount()));
item.setQuantity("1");
items.add(item);
itemList.setItems(items);
transaction.setItemList(itemList);
List<Transaction> transactions = new ArrayList<>();
transactions.add(transaction);
// 创建支付方式
Payer payer = new Payer();
payer.setPaymentMethod("paypal");
// 创建支付对象
com.paypal.api.payments.Payment paypalPayment = new com.paypal.api.payments.Payment();
paypalPayment.setIntent("sale");
paypalPayment.setPayer(payer);
paypalPayment.setTransactions(transactions);
// 设置重定向URL
RedirectUrls redirectUrls = new RedirectUrls();
redirectUrls.setCancelUrl(payPalConfig.getCancelUrl() + "?paymentId=" + payment.getId());
redirectUrls.setReturnUrl(payPalConfig.getSuccessUrl() + "?paymentId=" + payment.getId());
paypalPayment.setRedirectUrls(redirectUrls);
// 创建支付
com.paypal.api.payments.Payment createdPayment = paypalPayment.create(apiContext);
logger.info("✅ PayPal支付创建成功");
logger.info("PayPal Payment ID: {}", createdPayment.getId());
logger.info("State: {}", createdPayment.getState());
// 获取批准URL
String approvalUrl = null;
for (Links link : createdPayment.getLinks()) {
if (link.getRel().equals("approval_url")) {
approvalUrl = link.getHref();
break;
}
}
if (approvalUrl == null) {
throw new RuntimeException("无法获取PayPal批准URL");
}
logger.info("Approval URL: {}", approvalUrl);
// 返回结果
Map<String, Object> result = new HashMap<>();
result.put("paymentUrl", approvalUrl);
result.put("paypalPaymentId", createdPayment.getId());
result.put("state", createdPayment.getState());
return result;
} catch (PayPalRESTException e) {
logger.error("❌ 创建PayPal支付失败", e);
logger.error("错误信息: {}", e.getMessage());
logger.error("详细错误: {}", e.getDetails());
throw new RuntimeException("创建PayPal支付失败: " + e.getMessage());
} catch (Exception e) {
logger.error("❌ 创建PayPal支付失败", e);
throw new RuntimeException("创建PayPal支付失败: " + e.getMessage());
}
}
/**
* 执行PayPal支付
* @param paymentId PayPal支付ID
* @param payerId 支付者ID
* @return 执行结果
*/
public Map<String, Object> executePayment(String paymentId, String payerId) {
try {
if (apiContext == null) {
throw new RuntimeException("PayPal未配置或配置无效");
}
logger.info("=== 执行PayPal支付 ===");
logger.info("Payment ID: {}", paymentId);
logger.info("Payer ID: {}", payerId);
// 获取支付对象
com.paypal.api.payments.Payment payment = com.paypal.api.payments.Payment.get(apiContext, paymentId);
// 创建执行对象
PaymentExecution paymentExecution = new PaymentExecution();
paymentExecution.setPayerId(payerId);
// 执行支付
com.paypal.api.payments.Payment executedPayment = payment.execute(apiContext, paymentExecution);
logger.info("✅ PayPal支付执行成功");
logger.info("State: {}", executedPayment.getState());
// 获取交易ID
String transactionId = null;
if (executedPayment.getTransactions() != null && !executedPayment.getTransactions().isEmpty()) {
Transaction transaction = executedPayment.getTransactions().get(0);
if (transaction.getRelatedResources() != null && !transaction.getRelatedResources().isEmpty()) {
RelatedResources relatedResource = transaction.getRelatedResources().get(0);
if (relatedResource.getSale() != null) {
transactionId = relatedResource.getSale().getId();
}
}
}
logger.info("Transaction ID: {}", transactionId);
// 返回结果
Map<String, Object> result = new HashMap<>();
result.put("state", executedPayment.getState());
result.put("paymentId", executedPayment.getId());
result.put("transactionId", transactionId);
result.put("success", "approved".equalsIgnoreCase(executedPayment.getState()));
return result;
} catch (PayPalRESTException e) {
logger.error("❌ 执行PayPal支付失败", e);
logger.error("错误信息: {}", e.getMessage());
logger.error("详细错误: {}", e.getDetails());
throw new RuntimeException("执行PayPal支付失败: " + e.getMessage());
} catch (Exception e) {
logger.error("❌ 执行PayPal支付失败", e);
throw new RuntimeException("执行PayPal支付失败: " + e.getMessage());
}
}
/**
* 查询PayPal支付状态
* @param paymentId PayPal支付ID
* @return 支付状态
*/
public Map<String, Object> getPaymentStatus(String paymentId) {
try {
if (apiContext == null) {
throw new RuntimeException("PayPal未配置或配置无效");
}
logger.info("=== 查询PayPal支付状态 ===");
logger.info("Payment ID: {}", paymentId);
// 获取支付对象
com.paypal.api.payments.Payment payment = com.paypal.api.payments.Payment.get(apiContext, paymentId);
logger.info("✅ 查询成功");
logger.info("State: {}", payment.getState());
// 返回结果
Map<String, Object> result = new HashMap<>();
result.put("state", payment.getState());
result.put("paymentId", payment.getId());
result.put("createTime", payment.getCreateTime());
result.put("updateTime", payment.getUpdateTime());
return result;
} catch (PayPalRESTException e) {
logger.error("❌ 查询PayPal支付状态失败", e);
logger.error("错误信息: {}", e.getMessage());
throw new RuntimeException("查询PayPal支付状态失败: " + e.getMessage());
} catch (Exception e) {
logger.error("❌ 查询PayPal支付状态失败", e);
throw new RuntimeException("查询PayPal支付状态失败: " + e.getMessage());
}
}
/**
* 转换货币代码
* PayPal不直接支持CNY需要转换为USD
* @param currency 原始货币代码
* @return PayPal支持的货币代码
*/
private String convertCurrency(String currency) {
if ("CNY".equalsIgnoreCase(currency)) {
logger.info("货币转换: CNY -> USD");
return "USD";
}
return currency;
}
/**
* 格式化金额
* PayPal要求金额最多2位小数
* @param amount 原始金额
* @return 格式化后的金额字符串
*/
private String formatAmount(BigDecimal amount) {
if (amount == null) {
return "0.00";
}
BigDecimal rounded = amount.setScale(2, RoundingMode.HALF_UP);
return rounded.toString();
}
/**
* 取消PayPal支付退款
* @param saleId 交易ID
* @return 退款结果
*/
public Map<String, Object> refundPayment(String saleId) {
try {
if (apiContext == null) {
throw new RuntimeException("PayPal未配置或配置无效");
}
logger.info("=== 退款PayPal支付 ===");
logger.info("Sale ID: {}", saleId);
// 获取Sale对象
Sale sale = Sale.get(apiContext, saleId);
// 创建退款对象
RefundRequest refundRequest = new RefundRequest();
// 执行退款
DetailedRefund refund = sale.refund(apiContext, refundRequest);
logger.info("✅ PayPal退款成功");
logger.info("Refund ID: {}", refund.getId());
logger.info("State: {}", refund.getState());
// 返回结果
Map<String, Object> result = new HashMap<>();
result.put("refundId", refund.getId());
result.put("state", refund.getState());
result.put("success", "completed".equalsIgnoreCase(refund.getState()));
return result;
} catch (PayPalRESTException e) {
logger.error("❌ PayPal退款失败", e);
logger.error("错误信息: {}", e.getMessage());
throw new RuntimeException("PayPal退款失败: " + e.getMessage());
} catch (Exception e) {
logger.error("❌ PayPal退款失败", e);
throw new RuntimeException("PayPal退款失败: " + e.getMessage());
}
}
}

View File

@@ -41,6 +41,9 @@ public class PaymentService {
@Autowired
private AlipayService alipayService;
@Autowired(required = false)
private PayPalService payPalService;
/**
* 保存支付记录
*/
@@ -56,11 +59,11 @@ public class PaymentService {
}
/**
* 根据ID查找支付记录
* 根据ID查找支付记录包含User信息避免LazyInitializationException
*/
@Transactional(readOnly = true)
public Optional<Payment> findById(Long id) {
return paymentRepository.findById(id);
return paymentRepository.findByIdWithUser(id);
}
/**
@@ -348,6 +351,24 @@ public class PaymentService {
logger.error("调用支付宝支付接口失败:", e);
// 不抛出异常,让前端处理
}
} else if (paymentMethod == PaymentMethod.PAYPAL) {
try {
if (payPalService == null) {
throw new RuntimeException("PayPal服务未配置");
}
Map<String, Object> paymentResult = payPalService.createPayment(savedPayment);
if (paymentResult.containsKey("paymentUrl")) {
savedPayment.setPaymentUrl(paymentResult.get("paymentUrl").toString());
}
if (paymentResult.containsKey("paypalPaymentId")) {
savedPayment.setExternalTransactionId(paymentResult.get("paypalPaymentId").toString());
}
save(savedPayment);
logger.info("PayPal支付链接生成成功: {}", paymentResult.get("paymentUrl"));
} catch (Exception e) {
logger.error("调用PayPal支付接口失败", e);
throw new RuntimeException("创建PayPal支付失败: " + e.getMessage());
}
}
return savedPayment;

View File

@@ -7,9 +7,12 @@ import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.example.demo.config.DynamicApiConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import kong.unirest.HttpResponse;
@@ -25,17 +28,24 @@ public class RealAIService {
private static final Logger logger = LoggerFactory.getLogger(RealAIService.class);
@Value("${ai.api.base-url:http://116.62.4.26:8081}")
private String aiApiBaseUrl;
@Autowired
private DynamicApiConfig dynamicApiConfig;
@Autowired
private SystemSettingsService systemSettingsService;
// 保留@Value作为后备但优先使用DynamicApiConfig
@Value("${ai.api.base-url:https://ai.comfly.chat}")
private String fallbackApiBaseUrl;
@Value("${ai.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
private String aiApiKey;
private String fallbackApiKey;
@Value("${ai.image.api.base-url:https://ai.comfly.chat}")
private String aiImageApiBaseUrl;
private String fallbackImageApiBaseUrl;
@Value("${ai.image.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
private String aiImageApiKey;
private String fallbackImageApiKey;
private final ObjectMapper objectMapper;
@@ -46,27 +56,59 @@ public class RealAIService {
// objectMapper.setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS);
// 默认行为是包含null值这是正确的
// 设置Unirest超时 - 参考Comfly实现连接超时30秒读取超时5分钟300秒匹配Python requests timeout=300
// 禁用Apache HttpClient的内部重试使用我们自己的重试机制
Unirest.config()
.connectTimeout(30000) // 30秒连接超时
.socketTimeout(300000) // 5分钟读取超时300秒匹配参考代码
.retryAfter(false) // 禁用自动重试,使用我们自己的重试逻辑
.httpClient(org.apache.http.impl.client.HttpClients.custom()
.setRetryHandler(new org.apache.http.impl.client.DefaultHttpRequestRetryHandler(0, false)) // 禁用内部重试
.setMaxConnTotal(500)
.setMaxConnPerRoute(100)
.setConnectionTimeToLive(30, java.util.concurrent.TimeUnit.SECONDS)
.evictExpiredConnections()
.evictIdleConnections(30, java.util.concurrent.TimeUnit.SECONDS)
// 配置请求和响应缓冲区
.setDefaultRequestConfig(org.apache.http.client.config.RequestConfig.custom()
.setConnectTimeout(30000)
.setSocketTimeout(300000)
.setConnectionRequestTimeout(30000)
.setContentCompressionEnabled(false) // 禁用压缩,避免额外开销
.build())
.build());
// Unirest配置已移至 UnirestConfig 统一管理,避免重复配置警告
}
/**
* 获取当前有效的API密钥优先使用动态配置
*/
private String getEffectiveApiKey() {
if (dynamicApiConfig != null) {
String key = dynamicApiConfig.getApiKey();
if (key != null && !key.isEmpty()) {
return key;
}
}
return fallbackApiKey;
}
/**
* 获取当前有效的API基础URL优先使用动态配置
*/
private String getEffectiveApiBaseUrl() {
if (dynamicApiConfig != null) {
String url = dynamicApiConfig.getApiBaseUrl();
if (url != null && !url.isEmpty()) {
return url;
}
}
return fallbackApiBaseUrl;
}
/**
* 获取当前有效的图片API密钥优先使用动态配置
*/
private String getEffectiveImageApiKey() {
if (dynamicApiConfig != null) {
String key = dynamicApiConfig.getImageApiKey();
if (key != null && !key.isEmpty()) {
return key;
}
}
return fallbackImageApiKey;
}
/**
* 获取当前有效的图片API基础URL优先使用动态配置
*/
private String getEffectiveImageApiBaseUrl() {
if (dynamicApiConfig != null) {
String url = dynamicApiConfig.getImageApiBaseUrl();
if (url != null && !url.isEmpty()) {
return url;
}
}
return fallbackImageApiBaseUrl;
}
/**
@@ -82,14 +124,14 @@ public class RealAIService {
while (retryCount < maxRetries) {
try {
// 根据参数选择可用的模型使用sora2模型
String modelName = selectTextToVideoModel(aspectRatio, duration, hdMode);
// 分镜视频:根据高清模式选择模型
String modelName = selectStoryboardVideoModel(aspectRatio, duration, hdMode);
// 验证图片格式参考sora2实现确保每张图片都有data URI前缀
List<String> validatedImages = validateImageFormat(images);
// 使用 Sora2 端点参考Comfly.py 6297行
String url = aiApiBaseUrl + "/v2/videos/generations";
String url = getEffectiveApiBaseUrl() + "/v2/videos/generations";
// 使用 Sora2 API 的请求格式参考Comfly.py 6285-6292行
Map<String, Object> requestMap = new HashMap<>();
@@ -118,7 +160,7 @@ public class RealAIService {
}
HttpResponse<String> response = Unirest.post(url)
.header("Authorization", "Bearer " + aiApiKey)
.header("Authorization", "Bearer " + getEffectiveApiKey())
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json")
.header("Connection", "keep-alive")
@@ -220,7 +262,7 @@ public class RealAIService {
String size = convertAspectRatioToSize(aspectRatio, hdMode);
// 使用 Sora2 端点(与文生视频使用相同的端点,参考 Comfly.py 6297 行)
String url = aiApiBaseUrl + "/v2/videos/generations";
String url = getEffectiveApiBaseUrl() + "/v2/videos/generations";
// 使用 Sora2 API 的请求格式(参考 Comfly.py 6285-6292行
// 图生视频使用 images 数组(即使只有一张图片)
@@ -257,7 +299,7 @@ public class RealAIService {
// 使用流式传输,避免一次性加载整个请求体到内存
// 添加额外的请求头以支持大请求体
HttpResponse<String> response = Unirest.post(url)
.header("Authorization", "Bearer " + aiApiKey)
.header("Authorization", "Bearer " + getEffectiveApiKey())
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json")
.header("Connection", "keep-alive")
@@ -395,16 +437,17 @@ public class RealAIService {
// 根据参数选择可用的模型
String modelName = selectAvailableTextToVideoModel(aspectRatio, duration, hdMode);
// 提交文生视频任务
// 提交文生视频任务参考Comfly_sora2实现
// 使用 /v2/videos/generations 端点JSON格式
String url = aiApiBaseUrl + "/v2/videos/generations";
String url = fallbackApiBaseUrl + "/v2/videos/generations";
// 构建请求体参考Comfly项目的格式
// 构建请求体参考Comfly项目Comfly_sora2类的格式)
Map<String, Object> requestBodyMap = new HashMap<>();
requestBodyMap.put("prompt", prompt);
requestBodyMap.put("model", modelName);
requestBodyMap.put("aspect_ratio", aspectRatio);
requestBodyMap.put("duration", duration); // duration直接传入,不转换
requestBodyMap.put("duration", duration); // duration是字符串类型
requestBodyMap.put("hd", hdMode);
String requestBody = objectMapper.writeValueAsString(requestBodyMap);
@@ -412,8 +455,9 @@ public class RealAIService {
logger.info("提交文生视频任务 - URL: {}", url);
logger.info("提交文生视频任务 - 请求体: {}", requestBody);
// 使用 JSON 格式参考Comfly_sora2实现
HttpResponse<String> response = Unirest.post(url)
.header("Authorization", "Bearer " + aiApiKey)
.header("Authorization", "Bearer " + fallbackApiKey)
.header("Content-Type", "application/json")
.body(requestBody)
.asString();
@@ -487,9 +531,9 @@ public class RealAIService {
*/
public Map<String, Object> getTaskStatus(String taskId) {
try {
String url = aiApiBaseUrl + "/v2/videos/generations/" + taskId;
String url = getEffectiveApiBaseUrl() + "/v2/videos/generations/" + taskId;
HttpResponse<String> response = Unirest.get(url)
.header("Authorization", "Bearer " + aiApiKey)
.header("Authorization", "Bearer " + getEffectiveApiKey())
.header("Content-Type", "application/json")
.asString();
@@ -571,16 +615,15 @@ public class RealAIService {
/**
* 根据参数选择图生视频模型(默认逻辑)
* 使用 Sora2 模型,与文生视频使用相同的模型选择逻辑
* sora-2 支持: 16:9, 9:16, 10s, 15s
* sora-2-pro 支持: 16:9, 9:16, 10s, 15s, 25s, HD
*/
private String selectImageToVideoModel(String aspectRatio, String duration, boolean hdMode) {
// 使用 Sora2 模型,与文生视频相同
// - sora-2: 支持10s和15s不支持25s和HD
// - sora-2-pro: 支持10s、15s和25s支持HD
if ("25".equals(duration) || hdMode) {
// 25s 时长必须使用 sora-2-pro
if ("25".equals(duration)) {
return "sora-2-pro";
}
// aspectRatio参数未使用但保留以保持方法签名一致
// 其他情况使用 sora-2
return "sora-2";
}
@@ -646,16 +689,28 @@ public class RealAIService {
/**
* 根据参数选择文生视频模型(默认逻辑)
* 参考Comfly项目使用 sora-2 或 sora-2-pro
* sora-2 支持: 16:9, 9:16, 10s, 15s
* sora-2-pro 支持: 16:9, 9:16, 10s, 15s, 25s, HD
*/
private String selectTextToVideoModel(String aspectRatio, String duration, boolean hdMode) {
// 参考Comfly项目
// - sora-2: 支持10s和15s不支持25s和HD
// - sora-2-pro: 支持10s、15s和25s支持HD
// 25s 时长必须使用 sora-2-pro
if ("25".equals(duration)) {
return "sora-2-pro";
}
// 其他情况使用 sora-2
return "sora-2";
}
/**
* 根据参数选择分镜视频模型
* sora-2 支持: 16:9, 9:16, 10s, 15s
* sora-2-pro 支持: 16:9, 9:16, 10s, 15s, 25s, HD
*/
private String selectStoryboardVideoModel(String aspectRatio, String duration, boolean hdMode) {
// 25s 时长或高清模式必须使用 sora-2-pro
if ("25".equals(duration) || hdMode) {
return "sora-2-pro";
}
// aspectRatio参数未使用但保留以保持方法签名一致
return "sora-2";
}
@@ -710,12 +765,85 @@ public class RealAIService {
}
/**
* 将图片文件转换为Base64
* 将图片文件转换为Base64(带压缩,避免请求体过大)
* 参考分镜视频的压缩策略最大1024pxJPEG质量85%
*/
public String convertImageToBase64(byte[] imageBytes, String contentType) {
try {
String base64 = java.util.Base64.getEncoder().encodeToString(imageBytes);
return "data:" + contentType + ";base64," + base64;
// 先读取原始图片
java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(imageBytes);
java.awt.image.BufferedImage originalImage = javax.imageio.ImageIO.read(bais);
if (originalImage == null) {
logger.warn("无法读取图片直接转Base64");
String base64 = java.util.Base64.getEncoder().encodeToString(imageBytes);
return "data:" + contentType + ";base64," + base64;
}
int originalWidth = originalImage.getWidth();
int originalHeight = originalImage.getHeight();
int originalSize = imageBytes.length;
logger.info("原始图片: {}x{}, 大小: {} KB",
originalWidth, originalHeight, originalSize / 1024);
// 压缩图片到最大1024px参考分镜视频实现
int maxSize = 1024;
// 检查是否需要压缩(与分镜视频逻辑一致)
if (originalWidth <= maxSize && originalHeight <= maxSize && originalSize < 500 * 1024) {
// 图片已经足够小,不需要压缩
logger.info("图片尺寸和大小已满足要求,跳过压缩");
String base64 = java.util.Base64.getEncoder().encodeToString(imageBytes);
return "data:" + contentType + ";base64," + base64;
}
java.awt.image.BufferedImage compressedImage = originalImage;
if (originalWidth > maxSize || originalHeight > maxSize) {
// 计算缩放比例
double scale = Math.min((double) maxSize / originalWidth, (double) maxSize / originalHeight);
int newWidth = (int) (originalWidth * scale);
int newHeight = (int) (originalHeight * scale);
// 创建缩放后的图片
compressedImage = new java.awt.image.BufferedImage(newWidth, newHeight, java.awt.image.BufferedImage.TYPE_INT_RGB);
java.awt.Graphics2D g = compressedImage.createGraphics();
// 设置高质量缩放
g.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION, java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(java.awt.RenderingHints.KEY_RENDERING, java.awt.RenderingHints.VALUE_RENDER_QUALITY);
g.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING, java.awt.RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(originalImage, 0, 0, newWidth, newHeight, null);
g.dispose();
logger.info("图片已缩放: {}x{} -> {}x{}", originalWidth, originalHeight, newWidth, newHeight);
}
// 转换为JPEG格式的Base64质量85%
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
javax.imageio.ImageWriter writer = javax.imageio.ImageIO.getImageWritersByFormatName("jpg").next();
javax.imageio.ImageWriteParam param = writer.getDefaultWriteParam();
if (param.canWriteCompressed()) {
param.setCompressionMode(javax.imageio.ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.85f);
}
javax.imageio.IIOImage iioImage = new javax.imageio.IIOImage(compressedImage, null, null);
writer.setOutput(javax.imageio.ImageIO.createImageOutputStream(baos));
writer.write(null, iioImage, param);
writer.dispose();
byte[] compressedBytes = baos.toByteArray();
double compressionRatio = (1.0 - (double) compressedBytes.length / originalSize) * 100;
logger.info("图片压缩完成: {} KB -> {} KB (压缩率: {}%)",
originalSize / 1024, compressedBytes.length / 1024,
String.format("%.1f", compressionRatio));
String base64 = java.util.Base64.getEncoder().encodeToString(compressedBytes);
return "data:image/jpeg;base64," + base64;
} catch (Exception e) {
logger.error("图片转Base64失败", e);
throw new RuntimeException("图片转换失败: " + e.getMessage());
@@ -741,21 +869,27 @@ public class RealAIService {
* 参考Comfly项目的Comfly_nano_banana_edit节点实现
*/
public Map<String, Object> submitTextToImageTask(String prompt, String aspectRatio, int numImages, boolean hdMode) {
return submitTextToImageTask(prompt, aspectRatio, numImages, hdMode, "nano-banana");
}
/**
* 提交文生图任务(使用指定的模型)
* @param imageModel 图像生成模型nano-banana 或 nano-banana2
*/
public Map<String, Object> submitTextToImageTask(String prompt, String aspectRatio, int numImages, boolean hdMode, String imageModel) {
try {
logger.info("提交文生图任务banana模型: prompt={}, aspectRatio={}, hdMode={}",
prompt, aspectRatio, hdMode);
logger.info("提交文生图任务: prompt={}, aspectRatio={}, hdMode={}, imageModel={}",
prompt, aspectRatio, hdMode, imageModel);
// 注意banana模型一次只生成1张图片numImages参数用于兼容性实际请求中不使用
// 参考Comfly_nano_banana_edit节点每次调用只生成1张图片
// 使用文生图的API端点Comfly API
// 参考Comfly_nano_banana_edit节点使用 /v1/images/generations 端点
String url = aiImageApiBaseUrl + "/v1/images/generations";
String url = getEffectiveImageApiBaseUrl() + "/v1/images/generations";
// 根据hdMode选择模型参考Comfly_nano_banana_edit节点
// nano-banana: 标准模式
// nano-banana-hd: 高清模式
String model = hdMode ? "nano-banana-hd" : "nano-banana";
// 使用指定的模型,默认为 nano-banana
String model = (imageModel != null && !imageModel.isEmpty()) ? imageModel : "nano-banana";
// 构建请求体参考Comfly_nano_banana_edit节点的参数设置
// 注意banana模型不需要n参数每次只生成1张图片
@@ -768,7 +902,7 @@ public class RealAIService {
String requestBodyJson = objectMapper.writeValueAsString(requestBody);
HttpResponse<String> response = Unirest.post(url)
.header("Authorization", "Bearer " + aiImageApiKey)
.header("Authorization", "Bearer " + getEffectiveImageApiKey())
.header("Content-Type", "application/json")
.body(requestBodyJson)
.asString();
@@ -836,6 +970,109 @@ public class RealAIService {
}
}
/**
* 提交图生图任务使用banana模型基于参考图片生成
* 参考 Comfly_nano_banana_edit 节点实现
*
* @param prompt 提示词
* @param imageBase64 参考图片Base64数据带或不带 data:image 前缀)
* @param aspectRatio 宽高比
* @param hdMode 是否高清模式
* @return API响应
*/
public Map<String, Object> submitImageToImageTask(String prompt, String imageBase64, String aspectRatio, boolean hdMode) {
// 参数验证:图生图必须有参考图片
if (imageBase64 == null || imageBase64.isEmpty()) {
logger.error("图生图任务失败:缺少参考图片");
throw new IllegalArgumentException("图生图任务必须提供参考图片");
}
try {
logger.info("提交图生图任务banana模型: prompt={}, aspectRatio={}, hdMode={}, imageBase64长度={}",
prompt, aspectRatio, hdMode, imageBase64.length());
// 图生图使用 /v1/images/edits 端点(参考 Comfly_nano_banana_edit
String url = getEffectiveImageApiBaseUrl() + "/v1/images/edits";
// 模型选择
String model = "nano-banana";
// 将 Base64 转换为二进制数据
String base64Data = imageBase64;
// 去除 data:image/xxx;base64, 前缀
if (base64Data.contains(",")) {
base64Data = base64Data.substring(base64Data.indexOf(",") + 1);
}
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
logger.info("参考图片解码成功,字节数: {}", imageBytes.length);
// 使用 multipart/form-data 格式(参考 Comfly_nano_banana_edit
HttpResponse<String> response = Unirest.post(url)
.header("Authorization", "Bearer " + getEffectiveImageApiKey())
.field("prompt", prompt)
.field("model", model)
.field("aspect_ratio", aspectRatio)
.field("response_format", "url")
.field("image", imageBytes, "image.png")
.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) {
// 检查响应是否为HTML
String trimmedResponse = responseBodyStr.trim();
String lowerResponse = trimmedResponse.toLowerCase();
if (lowerResponse.startsWith("<!") || lowerResponse.startsWith("<html") ||
lowerResponse.contains("<!doctype") || (!trimmedResponse.startsWith("{") && !trimmedResponse.startsWith("["))) {
logger.error("图生图API返回HTML页面而不是JSON可能是认证失败或API端点错误");
throw new RuntimeException("API返回HTML页面可能是认证失败。请检查API密钥和端点配置");
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = objectMapper.readValue(responseBodyStr, Map.class);
Object dataObj = responseBody.get("data");
if (dataObj != null) {
if (dataObj instanceof List) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> dataList = (List<Map<String, Object>>) dataObj;
if (dataList.isEmpty()) {
logger.error("图生图任务提交失败: data字段为空列表");
throw new RuntimeException("任务提交失败: API返回空的data列表");
}
}
logger.info("图生图任务提交成功");
return responseBody;
} else {
logger.error("图生图任务提交失败: 响应中没有data字段");
Object errorMessage = responseBody.get("message");
if (errorMessage != null) {
throw new RuntimeException("任务提交失败: " + errorMessage);
} else {
throw new RuntimeException("任务提交失败: API响应中没有data字段");
}
}
} catch (com.fasterxml.jackson.core.JsonParseException e) {
logger.error("解析图生图API响应为JSON失败", e);
throw new RuntimeException("API返回非JSON响应可能是认证失败");
}
} else {
logger.error("图生图任务提交失败HTTP状态: {}", response.getStatus());
throw new RuntimeException("任务提交失败HTTP状态: " + response.getStatus());
}
} catch (UnirestException e) {
logger.error("提交图生图任务异常", e);
throw new RuntimeException("提交任务失败: " + e.getMessage());
} catch (Exception e) {
logger.error("提交图生图任务异常", e);
throw new RuntimeException("提交任务失败: " + e.getMessage());
}
}
/**
* 将宽高比转换为图片尺寸
* 参考Comfly项目的尺寸映射支持分镜图生成的各种常用尺寸
@@ -893,11 +1130,24 @@ public class RealAIService {
// 根据类型生成不同的优化指令
String systemPrompt = getOptimizationPrompt(type);
// 构建请求体使用ChatGPT API格式
String url = aiApiBaseUrl + "/v1/chat/completions";
// 从系统设置获取优化提示词的API端点和模型
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
String apiUrl = settings.getPromptOptimizationApiUrl();
if (apiUrl == null || apiUrl.isEmpty()) {
apiUrl = getEffectiveApiBaseUrl(); // 使用默认API端点
}
// 构建请求URL
String url = apiUrl + "/v1/chat/completions";
String optimizationModel = settings.getPromptOptimizationModel();
if (optimizationModel == null || optimizationModel.isEmpty()) {
optimizationModel = "gpt-5.1-thinking"; // 默认模型
}
logger.info("使用API端点: {}, 模型: {} 进行提示词优化", apiUrl, optimizationModel);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", "gpt-4o-mini"); // 使用GPT-4o-mini进行优化
requestBody.put("model", optimizationModel);
List<Map<String, String>> messages = new java.util.ArrayList<>();
Map<String, String> systemMessage = new HashMap<>();
@@ -918,7 +1168,7 @@ public class RealAIService {
// 设置超时时间30秒
HttpResponse<String> response = Unirest.post(url)
.header("Authorization", "Bearer " + aiApiKey)
.header("Authorization", "Bearer " + fallbackApiKey)
.header("Content-Type", "application/json")
.socketTimeout(30000)
.connectTimeout(10000)

View File

@@ -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, Integer duration) {
public StoryboardVideoTask createTask(String username, String prompt, String aspectRatio, boolean hdMode, String imageUrl, Integer duration, String imageModel) {
// 验证参数
if (username == null || username.trim().isEmpty()) {
throw new IllegalArgumentException("用户名不能为空");
@@ -105,6 +105,11 @@ public class StoryboardVideoService {
task.setStatus(StoryboardVideoTask.TaskStatus.PENDING);
task.setProgress(0);
// 设置图像生成模型
if (imageModel != null && !imageModel.isEmpty()) {
task.setImageModel(imageModel);
}
if (imageUrl != null && !imageUrl.isEmpty()) {
task.setImageUrl(imageUrl);
}
@@ -149,26 +154,33 @@ public class StoryboardVideoService {
String prompt = taskInfo.getPrompt();
String aspectRatio = taskInfo.getAspectRatio();
boolean hdMode = taskInfo.isHdMode();
String imageUrl = taskInfo.getImageUrl(); // 获取参考图片
String imageModel = taskInfo.getImageModel(); // 获取图像生成模型
// 判断是否有参考图片
boolean hasReferenceImage = imageUrl != null && !imageUrl.isEmpty();
logger.info("任务参数 - prompt: {}, aspectRatio: {}, hdMode: {}, 有参考图片: {}, imageModel: {}",
prompt, aspectRatio, hdMode, hasReferenceImage, imageModel);
// 更新任务状态为处理中(使用 TransactionTemplate 确保事务正确关闭)
updateTaskStatusWithTransactionTemplate(taskId);
// 调用真实文生图API生成多张分镜图
logger.info("开始生成{}张分镜图...", DEFAULT_STORYBOARD_IMAGES);
// 调用AI生图API生成多张分镜图
logger.info("开始生成{}张分镜图{}模式)...", DEFAULT_STORYBOARD_IMAGES, hasReferenceImage ? "图生图" : "文生图");
// 收集所有图片URL
List<String> imageUrls = new ArrayList<>();
// 参考Comfly项目多次调用API生成多张图片因为Comfly API可能不支持一次生成多张
// 添加重试机制,提高成功
int maxRetriesPerImage = 2; // 每张图片最多重试2次
// 添加重试机制,确保每张图片都生成成功
int maxRetriesPerImage = 10; // 每张图片最多重试10次确保生成成功
long startTime = System.currentTimeMillis();
for (int i = 0; i < DEFAULT_STORYBOARD_IMAGES; i++) {
boolean imageGenerated = false;
int retryCount = 0;
// 重试机制:如果单张图片生成失败,重试最多2次
// 重试机制:如果单张图片生成失败,持续重试直到成功最多重试10次
while (!imageGenerated && retryCount <= maxRetriesPerImage) {
try {
if (retryCount > 0) {
@@ -176,16 +188,30 @@ public class StoryboardVideoService {
Thread.sleep(1000 * retryCount); // 重试时延迟递增
}
// 每次调用生成1张图片使用banana模型
Map<String, Object> apiResponse = realAIService.submitTextToImageTask(
prompt,
aspectRatio,
1, // 每次生成1张图片
hdMode // 使用任务的hdMode参数选择模型
);
// 根据是否有参考图片调用不同的API
Map<String, Object> apiResponse;
if (hasReferenceImage) {
// 有参考图片使用图生图API
apiResponse = realAIService.submitImageToImageTask(
prompt,
imageUrl,
aspectRatio,
hdMode
);
} else {
// 无参考图片使用文生图API
apiResponse = realAIService.submitTextToImageTask(
prompt,
aspectRatio,
1, // 每次生成1张图片
hdMode,
imageModel // 使用用户选择的图像生成模型
);
}
// 检查API响应是否为空
if (apiResponse == null) {
logger.warn("第{}张分镜图API响应为空重试: {}/{}", i + 1, retryCount, maxRetriesPerImage);
retryCount++;
continue;
}
@@ -199,6 +225,7 @@ public class StoryboardVideoService {
// 提取第一张图片的URL因为每次只生成1张
Map<String, Object> imageData = data.get(0);
if (imageData == null) {
logger.warn("第{}张分镜图data[0]为空(重试: {}/{}", i + 1, retryCount, maxRetriesPerImage);
retryCount++;
continue;
}
@@ -214,13 +241,16 @@ public class StoryboardVideoService {
logger.info("✓ 成功生成第{}/{}张分镜图(进度: {}%, 耗时: {}ms, Base64长度: {}",
i + 1, DEFAULT_STORYBOARD_IMAGES, progress, elapsed, imageBase64.length());
} else {
logger.warn("未能提取第{}张分镜图的数据", i + 1);
logger.warn("未能提取第{}张分镜图的数据imageBase64为空重试: {}/{}", i + 1, retryCount, maxRetriesPerImage);
retryCount++;
}
} else {
logger.warn("第{}张分镜图data列表为空重试: {}/{}", i + 1, retryCount, maxRetriesPerImage);
retryCount++;
}
} else {
logger.warn("第{}张分镜图响应格式异常data不是List类型: {}(重试: {}/{}",
i + 1, dataObj != null ? dataObj.getClass().getName() : "null", retryCount, maxRetriesPerImage);
retryCount++;
}
@@ -229,15 +259,17 @@ public class StoryboardVideoService {
logger.error("生成分镜图被中断: {}", taskId, e);
throw new RuntimeException("生成分镜图被中断", e);
} catch (Exception e) {
logger.warn("生成第{}张分镜图异常(重试: {}/{}: {}", i + 1, retryCount, maxRetriesPerImage, e.getMessage());
retryCount++;
if (retryCount > maxRetriesPerImage) {
logger.warn("生成第{}张分镜图失败,已重试{}次: {}, 继续生成其他图片",
i + 1, maxRetriesPerImage, e.getMessage());
// 已达到最大重试次数
}
}
}
// 检查该张图片是否生成成功,如果失败则整个任务失败
if (!imageGenerated) {
logger.error("生成第{}张分镜图失败,已达最大重试次数{},任务失败", i + 1, maxRetriesPerImage);
throw new RuntimeException("生成第" + (i + 1) + "张分镜图失败,已重试" + maxRetriesPerImage + "");
}
// 在多次调用之间添加短暂延迟避免API限流
if (i < DEFAULT_STORYBOARD_IMAGES - 1) {
try {
@@ -249,24 +281,15 @@ public class StoryboardVideoService {
}
}
if (imageUrls.isEmpty()) {
throw new RuntimeException("未能从API响应中提取任何图片URL");
}
// 检查生成的图片数量允许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);
// 严格检查必须生成6张图片才能拼接
if (imageUrls.size() < DEFAULT_STORYBOARD_IMAGES) {
String errorMsg = String.format("只生成了%d张图片需要%d张才能拼接分镜图",
imageUrls.size(), DEFAULT_STORYBOARD_IMAGES);
logger.error(errorMsg);
throw new RuntimeException(errorMsg);
}
// 如果图片数量不足6张但满足最低要求记录警告但继续处理
if (imageUrls.size() < DEFAULT_STORYBOARD_IMAGES) {
logger.warn("生成了{}张图片,少于预期的{}张,但满足最低要求,将继续拼接",
imageUrls.size(), DEFAULT_STORYBOARD_IMAGES);
}
logger.info("成功生成{}张分镜图,开始拼接...", imageUrls.size());
// 确保不超过6张图片如果多于6张只取前6张
if (imageUrls.size() > DEFAULT_STORYBOARD_IMAGES) {
@@ -282,10 +305,10 @@ public class StoryboardVideoService {
// 参考sora2实现确保所有图片格式一致
List<String> validatedImages = validateAndNormalizeImages(imageUrls);
// 验证后的图片数量也要满足最低要求
if (validatedImages.size() < MIN_STORYBOARD_IMAGES) {
String errorMsg = String.format("验证后只有%d张图片少于最低要求的%d张无法拼接分镜图",
validatedImages.size(), MIN_STORYBOARD_IMAGES);
// 验证后的图片数量也要满足要求必须6张
if (validatedImages.size() < DEFAULT_STORYBOARD_IMAGES) {
String errorMsg = String.format("验证后只有%d张图片需要%d张才能拼接分镜图",
validatedImages.size(), DEFAULT_STORYBOARD_IMAGES);
logger.error(errorMsg);
throw new RuntimeException(errorMsg);
}
@@ -442,6 +465,15 @@ public class StoryboardVideoService {
logger.error("创建分镜图作品记录失败: taskId={}", taskId, e);
// 不抛出异常,避免影响主流程
}
// 分镜图生成完成,从任务队列中移除(用户点击"生成视频"时会重新添加)
try {
taskQueueService.removeTaskFromQueue(taskId);
logger.info("分镜图生成完成,已从任务队列移除: taskId={}", taskId);
} catch (Exception e) {
logger.warn("从任务队列移除失败: taskId={}, error={}", taskId, e.getMessage());
// 不抛出异常,避免影响主流程
}
} catch (Exception e) {
logger.error("保存分镜图结果失败: {}", taskId, e);
status.setRollbackOnly();
@@ -475,6 +507,16 @@ public class StoryboardVideoService {
logger.error("更新 TaskStatus 为失败失败: {}", taskId, e);
// 不抛出异常,避免影响主流程
}
// 同步更新 UserWork 状态为失败
try {
userWorkService.updateWorkStatusByTaskId(taskId,
com.example.demo.model.UserWork.WorkStatus.FAILED);
logger.info("UserWork 已更新为失败: taskId={}", taskId);
} catch (Exception e) {
logger.warn("更新 UserWork 状态失败: taskId={}", taskId);
// 不抛出异常,避免影响主流程
}
} catch (Exception e) {
logger.error("更新任务失败状态失败: {}", taskId, e);
status.setRollbackOnly();
@@ -585,9 +627,11 @@ public class StoryboardVideoService {
throw new RuntimeException("分镜图尚未生成,无法生成视频");
}
// 检查任务状态:允许从 PROCESSINGCOMPLETED 状态生成视频
// 检查任务状态:允许从 PROCESSINGCOMPLETED 或 FAILED重试状态生成视频
// 只要分镜图已生成,就允许重试生成视频
if (task.getStatus() != StoryboardVideoTask.TaskStatus.PROCESSING &&
task.getStatus() != StoryboardVideoTask.TaskStatus.COMPLETED) {
task.getStatus() != StoryboardVideoTask.TaskStatus.COMPLETED &&
task.getStatus() != StoryboardVideoTask.TaskStatus.FAILED) {
throw new RuntimeException("任务状态不正确,无法生成视频。当前状态: " + task.getStatus());
}
@@ -609,10 +653,12 @@ public class StoryboardVideoService {
paramsUpdated = true;
}
// 如果是 COMPLETED 状态,更新为 PROCESSING表示正在生成视频
if (task.getStatus() == StoryboardVideoTask.TaskStatus.COMPLETED) {
// 如果是 COMPLETED 或 FAILED 状态,更新为 PROCESSING表示正在生成视频
if (task.getStatus() == StoryboardVideoTask.TaskStatus.COMPLETED ||
task.getStatus() == StoryboardVideoTask.TaskStatus.FAILED) {
task.setStatus(StoryboardVideoTask.TaskStatus.PROCESSING);
task.setProgress(50); // 分镜图完成,视频生成中
task.setErrorMessage(null); // 清除之前的错误信息
paramsUpdated = true;
logger.info("任务状态已更新: {} -> PROCESSING (开始视频生成)", taskId);
}
@@ -927,60 +973,125 @@ public class StoryboardVideoService {
}
/**
* 检查并处理超时的分镜图生成任务
* 如果任务状态为PROCESSINGrealTaskId为空说明还在生成分镜图阶段且创建时间超过10分钟则标记为超时
* 注意如果任务已经有resultUrl分镜图已生成即使超时也不标记为失败因为分镜图已经成功生成
* 检查并处理超时的分镜视频任务
* 分两个阶段:
* 1. 分镜图生成阶段无resultUrl10分钟超时
* 2. 视频生成阶段有resultUrlprogress > 50 但 < 1001小时超时
*/
@Transactional
public int checkAndHandleTimeoutTasks() {
try {
// 计算超时时间点10分钟前
LocalDateTime timeoutTime = LocalDateTime.now().minusMinutes(10);
int totalHandledCount = 0;
// 查找超时的任务状态为PROCESSINGrealTaskId为空创建时间超过10分钟
List<StoryboardVideoTask> timeoutTasks = taskRepository.findTimeoutTasks(
// ========== 阶段1检查分镜图生成超时10分钟 ==========
LocalDateTime storyboardTimeoutTime = LocalDateTime.now().minusMinutes(10);
List<StoryboardVideoTask> storyboardTimeoutTasks = taskRepository.findTimeoutTasks(
StoryboardVideoTask.TaskStatus.PROCESSING,
timeoutTime
storyboardTimeoutTime
);
if (timeoutTasks.isEmpty()) {
return 0;
}
int storyboardHandledCount = 0;
int storyboardSkippedCount = 0;
logger.warn("发现 {} 个可能超时的分镜图生成任务,开始检查", timeoutTasks.size());
int handledCount = 0;
int skippedCount = 0;
for (StoryboardVideoTask task : timeoutTasks) {
for (StoryboardVideoTask task : storyboardTimeoutTasks) {
try {
// 检查任务是否已经有resultUrl分镜图已生成
// 如果有resultUrl说明分镜图已经成功生成不应该被标记为超时失败
// 如果有resultUrl,说明分镜图已生成,跳过此阶段的超时检查
if (task.getResultUrl() != null && !task.getResultUrl().isEmpty()) {
skippedCount++;
storyboardSkippedCount++;
continue;
}
// 更新任务状态为失败
// 分镜图生成超时,标记为失败
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
task.setErrorMessage("分镜图生成超时任务创建后超过10分钟仍未完成");
taskRepository.save(task);
logger.warn("分镜图生成任务超时,已标记为失败: taskId={}", task.getTaskId());
// 同步更新 UserWork 表的状态
try {
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
com.example.demo.model.UserWork.WorkStatus.FAILED);
} catch (Exception e) {
logger.warn("更新UserWork状态失败: taskId={}", task.getTaskId());
}
handledCount++;
// 返还冻结积分
try {
userService.returnFrozenPoints(task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
}
logger.warn("分镜图生成任务超时,已标记为失败: taskId={}", task.getTaskId());
storyboardHandledCount++;
} catch (Exception e) {
logger.error("处理超时分镜图生成任务失败: taskId={}", task.getTaskId(), e);
}
}
if (handledCount > 0 || skippedCount > 0) {
logger.info("处理超时分镜图生成任务完成,失败: {}/{},跳过(已生成): {}",
handledCount, timeoutTasks.size(), skippedCount);
if (storyboardHandledCount > 0 || storyboardSkippedCount > 0) {
logger.info("分镜图生成超时检查完成,失败: {}/{},跳过(已生成分镜图: {}",
storyboardHandledCount, storyboardTimeoutTasks.size(), storyboardSkippedCount);
}
return handledCount;
totalHandledCount += storyboardHandledCount;
// ========== 阶段2检查视频生成超时1小时 ==========
LocalDateTime videoTimeoutTime = LocalDateTime.now().minusMinutes(60);
List<StoryboardVideoTask> videoTimeoutTasks = taskRepository.findTimeoutTasks(
StoryboardVideoTask.TaskStatus.PROCESSING,
videoTimeoutTime
);
int videoHandledCount = 0;
for (StoryboardVideoTask task : videoTimeoutTasks) {
try {
// 只处理有resultUrl分镜图已生成但视频未完成的任务
if (task.getResultUrl() == null || task.getResultUrl().isEmpty()) {
continue; // 无分镜图由阶段1处理
}
// 检查progress如果已完成>= 100则跳过
int progress = task.getProgress();
if (progress >= 100) {
continue; // 已完成,不处理
}
// 视频生成超时,标记为失败
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
task.setErrorMessage("视频生成超时任务创建后超过1小时仍未完成");
taskRepository.save(task);
// 同步更新 UserWork 表的状态
try {
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
com.example.demo.model.UserWork.WorkStatus.FAILED);
} catch (Exception e) {
logger.warn("更新UserWork状态失败: taskId={}", task.getTaskId());
}
// 返还冻结积分
try {
userService.returnFrozenPoints(task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
}
logger.warn("视频生成任务超时,已标记为失败: taskId={}, progress={}", task.getTaskId(), progress);
videoHandledCount++;
} catch (Exception e) {
logger.error("处理超时视频生成任务失败: taskId={}", task.getTaskId(), e);
}
}
if (videoHandledCount > 0) {
logger.info("视频生成超时检查完成,失败: {}", videoHandledCount);
}
totalHandledCount += videoHandledCount;
return totalHandledCount;
} catch (Exception e) {
logger.error("检查超时分镜图生成任务失败", e);
logger.error("检查超时分镜视频任务失败", e);
return 0;
}
}

View File

@@ -38,6 +38,8 @@ public class SystemSettingsService {
defaults.setEnableAlipay(true);
defaults.setEnablePaypal(true);
defaults.setContactEmail("support@example.com");
defaults.setPromptOptimizationModel("gpt-5.1-thinking");
defaults.setPromptOptimizationApiUrl("https://ai.comfly.chat");
SystemSettings saved = repository.save(defaults);
logger.info("Initialized default SystemSettings: std={}, pro={}, points={}",
saved.getStandardPriceCny(), saved.getProPriceCny(), saved.getPointsPerGeneration());
@@ -60,6 +62,8 @@ public class SystemSettingsService {
current.setEnableAlipay(updated.getEnableAlipay());
current.setEnablePaypal(updated.getEnablePaypal());
current.setContactEmail(updated.getContactEmail());
current.setPromptOptimizationModel(updated.getPromptOptimizationModel());
current.setPromptOptimizationApiUrl(updated.getPromptOptimizationApiUrl());
return repository.save(current);
}
}

View File

@@ -1,6 +1,7 @@
package com.example.demo.service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -23,6 +24,7 @@ import com.example.demo.model.PointsFreezeRecord;
import com.example.demo.model.StoryboardVideoTask;
import com.example.demo.model.TaskQueue;
import com.example.demo.model.TextToVideoTask;
import com.example.demo.model.UserWork;
import com.example.demo.repository.ImageToVideoTaskRepository;
import com.example.demo.repository.StoryboardVideoTaskRepository;
import com.example.demo.repository.TaskQueueRepository;
@@ -87,7 +89,7 @@ public class TaskQueueService {
@org.springframework.beans.factory.annotation.Value("${app.upload.path:uploads}")
private String uploadPath;
private static final int MAX_TASKS_PER_USER = 1; // 限制每个用户同时只能有1个待处理任务
private static final int MAX_TASKS_PER_USER = 3; // 限制每个用户同时最多有3个待处理任务
/**
* 无界阻塞队列:用于内存中的任务处理
@@ -103,10 +105,10 @@ public class TaskQueueService {
/**
* 消费者线程数量
* 配合 MAX_TASKS_PER_USER=1理论上可支持5个用户同时处理任务
* 配合 MAX_TASKS_PER_USER=3理论上可支持5个用户同时处理任务最多15个并发任务
* 可根据服务器性能调整此值以支持更多并发
*/
private static final int CONSUMER_THREAD_COUNT = 5;
private static final int CONSUMER_THREAD_COUNT = 10;
/**
* 是否正在运行
@@ -272,34 +274,87 @@ public class TaskQueueService {
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++;
}
// 超时阈值任务创建超过1小时视为超时
LocalDateTime timeoutThreshold = LocalDateTime.now().minusHours(1);
for (TaskQueue taskQueue : processingQueues) {
taskQueue.updateStatus(TaskQueue.QueueStatus.FAILED);
taskQueue.setErrorMessage("系统重启,任务已取消");
taskQueueRepository.save(taskQueue);
// 合并所有需要处理的队列任务
List<TaskQueue> allQueues = new ArrayList<>();
allQueues.addAll(pendingQueues);
allQueues.addAll(processingQueues);
for (TaskQueue taskQueue : allQueues) {
boolean shouldRecover = false; // 默认不恢复
String failReason = "系统重启,任务已取消";
// 返还冻结的积分
try {
userService.returnFrozenPoints(taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("返还积分失败(可能未冻结): taskId={}", taskQueue.getTaskId());
// 只有已提交到外部API的任务才有可能恢复
if (taskQueue.getRealTaskId() != null && !taskQueue.getRealTaskId().isEmpty()) {
// 超时检查创建时间超过1小时 或 检查次数已达上限
boolean isTimeout = taskQueue.getCreatedAt().isBefore(timeoutThreshold);
boolean isMaxCheckReached = taskQueue.getCheckCount() >= taskQueue.getMaxCheckCount();
if (isTimeout) {
failReason = "任务超时创建超过1小时";
} else if (isMaxCheckReached) {
failReason = "任务超时(检查次数已达上限)";
} else {
// 未超时查询外部API状态
try {
logger.info("系统重启:查询任务 {} 的外部API状态realTaskId={}",
taskQueue.getTaskId(), taskQueue.getRealTaskId());
Map<String, Object> statusResponse = realAIService.getTaskStatus(taskQueue.getRealTaskId());
if (statusResponse != null && statusResponse.containsKey("data")) {
Object data = statusResponse.get("data");
if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> dataMap = (Map<String, Object>) data;
String externalStatus = String.valueOf(dataMap.get("status"));
// 只有外部API状态为"进行中"才恢复
if ("Processing".equalsIgnoreCase(externalStatus) ||
"Pending".equalsIgnoreCase(externalStatus) ||
"Running".equalsIgnoreCase(externalStatus) ||
"Queued".equalsIgnoreCase(externalStatus)) {
shouldRecover = true;
logger.info("系统重启:任务 {} 外部API状态为{},恢复轮询",
taskQueue.getTaskId(), externalStatus);
} else {
failReason = "外部API状态: " + externalStatus;
logger.warn("系统重启:任务 {} 外部API状态为{},不恢复",
taskQueue.getTaskId(), externalStatus);
}
}
} else {
failReason = "无法获取外部API状态";
}
} catch (Exception e) {
failReason = "查询外部API失败: " + e.getMessage();
logger.warn("系统重启:查询任务 {} 外部状态失败: {}", taskQueue.getTaskId(), e.getMessage());
}
}
}
totalCleanedCount++;
if (shouldRecover) {
// 恢复任务状态为PROCESSING让轮询服务继续处理
taskQueue.updateStatus(TaskQueue.QueueStatus.PROCESSING);
taskQueueRepository.save(taskQueue);
} else {
// 不恢复,标记为失败
logger.warn("系统重启:任务 {} 不恢复,原因: {}", taskQueue.getTaskId(), failReason);
taskQueue.updateStatus(TaskQueue.QueueStatus.FAILED);
taskQueue.setErrorMessage(failReason);
taskQueueRepository.save(taskQueue);
// 返还冻结的积分
try {
userService.returnFrozenPoints(taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("返还积分失败(可能未冻结): taskId={}", taskQueue.getTaskId());
}
totalCleanedCount++;
}
}
// 2. 清理所有业务任务表中的PENDING和PROCESSING状态任务
@@ -372,13 +427,8 @@ public class TaskQueueService {
processTask(task);
} catch (Exception e) {
logger.error("处理任务失败: {}", task.getTaskId(), e);
// markTaskAsFailed 内部会返还积分并删除队列记录
markTaskAsFailed(task.getTaskId(), "处理失败: " + e.getMessage());
// 返还冻结的积分
try {
userService.returnFrozenPoints(task.getTaskId());
} catch (Exception freezeException) {
logger.error("返还冻结积分失败: {}", task.getTaskId(), freezeException);
}
}
}
} catch (InterruptedException e) {
@@ -422,13 +472,22 @@ public class TaskQueueService {
// 检查用户是否已有待处理任务
long pendingCount = taskQueueRepository.countPendingTasksByUsername(username);
if (pendingCount >= MAX_TASKS_PER_USER) {
throw new RuntimeException("您当前任务正在进行中,请等待任务完成后再创建新任务");
throw new RuntimeException("您当前已有" + MAX_TASKS_PER_USER + "任务正在进行中,请等待任务完成后再创建新任务");
}
// 检查任务是否已存在
Optional<TaskQueue> existingTask = taskQueueRepository.findByTaskId(taskId);
if (existingTask.isPresent()) {
throw new RuntimeException("任务 " + taskId + " 已存在于队列中");
TaskQueue oldTask = existingTask.get();
// 如果旧任务已失败或取消,删除旧记录允许重试
if (oldTask.getStatus() == TaskQueue.QueueStatus.FAILED ||
oldTask.getStatus() == TaskQueue.QueueStatus.CANCELLED) {
logger.info("任务 {} 之前已失败/取消,删除旧记录允许重试", taskId);
taskQueueRepository.delete(oldTask);
taskQueueRepository.flush(); // 确保删除立即生效
} else {
throw new RuntimeException("任务 " + taskId + " 已存在于队列中(状态: " + oldTask.getStatus() + "");
}
}
// 计算任务所需积分 - 降低积分要求
@@ -502,7 +561,7 @@ public class TaskQueueService {
* 实际的任务处理由消费者线程从阻塞队列中取任务并处理
*/
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void processPendingTasks() {
public int processPendingTasks() {
// 从数据库加载待处理任务到内存队列
List<TaskQueue> pendingTasks = getPendingTasks();
int addedCount = 0;
@@ -512,7 +571,7 @@ public class TaskQueueService {
addedCount++;
}
}
// 静默加载
return addedCount;
}
/**
@@ -534,6 +593,7 @@ public class TaskQueueService {
/**
* 标记任务为失败(单独的事务方法)
* 会返还冻结的积分并删除队列记录
*/
@Transactional
public void markTaskAsFailed(String taskId, String errorMessage) {
@@ -544,6 +604,30 @@ public class TaskQueueService {
taskQueue.setErrorMessage(errorMessage);
taskQueueRepository.save(taskQueue);
logger.info("任务已标记为失败: taskId={}, error={}", taskId, errorMessage);
// 更新关联的业务任务状态ImageToVideoTask/TextToVideoTask等
try {
updateRelatedTaskStatusWithError(taskId, taskQueue.getTaskType(), errorMessage);
logger.info("已更新关联任务状态为失败: taskId={}", taskId);
} catch (Exception e) {
logger.error("更新关联任务状态失败: taskId={}, error={}", taskId, e.getMessage());
}
// 返还冻结的积分
try {
userService.returnFrozenPoints(taskId);
logger.info("已返还冻结积分: taskId={}", taskId);
} catch (Exception e) {
logger.error("返还冻结积分失败: taskId={}, error={}", taskId, e.getMessage());
}
// 删除队列记录(失败任务不再保留在队列中)
try {
taskQueueRepository.delete(taskQueue);
logger.info("已删除失败任务队列记录: taskId={}", taskId);
} catch (Exception e) {
logger.error("删除失败任务队列记录失败: taskId={}, error={}", taskId, e.getMessage());
}
}
}
@@ -661,6 +745,41 @@ public class TaskQueueService {
}
taskQueue.setRealTaskId(realTaskId);
taskQueueRepository.save(taskQueue);
// TODO: 为分镜视频任务创建或更新 TaskStatus功能待实现
// 区分图生视频和分镜视频任务的状态码
/*
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
try {
// 检查是否已存在 TaskStatus
TaskStatus existingTaskStatus = taskStatusPollingService.getTaskStatus(taskId);
if (existingTaskStatus != null) {
// 更新现有的 TaskStatus
existingTaskStatus.setExternalTaskId(realTaskId);
existingTaskStatus.setStatus(TaskStatus.Status.PROCESSING);
existingTaskStatus.setProgress(50); // 分镜图已完成,视频生成中
taskStatusPollingService.saveOrUpdateTaskStatus(existingTaskStatus);
logger.info("已更新分镜视频任务的 TaskStatus: taskId={}, externalTaskId={}, type=STORYBOARD_VIDEO",
taskId, realTaskId);
} else {
// 创建新的 TaskStatus分镜视频类型
TaskStatus taskStatus = taskStatusPollingService.createTaskStatus(
taskId,
taskQueue.getUsername(),
TaskStatus.TaskType.STORYBOARD_VIDEO, // 使用分镜视频类型,而不是图生视频类型
realTaskId
);
taskStatus.setProgress(50); // 分镜图已完成,视频生成中
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
logger.info("已创建分镜视频任务的 TaskStatus: taskId={}, externalTaskId={}, type=STORYBOARD_VIDEO",
taskId, realTaskId);
}
} catch (Exception e) {
logger.error("为分镜视频任务创建/更新 TaskStatus 失败: taskId={}", taskId, e);
// 不抛出异常,避免影响主流程
}
}
*/
}
}
@@ -844,11 +963,23 @@ public class TaskQueueService {
private void saveVideoTaskId(String taskId, String videoTaskId) {
try {
transactionTemplate.executeWithoutResult(status -> {
// 1. 更新 StoryboardVideoTask
StoryboardVideoTask currentTask = storyboardVideoTaskRepository.findByTaskId(taskId)
.orElseThrow(() -> new RuntimeException("任务不存在: " + taskId));
// 保存为单个任务ID不再使用videoTaskIds数组
currentTask.setRealTaskId(videoTaskId);
storyboardVideoTaskRepository.save(currentTask);
// 2. 同时更新 TaskQueue这一步非常关键否则轮询服务找不到realTaskId
Optional<TaskQueue> taskQueueOpt = taskQueueRepository.findByTaskId(taskId);
if (taskQueueOpt.isPresent()) {
TaskQueue taskQueue = taskQueueOpt.get();
taskQueue.setRealTaskId(videoTaskId);
taskQueueRepository.save(taskQueue);
logger.info("已更新任务队列的 realTaskId: taskId={}, realTaskId={}", taskId, videoTaskId);
} else {
logger.warn("找不到对应的任务队列记录: {}", taskId);
}
});
} catch (Exception e) {
logger.error("保存视频任务ID失败: {}", e.getMessage(), e);
@@ -1469,6 +1600,14 @@ public class TaskQueueService {
// 作品创建失败不影响任务完成状态
}
}
// 任务完成后从队列中删除记录
try {
taskQueueRepository.delete(freshTaskQueue);
logger.info("任务完成,已从队列中删除: {}", taskQueue.getTaskId());
} catch (Exception deleteException) {
logger.warn("删除队列记录失败: {}", taskQueue.getTaskId(), deleteException);
}
} catch (Exception e) {
logger.error("更新任务完成状态失败: {}", taskQueue.getTaskId(), e);
status.setRollbackOnly();
@@ -1489,7 +1628,7 @@ public class TaskQueueService {
try {
// 使用 TransactionTemplate 确保在事务中执行
transactionTemplate.executeWithoutResult(status -> {
try {
try {
taskQueue.updateStatus(TaskQueue.QueueStatus.FAILED);
taskQueue.setErrorMessage(errorMessage);
taskQueueRepository.save(taskQueue);
@@ -1499,6 +1638,21 @@ public class TaskQueueService {
// 更新原始任务状态
updateOriginalTaskStatus(taskQueue, "FAILED", null, errorMessage);
// 同步更新用户作品状态为 FAILED避免前端将失败任务当作进行中任务恢复
try {
userWorkService.updateWorkStatus(taskQueue.getTaskId(), UserWork.WorkStatus.FAILED);
} catch (Exception workException) {
logger.warn("更新作品状态为FAILED失败: {}", taskQueue.getTaskId(), workException);
}
// 任务失败后从队列中删除记录
try {
taskQueueRepository.delete(taskQueue);
logger.info("任务失败,已从队列中删除: {}", taskQueue.getTaskId());
} catch (Exception deleteException) {
logger.warn("删除队列记录失败: {}", taskQueue.getTaskId(), deleteException);
}
} catch (Exception e) {
logger.error("更新任务失败状态失败: {}", taskQueue.getTaskId(), e);
status.setRollbackOnly();
@@ -1519,7 +1673,7 @@ public class TaskQueueService {
try {
// 使用 TransactionTemplate 确保在事务中执行
transactionTemplate.executeWithoutResult(status -> {
try {
try {
taskQueue.updateStatus(TaskQueue.QueueStatus.TIMEOUT);
taskQueue.setErrorMessage("任务处理超时");
taskQueueRepository.save(taskQueue);
@@ -1529,6 +1683,21 @@ public class TaskQueueService {
// 更新原始任务状态
updateOriginalTaskStatus(taskQueue, "FAILED", null, "任务处理超时");
// 超时任务同样标记为 FAILED防止前端一直恢复到创作页面
try {
userWorkService.updateWorkStatus(taskQueue.getTaskId(), UserWork.WorkStatus.FAILED);
} catch (Exception workException) {
logger.warn("更新超时任务的作品状态为FAILED失败: {}", taskQueue.getTaskId(), workException);
}
// 任务超时后从队列中删除记录
try {
taskQueueRepository.delete(taskQueue);
logger.info("任务超时,已从队列中删除: {}", taskQueue.getTaskId());
} catch (Exception deleteException) {
logger.warn("删除队列记录失败: {}", taskQueue.getTaskId(), deleteException);
}
} catch (Exception e) {
logger.error("更新任务超时状态失败: {}", taskQueue.getTaskId(), e);
status.setRollbackOnly();
@@ -1664,12 +1833,21 @@ public class TaskQueueService {
}
/**
* 获取用户的任务队列
* 获取用户的任务队列(仅待处理和处理中的任务)
*/
@Transactional(readOnly = true)
public List<TaskQueue> getUserTaskQueue(String username) {
return taskQueueRepository.findPendingTasksByUsername(username);
}
/**
* 分页获取用户的所有任务队列
*/
@Transactional(readOnly = true)
public org.springframework.data.domain.Page<TaskQueue> getUserAllTasks(String username, int page, int size) {
org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(page, size);
return taskQueueRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
}
/**
* 获取用户任务队列统计
@@ -1679,6 +1857,21 @@ public class TaskQueueService {
return taskQueueRepository.countByUsername(username);
}
/**
* 检查任务是否在队列中(用于前端决定是否需要轮询)
* 只有 PENDING 或 PROCESSING 状态的任务才算在队列中
*/
@Transactional(readOnly = true)
public boolean isTaskInQueue(String taskId) {
Optional<TaskQueue> taskOpt = taskQueueRepository.findByTaskId(taskId);
if (taskOpt.isEmpty()) {
return false;
}
TaskQueue task = taskOpt.get();
return task.getStatus() == TaskQueue.QueueStatus.PENDING ||
task.getStatus() == TaskQueue.QueueStatus.PROCESSING;
}
/**
* 清理过期任务
*/
@@ -1687,4 +1880,128 @@ public class TaskQueueService {
LocalDateTime expiredDate = LocalDateTime.now().minusDays(7);
return taskQueueRepository.deleteExpiredTasks(expiredDate);
}
/**
* 从任务队列中移除指定任务
* 用于分镜图生成完成后移除任务,等待用户点击"生成视频"时重新添加
*/
@Transactional
public void removeTaskFromQueue(String taskId) {
Optional<TaskQueue> taskOpt = taskQueueRepository.findByTaskId(taskId);
if (taskOpt.isPresent()) {
taskQueueRepository.delete(taskOpt.get());
logger.info("任务已从队列中移除: {}", taskId);
} else {
logger.debug("任务不在队列中,无需移除: {}", taskId);
}
}
/**
* 检查并处理队列中超时的任务
* 超时时间1小时
* 超过1小时仍在PENDING或PROCESSING状态的任务将被标记为FAILED
*/
@Transactional
public int checkAndHandleQueueTimeoutTasks() {
try {
// 计算超时时间点1小时前
LocalDateTime timeoutTime = LocalDateTime.now().minusMinutes(60);
// 查找超时的队列任务
List<TaskQueue> timeoutTasks = taskQueueRepository.findTimeoutTasksByCreatedTime(timeoutTime);
if (timeoutTasks.isEmpty()) {
return 0;
}
logger.warn("发现 {} 个队列超时任务,开始处理", timeoutTasks.size());
int handledCount = 0;
LocalDateTime now = LocalDateTime.now();
for (TaskQueue task : timeoutTasks) {
try {
// 更新队列状态为失败
task.setStatus(TaskQueue.QueueStatus.FAILED);
task.setErrorMessage("任务队列超时任务创建后超过1小时仍未完成");
task.setUpdatedAt(now);
task.setCompletedAt(now);
taskQueueRepository.save(task);
// 同时更新对应的任务状态
updateRelatedTaskStatus(task.getTaskId(), task.getTaskType());
// 返还冻结积分
try {
userService.returnFrozenPoints(task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
}
logger.warn("队列任务超时,已标记为失败: taskId={}, taskType={}, createdAt={}",
task.getTaskId(), task.getTaskType(), task.getCreatedAt());
handledCount++;
} catch (Exception e) {
logger.error("处理超时队列任务失败: taskId={}", task.getTaskId(), e);
}
}
if (handledCount > 0) {
logger.info("队列超时任务处理完成,处理数量: {}", handledCount);
}
return handledCount;
} catch (Exception e) {
logger.error("检查队列超时任务失败", e);
return 0;
}
}
/**
* 更新关联的具体任务状态(使用默认超时错误信息)
*/
private void updateRelatedTaskStatus(String taskId, TaskQueue.TaskType taskType) {
updateRelatedTaskStatusWithError(taskId, taskType, "任务队列超时任务创建后超过1小时仍未完成");
}
/**
* 更新关联的具体任务状态(使用自定义错误信息)
*/
private void updateRelatedTaskStatusWithError(String taskId, TaskQueue.TaskType taskType, String errorMessage) {
try {
switch (taskType) {
case TEXT_TO_VIDEO:
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
task.setErrorMessage(errorMessage);
textToVideoTaskRepository.save(task);
});
break;
case IMAGE_TO_VIDEO:
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.updateStatus(ImageToVideoTask.TaskStatus.FAILED);
task.setErrorMessage(errorMessage);
imageToVideoTaskRepository.save(task);
});
break;
case STORYBOARD_VIDEO:
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.updateStatus(StoryboardVideoTask.TaskStatus.FAILED);
task.setErrorMessage(errorMessage);
storyboardVideoTaskRepository.save(task);
});
break;
}
// 同步更新 UserWork 状态为 FAILED确保前端不会将失败任务当作进行中任务恢复
try {
userWorkService.updateWorkStatus(taskId, UserWork.WorkStatus.FAILED);
logger.info("已同步更新UserWork状态为FAILED: taskId={}", taskId);
} catch (Exception workException) {
logger.warn("更新UserWork状态为FAILED失败: taskId={}, error={}", taskId, workException.getMessage());
}
} catch (Exception e) {
logger.warn("更新关联任务状态失败: taskId={}, taskType={}", taskId, taskType, e);
}
}
}

View File

@@ -430,16 +430,16 @@ public class TextToVideoService {
/**
* 检查并处理超时的文生视频任务
* 如果任务状态为PROCESSING且创建时间超过10分钟则标记为超时
* 注意如果任务已经有resultUrl视频已生成即使超时也不标记为失败,因为视频已经成功生成
* 超时时间1小时视频生成需要较长时间
* 注意如果任务已经有resultUrl视频已生成即使超时也不标记为失败
*/
@Transactional
public int checkAndHandleTimeoutTasks() {
try {
// 计算超时时间点10分钟前)
LocalDateTime timeoutTime = LocalDateTime.now().minusMinutes(10);
// 计算超时时间点1小时前)
LocalDateTime timeoutTime = LocalDateTime.now().minusMinutes(60);
// 查找超时的任务状态为PROCESSING创建时间超过10分钟
// 查找超时的任务状态为PROCESSING创建时间超过1小时
List<TextToVideoTask> timeoutTasks = taskRepository.findTimeoutTasks(
TextToVideoTask.TaskStatus.PROCESSING,
timeoutTime
@@ -465,9 +465,17 @@ public class TextToVideoService {
// 更新任务状态为失败
task.updateStatus(TextToVideoTask.TaskStatus.FAILED);
task.setErrorMessage("文生视频任务超时任务创建后超过10分钟仍未完成");
task.setErrorMessage("文生视频任务超时任务创建后超过1小时仍未完成");
taskRepository.save(task);
// 同步更新 UserWork 表的状态
try {
userWorkService.updateWorkStatusByTaskId(task.getTaskId(),
com.example.demo.model.UserWork.WorkStatus.FAILED);
} catch (Exception e) {
logger.warn("更新UserWork状态失败: taskId={}", task.getTaskId());
}
logger.warn("文生视频任务超时,已标记为失败: taskId={}", task.getTaskId());
handledCount++;

View File

@@ -6,12 +6,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.example.demo.model.PointsFreezeRecord;
import com.example.demo.model.User;
import com.example.demo.repository.PointsFreezeRecordRepository;
import com.example.demo.repository.UserRepository;
import com.example.demo.util.UserIdGenerator;
@Service
public class UserService {
@@ -44,6 +46,16 @@ public class UserService {
throw new IllegalArgumentException("邮箱已被使用");
}
User user = new User();
// 生成唯一用户ID重试最多3次确保唯一性
String userId = null;
for (int i = 0; i < 3; i++) {
userId = UserIdGenerator.generate();
if (!userRepository.existsByUserId(userId)) {
break;
}
logger.warn("用户ID冲突重新生成: {}", userId);
}
user.setUserId(userId);
user.setUsername(username);
user.setEmail(email);
user.setPasswordHash(passwordEncoder.encode(rawPassword));
@@ -144,7 +156,6 @@ public class UserService {
user.setPasswordHash(passwordEncoder.encode(newPassword));
userRepository.save(user);
logger.info("用户 {} 修改密码成功", user.getUsername());
}
/**
@@ -236,15 +247,16 @@ public class UserService {
PointsFreezeRecord record = new PointsFreezeRecord(username, taskId, taskType, points, reason);
record = pointsFreezeRecordRepository.save(record);
logger.info("用户 {} 冻结积分成功: {} 积分任务ID: {}", username, points, taskId);
return record;
}
/**
* 扣除冻结的积分(任务完成)
* 使用悲观锁防止并发重复扣除
* 使用 REQUIRES_NEW 传播行为,确保在一个独立的事务中执行,
* 这样即使抛出异常回滚,也不会影响调用者的事务(只要调用者捕获了异常)
*/
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deductFrozenPoints(String taskId) {
// 使用悲观写锁查询,防止并发重复扣除
PointsFreezeRecord record = pointsFreezeRecordRepository.findByTaskIdWithLock(taskId)
@@ -252,7 +264,6 @@ public class UserService {
// 如果已经扣除过,直接返回,避免重复扣除(双重检查,悲观锁已经保证了并发安全)
if (record.getStatus() == PointsFreezeRecord.FreezeStatus.DEDUCTED) {
logger.info("冻结记录 {} 已扣除,跳过重复扣除", taskId);
return;
}
@@ -271,14 +282,13 @@ public class UserService {
// 更新冻结记录状态
record.updateStatus(PointsFreezeRecord.FreezeStatus.DEDUCTED);
pointsFreezeRecordRepository.save(record);
logger.info("用户 {} 扣除冻结积分成功: {} 积分任务ID: {}", record.getUsername(), record.getFreezePoints(), taskId);
}
/**
* 返还冻结的积分(任务失败)
* 使用 REQUIRES_NEW 传播行为,防止异常导致外部事务回滚
*/
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void returnFrozenPoints(String taskId) {
PointsFreezeRecord record = pointsFreezeRecordRepository.findByTaskId(taskId)
.orElseThrow(() -> new RuntimeException("找不到冻结记录: " + taskId));
@@ -297,8 +307,6 @@ public class UserService {
// 更新冻结记录状态
record.updateStatus(PointsFreezeRecord.FreezeStatus.RETURNED);
pointsFreezeRecordRepository.save(record);
logger.info("用户 {} 返还冻结积分成功: {} 积分任务ID: {}", record.getUsername(), record.getFreezePoints(), taskId);
}
/**
@@ -311,8 +319,6 @@ public class UserService {
user.setPoints(user.getPoints() + points);
userRepository.save(user);
logger.info("用户 {} 积分增加: {} 积分,当前积分: {}", username, points, user.getPoints());
}
/**
@@ -325,8 +331,6 @@ public class UserService {
user.setPoints(points);
userRepository.save(user);
logger.info("用户 {} 积分设置为: {} 积分", username, points);
}
/**
@@ -371,7 +375,6 @@ public class UserService {
// 返还过期冻结的积分
returnFrozenPoints(record.getTaskId());
processedCount++;
logger.info("处理过期冻结记录: {}", record.getTaskId());
} catch (Exception e) {
logger.error("处理过期冻结记录失败: {}", record.getTaskId(), e);
}
@@ -407,29 +410,15 @@ public class UserService {
java.util.List<com.example.demo.model.Payment> allPayments = paymentRepository
.findByUserIdOrderByCreatedAtDesc(user.getId());
logger.info("获取用户 {} (ID: {}) 的积分历史记录", username, user.getId());
logger.info("用户 {} 的支付记录总数: {}", username, allPayments.size());
// 打印所有支付记录的状态,用于调试
for (com.example.demo.model.Payment p : allPayments) {
logger.info("支付记录 ID: {}, 状态: {}, 金额: {}, 描述: {}",
p.getId(), p.getStatus(), p.getAmount(), p.getDescription());
}
java.util.List<com.example.demo.model.Payment> successfulPayments = allPayments
.stream()
.filter(payment -> payment.getStatus() == com.example.demo.model.PaymentStatus.SUCCESS)
.collect(java.util.stream.Collectors.toList());
logger.info("用户 {} 的成功支付记录数: {}", username, successfulPayments.size());
for (com.example.demo.model.Payment payment : successfulPayments) {
// 从支付记录中提取积分数量(使用与 PaymentService.addPointsForPayment 相同的逻辑)
Integer points = extractPointsFromPayment(payment);
logger.info("处理支付记录 ID: {}, 金额: {}, 描述: {}, 提取的积分: {}",
payment.getId(), payment.getAmount(), payment.getDescription(), points);
// 即使没有提取到积分,也显示充值记录(可能金额不在套餐范围内,但用户确实支付了)
// 如果提取到积分使用提取的积分否则显示0积分表示支付成功但未获得积分
Integer displayPoints = (points != null && points > 0) ? points : 0;
@@ -447,11 +436,8 @@ public class UserService {
record.put("orderId", payment.getOrderId());
record.put("paymentId", payment.getId());
history.add(record);
logger.info("✓ 添加充值记录: {} 积分, 时间: {}, 描述: {}", displayPoints, record.get("time"), description);
}
logger.info("用户 {} 的充值记录数: {}", username, history.size());
// 2. 也检查已完成订单(作为补充,以防有订单但没有支付记录的情况)
java.util.List<com.example.demo.model.Order> completedOrders = orderRepository.findByUserIdAndStatus(
user.getId(),
@@ -594,26 +580,20 @@ public class UserService {
description.contains("Standard") || description.contains("STANDARD")) {
// 标准版订阅 - 200积分
pointsToAdd = 200;
logger.debug("从描述识别为标准版,积分: 200");
} else if (description.contains("专业版") || description.contains("premium") ||
description.contains("Premium") || description.contains("PREMIUM")) {
// 专业版订阅 - 1000积分
pointsToAdd = 1000;
logger.debug("从描述识别为专业版,积分: 1000");
} else {
// 如果描述中没有套餐信息,根据金额判断
// 标准版订阅 (59-258元) - 200积分
if (amount.compareTo(new java.math.BigDecimal("59.00")) >= 0 &&
amount.compareTo(new java.math.BigDecimal("259.00")) < 0) {
pointsToAdd = 200;
logger.debug("根据金额 {} 判断为标准版,积分: 200", amount);
}
// 专业版订阅 (259元以上) - 1000积分
else if (amount.compareTo(new java.math.BigDecimal("259.00")) >= 0) {
pointsToAdd = 1000;
logger.debug("根据金额 {} 判断为专业版,积分: 1000", amount);
} else {
logger.debug("支付金额 {} 不在已知套餐范围内,不增加积分", amount);
}
}

View File

@@ -12,6 +12,7 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.example.demo.model.ImageToVideoTask;
@@ -50,8 +51,9 @@ public class UserWorkService {
/**
* 从任务创建作品
* 使用 REQUIRES_NEW 传播行为,防止内部异常导致外部事务回滚
*/
@Transactional
@Transactional(propagation = Propagation.REQUIRES_NEW)
public UserWork createWorkFromTask(String taskId, String resultUrl) {
// 检查是否已存在作品(使用同步检查,防止并发创建)
// 注意:这个检查不是原子的,但配合外部的悲观锁应该能防止大部分并发问题
@@ -182,6 +184,7 @@ public class UserWorkService {
work.setQuality(Boolean.TRUE.equals(task.getHdMode()) ? "HD" : "SD");
work.setPointsCost(task.getCostPoints()); // 设置预估积分成本
work.setStatus(UserWork.WorkStatus.PROCESSING); // 初始状态为PROCESSING
work.setThumbnailUrl(task.getFirstFrameUrl()); // 保存首帧图片URL用于恢复时显示
work = userWorkRepository.save(work);
logger.info("创建PROCESSING状态图生视频作品: {}, 用户: {}", work.getId(), work.getUsername());
@@ -237,6 +240,7 @@ public class UserWorkService {
work.setQuality(task.isHdMode() ? "HD" : "SD");
work.setPointsCost(task.getCostPoints()); // 设置预估积分成本
work.setStatus(UserWork.WorkStatus.PROCESSING); // 初始状态为PROCESSING
work.setThumbnailUrl(task.getImageUrl()); // 保存参考图URL用于恢复时显示
work = userWorkRepository.save(work);
logger.info("创建PROCESSING状态分镜视频作品: {}, 用户: {}", work.getId(), work.getUsername());
@@ -353,10 +357,13 @@ public class UserWorkService {
/**
* 获取用户正在进行中的作品包括PROCESSING和PENDING状态
* 只返回最近24小时内的任务避免返回陈旧的僵尸任务
*/
@Transactional(readOnly = true)
public java.util.List<UserWork> getProcessingWorks(String username) {
return userWorkRepository.findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(username);
// 只查询最近24小时内的任务
LocalDateTime afterTime = LocalDateTime.now().minusHours(24);
return userWorkRepository.findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(username, afterTime);
}
/**
@@ -427,6 +434,24 @@ public class UserWorkService {
return result > 0;
}
/**
* 根据任务ID更新作品状态
* 使用 REQUIRES_NEW 传播行为,防止内部异常导致外部事务回滚
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateWorkStatusByTaskId(String taskId, UserWork.WorkStatus status) {
Optional<UserWork> workOpt = userWorkRepository.findByTaskId(taskId);
if (workOpt.isPresent()) {
UserWork work = workOpt.get();
work.setStatus(status);
work.setUpdatedAt(LocalDateTime.now());
userWorkRepository.save(work);
logger.info("更新作品状态: taskId={}, status={}", taskId, status);
} else {
logger.warn("未找到对应的作品记录: taskId={}", taskId);
}
}
/**
* 获取公开作品列表
*/

View File

@@ -0,0 +1,60 @@
package com.example.demo.util;
import java.security.SecureRandom;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
* 用户ID生成器
* 格式: UID + yyMMdd + 4位随机字符
* 示例: UID241204X7K9
*/
public class UserIdGenerator {
private static final String PREFIX = "UID";
// 去除易混淆字符: 0/O, 1/I/L
private static final String CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
private static final SecureRandom RANDOM = new SecureRandom();
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd");
/**
* 生成用户ID
* @return 格式: UID + yyMMdd + 4位随机字符 (共14位)
*/
public static String generate() {
String datePart = LocalDate.now().format(DATE_FORMAT);
String randomPart = generateRandomString(4);
return PREFIX + datePart + randomPart;
}
/**
* 生成指定长度的随机字符串
*/
private static String generateRandomString(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append(CHARS.charAt(RANDOM.nextInt(CHARS.length())));
}
return sb.toString();
}
/**
* 验证用户ID格式是否正确
*/
public static boolean isValid(String userId) {
if (userId == null || userId.length() != 14) {
return false;
}
if (!userId.startsWith(PREFIX)) {
return false;
}
// 检查日期部分(6位数字)
String datePart = userId.substring(3, 9);
if (!datePart.matches("\\d{6}")) {
return false;
}
// 检查随机部分(4位字母数字)
String randomPart = userId.substring(9);
return randomPart.matches("[A-Z0-9]{4}");
}
}

View File

@@ -1,92 +1,66 @@
# 数据库配置
spring.datasource.url=jdbc:mysql://localhost:3306/aigc_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=177615
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# 数据库连接池配置(开发环境 - 支持50人并发
spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=1200000
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.leak-detection-threshold=60000
spring.datasource.hikari.validation-timeout=3000
spring.datasource.hikari.connection-test-query=SELECT 1
# JPA配置
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.properties.hibernate.format_sql=false
# 数据初始化配置 - 开发环境启动时自动执行 data.sql
spring.sql.init.mode=always
spring.sql.init.continue-on-error=true
# 服务器配置
#Updated by API Key Management
#Mon Nov 24 17:13:13 CST 2025
ai.api.base-url=https\://ai.comfly.chat
ai.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
ai.image.api.base-url=https\://ai.comfly.chat
ai.image.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
alipay.app-id=9021000157616562
alipay.charset=UTF-8
alipay.gateway-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
alipay.notify-url=https\://curtly-aphorismatic-ginger.ngrok-free.dev/api/payments/alipay/notify
alipay.private-key=MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCH7wPeptkJlJuoKwDqxvfJJLTOAWVkHa/TLh+wiy1tEtmwcrOwEU3GuqfkUlhij71WJIZi8KBytCwbax1QGZA/oLXvTCGJJrYrsEL624X5gGCCPKWwHRDhewsQ5W8jFxaaMXxth8GKlSW61PZD2cOQClRVEm2xnWFZ+6/7WBI7082g7ayzGCD2eowXsJyWyuEBCUSbHXkSgxVhqj5wUGIXhr8ly+pdUlJmDX5K8UG2rjJYx+0AU5UZJbOAND7d3iyDsOulHDvth50t8MOWDnDCVJ2aAgUB5FZKtOFxOmzNTsMjvzYldFztF0khbypeeMVL2cxgioIgTvjBkUwd55hZAgMBAAECggEAUjk3pARUoEDt7skkYt87nsW/QCUECY0Tf7AUpxtovON8Hgkju8qbuyvIxokwwV2k72hkiZB33Soyy9r8/iiYYoR5yGfKmUV7R+30df03ivYmamD48BCE138v8GZ31Ufv+hEY7MADSCpzihGrbNtaOdSlslfVVmyWKHHfvy9EyD6yHJGYswLpHXC/QX1TuLRRxk6Uup8qENOG/6zjGWMfxoRZFwTt80ml1mKy32YZGyJqDaQpJcdYwAHOPcnJl1emw4E+oVjiLyksl643npuTkgnZXs1iWcWSS8ojF1w/0kVDzcNh9toLg+HDuQlIHOis01VQ7lYcG4oiMOnhX1QHIQKBgQC9fgBuILjBhuCI9fHvLRdzoNC9heD54YK7xGvEV/mv90k8xcmNx+Yg5C57ASaMRtOq3b7muPiCv5wOtMT4tUCcMIwSrTNlcBM6EoTagnaGfpzOMaHGMXO4vbaw+MIynHnvXFj1rZjG1lzkV/9K36LAaHD9ZKVJaBQ9mK+0CIq/3QKBgQC3pL5GbvXj6/4ahTraXzNDQQpPGVgbHxcOioEXL4ibaOPC58puTW8HDbRvVuhl/4EEOBRVX81BSgkN8XHwTSiZdih2iOqByg+o9kixs7nlFn3Iw9BBP2/g+Wqiyi2N+9g17kfWXXVOKYz/eMXLBeOo4KhQE9wqNGyZldYzX2ywrQKBgApJmvBfqmgnUG1fHOFlS06lvm9ro0ktqxFSmp8wP4gEHt/DxSuDXMUQXk2jRFp9ReSS4VhZVnSSvoA15DO0c2uHXzNsX8v0B7cxZjEOwCyRFyZCn4vJB4VSF2cIOlLRF/Wcx9+eqxqwbJ6hAGUqOwXDJc879ZVEp0So03EsvYupAoGAAnI+Wp/VxLB7FQ1bSFdmTmoKYh1bUBks7HOp3o4yiqduCUWfK7L6XKSxF56Xv+wUYuMAWlbJXCpJTpc9xk6w0MKDLXkLbqkrZjvJohxbyJJxIICDQKtAqUWJRxvcWXzWV3mSGWfrTRw+lZSdReQRMUm01EQ/dYx3OeCGFu8Zeo0CgYAlH5YSYdJxZSoDCJeoTrkxUlFoOg8UQ7SrsaLYLwpwcwpuiWJaTrg6jwFocj+XhjQ9RtRbSBHz2wKSLdl+pXbTbqECKk85zMFl6zG3etXtTJU/dD750Ty4i8zt3+JGhvglPrQBY1CfItgml2oXa/VUVMnLCUS0WSZuPRmPYZD8dg\=\=
alipay.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAksEwzuR3ASrKtTzaANqdQYKOoD44itA1TWG/6onvQr8PHNEMgcguLuJNrdeuT2PDg23byzZ9qKfEM2D5U4zbpt0/uCYLfZQyAnAWWyMvnKPoSIgrtBjnxYK6HE6fuQV3geJTcZxvP/z8dGZB0V0s6a53rzbKSLh0p4w0hWfVXlQihq3Xh4vSKB+ojdhEkIblhpWPT42NPbjVNdwPzIhUGpRy3/nsgNqVBu+ZacQ5/rCvzXU1RE0allBbjcvjymKQTS7bAE0i1Mgo1eX8njvElsfQUv5P7xQdrvZagqtIuTdP19cmsSNGdIC9Z5Po3j0z3KWPR7MrKgDuJfzkWtJR4wIDAQAB
alipay.return-url=https\://curtly-aphorismatic-ginger.ngrok-free.dev/api/payments/alipay/return
alipay.server-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
alipay.sign-type=RSA2
app.ffmpeg.path=C\:/Users/UI/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.0-full_build/bin/ffmpeg.exe
app.temp.dir=./temp
jwt.expiration=86400000
jwt.secret=mySecretKey123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
logging.level.com.example.demo=DEBUG
logging.level.org.hibernate.SQL=WARN
logging.level.org.hibernate.orm.jdbc.bind=WARN
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
logging.level.org.springframework.security=DEBUG
server.port=8080
# Tomcat 最大POST大小扩大请求体大小以支持大图片Base64编码
server.tomcat.max-http-post-size=600MB
# Tomcat线程池配置支持50人并发
server.tomcat.threads.max=150
server.tomcat.threads.min-spare=20
server.tomcat.max-connections=500
server.tomcat.accept-count=100
server.tomcat.connection-timeout=20000
# 文件上传配置扩大请求体大小以支持大图片Base64编码
server.tomcat.max-connections=500
server.tomcat.max-http-post-size=600MB
server.tomcat.threads.max=150
server.tomcat.threads.min-spare=20
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.hikari.connection-test-query=SELECT 1
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.leak-detection-threshold=60000
spring.datasource.hikari.max-lifetime=1200000
spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.validation-timeout=3000
spring.datasource.password=177615
spring.datasource.url=jdbc\:mysql\://localhost\:3306/aigc_platform?useUnicode\=true&characterEncoding\=utf8&useSSL\=false&serverTimezone\=Asia/Shanghai&allowPublicKeyRetrieval\=true
spring.datasource.username=root
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.properties.hibernate.format_sql=false
spring.jpa.show-sql=false
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=500MB
spring.servlet.multipart.max-request-size=600MB
spring.servlet.multipart.enabled=true
# 日志配置
logging.level.com.example.demo=DEBUG
logging.level.org.springframework.security=DEBUG
# 关闭 Hibernate SQL 日志
logging.level.org.hibernate.SQL=WARN
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
logging.level.org.hibernate.orm.jdbc.bind=WARN
# JWT配置
jwt.secret=mySecretKey123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
jwt.expiration=86400000
# 腾讯云SES配置
# 主账号ID: 100040185043
# 用户名: test
spring.sql.init.continue-on-error=true
spring.sql.init.mode=always
tencent.ses.from-email=newletter@vionow.com
tencent.ses.from-name=AIGC\u00E5\u00B9\u00B3\u00E5\u008F\u00B0
tencent.ses.region=ap-hongkong
tencent.ses.secret-id=AKIDoaEjFbqxxqZAcv8EE6oZCg2IQPG1fCxm
tencent.ses.secret-key=nR83I79FOSpGcqNo7JXkqnU8g7SjsxuG
tencent.ses.region=ap-hongkong
tencent.ses.from-email=newletter@vionow.com
tencent.ses.from-name=AIGC平台
# 邮件模板ID在腾讯云SES控制台创建模板后获取
# 如果未配置或为0将使用开发模式仅记录日志
tencent.ses.template-id=154360
# AI API配置
# 文生视频、图生视频、分镜视频都使用Comfly API
ai.api.base-url=https://ai.comfly.chat
ai.api.key=sk-xCX1X12e8Dpj4mRJKFMxFUnV29pzJQpPeuZFGqTwYOorjvOQ
# 文生图使用Comfly API (在代码中单独配置)
ai.image.api.base-url=https://ai.comfly.chat
ai.image.api.key=sk-xCX1X12e8Dpj4mRJKFMxFUnV29pzJQpPeuZFGqTwYOorjvOQ
# 支付宝配置 (开发环境 - 沙箱测试)
# 请替换为您的实际配置
alipay.app-id=9021000157616562
alipay.private-key=MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCH7wPeptkJlJuoKwDqxvfJJLTOAWVkHa/TLh+wiy1tEtmwcrOwEU3GuqfkUlhij71WJIZi8KBytCwbax1QGZA/oLXvTCGJJrYrsEL624X5gGCCPKWwHRDhewsQ5W8jFxaaMXxth8GKlSW61PZD2cOQClRVEm2xnWFZ+6/7WBI7082g7ayzGCD2eowXsJyWyuEBCUSbHXkSgxVhqj5wUGIXhr8ly+pdUlJmDX5K8UG2rjJYx+0AU5UZJbOAND7d3iyDsOulHDvth50t8MOWDnDCVJ2aAgUB5FZKtOFxOmzNTsMjvzYldFztF0khbypeeMVL2cxgioIgTvjBkUwd55hZAgMBAAECggEAUjk3pARUoEDt7skkYt87nsW/QCUECY0Tf7AUpxtovON8Hgkju8qbuyvIxokwwV2k72hkiZB33Soyy9r8/iiYYoR5yGfKmUV7R+30df03ivYmamD48BCE138v8GZ31Ufv+hEY7MADSCpzihGrbNtaOdSlslfVVmyWKHHfvy9EyD6yHJGYswLpHXC/QX1TuLRRxk6Uup8qENOG/6zjGWMfxoRZFwTt80ml1mKy32YZGyJqDaQpJcdYwAHOPcnJl1emw4E+oVjiLyksl643npuTkgnZXs1iWcWSS8ojF1w/0kVDzcNh9toLg+HDuQlIHOis01VQ7lYcG4oiMOnhX1QHIQKBgQC9fgBuILjBhuCI9fHvLRdzoNC9heD54YK7xGvEV/mv90k8xcmNx+Yg5C57ASaMRtOq3b7muPiCv5wOtMT4tUCcMIwSrTNlcBM6EoTagnaGfpzOMaHGMXO4vbaw+MIynHnvXFj1rZjG1lzkV/9K36LAaHD9ZKVJaBQ9mK+0CIq/3QKBgQC3pL5GbvXj6/4ahTraXzNDQQpPGVgbHxcOioEXL4ibaOPC58puTW8HDbRvVuhl/4EEOBRVX81BSgkN8XHwTSiZdih2iOqByg+o9kixs7nlFn3Iw9BBP2/g+Wqiyi2N+9g17kfWXXVOKYz/eMXLBeOo4KhQE9wqNGyZldYzX2ywrQKBgApJmvBfqmgnUG1fHOFlS06lvm9ro0ktqxFSmp8wP4gEHt/DxSuDXMUQXk2jRFp9ReSS4VhZVnSSvoA15DO0c2uHXzNsX8v0B7cxZjEOwCyRFyZCn4vJB4VSF2cIOlLRF/Wcx9+eqxqwbJ6hAGUqOwXDJc879ZVEp0So03EsvYupAoGAAnI+Wp/VxLB7FQ1bSFdmTmoKYh1bUBks7HOp3o4yiqduCUWfK7L6XKSxF56Xv+wUYuMAWlbJXCpJTpc9xk6w0MKDLXkLbqkrZjvJohxbyJJxIICDQKtAqUWJRxvcWXzWV3mSGWfrTRw+lZSdReQRMUm01EQ/dYx3OeCGFu8Zeo0CgYAlH5YSYdJxZSoDCJeoTrkxUlFoOg8UQ7SrsaLYLwpwcwpuiWJaTrg6jwFocj+XhjQ9RtRbSBHz2wKSLdl+pXbTbqECKk85zMFl6zG3etXtTJU/dD750Ty4i8zt3+JGhvglPrQBY1CfItgml2oXa/VUVMnLCUS0WSZuPRmPYZD8dg==
alipay.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAksEwzuR3ASrKtTzaANqdQYKOoD44itA1TWG/6onvQr8PHNEMgcguLuJNrdeuT2PDg23byzZ9qKfEM2D5U4zbpt0/uCYLfZQyAnAWWyMvnKPoSIgrtBjnxYK6HE6fuQV3geJTcZxvP/z8dGZB0V0s6a53rzbKSLh0p4w0hWfVXlQihq3Xh4vSKB+ojdhEkIblhpWPT42NPbjVNdwPzIhUGpRy3/nsgNqVBu+ZacQ5/rCvzXU1RE0allBbjcvjymKQTS7bAE0i1Mgo1eX8njvElsfQUv5P7xQdrvZagqtIuTdP19cmsSNGdIC9Z5Po3j0z3KWPR7MrKgDuJfzkWtJR4wIDAQAB
alipay.server-url=https://openapi-sandbox.dl.alipaydev.com/gateway.do
alipay.gateway-url=https://openapi-sandbox.dl.alipaydev.com/gateway.do
alipay.charset=UTF-8
alipay.sign-type=RSA2
alipay.notify-url=https://curtly-aphorismatic-ginger.ngrok-free.dev/api/payments/alipay/notify
alipay.return-url=https://curtly-aphorismatic-ginger.ngrok-free.dev/api/payments/alipay/return
# 视频处理配置
# FFmpeg路径如果FFmpeg不在PATH中请指定完整路径
# 已通过winget安装使用完整路径使用正斜杠避免转义问题
app.ffmpeg.path=C:/Users/UI/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.0-full_build/bin/ffmpeg.exe
# 临时文件目录
app.temp.dir=./temp
# ============================================
# PayPal支付配置沙箱测试环境
# ============================================
paypal.client-id=Adpi67TvppjhyyWhrALWwJhLFzv5S_vXoUHzWQchqZe48NaONSryg7QHKBubf0PRmkeJoaxGEKV5v9lT
paypal.client-secret=EDzZl-hddwtt2pNt5RpBIICdlrUS8QtcmAttU_kuANL8Vd937SC4xel_K2hArTovVqEtyL2ZS5IcQcQV
paypal.mode=sandbox
paypal.success-url=http://localhost:8080/api/payment/paypal/success
paypal.cancel-url=http://localhost:8080/api/payment/paypal/cancel

View File

@@ -14,6 +14,9 @@ spring.servlet.multipart.enabled=true
# Tomcat 最大POST大小
server.tomcat.max-http-post-size=600MB
# JPA配置 - 禁用open-in-view避免视图层执行SQL查询
spring.jpa.open-in-view=false
# 应用配置
app.upload.path=uploads
app.video.output.path=outputs
@@ -50,3 +53,19 @@ tencent.cos.region=ap-guangzhou
# COS存储桶名称例如my-bucket-1234567890
tencent.cos.bucket-name=
# ============================================
# PayPal支付配置
# ============================================
# 注意请将实际的PayPal凭证配置到 application-dev.properties 中
# 该文件已在.gitignore中不会被提交到版本控制
# PayPal Client ID请在application-dev.properties中配置
paypal.client-id=
# PayPal Client Secret请在application-dev.properties中配置
paypal.client-secret=
# PayPal模式sandbox(测试环境) 或 live(生产环境)
paypal.mode=sandbox
# 支付成功回调URL
paypal.success-url=https://vionow.com/api/payment/paypal/success
# 支付取消回调URL
paypal.cancel-url=https://vionow.com/api/payment/paypal/cancel