feat: 线程池扩容、会员注册和过期逻辑优化、API管理页面显示当前配置
- 线程池扩容:TaskQueueService 10->20, AsyncConfig 核心5->10/最大20->40/队列50->100 - 新用户注册自动创建免费会员记录(永久有效到2099年) - 付费会员过期自动降级为免费会员并清零积分 - API管理页面显示当前API密钥(脱敏)和端点 - 修复StoryboardVideoCreate.vue语法错误
This commit is contained in:
@@ -9,7 +9,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* 异步执行器配置
|
||||
* 支持50人并发处理异步任务(如视频生成、图片处理等)
|
||||
* 支持100-200人并发处理异步任务(如视频生成、图片处理等)
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
@@ -25,11 +25,11 @@ public class AsyncConfig {
|
||||
public Executor taskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
// 核心线程数:保持活跃的最小线程数
|
||||
executor.setCorePoolSize(5);
|
||||
executor.setCorePoolSize(10);
|
||||
// 最大线程数:最大并发执行的任务数
|
||||
executor.setMaxPoolSize(20);
|
||||
executor.setMaxPoolSize(40);
|
||||
// 队列容量:等待执行的任务数
|
||||
executor.setQueueCapacity(50);
|
||||
executor.setQueueCapacity(100);
|
||||
// 线程名前缀
|
||||
executor.setThreadNamePrefix("async-task-");
|
||||
// 拒绝策略:当线程池和队列都满时,使用调用者线程执行(保证任务不丢失)
|
||||
|
||||
@@ -19,8 +19,8 @@ public class PollingConfig implements SchedulingConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureTasks(@NonNull ScheduledTaskRegistrar taskRegistrar) {
|
||||
// 使用自定义线程池执行定时任务(支持50人并发)
|
||||
ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
|
||||
// 使用自定义线程池执行定时任务(支持100-200人并发)
|
||||
ScheduledExecutorService executor = Executors.newScheduledThreadPool(10);
|
||||
taskRegistrar.setScheduler(executor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/orders/stats").permitAll()
|
||||
.requestMatchers("/api/orders/**").authenticated()
|
||||
.requestMatchers("/api/payments/alipay/notify", "/api/payments/alipay/return").permitAll()
|
||||
.requestMatchers("/api/payments/lulupay/notify", "/api/payments/lulupay/return").permitAll()
|
||||
.requestMatchers("/api/payment/paypal/success", "/api/payment/paypal/cancel").permitAll()
|
||||
.requestMatchers("/api/payments/**").authenticated()
|
||||
.requestMatchers("/api/image-to-video/**").authenticated()
|
||||
.requestMatchers("/api/text-to-video/**").authenticated()
|
||||
|
||||
@@ -17,16 +17,23 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.example.demo.model.User;
|
||||
import com.example.demo.model.SystemSettings;
|
||||
import com.example.demo.model.UserMembership;
|
||||
import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.service.RedisTokenService;
|
||||
import com.example.demo.service.SystemSettingsService;
|
||||
import com.example.demo.service.UserService;
|
||||
import com.example.demo.service.VerificationCodeService;
|
||||
import com.example.demo.util.JwtUtils;
|
||||
import com.example.demo.util.UserIdGenerator;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthApiController {
|
||||
@@ -48,6 +55,12 @@ public class AuthApiController {
|
||||
@Autowired
|
||||
private SystemSettingsService systemSettingsService;
|
||||
|
||||
@Autowired
|
||||
private UserMembershipRepository userMembershipRepository;
|
||||
|
||||
@Autowired
|
||||
private MembershipLevelRepository membershipLevelRepository;
|
||||
|
||||
/**
|
||||
* 用户登录(已禁用,仅支持邮箱验证码登录)
|
||||
* 为了向后兼容,保留此接口但返回提示信息
|
||||
@@ -251,6 +264,9 @@ public class AuthApiController {
|
||||
|
||||
// 保存用户(@PrePersist 会自动设置 createdAt 等字段)
|
||||
user = userService.save(user);
|
||||
|
||||
// 为新用户创建默认会员记录(标准会员,到期时间为1年后)
|
||||
createDefaultMembershipForUser(user);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("自动注册用户失败: {}", email, e);
|
||||
return ResponseEntity.badRequest()
|
||||
@@ -594,6 +610,35 @@ public class AuthApiController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为新用户创建默认会员记录(免费会员,永久有效)
|
||||
*/
|
||||
private void createDefaultMembershipForUser(User user) {
|
||||
try {
|
||||
// 查找免费会员等级
|
||||
Optional<MembershipLevel> freeLevel = membershipLevelRepository.findByName("free");
|
||||
if (freeLevel.isEmpty()) {
|
||||
logger.warn("未找到免费会员等级(free),跳过创建会员记录");
|
||||
return;
|
||||
}
|
||||
|
||||
UserMembership membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setMembershipLevelId(freeLevel.get().getId());
|
||||
membership.setStartDate(LocalDateTime.now());
|
||||
membership.setEndDate(LocalDateTime.of(2099, 12, 31, 23, 59, 59)); // 免费会员永久有效
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setAutoRenew(false);
|
||||
membership.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
userMembershipRepository.save(membership);
|
||||
logger.info("✅ 为新用户创建默认会员记录: userId={}, level=免费会员(永久有效)", user.getId());
|
||||
} catch (Exception e) {
|
||||
logger.error("创建默认会员记录失败: userId={}", user.getId(), e);
|
||||
// 不抛出异常,允许用户注册成功
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> createErrorResponse(String message) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.example.demo.service.LuluPayService;
|
||||
import com.example.demo.service.PaymentService;
|
||||
|
||||
/**
|
||||
* 噜噜支付(彩虹易支付)回调控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/payments/lulupay")
|
||||
public class LuluPayCallbackController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LuluPayCallbackController.class);
|
||||
|
||||
@Autowired
|
||||
private LuluPayService luluPayService;
|
||||
|
||||
@Autowired
|
||||
private PaymentService paymentService;
|
||||
|
||||
@Value("${app.frontend-url:}")
|
||||
private String frontendUrl;
|
||||
|
||||
/**
|
||||
* 异步通知接口
|
||||
* 支付平台会通过GET方式调用此接口通知支付结果
|
||||
*/
|
||||
@GetMapping(value = "/notify", produces = MediaType.TEXT_PLAIN_VALUE)
|
||||
public String notifyGet(@RequestParam Map<String, String> params) {
|
||||
return handleNotify(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步通知接口(POST方式)
|
||||
*/
|
||||
@PostMapping(value = "/notify", produces = MediaType.TEXT_PLAIN_VALUE)
|
||||
public String notifyPost(@RequestParam Map<String, String> params) {
|
||||
return handleNotify(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理异步通知
|
||||
*/
|
||||
private String handleNotify(Map<String, String> params) {
|
||||
logger.info("========== 收到噜噜支付异步通知 ==========");
|
||||
logger.info("参数: {}", params);
|
||||
|
||||
try {
|
||||
boolean success = luluPayService.handleNotify(params);
|
||||
|
||||
if (success) {
|
||||
String tradeStatus = params.get("trade_status");
|
||||
String outTradeNo = params.get("out_trade_no");
|
||||
String tradeNo = params.get("trade_no");
|
||||
|
||||
logger.info("噜噜支付验签成功: tradeStatus={}, outTradeNo={}, tradeNo={}", tradeStatus, outTradeNo, tradeNo);
|
||||
|
||||
// 如果支付成功,调用统一的支付确认方法
|
||||
if ("TRADE_SUCCESS".equals(tradeStatus)) {
|
||||
try {
|
||||
// 查找支付记录并确认
|
||||
logger.info("开始查找支付记录: outTradeNo={}", outTradeNo);
|
||||
var paymentOpt = paymentService.findByOrderId(outTradeNo);
|
||||
if (paymentOpt.isPresent()) {
|
||||
var payment = paymentOpt.get();
|
||||
logger.info("找到支付记录: paymentId={}, status={}, orderId={}",
|
||||
payment.getId(), payment.getStatus(), payment.getOrderId());
|
||||
|
||||
// 调用确认方法(内部会检查是否已处理,避免重复增加积分)
|
||||
// 即使状态已经是SUCCESS,也要确保会员信息已更新
|
||||
logger.info("开始调用confirmPaymentSuccess: paymentId={}", payment.getId());
|
||||
paymentService.confirmPaymentSuccess(payment.getId(), tradeNo);
|
||||
logger.info("✅ 支付确认处理完成: 订单号={}", outTradeNo);
|
||||
} else {
|
||||
logger.error("❌ 未找到支付记录: outTradeNo={}", outTradeNo);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ 支付确认失败: outTradeNo={}, error={}", outTradeNo, e.getMessage(), e);
|
||||
}
|
||||
} else {
|
||||
logger.info("交易状态不是TRADE_SUCCESS,不处理积分: tradeStatus={}", tradeStatus);
|
||||
}
|
||||
|
||||
logger.info("========== 噜噜支付异步通知处理成功 ==========");
|
||||
return "success";
|
||||
} else {
|
||||
logger.warn("========== 噜噜支付异步通知处理失败(验签失败) ==========");
|
||||
return "fail";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("处理噜噜支付异步通知异常: ", e);
|
||||
return "fail";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步返回接口
|
||||
* 用户支付完成后跳转到会员订阅页面
|
||||
*/
|
||||
@GetMapping("/return")
|
||||
public ResponseEntity<Void> returnUrl(@RequestParam Map<String, String> params) {
|
||||
logger.info("========== 收到噜噜支付同步返回 ==========");
|
||||
logger.info("参数: {}", params);
|
||||
|
||||
try {
|
||||
boolean success = luluPayService.handleReturn(params);
|
||||
String tradeStatus = params.get("trade_status");
|
||||
String orderId = params.get("out_trade_no");
|
||||
|
||||
logger.info("支付同步返回处理结果: success={}, tradeStatus={}, orderId={}", success, tradeStatus, orderId);
|
||||
|
||||
// 构建重定向URL,跳转到会员订阅页面
|
||||
String redirectUrl = (frontendUrl != null && !frontendUrl.isEmpty() ? frontendUrl : "")
|
||||
+ "/subscription?paymentStatus=" + (success && "TRADE_SUCCESS".equals(tradeStatus) ? "success" : "pending")
|
||||
+ "&orderId=" + (orderId != null ? orderId : "");
|
||||
|
||||
logger.info("重定向到: {}", redirectUrl);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header("Location", redirectUrl)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理噜噜支付同步返回异常: ", e);
|
||||
|
||||
// 出错也跳转到订阅页面
|
||||
String redirectUrl = (frontendUrl != null && !frontendUrl.isEmpty() ? frontendUrl : "")
|
||||
+ "/subscription?paymentStatus=error";
|
||||
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header("Location", redirectUrl)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,61 +247,105 @@ public class MemberApiController {
|
||||
|
||||
logger.info("更新会员等级: userId={}, levelName={}, expiryDate={}", id, levelName, expiryDateStr);
|
||||
|
||||
if (levelName != null) {
|
||||
Optional<MembershipLevel> levelOpt = membershipLevelRepository
|
||||
.findByDisplayName(levelName);
|
||||
// 只要有会员等级或到期时间参数,就需要更新会员信息
|
||||
if (levelName != null || (expiryDateStr != null && !expiryDateStr.isEmpty())) {
|
||||
// 查找或创建会员信息(按到期时间降序,返回最新的)
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository
|
||||
.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
|
||||
logger.info("查找会员等级结果: levelName={}, found={}", levelName, levelOpt.isPresent());
|
||||
UserMembership membership;
|
||||
MembershipLevel level = null;
|
||||
|
||||
if (levelOpt.isPresent()) {
|
||||
MembershipLevel level = levelOpt.get();
|
||||
// 如果传入了会员等级,查找对应的等级
|
||||
if (levelName != null) {
|
||||
// 先尝试精确匹配 displayName
|
||||
Optional<MembershipLevel> levelOpt = membershipLevelRepository.findByDisplayName(levelName);
|
||||
|
||||
// 查找或创建会员信息(按到期时间降序,返回最新的)
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository
|
||||
.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
|
||||
UserMembership membership;
|
||||
if (membershipOpt.isPresent()) {
|
||||
membership = membershipOpt.get();
|
||||
} else {
|
||||
// 创建新的会员记录
|
||||
membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setStartDate(java.time.LocalDateTime.now());
|
||||
// 根据会员等级的 durationDays 计算到期时间
|
||||
int durationDays = level.getDurationDays() != null ? level.getDurationDays() : 365;
|
||||
membership.setEndDate(java.time.LocalDateTime.now().plusDays(durationDays));
|
||||
}
|
||||
|
||||
membership.setMembershipLevelId(level.getId());
|
||||
|
||||
// 更新到期时间
|
||||
if (expiryDateStr != null && !expiryDateStr.isEmpty()) {
|
||||
try {
|
||||
// 尝试解析带时间的格式 (如 2025-12-11T17:03:16)
|
||||
java.time.LocalDateTime expiryDateTime = java.time.LocalDateTime.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDateTime);
|
||||
} catch (Exception e1) {
|
||||
try {
|
||||
// 尝试解析仅日期格式 (如 2025-12-11)
|
||||
java.time.LocalDate expiryDate = java.time.LocalDate.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDate.atStartOfDay());
|
||||
} catch (Exception e2) {
|
||||
logger.warn("日期格式错误: {}", expiryDateStr);
|
||||
// 如果找不到,尝试模糊匹配
|
||||
if (!levelOpt.isPresent()) {
|
||||
List<MembershipLevel> allLevels = membershipLevelRepository.findAll();
|
||||
for (MembershipLevel lvl : allLevels) {
|
||||
String name = lvl.getName();
|
||||
String displayName = lvl.getDisplayName();
|
||||
|
||||
// 匹配 "专业会员" -> "professional" 或 "专业版"
|
||||
if (levelName.contains("专业") && "professional".equalsIgnoreCase(name)) {
|
||||
levelOpt = Optional.of(lvl);
|
||||
break;
|
||||
}
|
||||
// 匹配 "标准会员" -> "standard" 或 "标准会员"
|
||||
if (levelName.contains("标准") && "standard".equalsIgnoreCase(name)) {
|
||||
levelOpt = Optional.of(lvl);
|
||||
break;
|
||||
}
|
||||
// 匹配 "免费会员" -> "free" 或 "免费版"
|
||||
if (levelName.contains("免费") && "free".equalsIgnoreCase(name)) {
|
||||
levelOpt = Optional.of(lvl);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
membership.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
UserMembership saved = userMembershipRepository.save(membership);
|
||||
logger.info("✅ 会员等级已保存: userId={}, membershipId={}, levelId={}, levelName={}",
|
||||
user.getId(), saved.getId(), saved.getMembershipLevelId(), level.getDisplayName());
|
||||
} else {
|
||||
logger.warn("❌ 未找到会员等级: levelName={}", levelName);
|
||||
logger.info("查找会员等级结果: levelName={}, found={}", levelName, levelOpt.isPresent());
|
||||
if (levelOpt.isPresent()) {
|
||||
level = levelOpt.get();
|
||||
} else {
|
||||
logger.warn("❌ 未找到会员等级: levelName={}", levelName);
|
||||
}
|
||||
}
|
||||
|
||||
if (membershipOpt.isPresent()) {
|
||||
membership = membershipOpt.get();
|
||||
logger.info("找到现有会员记录: membershipId={}, currentEndDate={}", membership.getId(), membership.getEndDate());
|
||||
} else {
|
||||
// 创建新的会员记录
|
||||
membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setStartDate(java.time.LocalDateTime.now());
|
||||
// 默认到期时间为1年后
|
||||
membership.setEndDate(java.time.LocalDateTime.now().plusDays(365));
|
||||
logger.info("创建新会员记录");
|
||||
|
||||
// 如果没有指定等级,默认使用标准会员
|
||||
if (level == null) {
|
||||
Optional<MembershipLevel> defaultLevel = membershipLevelRepository.findByName("standard");
|
||||
if (defaultLevel.isPresent()) {
|
||||
level = defaultLevel.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新会员等级
|
||||
if (level != null) {
|
||||
membership.setMembershipLevelId(level.getId());
|
||||
}
|
||||
|
||||
// 更新到期时间
|
||||
if (expiryDateStr != null && !expiryDateStr.isEmpty()) {
|
||||
try {
|
||||
// 尝试解析带时间的格式 (如 2025-12-11T17:03:16)
|
||||
java.time.LocalDateTime expiryDateTime = java.time.LocalDateTime.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDateTime);
|
||||
logger.info("设置到期时间(带时间格式): {}", expiryDateTime);
|
||||
} catch (Exception e1) {
|
||||
try {
|
||||
// 尝试解析仅日期格式 (如 2025-12-11)
|
||||
java.time.LocalDate expiryDate = java.time.LocalDate.parse(expiryDateStr);
|
||||
membership.setEndDate(expiryDate.atTime(23, 59, 59));
|
||||
logger.info("设置到期时间(日期格式): {}", expiryDate.atTime(23, 59, 59));
|
||||
} catch (Exception e2) {
|
||||
logger.warn("日期格式错误: {}", expiryDateStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
membership.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
UserMembership saved = userMembershipRepository.save(membership);
|
||||
logger.info("✅ 会员信息已保存: userId={}, membershipId={}, levelId={}, endDate={}",
|
||||
user.getId(), saved.getId(), saved.getMembershipLevelId(), saved.getEndDate());
|
||||
} else {
|
||||
logger.info("未传入会员等级参数,跳过会员等级更新");
|
||||
logger.info("未传入会员等级和到期时间参数,跳过会员信息更新");
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
@@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
@@ -35,6 +36,9 @@ public class PayPalController {
|
||||
@Autowired
|
||||
private PaymentService paymentService;
|
||||
|
||||
@Value("${app.frontend-url:https://www.vionow.com}")
|
||||
private String frontendUrl;
|
||||
|
||||
/**
|
||||
* 创建PayPal支付
|
||||
* 支持两种模式:
|
||||
@@ -71,6 +75,14 @@ public class PayPalController {
|
||||
return ResponseEntity.badRequest().body(errorResponse);
|
||||
}
|
||||
payment = existingPayment.get();
|
||||
|
||||
// 检查支付状态,如果已成功则不允许重复创建
|
||||
if (payment.getStatus() == PaymentStatus.SUCCESS) {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", "该支付已完成,请勿重复支付");
|
||||
return ResponseEntity.badRequest().body(errorResponse);
|
||||
}
|
||||
} else {
|
||||
// 旧模式:创建新的支付记录
|
||||
String username = (String) request.get("username");
|
||||
@@ -152,11 +164,17 @@ public class PayPalController {
|
||||
logger.info("Payer ID: {}", payerId);
|
||||
logger.info("Token: {}", token);
|
||||
|
||||
// 检查PayPal服务是否可用
|
||||
if (payPalService == null) {
|
||||
logger.error("PayPal服务未配置");
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=error&message=PayPal service not configured");
|
||||
}
|
||||
|
||||
// 获取支付记录
|
||||
Optional<Payment> paymentOpt = paymentService.findById(paymentId);
|
||||
if (!paymentOpt.isPresent()) {
|
||||
logger.error("支付记录不存在: {}", paymentId);
|
||||
return new RedirectView("/payment/error?message=Payment not found");
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=error&message=Payment not found");
|
||||
}
|
||||
|
||||
Payment payment = paymentOpt.get();
|
||||
@@ -173,15 +191,15 @@ public class PayPalController {
|
||||
logger.info("✅ PayPal支付确认成功");
|
||||
|
||||
// 重定向到前端会员订阅页面(支付成功)
|
||||
return new RedirectView("https://vionow.com/subscription?paymentSuccess=true&paymentId=" + paymentId);
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=success&paymentId=" + paymentId);
|
||||
} else {
|
||||
logger.error("PayPal支付执行失败");
|
||||
return new RedirectView("https://vionow.com/?paymentError=true&message=Payment execution failed");
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=error&message=Payment execution failed");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ PayPal支付成功回调处理失败", e);
|
||||
return new RedirectView("https://vionow.com/?paymentError=true&message=" + e.getMessage());
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=error&message=" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,12 +218,12 @@ public class PayPalController {
|
||||
|
||||
logger.info("✅ PayPal支付已取消");
|
||||
|
||||
// 重定向到前端首页(支付已取消)
|
||||
return new RedirectView("https://vionow.com/?paymentCancelled=true&paymentId=" + paymentId);
|
||||
// 重定向到前端会员订阅页面(支付已取消)
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=cancelled&paymentId=" + paymentId);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("❌ PayPal支付取消回调处理失败", e);
|
||||
return new RedirectView("https://vionow.com/?paymentError=true&message=" + e.getMessage());
|
||||
return new RedirectView(frontendUrl + "/subscription?paymentStatus=error&message=" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,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.LuluPayService;
|
||||
import com.example.demo.service.OrderService;
|
||||
import com.example.demo.service.PaymentService;
|
||||
import com.example.demo.service.UserService;
|
||||
@@ -51,6 +52,9 @@ public class PaymentApiController {
|
||||
@Autowired
|
||||
private AlipayService alipayService;
|
||||
|
||||
@Autowired
|
||||
private LuluPayService luluPayService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@@ -591,7 +595,8 @@ public class PaymentApiController {
|
||||
@PostMapping("/alipay/create")
|
||||
public ResponseEntity<Map<String, Object>> createAlipayPayment(
|
||||
@RequestBody Map<String, Object> paymentData,
|
||||
Authentication authentication) {
|
||||
Authentication authentication,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
String username;
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
@@ -614,8 +619,12 @@ public class PaymentApiController {
|
||||
.body(createErrorResponse("无权限操作此支付记录"));
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
String clientIp = getClientIp(request);
|
||||
logger.info("创建支付宝支付,客户端IP: {}", clientIp);
|
||||
|
||||
// 调用支付宝接口创建支付
|
||||
Map<String, Object> paymentResult = alipayService.createPayment(payment);
|
||||
Map<String, Object> paymentResult = alipayService.createPayment(payment, clientIp);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
@@ -635,6 +644,62 @@ public class PaymentApiController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建噜噜支付(彩虹易支付)
|
||||
* 支持多种支付方式:alipay/wxpay/qqpay/bank
|
||||
*/
|
||||
@PostMapping("/lulupay/create")
|
||||
public ResponseEntity<Map<String, Object>> createLuluPayment(
|
||||
@RequestBody Map<String, Object> paymentData,
|
||||
Authentication authentication,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
String username;
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
username = authentication.getName();
|
||||
} else {
|
||||
logger.warn("用户未认证,拒绝支付请求");
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("请先登录后再创建支付"));
|
||||
}
|
||||
|
||||
Long paymentId = Long.valueOf(paymentData.get("paymentId").toString());
|
||||
String payType = (String) paymentData.getOrDefault("payType", "alipay");
|
||||
|
||||
Payment payment = paymentService.findById(paymentId)
|
||||
.orElseThrow(() -> new RuntimeException("支付记录不存在"));
|
||||
|
||||
// 检查权限
|
||||
if (!payment.getUser().getUsername().equals(username)) {
|
||||
logger.warn("用户{}无权限操作支付记录{}", username, paymentId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(createErrorResponse("无权限操作此支付记录"));
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
String clientIp = getClientIp(request);
|
||||
logger.info("创建噜噜支付,客户端IP: {}", clientIp);
|
||||
|
||||
// 调用噜噜支付接口创建支付
|
||||
Map<String, Object> paymentResult = luluPayService.createPayment(payment, payType, clientIp);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "支付创建成功");
|
||||
response.put("data", paymentResult);
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
logger.error("创建噜噜支付失败", e);
|
||||
String errorMsg = e.getMessage();
|
||||
if (errorMsg != null && errorMsg.length() > 100) {
|
||||
errorMsg = errorMsg.substring(0, 100) + "...";
|
||||
}
|
||||
return ResponseEntity.badRequest()
|
||||
.body(createErrorResponse("创建噜噜支付失败: " + errorMsg));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除支付记录(仅管理员)
|
||||
*/
|
||||
@@ -713,4 +778,28 @@ public class PaymentApiController {
|
||||
response.put("message", message);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实IP地址
|
||||
*/
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
// 如果是多个代理,取第一个IP
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public class PaymentController {
|
||||
public String createPayment(@Valid @ModelAttribute Payment payment,
|
||||
BindingResult result,
|
||||
Authentication authentication,
|
||||
HttpServletRequest request,
|
||||
Model model) {
|
||||
|
||||
if (result.hasErrors()) {
|
||||
@@ -77,9 +78,12 @@ public class PaymentController {
|
||||
payment.setCurrency("CNY");
|
||||
}
|
||||
|
||||
// 获取客户端IP
|
||||
String clientIp = getClientIp(request);
|
||||
|
||||
// 根据支付方式创建支付
|
||||
if (payment.getPaymentMethod() == PaymentMethod.ALIPAY) {
|
||||
Map<String, Object> paymentResult = alipayService.createPayment(payment);
|
||||
Map<String, Object> paymentResult = alipayService.createPayment(payment, clientIp);
|
||||
if (paymentResult.containsKey("qrCode")) {
|
||||
// 对于二维码支付,重定向到支付页面显示二维码
|
||||
return "redirect:/payment/qr?qrCode=" + paymentResult.get("qrCode");
|
||||
@@ -253,4 +257,28 @@ public class PaymentController {
|
||||
return "payment/detail";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实IP地址
|
||||
*/
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("X-Real-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
// 如果是多个代理,取第一个IP
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,12 @@ public class PublicApiController {
|
||||
MembershipLevel proLevel = membershipLevelRepository.findByName("professional")
|
||||
.orElseThrow(() -> new IllegalStateException("数据库中缺少professional会员等级配置"));
|
||||
|
||||
int standardPrice = standardLevel.getPrice().intValue();
|
||||
Double standardPrice = standardLevel.getPrice();
|
||||
int standardPoints = standardLevel.getPointsBonus();
|
||||
int proPrice = proLevel.getPrice().intValue();
|
||||
Double proPrice = proLevel.getPrice();
|
||||
int proPoints = proLevel.getPointsBonus();
|
||||
|
||||
// 套餐价格配置(从membership_levels表读取)
|
||||
// 套餐价格配置(从membership_levels表读取,保留两位小数)
|
||||
config.put("standardPriceCny", standardPrice);
|
||||
config.put("proPriceCny", proPrice);
|
||||
config.put("standardPoints", standardPoints);
|
||||
|
||||
@@ -104,6 +104,82 @@ public class StoryboardVideoApiController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接使用上传的分镜图创建视频任务(跳过分镜图生成)
|
||||
* 用户在STEP2上传分镜图后直接生成视频时调用
|
||||
*/
|
||||
@PostMapping("/create-video-direct")
|
||||
public ResponseEntity<?> createVideoDirectly(
|
||||
@RequestBody Map<String, Object> request,
|
||||
Authentication authentication) {
|
||||
try {
|
||||
// 检查用户是否已认证
|
||||
if (authentication == null) {
|
||||
logger.warn("直接创建视频任务失败: 用户未登录");
|
||||
return ResponseEntity.status(401)
|
||||
.body(Map.of("success", false, "message", "用户未登录,请先登录"));
|
||||
}
|
||||
String username = authentication.getName();
|
||||
logger.info("收到直接创建视频任务请求,用户: {}", username);
|
||||
|
||||
// 从请求中提取参数
|
||||
String storyboardImage = (String) request.get("storyboardImage"); // 用户上传的分镜图
|
||||
String prompt = (String) request.getOrDefault("prompt", "根据分镜图生成视频");
|
||||
String aspectRatio = (String) request.getOrDefault("aspectRatio", "16:9");
|
||||
Boolean hdMode = (Boolean) request.getOrDefault("hdMode", false);
|
||||
|
||||
// 提取视频参考图
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> referenceImages = (List<String>) request.get("referenceImages");
|
||||
|
||||
// 提取duration参数
|
||||
Integer duration = 10;
|
||||
Object durationObj = request.get("duration");
|
||||
if (durationObj instanceof Number) {
|
||||
duration = ((Number) durationObj).intValue();
|
||||
} else if (durationObj instanceof String) {
|
||||
try {
|
||||
duration = Integer.parseInt((String) durationObj);
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warn("无效的duration参数: {}, 使用默认值10", durationObj);
|
||||
}
|
||||
}
|
||||
|
||||
if (storyboardImage == null || storyboardImage.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", "分镜图不能为空"));
|
||||
}
|
||||
|
||||
logger.info("直接创建视频任务参数 - duration: {}, aspectRatio: {}, hdMode: {}, referenceImages: {}",
|
||||
duration, aspectRatio, hdMode, referenceImages != null ? referenceImages.size() : 0);
|
||||
|
||||
// 调用服务层方法直接创建视频任务
|
||||
StoryboardVideoTask task = storyboardVideoService.createVideoDirectTask(
|
||||
username, prompt, storyboardImage, aspectRatio, hdMode != null && hdMode, duration, referenceImages
|
||||
);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "视频任务创建成功");
|
||||
response.put("data", Map.of(
|
||||
"taskId", task.getTaskId(),
|
||||
"status", task.getStatus(),
|
||||
"progress", task.getProgress()
|
||||
));
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.error("参数错误: {}", e.getMessage(), e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "message", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
logger.error("直接创建视频任务失败", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "message", "创建任务失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务详情
|
||||
*/
|
||||
|
||||
@@ -47,6 +47,9 @@ public class TaskStatusApiController {
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private com.example.demo.repository.StoryboardVideoTaskRepository storyboardVideoTaskRepository;
|
||||
|
||||
/**
|
||||
* 获取任务状态
|
||||
*/
|
||||
@@ -83,6 +86,19 @@ public class TaskStatusApiController {
|
||||
response.put("pollCount", taskStatus.getPollCount());
|
||||
response.put("maxPolls", taskStatus.getMaxPolls());
|
||||
|
||||
// 如果是分镜视频任务,额外返回 videoPrompt、imagePrompt、shotList
|
||||
if (taskId.startsWith("sb_")) {
|
||||
try {
|
||||
storyboardVideoTaskRepository.findByTaskId(taskId).ifPresent(storyboardTask -> {
|
||||
response.put("videoPrompt", storyboardTask.getVideoPrompt());
|
||||
response.put("imagePrompt", storyboardTask.getImagePrompt());
|
||||
response.put("shotList", storyboardTask.getShotList());
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.warn("获取分镜视频任务额外信息失败: taskId={}, error={}", taskId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -89,13 +89,15 @@ public class UserWorkApiController {
|
||||
/**
|
||||
* 获取我的作品列表
|
||||
* @param includeProcessing 是否包含正在排队和生成中的作品,默认为true
|
||||
* @param workType 作品类型筛选(可选):TEXT_TO_VIDEO, IMAGE_TO_VIDEO, STORYBOARD_VIDEO, STORYBOARD_IMAGE
|
||||
*/
|
||||
@GetMapping("/my-works")
|
||||
public ResponseEntity<Map<String, Object>> getMyWorks(
|
||||
@RequestHeader(value = "Authorization", required = false) String token,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "1000") int size,
|
||||
@RequestParam(defaultValue = "true") boolean includeProcessing) {
|
||||
@RequestParam(defaultValue = "true") boolean includeProcessing,
|
||||
@RequestParam(required = false) String workType) {
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
@@ -111,9 +113,24 @@ public class UserWorkApiController {
|
||||
if (page < 0) page = 0;
|
||||
if (size <= 0) size = 1000; // 不设上限,默认1000条
|
||||
|
||||
// 解析作品类型
|
||||
UserWork.WorkType filterType = null;
|
||||
if (workType != null && !workType.isEmpty()) {
|
||||
try {
|
||||
filterType = UserWork.WorkType.valueOf(workType.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn("无效的作品类型: {}", workType);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据参数决定是否包含正在进行中的作品
|
||||
Page<UserWork> works;
|
||||
if (includeProcessing) {
|
||||
if (filterType != null) {
|
||||
// 按类型筛选
|
||||
works = userWorkService.getUserWorksByType(username, filterType, includeProcessing, page, size);
|
||||
logger.info("获取作品列表(按类型): username={}, type={}, total={}",
|
||||
username, filterType, works.getTotalElements());
|
||||
} else if (includeProcessing) {
|
||||
works = userWorkService.getAllUserWorks(username, page, size);
|
||||
// 调试日志:检查是否有 PROCESSING 状态的作品
|
||||
long processingCount = works.getContent().stream()
|
||||
|
||||
@@ -48,7 +48,7 @@ public class ImageToVideoTask {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false)
|
||||
private TaskStatus status = TaskStatus.PENDING;
|
||||
private TaskStatus status = TaskStatus.PROCESSING; // 默认生成中
|
||||
|
||||
@Column(name = "progress")
|
||||
private Integer progress = 0;
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.example.demo.model;
|
||||
|
||||
public enum PaymentMethod {
|
||||
ALIPAY("支付宝"),
|
||||
WECHAT("微信支付"),
|
||||
QQPAY("QQ钱包"),
|
||||
BANK("云闪付"),
|
||||
PAYPAL("PayPal");
|
||||
|
||||
private final String displayName;
|
||||
|
||||
@@ -27,9 +27,10 @@ public interface PointsFreezeRecordRepository extends JpaRepository<PointsFreeze
|
||||
/**
|
||||
* 根据任务ID查找冻结记录(带悲观写锁,用于防止并发重复扣除)
|
||||
* 使用悲观锁确保在高并发场景下不会重复扣除积分
|
||||
* 只返回状态为 FROZEN 的第一条记录,避免重复记录导致的问题
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT pfr FROM PointsFreezeRecord pfr WHERE pfr.taskId = :taskId")
|
||||
@Query("SELECT pfr FROM PointsFreezeRecord pfr WHERE pfr.taskId = :taskId AND pfr.status = 'FROZEN' ORDER BY pfr.createdAt ASC LIMIT 1")
|
||||
Optional<PointsFreezeRecord> findByTaskIdWithLock(@Param("taskId") String taskId);
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,19 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status = :status ORDER BY uw.createdAt DESC")
|
||||
List<UserWork> findByUsernameAndStatusOrderByCreatedAtDesc(@Param("username") String username, @Param("status") UserWork.WorkStatus status);
|
||||
|
||||
/**
|
||||
* 根据用户名和作品类型查找作品(包括所有状态,用于历史记录)
|
||||
* 排除 DELETED 和 FAILED 状态
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.workType = :workType AND uw.status NOT IN ('DELETED', 'FAILED') ORDER BY uw.createdAt DESC")
|
||||
Page<UserWork> findByUsernameAndWorkTypeOrderByCreatedAtDesc(@Param("username") String username, @Param("workType") UserWork.WorkType workType, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 根据用户名、作品类型和状态查找作品
|
||||
*/
|
||||
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.workType = :workType AND uw.status = :status ORDER BY uw.createdAt DESC")
|
||||
Page<UserWork> findByUsernameAndWorkTypeAndStatusOrderByCreatedAtDesc(@Param("username") String username, @Param("workType") UserWork.WorkType workType, @Param("status") UserWork.WorkStatus status, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 根据用户名查找正在进行中和排队中的作品
|
||||
*/
|
||||
|
||||
@@ -58,6 +58,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
"/api/email/",
|
||||
"/api/payments/alipay/notify",
|
||||
"/api/payments/alipay/return",
|
||||
"/api/payments/lulupay/notify",
|
||||
"/api/payments/lulupay/return",
|
||||
"/api/payment/paypal/success",
|
||||
"/api/payment/paypal/cancel",
|
||||
"/swagger-ui",
|
||||
"/v3/api-docs",
|
||||
"/api-docs"
|
||||
|
||||
@@ -7,22 +7,15 @@ import java.util.UUID;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.alipay.api.domain.AlipayTradePagePayModel;
|
||||
import com.alipay.api.domain.AlipayTradePrecreateModel;
|
||||
import com.alipay.api.internal.util.AlipaySignature;
|
||||
import com.example.demo.model.Payment;
|
||||
import com.example.demo.model.PaymentMethod;
|
||||
import com.example.demo.model.PaymentStatus;
|
||||
import com.example.demo.repository.PaymentRepository;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ijpay.alipay.AliPayApi;
|
||||
import com.ijpay.alipay.AliPayApiConfig;
|
||||
import com.ijpay.alipay.AliPayApiConfigKit;
|
||||
import com.example.demo.config.PaymentConfig.AliPayApiConfigHolder;
|
||||
|
||||
@Service
|
||||
public class AlipayService {
|
||||
@@ -31,21 +24,15 @@ public class AlipayService {
|
||||
|
||||
private final PaymentRepository paymentRepository;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
@Autowired
|
||||
private LuluPayService luluPayService;
|
||||
|
||||
@Autowired(required = false)
|
||||
private UserErrorLogService userErrorLogService;
|
||||
|
||||
@Value("${alipay.app-id}")
|
||||
private String appId;
|
||||
|
||||
@Value("${alipay.private-key}")
|
||||
private String privateKey;
|
||||
|
||||
@Value("${alipay.public-key}")
|
||||
private String publicKey;
|
||||
|
||||
@Value("${alipay.gateway-url}")
|
||||
private String gatewayUrl;
|
||||
|
||||
@Value("${alipay.charset}")
|
||||
private String charset;
|
||||
|
||||
@@ -58,50 +45,38 @@ public class AlipayService {
|
||||
@Value("${alipay.return-url}")
|
||||
private String returnUrl;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AlipayService(PaymentRepository paymentRepository) {
|
||||
this.paymentRepository = paymentRepository;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付宝支付订单(电脑网站支付)
|
||||
* 创建支付宝支付订单
|
||||
* 内部调用噜噜支付(彩虹易支付)
|
||||
*/
|
||||
public Map<String, Object> createPayment(Payment payment) {
|
||||
public Map<String, Object> createPayment(Payment payment, String clientIp) {
|
||||
try {
|
||||
logger.info("开始创建支付宝支付订单,订单号:{},金额:{}", payment.getOrderId(), payment.getAmount());
|
||||
logger.info("开始创建支付订单(使用噜噜支付),订单号:{},金额:{},客户端IP:{}", payment.getOrderId(), payment.getAmount(), clientIp);
|
||||
|
||||
// 设置支付状态
|
||||
payment.setStatus(PaymentStatus.PENDING);
|
||||
payment.setPaymentMethod(PaymentMethod.ALIPAY);
|
||||
if (payment.getOrderId() == null || payment.getOrderId().isEmpty()) {
|
||||
payment.setOrderId(generateOrderId());
|
||||
}
|
||||
payment.setCallbackUrl(notifyUrl);
|
||||
payment.setReturnUrl(returnUrl);
|
||||
|
||||
// 保存支付记录
|
||||
paymentRepository.save(payment);
|
||||
logger.info("支付记录已保存,ID:{}", payment.getId());
|
||||
|
||||
// 调用电脑网站支付API
|
||||
return callPagePayAPI(payment);
|
||||
// 调用噜噜支付创建支付宝订单
|
||||
return luluPayService.createPayment(payment, "alipay", clientIp);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("创建支付订单时发生异常,订单号:{},错误:{}", payment.getOrderId(), e.getMessage(), e);
|
||||
payment.setStatus(PaymentStatus.FAILED);
|
||||
paymentRepository.save(payment);
|
||||
|
||||
// 记录支付错误
|
||||
try {
|
||||
userErrorLogService.logErrorAsync(
|
||||
null,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.PAYMENT_ERROR,
|
||||
"支付宝支付创建失败: " + e.getMessage(),
|
||||
"AlipayService",
|
||||
payment.getOrderId(),
|
||||
"ALIPAY"
|
||||
);
|
||||
if (userErrorLogService != null) {
|
||||
userErrorLogService.logErrorAsync(
|
||||
null,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.PAYMENT_ERROR,
|
||||
"支付创建失败: " + e.getMessage(),
|
||||
"AlipayService",
|
||||
payment.getOrderId(),
|
||||
"ALIPAY"
|
||||
);
|
||||
}
|
||||
} catch (Exception logEx) {
|
||||
logger.warn("记录支付错误日志失败: {}", logEx.getMessage());
|
||||
}
|
||||
@@ -109,299 +84,26 @@ public class AlipayService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用电脑网页支付API(alipay.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获取并设置
|
||||
* 根据IJPay源码,正确的方法是 putApiConfig
|
||||
*/
|
||||
private void ensureAliPayConfigSet() {
|
||||
try {
|
||||
// 从AliPayApiConfigHolder获取配置
|
||||
AliPayApiConfig config = AliPayApiConfigHolder.getConfig();
|
||||
if (config != null) {
|
||||
// 根据IJPay源码,使用 putApiConfig 方法设置配置
|
||||
try {
|
||||
AliPayApiConfigKit.putApiConfig(config);
|
||||
logger.debug("IJPay配置已动态设置到AliPayApiConfigKit");
|
||||
} catch (Exception e) {
|
||||
logger.warn("动态设置IJPay配置到AliPayApiConfigKit时发生异常: {}", e.getMessage());
|
||||
}
|
||||
} else {
|
||||
logger.warn("AliPayApiConfigHolder中没有配置,IJPay配置可能未初始化");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("动态设置IJPay配置时发生异常: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用真实的支付宝API(使用IJPay)
|
||||
*/
|
||||
private Map<String, Object> callRealAlipayAPI(Payment payment) throws Exception {
|
||||
// 记录配置信息
|
||||
logger.info("=== 使用IJPay调用支付宝API ===");
|
||||
logger.info("网关地址: {}", gatewayUrl);
|
||||
logger.info("应用ID: {}", appId);
|
||||
logger.info("字符集: {}", charset);
|
||||
logger.info("签名类型: {}", signType);
|
||||
logger.info("通知URL: {}", notifyUrl);
|
||||
logger.info("返回URL: {}", returnUrl);
|
||||
|
||||
// 设置业务参数
|
||||
AlipayTradePrecreateModel model = new AlipayTradePrecreateModel();
|
||||
model.setOutTradeNo(payment.getOrderId());
|
||||
|
||||
// 如果是美元,按汇率转换为人民币(支付宝只支持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"); // USD -> CNY 汇率
|
||||
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("5m");
|
||||
|
||||
logger.info("调用支付宝预创建API(IJPay),订单号:{},金额:{},商品名称:{}",
|
||||
model.getOutTradeNo(), model.getTotalAmount(), model.getSubject());
|
||||
|
||||
// 使用IJPay调用API,增加重试机制
|
||||
String qrCode = null;
|
||||
int maxRetries = 5; // 最大重试次数
|
||||
int retryCount = 0;
|
||||
|
||||
// 在调用前确保配置已设置
|
||||
ensureAliPayConfigSet();
|
||||
|
||||
// 验证配置中的网关地址
|
||||
try {
|
||||
AliPayApiConfig config = AliPayApiConfigKit.getAliPayApiConfig();
|
||||
logger.info("=== API调用前配置验证 ===");
|
||||
logger.info("配置中的serviceUrl: {}", config.getServiceUrl());
|
||||
logger.info("配置中的appId: {}", config.getAppId());
|
||||
logger.info("配置中的charset: {}", config.getCharset());
|
||||
logger.info("配置中的signType: {}", config.getSignType());
|
||||
} catch (Exception e) {
|
||||
logger.warn("获取IJPay配置时发生异常: {}", e.getMessage());
|
||||
}
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
logger.info("正在调用支付宝API(IJPay)... (第{}次尝试,共{}次)", retryCount + 1, maxRetries);
|
||||
logger.info("API方法: alipay.trade.precreate (由AlipayTradePrecreateRequest自动设置)");
|
||||
logger.info("通知URL: {}", notifyUrl);
|
||||
|
||||
// 使用IJPay的AliPayApi调用预创建接口
|
||||
// AlipayTradePrecreateRequest会自动设置method参数为"alipay.trade.precreate"
|
||||
String responseBody = AliPayApi.tradePrecreatePayToResponse(model, notifyUrl).getBody();
|
||||
|
||||
if (responseBody == null || responseBody.isEmpty()) {
|
||||
throw new RuntimeException("IJPay API响应为空");
|
||||
}
|
||||
|
||||
logger.info("IJPay API响应: {}", responseBody);
|
||||
|
||||
// 解析JSON响应
|
||||
// IJPay返回的响应体是JSON字符串,格式为: {"alipay_trade_precreate_response":{"code":"10000","msg":"Success","qr_code":"..."},"sign":"..."}
|
||||
try {
|
||||
Map<String, Object> responseMap = objectMapper.readValue(
|
||||
responseBody,
|
||||
new TypeReference<Map<String, Object>>() {}
|
||||
);
|
||||
Map<String, Object> precreateResponse = (Map<String, Object>) responseMap.get("alipay_trade_precreate_response");
|
||||
|
||||
if (precreateResponse != null) {
|
||||
String code = (String) precreateResponse.get("code");
|
||||
String msg = (String) precreateResponse.get("msg");
|
||||
|
||||
if ("10000".equals(code)) {
|
||||
qrCode = (String) precreateResponse.get("qr_code");
|
||||
if (qrCode != null && !qrCode.isEmpty()) {
|
||||
logger.info("支付宝二维码生成成功(IJPay),订单号:{},二维码:{}", payment.getOrderId(), qrCode);
|
||||
break; // 成功则跳出循环
|
||||
} else {
|
||||
throw new RuntimeException("二维码为空,响应消息:" + msg);
|
||||
}
|
||||
} else {
|
||||
String subCode = (String) precreateResponse.get("sub_code");
|
||||
String subMsg = (String) precreateResponse.get("sub_msg");
|
||||
|
||||
// 如果交易已经成功,需要检查是否是当前支付记录
|
||||
// 避免新创建的支付被误判为成功
|
||||
if ("ACQ.TRADE_HAS_SUCCESS".equals(subCode)) {
|
||||
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 : "") + ")");
|
||||
}
|
||||
} else {
|
||||
logger.error("无法解析支付宝响应,响应体:{}", responseBody);
|
||||
throw new RuntimeException("支付宝响应格式异常,请稍后重试");
|
||||
}
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
||||
logger.error("JSON解析失败,响应体:{}", responseBody, e);
|
||||
throw new RuntimeException("支付宝响应解析失败,请稍后重试");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
retryCount++;
|
||||
String errorType = e.getClass().getSimpleName();
|
||||
String errorMessage = e.getMessage();
|
||||
|
||||
logger.warn("支付宝API调用失败(IJPay),第{}次尝试失败", retryCount);
|
||||
logger.warn("错误类型: {}", errorType);
|
||||
logger.warn("错误信息: {}", errorMessage);
|
||||
|
||||
// 根据错误类型提供诊断建议和决定重试策略
|
||||
boolean isTimeoutError = false;
|
||||
if (errorMessage != null) {
|
||||
if (errorMessage.contains("Read timed out") || errorMessage.contains("timeout")
|
||||
|| errorMessage.contains("SocketTimeoutException")) {
|
||||
isTimeoutError = true;
|
||||
logger.error("=== 网络超时错误诊断 ===");
|
||||
logger.error("可能的原因:");
|
||||
logger.error("1. 网络连接不稳定或延迟过高");
|
||||
logger.error("2. 支付宝沙箱环境响应慢(openapi-sandbox.dl.alipaydev.com)");
|
||||
logger.error("3. 防火墙或代理服务器阻止连接");
|
||||
logger.error("4. ngrok隧道可能已过期或不可用");
|
||||
logger.error("解决方案:");
|
||||
logger.error("1. 检查网络连接,尝试ping openapi-sandbox.dl.alipaydev.com");
|
||||
logger.error("2. 检查ngrok是否正常运行: {}", notifyUrl);
|
||||
logger.error("3. 考虑使用代理服务器或VPN");
|
||||
} else if (errorMessage.contains("Connection refused") || errorMessage.contains("ConnectException")) {
|
||||
logger.error("=== 连接拒绝错误诊断 ===");
|
||||
logger.error("无法连接到支付宝服务器: {}", gatewayUrl);
|
||||
logger.error("请检查网络连接和防火墙设置");
|
||||
} else if (errorMessage.contains("UnknownHostException")) {
|
||||
logger.error("=== DNS解析错误诊断 ===");
|
||||
logger.error("无法解析域名: {}", gatewayUrl);
|
||||
logger.error("请检查DNS设置和网络连接");
|
||||
}
|
||||
}
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
logger.error("支付宝API调用失败(IJPay),已达到最大重试次数({}次)", maxRetries);
|
||||
logger.error("最终失败原因: {}", errorMessage);
|
||||
throw new RuntimeException("支付宝API调用失败(IJPay),已重试" + maxRetries + "次:" + errorMessage);
|
||||
}
|
||||
|
||||
// 根据错误类型决定等待时间:超时错误等待更长时间
|
||||
int waitTime = isTimeoutError ? 5000 : 3000; // 超时错误等待5秒,其他错误等待3秒
|
||||
logger.info("等待{}秒后重试...", waitTime / 1000);
|
||||
try {
|
||||
Thread.sleep(waitTime);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new RuntimeException("重试被中断", ie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查二维码是否为空
|
||||
if (qrCode == null || qrCode.isEmpty()) {
|
||||
logger.error("支付宝API调用失败(IJPay),二维码为空");
|
||||
payment.setStatus(PaymentStatus.FAILED);
|
||||
paymentRepository.save(payment);
|
||||
throw new RuntimeException("支付宝API调用失败(IJPay),二维码为空");
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("qrCode", qrCode);
|
||||
result.put("outTradeNo", payment.getOrderId());
|
||||
result.put("success", true);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 处理支付宝异步通知
|
||||
* 处理支付宝异步通知(保留兼容旧的支付宝直连回调)
|
||||
* 新的支付会走噜噜支付回调 /api/payments/lulupay/notify
|
||||
*/
|
||||
public boolean handleNotify(Map<String, String> params) {
|
||||
try {
|
||||
// 验证签名
|
||||
boolean signVerified = AlipaySignature.rsaCheckV1(
|
||||
params, publicKey, charset, signType);
|
||||
// 尝试验证支付宝签名(兼容旧订单)
|
||||
boolean signVerified = false;
|
||||
try {
|
||||
signVerified = AlipaySignature.rsaCheckV1(
|
||||
params, publicKey, charset, signType);
|
||||
} catch (Exception e) {
|
||||
logger.warn("支付宝签名验证异常,可能是噜噜支付回调: {}", e.getMessage());
|
||||
}
|
||||
|
||||
if (!signVerified) {
|
||||
logger.warn("支付宝异步通知签名验证失败");
|
||||
logger.warn("支付宝异步通知签名验证失败,尝试作为噜噜支付处理");
|
||||
// 可能是噜噜支付的回调,交给LuluPayService处理
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -447,19 +149,10 @@ public class AlipayService {
|
||||
*/
|
||||
public boolean handleReturn(Map<String, String> params) {
|
||||
try {
|
||||
// 验证签名
|
||||
boolean signVerified = AlipaySignature.rsaCheckV1(
|
||||
params, publicKey, charset, signType);
|
||||
|
||||
if (!signVerified) {
|
||||
logger.warn("支付宝同步返回签名验证失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
String outTradeNo = params.get("out_trade_no");
|
||||
String tradeNo = params.get("trade_no");
|
||||
|
||||
logger.info("收到支付宝同步返回,订单号:{},交易号:{}", outTradeNo, tradeNo);
|
||||
logger.info("收到支付同步返回,订单号:{},交易号:{}", outTradeNo, tradeNo);
|
||||
|
||||
// 查找支付记录
|
||||
Payment payment = paymentRepository.findByOrderId(outTradeNo)
|
||||
@@ -475,7 +168,7 @@ public class AlipayService {
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理支付宝同步返回异常:", e);
|
||||
logger.error("处理支付同步返回异常:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -486,5 +179,4 @@ public class AlipayService {
|
||||
private String generateOrderId() {
|
||||
return "ALI" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -787,7 +787,7 @@ public class ImageToVideoService {
|
||||
*/
|
||||
@Transactional
|
||||
public ImageToVideoTask retryTask(String taskId, String username) {
|
||||
logger.info("重试失败任务: taskId={}, username={}", taskId, username);
|
||||
logger.info("重试任务: taskId={}, username={}", taskId, username);
|
||||
|
||||
// 获取任务
|
||||
ImageToVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
@@ -798,9 +798,13 @@ public class ImageToVideoService {
|
||||
throw new RuntimeException("无权操作此任务");
|
||||
}
|
||||
|
||||
// 验证任务状态必须是 FAILED
|
||||
if (task.getStatus() != ImageToVideoTask.TaskStatus.FAILED) {
|
||||
throw new RuntimeException("只能重试失败的任务,当前状态: " + task.getStatus());
|
||||
// 允许重试:失败的任务、完成但没有结果的任务、或者正在处理中的任务(可能卡住了)
|
||||
boolean canRetry = task.getStatus() == ImageToVideoTask.TaskStatus.FAILED ||
|
||||
task.getStatus() == ImageToVideoTask.TaskStatus.COMPLETED ||
|
||||
task.getStatus() == ImageToVideoTask.TaskStatus.PROCESSING;
|
||||
|
||||
if (!canRetry) {
|
||||
throw new RuntimeException("无法重试此任务,当前状态: " + task.getStatus());
|
||||
}
|
||||
|
||||
// 验证图片URL存在
|
||||
@@ -808,12 +812,17 @@ public class ImageToVideoService {
|
||||
throw new RuntimeException("任务缺少首帧图片,无法重试");
|
||||
}
|
||||
|
||||
// 重置任务状态为 PENDING
|
||||
task.setStatus(ImageToVideoTask.TaskStatus.PENDING);
|
||||
logger.info("允许重试任务: taskId={}, 原状态={}, 原结果URL={}",
|
||||
taskId, task.getStatus(), task.getResultUrl());
|
||||
|
||||
// 重置任务状态为 PROCESSING(直接生成中)
|
||||
task.setStatus(ImageToVideoTask.TaskStatus.PROCESSING);
|
||||
task.setErrorMessage(null);
|
||||
task.setRealTaskId(null); // 清除旧的外部任务ID
|
||||
task.setResultUrl(null); // 清除旧的结果URL
|
||||
task.setProgress(0);
|
||||
task.setUpdatedAt(java.time.LocalDateTime.now());
|
||||
task.setCompletedAt(null);
|
||||
taskRepository.save(task);
|
||||
|
||||
// 重新添加到任务队列
|
||||
|
||||
345
demo/src/main/java/com/example/demo/service/LuluPayService.java
Normal file
345
demo/src/main/java/com/example/demo/service/LuluPayService.java
Normal file
@@ -0,0 +1,345 @@
|
||||
package com.example.demo.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
|
||||
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.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import com.example.demo.model.Payment;
|
||||
import com.example.demo.model.PaymentMethod;
|
||||
import com.example.demo.model.PaymentStatus;
|
||||
import com.example.demo.repository.PaymentRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* 噜噜支付(彩虹易支付)服务
|
||||
*
|
||||
* 签名方式:RSA(商户私钥,SHA256WithRSA)
|
||||
* 验签方式:RSA(平台公钥,SHA256WithRSA)
|
||||
*/
|
||||
@Service
|
||||
public class LuluPayService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LuluPayService.class);
|
||||
|
||||
private final PaymentRepository paymentRepository;
|
||||
private final RestTemplate restTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${lulupay.api-url:}")
|
||||
private String apiUrl;
|
||||
|
||||
@Value("${lulupay.pid:}")
|
||||
private String pid;
|
||||
|
||||
@Value("${lulupay.merchant-key:}")
|
||||
private String merchantKey; // MD5签名密钥
|
||||
|
||||
@Value("${lulupay.platform-public-key:}")
|
||||
private String platformPublicKey; // RSA验签公钥
|
||||
|
||||
@Value("${lulupay.notify-url:}")
|
||||
private String notifyUrl;
|
||||
|
||||
@Value("${lulupay.return-url:}")
|
||||
private String returnUrl;
|
||||
|
||||
@Autowired(required = false)
|
||||
private UserErrorLogService userErrorLogService;
|
||||
|
||||
public LuluPayService(PaymentRepository paymentRepository) {
|
||||
this.paymentRepository = paymentRepository;
|
||||
this.restTemplate = new RestTemplate();
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付订单
|
||||
* 调用API接口:https://api.dulupay.com/api/pay/create
|
||||
*/
|
||||
public Map<String, Object> createPayment(Payment payment, String payType, String clientIp) {
|
||||
try {
|
||||
logger.info("开始创建噜噜支付订单,订单号:{},金额:{},支付方式:{}",
|
||||
payment.getOrderId(), payment.getAmount(), payType);
|
||||
|
||||
// 设置支付状态和方式
|
||||
payment.setStatus(PaymentStatus.PENDING);
|
||||
if ("wxpay".equals(payType)) {
|
||||
payment.setPaymentMethod(PaymentMethod.WECHAT);
|
||||
} else {
|
||||
payment.setPaymentMethod(PaymentMethod.ALIPAY);
|
||||
}
|
||||
|
||||
if (payment.getOrderId() == null || payment.getOrderId().isEmpty()) {
|
||||
payment.setOrderId(generateOrderId());
|
||||
}
|
||||
payment.setCallbackUrl(notifyUrl);
|
||||
payment.setReturnUrl(returnUrl);
|
||||
|
||||
// 保存支付记录
|
||||
paymentRepository.save(payment);
|
||||
logger.info("支付记录已保存,ID:{}", payment.getId());
|
||||
|
||||
// 调用API创建支付
|
||||
return callCreateApi(payment, payType, clientIp);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("创建支付订单异常,订单号:{},错误:{}", payment.getOrderId(), e.getMessage(), e);
|
||||
payment.setStatus(PaymentStatus.FAILED);
|
||||
paymentRepository.save(payment);
|
||||
logPaymentError(payment.getOrderId(), payType, e.getMessage());
|
||||
throw new RuntimeException("支付服务异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用统一下单API(使用submit方式,返回表单直接跳转)
|
||||
*/
|
||||
private Map<String, Object> callCreateApi(Payment payment, String payType, String clientIp) throws Exception {
|
||||
String apiEndpoint = apiUrl + "api/pay/submit";
|
||||
|
||||
// 构建请求参数(按SDK方式,不含method和clientip)
|
||||
Map<String, String> params = new TreeMap<>();
|
||||
params.put("pid", pid);
|
||||
params.put("type", payType);
|
||||
params.put("out_trade_no", payment.getOrderId());
|
||||
params.put("notify_url", notifyUrl);
|
||||
params.put("return_url", returnUrl);
|
||||
params.put("name", payment.getDescription() != null ? payment.getDescription() : "VIP会员");
|
||||
params.put("money", payment.getAmount().setScale(2, BigDecimal.ROUND_HALF_UP).toString());
|
||||
params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
|
||||
|
||||
// 生成MD5签名
|
||||
String sign = generateMd5Sign(params);
|
||||
params.put("sign", sign);
|
||||
params.put("sign_type", "MD5");
|
||||
|
||||
logger.info("调用噜噜支付API: {},参数: {}", apiEndpoint, params);
|
||||
|
||||
// 构建跳转表单(SDK方式)
|
||||
StringBuilder formHtml = new StringBuilder();
|
||||
formHtml.append("<form id=\"lulupay_form\" action=\"").append(escapeHtml(apiEndpoint)).append("\" method=\"post\">");
|
||||
for (Map.Entry<String, String> entry : params.entrySet()) {
|
||||
formHtml.append("<input type=\"hidden\" name=\"").append(entry.getKey())
|
||||
.append("\" value=\"").append(escapeHtml(entry.getValue())).append("\"/>");
|
||||
}
|
||||
formHtml.append("<input type=\"submit\" value=\"正在跳转...\"/>");
|
||||
formHtml.append("</form>");
|
||||
formHtml.append("<script>document.getElementById('lulupay_form').submit();</script>");
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("outTradeNo", payment.getOrderId());
|
||||
result.put("payForm", formHtml.toString());
|
||||
result.put("payType", "PAGE_PAY");
|
||||
|
||||
logger.info("支付表单已生成,订单号:{}", payment.getOrderId());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理异步通知回调
|
||||
* 使用RSA验签(平台公钥)
|
||||
*/
|
||||
public boolean handleNotify(Map<String, String> params) {
|
||||
try {
|
||||
logger.info("收到噜噜支付异步通知: {}", params);
|
||||
|
||||
// 使用RSA验签
|
||||
if (!verifyRsaSign(params)) {
|
||||
logger.warn("噜噜支付异步通知RSA签名验证失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
String tradeStatus = params.get("trade_status");
|
||||
String outTradeNo = params.get("out_trade_no");
|
||||
String tradeNo = params.get("trade_no");
|
||||
String type = params.get("type");
|
||||
String money = params.get("money");
|
||||
|
||||
logger.info("订单号:{},交易号:{},交易状态:{},支付方式:{},金额:{}",
|
||||
outTradeNo, tradeNo, tradeStatus, type, money);
|
||||
|
||||
// 查找支付记录
|
||||
Payment payment = paymentRepository.findByOrderId(outTradeNo)
|
||||
.orElseThrow(() -> new RuntimeException("支付记录不存在:" + outTradeNo));
|
||||
|
||||
// 注意:这里只做验签和基本状态记录,不更新支付状态为SUCCESS
|
||||
// 支付状态的更新和积分增加由 LuluPayCallbackController 调用 PaymentService.confirmPaymentSuccess 统一处理
|
||||
// 这样可以确保积分增加逻辑被正确触发
|
||||
if ("TRADE_CLOSED".equals(tradeStatus)) {
|
||||
payment.setStatus(PaymentStatus.CANCELLED);
|
||||
paymentRepository.save(payment);
|
||||
logger.info("交易关闭,订单号:{}", outTradeNo);
|
||||
}
|
||||
// TRADE_SUCCESS 的处理交给 Controller 层的 confirmPaymentSuccess
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理噜噜支付异步通知异常:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理同步返回
|
||||
*/
|
||||
public boolean handleReturn(Map<String, String> params) {
|
||||
try {
|
||||
logger.info("收到噜噜支付同步返回: {}", params);
|
||||
|
||||
// 使用RSA验签
|
||||
if (!verifyRsaSign(params)) {
|
||||
logger.warn("噜噜支付同步返回RSA签名验证失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
String outTradeNo = params.get("out_trade_no");
|
||||
String tradeNo = params.get("trade_no");
|
||||
String tradeStatus = params.get("trade_status");
|
||||
|
||||
logger.info("同步返回,订单号:{},交易号:{},状态:{}", outTradeNo, tradeNo, tradeStatus);
|
||||
|
||||
// 同步返回只做验签和记录日志,不更新状态
|
||||
// 状态更新由异步通知处理,避免重复处理
|
||||
// 这里只返回验签结果,让前端知道支付是否成功
|
||||
|
||||
return "TRADE_SUCCESS".equals(tradeStatus);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("处理噜噜支付同步返回异常:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成MD5签名(用于请求)
|
||||
* 签名规则:参数按key排序,拼接成 key1=value1&key2=value2&key=商户密钥,然后MD5
|
||||
*/
|
||||
private String generateMd5Sign(Map<String, String> params) throws Exception {
|
||||
String signContent = getSignContent(params) + merchantKey;
|
||||
logger.debug("MD5待签名字符串: {}", signContent);
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(signContent.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
|
||||
String sign = sb.toString();
|
||||
logger.debug("MD5签名结果: {}", sign);
|
||||
return sign;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证RSA签名(用于回调验签)
|
||||
* 使用平台公钥验证 SHA256WithRSA 签名
|
||||
*/
|
||||
private boolean verifyRsaSign(Map<String, String> params) {
|
||||
try {
|
||||
String sign = params.get("sign");
|
||||
if (sign == null || sign.isEmpty()) {
|
||||
logger.warn("签名为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
String signContent = getSignContent(params);
|
||||
logger.debug("RSA验签字符串: {}", signContent);
|
||||
|
||||
// 解析公钥
|
||||
byte[] keyBytes = Base64.getDecoder().decode(platformPublicKey.replaceAll("\\s+", ""));
|
||||
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
PublicKey publicKey = keyFactory.generatePublic(keySpec);
|
||||
|
||||
// SHA256WithRSA验签
|
||||
Signature signature = Signature.getInstance("SHA256withRSA");
|
||||
signature.initVerify(publicKey);
|
||||
signature.update(signContent.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
boolean result = signature.verify(Base64.getDecoder().decode(sign));
|
||||
if (!result) {
|
||||
logger.warn("RSA签名验证失败");
|
||||
}
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("RSA验签异常: {}", e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取待签名字符串(按key的ASCII码升序排序)
|
||||
*/
|
||||
private String getSignContent(Map<String, String> params) {
|
||||
TreeMap<String, String> sortedParams = new TreeMap<>(params);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
|
||||
String k = entry.getKey();
|
||||
String v = entry.getValue();
|
||||
|
||||
// 跳过sign、sign_type和空值
|
||||
if ("sign".equals(k) || "sign_type".equals(k) || v == null || v.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sb.length() > 0) {
|
||||
sb.append("&");
|
||||
}
|
||||
sb.append(k).append("=").append(v);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String escapeHtml(String str) {
|
||||
if (str == null) return "";
|
||||
return str.replace("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace("\"", """).replace("'", "'");
|
||||
}
|
||||
|
||||
private String generateOrderId() {
|
||||
return "LULU" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 6).toUpperCase();
|
||||
}
|
||||
|
||||
private void logPaymentError(String orderId, String payType, String errorMsg) {
|
||||
try {
|
||||
if (userErrorLogService != null) {
|
||||
userErrorLogService.logErrorAsync(null,
|
||||
com.example.demo.model.UserErrorLog.ErrorType.PAYMENT_ERROR,
|
||||
"噜噜支付创建失败: " + errorMsg, "LuluPayService", orderId, payType);
|
||||
}
|
||||
} catch (Exception logEx) {
|
||||
logger.warn("记录支付错误日志失败: {}", logEx.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,41 +240,49 @@ public class OrderService {
|
||||
*/
|
||||
private void handlePointsForStatusChange(Order order, OrderStatus oldStatus, OrderStatus newStatus) {
|
||||
if (order == null || order.getUser() == null) {
|
||||
logger.warn("handlePointsForStatusChange: order或user为空");
|
||||
return;
|
||||
}
|
||||
|
||||
User user = order.getUser();
|
||||
|
||||
// 根据订单描述获取会员等级和对应积分
|
||||
int points = getPointsFromOrderMembershipLevel(order);
|
||||
|
||||
if (points <= 0) {
|
||||
logger.info("无法从订单识别会员等级,不处理积分: orderId={}, description={}", order.getId(), order.getDescription());
|
||||
return;
|
||||
}
|
||||
logger.info("handlePointsForStatusChange: userId={}, orderId={}, oldStatus={}, newStatus={}",
|
||||
user.getId(), order.getId(), oldStatus, newStatus);
|
||||
|
||||
// 从非PAID状态变为PAID状态:增加积分并更新会员等级
|
||||
if (newStatus == OrderStatus.PAID && oldStatus != OrderStatus.PAID) {
|
||||
// 先更新会员等级和到期时间(无论积分是否为0都要更新)
|
||||
try {
|
||||
userService.addPoints(user.getId(), points);
|
||||
logger.info("✅ 订单状态变更增加积分: userId={}, orderId={}, points={}, {} -> {}",
|
||||
user.getId(), order.getId(), points, oldStatus, newStatus);
|
||||
|
||||
// 更新会员等级和到期时间
|
||||
updateMembershipForOrder(order, user);
|
||||
} catch (Exception e) {
|
||||
logger.error("增加积分失败: userId={}, orderId={}, points={}", user.getId(), order.getId(), points, e);
|
||||
logger.error("更新会员等级失败: userId={}, orderId={}", user.getId(), order.getId(), e);
|
||||
}
|
||||
|
||||
// 再处理积分
|
||||
int points = getPointsFromOrderMembershipLevel(order);
|
||||
if (points > 0) {
|
||||
try {
|
||||
userService.addPoints(user.getId(), points);
|
||||
logger.info("订单状态变更增加积分: userId={}, orderId={}, points={}",
|
||||
user.getId(), order.getId(), points);
|
||||
} catch (Exception e) {
|
||||
logger.error("增加积分失败: userId={}, orderId={}, points={}", user.getId(), order.getId(), points, e);
|
||||
}
|
||||
} else {
|
||||
logger.warn("积分为0,跳过积分增加: userId={}, orderId={}", user.getId(), order.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// 从PAID状态变为CANCELLED或REFUNDED状态:扣除积分
|
||||
if ((newStatus == OrderStatus.CANCELLED || newStatus == OrderStatus.REFUNDED) && oldStatus == OrderStatus.PAID) {
|
||||
try {
|
||||
userService.deductPoints(user.getId(), points);
|
||||
logger.info("✅ 订单状态变更扣除积分: userId={}, orderId={}, points={}, {} -> {}",
|
||||
user.getId(), order.getId(), points, oldStatus, newStatus);
|
||||
} catch (Exception e) {
|
||||
logger.error("扣除积分失败: userId={}, orderId={}, points={}", user.getId(), order.getId(), points, e);
|
||||
int points = getPointsFromOrderMembershipLevel(order);
|
||||
if (points > 0) {
|
||||
try {
|
||||
userService.deductPoints(user.getId(), points);
|
||||
logger.info("订单状态变更扣除积分: userId={}, orderId={}, points={}",
|
||||
user.getId(), order.getId(), points);
|
||||
} catch (Exception e) {
|
||||
logger.error("扣除积分失败: userId={}, orderId={}, points={}", user.getId(), order.getId(), points, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,18 +405,37 @@ public class OrderService {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("开始匹配会员等级,订单描述: {}", description);
|
||||
|
||||
// 从数据库获取所有会员等级,动态匹配
|
||||
MembershipLevel level = null;
|
||||
List<MembershipLevel> allLevels = membershipLevelRepository.findAll();
|
||||
String descLower = description.toLowerCase();
|
||||
|
||||
// 优先匹配中文关键词(更精确)
|
||||
for (MembershipLevel lvl : allLevels) {
|
||||
String name = lvl.getName();
|
||||
String displayName = lvl.getDisplayName();
|
||||
if (name == null) continue;
|
||||
|
||||
if ((name != null && descLower.contains(name.toLowerCase())) ||
|
||||
(displayName != null && descLower.contains(displayName.toLowerCase()))) {
|
||||
// 跳过免费版
|
||||
if ("free".equalsIgnoreCase(name)) continue;
|
||||
|
||||
// 匹配中文关键词
|
||||
if ("standard".equalsIgnoreCase(name) && description.contains("标准")) {
|
||||
level = lvl;
|
||||
logger.info("匹配到标准版会员: levelId={}, name={}", lvl.getId(), name);
|
||||
break;
|
||||
}
|
||||
if ("professional".equalsIgnoreCase(name) && (description.contains("专业") || description.contains("Pro"))) {
|
||||
level = lvl;
|
||||
logger.info("匹配到专业版会员: levelId={}, name={}", lvl.getId(), name);
|
||||
break;
|
||||
}
|
||||
|
||||
// 匹配 displayName
|
||||
String displayName = lvl.getDisplayName();
|
||||
if (displayName != null && description.contains(displayName)) {
|
||||
level = lvl;
|
||||
logger.info("通过displayName匹配到会员: levelId={}, displayName={}", lvl.getId(), displayName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -418,6 +445,7 @@ public class OrderService {
|
||||
return;
|
||||
}
|
||||
int durationDays = level.getDurationDays();
|
||||
logger.info("会员等级匹配成功: level={}, durationDays={}", level.getName(), durationDays);
|
||||
|
||||
// 查找或创建用户会员信息(按到期时间降序,返回最新的)
|
||||
Optional<UserMembership> membershipOpt = userMembershipRepository.findFirstByUserIdAndStatusOrderByEndDateDesc(user.getId(), "ACTIVE");
|
||||
@@ -427,6 +455,8 @@ public class OrderService {
|
||||
|
||||
if (membershipOpt.isPresent()) {
|
||||
membership = membershipOpt.get();
|
||||
logger.info("找到现有会员记录: membershipId={}, currentEndDate={}", membership.getId(), membership.getEndDate());
|
||||
|
||||
// 从数据库获取当前会员等级的价格,用价格判断等级高低(价格越高等级越高)
|
||||
// 会员等级只能升不能降:专业版 > 标准版 > 免费版
|
||||
Optional<MembershipLevel> currentLevelOpt = membershipLevelRepository.findById(membership.getMembershipLevelId());
|
||||
@@ -440,27 +470,33 @@ public class OrderService {
|
||||
}
|
||||
// 延长到期时间
|
||||
LocalDateTime currentEndDate = membership.getEndDate();
|
||||
if (currentEndDate.isAfter(now)) {
|
||||
LocalDateTime newEndDate;
|
||||
if (currentEndDate != null && currentEndDate.isAfter(now)) {
|
||||
// 如果还没过期,在当前到期时间基础上延长
|
||||
membership.setEndDate(currentEndDate.plusDays(durationDays));
|
||||
newEndDate = currentEndDate.plusDays(durationDays);
|
||||
logger.info("会员未过期,在现有基础上延长: {} + {}天 = {}", currentEndDate, durationDays, newEndDate);
|
||||
} else {
|
||||
// 如果已过期,从现在开始计算
|
||||
membership.setEndDate(now.plusDays(durationDays));
|
||||
newEndDate = now.plusDays(durationDays);
|
||||
logger.info("会员已过期或无到期时间,从现在开始计算: {} + {}天 = {}", now, durationDays, newEndDate);
|
||||
}
|
||||
membership.setEndDate(newEndDate);
|
||||
} else {
|
||||
// 创建新的会员记录
|
||||
logger.info("未找到现有会员记录,创建新记录");
|
||||
membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setMembershipLevelId(level.getId());
|
||||
membership.setStartDate(now);
|
||||
membership.setEndDate(now.plusDays(durationDays));
|
||||
membership.setStatus("ACTIVE");
|
||||
logger.info("新会员记录: startDate={}, endDate={}", now, membership.getEndDate());
|
||||
}
|
||||
|
||||
membership.setUpdatedAt(now);
|
||||
userMembershipRepository.save(membership);
|
||||
|
||||
logger.info("✅ 更新用户会员信息: userId={}, level={}, endDate={}",
|
||||
logger.info("✅ 更新用户会员信息成功: userId={}, level={}, endDate={}",
|
||||
user.getId(), level.getName(), membership.getEndDate());
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -468,6 +504,31 @@ public class OrderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保订单的会员信息已更新(用于处理订单状态已经是PAID但会员信息未更新的情况)
|
||||
* 这个方法不会重复增加积分,只会更新会员等级和到期时间
|
||||
*/
|
||||
public void ensureMembershipUpdated(Long orderId) {
|
||||
try {
|
||||
Order order = orderRepository.findByIdWithUser(orderId)
|
||||
.orElseThrow(() -> new RuntimeException("订单不存在:" + orderId));
|
||||
|
||||
if (order.getUser() == null) {
|
||||
logger.warn("ensureMembershipUpdated: 订单没有关联用户, orderId={}", orderId);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("ensureMembershipUpdated: 检查并确保会员信息已更新, orderId={}, userId={}",
|
||||
orderId, order.getUser().getId());
|
||||
|
||||
// 直接调用更新会员信息的方法
|
||||
updateMembershipForOrder(order, order.getUser());
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("ensureMembershipUpdated失败: orderId={}", orderId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
|
||||
@@ -84,27 +84,28 @@ public class PaymentService {
|
||||
Payment payment = paymentRepository.findByIdWithUserAndOrder(paymentId)
|
||||
.orElseThrow(() -> new RuntimeException("Not found"));
|
||||
|
||||
// 检查是否已经处理过(防止重复增加积分和重复创建订单)
|
||||
if (payment.getStatus() == PaymentStatus.SUCCESS) {
|
||||
logger.info("支付记录已经是成功状态,跳过重复处理: paymentId={}", paymentId);
|
||||
return payment;
|
||||
boolean alreadySuccess = payment.getStatus() == PaymentStatus.SUCCESS;
|
||||
|
||||
if (alreadySuccess) {
|
||||
logger.info("支付记录已经是成功状态: paymentId={}", paymentId);
|
||||
} else {
|
||||
// 首次确认支付成功
|
||||
payment.setStatus(PaymentStatus.SUCCESS);
|
||||
payment.setPaidAt(LocalDateTime.now());
|
||||
payment.setExternalTransactionId(externalTransactionId);
|
||||
payment = paymentRepository.save(payment);
|
||||
logger.info("支付状态更新为SUCCESS: paymentId={}", paymentId);
|
||||
}
|
||||
|
||||
payment.setStatus(PaymentStatus.SUCCESS);
|
||||
payment.setPaidAt(LocalDateTime.now());
|
||||
payment.setExternalTransactionId(externalTransactionId);
|
||||
Payment savedPayment = paymentRepository.save(payment);
|
||||
|
||||
// 支付成功后更新订单状态为已支付
|
||||
// 注意:积分添加逻辑已移至 OrderService.handlePointsForStatusChange
|
||||
// 当订单状态变为 PAID 时会自动添加积分,避免重复添加
|
||||
// 无论是否已经是SUCCESS状态,都要确保订单状态和会员信息已更新
|
||||
// 这样可以处理同步返回先更新状态但没有更新会员信息的情况
|
||||
try {
|
||||
updateOrderStatusForPayment(savedPayment);
|
||||
updateOrderStatusForPayment(payment);
|
||||
} catch (Exception e) {
|
||||
logger.error("支付成功但更新订单状态失败: paymentId={}, error={}", paymentId, e.getMessage(), e);
|
||||
}
|
||||
|
||||
return savedPayment;
|
||||
return payment;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,16 +120,38 @@ public class PaymentService {
|
||||
// 检查是否已经关联了订单
|
||||
Order order = payment.getOrder();
|
||||
if (order == null) {
|
||||
logger.warn("支付记录未关联订单,无法更新状态: paymentId={}", payment.getId());
|
||||
return;
|
||||
// 如果没有关联订单,需要创建一个订单并关联
|
||||
logger.warn("支付记录未关联订单,尝试创建订单: paymentId={}", payment.getId());
|
||||
try {
|
||||
order = createOrderForPayment(payment);
|
||||
payment.setOrder(order);
|
||||
paymentRepository.save(payment);
|
||||
logger.info("✅ 为支付记录创建并关联订单成功: paymentId={}, orderId={}", payment.getId(), order.getId());
|
||||
} catch (Exception e) {
|
||||
logger.error("为支付记录创建订单失败: paymentId={}, error={}", payment.getId(), e.getMessage(), e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 直接调用orderService.updateOrderStatus,不要在这里修改order状态
|
||||
// orderService.updateOrderStatus会正确处理状态变更和积分添加
|
||||
orderService.updateOrderStatus(order.getId(), OrderStatus.PAID);
|
||||
// 检查订单当前状态
|
||||
OrderStatus currentStatus = order.getStatus();
|
||||
logger.info("当前订单状态: orderId={}, status={}", order.getId(), currentStatus);
|
||||
|
||||
logger.info("✅ 订单状态更新为已支付: orderId={}, orderNumber={}, paymentId={}",
|
||||
if (currentStatus == OrderStatus.PAID) {
|
||||
// 订单已经是PAID状态,但可能会员信息没有更新
|
||||
// 直接调用handlePointsForStatusChange来确保会员信息已更新
|
||||
logger.info("订单已是PAID状态,检查并确保会员信息已更新: orderId={}", order.getId());
|
||||
// 重新触发会员更新逻辑(通过设置相同状态不会重复增加积分,因为oldStatus == newStatus)
|
||||
// 但这样不会触发handlePointsForStatusChange,所以我们需要直接调用
|
||||
// 为了避免重复增加积分,我们在OrderService中添加一个专门的方法
|
||||
orderService.ensureMembershipUpdated(order.getId());
|
||||
} else {
|
||||
// 订单不是PAID状态,正常更新
|
||||
orderService.updateOrderStatus(order.getId(), OrderStatus.PAID);
|
||||
}
|
||||
|
||||
logger.info("✅ 订单状态处理完成: orderId={}, orderNumber={}, paymentId={}",
|
||||
order.getId(), order.getOrderNumber(), payment.getId());
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -138,6 +161,22 @@ public class PaymentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为没有关联订单的支付记录创建订单
|
||||
*/
|
||||
private Order createOrderForPayment(Payment payment) {
|
||||
Order order = new Order();
|
||||
order.setUser(payment.getUser());
|
||||
order.setOrderNumber("ORD" + System.currentTimeMillis());
|
||||
order.setTotalAmount(payment.getAmount());
|
||||
order.setCurrency(payment.getCurrency() != null ? payment.getCurrency() : "CNY");
|
||||
order.setStatus(OrderStatus.PENDING); // 待支付状态,后面会更新为PAID
|
||||
order.setOrderType(OrderType.SUBSCRIPTION);
|
||||
order.setDescription(payment.getDescription() != null ? payment.getDescription() : "会员订阅");
|
||||
|
||||
return orderService.createOrder(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据支付金额增加用户积分
|
||||
* 从 system_settings 读取配置的价格来判断套餐类型
|
||||
@@ -245,8 +284,15 @@ public class PaymentService {
|
||||
// 验证用户和金额是否匹配
|
||||
if (existingPayment.getUser().getUsername().equals(username) &&
|
||||
existingPayment.getAmount().compareTo(new BigDecimal(amountStr)) == 0) {
|
||||
logger.info("复用已存在的PENDING支付记录: orderId={}, paymentId={}", orderId, existingPayment.getId());
|
||||
return existingPayment;
|
||||
// 检查是否已关联订单,如果没有则需要补充关联
|
||||
if (existingPayment.getOrder() == null) {
|
||||
logger.warn("复用的PENDING支付记录未关联订单,需要创建订单并关联: orderId={}, paymentId={}", orderId, existingPayment.getId());
|
||||
// 不复用,创建新的支付记录和订单
|
||||
orderId = orderId + "_" + System.currentTimeMillis();
|
||||
} else {
|
||||
logger.info("复用已存在的PENDING支付记录: orderId={}, paymentId={}", orderId, existingPayment.getId());
|
||||
return existingPayment;
|
||||
}
|
||||
} else {
|
||||
// 用户或金额不匹配,生成新的 orderId
|
||||
logger.warn("已存在相同orderId的PENDING支付,但用户或金额不匹配,生成新orderId: {}", orderId);
|
||||
|
||||
@@ -571,10 +571,10 @@ public class RealAIService {
|
||||
}
|
||||
|
||||
} catch (UnirestException e) {
|
||||
logger.error("查询任务状态异常: {}", taskId, e);
|
||||
logger.error("查询任务状态异常: taskId={}", taskId, e);
|
||||
throw new RuntimeException("查询任务状态失败: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
logger.error("查询任务状态异常: {}", taskId, e);
|
||||
logger.error("查询任务状态异常: taskId={}", taskId, e);
|
||||
throw new RuntimeException("查询任务状态失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -1334,18 +1334,34 @@ public class RealAIService {
|
||||
* @return 优化后的提示词
|
||||
*/
|
||||
public String optimizePromptWithImages(String prompt, String type, List<String> imageUrls) {
|
||||
return optimizePromptWithImages(prompt, type, imageUrls, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 多模态提示词优化(支持自定义系统提示词)
|
||||
*
|
||||
* @param prompt 原始提示词
|
||||
* @param type 优化类型
|
||||
* @param imageUrls 参考图片列表(Base64 或 URL)
|
||||
* @param customSystemPrompt 自定义系统提示词(如果为null则使用默认)
|
||||
* @return 优化后的提示词
|
||||
*/
|
||||
public String optimizePromptWithImages(String prompt, String type, List<String> imageUrls, String customSystemPrompt) {
|
||||
// 如果没有图片,直接调用普通方法
|
||||
if (imageUrls == null || imageUrls.isEmpty()) {
|
||||
return optimizePrompt(prompt, type);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info("开始多模态提示词优化: prompt长度={}, type={}, 图片数量={}",
|
||||
prompt.length(), type, imageUrls.size());
|
||||
logger.info("开始多模态提示词优化: prompt长度={}, type={}, 图片数量={}, 自定义系统提示词={}",
|
||||
prompt.length(), type, imageUrls.size(), customSystemPrompt != null ? "有" : "无");
|
||||
|
||||
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
|
||||
|
||||
String systemPrompt = getOptimizationPrompt(type);
|
||||
// 优先使用自定义系统提示词,否则使用默认
|
||||
String systemPrompt = (customSystemPrompt != null && !customSystemPrompt.trim().isEmpty())
|
||||
? customSystemPrompt
|
||||
: getOptimizationPrompt(type);
|
||||
|
||||
// 提示词优化使用统一的 API base URL
|
||||
String apiUrl = getEffectiveApiBaseUrl();
|
||||
@@ -1376,10 +1392,16 @@ public class RealAIService {
|
||||
// 构建 content 数组(多模态格式)
|
||||
List<Map<String, Object>> contentArray = new java.util.ArrayList<>();
|
||||
|
||||
// 添加文本部分
|
||||
// 添加文本部分(如果有自定义系统提示词,直接使用用户提示词;否则添加默认前缀)
|
||||
Map<String, Object> textContent = new HashMap<>();
|
||||
textContent.put("type", "text");
|
||||
textContent.put("text", "请根据以下用户需求和参考图片,优化生成专业的分镜提示词:\n\n" + prompt);
|
||||
if (customSystemPrompt != null && !customSystemPrompt.trim().isEmpty()) {
|
||||
// 使用自定义系统提示词时,直接发送用户提示词
|
||||
textContent.put("text", prompt);
|
||||
} else {
|
||||
// 使用默认系统提示词时,添加引导前缀
|
||||
textContent.put("text", "请根据以下用户需求和参考图片,优化生成专业的分镜提示词:\n\n" + prompt);
|
||||
}
|
||||
contentArray.add(textContent);
|
||||
|
||||
// 添加图片部分(最多3张)
|
||||
|
||||
@@ -125,7 +125,7 @@ public class StoryboardVideoService {
|
||||
// 创建任务
|
||||
StoryboardVideoTask task = new StoryboardVideoTask(username, prompt.trim(), aspectRatio, hdMode, duration);
|
||||
task.setTaskId(taskId);
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.PENDING);
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.PROCESSING); // 直接设为生成中
|
||||
task.setProgress(0);
|
||||
|
||||
// 设置图像生成模型
|
||||
@@ -229,6 +229,171 @@ public class StoryboardVideoService {
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接使用上传的分镜图创建视频任务(跳过分镜图生成)
|
||||
* 用户在STEP2上传分镜图后直接生成视频时调用
|
||||
* 只冻结视频生成积分(30积分),不冻结分镜图生成积分
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public StoryboardVideoTask createVideoDirectTask(String username, String prompt, String storyboardImage,
|
||||
String aspectRatio, boolean hdMode, Integer duration, List<String> referenceImages) {
|
||||
// 验证参数
|
||||
if (username == null || username.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("用户名不能为空");
|
||||
}
|
||||
if (storyboardImage == null || storyboardImage.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("分镜图不能为空");
|
||||
}
|
||||
|
||||
// 检查用户所有类型任务的总数
|
||||
userWorkService.checkMaxConcurrentTasks(username);
|
||||
|
||||
// 生成任务ID
|
||||
String taskId = generateTaskId();
|
||||
|
||||
// 只冻结视频生成积分(30积分),不冻结分镜图生成积分
|
||||
try {
|
||||
userService.freezePoints(username, taskId + "_vid",
|
||||
com.example.demo.model.PointsFreezeRecord.TaskType.STORYBOARD_VIDEO,
|
||||
30, "分镜视频生成(直接上传分镜图)");
|
||||
logger.info("视频积分冻结成功: taskId={}, username={}, points=30", taskId, username);
|
||||
} catch (Exception e) {
|
||||
logger.error("视频积分冻结失败: taskId={}, username={}", taskId, username, e);
|
||||
throw new RuntimeException("积分不足,无法创建任务: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 创建任务
|
||||
StoryboardVideoTask task = new StoryboardVideoTask(username, prompt != null ? prompt.trim() : "根据分镜图生成视频", aspectRatio, hdMode, duration);
|
||||
task.setTaskId(taskId);
|
||||
task.setStatus(StoryboardVideoTask.TaskStatus.PROCESSING); // 直接设为生成中
|
||||
task.setProgress(50); // 分镜图已完成,从50%开始
|
||||
|
||||
// 处理分镜图:如果是Base64,上传到COS
|
||||
String finalStoryboardImage = storyboardImage;
|
||||
if (storyboardImage.startsWith("data:image") && cosService.isEnabled()) {
|
||||
try {
|
||||
String cosUrl = cosService.uploadBase64Image(storyboardImage, "storyboard_" + taskId + ".png");
|
||||
if (cosUrl != null && !cosUrl.isEmpty()) {
|
||||
finalStoryboardImage = cosUrl;
|
||||
logger.info("分镜图上传COS成功: taskId={}", taskId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("分镜图上传COS异常: taskId={}", taskId, e);
|
||||
}
|
||||
}
|
||||
task.setResultUrl(finalStoryboardImage); // 设置分镜图URL
|
||||
|
||||
// 处理视频参考图
|
||||
if (referenceImages != null && !referenceImages.isEmpty()) {
|
||||
try {
|
||||
List<String> processedImages = new ArrayList<>();
|
||||
int imageIndex = 0;
|
||||
for (String img : referenceImages) {
|
||||
if (img == null || img.isEmpty() || "null".equals(img)) {
|
||||
continue;
|
||||
}
|
||||
if (img.startsWith("data:image") && cosService.isEnabled()) {
|
||||
try {
|
||||
String cosUrl = cosService.uploadBase64Image(img, "videoref_" + taskId + "_" + imageIndex + ".png");
|
||||
if (cosUrl != null && !cosUrl.isEmpty()) {
|
||||
processedImages.add(cosUrl);
|
||||
} else {
|
||||
processedImages.add(img);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
processedImages.add(img);
|
||||
}
|
||||
} else {
|
||||
processedImages.add(img);
|
||||
}
|
||||
imageIndex++;
|
||||
}
|
||||
|
||||
if (!processedImages.isEmpty()) {
|
||||
String videoRefImagesJson = objectMapper.writeValueAsString(processedImages);
|
||||
task.setVideoReferenceImages(videoRefImagesJson);
|
||||
logger.info("保存视频参考图: taskId={}, 数量={}", taskId, processedImages.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("处理视频参考图失败: taskId={}", taskId, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存任务
|
||||
task = taskRepository.save(task);
|
||||
logger.info("直接视频任务创建成功: {}, 用户: {}", taskId, username);
|
||||
|
||||
// 创建PROCESSING状态的UserWork
|
||||
try {
|
||||
userWorkService.createProcessingStoryboardVideoWork(task);
|
||||
logger.info("创建PROCESSING状态分镜视频作品成功: {}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.warn("创建PROCESSING状态作品失败(不影响任务执行): {}", taskId, e);
|
||||
}
|
||||
|
||||
// 事务提交后,直接开始视频生成(跳过分镜图生成)
|
||||
final String finalTaskId = taskId;
|
||||
final StoryboardVideoService self = applicationContext.getBean(StoryboardVideoService.class);
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
// 直接调用视频生成,跳过分镜图生成
|
||||
self.processVideoGenerationAsync(finalTaskId);
|
||||
}
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接处理视频生成(异步)- 跳过分镜图生成
|
||||
* 用于用户直接上传分镜图后生成视频的场景
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
public void processVideoGenerationAsync(String taskId) {
|
||||
try {
|
||||
logger.info("开始直接视频生成(跳过分镜图生成): taskId={}", taskId);
|
||||
|
||||
// 短暂等待确保数据库同步完成
|
||||
Thread.sleep(500);
|
||||
|
||||
// 将任务添加到队列并直接处理
|
||||
StoryboardVideoTask task = loadTaskInfoWithTransactionTemplate(taskId);
|
||||
if (task == null) {
|
||||
logger.error("任务不存在: taskId={}", taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到任务队列
|
||||
try {
|
||||
taskQueueService.addStoryboardVideoTask(task.getUsername(), taskId);
|
||||
logger.info("视频任务已添加到队列: taskId={}", taskId);
|
||||
} catch (Exception e) {
|
||||
logger.error("添加视频任务到队列失败: taskId={}", taskId, e);
|
||||
// 返还积分
|
||||
try {
|
||||
userService.returnFrozenPoints(taskId + "_vid");
|
||||
} catch (Exception re) {
|
||||
logger.warn("返还积分失败: taskId={}_vid", taskId);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 直接处理视频生成
|
||||
taskQueueService.processStoryboardVideoTaskDirectly(taskId);
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("直接视频生成失败: taskId={}", taskId, e);
|
||||
// 更新任务状态为失败
|
||||
try {
|
||||
updateTaskStatusToFailed(taskId, "视频生成失败: " + e.getMessage());
|
||||
} catch (Exception ue) {
|
||||
logger.error("更新任务状态失败: taskId={}", taskId, ue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用真实API处理任务(异步)
|
||||
* 注意:此方法明确禁用事务,因为长时间运行的外部API调用会占用数据库连接
|
||||
@@ -288,13 +453,13 @@ public class StoryboardVideoService {
|
||||
String storyboardSystemPrompt = settings.getStoryboardSystemPrompt();
|
||||
|
||||
if (storyboardSystemPrompt != null && !storyboardSystemPrompt.trim().isEmpty()) {
|
||||
// 组合提示词并调用优化 API
|
||||
String combinedPrompt = storyboardSystemPrompt.trim() + "\n\n用户需求:" + prompt;
|
||||
// 使用后台配置的系统引导词作为系统消息,用户原始提示词作为用户消息
|
||||
logger.info("开始调用提示词优化API,获取 JSON 格式结果...");
|
||||
|
||||
// 调用多模态提示词优化(传递用户上传的图片)
|
||||
// 调用多模态提示词优化(传递用户上传的图片和自定义系统提示词)
|
||||
// 期望返回 JSON(包含 shotList、imagePrompt、videoPrompt)
|
||||
String optimizedResult = realAIService.optimizePromptWithImages(combinedPrompt, "storyboard", userUploadedImages);
|
||||
String userPrompt = "用户需求:" + prompt;
|
||||
String optimizedResult = realAIService.optimizePromptWithImages(userPrompt, "storyboard", userUploadedImages, storyboardSystemPrompt);
|
||||
|
||||
// 尝试解析 JSON 响应
|
||||
try {
|
||||
@@ -360,6 +525,14 @@ public class StoryboardVideoService {
|
||||
final String finalShotList = shotListResult;
|
||||
final String finalImagePrompt = finalPrompt;
|
||||
final String finalVideoPrompt = videoPromptResult;
|
||||
|
||||
// 调试日志:检查即将保存的提示词
|
||||
logger.info("【调试】即将保存提示词: taskId={}, shotList长度={}, imagePrompt长度={}, videoPrompt长度={}",
|
||||
taskId,
|
||||
finalShotList != null ? finalShotList.length() : 0,
|
||||
finalImagePrompt != null ? finalImagePrompt.length() : 0,
|
||||
finalVideoPrompt != null ? finalVideoPrompt.length() : 0);
|
||||
|
||||
asyncTransactionTemplate.executeWithoutResult(status -> {
|
||||
StoryboardVideoTask t = taskRepository.findByTaskId(taskId).orElse(null);
|
||||
if (t != null) {
|
||||
@@ -367,21 +540,24 @@ public class StoryboardVideoService {
|
||||
t.setImagePrompt(finalImagePrompt);
|
||||
t.setVideoPrompt(finalVideoPrompt);
|
||||
taskRepository.save(t);
|
||||
logger.info("【调试】提示词已保存到数据库: taskId={}", taskId);
|
||||
} else {
|
||||
logger.error("【调试】保存提示词失败:任务不存在: taskId={}", taskId);
|
||||
}
|
||||
});
|
||||
logger.info("已保存优化后的提示词到任务: taskId={}", taskId);
|
||||
|
||||
} catch (Exception jsonException) {
|
||||
// JSON 解析失败,使用原始优化结果作为 imagePrompt 和 videoPrompt
|
||||
logger.warn("JSON 解析失败,使用原始优化结果: {}", jsonException.getMessage());
|
||||
finalPrompt = optimizedResult;
|
||||
// 保存优化结果到 imagePrompt 和 videoPrompt
|
||||
final String optimizedPrompt = optimizedResult;
|
||||
// JSON 解析失败,使用用户原始提示词(不是AI返回的完整响应)
|
||||
logger.warn("JSON 解析失败,使用用户原始提示词: {}", jsonException.getMessage());
|
||||
// 不要使用 optimizedResult,因为它可能包含系统引导词
|
||||
// 使用用户的原始提示词
|
||||
final String originalPrompt = prompt;
|
||||
asyncTransactionTemplate.executeWithoutResult(status -> {
|
||||
StoryboardVideoTask t = taskRepository.findByTaskId(taskId).orElse(null);
|
||||
if (t != null) {
|
||||
t.setImagePrompt(optimizedPrompt);
|
||||
t.setVideoPrompt(optimizedPrompt);
|
||||
t.setImagePrompt(originalPrompt);
|
||||
t.setVideoPrompt(originalPrompt);
|
||||
taskRepository.save(t);
|
||||
}
|
||||
});
|
||||
@@ -969,7 +1145,12 @@ public class StoryboardVideoService {
|
||||
}
|
||||
|
||||
if (effectiveStoryboardUrl == null || effectiveStoryboardUrl.isEmpty()) {
|
||||
throw new RuntimeException("分镜图尚未生成,无法生成视频");
|
||||
// 根据任务状态给出更友好的提示
|
||||
if (task.getStatus() == StoryboardVideoTask.TaskStatus.PROCESSING) {
|
||||
throw new RuntimeException("分镜图正在生成中,请等待生成完成后再生成视频");
|
||||
} else {
|
||||
throw new RuntimeException("请先上传或生成分镜图,再生成视频");
|
||||
}
|
||||
}
|
||||
|
||||
// 更新任务的resultUrl为有效的分镜图URL(确保后续处理能获取到)
|
||||
|
||||
@@ -115,7 +115,7 @@ public class TaskQueueService {
|
||||
* 配合 MAX_TASKS_PER_USER=3,理论上可支持5个用户同时处理任务(最多15个并发任务)
|
||||
* 可根据服务器性能调整此值以支持更多并发
|
||||
*/
|
||||
private static final int CONSUMER_THREAD_COUNT = 10;
|
||||
private static final int CONSUMER_THREAD_COUNT = 20;
|
||||
|
||||
/**
|
||||
* 是否正在运行
|
||||
@@ -304,40 +304,70 @@ public class TaskQueueService {
|
||||
} else if (isMaxCheckReached) {
|
||||
failReason = "任务超时(检查次数已达上限)";
|
||||
} else {
|
||||
// 未超时,查询外部API状态
|
||||
try {
|
||||
logger.info("系统重启:查询任务 {} 的外部API状态(realTaskId={})",
|
||||
taskQueue.getTaskId(), taskQueue.getRealTaskId());
|
||||
|
||||
Map<String, Object> statusResponse = realAIService.getTaskStatus(taskQueue.getRealTaskId());
|
||||
|
||||
if (statusResponse != null && statusResponse.containsKey("data")) {
|
||||
Object data = statusResponse.get("data");
|
||||
if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> dataMap = (Map<String, Object>) data;
|
||||
String externalStatus = String.valueOf(dataMap.get("status"));
|
||||
|
||||
// 只有外部API状态为"进行中"才恢复
|
||||
if ("Processing".equalsIgnoreCase(externalStatus) ||
|
||||
"Pending".equalsIgnoreCase(externalStatus) ||
|
||||
"Running".equalsIgnoreCase(externalStatus) ||
|
||||
"Queued".equalsIgnoreCase(externalStatus)) {
|
||||
shouldRecover = true;
|
||||
logger.info("系统重启:任务 {} 外部API状态为{},恢复轮询",
|
||||
taskQueue.getTaskId(), externalStatus);
|
||||
} else {
|
||||
failReason = "外部API状态: " + externalStatus;
|
||||
logger.warn("系统重启:任务 {} 外部API状态为{},不恢复",
|
||||
taskQueue.getTaskId(), externalStatus);
|
||||
// 未超时,查询外部API状态(最多重试3次)
|
||||
int maxRetries = 3;
|
||||
int retryCount = 0;
|
||||
boolean apiQuerySuccess = false;
|
||||
String lastError = null;
|
||||
|
||||
while (retryCount < maxRetries && !apiQuerySuccess) {
|
||||
retryCount++;
|
||||
try {
|
||||
logger.info("系统重启:查询任务 {} 的外部API状态(realTaskId={},第{}次尝试)",
|
||||
taskQueue.getTaskId(), taskQueue.getRealTaskId(), retryCount);
|
||||
|
||||
Map<String, Object> statusResponse = realAIService.getTaskStatus(taskQueue.getRealTaskId());
|
||||
|
||||
if (statusResponse != null && statusResponse.containsKey("data")) {
|
||||
Object data = statusResponse.get("data");
|
||||
if (data instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> dataMap = (Map<String, Object>) data;
|
||||
String externalStatus = String.valueOf(dataMap.get("status"));
|
||||
|
||||
apiQuerySuccess = true; // API查询成功
|
||||
|
||||
// 只有外部API状态为"进行中"或"已完成"才恢复
|
||||
if ("Processing".equalsIgnoreCase(externalStatus) ||
|
||||
"Pending".equalsIgnoreCase(externalStatus) ||
|
||||
"Running".equalsIgnoreCase(externalStatus) ||
|
||||
"Queued".equalsIgnoreCase(externalStatus) ||
|
||||
"Success".equalsIgnoreCase(externalStatus) ||
|
||||
"Completed".equalsIgnoreCase(externalStatus)) {
|
||||
shouldRecover = true;
|
||||
logger.info("系统重启:任务 {} 外部API状态为{},恢复轮询",
|
||||
taskQueue.getTaskId(), externalStatus);
|
||||
} else {
|
||||
failReason = "外部API状态: " + externalStatus;
|
||||
logger.warn("系统重启:任务 {} 外部API状态为{},不恢复",
|
||||
taskQueue.getTaskId(), externalStatus);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastError = "无法获取外部API状态";
|
||||
logger.warn("系统重启:查询任务 {} 外部状态返回空(第{}次)", taskQueue.getTaskId(), retryCount);
|
||||
}
|
||||
} else {
|
||||
failReason = "无法获取外部API状态";
|
||||
} catch (Exception e) {
|
||||
lastError = e.getMessage();
|
||||
logger.warn("系统重启:查询任务 {} 外部状态失败(第{}次): {}", taskQueue.getTaskId(), retryCount, e.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
failReason = "查询外部API失败: " + e.getMessage();
|
||||
logger.warn("系统重启:查询任务 {} 外部状态失败: {}", taskQueue.getTaskId(), e.getMessage());
|
||||
|
||||
// 如果还需要重试,等待1秒
|
||||
if (!apiQuerySuccess && retryCount < maxRetries) {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果3次查询都失败,仍然恢复任务(让后续轮询继续处理)
|
||||
if (!apiQuerySuccess) {
|
||||
shouldRecover = true; // 查询失败时也恢复,让轮询服务继续处理
|
||||
logger.warn("系统重启:任务 {} 连续{}次查询外部API失败,但仍恢复任务继续轮询(最后错误: {})",
|
||||
taskQueue.getTaskId(), maxRetries, lastError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1122,6 +1152,14 @@ public class TaskQueueService {
|
||||
// 使用只读事务快速查询任务信息
|
||||
StoryboardVideoTask task = getStoryboardVideoTaskWithTransaction(taskQueue.getTaskId());
|
||||
|
||||
// 调试日志:检查从数据库加载的提示词字段
|
||||
logger.info("【调试】任务提示词字段: taskId={}, prompt={}, shotList={}, imagePrompt={}, videoPrompt={}",
|
||||
task.getTaskId(),
|
||||
task.getPrompt() != null ? task.getPrompt().substring(0, Math.min(50, task.getPrompt().length())) + "..." : "null",
|
||||
task.getShotList() != null ? "长度:" + task.getShotList().length() : "null",
|
||||
task.getImagePrompt() != null ? "长度:" + task.getImagePrompt().length() : "null",
|
||||
task.getVideoPrompt() != null ? "长度:" + task.getVideoPrompt().length() : "null");
|
||||
|
||||
// 获取6张分镜图片
|
||||
List<String> images = null;
|
||||
String storyboardImagesJson = task.getStoryboardImages();
|
||||
@@ -1255,9 +1293,14 @@ public class TaskQueueService {
|
||||
if (videoPromptBuilder.length() > 0) {
|
||||
videoPromptForApi = videoPromptBuilder.toString();
|
||||
logger.info("组合后的视频提示词长度: {}", videoPromptForApi.length());
|
||||
// 调试:显示实际使用的提示词前200字符
|
||||
logger.info("【调试】实际使用的视频提示词: {}",
|
||||
videoPromptForApi.length() > 200 ? videoPromptForApi.substring(0, 200) + "..." : videoPromptForApi);
|
||||
} else {
|
||||
videoPromptForApi = task.getPrompt();
|
||||
logger.info("使用原始提示词生成视频");
|
||||
logger.warn("【警告】shotList 和 videoPrompt 都为空,使用用户原始提示词: {}",
|
||||
videoPromptForApi != null ? videoPromptForApi.substring(0, Math.min(100, videoPromptForApi.length())) : "null");
|
||||
}
|
||||
|
||||
// 使用拼好的图片调用图生视频接口
|
||||
@@ -1550,7 +1593,8 @@ public class TaskQueueService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskQueue.getRealTaskId() == null) {
|
||||
if (taskQueue.getRealTaskId() == null || taskQueue.getRealTaskId().isEmpty()
|
||||
|| "<no value>".equals(taskQueue.getRealTaskId())) {
|
||||
// 增加时间窗口保护:只有任务创建超过5分钟后 realTaskId 仍为空才标记为失败
|
||||
// 避免任务刚创建还在等待外部API响应时就被错误地标记为失败
|
||||
java.time.LocalDateTime fiveMinutesAgo = java.time.LocalDateTime.now().minusMinutes(5);
|
||||
|
||||
@@ -126,70 +126,97 @@ public class TaskStatusPollingService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 有外部任务ID,查询外部API状态
|
||||
try {
|
||||
String url = dynamicApiConfig.getApiBaseUrl() + "/v2/videos/generations/" + task.getExternalTaskId();
|
||||
HttpResponse<String> response = Unirest.get(url)
|
||||
.header("Authorization", "Bearer " + dynamicApiConfig.getApiKey())
|
||||
.asString();
|
||||
|
||||
if (response.getStatus() == 200) {
|
||||
JsonNode responseJson = objectMapper.readTree(response.getBody());
|
||||
String status = responseJson.path("status").asText();
|
||||
String resultUrl = responseJson.path("data").path("output").asText();
|
||||
// 有外部任务ID,查询外部API状态(最多重试3次)
|
||||
int maxRetries = 3;
|
||||
int retryCount = 0;
|
||||
boolean apiQuerySuccess = false;
|
||||
String lastError = null;
|
||||
|
||||
while (retryCount < maxRetries && !apiQuerySuccess) {
|
||||
retryCount++;
|
||||
try {
|
||||
logger.info("恢复检查任务 {} 外部API状态(第{}次尝试)", task.getTaskId(), retryCount);
|
||||
|
||||
logger.info("外部API返回: taskId={}, status={}, resultUrl={}",
|
||||
task.getTaskId(), status, resultUrl);
|
||||
String url = dynamicApiConfig.getApiBaseUrl() + "/v2/videos/generations/" + task.getExternalTaskId();
|
||||
HttpResponse<String> response = Unirest.get(url)
|
||||
.header("Authorization", "Bearer " + dynamicApiConfig.getApiKey())
|
||||
.asString();
|
||||
|
||||
if ("SUCCESS".equalsIgnoreCase(status) && resultUrl != null && !resultUrl.isEmpty()) {
|
||||
// 任务已完成,标记成功并同步更新所有表
|
||||
task.markAsCompleted(resultUrl);
|
||||
taskStatusRepository.save(task);
|
||||
logger.info("任务 {} 恢复为已完成状态,resultUrl={}", task.getTaskId(), resultUrl);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskCompletionByTaskId(task.getTaskId(), resultUrl);
|
||||
} else if ("FAILED".equalsIgnoreCase(status) || "FAILURE".equalsIgnoreCase(status) || "ERROR".equalsIgnoreCase(status)) {
|
||||
// 任务失败
|
||||
String failReason = responseJson.path("fail_reason").asText("外部API返回失败");
|
||||
task.markAsFailed(failReason);
|
||||
taskStatusRepository.save(task);
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(task.getTaskId())) {
|
||||
logger.warn("任务 {} 恢复为失败状态,原因: {}", task.getTaskId(), failReason);
|
||||
}
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), failReason);
|
||||
} else {
|
||||
// 任务仍在处理中,检查是否超时
|
||||
if (isTaskTimeout(task)) {
|
||||
if (response.getStatus() == 200) {
|
||||
apiQuerySuccess = true;
|
||||
JsonNode responseJson = objectMapper.readTree(response.getBody());
|
||||
String status = responseJson.path("status").asText();
|
||||
String resultUrl = responseJson.path("data").path("output").asText();
|
||||
|
||||
logger.info("外部API返回: taskId={}, status={}, resultUrl={}",
|
||||
task.getTaskId(), status, resultUrl);
|
||||
|
||||
if ("SUCCESS".equalsIgnoreCase(status) && resultUrl != null && !resultUrl.isEmpty()) {
|
||||
// 任务已完成,标记成功并同步更新所有表
|
||||
task.markAsCompleted(resultUrl);
|
||||
taskStatusRepository.save(task);
|
||||
logger.info("任务 {} 恢复为已完成状态,resultUrl={}", task.getTaskId(), resultUrl);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskCompletionByTaskId(task.getTaskId(), resultUrl);
|
||||
} else if ("FAILED".equalsIgnoreCase(status) || "FAILURE".equalsIgnoreCase(status) || "ERROR".equalsIgnoreCase(status)) {
|
||||
// 任务失败
|
||||
String failReason = responseJson.path("fail_reason").asText("外部API返回失败");
|
||||
task.markAsFailed(failReason);
|
||||
taskStatusRepository.save(task);
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(task.getTaskId())) {
|
||||
logger.warn("任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
logger.warn("任务 {} 恢复为失败状态,原因: {}", task.getTaskId(), failReason);
|
||||
}
|
||||
task.markAsFailed("任务超时");
|
||||
taskStatusRepository.save(task);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时");
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), failReason);
|
||||
} else {
|
||||
logger.info("任务 {} 仍在处理中且未超时,保持现状", task.getTaskId());
|
||||
// 任务仍在处理中,检查是否超时
|
||||
if (isTaskTimeout(task)) {
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(task.getTaskId())) {
|
||||
logger.warn("任务 {} 已超时,标记为失败", task.getTaskId());
|
||||
}
|
||||
task.markAsFailed("任务超时");
|
||||
taskStatusRepository.save(task);
|
||||
// 同步更新业务表、UserWork 等
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时");
|
||||
} else {
|
||||
logger.info("任务 {} 仍在处理中且未超时,保持现状", task.getTaskId());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastError = "HTTP状态码: " + response.getStatus();
|
||||
logger.warn("查询任务状态失败(第{}次): taskId={}, status={}", retryCount, task.getTaskId(), response.getStatus());
|
||||
}
|
||||
} else {
|
||||
logger.warn("查询任务状态失败: taskId={}, status={}", task.getTaskId(), response.getStatus());
|
||||
// 检查是否超时
|
||||
if (isTaskTimeout(task)) {
|
||||
task.markAsFailed("任务超时:无法获取外部状态");
|
||||
taskStatusRepository.save(task);
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时:无法获取外部状态");
|
||||
} catch (Exception e) {
|
||||
lastError = e.getMessage();
|
||||
logger.warn("查询外部API失败(第{}次): taskId={}, error={}", retryCount, task.getTaskId(), e.getMessage());
|
||||
}
|
||||
|
||||
// 如果还需要重试,等待1秒
|
||||
if (!apiQuerySuccess && retryCount < maxRetries) {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("查询外部API失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
|
||||
}
|
||||
|
||||
// 如果3次查询都失败
|
||||
if (!apiQuerySuccess) {
|
||||
// 检查是否超时
|
||||
if (isTaskTimeout(task)) {
|
||||
task.markAsFailed("任务超时:查询外部API异常");
|
||||
logger.warn("任务 {} 连续{}次查询外部API失败且已超时,标记为失败(最后错误: {})",
|
||||
task.getTaskId(), maxRetries, lastError);
|
||||
task.markAsFailed("任务超时:连续" + maxRetries + "次查询外部API失败");
|
||||
taskStatusRepository.save(task);
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时:查询外部API异常");
|
||||
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), "任务超时:连续" + maxRetries + "次查询外部API失败");
|
||||
} else {
|
||||
// 未超时,保持现状让后续轮询继续处理
|
||||
logger.warn("任务 {} 连续{}次查询外部API失败,但未超时,保持现状继续轮询(最后错误: {})",
|
||||
task.getTaskId(), maxRetries, lastError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,9 +304,17 @@ public class TaskStatusPollingService {
|
||||
boolean isVideoTask = taskId != null && (taskId.startsWith("txt2vid_") || taskId.startsWith("img2vid_"));
|
||||
|
||||
if (isVideoTask) {
|
||||
// 增加时间窗口保护:只有任务创建超过5分钟后 externalTaskId 仍为空才标记为失败
|
||||
// 避免任务刚创建还在等待提交到外部API时就被错误地标记为失败
|
||||
java.time.LocalDateTime fiveMinutesAgo = java.time.LocalDateTime.now().minusMinutes(5);
|
||||
if (task.getCreatedAt() != null && task.getCreatedAt().isAfter(fiveMinutesAgo)) {
|
||||
logger.debug("任务 {} 的 externalTaskId 为空,但创建时间未超过5分钟,跳过本次轮询", taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 只在第一次记录失败日志
|
||||
if (loggedFailedTaskIds.add(taskId)) {
|
||||
logger.warn("视频任务 {} 的 externalTaskId 为空,标记为失败并返还积分", taskId);
|
||||
logger.warn("视频任务 {} 的 externalTaskId 为空且已超过5分钟,标记为失败并返还积分", taskId);
|
||||
}
|
||||
String errorMessage = "任务提交失败:未能成功提交到外部API,请检查网络或稍后重试";
|
||||
markTaskFailed(taskId, errorMessage);
|
||||
|
||||
@@ -76,7 +76,7 @@ public class TextToVideoService {
|
||||
// 创建任务
|
||||
TextToVideoTask task = new TextToVideoTask(username, prompt.trim(), aspectRatio, duration, hdMode);
|
||||
task.setTaskId(taskId);
|
||||
task.setStatus(TextToVideoTask.TaskStatus.PENDING);
|
||||
task.setStatus(TextToVideoTask.TaskStatus.PROCESSING); // 直接设为生成中
|
||||
task.setProgress(0);
|
||||
|
||||
// 保存任务
|
||||
@@ -595,7 +595,7 @@ public class TextToVideoService {
|
||||
*/
|
||||
@Transactional
|
||||
public TextToVideoTask retryTask(String taskId, String username) {
|
||||
logger.info("重试失败任务: taskId={}, username={}", taskId, username);
|
||||
logger.info("重试任务: taskId={}, username={}", taskId, username);
|
||||
|
||||
// 获取任务
|
||||
TextToVideoTask task = taskRepository.findByTaskId(taskId)
|
||||
@@ -606,17 +606,26 @@ public class TextToVideoService {
|
||||
throw new RuntimeException("无权操作此任务");
|
||||
}
|
||||
|
||||
// 验证任务状态必须是 FAILED
|
||||
if (task.getStatus() != TextToVideoTask.TaskStatus.FAILED) {
|
||||
throw new RuntimeException("只能重试失败的任务,当前状态: " + task.getStatus());
|
||||
// 允许重试:失败的任务、完成的任务、或者正在处理中的任务(可能卡住了)
|
||||
boolean canRetry = task.getStatus() == TextToVideoTask.TaskStatus.FAILED ||
|
||||
task.getStatus() == TextToVideoTask.TaskStatus.COMPLETED ||
|
||||
task.getStatus() == TextToVideoTask.TaskStatus.PROCESSING;
|
||||
|
||||
if (!canRetry) {
|
||||
throw new RuntimeException("无法重试此任务,当前状态: " + task.getStatus());
|
||||
}
|
||||
|
||||
// 重置任务状态为 PENDING
|
||||
task.setStatus(TextToVideoTask.TaskStatus.PENDING);
|
||||
logger.info("允许重试任务: taskId={}, 原状态={}, 原结果URL={}",
|
||||
taskId, task.getStatus(), task.getResultUrl());
|
||||
|
||||
// 重置任务状态为 PROCESSING(直接生成中)
|
||||
task.setStatus(TextToVideoTask.TaskStatus.PROCESSING);
|
||||
task.setErrorMessage(null);
|
||||
task.setRealTaskId(null); // 清除旧的外部任务ID
|
||||
task.setResultUrl(null); // 清除旧的结果URL
|
||||
task.setProgress(0);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setCompletedAt(null);
|
||||
taskRepository.save(task);
|
||||
|
||||
// 重新添加到任务队列
|
||||
|
||||
@@ -20,9 +20,11 @@ import com.example.demo.model.MembershipLevel;
|
||||
import com.example.demo.repository.PointsFreezeRecordRepository;
|
||||
import com.example.demo.repository.UserRepository;
|
||||
import com.example.demo.repository.MembershipLevelRepository;
|
||||
import com.example.demo.repository.UserMembershipRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
import com.example.demo.util.UserIdGenerator;
|
||||
import com.example.demo.model.UserMembership;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
@@ -36,13 +38,15 @@ public class UserService {
|
||||
private final com.example.demo.repository.PaymentRepository paymentRepository;
|
||||
private final CacheManager cacheManager;
|
||||
private final MembershipLevelRepository membershipLevelRepository;
|
||||
private final UserMembershipRepository userMembershipRepository;
|
||||
|
||||
public UserService(UserRepository userRepository, @Lazy PasswordEncoder passwordEncoder,
|
||||
PointsFreezeRecordRepository pointsFreezeRecordRepository,
|
||||
com.example.demo.repository.OrderRepository orderRepository,
|
||||
com.example.demo.repository.PaymentRepository paymentRepository,
|
||||
CacheManager cacheManager,
|
||||
MembershipLevelRepository membershipLevelRepository) {
|
||||
MembershipLevelRepository membershipLevelRepository,
|
||||
UserMembershipRepository userMembershipRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.pointsFreezeRecordRepository = pointsFreezeRecordRepository;
|
||||
@@ -50,6 +54,7 @@ public class UserService {
|
||||
this.paymentRepository = paymentRepository;
|
||||
this.cacheManager = cacheManager;
|
||||
this.membershipLevelRepository = membershipLevelRepository;
|
||||
this.userMembershipRepository = userMembershipRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -76,7 +81,41 @@ public class UserService {
|
||||
user.setPasswordHash(passwordEncoder.encode(rawPassword));
|
||||
// 注册时默认为普通用户
|
||||
user.setRole("ROLE_USER");
|
||||
return userRepository.save(user);
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
// 自动创建默认会员记录(标准会员,到期时间为1年后)
|
||||
createDefaultMembership(savedUser);
|
||||
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为新用户创建默认会员记录
|
||||
*/
|
||||
private void createDefaultMembership(User user) {
|
||||
try {
|
||||
// 查找免费会员等级
|
||||
Optional<MembershipLevel> freeLevel = membershipLevelRepository.findByName("free");
|
||||
if (freeLevel.isEmpty()) {
|
||||
logger.warn("未找到免费会员等级(free),跳过创建会员记录");
|
||||
return;
|
||||
}
|
||||
|
||||
UserMembership membership = new UserMembership();
|
||||
membership.setUserId(user.getId());
|
||||
membership.setMembershipLevelId(freeLevel.get().getId());
|
||||
membership.setStartDate(LocalDateTime.now());
|
||||
membership.setEndDate(LocalDateTime.of(2099, 12, 31, 23, 59, 59)); // 免费会员永久有效
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setAutoRenew(false);
|
||||
membership.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
userMembershipRepository.save(membership);
|
||||
logger.info("✅ 为新用户创建默认会员记录: userId={}, level=免费会员(永久有效)", user.getId());
|
||||
} catch (Exception e) {
|
||||
logger.error("创建默认会员记录失败: userId={}", user.getId(), e);
|
||||
// 不抛出异常,允许用户注册成功
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -303,6 +342,13 @@ public class UserService {
|
||||
*/
|
||||
@Transactional
|
||||
public PointsFreezeRecord freezePoints(String username, String taskId, PointsFreezeRecord.TaskType taskType, Integer points, String reason) {
|
||||
// 检查是否已经存在相同 taskId 的冻结记录(防止重复冻结)
|
||||
Optional<PointsFreezeRecord> existingRecord = pointsFreezeRecordRepository.findByTaskId(taskId);
|
||||
if (existingRecord.isPresent()) {
|
||||
logger.info("积分已冻结,跳过重复冻结: taskId={}, status={}", taskId, existingRecord.get().getStatus());
|
||||
return existingRecord.get();
|
||||
}
|
||||
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||||
|
||||
@@ -754,7 +800,8 @@ public class UserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理过期会员:将状态改为EXPIRED,清零积分
|
||||
* 处理过期会员:付费会员过期后降级为免费会员,清零积分
|
||||
* 免费会员不会过期(到期时间设为2099年)
|
||||
* @return 处理的过期会员数量
|
||||
*/
|
||||
@Transactional
|
||||
@@ -763,11 +810,34 @@ public class UserService {
|
||||
java.util.List<com.example.demo.model.UserMembership> expiredMemberships =
|
||||
userMembershipRepository.findByStatusAndEndDateBefore("ACTIVE", now);
|
||||
|
||||
// 查找免费会员等级
|
||||
Optional<MembershipLevel> freeLevelOpt = membershipLevelRepository.findByName("free");
|
||||
if (freeLevelOpt.isEmpty()) {
|
||||
logger.error("未找到免费会员等级(free),无法处理过期会员");
|
||||
return 0;
|
||||
}
|
||||
Long freeLevelId = freeLevelOpt.get().getId();
|
||||
|
||||
// 永久有效的到期时间
|
||||
LocalDateTime permanentEndDate = LocalDateTime.of(2099, 12, 31, 23, 59, 59);
|
||||
|
||||
int processedCount = 0;
|
||||
for (com.example.demo.model.UserMembership membership : expiredMemberships) {
|
||||
try {
|
||||
// 更新会员状态为EXPIRED
|
||||
membership.setStatus("EXPIRED");
|
||||
// 跳过免费会员(免费会员不应该过期,但以防万一)
|
||||
if (membership.getMembershipLevelId().equals(freeLevelId)) {
|
||||
// 免费会员如果意外过期,直接延长到2099年
|
||||
membership.setEndDate(permanentEndDate);
|
||||
membership.setUpdatedAt(now);
|
||||
userMembershipRepository.save(membership);
|
||||
logger.info("✅ 免费会员到期时间已延长: userId={}", membership.getUserId());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 付费会员过期:降级为免费会员
|
||||
membership.setMembershipLevelId(freeLevelId);
|
||||
membership.setEndDate(permanentEndDate); // 免费会员永久有效
|
||||
membership.setStatus("ACTIVE"); // 保持ACTIVE状态
|
||||
membership.setUpdatedAt(now);
|
||||
userMembershipRepository.save(membership);
|
||||
|
||||
@@ -778,20 +848,22 @@ public class UserService {
|
||||
int oldPoints = user.getPoints();
|
||||
if (oldPoints > 0) {
|
||||
user.setPoints(0);
|
||||
user.setFrozenPoints(0); // 同时清零冻结积分
|
||||
userRepository.save(user);
|
||||
logger.info("✅ 会员过期积分清零: userId={}, 原积分={}", membership.getUserId(), oldPoints);
|
||||
logger.info("✅ 付费会员过期积分清零: userId={}, 原积分={}", membership.getUserId(), oldPoints);
|
||||
}
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
logger.info("✅ 处理过期会员: userId={}, endDate={}", membership.getUserId(), membership.getEndDate());
|
||||
logger.info("✅ 付费会员过期已降级为免费会员: userId={}, 原到期时间={}",
|
||||
membership.getUserId(), membership.getEndDate());
|
||||
} catch (Exception e) {
|
||||
logger.error("处理过期会员失败: userId={}", membership.getUserId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
if (processedCount > 0) {
|
||||
logger.info("✅ 共处理 {} 个过期会员", processedCount);
|
||||
logger.info("✅ 共处理 {} 个过期付费会员,已降级为免费会员", processedCount);
|
||||
}
|
||||
|
||||
return processedCount;
|
||||
|
||||
@@ -283,7 +283,12 @@ public class UserWorkService {
|
||||
work.setWorkType(UserWork.WorkType.STORYBOARD_VIDEO);
|
||||
work.setTitle(generateTitle(task.getPrompt()));
|
||||
work.setDescription("分镜视频作品");
|
||||
work.setPrompt(task.getPrompt());
|
||||
// 优先使用 videoPrompt,如果没有则使用原始 prompt
|
||||
String displayPrompt = task.getVideoPrompt();
|
||||
if (displayPrompt == null || displayPrompt.trim().isEmpty()) {
|
||||
displayPrompt = task.getPrompt();
|
||||
}
|
||||
work.setPrompt(displayPrompt);
|
||||
work.setResultUrl(resultUrl);
|
||||
work.setThumbnailUrl(task.getResultUrl()); // 保存分镜图作为缩略图
|
||||
work.setImagePrompt(task.getImagePrompt()); // 保存优化后的分镜图提示词
|
||||
@@ -442,6 +447,24 @@ public class UserWorkService {
|
||||
return userWorkRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型获取用户作品列表(用于创作页面历史记录)
|
||||
* @param username 用户名
|
||||
* @param workType 作品类型
|
||||
* @param includeProcessing 是否包含正在处理中的作品
|
||||
* @param page 页码
|
||||
* @param size 每页数量
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Page<UserWork> getUserWorksByType(String username, UserWork.WorkType workType, boolean includeProcessing, int page, int size) {
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
if (includeProcessing) {
|
||||
return userWorkRepository.findByUsernameAndWorkTypeOrderByCreatedAtDesc(username, workType, pageable);
|
||||
} else {
|
||||
return userWorkRepository.findByUsernameAndWorkTypeAndStatusOrderByCreatedAtDesc(username, workType, UserWork.WorkStatus.COMPLETED, pageable);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户正在进行中的作品(包括PROCESSING和PENDING状态)
|
||||
*/
|
||||
|
||||
@@ -62,3 +62,12 @@ tencent.ses.region=ap-hongkong
|
||||
tencent.ses.secret-id=AKIDoaEjFbqxxqZAcv8EE6oZCg2IQPG1fCxm
|
||||
tencent.ses.secret-key=nR83I79FOSpGcqNo7JXkqnU8g7SjsxuG
|
||||
tencent.ses.template-id=154360
|
||||
|
||||
|
||||
# 噜噜支付(彩虹易支付)配置
|
||||
lulupay.api-url=https://api.dulupay.com/
|
||||
lulupay.pid=1516
|
||||
lulupay.merchant-key=lqzsNGKPxNssG1Dxa19GgPsPSpxGKXg9
|
||||
lulupay.platform-public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsNrpsDeXhrPC+h3Y9FEUilZjhMI9MPOEcFvJgy4PGCEMoxCpQHps3F2sGf7MBuz4zbTYDY1tMFIQHDWRnE5I+JLttA3k5GXcXMysbcbnXcXrlMQ4h5yJOs8n73Q+K9vYLgBxjp+7MWzcwAANyZoE2mdw3ihRs8Rwz3ErcCjVdFePwLNQ2K9YXREOo/+QDh6wDyq3u7pU62Ja3dAWfEwj1LCibhRqGcQui7PBseokieL6X3++nKgrzI+C5uxgUlAr7uRwDqBWUEY0zveQMVT8o37zKH0BWumW7H5iZFAZ/OAYjH8zljR6AxFATtg2GdVRUsyhr2IBqBfXVN1Pmpjv9wIDAQAB
|
||||
lulupay.notify-url=https://vionow.com/api/payments/lulupay/notify
|
||||
lulupay.return-url=https://vionow.com/api/payments/lulupay/return
|
||||
|
||||
@@ -104,8 +104,14 @@ spring.servlet.multipart.max-file-size=500MB
|
||||
spring.servlet.multipart.max-request-size=600MB
|
||||
|
||||
# 生产环境日志配置
|
||||
# 日志文件路径 - 使用绝对路径确保日志目录正确
|
||||
logging.file.path=/www/wwwroot/logs
|
||||
logging.level.root=INFO
|
||||
logging.level.com.example.demo=INFO
|
||||
logging.level.com.example.demo.service.OrderService=DEBUG
|
||||
logging.level.com.example.demo.service.PaymentService=DEBUG
|
||||
logging.level.com.example.demo.service.LuluPayService=DEBUG
|
||||
logging.level.com.example.demo.controller.LuluPayCallbackController=DEBUG
|
||||
logging.level.com.example.demo.scheduler=WARN
|
||||
logging.level.com.example.demo.scheduler.OrderScheduler=WARN
|
||||
logging.level.org.springframework.security=WARN
|
||||
@@ -115,7 +121,8 @@ logging.level.org.hibernate.SQL=WARN
|
||||
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
|
||||
logging.level.org.hibernate.orm.jdbc.bind=WARN
|
||||
logging.file.name=${LOG_FILE_PATH:./logs/application.log}
|
||||
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
|
||||
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
|
||||
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
|
||||
|
||||
# 视频处理配置
|
||||
# 临时文件目录(相对路径:基于应用运行目录,或绝对路径)
|
||||
@@ -170,3 +177,16 @@ tencent.cos.prefix=aigc
|
||||
|
||||
|
||||
|
||||
|
||||
# ============================================
|
||||
# 噜噜支付(彩虹易支付)配置 (生产环境)
|
||||
# ============================================
|
||||
lulupay.api-url=https://api.dulupay.com/
|
||||
lulupay.pid=1516
|
||||
lulupay.merchant-key=lqzsNGKPxNssG1Dxa19GgPsPSpxGKXg9
|
||||
lulupay.platform-public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsNrpsDeXhrPC+h3Y9FEUilZjhMI9MPOEcFvJgy4PGCEMoxCpQHps3F2sGf7MBuz4zbTYDY1tMFIQHDWRnE5I+JLttA3k5GXcXMysbcbnXcXrlMQ4h5yJOs8n73Q+K9vYLgBxjp+7MWzcwAANyZoE2mdw3ihRs8Rwz3ErcCjVdFePwLNQ2K9YXREOo/+QDh6wDyq3u7pU62Ja3dAWfEwj1LCibhRqGcQui7PBseokieL6X3++nKgrzI+C5uxgUlAr7uRwDqBWUEY0zveQMVT8o37zKH0BWumW7H5iZFAZ/OAYjH8zljR6AxFATtg2GdVRUsyhr2IBqBfXVN1Pmpjv9wIDAQAB
|
||||
lulupay.notify-url=https://www.vionow.com/api/payments/lulupay/notify
|
||||
lulupay.return-url=https://www.vionow.com/api/payments/lulupay/return
|
||||
|
||||
# 前端URL配置(支付成功后跳转)
|
||||
app.frontend-url=https://www.vionow.com
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-- ============================================
|
||||
-- 超级管理员权限自动设置
|
||||
-- ============================================
|
||||
-- 应用启动时自动将 984523799@qq.com 设置为超级管理员
|
||||
-- 应用启动时自动将 shanghairuiyi2026@163.com 设置为超级管理员
|
||||
-- 如果该用户存在,则更新其角色为超级管理员
|
||||
UPDATE users
|
||||
SET role = 'ROLE_SUPER_ADMIN',
|
||||
|
||||
74
demo/src/main/resources/logback-spring.xml
Normal file
74
demo/src/main/resources/logback-spring.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<!-- 日志目录,从环境变量或属性读取,默认为当前目录下的logs -->
|
||||
<springProperty scope="context" name="LOG_PATH" source="logging.file.path" defaultValue="./logs"/>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/application.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/application.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/error.log</file>
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>ERROR</level>
|
||||
</filter>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/error.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="PAYMENT_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/payment.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/payment.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
<charset>UTF-8</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="com.example.demo.service.OrderService" level="DEBUG" additivity="true">
|
||||
<appender-ref ref="PAYMENT_FILE"/>
|
||||
</logger>
|
||||
<logger name="com.example.demo.service.PaymentService" level="DEBUG" additivity="true">
|
||||
<appender-ref ref="PAYMENT_FILE"/>
|
||||
</logger>
|
||||
<logger name="com.example.demo.service.LuluPayService" level="DEBUG" additivity="true">
|
||||
<appender-ref ref="PAYMENT_FILE"/>
|
||||
</logger>
|
||||
<logger name="com.example.demo.controller.LuluPayCallbackController" level="DEBUG" additivity="true">
|
||||
<appender-ref ref="PAYMENT_FILE"/>
|
||||
</logger>
|
||||
|
||||
<logger name="com.example.demo" level="INFO"/>
|
||||
<logger name="org.hibernate.SQL" level="WARN"/>
|
||||
<logger name="org.springframework.security" level="WARN"/>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FILE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
76
demo/src/test/java/com/example/demo/LuluPaySignTest.java
Normal file
76
demo/src/test/java/com/example/demo/LuluPaySignTest.java
Normal file
@@ -0,0 +1,76 @@
|
||||
package com.example.demo;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* 噜噜支付签名测试
|
||||
*/
|
||||
public class LuluPaySignTest {
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
// 商户密钥
|
||||
String merchantKey = "lqzsNGKPxNssG1Dxa19GgPsPSpxGKXg9";
|
||||
|
||||
// 模拟请求参数(不含sign和sign_type)
|
||||
Map<String, String> params = new TreeMap<>();
|
||||
params.put("pid", "1516");
|
||||
params.put("type", "alipay");
|
||||
params.put("out_trade_no", "TEST" + System.currentTimeMillis());
|
||||
params.put("notify_url", "https://www.vionow.com/api/payments/lulupay/notify");
|
||||
params.put("return_url", "https://www.vionow.com/api/payments/lulupay/return");
|
||||
params.put("name", "测试商品");
|
||||
params.put("money", "0.01");
|
||||
params.put("clientip", "127.0.0.1");
|
||||
params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
|
||||
|
||||
// 生成待签名字符串
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : params.entrySet()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append("&");
|
||||
}
|
||||
sb.append(entry.getKey()).append("=").append(entry.getValue());
|
||||
}
|
||||
String signContent = sb.toString();
|
||||
|
||||
System.out.println("=== 噜噜支付签名测试 ===");
|
||||
System.out.println("待签名字符串: " + signContent);
|
||||
System.out.println("待签名字符串+密钥: " + signContent + merchantKey);
|
||||
|
||||
// MD5签名
|
||||
String sign = md5(signContent + merchantKey);
|
||||
System.out.println("MD5签名结果: " + sign);
|
||||
|
||||
// 完整请求参数
|
||||
System.out.println("\n=== 完整请求参数 ===");
|
||||
params.put("sign", sign);
|
||||
params.put("sign_type", "MD5");
|
||||
for (Map.Entry<String, String> entry : params.entrySet()) {
|
||||
System.out.println(entry.getKey() + "=" + entry.getValue());
|
||||
}
|
||||
|
||||
// 生成测试URL
|
||||
StringBuilder url = new StringBuilder("https://api.dulupay.com/api/pay/create?");
|
||||
for (Map.Entry<String, String> entry : params.entrySet()) {
|
||||
if (url.charAt(url.length() - 1) != '?') {
|
||||
url.append("&");
|
||||
}
|
||||
url.append(entry.getKey()).append("=").append(java.net.URLEncoder.encode(entry.getValue(), "UTF-8"));
|
||||
}
|
||||
System.out.println("\n=== 测试URL ===");
|
||||
System.out.println(url.toString());
|
||||
}
|
||||
|
||||
private static String md5(String str) throws Exception {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(str.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : digest) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user