优化: Safari下载兼容、禁用生产Swagger、前端构建优化移除console、更新COS配置

This commit is contained in:
AIGC Developer
2026-01-05 15:40:28 +08:00
parent 38630dbb66
commit a99cfa28e5
40 changed files with 2550 additions and 1320 deletions

View File

@@ -0,0 +1,19 @@
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 单独的密码编码器配置类
* 避免与 SecurityConfig 产生循环依赖
*/
@Configuration
public class PasswordEncoderConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -20,79 +20,74 @@ import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import com.example.demo.security.JwtAuthenticationFilter;
import com.example.demo.security.PlainTextPasswordEncoder;
import com.example.demo.service.RedisTokenService;
import com.example.demo.service.UserService;
import com.example.demo.util.JwtUtils;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder();
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtUtils jwtUtils, UserService userService,
RedisTokenService redisTokenService) throws Exception {
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // 允许基于表单的账号密码登录后保持会话
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
.authorizeHttpRequests(auth -> auth
// Swagger/OpenAPI 路径 - 必须放在最前面,完全公开访问
.requestMatchers(
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/v3/api-docs",
"/api-docs/**",
"/swagger-resources/**",
"/webjars/**",
"/configuration/ui",
"/configuration/security",
"/swagger-config",
"/api/swagger-config"
).permitAll()
// 公共路径
.requestMatchers(
"/login",
"/register",
"/api/public/**",
"/api/auth/**",
"/api/verification/**",
"/api/email/**",
"/api/tencent/**",
"/api/test/**",
"/api/polling/**",
"/api/diagnostic/**",
"/api/polling-diagnostic/**",
"/api/monitor/**",
"/api/health/**",
"/css/**",
"/js/**",
"/h2-console/**"
).permitAll()
.requestMatchers("/api/orders/stats").permitAll() // 统计接口允许匿名访问
.requestMatchers("/api/orders/**").authenticated() // 订单接口需要认证
.requestMatchers("/api/payments/alipay/notify", "/api/payments/alipay/return").permitAll() // 支付宝回调接口允许匿名访问(外部调用)
.requestMatchers("/api/payments/**").authenticated() // 其他支付接口需要认证
.requestMatchers("/api/image-to-video/**").authenticated() // 图生视频接口需要认证
.requestMatchers("/api/text-to-video/**").authenticated() // 文生视频接口需要认证
.requestMatchers("/api/storyboard-video/**").authenticated() // 分镜视频接口需要认证
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 仪表盘API需要管理员权限
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 管理员API需要管理员权限
.requestMatchers("/settings", "/settings/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.requestMatchers("/users/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.anyRequest().permitAll()
.authorizeHttpRequests(auth -> auth
// Swagger/OpenAPI 路径
.requestMatchers(
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/v3/api-docs",
"/api-docs/**",
"/swagger-resources/**",
"/webjars/**",
"/configuration/ui",
"/configuration/security",
"/swagger-config",
"/api/swagger-config"
).permitAll()
// 公共路径
.requestMatchers(
"/login",
"/register",
"/api/public/**",
"/api/auth/**",
"/api/verification/**",
"/api/email/**",
"/api/tencent/**",
"/api/test/**",
"/api/polling/**",
"/api/diagnostic/**",
"/api/polling-diagnostic/**",
"/api/monitor/**",
"/api/health/**",
"/css/**",
"/js/**",
"/h2-console/**"
).permitAll()
.requestMatchers("/api/orders/stats").permitAll()
.requestMatchers("/api/orders/**").authenticated()
.requestMatchers("/api/payments/alipay/notify", "/api/payments/alipay/return").permitAll()
.requestMatchers("/api/payments/**").authenticated()
.requestMatchers("/api/image-to-video/**").authenticated()
.requestMatchers("/api/text-to-video/**").authenticated()
.requestMatchers("/api/storyboard-video/**").authenticated()
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.requestMatchers("/settings", "/settings/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.requestMatchers("/users/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.anyRequest().permitAll()
)
.formLogin(form -> form
.loginPage("/login")
// 使用email作为表单登录的用户名参数账号即邮箱
.usernameParameter("email")
.passwordParameter("password")
.defaultSuccessUrl("/", true)
@@ -101,15 +96,13 @@ public class SecurityConfig {
.logout(Customizer.withDefaults());
// 添加JWT过滤器
http.addFilterBefore(jwtAuthenticationFilter(jwtUtils, userService, redisTokenService), UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// H2 控制台需要以下设置
http.headers(headers -> headers.frameOptions(frame -> frame.disable()));
return http.build();
}
@Bean
public DaoAuthenticationProvider authenticationProvider(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
@@ -119,75 +112,32 @@ public class SecurityConfig {
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config, DaoAuthenticationProvider authenticationProvider) throws Exception {
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 面向海外用户使用最宽松的CORS配置
// 使用allowedOriginPatterns支持通配符当allowCredentials=true时必须使用patterns而不是origins
configuration.setAllowedOriginPatterns(Arrays.asList(
// 允许所有HTTP和HTTPS来源任意域名、任意IP、任意端口
"http://*:*",
"https://*:*",
"http://*",
"https://*",
"http://*.*",
"https://*.*",
"http://*.*.*",
"https://*.*.*",
// 本地开发环境
"http://localhost:*",
"http://127.0.0.1:*",
"http://0.0.0.0:*",
// 常见开发工具和隧道服务
"https://*.ngrok.io",
"https://*.ngrok-free.app",
"https://*.cloudflare.com",
"https://*.vercel.app",
"https://*.netlify.app",
"https://*.herokuapp.com",
"https://*.github.io",
"https://*.gitlab.io",
// 生产域名(如果有)
"https://vionow.com",
"https://www.vionow.com",
"http://vionow.com",
"http://www.vionow.com",
"https://*.vionow.com"
"http://*:*", "https://*:*", "http://*", "https://*",
"http://*.*", "https://*.*", "http://*.*.*", "https://*.*.*",
"http://localhost:*", "http://127.0.0.1:*", "http://0.0.0.0:*",
"https://*.ngrok.io", "https://*.ngrok-free.app",
"https://*.cloudflare.com", "https://*.vercel.app",
"https://*.netlify.app", "https://*.herokuapp.com",
"https://*.github.io", "https://*.gitlab.io",
"https://vionow.com", "https://www.vionow.com",
"http://vionow.com", "http://www.vionow.com", "https://*.vionow.com"
));
// 允许所有HTTP方法
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD", "TRACE", "CONNECT"));
// 允许所有请求头
configuration.setAllowedHeaders(Arrays.asList("*"));
// 允许携带凭证cookies, authorization headers等
configuration.setAllowCredentials(true);
// 暴露所有响应头给客户端
configuration.setExposedHeaders(Arrays.asList("*"));
// 预检请求缓存时间1小时减少预检请求频率
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtUtils jwtUtils, UserService userService,
com.example.demo.service.RedisTokenService redisTokenService) {
return new JwtAuthenticationFilter(jwtUtils, userService, redisTokenService);
}
}

View File

@@ -51,7 +51,7 @@ public class StoryboardVideoApiController {
String aspectRatio = (String) request.getOrDefault("aspectRatio", "16:9");
Boolean hdMode = (Boolean) request.getOrDefault("hdMode", false);
String imageUrl = (String) request.get("imageUrl");
String imageModel = (String) request.getOrDefault("imageModel", "nano-banana");
String imageModel = (String) request.getOrDefault("imageModel", "nano-banana-2");
// 提取用户上传的多张图片(新增)
@SuppressWarnings("unchecked")
@@ -129,6 +129,7 @@ public class StoryboardVideoApiController {
taskData.put("status", task.getStatus());
taskData.put("progress", task.getProgress());
taskData.put("resultUrl", task.getResultUrl());
taskData.put("videoUrls", task.getVideoUrls()); // 视频URLJSON数组
taskData.put("imageUrl", task.getImageUrl()); // 参考图片(旧字段)
taskData.put("uploadedImages", task.getUploadedImages()); // 用户上传的参考图JSON数组
taskData.put("prompt", task.getPrompt());

View File

@@ -115,6 +115,12 @@ public class UserWorkApiController {
Page<UserWork> works;
if (includeProcessing) {
works = userWorkService.getAllUserWorks(username, page, size);
// 调试日志:检查是否有 PROCESSING 状态的作品
long processingCount = works.getContent().stream()
.filter(w -> w.getStatus() == UserWork.WorkStatus.PROCESSING || w.getStatus() == UserWork.WorkStatus.PENDING)
.count();
logger.info("获取作品列表: username={}, total={}, processing/pending={}",
username, works.getTotalElements(), processingCount);
} else {
works = userWorkService.getUserWorks(username, page, size);
}

View File

@@ -44,10 +44,13 @@ public class StoryboardVideoTask {
private Integer duration; // 视频时长5, 10, 15
@Column(name = "image_model", length = 50)
private String imageModel = "nano-banana"; // 图像生成模型nano-banana, nano-banana2
private String imageModel = "nano-banana-2"; // 图像生成模型nano-banana, nano-banana-2
@Column(name = "uploaded_images", columnDefinition = "LONGTEXT")
private String uploadedImages; // 用户上传的多张参考图片JSON数组最多3张Base64格式
private String uploadedImages; // 用户上传的多张参考图片JSON数组最多3张Base64格式- 生成分镜图时使用
@Column(name = "video_reference_images", columnDefinition = "LONGTEXT")
private String videoReferenceImages; // 视频阶段用户上传的参考图片JSON数组- 生成视频时使用
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
@@ -188,6 +191,8 @@ public class StoryboardVideoTask {
public void setImageModel(String imageModel) { this.imageModel = imageModel; }
public String getUploadedImages() { return uploadedImages; }
public void setUploadedImages(String uploadedImages) { this.uploadedImages = uploadedImages; }
public String getVideoReferenceImages() { return videoReferenceImages; }
public void setVideoReferenceImages(String videoReferenceImages) { this.videoReferenceImages = videoReferenceImages; }
public TaskStatus getStatus() { return status; }
public void setStatus(TaskStatus status) { this.status = status; }
public int getProgress() { return progress; }

View File

@@ -91,6 +91,9 @@ public class UserWork {
@Column(name = "tags", length = 500)
private String tags; // 标签,用逗号分隔
@Column(name = "uploaded_images", columnDefinition = "LONGTEXT")
private String uploadedImages; // 用户上传的参考图片JSON数组用于"做同款"功能恢复
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -124,6 +127,7 @@ public class UserWork {
* 作品状态枚举
*/
public enum WorkStatus {
PENDING("排队中"),
PROCESSING("处理中"),
COMPLETED("已完成"),
FAILED("失败"),
@@ -378,6 +382,14 @@ public class UserWork {
this.tags = tags;
}
public String getUploadedImages() {
return uploadedImages;
}
public void setUploadedImages(String uploadedImages) {
this.uploadedImages = uploadedImages;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}

View File

@@ -6,6 +6,7 @@ import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -79,6 +80,7 @@ public interface CompletedTaskArchiveRepository extends JpaRepository<CompletedT
/**
* 删除超过指定天数的归档任务
*/
@Modifying
@Query("DELETE FROM CompletedTaskArchive c WHERE c.archivedAt < :cutoffDate")
int deleteOlderThan(@Param("cutoffDate") LocalDateTime cutoffDate);
}

View File

@@ -6,6 +6,7 @@ import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -79,6 +80,7 @@ public interface FailedTaskCleanupLogRepository extends JpaRepository<FailedTask
/**
* 删除超过指定天数的清理日志
*/
@Modifying
@Query("DELETE FROM FailedTaskCleanupLog f WHERE f.cleanedAt < :cutoffDate")
int deleteOlderThan(@Param("cutoffDate") LocalDateTime cutoffDate);
}

View File

@@ -86,18 +86,18 @@ public class TaskQueueScheduler {
return;
}
logger.info("[轮询调度] TaskQueue待检查任务: {}, TaskStatus处理中任务: {}",
logger.debug("[轮询调度] TaskQueue待检查任务: {}, TaskStatus处理中任务: {}",
hasQueueTasks, processingStatusCount);
// 队列中有任务:检查队列内任务状态
if (hasQueueTasks) {
logger.info("[轮询调度] 开始检查TaskQueue任务状态");
logger.debug("[轮询调度] 开始检查TaskQueue任务状态");
taskQueueService.checkTaskStatuses();
}
// TaskStatus表中有处理中任务调用轮询服务
if (processingStatusCount > 0) {
logger.info("[轮询调度] 开始轮询TaskStatus任务");
logger.debug("[轮询调度] 开始轮询TaskStatus任务");
taskStatusPollingService.pollTaskStatuses();
}
} catch (Exception e) {

View File

@@ -16,8 +16,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import com.example.demo.model.User;
import com.example.demo.service.RedisTokenService;
import com.example.demo.service.UserService;
import com.example.demo.repository.UserRepository;
import com.example.demo.util.JwtUtils;
import io.jsonwebtoken.ExpiredJwtException;
@@ -26,19 +25,21 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
/**
* JWT认证过滤器
* 注意:直接使用 UserRepository 而不是 UserService避免循环依赖
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtUtils jwtUtils;
private final UserService userService;
private final RedisTokenService redisTokenService;
private final UserRepository userRepository;
public JwtAuthenticationFilter(JwtUtils jwtUtils, UserService userService, RedisTokenService redisTokenService) {
public JwtAuthenticationFilter(JwtUtils jwtUtils, UserRepository userRepository) {
this.jwtUtils = jwtUtils;
this.userService = userService;
this.redisTokenService = redisTokenService;
this.userRepository = userRepository;
}
@Override
@@ -71,7 +72,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
}
// 如果是公开路径,直接放行不进行JWT验证
// 如果是公开路径,直接放行
if (isPublicPath) {
filterChain.doFilter(request, response);
return;
@@ -79,16 +80,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
try {
String authHeader = request.getHeader("Authorization");
String token = jwtUtils.extractTokenFromHeader(authHeader);
if (token != null && !token.equals("null") && !token.trim().isEmpty()) {
String username = jwtUtils.getUsernameFromToken(token);
if (username != null && jwtUtils.validateToken(token, username)) {
// Redis 验证已降级isTokenValid 总是返回 true
// 主要依赖 JWT 本身的有效性验证
User user = userService.findByUsernameOrNull(username);
// 直接使用 Repository 查询用户
User user = userRepository.findByUsername(username).orElse(null);
if (user != null) {
// 检查用户是否被封禁
@@ -101,12 +100,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return;
}
// 创建用户权限列表
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(user.getRole()));
// 使用用户名字符串作为Principal而不是User对象
// 这样 authentication.getName() 会返回用户名字符串
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), null, authorities);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
@@ -117,11 +113,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
}
} catch (ExpiredJwtException e) {
// Token过期返回401让前端跳转登录页
logger.warn("JWT已过期: 过期时间={}, 当前时间={}, token前30字符={}",
e.getClaims().getExpiration(),
new java.util.Date(),
e.getClaims().getSubject());
logger.warn("JWT已过期: {}", e.getMessage());
SecurityContextHolder.clearContext();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
@@ -129,7 +121,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return;
} catch (Exception e) {
logger.warn("JWT认证失败: {}", e.getMessage());
// 清除可能存在的认证信息
SecurityContextHolder.clearContext();
}

View File

@@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.alipay.api.domain.AlipayTradePagePayModel;
import com.alipay.api.domain.AlipayTradePrecreateModel;
import com.alipay.api.internal.util.AlipaySignature;
import com.example.demo.model.Payment;
@@ -65,7 +66,7 @@ public class AlipayService {
}
/**
* 创建支付宝支付订单并生成二维码
* 创建支付宝支付订单(电脑网站支付)
*/
public Map<String, Object> createPayment(Payment payment) {
try {
@@ -84,8 +85,8 @@ public class AlipayService {
paymentRepository.save(payment);
logger.info("支付记录已保存ID{}", payment.getId());
// 调用真实的支付API
return callRealAlipayAPI(payment);
// 调用电脑网站支付API
return callPagePayAPI(payment);
} catch (Exception e) {
logger.error("创建支付订单时发生异常,订单号:{},错误:{}", payment.getOrderId(), e.getMessage(), e);
@@ -108,6 +109,60 @@ public class AlipayService {
}
}
/**
* 调用电脑网页支付APIalipay.trade.page.pay
* 返回支付页面的HTML表单前端直接渲染即可跳转到支付宝
*/
private Map<String, Object> callPagePayAPI(Payment payment) throws Exception {
logger.info("=== 使用IJPay调用支付宝电脑网页支付API ===");
logger.info("网关地址: {}", gatewayUrl);
logger.info("应用ID: {}", appId);
logger.info("通知URL: {}", notifyUrl);
logger.info("返回URL: {}", returnUrl);
// 在调用前确保配置已设置
ensureAliPayConfigSet();
// 设置业务参数
AlipayTradePagePayModel model = new AlipayTradePagePayModel();
model.setOutTradeNo(payment.getOrderId());
model.setProductCode("FAST_INSTANT_TRADE_PAY"); // 电脑网站支付固定值
// 如果是美元按汇率转换为人民币支付宝只支持CNY
java.math.BigDecimal amount = payment.getAmount();
String currency = payment.getCurrency();
if ("USD".equalsIgnoreCase(currency)) {
java.math.BigDecimal exchangeRate = new java.math.BigDecimal("7.2");
amount = amount.multiply(exchangeRate).setScale(2, java.math.RoundingMode.HALF_UP);
logger.info("货币转换: {} USD -> {} CNY (汇率: 7.2)", payment.getAmount(), amount);
}
model.setTotalAmount(amount.toString());
model.setSubject(payment.getDescription() != null ? payment.getDescription() : "AIGC会员订阅");
model.setBody(payment.getDescription() != null ? payment.getDescription() : "AIGC平台会员订阅服务");
model.setTimeoutExpress("30m"); // 订单超时时间
logger.info("调用支付宝电脑网页支付API订单号{},金额:{},商品名称:{}",
model.getOutTradeNo(), model.getTotalAmount(), model.getSubject());
// 使用IJPay调用电脑网页支付API返回HTML表单
String form = AliPayApi.tradePage(model, notifyUrl, returnUrl);
if (form == null || form.isEmpty()) {
logger.error("支付宝电脑网页支付API返回为空");
throw new RuntimeException("支付宝支付页面生成失败");
}
logger.info("支付宝电脑网页支付表单生成成功,订单号:{}", payment.getOrderId());
Map<String, Object> result = new HashMap<>();
result.put("payForm", form); // HTML表单前端直接渲染
result.put("outTradeNo", payment.getOrderId());
result.put("success", true);
result.put("payType", "PAGE_PAY"); // 标识支付类型
return result;
}
/**
* 确保AliPayApiConfigKit中已设置配置
* 如果未设置从AliPayApiConfigHolder获取并设置

View File

@@ -173,9 +173,10 @@ public class ImageToVideoService {
if (page < 0) {
page = 0;
}
if (size <= 0 || size > 100) {
size = 10; // 默认每页10条最大100条
if (size <= 0) {
size = 10; // 默认每页10条
}
// 移除size上限限制允许获取全部历史记录
Pageable pageable = PageRequest.of(page, size);
Page<ImageToVideoTask> taskPage = taskRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
@@ -345,10 +346,11 @@ public class ImageToVideoService {
}
}
}
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
} catch (Exception saveException) {
logger.error("保存失败状态时出错: {}", task.getTaskId(), saveException);
} finally {
// 确保无论如何都返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
}
}
@@ -375,6 +377,8 @@ public class ImageToVideoService {
ImageToVideoTask currentTask = taskRepository.findByTaskId(task.getTaskId()).orElse(null);
if (currentTask != null && currentTask.getStatus() == ImageToVideoTask.TaskStatus.CANCELLED) {
logger.info("任务 {} 已被取消,停止轮询", task.getTaskId());
// 任务取消时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
return;
}
@@ -496,6 +500,8 @@ public class ImageToVideoService {
} catch (InterruptedException e) {
logger.error("轮询任务状态被中断: {}", task.getTaskId(), e);
// 线程中断时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
Thread.currentThread().interrupt();
} catch (Exception e) {
logger.error("轮询任务状态异常: {}", task.getTaskId(), e);
@@ -503,6 +509,8 @@ public class ImageToVideoService {
if (e.getCause() != null) {
logger.error("异常原因: {}", e.getCause().getMessage());
}
// 轮询异常时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
}
}

View File

@@ -853,12 +853,12 @@ public class RealAIService {
* 参考Comfly项目的Comfly_nano_banana_edit节点实现
*/
public Map<String, Object> submitTextToImageTask(String prompt, String aspectRatio, int numImages, boolean hdMode) {
return submitTextToImageTask(prompt, aspectRatio, numImages, hdMode, "nano-banana");
return submitTextToImageTask(prompt, aspectRatio, numImages, hdMode, "nano-banana-2");
}
/**
* 提交文生图任务(使用指定的模型)
* @param imageModel 图像生成模型nano-banana 或 nano-banana2
* @param imageModel 图像生成模型nano-banana 或 nano-banana-2
*/
public Map<String, Object> submitTextToImageTask(String prompt, String aspectRatio, int numImages, boolean hdMode, String imageModel) {
try {
@@ -885,8 +885,8 @@ public class RealAIService {
// 参考Comfly_nano_banana_edit节点使用 /v1/images/generations 端点
String url = getEffectiveImageApiBaseUrl() + "/v1/images/generations";
// 使用指定的模型,默认为 nano-banana
String model = (imageModel != null && !imageModel.isEmpty()) ? imageModel : "nano-banana";
// 强制使用 nano-banana-2 模型,忽略用户选择
String model = "nano-banana-2";
// 构建请求体参考Comfly_nano_banana_edit节点的参数设置
// 注意banana模型不需要n参数每次只生成1张图片
@@ -978,6 +978,14 @@ public class RealAIService {
* @return API响应
*/
public Map<String, Object> submitImageToImageTask(String prompt, String imageBase64, String aspectRatio, boolean hdMode) {
return submitImageToImageTask(prompt, imageBase64, aspectRatio, hdMode, "nano-banana-2");
}
/**
* 提交图生图任务(使用指定的模型)
* @param imageModel 图像生成模型参数(忽略,强制使用 nano-banana-2
*/
public Map<String, Object> submitImageToImageTask(String prompt, String imageBase64, String aspectRatio, boolean hdMode, String imageModel) {
// 参数验证:图生图必须有参考图片
if (imageBase64 == null || imageBase64.isEmpty()) {
logger.error("图生图任务失败:缺少参考图片");
@@ -985,15 +993,15 @@ public class RealAIService {
}
try {
logger.info("提交图生图任务banana模型: prompt={}, aspectRatio={}, hdMode={}, imageBase64长度={}",
prompt, aspectRatio, hdMode, imageBase64.length());
// 强制使用 nano-banana-2 模型,忽略用户选择
String model = "nano-banana-2";
logger.info("提交图生图任务: prompt={}, aspectRatio={}, hdMode={}, imageModel={}, imageBase64长度={}",
prompt, aspectRatio, hdMode, model, imageBase64.length());
// 图生图使用 /v1/images/edits 端点(参考 Comfly_nano_banana_edit
String url = getEffectiveImageApiBaseUrl() + "/v1/images/edits";
// 模型选择
String model = "nano-banana";
// 处理图片数据:如果是 URL先下载转换为 base64
String base64Data = imageBase64;
if (imageBase64.startsWith("http://") || imageBase64.startsWith("https://")) {

View File

@@ -133,14 +133,46 @@ public class StoryboardVideoService {
task.setImageModel(imageModel);
}
// 保存用户上传的多张图片(JSON数组
// 保存用户上传的多张图片(上传到COS后存储URL
if (uploadedImages != null && !uploadedImages.isEmpty()) {
try {
String uploadedImagesJson = objectMapper.writeValueAsString(uploadedImages);
task.setUploadedImages(uploadedImagesJson);
logger.info("保存用户上传的图片: taskId={}, 数量={}", taskId, uploadedImages.size());
List<String> processedImages = new ArrayList<>();
int imageIndex = 0;
for (String img : uploadedImages) {
if (img == null || img.isEmpty() || "null".equals(img)) {
continue;
}
// 如果是Base64图片且COS启用上传到COS
if (img.startsWith("data:image") && cosService.isEnabled()) {
try {
String cosUrl = cosService.uploadBase64Image(img, "uploaded_" + taskId + "_" + imageIndex + ".png");
if (cosUrl != null && !cosUrl.isEmpty()) {
processedImages.add(cosUrl);
logger.info("用户上传图片{}上传COS成功: taskId={}", imageIndex, taskId);
} else {
// COS上传失败保留原始Base64
processedImages.add(img);
logger.warn("用户上传图片{}上传COS失败保留Base64: taskId={}", imageIndex, taskId);
}
} catch (Exception e) {
// 异常时保留原始Base64
processedImages.add(img);
logger.error("用户上传图片{}上传COS异常: taskId={}", imageIndex, taskId, e);
}
} else {
// 非Base64或COS未启用直接保留
processedImages.add(img);
}
imageIndex++;
}
if (!processedImages.isEmpty()) {
String uploadedImagesJson = objectMapper.writeValueAsString(processedImages);
task.setUploadedImages(uploadedImagesJson);
logger.info("保存用户上传的图片: taskId={}, 数量={}", taskId, processedImages.size());
}
} catch (Exception e) {
logger.error("序列化上传图片失败: taskId={}", taskId, e);
logger.error("处理上传图片失败: taskId={}", taskId, e);
}
}
@@ -390,7 +422,8 @@ public class StoryboardVideoService {
finalPrompt,
refImage,
generationAspectRatio,
hdMode
hdMode,
imageModel // 使用用户选择的图像生成模型
);
} else {
// 无参考图片使用文生图API
@@ -673,6 +706,16 @@ public class StoryboardVideoService {
// 不抛出异常,避免影响主流程
}
// 更新分镜视频 UserWork 状态为 COMPLETED分镜图阶段完成
// 这样主页不会显示"生成中",用户点击"生成视频"后会重新创建 PROCESSING 状态的记录
try {
userWorkService.updateStoryboardVideoWorkToImageCompleted(taskId, imageUrlForDb);
logger.info("分镜视频作品状态已更新为分镜图完成: taskId={}", taskId);
} catch (Exception e) {
logger.warn("更新分镜视频作品状态失败: taskId={}, error={}", taskId, e.getMessage());
// 不抛出异常,避免影响主流程
}
// 分镜图生成完成,从任务队列中移除(用户点击"生成视频"时会重新添加)
try {
taskQueueService.removeTaskFromQueue(taskId);
@@ -804,6 +847,19 @@ public class StoryboardVideoService {
} catch (Exception ue) {
logger.warn("回退更新UserWork状态失败: taskId={}", taskId);
}
// 回退时也需要返还积分
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("回退更新时已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception e) {
logger.debug("回退更新时分镜视频积分返还失败(可能未冻结或已返还): taskId={}_vid", taskId);
}
try {
userService.returnFrozenPoints(taskId + "_img");
logger.info("回退更新时已返还分镜图积分: taskId={}_img", taskId);
} catch (Exception e) {
logger.debug("回退更新时分镜图积分返还失败(可能未冻结或已返还): taskId={}_img", taskId);
}
}
} catch (Exception ex) {
logger.error("更新任务失败状态失败: {}", taskId, ex);
@@ -887,17 +943,21 @@ public class StoryboardVideoService {
paramsUpdated = true;
}
// 更新参考图(如果提供了新的参考图)
// 更新视频阶段的参考图(如果提供了新的参考图)
if (referenceImages != null && !referenceImages.isEmpty()) {
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
String uploadedImagesJson = mapper.writeValueAsString(referenceImages);
task.setUploadedImages(uploadedImagesJson);
logger.info("更新任务 {} 的参考图: {} 张", taskId, referenceImages.size());
String videoRefImagesJson = mapper.writeValueAsString(referenceImages);
task.setVideoReferenceImages(videoRefImagesJson);
logger.info("更新任务 {} 的视频参考图: {} 张", taskId, referenceImages.size());
paramsUpdated = true;
} catch (Exception e) {
logger.warn("序列化参考图失败: {}", e.getMessage());
logger.warn("序列化视频参考图失败: {}", e.getMessage());
}
} else {
// 如果没有提供新的参考图,清空视频参考图字段
task.setVideoReferenceImages(null);
logger.info("任务 {} 未提供视频参考图,清空该字段", taskId);
}
// 如果是 COMPLETED 或 FAILED 状态,更新为 PROCESSING表示正在生成视频
@@ -925,6 +985,14 @@ public class StoryboardVideoService {
logger.error("更新 TaskStatus 状态失败: {}", taskId, e);
}
// 更新 UserWork 状态为 PROCESSING视频生成中
try {
userWorkService.updateStoryboardVideoWorkToProcessing(taskId);
logger.info("UserWork 已更新为 PROCESSING开始视频生成: taskId={}", taskId);
} catch (Exception e) {
logger.warn("更新 UserWork 状态失败: taskId={}, error={}", taskId, e.getMessage());
}
// 如果有任何参数更新,保存任务
if (paramsUpdated) {
taskRepository.save(task);
@@ -936,6 +1004,13 @@ public class StoryboardVideoService {
taskQueueService.addStoryboardVideoTask(task.getUsername(), taskId);
} catch (Exception e) {
logger.error("添加分镜视频任务到队列失败: {}", taskId, e);
// 返还刚刚冻结的视频积分
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("添加队列失败,已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception re) {
logger.warn("返还分镜视频积分失败: taskId={}_vid, error={}", taskId, re.getMessage());
}
throw new RuntimeException("添加视频生成任务失败: " + e.getMessage());
}
@@ -1313,11 +1388,12 @@ public class StoryboardVideoService {
}
}
// 返还冻结积分
// 返还冻结积分(分镜图阶段失败,返还 _img 积分)
try {
userService.returnFrozenPoints(task.getTaskId());
userService.returnFrozenPoints(task.getTaskId() + "_img");
logger.info("已返还分镜图积分: taskId={}_img", task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
logger.warn("返还冻结积分失败(可能已处理): taskId={}_img", task.getTaskId());
}
logger.warn("分镜图生成任务超时,已标记为失败: taskId={}", task.getTaskId());
@@ -1379,11 +1455,12 @@ public class StoryboardVideoService {
}
}
// 返还冻结积分
// 返还冻结积分(视频生成阶段失败,返还 _vid 积分)
try {
userService.returnFrozenPoints(task.getTaskId());
userService.returnFrozenPoints(task.getTaskId() + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
logger.warn("返还冻结积分失败(可能已处理): taskId={}_vid", task.getTaskId());
}
logger.warn("视频生成任务超时,已标记为失败: taskId={}, progress={}", task.getTaskId(), progress);

View File

@@ -362,7 +362,23 @@ public class TaskQueueService {
// 返还冻结的积分
try {
userService.returnFrozenPoints(taskQueue.getTaskId());
// 分镜视频任务需要同时返还 _vid 和 _img 积分
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_vid");
logger.debug("系统重启:已返还分镜视频积分: taskId={}_vid", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("系统重启:分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskQueue.getTaskId());
}
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_img");
logger.debug("系统重启:已返还分镜图积分: taskId={}_img", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("系统重启:分镜图积分返还失败(可能未冻结): taskId={}_img", taskQueue.getTaskId());
}
} else {
userService.returnFrozenPoints(taskQueue.getTaskId());
}
} catch (Exception e) {
logger.debug("返还积分失败(可能未冻结): taskId={}", taskQueue.getTaskId());
}
@@ -483,6 +499,19 @@ public class TaskQueueService {
logger.warn("记录错误日志失败: {}", task.getTaskId());
}
}
// 返还分镜任务冻结的积分_img 和 _vid
try {
userService.returnFrozenPoints(task.getTaskId() + "_vid");
logger.debug("系统重启:已返还分镜视频积分: taskId={}_vid", task.getTaskId());
} catch (Exception e) {
logger.debug("系统重启:分镜视频积分返还失败(可能未冻结): taskId={}_vid", task.getTaskId());
}
try {
userService.returnFrozenPoints(task.getTaskId() + "_img");
logger.debug("系统重启:已返还分镜图积分: taskId={}_img", task.getTaskId());
} catch (Exception e) {
logger.debug("系统重启:分镜图积分返还失败(可能未冻结): taskId={}_img", task.getTaskId());
}
businessTaskCleanedCount++;
logger.warn("系统重启:分镜视频任务 {} 已超时,标记为失败", task.getTaskId());
} else if (noRealTaskId) {
@@ -508,6 +537,13 @@ public class TaskQueueService {
logger.warn("记录错误日志失败: {}", task.getTaskId());
}
}
// 返还分镜图冻结的积分(分镜图阶段失败,只有 _img 积分)
try {
userService.returnFrozenPoints(task.getTaskId() + "_img");
logger.debug("系统重启:已返还分镜图积分: taskId={}_img", task.getTaskId());
} catch (Exception e) {
logger.debug("系统重启:分镜图积分返还失败(可能未冻结): taskId={}_img", task.getTaskId());
}
businessTaskCleanedCount++;
logger.warn("系统重启:分镜视频任务 {} 无外部任务ID标记为失败", task.getTaskId());
} else {
@@ -636,10 +672,16 @@ public class TaskQueueService {
Integer requiredPoints = calculateRequiredPoints(taskType);
// 冻结积分
PointsFreezeRecord.TaskType freezeTaskType = convertTaskType(taskType);
userService.freezePoints(username, taskId, freezeTaskType, requiredPoints,
"任务提交冻结积分 - " + taskType.getDescription());
// 注意:分镜视频任务的积分已在 StoryboardVideoService 中使用 _img 和 _vid 后缀冻结
// 这里跳过分镜视频任务的积分冻结,避免重复冻结
if (taskType != TaskQueue.TaskType.STORYBOARD_VIDEO) {
PointsFreezeRecord.TaskType freezeTaskType = convertTaskType(taskType);
userService.freezePoints(username, taskId, freezeTaskType, requiredPoints,
"任务提交冻结积分 - " + taskType.getDescription());
} else {
logger.info("分镜视频任务 {} 跳过积分冻结(已在业务层冻结)", taskId);
}
// 创建新的队列任务
TaskQueue taskQueue = new TaskQueue(username, taskId, taskType);
@@ -790,8 +832,22 @@ public class TaskQueueService {
// 返还冻结的积分
try {
// 分镜视频使用 taskId + "_vid" 作为积分冻结ID
// 分镜图使用 taskId + "_img" 作为积分冻结ID
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
userService.returnFrozenPoints(taskId + "_vid");
// 尝试返还视频积分
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception e) {
logger.debug("分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskId);
}
// 同时尝试返还分镜图积分(如果分镜图阶段失败)
try {
userService.returnFrozenPoints(taskId + "_img");
logger.info("已返还分镜图积分: taskId={}_img", taskId);
} catch (Exception e) {
logger.debug("分镜图积分返还失败(可能未冻结或已返还): taskId={}_img", taskId);
}
} else {
userService.returnFrozenPoints(taskId);
}
@@ -1135,29 +1191,30 @@ public class TaskQueueService {
// 获取分镜图(取第一张)
String storyboardImage = images.get(0);
// 解析用户上传的图片
// 解析视频阶段用户上传的参考图(优先使用 videoReferenceImages 字段)
List<String> userUploadedImages = new ArrayList<>();
String uploadedImagesJson = task.getUploadedImages();
if (uploadedImagesJson != null && !uploadedImagesJson.isEmpty()) {
String videoRefImagesJson = task.getVideoReferenceImages();
if (videoRefImagesJson != null && !videoRefImagesJson.isEmpty()) {
try {
List<String> parsedUploads = objectMapper.readValue(uploadedImagesJson,
List<String> parsedUploads = objectMapper.readValue(videoRefImagesJson,
objectMapper.getTypeFactory().constructCollectionType(List.class, String.class));
for (String img : parsedUploads) {
if (img != null && !img.isEmpty() && !"null".equals(img)) {
userUploadedImages.add(img);
}
}
logger.info("解析用户上传图片,有效数量: {}", userUploadedImages.size());
logger.info("解析视频阶段参考图,有效数量: {}", userUploadedImages.size());
} catch (Exception e) {
logger.warn("解析用户上传图片失败: {}", e.getMessage());
logger.warn("解析视频阶段参考图失败: {}", e.getMessage());
}
}
// 注意:不再使用 uploadedImages分镜图阶段的参考图只使用 videoReferenceImages
// 生成视频时进行拼图处理(分镜图 + 用户上传图片
// 生成视频时进行拼图处理(分镜图 + 视频阶段用户上传的参考图
String imageForVideo;
if (!userUploadedImages.isEmpty()) {
// 有用户上传图片,创建复合布局
logger.info("创建复合布局(分镜图 + {}张用户图片: 目标比例={}", userUploadedImages.size(), task.getAspectRatio());
logger.info("创建复合布局(分镜图 + {}张视频参考图: 目标比例={}", userUploadedImages.size(), task.getAspectRatio());
try {
imageForVideo = imageGridService.createStoryboardLayout(storyboardImage, userUploadedImages, task.getAspectRatio());
} catch (Exception e) {
@@ -1166,7 +1223,7 @@ public class TaskQueueService {
}
} else {
// 无用户上传图片,创建简单布局(分镜图适配目标比例)
logger.info("创建简单布局(无用户图片: 目标比例={}", task.getAspectRatio());
logger.info("创建简单布局(无视频参考图: 目标比例={}", task.getAspectRatio());
try {
imageForVideo = imageGridService.createStoryboardLayout(storyboardImage, null, task.getAspectRatio());
} catch (Exception e) {
@@ -1440,20 +1497,22 @@ public class TaskQueueService {
}
/**
* 获取待检查任务列表(只读事务,快速完成)
* 获取待检查任务列表(使用 TransactionTemplate 手动管理事务,快速完成)
*/
@Transactional(readOnly = true)
public List<TaskQueue> getTasksToCheck() {
return taskQueueRepository.findTasksToCheck();
return readOnlyTransactionTemplate.execute(status -> {
return taskQueueRepository.findTasksToCheck();
});
}
/**
* 检查是否有待处理的任务(快速检查,只统计数量)
* @return true 如果有待处理任务false 否则
*/
@Transactional(readOnly = true)
public boolean hasTasksToCheck() {
return taskQueueRepository.countTasksToCheck() > 0;
return readOnlyTransactionTemplate.execute(status -> {
return taskQueueRepository.countTasksToCheck() > 0;
});
}
/**
@@ -1898,7 +1957,8 @@ public class TaskQueueService {
private void updateTaskAsCompleted(TaskQueue taskQueue, String resultUrl) {
String taskId = taskQueue.getTaskId();
// 第一步:在事务中完成数据库更新不包含COS上传
// 第一步:在事务中完成核心数据库更新
boolean transactionSuccess = false;
try {
transactionTemplate.executeWithoutResult(status -> {
try {
@@ -1915,6 +1975,7 @@ public class TaskQueueService {
}
if (freshTaskQueue.getStatus() == TaskQueue.QueueStatus.COMPLETED) {
logger.info("任务已完成,跳过重复处理: {}", taskId);
return;
}
@@ -1934,39 +1995,6 @@ public class TaskQueueService {
logger.warn("积分扣除失败(不影响任务完成): taskId={}, error={}", taskId, e.getMessage());
}
// 更新原始任务状态先用原始URL
updateOriginalTaskStatus(taskQueue, "COMPLETED", resultUrl, null);
// 创建/更新用户作品
if (resultUrl != null && !resultUrl.isEmpty()) {
try {
userWorkService.createWorkFromTask(taskId, resultUrl);
} catch (Exception workException) {
if (workException.getMessage() == null ||
(!workException.getMessage().contains("已存在") &&
!workException.getMessage().contains("Duplicate entry"))) {
logger.warn("创建/更新用户作品失败: {}", taskId, workException);
}
}
}
// 更新 task_status 表中的状态
if (resultUrl != null && !resultUrl.isEmpty()) {
try {
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
if (taskStatus != null) {
taskStatus.setStatus(TaskStatus.Status.COMPLETED);
taskStatus.setProgress(100);
taskStatus.setResultUrl(resultUrl);
taskStatus.setCompletedAt(java.time.LocalDateTime.now());
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
logger.info("task_status 表状态已更新为 COMPLETED: {}", taskId);
}
} catch (Exception statusException) {
logger.warn("更新 task_status 状态失败: {}", taskId, statusException);
}
}
// 任务完成后从 task_queue 中删除记录
try {
taskQueueRepository.delete(freshTaskQueue);
@@ -1980,20 +2008,20 @@ public class TaskQueueService {
throw e;
}
});
transactionSuccess = true;
} catch (Exception e) {
logger.error("执行更新任务完成状态事务失败: {}", taskId, e);
return; // 事务失败不继续COS上传
}
// 第二步在事务外执行COS上传避免事务超时
String finalResultUrl = resultUrl;
if (resultUrl != null && !resultUrl.isEmpty() && cosService.isEnabled()) {
try {
logger.info("开始上传视频到COS事务外: taskId={}", taskId);
String cosUrl = cosService.uploadVideoFromUrl(resultUrl, null);
if (cosUrl != null && !cosUrl.isEmpty()) {
logger.info("COS上传成功: taskId={}, cosUrl={}", taskId, cosUrl);
// 用新事务更新URL为COS URL
updateResultUrlToCos(taskQueue, cosUrl);
finalResultUrl = cosUrl;
} else {
logger.warn("COS上传返回空URL使用原始URL: taskId={}", taskId);
}
@@ -2001,24 +2029,75 @@ public class TaskQueueService {
logger.error("上传视频到COS失败使用原始URL: taskId={}", taskId, cosException);
}
}
// 第三步更新业务表和UserWork使用级联更新避免锁竞争
if (finalResultUrl != null && !finalResultUrl.isEmpty()) {
try {
// 通过 TaskStatusPollingService 的级联更新来更新所有相关表
// 这样可以避免多个事务同时更新同一条记录
boolean updated = taskStatusPollingService.updateTaskStatusWithCascade(
taskId,
TaskStatus.Status.COMPLETED,
finalResultUrl,
null
);
if (updated) {
logger.info("任务完成状态已通过级联更新: taskId={}", taskId);
} else {
// 级联更新失败,回退到直接更新
logger.warn("级联更新失败,回退到直接更新: taskId={}", taskId);
updateOriginalTaskStatus(taskQueue, "COMPLETED", finalResultUrl, null);
try {
userWorkService.createWorkFromTask(taskId, finalResultUrl);
} catch (Exception e) {
logger.warn("创建/更新用户作品失败: {}", taskId, e);
}
}
} catch (Exception e) {
logger.error("更新任务完成状态失败: {}", taskId, e);
}
}
}
/**
* 更新结果URL为COS URL独立事务
* 只更新URL字段不重复更新状态
*/
private void updateResultUrlToCos(TaskQueue taskQueue, String cosUrl) {
String taskId = taskQueue.getTaskId();
try {
transactionTemplate.executeWithoutResult(status -> {
try {
// 更新原始任务的resultUrl
updateOriginalTaskStatus(taskQueue, "COMPLETED", cosUrl, null);
// 直接更新业务表的resultUrl不通过updateOriginalTaskStatus避免重复更新
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setResultUrl(cosUrl);
// 同时更新videoUrls
if (!cosUrl.startsWith("data:image")) {
task.setVideoUrls("[\"" + cosUrl + "\"]");
}
storyboardVideoTaskRepository.save(task);
logger.info("已更新StoryboardVideoTask的COS URL: taskId={}", taskId);
});
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.IMAGE_TO_VIDEO) {
imageToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setResultUrl(cosUrl);
imageToVideoTaskRepository.save(task);
logger.info("已更新ImageToVideoTask的COS URL: taskId={}", taskId);
});
} else if (taskQueue.getTaskType() == TaskQueue.TaskType.TEXT_TO_VIDEO) {
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setResultUrl(cosUrl);
textToVideoTaskRepository.save(task);
logger.info("已更新TextToVideoTask的COS URL: taskId={}", taskId);
});
}
// 更新用户作品的resultUrl
// 更新用户作品的resultUrl(使用简单更新,不创建新记录)
try {
userWorkService.createWorkFromTask(taskId, cosUrl);
userWorkService.updateWorkResultUrl(taskId, cosUrl);
} catch (Exception e) {
// 忽略
logger.warn("更新UserWork COS URL失败: {}", taskId, e);
}
// 更新task_status的resultUrl
@@ -2113,10 +2192,15 @@ public class TaskQueueService {
imageToVideoTaskRepository.save(task);
logger.info("回退更新图生视频任务为完成: {}", taskId);
});
} else if (taskId.startsWith("storyboard_")) {
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setStatus(StoryboardVideoTask.TaskStatus.COMPLETED);
task.setResultUrl(resultUrl);
// 如果是视频URL不是Base64图片同时设置videoUrls
if (resultUrl != null && !resultUrl.startsWith("data:image")) {
task.setVideoUrls("[\"" + resultUrl + "\"]");
logger.info("回退更新时设置videoUrls: taskId={}", taskId);
}
task.setProgress(100);
task.setCompletedAt(java.time.LocalDateTime.now());
storyboardVideoTaskRepository.save(task);
@@ -2177,7 +2261,14 @@ public class TaskQueueService {
imageToVideoTaskRepository.save(task);
logger.info("回退更新图生视频任务为失败: {}", taskId);
});
} else if (taskId.startsWith("storyboard_")) {
// 返还冻结的积分
try {
userService.returnFrozenPoints(taskId);
logger.info("回退更新时已返还积分: taskId={}", taskId);
} catch (Exception e) {
logger.debug("回退更新时积分返还失败(可能未冻结): taskId={}", taskId);
}
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setStatus(StoryboardVideoTask.TaskStatus.FAILED);
task.setErrorMessage(errorMessage);
@@ -2185,6 +2276,19 @@ public class TaskQueueService {
storyboardVideoTaskRepository.save(task);
logger.info("回退更新分镜视频任务为失败: {}", taskId);
});
// 分镜任务需要同时返还 _vid 和 _img 积分
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("回退更新时已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception e) {
logger.debug("回退更新时分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskId);
}
try {
userService.returnFrozenPoints(taskId + "_img");
logger.info("回退更新时已返还分镜图积分: taskId={}_img", taskId);
} catch (Exception e) {
logger.debug("回退更新时分镜图积分返还失败(可能未冻结): taskId={}_img", taskId);
}
} else if (taskId.startsWith("txt2vid_")) {
textToVideoTaskRepository.findByTaskId(taskId).ifPresent(task -> {
task.setStatus(TextToVideoTask.TaskStatus.FAILED);
@@ -2193,6 +2297,13 @@ public class TaskQueueService {
textToVideoTaskRepository.save(task);
logger.info("回退更新文生视频任务为失败: {}", taskId);
});
// 返还冻结的积分
try {
userService.returnFrozenPoints(taskId);
logger.info("回退更新时已返还积分: taskId={}", taskId);
} catch (Exception e) {
logger.debug("回退更新时积分返还失败(可能未冻结): taskId={}", taskId);
}
}
// 更新 UserWork
@@ -2211,7 +2322,7 @@ public class TaskQueueService {
taskType = "IMAGE_TO_VIDEO";
username = imageToVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
} else if (taskId.startsWith("storyboard_")) {
} else if (taskId.startsWith("storyboard_") || taskId.startsWith("sb_")) {
taskType = "STORYBOARD_VIDEO";
username = storyboardVideoTaskRepository.findByTaskId(taskId)
.map(t -> t.getUsername()).orElse(null);
@@ -2254,8 +2365,22 @@ public class TaskQueueService {
// 返还冻结的积分
try {
// 分镜视频使用 taskId + "_vid" 作为积分冻结ID
// 分镜图使用 taskId + "_img" 作为积分冻结ID
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_vid");
// 尝试返还视频积分
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskQueue.getTaskId());
}
// 同时尝试返还分镜图积分(如果分镜图阶段失败)
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_img");
logger.info("已返还分镜图积分: taskId={}_img", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("分镜图积分返还失败(可能未冻结或已返还): taskId={}_img", taskQueue.getTaskId());
}
} else {
userService.returnFrozenPoints(taskQueue.getTaskId());
}
@@ -2334,7 +2459,23 @@ public class TaskQueueService {
taskQueueRepository.save(taskQueue);
// 返还冻结的积分
userService.returnFrozenPoints(taskQueue.getTaskId());
// 分镜视频任务需要同时返还 _vid 和 _img 积分
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskQueue.getTaskId());
}
try {
userService.returnFrozenPoints(taskQueue.getTaskId() + "_img");
logger.info("已返还分镜图积分: taskId={}_img", taskQueue.getTaskId());
} catch (Exception e) {
logger.debug("分镜图积分返还失败(可能未冻结): taskId={}_img", taskQueue.getTaskId());
}
} else {
userService.returnFrozenPoints(taskQueue.getTaskId());
}
// 更新原始任务状态
updateOriginalTaskStatus(taskQueue, "FAILED", null, "任务处理超时");
@@ -2480,6 +2621,20 @@ public class TaskQueueService {
if (taskOpt.isPresent()) {
StoryboardVideoTask task = taskOpt.get();
if ("COMPLETED".equals(status)) {
// 保存视频URL到videoUrls字段JSON数组格式
if (resultUrl != null && !resultUrl.isEmpty() && !resultUrl.startsWith("data:image")) {
// 视频URL保存到videoUrls
try {
ObjectMapper mapper = new ObjectMapper();
String videoUrlsJson = mapper.writeValueAsString(java.util.List.of(resultUrl));
task.setVideoUrls(videoUrlsJson);
logger.info("已设置videoUrls: taskId={}, videoUrls={}", taskId, videoUrlsJson);
} catch (Exception e) {
// 如果JSON序列化失败直接保存原始URL
task.setVideoUrls("[\"" + resultUrl + "\"]");
logger.warn("JSON序列化失败使用简单格式: taskId={}", taskId);
}
}
// resultUrl 现在存储的是视频URL替换之前的分镜图Base64
task.setResultUrl(resultUrl);
task.setRealTaskId(taskQueue.getRealTaskId());
@@ -2540,7 +2695,23 @@ public class TaskQueueService {
taskQueueRepository.save(taskQueue);
// 返还冻结的积分
userService.returnFrozenPoints(taskId);
// 分镜视频任务需要同时返还 _vid 和 _img 积分
if (taskQueue.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception e) {
logger.debug("分镜视频积分返还失败(可能未冻结): taskId={}_vid", taskId);
}
try {
userService.returnFrozenPoints(taskId + "_img");
logger.info("已返还分镜图积分: taskId={}_img", taskId);
} catch (Exception e) {
logger.debug("分镜图积分返还失败(可能未冻结): taskId={}_img", taskId);
}
} else {
userService.returnFrozenPoints(taskId);
}
// 更新原始任务状态
updateOriginalTaskStatus(taskQueue, "CANCELLED", null, "用户取消了任务");
@@ -2651,10 +2822,26 @@ public class TaskQueueService {
updateRelatedTaskStatus(task.getTaskId(), task.getTaskType());
// 返还冻结积分
try {
userService.returnFrozenPoints(task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
// 分镜视频任务需要同时返还 _vid 和 _img 积分
if (task.getTaskType() == TaskQueue.TaskType.STORYBOARD_VIDEO) {
try {
userService.returnFrozenPoints(task.getTaskId() + "_vid");
logger.info("已返还分镜视频积分: taskId={}_vid", task.getTaskId());
} catch (Exception e) {
logger.warn("分镜视频积分返还失败(可能已处理): taskId={}_vid", task.getTaskId());
}
try {
userService.returnFrozenPoints(task.getTaskId() + "_img");
logger.info("已返还分镜图积分: taskId={}_img", task.getTaskId());
} catch (Exception e) {
logger.warn("分镜图积分返还失败(可能已处理): taskId={}_img", task.getTaskId());
}
} else {
try {
userService.returnFrozenPoints(task.getTaskId());
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能已处理): taskId={}", task.getTaskId());
}
}
logger.warn("队列任务超时,已标记为失败: taskId={}, taskType={}, createdAt={}",

View File

@@ -15,6 +15,7 @@ import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.example.demo.model.TaskStatus;
import com.example.demo.model.UserWork;
import com.example.demo.repository.TaskStatusRepository;
import com.example.demo.config.DynamicApiConfig;
import com.fasterxml.jackson.databind.JsonNode;
@@ -63,6 +64,9 @@ public class TaskStatusPollingService {
@Autowired
private DynamicApiConfig dynamicApiConfig;
@Autowired
private UserWorkService userWorkService;
/**
* 系统启动时恢复处理中的任务
* - 对所有 PROCESSING 状态的任务进行一次状态查询
@@ -611,6 +615,11 @@ public class TaskStatusPollingService {
}
if (resultUrl != null) {
task.setResultUrl(resultUrl);
// 如果是视频URL不是Base64图片同时设置videoUrls
if (status == TaskStatus.Status.COMPLETED && !resultUrl.startsWith("data:image")) {
task.setVideoUrls("[\"" + resultUrl + "\"]");
logger.info("已设置videoUrls: taskId={}", taskId);
}
}
if (status == TaskStatus.Status.COMPLETED) {
task.setProgress(100);
@@ -683,42 +692,92 @@ public class TaskStatusPollingService {
}
}
// 如果是失败状态,记录错误日志
if (status == TaskStatus.Status.FAILED && errorMessage != null) {
// 如果是失败状态,返还冻结的积分并记录错误日志
if (status == TaskStatus.Status.FAILED) {
// 返还冻结的积分
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
);
if (taskId.startsWith("sb_") || taskId.startsWith("storyboard_")) {
// 分镜任务需要同时返还 _vid 和 _img 积分
try {
userService.returnFrozenPoints(taskId + "_vid");
logger.info("级联更新时已返还分镜视频积分: taskId={}_vid", taskId);
} catch (Exception e) {
logger.debug("级联更新时分镜视频积分返还失败(可能未冻结或已返还): taskId={}_vid", taskId);
}
try {
userService.returnFrozenPoints(taskId + "_img");
logger.info("级联更新时已返还分镜图积分: taskId={}_img", taskId);
} catch (Exception e) {
logger.debug("级联更新时分镜图积分返还失败(可能未冻结或已返还): taskId={}_img", taskId);
}
} else {
logger.warn("无法记录错误日志,未找到用户名: taskId={}", taskId);
// 其他任务类型直接使用 taskId
userService.returnFrozenPoints(taskId);
logger.info("级联更新时已返还积分: taskId={}", taskId);
}
} catch (Exception e) {
logger.debug("级联更新时积分返还失败(可能未冻结或已返还): taskId={}", taskId);
}
// 记录错误日志
if (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());
}
} catch (Exception logException) {
logger.warn("记录错误日志失败: taskId={}, error={}", taskId, logException.getMessage());
}
}
// 同步 UserWork 状态
try {
UserWork.WorkStatus workStatus = convertToUserWorkStatus(status);
userWorkService.updateWorkStatusWithResult(taskId, workStatus, resultUrl);
logger.info("已同步 UserWork 状态: taskId={}, status={}", taskId, workStatus);
} catch (Exception e) {
logger.warn("同步 UserWork 状态失败: taskId={}, error={}", taskId, e.getMessage());
}
}
/**
* 将 TaskStatus.Status 转换为 UserWork.WorkStatus
*/
private UserWork.WorkStatus convertToUserWorkStatus(TaskStatus.Status status) {
return switch (status) {
case PENDING -> UserWork.WorkStatus.PENDING;
case PROCESSING -> UserWork.WorkStatus.PROCESSING;
case COMPLETED -> UserWork.WorkStatus.COMPLETED;
case FAILED -> UserWork.WorkStatus.FAILED;
default -> UserWork.WorkStatus.PROCESSING;
};
}
/**

View File

@@ -41,6 +41,9 @@ public class TextToVideoService {
@Autowired
private UserWorkService userWorkService;
@Autowired
private UserService userService;
@Autowired
private TaskStatusPollingService taskStatusPollingService;
@@ -196,6 +199,8 @@ public class TextToVideoService {
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
}
}
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
return CompletableFuture.completedFuture(null); // 直接返回,不进行轮询
}
@@ -231,6 +236,9 @@ public class TextToVideoService {
}
} catch (Exception saveException) {
logger.error("保存失败状态时出错: {}", task.getTaskId(), saveException);
} finally {
// 确保无论如何都返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
}
}
@@ -257,6 +265,8 @@ public class TextToVideoService {
TextToVideoTask currentTask = taskRepository.findByTaskId(task.getTaskId()).orElse(null);
if (currentTask != null && currentTask.getStatus() == TextToVideoTask.TaskStatus.CANCELLED) {
logger.info("任务 {} 已被取消,停止轮询", task.getTaskId());
// 任务取消时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
return;
}
@@ -329,6 +339,8 @@ public class TextToVideoService {
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
}
}
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
logger.error("文生视频任务失败: {}", task.getTaskId());
return;
} else if ("processing".equals(status) || "pending".equals(status) || "running".equals(status)) {
@@ -370,10 +382,14 @@ public class TextToVideoService {
logger.warn("回退更新UserWork状态失败: taskId={}", task.getTaskId());
}
}
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
logger.error("文生视频任务超时: {}", task.getTaskId());
} catch (InterruptedException e) {
logger.error("轮询任务状态被中断: {}", task.getTaskId(), e);
// 线程中断时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
Thread.currentThread().interrupt();
} catch (Exception e) {
logger.error("轮询任务状态异常: {}", task.getTaskId(), e);
@@ -381,6 +397,8 @@ public class TextToVideoService {
if (e.getCause() != null) {
logger.error("异常原因: {}", e.getCause().getMessage());
}
// 轮询异常时返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
}
}
@@ -421,9 +439,10 @@ public class TextToVideoService {
if (page < 0) {
page = 0;
}
if (size <= 0 || size > 100) {
size = 10; // 默认每页10条最大100条
if (size <= 0) {
size = 10; // 默认每页10条
}
// 移除size上限限制允许获取全部历史记录
Pageable pageable = PageRequest.of(page, size);
Page<TextToVideoTask> taskPage = taskRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
@@ -547,6 +566,9 @@ public class TextToVideoService {
}
}
// 返还冻结积分
returnFrozenPointsSafely(task.getTaskId());
logger.warn("文生视频任务超时,已标记为失败: taskId={}", task.getTaskId());
handledCount++;
@@ -631,4 +653,16 @@ public class TextToVideoService {
return task;
}
/**
* 安全返还冻结积分(捕获异常,避免影响主流程)
*/
private void returnFrozenPointsSafely(String taskId) {
try {
userService.returnFrozenPoints(taskId);
logger.info("已返还冻结积分: taskId={}", taskId);
} catch (Exception e) {
logger.warn("返还冻结积分失败(可能未冻结): taskId={}, error={}", taskId, e.getMessage());
}
}
}

View File

@@ -7,6 +7,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
@@ -36,7 +37,7 @@ public class UserService {
private final CacheManager cacheManager;
private final MembershipLevelRepository membershipLevelRepository;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder,
public UserService(UserRepository userRepository, @Lazy PasswordEncoder passwordEncoder,
PointsFreezeRecordRepository pointsFreezeRecordRepository,
com.example.demo.repository.OrderRepository orderRepository,
com.example.demo.repository.PaymentRepository paymentRepository,
@@ -364,15 +365,20 @@ public class UserService {
/**
* 返还冻结的积分(任务失败)
* 使用悲观锁防止并发重复返还
* 使用 REQUIRES_NEW 传播行为,防止异常导致外部事务回滚
* 如果积分已经返还或扣除,则静默返回(幂等操作)
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void returnFrozenPoints(String taskId) {
PointsFreezeRecord record = pointsFreezeRecordRepository.findByTaskId(taskId)
// 使用悲观写锁查询,防止并发重复返还
PointsFreezeRecord record = pointsFreezeRecordRepository.findByTaskIdWithLock(taskId)
.orElseThrow(() -> new RuntimeException("找不到冻结记录: " + taskId));
// 如果状态不是 FROZEN说明已经处理过静默返回幂等操作
if (record.getStatus() != PointsFreezeRecord.FreezeStatus.FROZEN) {
throw new RuntimeException("冻结记录状态不正确: " + record.getStatus());
logger.debug("积分记录状态为 {},跳过返还: taskId={}", record.getStatus(), taskId);
return;
}
User user = userRepository.findByUsername(record.getUsername())

View File

@@ -294,6 +294,7 @@ public class UserWorkService {
work.setPointsCost(task.getCostPoints());
work.setStatus(UserWork.WorkStatus.COMPLETED);
work.setCompletedAt(LocalDateTime.now());
work.setUploadedImages(task.getUploadedImages()); // 同步用户上传的参考图,用于"做同款"
work = userWorkRepository.save(work);
logger.info("创建分镜视频作品成功: {}, 用户: {}, 分镜图: {}", work.getId(), work.getUsername(), task.getResultUrl() != null ? "" : "");
@@ -334,12 +335,59 @@ public class UserWorkService {
work.setPointsCost(0); // 分镜图不单独扣费
work.setStatus(UserWork.WorkStatus.COMPLETED);
work.setCompletedAt(LocalDateTime.now());
work.setUploadedImages(task.getUploadedImages()); // 同步用户上传的参考图,用于"做同款"
work = userWorkRepository.save(work);
logger.info("创建分镜图作品成功: {}, 用户: {}", work.getId(), work.getUsername());
return work;
}
/**
* 更新分镜视频作品状态为分镜图完成
* 分镜图生成完成后调用,将 PROCESSING 状态改为 COMPLETED
* 这样主页不会显示"生成中"的轮询状态
*/
@Transactional
public void updateStoryboardVideoWorkToImageCompleted(String taskId, String imageUrl) {
Optional<UserWork> workOpt = userWorkRepository.findByTaskId(taskId);
if (workOpt.isPresent()) {
UserWork work = workOpt.get();
// 只更新 PROCESSING 状态的分镜视频作品
if (work.getWorkType() == UserWork.WorkType.STORYBOARD_VIDEO
&& work.getStatus() == UserWork.WorkStatus.PROCESSING) {
work.setStatus(UserWork.WorkStatus.COMPLETED);
work.setResultUrl(imageUrl); // 暂时设置为分镜图URL
work.setThumbnailUrl(imageUrl);
work.setCompletedAt(LocalDateTime.now());
userWorkRepository.save(work);
logger.info("分镜视频作品状态已更新为分镜图完成: taskId={}, workId={}", taskId, work.getId());
}
} else {
logger.warn("未找到分镜视频作品记录: taskId={}", taskId);
}
}
/**
* 更新分镜视频作品状态为视频生成中
* 用户点击"生成视频"后调用,将 COMPLETED 状态改回 PROCESSING
*/
@Transactional
public void updateStoryboardVideoWorkToProcessing(String taskId) {
Optional<UserWork> workOpt = userWorkRepository.findByTaskId(taskId);
if (workOpt.isPresent()) {
UserWork work = workOpt.get();
// 只更新分镜视频作品
if (work.getWorkType() == UserWork.WorkType.STORYBOARD_VIDEO) {
work.setStatus(UserWork.WorkStatus.PROCESSING);
work.setCompletedAt(null); // 清除完成时间
userWorkRepository.save(work);
logger.info("分镜视频作品状态已更新为视频生成中: taskId={}, workId={}", taskId, work.getId());
}
} else {
logger.warn("未找到分镜视频作品记录: taskId={}", taskId);
}
}
/**
* 根据用户名获取用户ID
*/
@@ -451,6 +499,7 @@ public class UserWorkService {
/**
* 删除作品(软删除)
* 如果是分镜图作品,同时删除对应的分镜视频作品
*/
@Transactional
public boolean deleteWork(Long workId, String username) {
@@ -466,6 +515,30 @@ public class UserWorkService {
int result = userWorkRepository.softDeleteWork(workId, username, LocalDateTime.now());
logger.info("删除作品成功: {}, 用户: {}", workId, username);
// 如果是分镜图作品task_id 以 _image 结尾),同时删除对应的分镜视频作品
String taskId = work.getTaskId();
if (taskId != null && taskId.endsWith("_image")) {
String videoTaskId = taskId.replace("_image", "");
Optional<UserWork> videoWorkOpt = userWorkRepository.findByTaskId(videoTaskId);
if (videoWorkOpt.isPresent()) {
UserWork videoWork = videoWorkOpt.get();
userWorkRepository.softDeleteWork(videoWork.getId(), username, LocalDateTime.now());
logger.info("同时删除分镜视频作品: {}, taskId: {}", videoWork.getId(), videoTaskId);
}
}
// 如果是分镜视频作品,同时删除对应的分镜图作品
if (taskId != null && work.getWorkType() == UserWork.WorkType.STORYBOARD_VIDEO) {
String imageTaskId = taskId + "_image";
Optional<UserWork> imageWorkOpt = userWorkRepository.findByTaskId(imageTaskId);
if (imageWorkOpt.isPresent()) {
UserWork imageWork = imageWorkOpt.get();
userWorkRepository.softDeleteWork(imageWork.getId(), username, LocalDateTime.now());
logger.info("同时删除分镜图作品: {}, taskId: {}", imageWork.getId(), imageTaskId);
}
}
return result > 0;
}
@@ -684,6 +757,27 @@ public class UserWorkService {
}
}
/**
* 更新作品状态和结果URL级联更新时使用
*/
@Transactional
public void updateWorkStatusWithResult(String taskId, UserWork.WorkStatus status, String resultUrl) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime completedAt = (status == UserWork.WorkStatus.COMPLETED || status == UserWork.WorkStatus.FAILED) ? now : null;
// 先更新状态
int result = userWorkRepository.updateStatusByTaskId(taskId, status, now, completedAt);
// 如果有结果URL且状态为完成同时更新URL
if (resultUrl != null && !resultUrl.isEmpty() && status == UserWork.WorkStatus.COMPLETED) {
userWorkRepository.updateResultUrlByTaskId(taskId, resultUrl, now);
}
if (result > 0) {
logger.info("更新作品状态和结果成功: taskId={}, status={}, hasResultUrl={}", taskId, status, resultUrl != null);
}
}
/**
* 更新作品结果URL
*/

View File

@@ -21,10 +21,10 @@ spring.h2.console.enabled=false
# DB_PASSWORD=your_secure_password_here
# ============================================
spring.datasource.url=jdbc:mysql://43.156.12.172:3306/aigc_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
spring.datasource.url=jdbc:mysql://localhost:3306/aigc_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
spring.datasource.driverClassName=com.mysql.cj.jdbc.Driver
spring.datasource.username=aigc_platform
spring.datasource.password=jRbHPZbbkdm24yTT
spring.datasource.username=root
spring.datasource.password=177615
# 数据库连接池配置 (生产环境 - 支持50人并发)
spring.datasource.hikari.maximum-pool-size=30
@@ -41,57 +41,75 @@ spring.datasource.hikari.connection-test-query=SELECT 1
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
# 禁用 SQL 脚本自动运行
spring.sql.init.mode=never
spring.sql.init.continue-on-error=true
# Thymeleaf 可启用缓存
spring.thymeleaf.cache=true
# AI API配置 (生产环境)
# 文生视频、图生视频、分镜视频都使用Comfly API
ai.api.base-url=${AI_API_BASE_URL:https://ai.comfly.chat}
ai.api.key=${AI_API_KEY}
ai.api.base-url=https://ai.comfly.chat
ai.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
# 文生图使用Comfly API
ai.image.api.base-url=${AI_IMAGE_API_BASE_URL:https://ai.comfly.chat}
ai.image.api.key=${AI_IMAGE_API_KEY}
ai.image.api.base-url=https://ai.comfly.chat
ai.image.api.key=sk-J9A9c7rr7Y2suarAudmLG1J722ozIIHOweIhsI8QXX68sjMW
# 支付宝配置 (生产环境)
alipay.app-id=${ALIPAY_APP_ID}
alipay.private-key=${ALIPAY_PRIVATE_KEY}
alipay.public-key=${ALIPAY_PUBLIC_KEY}
alipay.app-id=2021006103624219
alipay.private-key=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCFsCSu7FVwLVCsqbZAaxv0jVrIErE45aYahKXutFeDOs7IWvUOzugL3RKMsh5Ndx0mNO6nbcL4AxEFCa4EfZIMgyyCeFnG29o0E8N7zpxH0VAES92yLpwQZHV2M1LraMsfhW7Hk9I0EkC+cElUEMBQL0LrcdjfZpspIC1utKQPpepSRZ5GpYADtgnyu+B6aOSmZk5j6+lZmn2K06H8PMZjop029uN4HfSNBNdl1NIBTEs5Kk4hw/PQm5KmC5u0CkKgmqGXt6hV4zRb3USrEYLGBvLVpCkXzpQXWTACWxy3qqVeE5OiQWkbpWIhhWqybq0gmmOJCv4cusdutEkiFdgfAgMBAAECggEAeaUCbAxt3anOG549ULZldIvey+h+S+hi0QRcPCzq6GTtXU+uZnAMoybgxxcYDaLR6j8F3WE5pBSeOvhI2Jst9qaxLHK4NgM8tGA7Yv9oIs0pww8JRiW1KhFO9GPVEpGDKkZuu7kc7vag5OglQRIQ+6VVfglUrkqd6rj1viMumXEgRKAken+g39lC7/pzkS+6J4/hpD55XZ1jEJq9mj0DTOozlbImg8y1RiyZ/Te3RVsvmF1EgggA7Z5R8+/HvFlh3KZdWWfZHvzYeu4DWJhJu1RNmskCfMIF07O1wkl1+RrrGIDTtWtVN7/Gayirx3w40LmOUrb6FSTfvUsPKN03OQKBgQD0euD2tc4CXtfQYddnvWAZXvhVPteScgigRqB9xqyv0lwASZiH313Mbf0JdQId+7glmyleXPwvDpKzfs2yl9ZUCn5LtFtZkyGMeXf39hTpSyeFf2dLVJd1VFAM53GjMXw5kWi6OUL5lFewE0rLkIqr2oYF5UwlZIDWXaNwmXAQzQKBgQCL/MzKuNsMS733/8XTjopr+HYI0nLgKopclgT7p3BJw+VWY/8BuXmA4dpAZBBIersMtRDa9acxrOUtiEwknj5fGF/NmOqGlRamP+2Gna1+DdqRWqgmSuMAiEJKiSCsCVxXdktHfP6UMa1FwhxaOvLNKGnx7dGRofAKGi9RwfpcmwKBgAEW2xG+VaClE4kWJoOL0HXMeobGtOcuIuOz7Nsim3pdEZPewBM654wVoV79anj/uh5Qxqpo96auBfFOy1PUYVwWf+GOeCm6AhhCIkq0iftQHmj13Fv1kIcxTPoBvfvgKJGJGFJcFvRNuOZL77Vge32wh5BXKTOxcvGBkUzbIiixAoGAO31sXn5egIQzsA/fNz+tLaNCLg+ZSBBsClqqtXN7sa1xadxHA6mZrB7PDGw5y0N0+Dp+dj7NFbw/DLGOgkVJhkoqdIoWqKj1HiOuwnWBxD8I8pqPOO68N36whVJvMw0rU/Pum+vPmJTf6PRL7kB87JjPJUQGupgSFYj5MQp5Zh8CgYEAvNGL16QNKdAHadefwfZaGKNc9Yw66BsKEoG2HshjD2BBUABwmrF2DFB6n5erBChyQM9t2DBnxy4py7zAIArxFWhuDRDvP66D3kZR10r7PuIFzlBomEQDQ8JBHW1m4JEfpR3xlNGqemKsWTliKYMx3kHATAsq+XHw/anMGQq1J1c=
alipay.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlVXTDpQUaXKlYY3980lSH/4O6p/dSu71upk8SF9FKB5FCJgDgoOiIm4QJdAF1JSXNj11Q0CE+JKCc3e0Dq1Scc4pPL93SBGbGckukddQbRBLmblKtkTBnFc4zxakE2moJuGVoWthQIj6nJ2Y+Q63W8jb3mQPCxKLMhlDcWvgcx9zjr+ueWIP2OBEtOv8a4AIrNujG74CgRg7SEfxTo3LHicbr1hCX3w0BBDNiEH1pCsHxBNSsBqMmPwb3klj+/huleyhUPa2wv0EZsaZvKpp9omaLfec5myrfMXa5xxXyLAX+yT8GvkEk85U6pQ1L3VGFQQ+ExnjEQgzZDJ1+huOAwIDAQAB
alipay.server-url=https://openapi.alipay.com/gateway.do
alipay.gateway-url=https://openapi.alipay.com/gateway.do
alipay.charset=UTF-8
alipay.sign-type=RSA2
alipay.domain=${ALIPAY_DOMAIN:https://vionow.com}
alipay.notify-url=${ALIPAY_NOTIFY_URL:https://vionow.com/api/payments/alipay/notify}
alipay.return-url=${ALIPAY_RETURN_URL:https://vionow.com/payment/success}
alipay.domain=https://vionow.com
alipay.notify-url=https://vionow.com/api/payments/alipay/notify
alipay.return-url=https://vionow.com/payment/success
# JWT配置 - 使用环境变量
jwt.secret=${JWT_SECRET}
jwt.expiration=${JWT_EXPIRATION:604800000}
# JWT配置
jwt.secret=mySecretKey123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
jwt.expiration=604800000
# 腾讯云SES配置 (生产环境)
tencent.ses.secret-id=${TENCENT_SES_SECRET_ID}
tencent.ses.secret-key=${TENCENT_SES_SECRET_KEY}
tencent.ses.secret-id=AKIDoaEjFbqxxqZAcv8EE6oZCg2IQPG1fCxm
tencent.ses.secret-key=nR83I79FOSpGcqNo7JXkqnU8g7SjsxuG
tencent.ses.region=ap-hongkong
tencent.ses.from-email=${TENCENT_SES_FROM_EMAIL}
tencent.ses.from-email=newletter@vionow.com
tencent.ses.from-name=AIGC平台
# 邮件模板ID在腾讯云SES控制台创建模板后获取
# 如果未配置或为0将使用开发模式仅记录日志
tencent.ses.template-id=${TENCENT_SES_TEMPLATE_ID}
tencent.ses.template-id=154360
# PayPal配置 (生产环境)
paypal.client-id=Adpi67TvppjhyyWhrALWwJhLFzv5S_vXoUHzWQchqZe48NaONSryg7QHKBubf0PRmkeJoaxGEKV5v9lT
paypal.client-secret=EDzZl-hddwtt2pNt5RpBIICdlrUS8QtcmAttU_kuANL8Vd937SC4xel_K2hArTovVqEtyL2ZS5IcQcQV
paypal.mode=sandbox
paypal.success-url=https://vionow.com/api/payment/paypal/success
paypal.cancel-url=https://vionow.com/api/payment/paypal/cancel
# Tomcat线程池配置 (生产环境 - 支持50人并发)
server.port=8080
server.tomcat.threads.max=150
server.tomcat.threads.min-spare=20
server.tomcat.max-connections=500
server.tomcat.accept-count=100
server.tomcat.connection-timeout=20000
server.tomcat.max-http-post-size=600MB
# 文件上传配置
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=500MB
spring.servlet.multipart.max-request-size=600MB
# 生产环境日志配置
logging.level.root=INFO
logging.level.com.example.demo=INFO
logging.level.com.example.demo.scheduler=WARN
logging.level.com.example.demo.scheduler.OrderScheduler=WARN
logging.level.org.springframework.security=WARN
logging.level.org.springframework.scheduling=WARN
# 关闭 Hibernate SQL 日志
logging.level.org.hibernate.SQL=WARN
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
@@ -114,40 +132,42 @@ app.ffmpeg.path=${FFMPEG_PATH:ffmpeg}
app.upload.path=${UPLOAD_PATH:./uploads}
# SpringDoc OpenAPI (Swagger) 配置
# 生产环境建议禁用或限制访问
springdoc.api-docs.path=/v3/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.enabled=true
springdoc.swagger-ui.operationsSorter=method
springdoc.swagger-ui.tagsSorter=alpha
springdoc.swagger-ui.tryItOutEnabled=true
springdoc.swagger-ui.filter=true
springdoc.swagger-ui.display-request-duration=true
springdoc.swagger-ui.doc-expansion=none
# 生产环境禁用以提高安全性和性能
springdoc.api-docs.enabled=false
springdoc.swagger-ui.enabled=false
# ============================================
# 腾讯云 Redis 配置(生产环境)
# Redis 配置(生产环境 - 已禁用
# ============================================
# 腾讯云 Redis 内网地址(在云数据库 Redis 控制台查看)
spring.data.redis.host=crs-xxxxxxxx.sql.tencentcdb.com
spring.data.redis.port=6379
# Redis 密码(格式可能是:账号:密码 或 仅密码,取决于是否开启免密)
spring.data.redis.password=你的Redis密码
# 不使用 RedisToken 存储依赖 JWT 本身的验证
# 如需启用 Redis设置 redis.enabled=true 并配置连接信息
redis.enabled=false
spring.data.redis.database=0
# spring.data.redis.host=your-redis-host
# spring.data.redis.port=6379
# spring.data.redis.password=your-redis-password
# spring.data.redis.database=0
# 连接池配置
spring.data.redis.lettuce.pool.max-active=16
spring.data.redis.lettuce.pool.max-idle=8
spring.data.redis.lettuce.pool.min-idle=2
spring.data.redis.lettuce.pool.max-wait=3000ms
# 连接超时
spring.data.redis.timeout=5000ms
spring.data.redis.connect-timeout=5000ms
# 禁用 Redis 自动配置
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration
# Token过期时间
redis.token.expire-seconds=86400
# ============================================
# 腾讯云COS对象存储配置 (生产环境)
# ============================================
tencent.cos.enabled=true
# 腾讯云SecretId
tencent.cos.secret-id=AKIDeLqCNODrKafXSAqPrRtCSp9NRwU0Ok5G
# 腾讯云SecretKey
tencent.cos.secret-key=4uZ1Hcu0xiHiy1ucAYnsoZ8WhqqlW5RZ
# COS区域
tencent.cos.region=ap-hongkong
# COS存储桶名称
tencent.cos.bucket-name=aigc-1393834230
# COS文件夹前缀
tencent.cos.prefix=aigc

View File

@@ -1,7 +1,7 @@
spring.application.name=demo
spring.messages.basename=messages
spring.thymeleaf.cache=false
spring.profiles.active=dev
spring.profiles.active=prod
# 服务器配置
server.address=0.0.0.0

View File

@@ -12,4 +12,4 @@
UPDATE users
SET role = 'ROLE_SUPER_ADMIN',
updated_at = CURRENT_TIMESTAMP
WHERE email = '984523799@qq.com';
WHERE email = 'shanghairuiyi2026@163.com';

View File

@@ -0,0 +1,5 @@
-- V14: 为user_works表添加uploaded_images字段
-- 用于存储用户上传的参考图片,支持"做同款"功能恢复原始参考图
ALTER TABLE user_works
ADD COLUMN IF NOT EXISTS uploaded_images LONGTEXT COMMENT '用户上传的参考图片JSON数组用于做同款功能恢复';

View File

@@ -1,15 +0,0 @@
# 支付配置
# 支付宝沙箱配置
alipay.app-id=9021000157616562
alipay.private-key=MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCH7wPeptkJlJuoKwDqxvfJJLTOAWVkHa/TLh+wiy1tEtmwcrOwEU3GuqfkUlhij71WJIZi8KBytCwbax1QGZA/oLXvTCGJJrYrsEL624X5gGCCPKWwHRDhewsQ5W8jFxaaMXxth8GKlSW61PZD2cOQClRVEm2xnWFZ+6/7WBI7082g7ayzGCD2eowXsJyWyuEBCUSbHXkSgxVhqj5wUGIXhr8ly+pdUlJmDX5K8UG2rjJYx+0AU5UZJbOAND7d3iyDsOulHDvth50t8MOWDnDCVJ2aAgUB5FZKtOFxOmzNTsMjvzYldFztF0khbypeeMVL2cxgioIgTvjBkUwd55hZAgMBAAECggEAUjk3pARUoEDt7skkYt87nsW/QCUECY0Tf7AUpxtovON8Hgkju8qbuyvIxokwwV2k72hkiZB33Soyy9r8/iiYYoR5yGfKmUV7R+30df03ivYmamD48BCE138v8GZ31Ufv+hEY7MADSCpzihGrbNtaOdSlslfVVmyWKHHfvy9EyD6yHJGYswLpHXC/QX1TuLRRxk6Uup8qENOG/6zjGWMfxoRZFwTt80ml1mKy32YZGyJqDaQpJcdYwAHOPcnJl1emw4E+oVjiLyksl643npuTkgnZXs1iWcWSS8ojF1w/0kVDzcNh9toLg+HDuQlIHOis01VQ7lYcG4oiMOnhX1QHIQKBgQC9fgBuILjBhuCI9fHvLRdzoNC9heD54YK7xGvEV/mv90k8xcmNx+Yg5C57ASaMRtOq3b7muPiCv5wOtMT4tUCcMIwSrTNlcBM6EoTagnaGfpzOMaHGMXO4vbaw+MIynHnvXFj1rZjG1lzkV/9K36LAaHD9ZKVJaBQ9mK+0CIq/3QKBgQC3pL5GbvXj6/4ahTraXzNDQQpPGVgbHxcOioEXL4ibaOPC58puTW8HDbRvVuhl/4EEOBRVX81BSgkN8XHwTSiZdih2iOqByg+o9kixs7nlFn3Iw9BBP2/g+Wqiyi2N+9g17kfWXXVOKYz/eMXLBeOo4KhQE9wqNGyZldYzX2ywrQKBgApJmvBfqmgnUG1fHOFlS06lvm9ro0ktqxFSmp8wP4gEHt/DxSuDXMUQXk2jRFp9ReSS4VhZVnSSvoA15DO0c2uHXzNsX8v0B7cxZjEOwCyRFyZCn4vJB4VSF2cIOlLRF/Wcx9+eqxqwbJ6hAGUqOwXDJc879ZVEp0So03EsvYupAoGAAnI+Wp/VxLB7FQ1bSFdmTmoKYh1bUBks7HOp3o4yiqduCUWfK7L6XKSxF56Xv+wUYuMAWlbJXCpJTpc9xk6w0MKDLXkLbqkrZjvJohxbyJJxIICDQKtAqUWJRxvcWXzWV3mSGWfrTRw+lZSdReQRMUm01EQ/dYx3OeCGFu8Zeo0CgYAlH5YSYdJxZSoDCJeoTrkxUlFoOg8UQ7SrsaLYLwpwcwpuiWJaTrg6jwFocj+XhjQ9RtRbSBHz2wKSLdl+pXbTbqECKk85zMFl6zG3etXtTJU/dD750Ty4i8zt3+JGhvglPrQBY1CfItgml2oXa/VUVMnLCUS0WSZuPRmPYZD8dg==
alipay.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAksEwzuR3ASrKtTzaANqdQYKOoD44itA1TWG/6onvQr8PHNEMgcguLuJNrdeuT2PDg23byzZ9qKfEM2D5U4zbpt0/uCYLfZQyAnAWWyMvnKPoSIgrtBjnxYK6HE6fuQV3geJTcZxvP/z8dGZB0V0s6a53rzbKSLh0p4w0hWfVXlQihq3Xh4vSKB+ojdhEkIblhpWPT42NPbjVNdwPzIhUGpRy3/nsgNqVBu+ZacQ5/rCvzXU1RE0allBbjcvjymKQTS7bAE0i1Mgo1eX8njvElsfQUv5P7xQdrvZagqtIuTdP19cmsSNGdIC9Z5Po3j0z3KWPR7MrKgDuJfzkWtJR4wIDAQAB
alipay.server-url=https://openapi-sandbox.dl.alipaydev.com/gateway.do
alipay.gateway-url=https://openapi-sandbox.dl.alipaydev.com/gateway.do
alipay.domain=https://vionow.com
alipay.charset=UTF-8
alipay.sign-type=RSA2
alipay.notify-url=https://vionow.com/api/payments/alipay/notify
alipay.return-url=https://vionow.com/api/payments/alipay/return
alipay.app-cert-path=classpath:cert/alipay/appCertPublicKey.crt
alipay.ali-pay-cert-path=classpath:cert/alipay/alipayCertPublicKey_RSA2.crt
alipay.ali-pay-root-cert-path=classpath:cert/alipay/alipayRootCert.crt