项目重构: 整理目录结构, 更新前后端代码, 添加测试和数据库迁移
This commit is contained in:
@@ -2,64 +2,89 @@ package com.example.demo.config;
|
||||
|
||||
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.Component;
|
||||
|
||||
import com.example.demo.model.SystemSettings;
|
||||
import com.example.demo.repository.SystemSettingsRepository;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* 动态API配置管理器
|
||||
* 允许在运行时更新API密钥,无需重启应用
|
||||
* 优先从数据库加载配置,支持运行时更新,无需重启应用
|
||||
* 注意:视频生成和图片生成使用同一个 API Key
|
||||
*/
|
||||
@Component
|
||||
public class DynamicApiConfig {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DynamicApiConfig.class);
|
||||
|
||||
// 从配置文件读取初始值
|
||||
@Autowired
|
||||
private SystemSettingsRepository systemSettingsRepository;
|
||||
|
||||
// 从配置文件读取的默认值(作为兜底)
|
||||
@Value("${ai.api.key:}")
|
||||
private String initialApiKey;
|
||||
private String defaultApiKey;
|
||||
|
||||
@Value("${ai.api.base-url:http://116.62.4.26:8081}")
|
||||
private String initialApiBaseUrl;
|
||||
@Value("${ai.api.base-url:https://ai.comfly.chat}")
|
||||
private String defaultApiBaseUrl;
|
||||
|
||||
@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密钥
|
||||
* 优先使用运行时设置的值,否则使用配置文件的值
|
||||
* 应用启动时从数据库加载配置
|
||||
*/
|
||||
@PostConstruct
|
||||
public void loadFromDatabase() {
|
||||
try {
|
||||
SystemSettings settings = systemSettingsRepository.findById(1L).orElse(null);
|
||||
if (settings != null) {
|
||||
if (settings.getAiApiKey() != null && !settings.getAiApiKey().isEmpty()) {
|
||||
this.runtimeApiKey = settings.getAiApiKey();
|
||||
logger.info("✅ 从数据库加载 AI API Key: {}****", settings.getAiApiKey().substring(0, Math.min(4, settings.getAiApiKey().length())));
|
||||
}
|
||||
if (settings.getAiApiBaseUrl() != null && !settings.getAiApiBaseUrl().isEmpty()) {
|
||||
this.runtimeApiBaseUrl = settings.getAiApiBaseUrl();
|
||||
logger.info("✅ 从数据库加载 AI API Base URL: {}", settings.getAiApiBaseUrl());
|
||||
}
|
||||
}
|
||||
logger.info("API配置加载完成: apiKey={}, apiBaseUrl={}", maskApiKey(getApiKey()), getApiBaseUrl());
|
||||
} catch (Exception e) {
|
||||
logger.warn("从数据库加载API配置失败,使用配置文件默认值: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效的AI API密钥(视频和图片生成共用)
|
||||
* 优先级:运行时配置 > 配置文件默认值
|
||||
*/
|
||||
public String getApiKey() {
|
||||
return runtimeApiKey != null ? runtimeApiKey : initialApiKey;
|
||||
return runtimeApiKey != null ? runtimeApiKey : defaultApiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效的AI API基础URL
|
||||
*/
|
||||
public String getApiBaseUrl() {
|
||||
return runtimeApiBaseUrl != null ? runtimeApiBaseUrl : initialApiBaseUrl;
|
||||
return runtimeApiBaseUrl != null ? runtimeApiBaseUrl : defaultApiBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效的图片API密钥
|
||||
* 获取图片API密钥(与视频API共用同一个Key)
|
||||
*/
|
||||
public String getImageApiKey() {
|
||||
return runtimeImageApiKey != null ? runtimeImageApiKey : initialImageApiKey;
|
||||
return getApiKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效的图片API基础URL
|
||||
* 获取图片API基础URL(与视频API共用同一个URL)
|
||||
*/
|
||||
public String getImageApiBaseUrl() {
|
||||
return runtimeImageApiBaseUrl != null ? runtimeImageApiBaseUrl : initialImageApiBaseUrl;
|
||||
return getApiBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,23 +108,17 @@ public class DynamicApiConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态更新图片API密钥(立即生效,无需重启)
|
||||
* 动态更新图片API密钥(与视频API共用,调用 updateApiKey)
|
||||
*/
|
||||
public synchronized void updateImageApiKey(String newApiKey) {
|
||||
if (newApiKey != null && !newApiKey.trim().isEmpty()) {
|
||||
this.runtimeImageApiKey = newApiKey.trim();
|
||||
logger.info("✅ 图片API密钥已动态更新,立即生效(无需重启)");
|
||||
}
|
||||
updateApiKey(newApiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态更新图片API基础URL(立即生效,无需重启)
|
||||
* 动态更新图片API基础URL(与视频API共用,调用 updateApiBaseUrl)
|
||||
*/
|
||||
public synchronized void updateImageApiBaseUrl(String newBaseUrl) {
|
||||
if (newBaseUrl != null && !newBaseUrl.trim().isEmpty()) {
|
||||
this.runtimeImageApiBaseUrl = newBaseUrl.trim();
|
||||
logger.info("✅ 图片API基础URL已动态更新,立即生效(无需重启)");
|
||||
}
|
||||
updateApiBaseUrl(newBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,8 +127,6 @@ public class DynamicApiConfig {
|
||||
public synchronized void reset() {
|
||||
this.runtimeApiKey = null;
|
||||
this.runtimeApiBaseUrl = null;
|
||||
this.runtimeImageApiKey = null;
|
||||
this.runtimeImageApiBaseUrl = null;
|
||||
logger.info("⚠️ API配置已重置为配置文件的初始值");
|
||||
}
|
||||
|
||||
@@ -120,10 +137,7 @@ public class DynamicApiConfig {
|
||||
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);
|
||||
status.put("usingRuntimeConfig", runtimeApiKey != null || runtimeApiBaseUrl != null);
|
||||
return status;
|
||||
}
|
||||
|
||||
|
||||
@@ -408,7 +408,6 @@ public class AdminController {
|
||||
SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
|
||||
response.put("promptOptimizationModel", settings.getPromptOptimizationModel());
|
||||
response.put("promptOptimizationApiUrl", settings.getPromptOptimizationApiUrl());
|
||||
response.put("storyboardSystemPrompt", settings.getStoryboardSystemPrompt());
|
||||
response.put("siteName", settings.getSiteName());
|
||||
response.put("siteSubtitle", settings.getSiteSubtitle());
|
||||
@@ -416,9 +415,11 @@ public class AdminController {
|
||||
response.put("maintenanceMode", settings.getMaintenanceMode());
|
||||
response.put("contactEmail", settings.getContactEmail());
|
||||
response.put("tokenExpireHours", settings.getTokenExpireHours());
|
||||
// 套餐价格配置
|
||||
response.put("standardPriceCny", settings.getStandardPriceCny());
|
||||
response.put("proPriceCny", settings.getProPriceCny());
|
||||
// 套餐价格配置(从membership_levels表读取)
|
||||
membershipLevelRepository.findByName("standard").ifPresent(level ->
|
||||
response.put("standardPriceCny", level.getPrice().intValue()));
|
||||
membershipLevelRepository.findByName("professional").ifPresent(level ->
|
||||
response.put("proPriceCny", level.getPrice().intValue()));
|
||||
response.put("pointsPerGeneration", settings.getPointsPerGeneration());
|
||||
// 支付渠道开关
|
||||
response.put("enableAlipay", settings.getEnableAlipay());
|
||||
@@ -452,13 +453,6 @@ public class AdminController {
|
||||
logger.info("更新优化提示词模型为: {}", model);
|
||||
}
|
||||
|
||||
// 更新优化提示词API端点
|
||||
if (settingsData.containsKey("promptOptimizationApiUrl")) {
|
||||
String apiUrl = (String) settingsData.get("promptOptimizationApiUrl");
|
||||
settings.setPromptOptimizationApiUrl(apiUrl);
|
||||
logger.info("更新优化提示词API端点为: {}", apiUrl);
|
||||
}
|
||||
|
||||
// 更新分镜图系统引导词
|
||||
if (settingsData.containsKey("storyboardSystemPrompt")) {
|
||||
String prompt = (String) settingsData.get("storyboardSystemPrompt");
|
||||
@@ -487,30 +481,26 @@ public class AdminController {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新套餐价格(同时更新system_settings和membership_levels表)
|
||||
// 更新套餐价格(只更新membership_levels表)
|
||||
if (settingsData.containsKey("standardPriceCny")) {
|
||||
Object value = settingsData.get("standardPriceCny");
|
||||
Integer price = value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(value.toString());
|
||||
settings.setStandardPriceCny(price);
|
||||
// 同步更新membership_levels表
|
||||
membershipLevelRepository.findByName("standard").ifPresent(level -> {
|
||||
level.setPrice(price.doubleValue());
|
||||
level.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
membershipLevelRepository.save(level);
|
||||
logger.info("同步更新membership_levels表: standard价格={}", price);
|
||||
logger.info("更新membership_levels表: standard价格={}", price);
|
||||
});
|
||||
logger.info("更新标准版价格为: {} 元", price);
|
||||
}
|
||||
if (settingsData.containsKey("proPriceCny")) {
|
||||
Object value = settingsData.get("proPriceCny");
|
||||
Integer price = value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(value.toString());
|
||||
settings.setProPriceCny(price);
|
||||
// 同步更新membership_levels表
|
||||
membershipLevelRepository.findByName("professional").ifPresent(level -> {
|
||||
level.setPrice(price.doubleValue());
|
||||
level.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
membershipLevelRepository.save(level);
|
||||
logger.info("同步更新membership_levels表: professional价格={}", price);
|
||||
logger.info("更新membership_levels表: professional价格={}", price);
|
||||
});
|
||||
logger.info("更新专业版价格为: {} 元", price);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
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;
|
||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -25,33 +15,38 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.example.demo.config.DynamicApiConfig;
|
||||
import com.example.demo.model.SystemSettings;
|
||||
import com.example.demo.service.SystemSettingsService;
|
||||
|
||||
/**
|
||||
* API密钥管理控制器
|
||||
* 配置保存到数据库,支持运行时更新,重启后自动加载
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/api-key")
|
||||
@CrossOrigin(origins = "*")
|
||||
public class ApiKeyController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ApiKeyController.class);
|
||||
|
||||
@Value("${spring.profiles.active:dev}")
|
||||
private String activeProfile;
|
||||
|
||||
@Value("${ai.api.key:}")
|
||||
private String currentApiKey;
|
||||
|
||||
@Value("${jwt.expiration:86400000}")
|
||||
private Long currentJwtExpiration;
|
||||
|
||||
@Autowired
|
||||
private DynamicApiConfig dynamicApiConfig;
|
||||
|
||||
@Autowired
|
||||
private SystemSettingsService systemSettingsService;
|
||||
|
||||
/**
|
||||
* 获取当前API密钥和JWT配置(仅显示部分,用于验证)
|
||||
* 获取当前API密钥配置(仅显示部分,用于验证)
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getApiKey() {
|
||||
try {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
// 从动态配置获取当前使用的 API Key
|
||||
String currentApiKey = dynamicApiConfig.getApiKey();
|
||||
String currentApiBaseUrl = dynamicApiConfig.getApiBaseUrl();
|
||||
|
||||
// 只返回密钥的前4位和后4位,中间用*代替
|
||||
if (currentApiKey != null && currentApiKey.length() > 8) {
|
||||
String masked = currentApiKey.substring(0, 4) + "****" + currentApiKey.substring(currentApiKey.length() - 4);
|
||||
@@ -59,10 +54,13 @@ public class ApiKeyController {
|
||||
} else {
|
||||
response.put("maskedKey", "****");
|
||||
}
|
||||
// 返回JWT过期时间(毫秒)
|
||||
response.put("jwtExpiration", currentJwtExpiration);
|
||||
// 转换为小时显示
|
||||
response.put("jwtExpirationHours", currentJwtExpiration / 3600000.0);
|
||||
|
||||
response.put("apiBaseUrl", currentApiBaseUrl);
|
||||
|
||||
// 从数据库获取 Token 过期时间
|
||||
SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
response.put("tokenExpireHours", settings.getTokenExpireHours());
|
||||
|
||||
response.put("success", true);
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
@@ -75,99 +73,94 @@ public class ApiKeyController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新API密钥和JWT配置到配置文件
|
||||
* 更新API密钥配置到数据库(立即生效,重启后自动加载)
|
||||
*/
|
||||
@PutMapping
|
||||
public ResponseEntity<Map<String, Object>> updateApiKey(@RequestBody Map<String, Object> request) {
|
||||
try {
|
||||
String newApiKey = (String) request.get("apiKey");
|
||||
Object jwtExpirationObj = request.get("jwtExpiration");
|
||||
String newApiBaseUrl = (String) request.get("apiBaseUrl");
|
||||
Object tokenExpireObj = request.get("tokenExpireHours");
|
||||
|
||||
// 验证API密钥
|
||||
if (newApiKey != null && newApiKey.trim().isEmpty()) {
|
||||
newApiKey = null; // 如果为空字符串,则不更新
|
||||
newApiKey = null;
|
||||
}
|
||||
|
||||
// 验证JWT过期时间
|
||||
Long newJwtExpiration = null;
|
||||
if (jwtExpirationObj != null) {
|
||||
if (jwtExpirationObj instanceof Number) {
|
||||
newJwtExpiration = ((Number) jwtExpirationObj).longValue();
|
||||
} else if (jwtExpirationObj instanceof String) {
|
||||
// 验证API基础URL
|
||||
if (newApiBaseUrl != null && newApiBaseUrl.trim().isEmpty()) {
|
||||
newApiBaseUrl = null;
|
||||
}
|
||||
|
||||
// 验证Token过期时间
|
||||
Integer tokenExpireHours = null;
|
||||
if (tokenExpireObj != null) {
|
||||
if (tokenExpireObj instanceof Number) {
|
||||
tokenExpireHours = ((Number) tokenExpireObj).intValue();
|
||||
} else if (tokenExpireObj instanceof String) {
|
||||
try {
|
||||
newJwtExpiration = Long.parseLong((String) jwtExpirationObj);
|
||||
tokenExpireHours = Integer.parseInt((String) tokenExpireObj);
|
||||
} catch (NumberFormatException e) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "JWT过期时间格式错误");
|
||||
error.put("message", "JWT过期时间必须是数字(毫秒)");
|
||||
return ResponseEntity.badRequest().body(error);
|
||||
// 忽略无效值
|
||||
}
|
||||
}
|
||||
|
||||
// 验证过期时间范围(至少1小时,最多30天)
|
||||
if (newJwtExpiration != null && (newJwtExpiration < 3600000 || newJwtExpiration > 2592000000L)) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "JWT过期时间超出范围");
|
||||
error.put("message", "JWT过期时间必须在1小时(3600000毫秒)到30天(2592000000毫秒)之间");
|
||||
return ResponseEntity.badRequest().body(error);
|
||||
// 验证范围
|
||||
if (tokenExpireHours != null && (tokenExpireHours < 1 || tokenExpireHours > 720)) {
|
||||
tokenExpireHours = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没有提供,返回错误
|
||||
if (newApiKey == null && newJwtExpiration == null) {
|
||||
if (newApiKey == null && newApiBaseUrl == null && tokenExpireHours == null) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("error", "至少需要提供一个配置项");
|
||||
error.put("message", "请提供API密钥或JWT过期时间");
|
||||
error.put("message", "请提供API密钥、API基础URL或Token过期时间");
|
||||
return ResponseEntity.badRequest().body(error);
|
||||
}
|
||||
|
||||
// 确定配置文件路径
|
||||
String configFileName = "application-" + activeProfile + ".properties";
|
||||
Path configPath = getConfigFilePath(configFileName);
|
||||
|
||||
// 读取现有配置
|
||||
Properties props = new Properties();
|
||||
if (Files.exists(configPath)) {
|
||||
try (FileInputStream fis = new FileInputStream(configPath.toFile())) {
|
||||
props.load(fis);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统设置
|
||||
SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
|
||||
StringBuilder message = new StringBuilder();
|
||||
|
||||
// 更新API密钥
|
||||
if (newApiKey != null) {
|
||||
props.setProperty("ai.api.key", newApiKey);
|
||||
props.setProperty("ai.image.api.key", newApiKey); // 同时更新图片API密钥
|
||||
logger.info("API密钥已更新到配置文件");
|
||||
newApiKey = newApiKey.trim();
|
||||
settings.setAiApiKey(newApiKey);
|
||||
|
||||
// ✅ 动态更新运行时配置,立即生效!
|
||||
// 动态更新运行时配置,立即生效
|
||||
dynamicApiConfig.updateApiKey(newApiKey);
|
||||
dynamicApiConfig.updateImageApiKey(newApiKey);
|
||||
logger.info("✅ API密钥已立即生效(无需重启应用)");
|
||||
|
||||
logger.info("✅ API密钥已保存到数据库并立即生效");
|
||||
message.append("API密钥已更新。");
|
||||
}
|
||||
|
||||
// 更新JWT过期时间
|
||||
if (newJwtExpiration != null) {
|
||||
props.setProperty("jwt.expiration", String.valueOf(newJwtExpiration));
|
||||
logger.info("JWT过期时间已更新: {} 毫秒 ({} 小时)", newJwtExpiration, newJwtExpiration / 3600000.0);
|
||||
|
||||
// 更新API基础URL
|
||||
if (newApiBaseUrl != null) {
|
||||
newApiBaseUrl = newApiBaseUrl.trim();
|
||||
settings.setAiApiBaseUrl(newApiBaseUrl);
|
||||
|
||||
// 动态更新运行时配置,立即生效
|
||||
dynamicApiConfig.updateApiBaseUrl(newApiBaseUrl);
|
||||
|
||||
logger.info("✅ API基础URL已保存到数据库并立即生效: {}", newApiBaseUrl);
|
||||
message.append("API基础URL已更新。");
|
||||
}
|
||||
|
||||
// 保存配置文件
|
||||
try (FileOutputStream fos = new FileOutputStream(configPath.toFile())) {
|
||||
props.store(fos, "Updated by API Key Management");
|
||||
|
||||
// 更新Token过期时间
|
||||
if (tokenExpireHours != null) {
|
||||
settings.setTokenExpireHours(tokenExpireHours);
|
||||
logger.info("✅ Token过期时间已保存到数据库: {} 小时", tokenExpireHours);
|
||||
message.append("Token过期时间已更新为" + tokenExpireHours + "小时。");
|
||||
}
|
||||
|
||||
logger.info("配置已更新到配置文件: {}", configPath);
|
||||
|
||||
// 保存到数据库
|
||||
systemSettingsService.update(settings);
|
||||
|
||||
message.append("配置已保存到数据库,立即生效且重启后自动加载。");
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
StringBuilder message = new StringBuilder();
|
||||
if (newApiKey != null) {
|
||||
message.append("API密钥已更新。");
|
||||
}
|
||||
if (newJwtExpiration != null) {
|
||||
message.append("JWT过期时间已更新。");
|
||||
}
|
||||
message.append("配置已立即生效(无需重启)。如需永久保存,请重启应用加载配置文件。");
|
||||
response.put("message", message.toString());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
@@ -180,45 +173,5 @@ message.append("配置已立即生效(无需重启)。如需永久保存,
|
||||
return ResponseEntity.status(500).body(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置文件路径
|
||||
* 优先使用外部配置文件,如果不存在则使用classpath中的配置文件
|
||||
*/
|
||||
private Path getConfigFilePath(String fileName) throws IOException {
|
||||
// 尝试从外部配置目录查找
|
||||
String externalConfigDir = System.getProperty("user.dir");
|
||||
Path externalPath = Paths.get(externalConfigDir, "config", fileName);
|
||||
if (Files.exists(externalPath)) {
|
||||
return externalPath;
|
||||
}
|
||||
|
||||
// 尝试从项目根目录查找
|
||||
Path rootPath = Paths.get(externalConfigDir, "src", "main", "resources", fileName);
|
||||
if (Files.exists(rootPath)) {
|
||||
return rootPath;
|
||||
}
|
||||
|
||||
// 尝试从classpath复制到外部目录
|
||||
ClassPathResource resource = new ClassPathResource(fileName);
|
||||
if (resource.exists()) {
|
||||
// 创建config目录
|
||||
Path configDir = Paths.get(externalConfigDir, "config");
|
||||
Files.createDirectories(configDir);
|
||||
|
||||
// 复制文件到外部目录
|
||||
Path targetPath = configDir.resolve(fileName);
|
||||
try (InputStream is = resource.getInputStream();
|
||||
FileOutputStream fos = new FileOutputStream(targetPath.toFile())) {
|
||||
is.transferTo(fos);
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
// 如果都不存在,创建新的配置文件
|
||||
Path configDir = Paths.get(externalConfigDir, "config");
|
||||
Files.createDirectories(configDir);
|
||||
return configDir.resolve(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ 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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -128,6 +129,82 @@ public class ImageToVideoApiController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过图片URL创建图生视频任务(用于"做同款"功能)
|
||||
*/
|
||||
@PostMapping("/create-by-url")
|
||||
public ResponseEntity<Map<String, Object>> createTaskByUrl(
|
||||
@RequestBody Map<String, Object> request,
|
||||
@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);
|
||||
}
|
||||
|
||||
// 提取参数
|
||||
String imageUrl = (String) request.get("imageUrl");
|
||||
String prompt = (String) request.get("prompt");
|
||||
String aspectRatio = (String) request.getOrDefault("aspectRatio", "16:9");
|
||||
int duration = request.get("duration") instanceof Number
|
||||
? ((Number) request.get("duration")).intValue()
|
||||
: Integer.parseInt(request.getOrDefault("duration", "5").toString());
|
||||
boolean hdMode = Boolean.parseBoolean(request.getOrDefault("hdMode", "false").toString());
|
||||
|
||||
logger.info("通过URL创建图生视频任务: username={}, imageUrl={}, prompt={}",
|
||||
username, imageUrl != null ? imageUrl.substring(0, Math.min(50, imageUrl.length())) : "null", prompt);
|
||||
|
||||
// 验证参数
|
||||
if (imageUrl == null || imageUrl.trim().isEmpty()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "图片URL不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
if (prompt == null || prompt.trim().isEmpty()) {
|
||||
response.put("success", false);
|
||||
response.put("message", "描述文字不能为空");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
if (duration < 1 || duration > 60) {
|
||||
response.put("success", false);
|
||||
response.put("message", "视频时长必须在1-60秒之间");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
if (!isValidAspectRatio(aspectRatio)) {
|
||||
response.put("success", false);
|
||||
response.put("message", "不支持的视频比例");
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
ImageToVideoTask task = imageToVideoService.createTaskByUrl(
|
||||
username, imageUrl.trim(), prompt.trim(), aspectRatio, duration, hdMode
|
||||
);
|
||||
|
||||
response.put("success", true);
|
||||
response.put("message", "任务创建成功");
|
||||
response.put("data", task);
|
||||
|
||||
logger.info("用户 {} 通过URL创建图生视频任务成功: {}", username, task.getId());
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("通过URL创建图生视频任务失败", e);
|
||||
response.put("success", false);
|
||||
response.put("message", "创建任务失败:" + e.getMessage());
|
||||
return ResponseEntity.status(500).body(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的任务列表
|
||||
*/
|
||||
|
||||
@@ -94,9 +94,9 @@ public class MemberApiController {
|
||||
member.put("createdAt", user.getCreatedAt());
|
||||
member.put("lastLoginAt", user.getLastLoginAt());
|
||||
|
||||
// 获取会员信息
|
||||
// 获取会员信息(按到期时间降序,返回最新的)
|
||||
Optional<UserMembership> membership = userMembershipRepository
|
||||
.findByUserIdAndStatus(user.getId(), "ACTIVE");
|
||||
.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
|
||||
if (membership.isPresent()) {
|
||||
UserMembership userMembership = membership.get();
|
||||
@@ -155,9 +155,9 @@ public class MemberApiController {
|
||||
member.put("createdAt", user.getCreatedAt());
|
||||
member.put("lastLoginAt", user.getLastLoginAt());
|
||||
|
||||
// 获取会员信息
|
||||
// 获取会员信息(按到期时间降序,返回最新的)
|
||||
Optional<UserMembership> membership = userMembershipRepository
|
||||
.findByUserIdAndStatus(user.getId(), "ACTIVE");
|
||||
.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
|
||||
if (membership.isPresent()) {
|
||||
UserMembership userMembership = membership.get();
|
||||
@@ -256,9 +256,9 @@ public class MemberApiController {
|
||||
if (levelOpt.isPresent()) {
|
||||
MembershipLevel level = levelOpt.get();
|
||||
|
||||
// 查找或创建会员信息
|
||||
// 查找或创建会员信息(按到期时间降序,返回最新的)
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository
|
||||
.findByUserIdAndStatus(user.getId(), "ACTIVE");
|
||||
.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
|
||||
UserMembership membership;
|
||||
if (membershipOpt.isPresent()) {
|
||||
|
||||
@@ -77,8 +77,9 @@ public class PayPalController {
|
||||
String orderId = (String) request.get("orderId");
|
||||
String amount = request.get("amount") != null ? request.get("amount").toString() : null;
|
||||
String method = (String) request.get("method");
|
||||
String description = (String) request.get("description");
|
||||
|
||||
payment = paymentService.createPayment(username, orderId, amount, method);
|
||||
payment = paymentService.createPayment(username, orderId, amount, method, description);
|
||||
}
|
||||
|
||||
// 调用 PayPal API 创建支付
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import com.example.demo.model.Payment;
|
||||
import com.example.demo.model.PaymentMethod;
|
||||
import com.example.demo.model.PaymentStatus;
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.model.UserMembership;
|
||||
@@ -33,6 +34,7 @@ import com.example.demo.repository.PaymentRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.service.AlipayService;
|
||||
import com.example.demo.service.OrderService;
|
||||
import com.example.demo.service.PaymentService;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
@@ -52,6 +54,9 @@ public class PaymentApiController {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private OrderService orderService;
|
||||
|
||||
@Autowired
|
||||
private PaymentRepository paymentRepository;
|
||||
|
||||
@@ -162,13 +167,14 @@ public class PaymentApiController {
|
||||
String orderId = (String) paymentData.get("orderId");
|
||||
String amountStr = paymentData.get("amount") != null ? paymentData.get("amount").toString() : null;
|
||||
String method = (String) paymentData.get("method");
|
||||
String description = (String) paymentData.get("description");
|
||||
|
||||
if (orderId == null || amountStr == null || method == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("订单号、金额和支付方式不能为空"));
|
||||
}
|
||||
|
||||
Payment payment = paymentService.createPayment(username, orderId, amountStr, method);
|
||||
Payment payment = paymentService.createPayment(username, orderId, amountStr, method, description);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
@@ -210,6 +216,7 @@ public class PaymentApiController {
|
||||
payment.setStatus(PaymentStatus.valueOf(status));
|
||||
paymentService.save(payment);
|
||||
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "状态更新成功");
|
||||
@@ -223,6 +230,41 @@ public class PaymentApiController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新支付方式和描述(用于切换支付方式时)
|
||||
*/
|
||||
@PutMapping("/{id}/method")
|
||||
public ResponseEntity<Map<String, Object>> updatePaymentMethod(
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, String> methodData,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
String method = methodData.get("method");
|
||||
String description = methodData.get("description");
|
||||
|
||||
if (method == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("支付方式不能为空"));
|
||||
}
|
||||
|
||||
// 调用Service层方法(带事务)
|
||||
Payment payment = paymentService.updatePaymentMethod(id, method, description, authentication.getName());
|
||||
|
||||
logger.info("支付方式更新成功: paymentId={}, method={}, description={}", id, method, description);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "支付方式更新成功");
|
||||
response.put("data", payment);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("更新支付方式失败", e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("更新支付方式失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认支付成功
|
||||
*/
|
||||
@@ -340,7 +382,7 @@ public class PaymentApiController {
|
||||
|
||||
// 生成测试订单号
|
||||
String testOrderId = "TEST_" + System.currentTimeMillis();
|
||||
Payment payment = paymentService.createPayment(username, testOrderId, amountStr, method);
|
||||
Payment payment = paymentService.createPayment(username, testOrderId, amountStr, method, "测试支付");
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
@@ -467,9 +509,9 @@ public class PaymentApiController {
|
||||
String expiryTime = "永久";
|
||||
LocalDateTime paidAt = null;
|
||||
|
||||
// 优先从UserMembership表获取用户的实际会员等级(管理员可能手动修改)
|
||||
// 优先从UserMembership表获取用户的实际会员等级(按到期时间降序,返回最新的)
|
||||
try {
|
||||
java.util.Optional<UserMembership> membershipOpt = userMembershipRepository.findByUserIdAndStatus(user.getId(), "ACTIVE");
|
||||
java.util.Optional<UserMembership> membershipOpt = userMembershipRepository.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
if (membershipOpt.isPresent()) {
|
||||
UserMembership membership = membershipOpt.get();
|
||||
LocalDateTime endDate = membership.getEndDate();
|
||||
|
||||
@@ -83,6 +83,18 @@ public class FailedTaskCleanupLog {
|
||||
);
|
||||
}
|
||||
|
||||
// 从StoryboardVideoTask创建
|
||||
public static FailedTaskCleanupLog fromStoryboardVideoTask(StoryboardVideoTask task) {
|
||||
return new FailedTaskCleanupLog(
|
||||
task.getTaskId(),
|
||||
task.getUsername(),
|
||||
"STORYBOARD_VIDEO",
|
||||
task.getErrorMessage(),
|
||||
task.getCreatedAt(),
|
||||
task.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public Long getId() {
|
||||
return id;
|
||||
|
||||
@@ -17,18 +17,6 @@ public class SystemSettings {
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 标准版价格(单位:元) */
|
||||
@NotNull
|
||||
@Min(0)
|
||||
@Column(nullable = false)
|
||||
private Integer standardPriceCny = 298;
|
||||
|
||||
/** 专业版价格(单位:元) */
|
||||
@NotNull
|
||||
@Min(0)
|
||||
@Column(nullable = false)
|
||||
private Integer proPriceCny = 398;
|
||||
|
||||
/** 每次生成消耗的资源点数量 */
|
||||
@NotNull
|
||||
@Min(0)
|
||||
@@ -70,10 +58,6 @@ public class SystemSettings {
|
||||
@Column(length = 50)
|
||||
private String promptOptimizationModel = "gpt-5.1-thinking";
|
||||
|
||||
/** 优化提示词API端点 */
|
||||
@Column(length = 200)
|
||||
private String promptOptimizationApiUrl = "https://ai.comfly.chat";
|
||||
|
||||
/** 分镜图生成系统引导词 */
|
||||
@Column(length = 2000)
|
||||
private String storyboardSystemPrompt = "";
|
||||
@@ -84,6 +68,14 @@ public class SystemSettings {
|
||||
@Column(nullable = false)
|
||||
private Integer tokenExpireHours = 24;
|
||||
|
||||
/** AI API密钥(视频和图片生成共用) */
|
||||
@Column(length = 200)
|
||||
private String aiApiKey;
|
||||
|
||||
/** AI API基础URL */
|
||||
@Column(length = 200)
|
||||
private String aiApiBaseUrl;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -92,22 +84,6 @@ public class SystemSettings {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public Integer getStandardPriceCny() {
|
||||
return standardPriceCny;
|
||||
}
|
||||
|
||||
public void setStandardPriceCny(Integer standardPriceCny) {
|
||||
this.standardPriceCny = standardPriceCny;
|
||||
}
|
||||
|
||||
public Integer getProPriceCny() {
|
||||
return proPriceCny;
|
||||
}
|
||||
|
||||
public void setProPriceCny(Integer proPriceCny) {
|
||||
this.proPriceCny = proPriceCny;
|
||||
}
|
||||
|
||||
public Integer getPointsPerGeneration() {
|
||||
return pointsPerGeneration;
|
||||
}
|
||||
@@ -180,14 +156,6 @@ public class SystemSettings {
|
||||
this.promptOptimizationModel = promptOptimizationModel;
|
||||
}
|
||||
|
||||
public String getPromptOptimizationApiUrl() {
|
||||
return promptOptimizationApiUrl;
|
||||
}
|
||||
|
||||
public void setPromptOptimizationApiUrl(String promptOptimizationApiUrl) {
|
||||
this.promptOptimizationApiUrl = promptOptimizationApiUrl;
|
||||
}
|
||||
|
||||
public String getStoryboardSystemPrompt() {
|
||||
return storyboardSystemPrompt;
|
||||
}
|
||||
@@ -206,6 +174,22 @@ public class SystemSettings {
|
||||
this.tokenExpireHours = tokenExpireHours;
|
||||
}
|
||||
}
|
||||
|
||||
public String getAiApiKey() {
|
||||
return aiApiKey;
|
||||
}
|
||||
|
||||
public void setAiApiKey(String aiApiKey) {
|
||||
this.aiApiKey = aiApiKey;
|
||||
}
|
||||
|
||||
public String getAiApiBaseUrl() {
|
||||
return aiApiBaseUrl;
|
||||
}
|
||||
|
||||
public void setAiApiBaseUrl(String aiApiBaseUrl) {
|
||||
this.aiApiBaseUrl = aiApiBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ public interface PaymentRepository extends JpaRepository<Payment, Long> {
|
||||
@Query("SELECT p FROM Payment p LEFT JOIN FETCH p.user WHERE p.id = :id")
|
||||
Optional<Payment> findByIdWithUser(@Param("id") Long id);
|
||||
|
||||
/**
|
||||
* 根据ID查询Payment并立即加载User和Order(避免LazyInitializationException)
|
||||
*/
|
||||
@Query("SELECT p FROM Payment p LEFT JOIN FETCH p.user LEFT JOIN FETCH p.order WHERE p.id = :id")
|
||||
Optional<Payment> findByIdWithUserAndOrder(@Param("id") Long id);
|
||||
|
||||
Optional<Payment> findByExternalTransactionId(String externalTransactionId);
|
||||
|
||||
List<Payment> findByUserId(Long userId);
|
||||
|
||||
@@ -2,19 +2,9 @@ package com.example.demo.repository;
|
||||
|
||||
import com.example.demo.model.SystemSettings;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface SystemSettingsRepository extends JpaRepository<SystemSettings, Long> {
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE SystemSettings s SET s.standardPriceCny = :price WHERE s.id = :id")
|
||||
int updateStandardPrice(@Param("id") Long id, @Param("price") Integer price);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE SystemSettings s SET s.proPriceCny = :price WHERE s.id = :id")
|
||||
int updateProPrice(@Param("id") Long id, @Param("price") Integer price);
|
||||
// 套餐价格已移至 membership_levels 表管理
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ import com.example.demo.model.UserMembership;
|
||||
public interface UserMembershipRepository extends JpaRepository<UserMembership, Long> {
|
||||
Optional<UserMembership> findByUserIdAndStatus(Long userId, String status);
|
||||
|
||||
/**
|
||||
* 按到期时间降序查找用户的有效会员记录(返回到期时间最晚的)
|
||||
*/
|
||||
Optional<UserMembership> findFirstByUserIdAndStatusOrderByEndDateDesc(Long userId, String status);
|
||||
|
||||
long countByStatus(String status);
|
||||
|
||||
long countByStartDateBetween(LocalDateTime startDate, LocalDateTime endDate);
|
||||
|
||||
@@ -83,13 +83,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String token = jwtUtils.extractTokenFromHeader(authHeader);
|
||||
|
||||
if (token != null && !token.equals("null") && !token.trim().isEmpty()) {
|
||||
logger.info("JWT过滤器: 收到token, 长度={}, 前20字符={}", token.length(), token.substring(0, Math.min(20, token.length())));
|
||||
|
||||
String username = jwtUtils.getUsernameFromToken(token);
|
||||
logger.info("JWT过滤器: 从token提取用户名={}", username);
|
||||
|
||||
if (username != null && jwtUtils.validateToken(token, username)) {
|
||||
logger.info("JWT过滤器: token验证通过, username={}", username);
|
||||
// Redis 验证已降级:isTokenValid 总是返回 true
|
||||
// 主要依赖 JWT 本身的有效性验证
|
||||
User user = userService.findByUsernameOrNull(username);
|
||||
|
||||
@@ -227,18 +227,30 @@ public class AlipayService {
|
||||
String subCode = (String) precreateResponse.get("sub_code");
|
||||
String subMsg = (String) precreateResponse.get("sub_msg");
|
||||
|
||||
// 如果交易已经成功,不需要再生成二维码
|
||||
// 如果交易已经成功,需要检查是否是当前支付记录
|
||||
// 避免新创建的支付被误判为成功
|
||||
if ("ACQ.TRADE_HAS_SUCCESS".equals(subCode)) {
|
||||
logger.info("交易已成功支付,订单号:{},无需再生成二维码", payment.getOrderId());
|
||||
// 更新支付状态为成功
|
||||
payment.setStatus(PaymentStatus.SUCCESS);
|
||||
payment.setPaidAt(LocalDateTime.now());
|
||||
paymentRepository.save(payment);
|
||||
// 返回已支付成功的信息
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("alreadyPaid", true);
|
||||
result.put("message", "该订单已支付成功");
|
||||
return result;
|
||||
logger.warn("支付宝返回交易已成功,订单号:{},支付ID:{},当前状态:{}",
|
||||
payment.getOrderId(), payment.getId(), payment.getStatus());
|
||||
|
||||
// 检查当前支付记录的状态
|
||||
// 如果当前支付记录已经是成功状态,说明是重复通知,可以返回成功
|
||||
// 如果当前支付记录是PENDING状态,说明可能是订单号冲突,需要生成新的订单号
|
||||
if (payment.getStatus() == PaymentStatus.SUCCESS) {
|
||||
logger.info("支付记录已经是成功状态,返回已支付信息");
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("alreadyPaid", true);
|
||||
result.put("message", "该订单已支付成功");
|
||||
return result;
|
||||
} else {
|
||||
// 当前支付是PENDING状态,但支付宝说已成功
|
||||
// 这可能是订单号冲突(之前的支付使用了相同的订单号)
|
||||
// 不应该直接将新支付标记为成功,而是抛出错误让前端重新创建
|
||||
logger.error("⚠️ 订单号冲突:支付宝返回交易已成功,但当前支付记录状态为PENDING。订单号:{},支付ID:{}",
|
||||
payment.getOrderId(), payment.getId());
|
||||
logger.error("这可能是订单号冲突导致的,建议生成新的订单号重新创建支付");
|
||||
throw new RuntimeException("订单号冲突:该订单号已被使用,请重新创建支付订单");
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException("二维码生成失败:" + msg + (subMsg != null ? " - " + subMsg : "") + " (code: " + code + (subCode != null ? ", sub_code: " + subCode : "") + ")");
|
||||
|
||||
@@ -116,6 +116,51 @@ public class ImageToVideoService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过图片URL创建图生视频任务(用于"做同款"功能)
|
||||
* 直接使用已有的图片URL,无需重新上传
|
||||
*/
|
||||
@Transactional
|
||||
public ImageToVideoTask createTaskByUrl(String username, String imageUrl, String prompt,
|
||||
String aspectRatio, int duration, boolean hdMode) {
|
||||
try {
|
||||
// 检查用户所有类型任务的总数(统一检查)
|
||||
userWorkService.checkMaxConcurrentTasks(username);
|
||||
|
||||
// 生成任务ID
|
||||
String taskId = generateTaskId();
|
||||
|
||||
// 直接使用传入的图片URL作为首帧图片URL
|
||||
String firstFrameUrl = imageUrl;
|
||||
|
||||
// 创建任务记录
|
||||
ImageToVideoTask task = new ImageToVideoTask(
|
||||
taskId, username, firstFrameUrl, prompt, aspectRatio, duration, hdMode
|
||||
);
|
||||
|
||||
// 保存到数据库
|
||||
task = taskRepository.save(task);
|
||||
|
||||
// 添加任务到队列
|
||||
taskQueueService.addImageToVideoTask(username, taskId);
|
||||
|
||||
// 创建PROCESSING状态的UserWork,以便用户刷新页面后能恢复任务
|
||||
try {
|
||||
userWorkService.createProcessingImageToVideoWork(task);
|
||||
} catch (Exception e) {
|
||||
logger.warn("创建PROCESSING状态作品失败(不影响任务执行): {}", taskId, e);
|
||||
}
|
||||
|
||||
logger.info("通过URL创建图生视频任务成功: taskId={}, username={}, imageUrl={}",
|
||||
taskId, username, imageUrl.substring(0, Math.min(50, imageUrl.length())));
|
||||
return task;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("通过URL创建图生视频任务失败", e);
|
||||
throw new RuntimeException("创建任务失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户任务列表
|
||||
*/
|
||||
|
||||
@@ -280,29 +280,76 @@ public class OrderService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据订单描述获取对应会员等级的积分(完全动态,从数据库获取所有会员等级进行匹配)
|
||||
* 根据订单描述或金额获取对应会员等级的积分(完全动态,从数据库获取所有会员等级进行匹配)
|
||||
*/
|
||||
private int getPointsFromOrderMembershipLevel(Order order) {
|
||||
String description = order.getDescription();
|
||||
if (description == null || description.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 从数据库获取所有会员等级,动态匹配
|
||||
// 从数据库获取所有会员等级
|
||||
List<MembershipLevel> allLevels = membershipLevelRepository.findAll();
|
||||
for (MembershipLevel level : allLevels) {
|
||||
// 检查订单描述是否包含会员等级的name或displayName(不区分大小写)
|
||||
String name = level.getName();
|
||||
String displayName = level.getDisplayName();
|
||||
|
||||
// 1. 首先尝试从描述匹配
|
||||
if (description != null && !description.isEmpty()) {
|
||||
String descLower = description.toLowerCase();
|
||||
|
||||
if ((name != null && descLower.contains(name.toLowerCase())) ||
|
||||
(displayName != null && descLower.contains(displayName.toLowerCase()))) {
|
||||
logger.info("从订单描述匹配到会员等级: level={}, points={}", name, level.getPointsBonus());
|
||||
return level.getPointsBonus();
|
||||
for (MembershipLevel level : allLevels) {
|
||||
String name = level.getName();
|
||||
String displayName = level.getDisplayName();
|
||||
|
||||
// 检查订单描述是否包含会员等级的name或displayName(不区分大小写)
|
||||
if ((name != null && descLower.contains(name.toLowerCase())) ||
|
||||
(displayName != null && descLower.contains(displayName.toLowerCase()))) {
|
||||
logger.info("从订单描述匹配到会员等级: level={}, points={}", name, level.getPointsBonus());
|
||||
return level.getPointsBonus();
|
||||
}
|
||||
|
||||
// 额外匹配中文关键词
|
||||
if (name != null) {
|
||||
if (name.equals("standard") && (description.contains("标准") || description.contains("Standard"))) {
|
||||
logger.info("从订单描述匹配到会员等级(中文): level={}, points={}", name, level.getPointsBonus());
|
||||
return level.getPointsBonus();
|
||||
}
|
||||
if (name.equals("professional") && (description.contains("专业") || description.contains("Professional") || description.contains("Pro"))) {
|
||||
logger.info("从订单描述匹配到会员等级(中文): level={}, points={}", name, level.getPointsBonus());
|
||||
return level.getPointsBonus();
|
||||
}
|
||||
if (name.equals("free") && (description.contains("免费") || description.contains("Free"))) {
|
||||
logger.info("从订单描述匹配到会员等级(中文): level={}, points={}", name, level.getPointsBonus());
|
||||
return level.getPointsBonus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 描述为空或无法匹配时,使用金额判断
|
||||
BigDecimal amount = order.getTotalAmount();
|
||||
if (amount != null) {
|
||||
int amountInt = amount.intValue();
|
||||
logger.info("订单描述为空或无法匹配,尝试使用金额判断: amount={}", amountInt);
|
||||
|
||||
// 按价格从高到低排序,优先匹配高价套餐
|
||||
allLevels.sort((a, b) -> {
|
||||
Double priceA = a.getPrice() != null ? a.getPrice() : 0.0;
|
||||
Double priceB = b.getPrice() != null ? b.getPrice() : 0.0;
|
||||
return priceB.compareTo(priceA);
|
||||
});
|
||||
|
||||
for (MembershipLevel level : allLevels) {
|
||||
if (level.getPrice() == null || level.getPrice() <= 0) continue;
|
||||
int levelPrice = level.getPrice().intValue();
|
||||
|
||||
// 允许10%的价格浮动
|
||||
if (amountInt >= levelPrice * 0.9 && amountInt <= levelPrice * 1.1) {
|
||||
logger.info("从订单金额匹配到会员等级: level={}, price={}, amount={}, points={}",
|
||||
level.getName(), levelPrice, amountInt, level.getPointsBonus());
|
||||
return level.getPointsBonus();
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn("无法从订单金额匹配会员等级: amount={}", amountInt);
|
||||
}
|
||||
|
||||
logger.warn("无法从订单识别会员等级: description={}, amount={}", description, amount);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -372,8 +419,8 @@ public class OrderService {
|
||||
}
|
||||
int durationDays = level.getDurationDays();
|
||||
|
||||
// 查找或创建用户会员信息
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository.findByUserIdAndStatus(user.getId(), "ACTIVE");
|
||||
// 查找或创建用户会员信息(按到期时间降序,返回最新的)
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
|
||||
UserMembership membership;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
@@ -40,6 +40,38 @@ public class PaymentService {
|
||||
@Transactional(readOnly = true) public long countByStatus(PaymentStatus status) { return paymentRepository.countByStatus(status); }
|
||||
@Transactional(readOnly = true) public long countByUserIdAndStatus(Long userId, PaymentStatus status) { return paymentRepository.countByUserIdAndStatus(userId, status); }
|
||||
|
||||
/**
|
||||
* 更新支付方式和描述(带事务,解决懒加载问题)
|
||||
*/
|
||||
@Transactional
|
||||
public Payment updatePaymentMethod(Long paymentId, String method, String description, String username) {
|
||||
// 使用findByIdWithUserAndOrder一次性加载User和Order,避免懒加载异常
|
||||
Payment payment = paymentRepository.findByIdWithUserAndOrder(paymentId)
|
||||
.orElseThrow(() -> new RuntimeException("支付记录不存在"));
|
||||
|
||||
// 检查权限
|
||||
if (!payment.getUser().getUsername().equals(username)) {
|
||||
throw new RuntimeException("无权限修改此支付记录");
|
||||
}
|
||||
|
||||
// 只有PENDING状态的支付才能修改支付方式
|
||||
if (payment.getStatus() != PaymentStatus.PENDING) {
|
||||
throw new RuntimeException("只有待支付状态的订单才能修改支付方式");
|
||||
}
|
||||
|
||||
payment.setPaymentMethod(PaymentMethod.valueOf(method));
|
||||
if (description != null && !description.isEmpty()) {
|
||||
payment.setDescription(description);
|
||||
// 同时更新关联订单的描述
|
||||
if (payment.getOrder() != null) {
|
||||
payment.getOrder().setDescription(description);
|
||||
// 由于在同一事务中,Order会自动保存
|
||||
}
|
||||
}
|
||||
|
||||
return paymentRepository.save(payment);
|
||||
}
|
||||
|
||||
public Payment updatePaymentStatus(Long paymentId, PaymentStatus newStatus) {
|
||||
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
|
||||
payment.setStatus(newStatus);
|
||||
@@ -48,7 +80,9 @@ public class PaymentService {
|
||||
}
|
||||
|
||||
public Payment confirmPaymentSuccess(Long paymentId, String externalTransactionId) {
|
||||
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
|
||||
// 使用findByIdWithUserAndOrder确保加载User和Order,避免懒加载问题
|
||||
Payment payment = paymentRepository.findByIdWithUserAndOrder(paymentId)
|
||||
.orElseThrow(() -> new RuntimeException("Not found"));
|
||||
|
||||
// 检查是否已经处理过(防止重复增加积分和重复创建订单)
|
||||
if (payment.getStatus() == PaymentStatus.SUCCESS) {
|
||||
@@ -62,19 +96,14 @@ public class PaymentService {
|
||||
Payment savedPayment = paymentRepository.save(payment);
|
||||
|
||||
// 支付成功后更新订单状态为已支付
|
||||
// 注意:积分添加逻辑已移至 OrderService.handlePointsForStatusChange
|
||||
// 当订单状态变为 PAID 时会自动添加积分,避免重复添加
|
||||
try {
|
||||
updateOrderStatusForPayment(savedPayment);
|
||||
} catch (Exception e) {
|
||||
logger.error("支付成功但更新订单状态失败: paymentId={}, error={}", paymentId, e.getMessage(), e);
|
||||
}
|
||||
|
||||
// 支付成功后增加用户积分
|
||||
try {
|
||||
addPointsForPayment(savedPayment);
|
||||
} catch (Exception e) {
|
||||
logger.error("支付成功但增加积分失败: paymentId={}, error={}", paymentId, e.getMessage(), e);
|
||||
}
|
||||
|
||||
return savedPayment;
|
||||
}
|
||||
|
||||
@@ -95,9 +124,8 @@ public class PaymentService {
|
||||
}
|
||||
|
||||
try {
|
||||
// 更新订单状态为已支付
|
||||
order.setStatus(OrderStatus.PAID);
|
||||
order.setPaidAt(LocalDateTime.now());
|
||||
// 直接调用orderService.updateOrderStatus,不要在这里修改order状态
|
||||
// orderService.updateOrderStatus会正确处理状态变更和积分添加
|
||||
orderService.updateOrderStatus(order.getId(), OrderStatus.PAID);
|
||||
|
||||
logger.info("✅ 订单状态更新为已支付: orderId={}, orderNumber={}, paymentId={}",
|
||||
@@ -207,19 +235,33 @@ public class PaymentService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Payment createPayment(String username, String orderId, String amountStr, String method) {
|
||||
public Payment createPayment(String username, String orderId, String amountStr, String method, String description) {
|
||||
// 检查是否已存在相同 orderId 的支付记录
|
||||
Optional<Payment> existing = paymentRepository.findByOrderId(orderId);
|
||||
if (existing.isPresent()) {
|
||||
Payment existingPayment = existing.get();
|
||||
// 如果已存在且状态是 PENDING,直接返回
|
||||
// 如果已存在且状态是 PENDING,检查是否是同一用户且金额相同
|
||||
if (existingPayment.getStatus() == PaymentStatus.PENDING) {
|
||||
logger.info("复用已存在的PENDING支付记录: orderId={}, paymentId={}", orderId, existingPayment.getId());
|
||||
return existingPayment;
|
||||
// 验证用户和金额是否匹配
|
||||
if (existingPayment.getUser().getUsername().equals(username) &&
|
||||
existingPayment.getAmount().compareTo(new BigDecimal(amountStr)) == 0) {
|
||||
logger.info("复用已存在的PENDING支付记录: orderId={}, paymentId={}", orderId, existingPayment.getId());
|
||||
return existingPayment;
|
||||
} else {
|
||||
// 用户或金额不匹配,生成新的 orderId
|
||||
logger.warn("已存在相同orderId的PENDING支付,但用户或金额不匹配,生成新orderId: {}", orderId);
|
||||
orderId = orderId + "_" + System.currentTimeMillis();
|
||||
}
|
||||
} else if (existingPayment.getStatus() == PaymentStatus.SUCCESS) {
|
||||
// 如果已存在成功状态的支付,绝对不能复用,必须生成新的 orderId
|
||||
logger.warn("⚠️ 已存在相同orderId的成功支付记录,生成新orderId避免冲突: orderId={}, existingPaymentId={}, status={}",
|
||||
orderId, existingPayment.getId(), existingPayment.getStatus());
|
||||
orderId = orderId + "_" + System.currentTimeMillis();
|
||||
} else {
|
||||
// 如果是其他状态(FAILED、CANCELLED等),生成新的 orderId
|
||||
orderId = orderId + "_" + System.currentTimeMillis();
|
||||
logger.info("已存在相同orderId但状态为{},生成新orderId: {}", existingPayment.getStatus(), orderId);
|
||||
}
|
||||
// 如果是其他状态,生成新的 orderId
|
||||
orderId = orderId + "_" + System.currentTimeMillis();
|
||||
logger.info("已存在相同orderId但状态为{},生成新orderId: {}", existingPayment.getStatus(), orderId);
|
||||
}
|
||||
|
||||
User user = null;
|
||||
@@ -237,11 +279,24 @@ public class PaymentService {
|
||||
order.setStatus(OrderStatus.PENDING); // 待支付状态
|
||||
order.setOrderType(OrderType.SUBSCRIPTION);
|
||||
|
||||
// 根据金额设置订单描述
|
||||
if (amount.compareTo(new BigDecimal("259.00")) >= 0) {
|
||||
order.setDescription("专业版会员订阅 - " + amount + "元");
|
||||
} else if (amount.compareTo(new BigDecimal("59.00")) >= 0) {
|
||||
order.setDescription("标准版会员订阅 - " + amount + "元");
|
||||
// 使用前端传递的描述,如果没有则根据orderId中的套餐类型设置
|
||||
if (description != null && !description.isEmpty()) {
|
||||
order.setDescription(description);
|
||||
} else if (orderId != null && orderId.contains("_")) {
|
||||
// 从orderId中提取套餐类型(如 SUB_standard_xxx -> standard)
|
||||
String[] parts = orderId.split("_");
|
||||
if (parts.length >= 2) {
|
||||
String planType = parts[1];
|
||||
if ("standard".equalsIgnoreCase(planType)) {
|
||||
order.setDescription("标准版会员订阅 - " + amount + "元");
|
||||
} else if ("premium".equalsIgnoreCase(planType)) {
|
||||
order.setDescription("专业版会员订阅 - " + amount + "元");
|
||||
} else {
|
||||
order.setDescription("会员订阅 - " + amount + "元");
|
||||
}
|
||||
} else {
|
||||
order.setDescription("会员订阅 - " + amount + "元");
|
||||
}
|
||||
} else {
|
||||
order.setDescription("会员订阅 - " + amount + "元");
|
||||
}
|
||||
|
||||
@@ -145,13 +145,20 @@ public class RealAIService {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> responseBody = objectMapper.readValue(responseBodyStr, Map.class);
|
||||
|
||||
// Sora2 API 使用 task_id 字段表示成功
|
||||
// 支持两种响应格式:{"task_id": "xxx"} 或 {"id": "xxx"}
|
||||
String taskId = null;
|
||||
if (responseBody.containsKey("task_id")) {
|
||||
logger.info("分镜视频任务提交成功,task_id: {}", responseBody.get("task_id"));
|
||||
taskId = String.valueOf(responseBody.get("task_id"));
|
||||
} else if (responseBody.containsKey("id")) {
|
||||
taskId = String.valueOf(responseBody.get("id"));
|
||||
}
|
||||
|
||||
if (taskId != null && !taskId.isEmpty() && !"null".equals(taskId)) {
|
||||
logger.info("分镜视频任务提交成功,task_id: {}", taskId);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("data", responseBody);
|
||||
result.put("task_id", responseBody.get("task_id"));
|
||||
result.put("task_id", taskId);
|
||||
return result;
|
||||
} else {
|
||||
// 处理错误响应
|
||||
@@ -291,24 +298,30 @@ public class RealAIService {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> responseBody = objectMapper.readValue(responseBodyStr, Map.class);
|
||||
|
||||
// Sora2 API 使用 task_id 字段表示成功(与文生视频相同格式)
|
||||
if (responseBody.containsKey("task_id")) {
|
||||
logger.info("图生视频任务提交成功,task_id: {}", responseBody.get("task_id"));
|
||||
// 转换为统一的响应格式(与文生视频保持一致)
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("data", responseBody);
|
||||
result.put("task_id", responseBody.get("task_id"));
|
||||
return result;
|
||||
|
||||
// 支持两种响应格式:{"task_id": "xxx"} 或 {"id": "xxx"}
|
||||
String taskId = null;
|
||||
if (responseBody.containsKey("task_id")) {
|
||||
taskId = String.valueOf(responseBody.get("task_id"));
|
||||
} else if (responseBody.containsKey("id")) {
|
||||
taskId = String.valueOf(responseBody.get("id"));
|
||||
}
|
||||
|
||||
if (taskId != null && !taskId.isEmpty() && !"null".equals(taskId)) {
|
||||
logger.info("图生视频任务提交成功,task_id: {}", taskId);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("data", responseBody);
|
||||
result.put("task_id", taskId);
|
||||
return result;
|
||||
} else {
|
||||
// 处理错误响应
|
||||
logger.error("图生视频任务提交失败,响应中缺少task_id: {}", responseBody);
|
||||
String errorMsg = "未知错误";
|
||||
if (responseBody.get("message") != null) {
|
||||
errorMsg = responseBody.get("message").toString();
|
||||
}
|
||||
throw new RuntimeException("任务提交失败: " + errorMsg);
|
||||
// 处理错误响应
|
||||
logger.error("图生视频任务提交失败,响应中缺少task_id或id: {}", responseBody);
|
||||
String errorMsg = "未知错误";
|
||||
if (responseBody.get("message") != null) {
|
||||
errorMsg = responseBody.get("message").toString();
|
||||
}
|
||||
throw new RuntimeException("任务提交失败: " + errorMsg);
|
||||
}
|
||||
} catch (com.fasterxml.jackson.core.JsonParseException e) {
|
||||
logger.error("解析API响应为JSON失败,响应内容可能是HTML或其他格式", e);
|
||||
@@ -448,17 +461,24 @@ public class RealAIService {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> responseBody = objectMapper.readValue(responseBodyStr, Map.class);
|
||||
|
||||
// 参考Comfly项目,响应格式为 {"task_id": "xxx"}
|
||||
// 支持两种响应格式:{"task_id": "xxx"} 或 {"id": "xxx"}
|
||||
String taskId = null;
|
||||
if (responseBody.containsKey("task_id")) {
|
||||
logger.info("文生视频任务提交成功,task_id: {}", responseBody.get("task_id"));
|
||||
taskId = String.valueOf(responseBody.get("task_id"));
|
||||
} else if (responseBody.containsKey("id")) {
|
||||
taskId = String.valueOf(responseBody.get("id"));
|
||||
}
|
||||
|
||||
if (taskId != null && !taskId.isEmpty() && !"null".equals(taskId)) {
|
||||
logger.info("文生视频任务提交成功,task_id: {}", taskId);
|
||||
// 转换为统一的响应格式
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("data", responseBody);
|
||||
result.put("task_id", responseBody.get("task_id"));
|
||||
result.put("task_id", taskId);
|
||||
return result;
|
||||
} else {
|
||||
logger.error("文生视频任务提交失败,响应中缺少task_id: {}", responseBody);
|
||||
logger.error("文生视频任务提交失败,响应中缺少task_id或id: {}", responseBody);
|
||||
throw new RuntimeException("任务提交失败: 响应格式不正确");
|
||||
}
|
||||
} catch (com.fasterxml.jackson.core.JsonParseException e) {
|
||||
@@ -1146,11 +1166,8 @@ public class RealAIService {
|
||||
}
|
||||
}
|
||||
|
||||
String apiUrl = settings.getPromptOptimizationApiUrl();
|
||||
if (apiUrl == null || apiUrl.isEmpty()) {
|
||||
apiUrl = getEffectiveApiBaseUrl(); // 使用默认API端点
|
||||
}
|
||||
// 构建请求URL
|
||||
// 提示词优化使用统一的 API base URL
|
||||
String apiUrl = getEffectiveApiBaseUrl();
|
||||
String url = apiUrl + "/v1/chat/completions";
|
||||
|
||||
String optimizationModel = settings.getPromptOptimizationModel();
|
||||
@@ -1290,10 +1307,8 @@ public class RealAIService {
|
||||
|
||||
String systemPrompt = getOptimizationPrompt(type);
|
||||
|
||||
String apiUrl = settings.getPromptOptimizationApiUrl();
|
||||
if (apiUrl == null || apiUrl.isEmpty()) {
|
||||
apiUrl = getEffectiveApiBaseUrl();
|
||||
}
|
||||
// 提示词优化使用统一的 API base URL
|
||||
String apiUrl = getEffectiveApiBaseUrl();
|
||||
String url = apiUrl + "/v1/chat/completions";
|
||||
|
||||
// 使用支持视觉的模型
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.example.demo.service;
|
||||
|
||||
import com.example.demo.config.DynamicApiConfig;
|
||||
import com.example.demo.model.SystemSettings;
|
||||
import com.example.demo.repository.SystemSettingsRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -15,6 +17,9 @@ public class SystemSettingsService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(SystemSettingsService.class);
|
||||
|
||||
private final SystemSettingsRepository repository;
|
||||
|
||||
@Autowired
|
||||
private DynamicApiConfig dynamicApiConfig;
|
||||
|
||||
public SystemSettingsService(SystemSettingsRepository repository) {
|
||||
this.repository = repository;
|
||||
@@ -22,14 +27,13 @@ public class SystemSettingsService {
|
||||
|
||||
/**
|
||||
* 获取唯一的系统设置;若不存在则初始化默认值。
|
||||
* 注意:套餐价格已移至 membership_levels 表管理
|
||||
*/
|
||||
@Transactional
|
||||
public SystemSettings getOrCreate() {
|
||||
List<SystemSettings> all = repository.findAll();
|
||||
if (all.isEmpty()) {
|
||||
SystemSettings defaults = new SystemSettings();
|
||||
defaults.setStandardPriceCny(9); // 默认标准版 9 元
|
||||
defaults.setProPriceCny(29); // 默认专业版 29 元
|
||||
defaults.setPointsPerGeneration(1); // 默认每次消耗 1 点
|
||||
defaults.setSiteName("AIGC Demo");
|
||||
defaults.setSiteSubtitle("现代化的Spring Boot应用演示");
|
||||
@@ -39,11 +43,10 @@ public class SystemSettingsService {
|
||||
defaults.setEnablePaypal(true);
|
||||
defaults.setContactEmail("support@example.com");
|
||||
defaults.setPromptOptimizationModel("gpt-5.1-thinking");
|
||||
defaults.setPromptOptimizationApiUrl("https://ai.comfly.chat");
|
||||
defaults.setStoryboardSystemPrompt("");
|
||||
SystemSettings saved = repository.save(defaults);
|
||||
logger.info("Initialized default SystemSettings: std={}, pro={}, points={}",
|
||||
saved.getStandardPriceCny(), saved.getProPriceCny(), saved.getPointsPerGeneration());
|
||||
logger.info("Initialized default SystemSettings: points={}",
|
||||
saved.getPointsPerGeneration());
|
||||
return saved;
|
||||
}
|
||||
return all.get(0);
|
||||
@@ -51,23 +54,26 @@ public class SystemSettingsService {
|
||||
|
||||
@Transactional
|
||||
public SystemSettings update(SystemSettings updated) {
|
||||
logger.info("要更新的值: standardPriceCny={}, proPriceCny={}",
|
||||
updated.getStandardPriceCny(), updated.getProPriceCny());
|
||||
|
||||
// 使用原生更新方法强制更新数据库
|
||||
Long id = updated.getId();
|
||||
if (id == null) {
|
||||
id = 1L; // 默认ID
|
||||
// 如果 API Key 为空,保留原来的值
|
||||
if (updated.getAiApiKey() == null || updated.getAiApiKey().trim().isEmpty()) {
|
||||
SystemSettings existing = getOrCreate();
|
||||
updated.setAiApiKey(existing.getAiApiKey());
|
||||
}
|
||||
|
||||
int rows1 = repository.updateStandardPrice(id, updated.getStandardPriceCny());
|
||||
int rows2 = repository.updateProPrice(id, updated.getProPriceCny());
|
||||
// 保存更新
|
||||
SystemSettings saved = repository.save(updated);
|
||||
logger.info("系统设置保存成功: id={}", saved.getId());
|
||||
|
||||
logger.info("更新标准价格影响行数: {}, 更新专业价格影响行数: {}", rows1, rows2);
|
||||
// 刷新运行时配置
|
||||
if (saved.getAiApiKey() != null && !saved.getAiApiKey().isEmpty()) {
|
||||
dynamicApiConfig.updateApiKey(saved.getAiApiKey());
|
||||
dynamicApiConfig.updateImageApiKey(saved.getAiApiKey());
|
||||
}
|
||||
if (saved.getAiApiBaseUrl() != null && !saved.getAiApiBaseUrl().isEmpty()) {
|
||||
dynamicApiConfig.updateApiBaseUrl(saved.getAiApiBaseUrl());
|
||||
dynamicApiConfig.updateImageApiBaseUrl(saved.getAiApiBaseUrl());
|
||||
}
|
||||
|
||||
// 重新查询返回最新数据
|
||||
SystemSettings saved = getOrCreate();
|
||||
logger.info("系统设置保存成功: id={}, standardPriceCny={}", saved.getId(), saved.getStandardPriceCny());
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ public class TaskCleanupService {
|
||||
@Autowired
|
||||
private ImageToVideoTaskRepository imageToVideoTaskRepository;
|
||||
|
||||
@Autowired
|
||||
private StoryboardVideoTaskRepository storyboardVideoTaskRepository;
|
||||
|
||||
@Autowired
|
||||
private CompletedTaskArchiveRepository completedTaskArchiveRepository;
|
||||
|
||||
@@ -63,10 +66,13 @@ public class TaskCleanupService {
|
||||
// 2. 清理图生视频任务
|
||||
Map<String, Object> imageCleanupResult = cleanupImageToVideoTasks();
|
||||
|
||||
// 3. 清理任务队列
|
||||
// 3. 清理分镜视频任务
|
||||
Map<String, Object> storyboardCleanupResult = cleanupStoryboardVideoTasks();
|
||||
|
||||
// 4. 清理任务队列
|
||||
Map<String, Object> queueCleanupResult = cleanupTaskQueue();
|
||||
|
||||
// 4. 清理过期的归档记录
|
||||
// 5. 清理过期的归档记录
|
||||
Map<String, Object> archiveCleanupResult = cleanupExpiredArchives();
|
||||
|
||||
// 汇总结果
|
||||
@@ -74,6 +80,7 @@ public class TaskCleanupService {
|
||||
result.put("message", "任务清理完成");
|
||||
result.put("textToVideo", textCleanupResult);
|
||||
result.put("imageToVideo", imageCleanupResult);
|
||||
result.put("storyboardVideo", storyboardCleanupResult);
|
||||
result.put("taskQueue", queueCleanupResult);
|
||||
result.put("archiveCleanup", archiveCleanupResult);
|
||||
|
||||
@@ -126,10 +133,8 @@ public class TaskCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
// 删除原始任务记录
|
||||
if (!completedTasks.isEmpty()) {
|
||||
textToVideoTaskRepository.deleteAll(completedTasks);
|
||||
}
|
||||
// 只删除失败任务记录,保留成功任务的创作历史
|
||||
// 成功任务保留在原表中,用户可以查看历史记录
|
||||
if (!failedTasks.isEmpty()) {
|
||||
textToVideoTaskRepository.deleteAll(failedTasks);
|
||||
}
|
||||
@@ -138,7 +143,7 @@ public class TaskCleanupService {
|
||||
result.put("cleaned", cleanedCount);
|
||||
result.put("total", archivedCount + cleanedCount);
|
||||
|
||||
logger.info("文生视频任务清理完成: 归档{}个, 清理{}个", archivedCount, cleanedCount);
|
||||
logger.info("文生视频任务处理完成: 归档{}个成功任务(保留原记录), 清理{}个失败任务", archivedCount, cleanedCount);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("清理文生视频任务失败", e);
|
||||
@@ -186,10 +191,7 @@ public class TaskCleanupService {
|
||||
}
|
||||
}
|
||||
|
||||
// 删除原始任务记录
|
||||
if (!completedTasks.isEmpty()) {
|
||||
imageToVideoTaskRepository.deleteAll(completedTasks);
|
||||
}
|
||||
// 只删除失败任务记录,保留成功任务的创作历史
|
||||
if (!failedTasks.isEmpty()) {
|
||||
imageToVideoTaskRepository.deleteAll(failedTasks);
|
||||
}
|
||||
@@ -198,7 +200,7 @@ public class TaskCleanupService {
|
||||
result.put("cleaned", cleanedCount);
|
||||
result.put("total", archivedCount + cleanedCount);
|
||||
|
||||
logger.info("图生视频任务清理完成: 归档{}个, 清理{}个", archivedCount, cleanedCount);
|
||||
logger.info("图生视频任务处理完成: 归档{}个成功任务(保留原记录), 清理{}个失败任务", archivedCount, cleanedCount);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("清理图生视频任务失败", e);
|
||||
@@ -208,6 +210,47 @@ public class TaskCleanupService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理分镜视频任务
|
||||
* 只删除失败任务,保留成功任务的创作历史
|
||||
*/
|
||||
private Map<String, Object> cleanupStoryboardVideoTasks() {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 查找失败的任务
|
||||
List<StoryboardVideoTask> failedTasks = storyboardVideoTaskRepository.findByStatus(StoryboardVideoTask.TaskStatus.FAILED);
|
||||
|
||||
int cleanedCount = 0;
|
||||
|
||||
// 记录失败任务到清理日志
|
||||
for (StoryboardVideoTask task : failedTasks) {
|
||||
try {
|
||||
FailedTaskCleanupLog log = FailedTaskCleanupLog.fromStoryboardVideoTask(task);
|
||||
failedTaskCleanupLogRepository.save(log);
|
||||
cleanedCount++;
|
||||
} catch (Exception e) {
|
||||
logger.error("记录失败分镜视频任务日志失败: {}", task.getTaskId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 只删除失败任务记录,保留成功任务的创作历史
|
||||
if (!failedTasks.isEmpty()) {
|
||||
storyboardVideoTaskRepository.deleteAll(failedTasks);
|
||||
}
|
||||
|
||||
result.put("cleaned", cleanedCount);
|
||||
|
||||
logger.info("分镜视频任务处理完成: 清理{}个失败任务(成功任务保留)", cleanedCount);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("清理分镜视频任务失败", e);
|
||||
result.put("error", e.getMessage());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理任务队列
|
||||
*/
|
||||
|
||||
@@ -1492,7 +1492,15 @@ public class TaskQueueService {
|
||||
}
|
||||
|
||||
if (taskQueue.getRealTaskId() == null) {
|
||||
logger.warn("任务 {} 的 realTaskId 为空,标记为失败并返还积分", taskId);
|
||||
// 增加时间窗口保护:只有任务创建超过5分钟后 realTaskId 仍为空才标记为失败
|
||||
// 避免任务刚创建还在等待外部API响应时就被错误地标记为失败
|
||||
java.time.LocalDateTime fiveMinutesAgo = java.time.LocalDateTime.now().minusMinutes(5);
|
||||
if (taskQueue.getCreatedAt() != null && taskQueue.getCreatedAt().isAfter(fiveMinutesAgo)) {
|
||||
logger.debug("任务 {} 的 realTaskId 为空,但创建时间未超过5分钟,跳过本次检查", taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn("任务 {} 的 realTaskId 为空且已超过5分钟,标记为失败并返还积分", taskId);
|
||||
|
||||
// 标记任务为失败
|
||||
String errorMessage = "任务提交失败:未能成功提交到外部API,请检查网络或稍后重试";
|
||||
@@ -1544,6 +1552,15 @@ public class TaskQueueService {
|
||||
if (taskData != null) {
|
||||
logger.info("任务状态响应: taskId={}, taskData={}", taskQueue.getTaskId(), taskData);
|
||||
|
||||
// 处理嵌套格式:{code=success, data={status=processing, url=...}}
|
||||
if (taskData.containsKey("code") && taskData.containsKey("data")) {
|
||||
Object innerData = taskData.get("data");
|
||||
if (innerData instanceof Map) {
|
||||
taskData = (Map<?, ?>) innerData;
|
||||
logger.info("检测到嵌套格式,提取内层data: {}", taskData);
|
||||
}
|
||||
}
|
||||
|
||||
String status = (String) taskData.get("status");
|
||||
// 支持大小写不敏感的状态检查
|
||||
if (status != null) {
|
||||
@@ -1578,6 +1595,17 @@ public class TaskQueueService {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式3: 直接在根级别的 url 字段(新API格式)
|
||||
if (resultUrl == null) {
|
||||
Object urlField = taskData.get("url");
|
||||
if (urlField != null) {
|
||||
String urlStr = urlField.toString();
|
||||
if (!urlStr.trim().isEmpty() && !urlStr.equals("null")) {
|
||||
resultUrl = urlStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("解析到的结果URL: taskId={}, resultUrl={}", taskQueue.getTaskId(),
|
||||
resultUrl != null ? (resultUrl.length() > 100 ? resultUrl.substring(0, 100) + "..." : resultUrl) : "null");
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
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 org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.example.demo.model.TaskStatus;
|
||||
import com.example.demo.repository.TaskStatusRepository;
|
||||
import com.example.demo.config.DynamicApiConfig;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
@@ -60,11 +60,8 @@ public class TaskStatusPollingService {
|
||||
@Autowired
|
||||
private UserErrorLogService userErrorLogService;
|
||||
|
||||
@Value("${ai.api.key:ak_5f13ec469e6047d5b8155c3cc91350e2}")
|
||||
private String apiKey;
|
||||
|
||||
@Value("${ai.api.base-url:http://116.62.4.26:8081}")
|
||||
private String apiBaseUrl;
|
||||
@Autowired
|
||||
private DynamicApiConfig dynamicApiConfig;
|
||||
|
||||
/**
|
||||
* 系统启动时恢复处理中的任务
|
||||
@@ -127,9 +124,9 @@ public class TaskStatusPollingService {
|
||||
|
||||
// 有外部任务ID,查询外部API状态
|
||||
try {
|
||||
String url = apiBaseUrl + "/v2/videos/generations/" + task.getExternalTaskId();
|
||||
String url = dynamicApiConfig.getApiBaseUrl() + "/v2/videos/generations/" + task.getExternalTaskId();
|
||||
HttpResponse<String> response = Unirest.get(url)
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Authorization", "Bearer " + dynamicApiConfig.getApiKey())
|
||||
.asString();
|
||||
|
||||
if (response.getStatus() == 200) {
|
||||
@@ -301,9 +298,9 @@ public class TaskStatusPollingService {
|
||||
|
||||
try {
|
||||
// 使用正确的 API 端点:GET /v2/videos/generations/{task_id}
|
||||
String url = apiBaseUrl + "/v2/videos/generations/" + task.getExternalTaskId();
|
||||
String url = dynamicApiConfig.getApiBaseUrl() + "/v2/videos/generations/" + task.getExternalTaskId();
|
||||
HttpResponse<String> response = Unirest.get(url)
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Authorization", "Bearer " + dynamicApiConfig.getApiKey())
|
||||
.asString();
|
||||
|
||||
if (response.getStatus() == 200) {
|
||||
@@ -565,159 +562,163 @@ public class TaskStatusPollingService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只有状态变化时才更新(触发器条件)
|
||||
if (taskStatus.getStatus() == status) {
|
||||
logger.debug("任务状态未变化,跳过更新: taskId={}, status={}", taskId, status);
|
||||
return true;
|
||||
}
|
||||
|
||||
taskStatus.setStatus(status);
|
||||
taskStatus.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
if (resultUrl != null) {
|
||||
taskStatus.setResultUrl(resultUrl);
|
||||
}
|
||||
|
||||
if (errorMessage != null) {
|
||||
taskStatus.setErrorMessage(errorMessage);
|
||||
}
|
||||
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
taskStatus.setProgress(100);
|
||||
taskStatus.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
taskStatusRepository.save(taskStatus);
|
||||
logger.info("任务状态已更新: taskId={}, status={}", taskId, status);
|
||||
|
||||
// 手动同步业务表状态(避免依赖数据库触发器)
|
||||
if (taskId != null) {
|
||||
if (taskId.startsWith("sb_") || taskId.startsWith("storyboard_")) {
|
||||
// 同步分镜视频任务
|
||||
try {
|
||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
com.example.demo.model.StoryboardVideoTask.TaskStatus newTaskStatus =
|
||||
convertToStoryboardTaskStatus(status);
|
||||
if (task.getStatus() != newTaskStatus) {
|
||||
task.setStatus(newTaskStatus);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (errorMessage != null) {
|
||||
task.setErrorMessage(errorMessage);
|
||||
}
|
||||
if (resultUrl != null) {
|
||||
task.setResultUrl(resultUrl);
|
||||
}
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
task.setProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
} else if (status == TaskStatus.Status.FAILED) {
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
logger.info("已同步 StoryboardVideoTask 状态: taskId={}, status={}, errorMessage={}", taskId, newTaskStatus, errorMessage);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("同步 StoryboardVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
} else if (taskId.startsWith("img2vid_")) {
|
||||
// 同步图生视频任务
|
||||
try {
|
||||
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
com.example.demo.model.ImageToVideoTask.TaskStatus newTaskStatus =
|
||||
convertToImageToVideoTaskStatus(status);
|
||||
if (task.getStatus() != newTaskStatus) {
|
||||
task.updateStatus(newTaskStatus);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (errorMessage != null) {
|
||||
task.setErrorMessage(errorMessage);
|
||||
}
|
||||
if (resultUrl != null) {
|
||||
task.setResultUrl(resultUrl);
|
||||
}
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
task.setProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
} else if (status == TaskStatus.Status.FAILED) {
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
imageToVideoTaskRepository.save(task);
|
||||
logger.info("已同步 ImageToVideoTask 状态: taskId={}, status={}, errorMessage={}", taskId, newTaskStatus, errorMessage);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("同步 ImageToVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
} else if (taskId.startsWith("txt2vid_")) {
|
||||
// 同步文生视频任务
|
||||
try {
|
||||
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
com.example.demo.model.TextToVideoTask.TaskStatus newTaskStatus =
|
||||
convertToTextToVideoTaskStatus(status);
|
||||
if (task.getStatus() != newTaskStatus) {
|
||||
task.updateStatus(newTaskStatus);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (errorMessage != null) {
|
||||
task.setErrorMessage(errorMessage);
|
||||
}
|
||||
if (resultUrl != null) {
|
||||
task.setResultUrl(resultUrl);
|
||||
}
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
task.setProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
} else if (status == TaskStatus.Status.FAILED) {
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
textToVideoTaskRepository.save(task);
|
||||
logger.info("已同步 TextToVideoTask 状态: taskId={}, status={}, errorMessage={}", taskId, newTaskStatus, errorMessage);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("同步 TextToVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
// 只有状态变化时才更新task_status表
|
||||
boolean statusChanged = taskStatus.getStatus() != status;
|
||||
if (statusChanged) {
|
||||
taskStatus.setStatus(status);
|
||||
taskStatus.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
if (resultUrl != null) {
|
||||
taskStatus.setResultUrl(resultUrl);
|
||||
}
|
||||
|
||||
// 如果是失败状态,记录错误日志
|
||||
if (status == TaskStatus.Status.FAILED && errorMessage != null) {
|
||||
try {
|
||||
String username = null;
|
||||
String taskType = "UNKNOWN";
|
||||
|
||||
if (taskId.startsWith("img2vid_")) {
|
||||
taskType = "IMAGE_TO_VIDEO";
|
||||
username = imageToVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
|
||||
taskType = "STORYBOARD_VIDEO";
|
||||
username = storyboardVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
} else if (taskId.startsWith("txt2vid_")) {
|
||||
taskType = "TEXT_TO_VIDEO";
|
||||
username = textToVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
if (errorMessage != null) {
|
||||
taskStatus.setErrorMessage(errorMessage);
|
||||
}
|
||||
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
taskStatus.setProgress(100);
|
||||
taskStatus.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
taskStatusRepository.save(taskStatus);
|
||||
logger.info("任务状态已更新: taskId={}, status={}", taskId, status);
|
||||
}
|
||||
|
||||
// 无论状态是否变化,都要确保业务表同步(防止业务表状态不一致)
|
||||
syncBusinessTableStatus(taskId, status, resultUrl, errorMessage);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步业务表状态
|
||||
*/
|
||||
private void syncBusinessTableStatus(String taskId, TaskStatus.Status status, String resultUrl, String errorMessage) {
|
||||
if (taskId == null) return;
|
||||
|
||||
if (taskId.startsWith("sb_") || taskId.startsWith("storyboard_")) {
|
||||
// 同步分镜视频任务
|
||||
try {
|
||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
com.example.demo.model.StoryboardVideoTask.TaskStatus newTaskStatus =
|
||||
convertToStoryboardTaskStatus(status);
|
||||
if (task.getStatus() != newTaskStatus) {
|
||||
task.setStatus(newTaskStatus);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (errorMessage != null) {
|
||||
task.setErrorMessage(errorMessage);
|
||||
}
|
||||
if (resultUrl != null) {
|
||||
task.setResultUrl(resultUrl);
|
||||
}
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
task.setProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
} else if (status == TaskStatus.Status.FAILED) {
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
storyboardVideoTaskRepository.save(task);
|
||||
logger.info("已同步 StoryboardVideoTask 状态: taskId={}, status={}", taskId, newTaskStatus);
|
||||
}
|
||||
|
||||
if (username != null) {
|
||||
userErrorLogService.logErrorAsync(
|
||||
username,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
|
||||
errorMessage,
|
||||
"TaskStatusPollingService.updateTaskStatusWithCascade",
|
||||
taskId,
|
||||
taskType
|
||||
);
|
||||
logger.info("已记录错误日志: taskId={}, username={}, errorMessage={}", taskId, username, errorMessage);
|
||||
} else {
|
||||
logger.warn("无法记录错误日志,未找到用户名: taskId={}", taskId);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("同步 StoryboardVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
} else if (taskId.startsWith("img2vid_")) {
|
||||
// 同步图生视频任务
|
||||
try {
|
||||
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
com.example.demo.model.ImageToVideoTask.TaskStatus newTaskStatus =
|
||||
convertToImageToVideoTaskStatus(status);
|
||||
if (task.getStatus() != newTaskStatus) {
|
||||
task.updateStatus(newTaskStatus);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (errorMessage != null) {
|
||||
task.setErrorMessage(errorMessage);
|
||||
}
|
||||
if (resultUrl != null) {
|
||||
task.setResultUrl(resultUrl);
|
||||
}
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
task.setProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
} else if (status == TaskStatus.Status.FAILED) {
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
imageToVideoTaskRepository.save(task);
|
||||
logger.info("已同步 ImageToVideoTask 状态: taskId={}, status={}", taskId, newTaskStatus);
|
||||
}
|
||||
} catch (Exception logException) {
|
||||
logger.warn("记录错误日志失败: taskId={}, error={}", taskId, logException.getMessage());
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("同步 ImageToVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
} else if (taskId.startsWith("txt2vid_")) {
|
||||
// 同步文生视频任务
|
||||
try {
|
||||
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
|
||||
com.example.demo.model.TextToVideoTask.TaskStatus newTaskStatus =
|
||||
convertToTextToVideoTaskStatus(status);
|
||||
if (task.getStatus() != newTaskStatus) {
|
||||
task.updateStatus(newTaskStatus);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (errorMessage != null) {
|
||||
task.setErrorMessage(errorMessage);
|
||||
}
|
||||
if (resultUrl != null) {
|
||||
task.setResultUrl(resultUrl);
|
||||
}
|
||||
if (status == TaskStatus.Status.COMPLETED) {
|
||||
task.setProgress(100);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
} else if (status == TaskStatus.Status.FAILED) {
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
}
|
||||
textToVideoTaskRepository.save(task);
|
||||
logger.info("已同步 TextToVideoTask 状态: taskId={}, status={}", taskId, newTaskStatus);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("同步 TextToVideoTask 状态失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
// 如果是失败状态,记录错误日志
|
||||
if (status == TaskStatus.Status.FAILED && errorMessage != null) {
|
||||
try {
|
||||
String username = null;
|
||||
String taskType = "UNKNOWN";
|
||||
|
||||
if (taskId.startsWith("img2vid_")) {
|
||||
taskType = "IMAGE_TO_VIDEO";
|
||||
username = imageToVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
|
||||
taskType = "STORYBOARD_VIDEO";
|
||||
username = storyboardVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
} else if (taskId.startsWith("txt2vid_")) {
|
||||
taskType = "TEXT_TO_VIDEO";
|
||||
username = textToVideoTaskRepository.findByTaskId(taskId)
|
||||
.map(t -> t.getUsername()).orElse(null);
|
||||
}
|
||||
|
||||
if (username != null) {
|
||||
userErrorLogService.logErrorAsync(
|
||||
username,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.TASK_PROCESSING_ERROR,
|
||||
errorMessage,
|
||||
"TaskStatusPollingService.updateTaskStatusWithCascade",
|
||||
taskId,
|
||||
taskType
|
||||
);
|
||||
} else {
|
||||
logger.warn("无法记录错误日志,未找到用户名: taskId={}", taskId);
|
||||
}
|
||||
} catch (Exception logException) {
|
||||
logger.warn("记录错误日志失败: taskId={}, error={}", taskId, logException.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -534,8 +534,9 @@ public class UserService {
|
||||
|
||||
for (com.example.demo.model.Order order : paidOrders) {
|
||||
// 检查是否已经在支付记录中处理过(避免重复)
|
||||
// 通过Payment关联的Order ID来匹配,而不是通过orderId字符串
|
||||
boolean alreadyProcessed = successfulPayments.stream()
|
||||
.anyMatch(p -> p.getOrderId() != null && p.getOrderId().equals(order.getOrderNumber()));
|
||||
.anyMatch(p -> p.getOrder() != null && p.getOrder().getId().equals(order.getId()));
|
||||
|
||||
if (!alreadyProcessed) {
|
||||
// 从订单描述或订单项中提取积分数量
|
||||
@@ -613,30 +614,55 @@ public class UserService {
|
||||
if (matcher.find()) {
|
||||
return Integer.valueOf(matcher.group(1));
|
||||
}
|
||||
// 如果是会员订阅,根据订单金额计算积分
|
||||
// 如果是会员订阅,从数据库读取积分配置
|
||||
if (item.getProductName().contains("标准版") || item.getProductName().contains("专业版")) {
|
||||
// 标准版:200积分/月,专业版:1000积分/月
|
||||
if (item.getProductName().contains("标准版")) {
|
||||
return 200;
|
||||
} else if (item.getProductName().contains("专业版")) {
|
||||
return 1000;
|
||||
// 从membership_levels表读取积分配置(禁止硬编码)
|
||||
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
|
||||
.orElse(null);
|
||||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||||
.orElse(null);
|
||||
|
||||
if (item.getProductName().contains("标准版") && standardLevel != null) {
|
||||
return standardLevel.getPointsBonus();
|
||||
} else if (item.getProductName().contains("专业版") && proLevel != null) {
|
||||
return proLevel.getPointsBonus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 方法3:根据订单类型和金额估算
|
||||
// 方法3:根据订单类型和金额计算积分(从数据库读取配置)
|
||||
if (order.getOrderType() != null) {
|
||||
if (order.getOrderType() == com.example.demo.model.OrderType.SUBSCRIPTION) {
|
||||
// 订阅订单:根据金额估算积分
|
||||
// 标准版:$59 = 200积分,专业版:$259 = 1000积分
|
||||
// 订阅订单:根据金额计算积分(从数据库读取,禁止硬编码)
|
||||
if (order.getTotalAmount() != null) {
|
||||
double amount = order.getTotalAmount().doubleValue();
|
||||
if (amount >= 250) {
|
||||
return 1000; // 专业版
|
||||
} else if (amount >= 50) {
|
||||
return 200; // 标准版
|
||||
// 使用OrderService的逻辑从数据库读取积分配置
|
||||
try {
|
||||
MembershipLevel standardLevel = membershipLevelRepository.findByName("standard")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少standard会员等级配置"));
|
||||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少professional会员等级配置"));
|
||||
|
||||
int standardPrice = standardLevel.getPrice().intValue();
|
||||
int standardPoints = standardLevel.getPointsBonus();
|
||||
int proPrice = proLevel.getPrice().intValue();
|
||||
int proPoints = proLevel.getPointsBonus();
|
||||
|
||||
int amountInt = order.getTotalAmount().intValue();
|
||||
|
||||
// 判断套餐类型(允许10%的价格浮动范围)
|
||||
if (amountInt >= proPrice * 0.9 && amountInt <= proPrice * 1.1) {
|
||||
return proPoints; // 专业版积分
|
||||
} else if (amountInt >= standardPrice * 0.9 && amountInt <= standardPrice * 1.1) {
|
||||
return standardPoints; // 标准版积分
|
||||
} else if (amountInt >= proPrice) {
|
||||
return proPoints;
|
||||
} else if (amountInt >= standardPrice) {
|
||||
return standardPoints;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("从数据库读取会员等级配置失败: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#Updated by API Key Management
|
||||
#Thu Dec 18 13:00:49 CST 2025
|
||||
#Mon Dec 22 13:46:28 CST 2025
|
||||
ai.api.base-url=https\://ai.comfly.chat
|
||||
ai.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
|
||||
ai.api.key=sk-I9Z4qYL0Me7MtcQzDcBa9e7bDa23442a88768a4e0c17C0E4
|
||||
ai.image.api.base-url=https\://ai.comfly.chat
|
||||
ai.image.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
|
||||
ai.image.api.key=sk-I9Z4qYL0Me7MtcQzDcBa9e7bDa23442a88768a4e0c17C0E4
|
||||
alipay.app-id=9021000157616562
|
||||
alipay.charset=UTF-8
|
||||
alipay.domain=https\://vionow.com
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 此迁移文件已废弃,佣金相关字段的删除已移至 V13__Remove_Commission_Fields.sql
|
||||
-- 保留此文件以避免迁移版本冲突
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 删除 users 表中的佣金相关字段
|
||||
-- 注意:MySQL 5.7及以下版本不支持 DROP COLUMN IF EXISTS
|
||||
-- 如果字段不存在,执行会报错,但可以忽略
|
||||
|
||||
-- 删除 commission 字段(如果存在)
|
||||
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'users'
|
||||
AND COLUMN_NAME = 'commission');
|
||||
SET @sqlstmt := IF(@exist > 0, 'ALTER TABLE users DROP COLUMN commission', 'SELECT "commission字段不存在,跳过删除"');
|
||||
PREPARE stmt FROM @sqlstmt;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 删除 frozen_commission 字段(如果存在)
|
||||
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'users'
|
||||
AND COLUMN_NAME = 'frozen_commission');
|
||||
SET @sqlstmt := IF(@exist > 0, 'ALTER TABLE users DROP COLUMN frozen_commission', 'SELECT "frozen_commission字段不存在,跳过删除"');
|
||||
PREPARE stmt FROM @sqlstmt;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
|
||||
|
||||
@@ -102,53 +102,38 @@ CREATE TABLE IF NOT EXISTS user_memberships (
|
||||
UNIQUE KEY unique_active_membership (user_id, status)
|
||||
);
|
||||
|
||||
-- 视频生成任务表
|
||||
CREATE TABLE IF NOT EXISTS video_tasks (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
task_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
user_id BIGINT NOT NULL,
|
||||
task_type VARCHAR(50) NOT NULL, -- TEXT_TO_VIDEO, IMAGE_TO_VIDEO, STORYBOARD_VIDEO
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
input_text TEXT,
|
||||
input_image_url VARCHAR(500),
|
||||
output_video_url VARCHAR(500),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, PROCESSING, COMPLETED, FAILED
|
||||
progress INT NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 用户作品表
|
||||
CREATE TABLE IF NOT EXISTS user_works (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
work_type VARCHAR(50) NOT NULL, -- VIDEO, IMAGE, STORYBOARD
|
||||
cover_image VARCHAR(500),
|
||||
video_url VARCHAR(500),
|
||||
tags VARCHAR(500),
|
||||
is_public BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
view_count INT NOT NULL DEFAULT 0,
|
||||
like_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
username VARCHAR(100) NOT NULL COMMENT '用户名',
|
||||
task_id VARCHAR(50) NOT NULL UNIQUE COMMENT '任务ID',
|
||||
work_type ENUM('TEXT_TO_VIDEO', 'IMAGE_TO_VIDEO', 'STORYBOARD_VIDEO') NOT NULL COMMENT '作品类型',
|
||||
title VARCHAR(200) COMMENT '作品标题',
|
||||
description TEXT COMMENT '作品描述',
|
||||
prompt TEXT COMMENT '生成提示词',
|
||||
result_url VARCHAR(500) COMMENT '结果视频URL',
|
||||
thumbnail_url VARCHAR(500) COMMENT '缩略图URL',
|
||||
duration VARCHAR(10) COMMENT '视频时长',
|
||||
aspect_ratio VARCHAR(10) COMMENT '宽高比',
|
||||
quality VARCHAR(20) COMMENT '画质',
|
||||
file_size VARCHAR(20) COMMENT '文件大小',
|
||||
points_cost INT NOT NULL DEFAULT 0 COMMENT '消耗积分',
|
||||
status ENUM('PROCESSING', 'COMPLETED', 'FAILED', 'DELETED') NOT NULL DEFAULT 'PROCESSING' COMMENT '作品状态',
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否公开',
|
||||
view_count INT NOT NULL DEFAULT 0 COMMENT '浏览次数',
|
||||
like_count INT NOT NULL DEFAULT 0 COMMENT '点赞次数',
|
||||
download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数',
|
||||
tags VARCHAR(500) COMMENT '标签',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
completed_at TIMESTAMP NULL COMMENT '完成时间',
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_username_status (username, status),
|
||||
INDEX idx_task_id (task_id),
|
||||
INDEX idx_work_type (work_type),
|
||||
INDEX idx_is_public_status (is_public, status),
|
||||
INDEX idx_created_at (created_at)
|
||||
);
|
||||
|
||||
-- 系统配置表
|
||||
CREATE TABLE IF NOT EXISTS system_configs (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
config_key VARCHAR(100) NOT NULL UNIQUE,
|
||||
config_value TEXT,
|
||||
description VARCHAR(500),
|
||||
config_type VARCHAR(50) NOT NULL DEFAULT 'STRING', -- STRING, NUMBER, BOOLEAN, JSON
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form th:action="@{/settings}" th:object="${settings}" method="post">
|
||||
<input type="hidden" th:field="*{id}">
|
||||
<h5 class="mb-3">基础信息</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">站点名称</label>
|
||||
@@ -69,6 +70,26 @@
|
||||
<label class="form-label">每次生成消耗资源点</label>
|
||||
<input type="number" class="form-control" th:field="*{pointsPerGeneration}" min="0" required>
|
||||
</div>
|
||||
|
||||
<h5 class="mb-3">API 配置</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">AI API Base URL</label>
|
||||
<input type="text" class="form-control" th:field="*{aiApiBaseUrl}" placeholder="https://ai.comfly.chat">
|
||||
<div class="form-text">视频生成、图片生成、提示词优化统一使用此地址</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">AI API Key</label>
|
||||
<input type="password" class="form-control" th:field="*{aiApiKey}" placeholder="留空则不修改">
|
||||
<div class="form-text">API 密钥,留空表示不修改</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">提示词优化模型</label>
|
||||
<input type="text" class="form-control" th:field="*{promptOptimizationModel}" placeholder="gpt-5.1-thinking">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">分镜图系统引导词</label>
|
||||
<textarea class="form-control" th:field="*{storyboardSystemPrompt}" rows="3" placeholder="可选的系统引导词"></textarea>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-1"></i>保存
|
||||
|
||||
Reference in New Issue
Block a user