feat: 线程池扩容、会员注册和过期逻辑优化、API管理页面显示当前配置

- 线程池扩容:TaskQueueService 10->20, AsyncConfig 核心5->10/最大20->40/队列50->100
- 新用户注册自动创建免费会员记录(永久有效到2099年)
- 付费会员过期自动降级为免费会员并清零积分
- API管理页面显示当前API密钥(脱敏)和端点
- 修复StoryboardVideoCreate.vue语法错误
This commit is contained in:
AIGC Developer
2026-01-11 21:32:33 +08:00
parent a66bd806b2
commit 8404cb2b7a
50 changed files with 2635 additions and 971 deletions

View File

@@ -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-");
// 拒绝策略:当线程池和队列都满时,使用调用者线程执行(保证任务不丢失)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()));
}
}
/**
* 获取任务详情
*/

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,9 @@ package com.example.demo.model;
public enum PaymentMethod {
ALIPAY("支付宝"),
WECHAT("微信支付"),
QQPAY("QQ钱包"),
BANK("云闪付"),
PAYPAL("PayPal");
private final String displayName;

View File

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

View File

@@ -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);
/**
* 根据用户名查找正在进行中和排队中的作品
*/

View File

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

View File

@@ -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 {
}
}
/**
* 调用电脑网页支付APIalipay.trade.page.pay
* 返回支付页面的HTML表单前端直接渲染即可跳转到支付宝
*/
private Map<String, Object> callPagePayAPI(Payment payment) throws Exception {
logger.info("=== 使用IJPay调用支付宝电脑网页支付API ===");
logger.info("网关地址: {}", gatewayUrl);
logger.info("应用ID: {}", appId);
logger.info("通知URL: {}", notifyUrl);
logger.info("返回URL: {}", returnUrl);
// 在调用前确保配置已设置
ensureAliPayConfigSet();
// 设置业务参数
AlipayTradePagePayModel model = new AlipayTradePagePayModel();
model.setOutTradeNo(payment.getOrderId());
model.setProductCode("FAST_INSTANT_TRADE_PAY"); // 电脑网站支付固定值
// 如果是美元按汇率转换为人民币支付宝只支持CNY
java.math.BigDecimal amount = payment.getAmount();
String currency = payment.getCurrency();
if ("USD".equalsIgnoreCase(currency)) {
java.math.BigDecimal exchangeRate = new java.math.BigDecimal("7.2");
amount = amount.multiply(exchangeRate).setScale(2, java.math.RoundingMode.HALF_UP);
logger.info("货币转换: {} USD -> {} CNY (汇率: 7.2)", payment.getAmount(), amount);
}
model.setTotalAmount(amount.toString());
model.setSubject(payment.getDescription() != null ? payment.getDescription() : "AIGC会员订阅");
model.setBody(payment.getDescription() != null ? payment.getDescription() : "AIGC平台会员订阅服务");
model.setTimeoutExpress("30m"); // 订单超时时间
logger.info("调用支付宝电脑网页支付API订单号{},金额:{},商品名称:{}",
model.getOutTradeNo(), model.getTotalAmount(), model.getSubject());
// 使用IJPay调用电脑网页支付API返回HTML表单
String form = AliPayApi.tradePage(model, notifyUrl, returnUrl);
if (form == null || form.isEmpty()) {
logger.error("支付宝电脑网页支付API返回为空");
throw new RuntimeException("支付宝支付页面生成失败");
}
logger.info("支付宝电脑网页支付表单生成成功,订单号:{}", payment.getOrderId());
Map<String, Object> result = new HashMap<>();
result.put("payForm", form); // HTML表单前端直接渲染
result.put("outTradeNo", payment.getOrderId());
result.put("success", true);
result.put("payType", "PAGE_PAY"); // 标识支付类型
return result;
}
/**
* 确保AliPayApiConfigKit中已设置配置
* 如果未设置从AliPayApiConfigHolder获取并设置
* 根据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("调用支付宝预创建APIIJPay订单号{},金额:{},商品名称:{}",
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("正在调用支付宝APIIJPay... (第{}次尝试,共{}次)", 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();
}
}
}

View File

@@ -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);
// 重新添加到任务队列

View 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("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;");
}
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());
}
}
}

View File

@@ -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);
}
}
/**
* 取消订单
*/

View File

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

View File

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

View File

@@ -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确保后续处理能获取到

View File

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

View File

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

View File

@@ -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);
// 重新添加到任务队列

View File

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

View File

@@ -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状态
*/

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
-- ============================================
-- 超级管理员权限自动设置
-- ============================================
-- 应用启动时自动将 984523799@qq.com 设置为超级管理员
-- 应用启动时自动将 shanghairuiyi2026@163.com 设置为超级管理员
-- 如果该用户存在,则更新其角色为超级管理员
UPDATE users
SET role = 'ROLE_SUPER_ADMIN',

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

View 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();
}
}