项目重构: 整理目录结构, 更新前后端代码, 添加测试和数据库迁移

This commit is contained in:
AIGC Developer
2025-12-30 10:24:19 +08:00
parent 5344148a1c
commit 38630dbb66
117 changed files with 1987 additions and 1316 deletions

View File

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

View File

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

View File

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

View File

@@ -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);
}
}
/**
* 获取用户的任务列表
*/

View File

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

View File

@@ -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 创建支付

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 表管理
}

View File

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

View File

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

View File

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

View File

@@ -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());
}
}
/**
* 获取用户任务列表
*/

View File

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

View File

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

View File

@@ -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";
// 使用支持视觉的模型

View File

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

View File

@@ -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;
}
/**
* 清理任务队列
*/

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
-- 此迁移文件已废弃,佣金相关字段的删除已移至 V13__Remove_Commission_Fields.sql
-- 保留此文件以避免迁移版本冲突

View File

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

View File

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

View File

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

View File

@@ -1,370 +0,0 @@
-- ============================================
-- AIGC平台数据库完整结构
-- 适用于宝塔面板部署
-- 创建时间2025
-- ============================================
-- 设置字符集
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ============================================
-- 1. 基础表结构schema.sql
-- ============================================
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(100) NOT NULL,
role VARCHAR(30) NOT NULL DEFAULT 'ROLE_USER',
points INT NOT NULL DEFAULT 50,
frozen_points INT NOT NULL DEFAULT 0 COMMENT '冻结积分',
phone VARCHAR(20),
avatar VARCHAR(500),
nickname VARCHAR(100),
gender VARCHAR(10),
birthday DATE,
address TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_login_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- 支付表
CREATE TABLE IF NOT EXISTS payments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id VARCHAR(50) NOT NULL UNIQUE,
amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
payment_method VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
description VARCHAR(500),
external_transaction_id VARCHAR(100),
callback_url VARCHAR(1000),
return_url VARCHAR(1000),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
paid_at TIMESTAMP NULL,
user_id BIGINT,
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_order_id (order_id),
INDEX idx_user_id (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付表';
-- 订单表
CREATE TABLE IF NOT EXISTS orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_number VARCHAR(50) NOT NULL UNIQUE,
total_amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
order_type VARCHAR(20) NOT NULL DEFAULT 'PRODUCT',
description VARCHAR(500),
notes TEXT,
shipping_address TEXT,
billing_address TEXT,
contact_phone VARCHAR(20),
contact_email VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
paid_at TIMESTAMP NULL,
shipped_at TIMESTAMP NULL,
delivered_at TIMESTAMP NULL,
cancelled_at TIMESTAMP NULL,
user_id BIGINT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_order_number (order_number),
INDEX idx_user_id (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单表';
-- 订单项表
CREATE TABLE IF NOT EXISTS order_items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
product_name VARCHAR(100) NOT NULL,
product_description VARCHAR(500),
product_sku VARCHAR(200),
unit_price DECIMAL(10,2) NOT NULL,
quantity INT NOT NULL,
subtotal DECIMAL(10,2) NOT NULL,
product_image VARCHAR(100),
order_id BIGINT NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
INDEX idx_order_id (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单项表';
-- 会员等级表
CREATE TABLE IF NOT EXISTS membership_levels (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(50) NOT NULL,
description TEXT,
price DECIMAL(10,2) NOT NULL DEFAULT 0,
duration_days INT NOT NULL DEFAULT 30,
points_bonus INT NOT NULL DEFAULT 0,
features JSON,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员等级表';
-- 用户会员信息表
CREATE TABLE IF NOT EXISTS user_memberships (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
membership_level_id BIGINT NOT NULL,
start_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
end_date TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
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,
FOREIGN KEY (membership_level_id) REFERENCES membership_levels(id),
UNIQUE KEY unique_active_membership (user_id, status),
INDEX idx_user_id (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户会员信息表';
-- 视频生成任务表(基础表)
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,
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',
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,
INDEX idx_task_id (task_id),
INDEX idx_user_id (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='视频生成任务表';
-- 用户作品表(基础版)
CREATE TABLE IF NOT EXISTS user_works_base (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
work_type VARCHAR(50) NOT NULL,
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,
INDEX idx_user_id (user_id),
INDEX idx_work_type (work_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户作品表(基础版)';
-- 系统配置表
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',
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,
INDEX idx_config_key (config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';
-- ============================================
-- 2. 迁移表结构Flyway Migration
-- ============================================
-- V3: 任务队列表
CREATE TABLE IF NOT EXISTS task_queue (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL COMMENT '用户名',
task_id VARCHAR(50) NOT NULL UNIQUE COMMENT '任务ID',
task_type ENUM('TEXT_TO_VIDEO', 'IMAGE_TO_VIDEO') NOT NULL COMMENT '任务类型',
status ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED', 'TIMEOUT') NOT NULL DEFAULT 'PENDING' COMMENT '队列状态',
priority INT NOT NULL DEFAULT 0 COMMENT '优先级,数字越小优先级越高',
real_task_id VARCHAR(100) COMMENT '外部API返回的真实任务ID',
last_check_time DATETIME COMMENT '最后一次检查时间',
check_count INT NOT NULL DEFAULT 0 COMMENT '检查次数',
max_check_count INT NOT NULL DEFAULT 30 COMMENT '最大检查次数30次 * 2分钟 = 60分钟',
error_message TEXT COMMENT '错误信息',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
completed_at DATETIME COMMENT '完成时间',
INDEX idx_username_status (username, status),
INDEX idx_status_priority (status, priority),
INDEX idx_last_check_time (last_check_time),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务队列表';
-- V4: 积分冻结记录表
CREATE TABLE IF NOT EXISTS points_freeze_records (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL COMMENT '用户名',
task_id VARCHAR(50) NOT NULL UNIQUE COMMENT '任务ID',
task_type ENUM('TEXT_TO_VIDEO', 'IMAGE_TO_VIDEO') NOT NULL COMMENT '任务类型',
freeze_points INT NOT NULL COMMENT '冻结的积分数量',
status ENUM('FROZEN', 'DEDUCTED', 'RETURNED', 'EXPIRED') NOT NULL DEFAULT 'FROZEN' COMMENT '冻结状态',
freeze_reason VARCHAR(200) COMMENT '冻结原因',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
completed_at DATETIME COMMENT '完成时间',
INDEX idx_username_status (username, status),
INDEX idx_task_id (task_id),
INDEX idx_created_at (created_at),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分冻结记录表';
-- V5: 用户作品表(详细版)
CREATE TABLE IF NOT EXISTS user_works (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL COMMENT '用户名',
task_id VARCHAR(50) NOT NULL UNIQUE COMMENT '任务ID',
work_type ENUM('TEXT_TO_VIDEO', 'IMAGE_TO_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 DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
completed_at DATETIME COMMENT '完成时间',
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),
INDEX idx_view_count (view_count),
INDEX idx_like_count (like_count),
INDEX idx_tags (tags),
INDEX idx_prompt (prompt(100))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户作品表';
-- V6: 任务状态表
CREATE TABLE IF NOT EXISTS task_status (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(255) NOT NULL COMMENT '任务ID',
username VARCHAR(255) NOT NULL COMMENT '用户名',
task_type VARCHAR(50) NOT NULL COMMENT '任务类型',
status VARCHAR(50) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态',
progress INT DEFAULT 0 COMMENT '进度百分比',
result_url TEXT COMMENT '结果URL',
error_message TEXT COMMENT '错误信息',
external_task_id VARCHAR(255) COMMENT '外部任务ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
completed_at TIMESTAMP NULL COMMENT '完成时间',
last_polled_at TIMESTAMP NULL COMMENT '最后轮询时间',
poll_count INT DEFAULT 0 COMMENT '轮询次数',
max_polls INT DEFAULT 60 COMMENT '最大轮询次数(2小时)',
INDEX idx_task_id (task_id),
INDEX idx_username (username),
INDEX idx_status (status),
INDEX idx_created_at (created_at),
INDEX idx_last_polled (last_polled_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务状态表';
-- V7: 任务清理表
-- 成功任务归档表
CREATE TABLE IF NOT EXISTS completed_tasks_archive (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL,
task_type VARCHAR(50) NOT NULL,
prompt TEXT,
aspect_ratio VARCHAR(20),
duration INT,
hd_mode BOOLEAN DEFAULT FALSE,
result_url TEXT,
real_task_id VARCHAR(255),
progress INT DEFAULT 100,
created_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP NOT NULL,
archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
points_cost INT DEFAULT 0,
INDEX idx_username (username),
INDEX idx_task_type (task_type),
INDEX idx_created_at (created_at),
INDEX idx_completed_at (completed_at),
INDEX idx_archived_at (archived_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成功任务归档表';
-- 失败任务清理日志表
CREATE TABLE IF NOT EXISTS failed_tasks_cleanup_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL,
task_type VARCHAR(50) NOT NULL,
error_message TEXT,
created_at TIMESTAMP NOT NULL,
failed_at TIMESTAMP NOT NULL,
cleaned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_task_type (task_type),
INDEX idx_cleaned_at (cleaned_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='失败任务清理日志表';
-- V8: 分镜视频任务表
CREATE TABLE IF NOT EXISTS storyboard_video_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(50) NOT NULL UNIQUE,
username VARCHAR(100) NOT NULL,
prompt TEXT,
image_url VARCHAR(500),
aspect_ratio VARCHAR(10) NOT NULL,
hd_mode BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL,
progress INT NOT NULL DEFAULT 0,
result_url VARCHAR(500),
real_task_id VARCHAR(255),
error_message TEXT,
cost_points INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
INDEX idx_username (username),
INDEX idx_status (status),
INDEX idx_task_id (task_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分镜视频任务表';
-- ============================================
-- 完成
-- ============================================
SET FOREIGN_KEY_CHECKS = 1;
-- 显示所有表
SHOW TABLES;

View File

@@ -1,299 +0,0 @@
# 环境变量配置说明 - 宝塔部署
## 📋 必需的环境变量
### 1. 数据库配置
```bash
# MySQL数据库主机地址本地数据库使用127.0.0.1或localhost
DB_HOST=127.0.0.1
# MySQL数据库端口默认3306
DB_PORT=3306
# 数据库名称
DB_NAME=aigc_platform
# 数据库用户名
DB_USERNAME=aigc_user
# 数据库密码(请替换为你的实际密码)
DB_PASSWORD=your_secure_password_here
```
### 2. JWT配置
```bash
# JWT密钥请生成一个足够长的随机字符串至少64字符
JWT_SECRET=your-very-long-secret-key-at-least-64-characters-for-production-security
# JWT过期时间毫秒默认604800000=7天
JWT_EXPIRATION=604800000
```
### 3. AI API配置
```bash
# AI API基础URL
AI_API_BASE_URL=https://ai.comfly.chat
AI_API_KEY=your_ai_api_key
# 图片生成API配置
AI_IMAGE_API_BASE_URL=https://ai.comfly.chat
AI_IMAGE_API_KEY=your_ai_image_api_key
```
### 4. 支付宝配置
```bash
# 支付宝应用ID
ALIPAY_APP_ID=your_alipay_app_id
# 支付宝私钥
ALIPAY_PRIVATE_KEY=your_alipay_private_key
# 支付宝公钥
ALIPAY_PUBLIC_KEY=your_alipay_public_key
# 域名配置
ALIPAY_DOMAIN=https://yourdomain.com
# 回调URL
ALIPAY_NOTIFY_URL=https://yourdomain.com/api/payments/alipay/notify
ALIPAY_RETURN_URL=https://yourdomain.com/payment/success
```
### 5. 腾讯云SES邮件服务配置
```bash
# 腾讯云SES Secret ID
TENCENT_SES_SECRET_ID=your_tencent_ses_secret_id
# 腾讯云SES Secret Key
TENCENT_SES_SECRET_KEY=your_tencent_ses_secret_key
# 发件邮箱
TENCENT_SES_FROM_EMAIL=noreply@yourdomain.com
# 邮件模板ID
TENCENT_SES_TEMPLATE_ID=your_template_id
```
### 6. 文件路径配置(可选)
```bash
# FFmpeg可执行文件路径Linux服务器
FFMPEG_PATH=/usr/bin/ffmpeg
# 临时文件目录
TEMP_DIR=/app/temp
# 上传文件目录
UPLOAD_PATH=/app/uploads
# 日志文件路径
LOG_FILE_PATH=/app/logs/application.log
```
---
## 🔧 宝塔面板设置环境变量
### 方式一:通过启动脚本设置
在宝塔面板创建启动脚本 `start.sh`
```bash
#!/bin/bash
# 设置环境变量
export DB_HOST=127.0.0.1
export DB_PORT=3306
export DB_NAME=aigc_platform
export DB_USERNAME=aigc_user
export DB_PASSWORD=jRbHPZbbkdm24yTT
export JWT_SECRET=aigc-demo-secret-key-for-jwt-token-generation-2025-production-version-secure
export AI_API_BASE_URL=https://ai.comfly.chat
export AI_API_KEY=sk-xCX1X12e8Dpj4mRJKFMxFUnV29pzJQpPeuZFGqTwYOorjvOQ
export AI_IMAGE_API_BASE_URL=https://ai.comfly.chat
export AI_IMAGE_API_KEY=sk-xCX1X12e8Dpj4mRJKFMxFUnV29pzJQpPeuZFGqTwYOorjvOQ
export ALIPAY_APP_ID=your_app_id
export ALIPAY_PRIVATE_KEY=your_private_key
export ALIPAY_PUBLIC_KEY=your_public_key
export ALIPAY_DOMAIN=https://yourdomain.com
export ALIPAY_NOTIFY_URL=https://yourdomain.com/api/payments/alipay/notify
export ALIPAY_RETURN_URL=https://yourdomain.com/payment/success
export TENCENT_SES_SECRET_ID=your_secret_id
export TENCENT_SES_SECRET_KEY=your_secret_key
export TENCENT_SES_FROM_EMAIL=noreply@yourdomain.com
export TENCENT_SES_TEMPLATE_ID=your_template_id
export FFMPEG_PATH=/usr/bin/ffmpeg
export TEMP_DIR=/app/temp
export UPLOAD_PATH=/app/uploads
export LOG_FILE_PATH=/app/logs/application.log
# 启动应用
java -jar -Dspring.profiles.active=prod demo-0.0.1-SNAPSHOT.jar
```
赋予执行权限:
```bash
chmod +x start.sh
```
### 方式二:创建 .env 文件
创建 `.env` 文件:
```bash
# 数据库配置
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=aigc_platform
DB_USERNAME=aigc_user
DB_PASSWORD=jRbHPZbbkdm24yTT
# JWT配置
JWT_SECRET=aigc-demo-secret-key-for-jwt-token-generation-2025-production-version-secure
JWT_EXPIRATION=604800000
# AI API配置
AI_API_BASE_URL=https://ai.comfly.chat
AI_API_KEY=sk-xCX1X12e8Dpj4mRJKFMxFUnV29pzJQpPeuZFGqTwYOorjvOQ
AI_IMAGE_API_BASE_URL=https://ai.comfly.chat
AI_IMAGE_API_KEY=sk-xCX1X12e8Dpj4mRJKFMxFUnV29pzJQpPeuZFGqTwYOorjvOQ
# 支付宝配置
ALIPAY_APP_ID=your_app_id
ALIPAY_PRIVATE_KEY=your_private_key
ALIPAY_PUBLIC_KEY=your_public_key
ALIPAY_DOMAIN=https://yourdomain.com
ALIPAY_NOTIFY_URL=https://yourdomain.com/api/payments/alipay/notify
ALIPAY_RETURN_URL=https://yourdomain.com/payment/success
# 腾讯云SES配置
TENCENT_SES_SECRET_ID=your_secret_id
TENCENT_SES_SECRET_KEY=your_secret_key
TENCENT_SES_FROM_EMAIL=noreply@yourdomain.com
TENCENT_SES_TEMPLATE_ID=your_template_id
# 文件路径配置
FFMPEG_PATH=/usr/bin/ffmpeg
TEMP_DIR=/app/temp
UPLOAD_PATH=/app/uploads
LOG_FILE_PATH=/app/logs/application.log
```
使用 `source` 加载环境变量后启动:
```bash
source .env && java -jar -Dspring.profiles.active=prod demo-0.0.1-SNAPSHOT.jar
```
### 方式三systemd服务配置
创建 systemd 服务文件 `/etc/systemd/system/aigc-platform.service`
```ini
[Unit]
Description=AIGC Platform Service
After=network.target mysql.service
[Service]
Type=simple
User=www
WorkingDirectory=/www/wwwroot/aigc-platform
ExecStart=/usr/bin/java -jar -Dspring.profiles.active=prod demo-0.0.1-SNAPSHOT.jar
# 环境变量配置
Environment="DB_HOST=127.0.0.1"
Environment="DB_PORT=3306"
Environment="DB_NAME=aigc_platform"
Environment="DB_USERNAME=aigc_user"
Environment="DB_PASSWORD=jRbHPZbbkdm24yTT"
Environment="JWT_SECRET=your-jwt-secret-key"
Environment="AI_API_BASE_URL=https://ai.comfly.chat"
Environment="AI_API_KEY=your_api_key"
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
启动服务:
```bash
systemctl daemon-reload
systemctl enable aigc-platform
systemctl start aigc-platform
systemctl status aigc-platform
```
---
## ✅ 配置验证
启动应用后,检查日志确认数据库连接成功:
```bash
# 查看启动日志
tail -f logs/application.log
# 应该看到类似的日志:
# INFO - HikariPool-1 - Starting...
# INFO - HikariPool-1 - Start completed.
# INFO - Started DemoApplication in X.XX seconds
```
测试数据库连接:
```bash
curl http://localhost:8080/api/health/ping
```
---
## 🔐 安全建议
1. **不要将 .env 文件或包含密钥的启动脚本提交到Git**
```bash
echo ".env" >> .gitignore
echo "start.sh" >> .gitignore
```
2. **定期更换JWT密钥和数据库密码**
3. **生产环境使用强密码**
- 数据库密码至少16位包含大小写字母、数字、特殊字符
- JWT密钥至少64字符随机字符串
4. **限制数据库访问**
- 仅允许应用服务器IP访问MySQL
- 不要使用root用户创建专用数据库用户
5. **启用HTTPS**
- 生产环境务必配置SSL证书
- 在Nginx/宝塔面板配置HTTPS
---
## 📝 快速配置模板(本地测试用)
```bash
# 最小化配置(使用默认值)
export DB_HOST=127.0.0.1
export DB_USERNAME=aigc_user
export DB_PASSWORD=jRbHPZbbkdm24yTT
export JWT_SECRET=aigc-demo-secret-key-for-jwt-token-generation-2025
# 启动应用
java -jar -Dspring.profiles.active=prod demo-0.0.1-SNAPSHOT.jar
```