feat: 添加任务状态级联触发器,优化支付和做同款功能

主要更新:
- 添加 MySQL 触发器实现 task_status 表到其他表的状态级联
- 移除控制器中的多表状态检查代码
- 完善做同款功能,支持参数传递
- 支付宝 USD 转 CNY 汇率转换
- 修复状态枚举映射问题

注意: 触发器仅在 task_status 更新时触发,部分代码仍直接更新业务表
This commit is contained in:
AIGC Developer
2025-12-08 13:54:02 +08:00
parent 624d560fb4
commit 3c37006ebd
84 changed files with 5325 additions and 1668 deletions

View File

@@ -0,0 +1,49 @@
package com.example.demo.config;
import java.util.concurrent.TimeUnit;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.github.benmanes.caffeine.cache.Caffeine;
/**
* 缓存配置类
* 使用 Caffeine 作为本地缓存实现,提高系统性能
*/
@Configuration
@EnableCaching
public class CacheConfig {
/**
* 缓存名称常量
*/
public static final String USER_CACHE = "userCache";
public static final String USER_POINTS_CACHE = "userPointsCache";
public static final String USER_WORK_STATS_CACHE = "userWorkStatsCache";
public static final String SYSTEM_CONFIG_CACHE = "systemConfigCache";
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
// 默认缓存配置最大1000条5分钟过期
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats()); // 记录统计信息
// 注册缓存名称
cacheManager.setCacheNames(java.util.Arrays.asList(
USER_CACHE,
USER_POINTS_CACHE,
USER_WORK_STATS_CACHE,
SYSTEM_CONFIG_CACHE
));
return cacheManager;
}
}

View File

@@ -30,6 +30,9 @@ public class CosConfig {
@Value("${tencent.cos.enabled:false}")
private boolean enabled;
@Value("${tencent.cos.prefix:}")
private String prefix;
@Bean
public COSClient cosClient() {
if (!enabled || secretId.isEmpty() || secretKey.isEmpty()) {
@@ -59,4 +62,8 @@ public class CosConfig {
public boolean isEnabled() {
return enabled;
}
public String getPrefix() {
return prefix;
}
}

View File

@@ -30,10 +30,10 @@ public class PayPalConfig {
@Value("${paypal.mode:sandbox}")
private String mode;
@Value("${paypal.success-url:http://localhost:8080/payment/paypal/success}")
@Value("${paypal.success-url:https://vionow.com/api/payment/paypal/success}")
private String successUrl;
@Value("${paypal.cancel-url:http://localhost:8080/payment/paypal/cancel}")
@Value("${paypal.cancel-url:https://vionow.com/api/payment/paypal/cancel}")
private String cancelUrl;
/**

View File

@@ -81,10 +81,10 @@ public class SecurityConfig {
.requestMatchers("/api/payments/**").authenticated() // 其他支付接口需要认证
.requestMatchers("/api/image-to-video/**").authenticated() // 图生视频接口需要认证
.requestMatchers("/api/text-to-video/**").authenticated() // 文生视频接口需要认证
.requestMatchers("/api/dashboard/**").hasRole("ADMIN") // 仪表盘API需要管理员权限
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 管理员API需要管理员权限
.requestMatchers("/settings", "/settings/**").hasRole("ADMIN")
.requestMatchers("/users/**").hasRole("ADMIN")
.requestMatchers("/api/dashboard/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 仪表盘API需要管理员权限
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "SUPER_ADMIN") // 管理员API需要管理员权限
.requestMatchers("/settings", "/settings/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.requestMatchers("/users/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
.anyRequest().permitAll()
)
.formLogin(form -> form

View File

@@ -1,11 +1,13 @@
package com.example.demo.config;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
@@ -73,9 +75,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
// 确保路径使用正斜杠URL格式
String resourceLocation = "file:" + uploadDirPath.toAbsolutePath().toString().replace("\\", "/") + "/";
// 上传文件缓存7天视频/图片等媒体文件)
registry.addResourceHandler("/uploads/**")
.addResourceLocations(resourceLocation)
.setCachePeriod(3600); // 缓存1小时
.setCacheControl(CacheControl.maxAge(7, TimeUnit.DAYS).cachePublic());
// 静态资源缓存配置JS/CSS/图片等)
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic());
// 图片资源缓存30天
registry.addResourceHandler("/images/**")
.addResourceLocations("classpath:/static/images/")
.setCacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic());
}
// CORS配置已移至SecurityConfig避免冲突

View File

@@ -25,6 +25,7 @@ import com.example.demo.model.SystemSettings;
import com.example.demo.repository.TaskStatusRepository;
import com.example.demo.service.UserService;
import com.example.demo.service.SystemSettingsService;
import com.example.demo.service.OnlineStatsService;
import com.example.demo.util.JwtUtils;
/**
@@ -49,6 +50,9 @@ public class AdminController {
@Autowired
private TaskStatusRepository taskStatusRepository;
@Autowired
private OnlineStatsService onlineStatsService;
/**
* 给用户增加积分
*/
@@ -582,5 +586,29 @@ public class AdminController {
return ResponseEntity.status(500).body(response);
}
}
/**
* 获取系统在线统计
* 返回当天访问的独立IP数通过IP判断在线人数
*/
@GetMapping("/online-stats")
public ResponseEntity<Map<String, Object>> getOnlineStats() {
Map<String, Object> response = new HashMap<>();
try {
int todayVisitors = onlineStatsService.getTodayVisitorCount();
Map<String, Object> stats = onlineStatsService.getStats();
response.put("success", true);
response.put("todayVisitors", todayVisitors);
response.put("date", stats.get("date"));
response.put("uptime", stats.get("uptime"));
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("获取在线统计失败", e);
response.put("success", false);
response.put("message", "获取在线统计失败: " + e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
}

View File

@@ -100,11 +100,14 @@ public class AlipayCallbackController {
outTradeNo, tradeNo, tradeStatus, totalAmount, appId);
// 查找支付记录
logger.info("正在查找支付记录,订单号: {}", outTradeNo);
Optional<Payment> paymentOpt = paymentRepository.findByOrderId(outTradeNo);
if (!paymentOpt.isPresent()) {
logger.error("支付记录不存在: {}", outTradeNo);
logger.error("支付记录不存在: orderId={}", outTradeNo);
logger.error("请检查数据库中是否存在该订单号的支付记录");
return "failure";
}
logger.info("✅ 找到支付记录: paymentId={}", paymentOpt.get().getId());
Payment payment = paymentOpt.get();

View File

@@ -19,6 +19,7 @@ import com.example.demo.model.User;
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 jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -187,6 +188,12 @@ public class AuthApiController {
// 直接创建用户对象并设置所有必要字段
user = new User();
// 生成唯一用户IDUID + yyMMdd + 4位随机字符
String generatedUserId = UserIdGenerator.generate();
user.setUserId(generatedUserId);
logger.info("邮箱验证码登录 - 生成用户ID: {}", generatedUserId);
user.setUsername(username);
user.setEmail(email);
user.setPasswordHash(""); // 邮箱登录不需要密码
@@ -377,14 +384,16 @@ public class AuthApiController {
/**
* 修改当前登录用户密码
* 支持首次设置密码isFirstTimeSetup=true时不验证旧密码
*/
@PostMapping("/change-password")
public ResponseEntity<Map<String, Object>> changePassword(@RequestBody Map<String, String> requestBody,
public ResponseEntity<Map<String, Object>> changePassword(@RequestBody Map<String, Object> requestBody,
Authentication authentication,
HttpServletRequest request) {
try {
String oldPassword = requestBody.get("oldPassword");
String newPassword = requestBody.get("newPassword");
String oldPassword = (String) requestBody.get("oldPassword");
String newPassword = (String) requestBody.get("newPassword");
Boolean isFirstTimeSetup = Boolean.TRUE.equals(requestBody.get("isFirstTimeSetup"));
// 尝试从 Spring Security 上下文中获取当前用户名
User user = null;
@@ -410,7 +419,9 @@ public class AuthApiController {
.body(createErrorResponse("用户未登录或会话已失效"));
}
userService.changePassword(user.getId(), oldPassword, newPassword);
// changePassword 方法内部已处理首次设置密码场景
// 如果用户没有密码,可以直接设置新密码,不需要验证旧密码
userService.changePassword(user.getId(), isFirstTimeSetup ? null : oldPassword, newPassword);
Map<String, Object> response = new HashMap<>();
response.put("success", true);

View File

@@ -23,6 +23,7 @@ import com.example.demo.repository.OrderRepository;
import com.example.demo.repository.PaymentRepository;
import com.example.demo.repository.UserMembershipRepository;
import com.example.demo.repository.UserRepository;
import com.example.demo.service.DashboardService;
@RestController
@RequestMapping("/api/dashboard")
@@ -46,43 +47,16 @@ public class DashboardApiController {
@Autowired
private com.example.demo.service.UserService userService;
@Autowired
private DashboardService dashboardService;
// 获取仪表盘概览数据
@GetMapping("/overview")
public ResponseEntity<Map<String, Object>> getDashboardOverview() {
try {
Map<String, Object> overview = new HashMap<>();
// 用户总数
long totalUsers = userRepository.count();
overview.put("totalUsers", totalUsers);
// 付费用户数(有会员的用户)
long paidUsers = userMembershipRepository.countByStatus("ACTIVE");
overview.put("paidUsers", paidUsers);
// 今日收入(今日完成的支付)
LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0);
LocalDateTime todayEnd = LocalDateTime.now().withHour(23).withMinute(59).withSecond(59);
Double todayRevenue = paymentRepository.findTodayRevenue(todayStart, todayEnd);
overview.put("todayRevenue", todayRevenue != null ? todayRevenue : 0.0);
// 总订单数
long totalOrders = orderRepository.count();
overview.put("totalOrders", totalOrders);
// 总收入
Double totalRevenue = paymentRepository.findTotalRevenue();
overview.put("totalRevenue", totalRevenue != null ? totalRevenue : 0.0);
// 本月收入
LocalDateTime monthStart = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0);
LocalDateTime monthEnd = LocalDateTime.now();
Double monthRevenue = paymentRepository.findRevenueByDateRange(monthStart, monthEnd);
overview.put("monthRevenue", monthRevenue != null ? monthRevenue : 0.0);
// 使用 DashboardService 获取包含同比变化的完整数据
Map<String, Object> overview = dashboardService.getDashboardOverview();
return ResponseEntity.ok(overview);
} catch (Exception e) {
@@ -149,8 +123,8 @@ public class DashboardApiController {
// 总用户数
long totalUsers = userRepository.count();
// 付费用户数
long paidUsers = userMembershipRepository.countByStatus("ACTIVE");
// 付费用户数(使用 Payment 表统计,与卡片统计方式一致)
long paidUsers = paymentRepository.countDistinctUsersByStatus(com.example.demo.model.PaymentStatus.SUCCESS);
// 计算转化率
double conversionRate = totalUsers > 0 ? (double) paidUsers / totalUsers * 100 : 0.0;
@@ -179,7 +153,7 @@ public class DashboardApiController {
}
}
// 获取按月转化率数据
// 获取按月转化率数据(累计转化率:截止到该月底的累计付费用户/累计总用户)
private List<Map<String, Object>> getMonthlyConversionRate(int year) {
List<Map<String, Object>> monthlyData = new ArrayList<>();
@@ -187,20 +161,20 @@ public class DashboardApiController {
Map<String, Object> monthData = new HashMap<>();
monthData.put("month", month);
// 计算该月的总用户数(注册时间在该月)
LocalDateTime monthStart = LocalDateTime.of(year, month, 1, 0, 0, 0);
LocalDateTime monthEnd = monthStart.plusMonths(1).minusSeconds(1);
// 计算截止到该月底的累计总用户数
LocalDateTime monthEnd = LocalDateTime.of(year, month, 1, 0, 0, 0).plusMonths(1).minusSeconds(1);
long monthTotalUsers = userRepository.countByCreatedAtBetween(monthStart, monthEnd);
long cumulativeTotalUsers = userRepository.countByCreatedAtBefore(monthEnd);
// 计算该月新增的付费用户数(会员开始时间在该月
long monthPaidUsers = userMembershipRepository.countByStartDateBetween(monthStart, monthEnd);
// 计算截止到该月底的累计付费用户数(使用 Payment 表统计,与卡片统计方式一致
long cumulativePaidUsers = paymentRepository.countDistinctUsersByStatusAndPaidAtBefore(
com.example.demo.model.PaymentStatus.SUCCESS, monthEnd);
// 计算该月转化率
double monthConversionRate = monthTotalUsers > 0 ? (double) monthPaidUsers / monthTotalUsers * 100 : 0.0;
// 计算累计转化率
double monthConversionRate = cumulativeTotalUsers > 0 ? (double) cumulativePaidUsers / cumulativeTotalUsers * 100 : 0.0;
monthData.put("totalUsers", monthTotalUsers);
monthData.put("paidUsers", monthPaidUsers);
monthData.put("totalUsers", cumulativeTotalUsers);
monthData.put("paidUsers", cumulativePaidUsers);
monthData.put("conversionRate", Math.round(monthConversionRate * 100.0) / 100.0);
monthlyData.add(monthData);
@@ -247,25 +221,38 @@ public class DashboardApiController {
@GetMapping("/system-status")
public ResponseEntity<Map<String, Object>> getSystemStatus() {
try {
Map<String, Object> status = new HashMap<>();
Map<String, Object> response = new HashMap<>();
Map<String, Object> data = new HashMap<>();
// 当前在线用户基于最近10分钟内有活动的用户
long onlineUsers = userService.countOnlineUsers();
status.put("onlineUsers", onlineUsers);
data.put("onlineUsers", onlineUsers);
data.put("maxUsers", 500);
// 系统运行时间
status.put("systemUptime", "48小时32分");
// 系统运行时间从JVM启动时间计算
long uptimeMillis = java.lang.management.ManagementFactory.getRuntimeMXBean().getUptime();
long uptimeSeconds = uptimeMillis / 1000;
data.put("uptime", uptimeSeconds);
// 格式化的运行时间
long hours = uptimeSeconds / 3600;
long minutes = (uptimeSeconds % 3600) / 60;
data.put("uptimeFormatted", hours + "小时" + minutes + "");
// 数据库连接状态
status.put("databaseStatus", "正常");
data.put("databaseStatus", "正常");
// 服务状态
status.put("serviceStatus", "运行中");
data.put("serviceStatus", "运行中");
return ResponseEntity.ok(status);
response.put("success", true);
response.put("data", data);
return ResponseEntity.ok(response);
} catch (Exception e) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("error", "获取系统状态失败");
error.put("message", e.getMessage());
return ResponseEntity.status(500).body(error);

View File

@@ -8,14 +8,15 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.fasterxml.jackson.core.JsonParseException;
import jakarta.servlet.http.HttpServletRequest;
@ControllerAdvice
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@@ -92,6 +93,24 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
/**
* 处理不支持的请求方法异常(如 POST / 等)
* 这类请求通常是扫描器或误操作,静默返回 405 即可
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<Map<String, Object>> handleMethodNotSupported(
HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
// 仅记录简单日志,不输出详细堆栈
logger.warn("不支持的请求方法: {} {}", request.getMethod(), request.getRequestURI());
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "Method Not Allowed");
errorResponse.put("path", request.getRequestURI());
return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED).body(errorResponse);
}
/**
* 处理其他所有异常
*/

View File

@@ -242,6 +242,8 @@ public class ImageToVideoApiController {
response.put("message", "无权限访问此任务");
return ResponseEntity.status(403).body(response);
}
// 状态同步已通过数据库触发器实现,无需代码检查
response.put("success", true);
Map<String, Object> taskData = new HashMap<>();

View File

@@ -27,6 +27,10 @@ import com.example.demo.model.UserMembership;
import com.example.demo.repository.MembershipLevelRepository;
import com.example.demo.repository.UserMembershipRepository;
import com.example.demo.repository.UserRepository;
import com.example.demo.service.UserService;
import com.example.demo.util.JwtUtils;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.transaction.annotation.Transactional;
@RestController
@RequestMapping("/api/members")
@@ -42,16 +46,35 @@ public class MemberApiController {
@Autowired
private MembershipLevelRepository membershipLevelRepository;
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserService userService;
// 获取会员列表
@GetMapping
public ResponseEntity<Map<String, Object>> getMembers(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String level) {
@RequestParam(required = false) String level,
@RequestParam(required = false) String status) {
try {
Pageable pageable = PageRequest.of(page - 1, pageSize, Sort.by("createdAt").descending());
Page<User> userPage = userRepository.findAll(pageable);
Page<User> userPage;
// 根据 status 参数筛选用户
if ("all".equals(status)) {
// 显示所有用户
userPage = userRepository.findAll(pageable);
} else if ("banned".equals(status)) {
// 只显示封禁用户
userPage = userRepository.findByIsActive(false, pageable);
} else {
// 默认只显示活跃用户
userPage = userRepository.findByIsActive(true, pageable);
}
List<Map<String, Object>> members = userPage.getContent().stream()
.map(user -> {
@@ -160,8 +183,27 @@ public class MemberApiController {
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> updateMember(
@PathVariable Long id,
@RequestBody Map<String, Object> updateData) {
@RequestBody Map<String, Object> updateData,
@RequestHeader("Authorization") String token) {
try {
// 验证管理员权限
String adminUsername = extractUsernameFromToken(token);
if (adminUsername == null) {
return ResponseEntity.status(401).body(Map.of("success", false, "message", "用户未登录"));
}
User admin = userRepository.findByUsername(adminUsername).orElse(null);
if (admin == null) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
}
boolean isSuperAdmin = "ROLE_SUPER_ADMIN".equals(admin.getRole());
boolean isAdmin = "ROLE_ADMIN".equals(admin.getRole());
if (!isSuperAdmin && !isAdmin) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
}
Optional<User> userOpt = userRepository.findById(id);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
@@ -174,29 +216,69 @@ public class MemberApiController {
user.setUsername((String) updateData.get("username"));
}
if (updateData.containsKey("points")) {
user.setPoints((Integer) updateData.get("points"));
Object pointsObj = updateData.get("points");
if (pointsObj instanceof Number) {
user.setPoints(((Number) pointsObj).intValue());
}
}
userRepository.save(user);
// 只有超级管理员可以修改角色,且不能修改超级管理员的角色
if (updateData.containsKey("role") && isSuperAdmin) {
String newRole = (String) updateData.get("role");
// 如果被编辑的用户是超级管理员,跳过角色修改
if ("ROLE_SUPER_ADMIN".equals(user.getRole())) {
// 不做任何操作,保持超级管理员角色
} else if ("ROLE_USER".equals(newRole) || "ROLE_ADMIN".equals(newRole)) {
// 只允许设置为普通用户或管理员
user.setRole(newRole);
}
// 如果 newRole 是 ROLE_SUPER_ADMIN忽略不允许通过此接口设置超级管理员
}
// 更新会员等级
if (updateData.containsKey("level")) {
String levelName = (String) updateData.get("level");
userService.save(user);
// 更新会员等级和到期时间
String levelName = (String) updateData.get("level");
String expiryDateStr = (String) updateData.get("expiryDate");
if (levelName != null) {
Optional<MembershipLevel> levelOpt = membershipLevelRepository
.findByDisplayName(levelName);
if (levelOpt.isPresent()) {
MembershipLevel level = levelOpt.get();
// 更新或创建会员信息
// 查找或创建会员信息
Optional<UserMembership> membershipOpt = userMembershipRepository
.findByUserIdAndStatus(user.getId(), "ACTIVE");
UserMembership membership;
if (membershipOpt.isPresent()) {
UserMembership membership = membershipOpt.get();
membership.setMembershipLevelId(level.getId());
userMembershipRepository.save(membership);
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 {
java.time.LocalDate expiryDate = java.time.LocalDate.parse(expiryDateStr);
membership.setEndDate(expiryDate.atStartOfDay());
} catch (Exception e) {
// 日期格式错误,忽略
}
}
userMembershipRepository.save(membership);
}
}
@@ -216,17 +298,59 @@ public class MemberApiController {
// 删除会员
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, Object>> deleteMember(@PathVariable Long id) {
@Transactional
public ResponseEntity<Map<String, Object>> deleteMember(
@PathVariable Long id,
@RequestHeader("Authorization") String token) {
try {
// 验证管理员权限
String adminUsername = extractUsernameFromToken(token);
if (adminUsername == null) {
return ResponseEntity.status(401).body(Map.of("success", false, "message", "用户未登录"));
}
User admin = userRepository.findByUsername(adminUsername).orElse(null);
if (admin == null) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
}
boolean isSuperAdmin = "ROLE_SUPER_ADMIN".equals(admin.getRole());
boolean isAdmin = "ROLE_ADMIN".equals(admin.getRole());
if (!isSuperAdmin && !isAdmin) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
}
Optional<User> userOpt = userRepository.findById(id);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
// 软删除:设置为非活跃状态
User user = userOpt.get();
user.setIsActive(false);
userRepository.save(user);
// 不能删除自己
if (user.getUsername().equals(adminUsername)) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能删除自己的账号"));
}
// 不能删除超级管理员
if ("ROLE_SUPER_ADMIN".equals(user.getRole())) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能删除超级管理员账号"));
}
// 普通管理员不能删除其他管理员,只有超级管理员可以
if ("ROLE_ADMIN".equals(user.getRole()) && !isSuperAdmin) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "只有超级管理员才能删除管理员账号"));
}
// 先删除关联的会员信息
userMembershipRepository.deleteByUserId(user.getId());
// 清除用户缓存
userService.evictUserCache(user.getUsername());
// 物理删除用户
userRepository.delete(user);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
@@ -244,21 +368,60 @@ public class MemberApiController {
// 批量删除会员
@DeleteMapping("/batch")
public ResponseEntity<Map<String, Object>> deleteMembers(@RequestBody Map<String, List<Long>> request) {
@Transactional
public ResponseEntity<Map<String, Object>> deleteMembers(
@RequestBody Map<String, List<Long>> request,
@RequestHeader("Authorization") String token) {
try {
// 验证管理员权限
String adminUsername = extractUsernameFromToken(token);
if (adminUsername == null) {
return ResponseEntity.status(401).body(Map.of("success", false, "message", "用户未登录"));
}
User admin = userRepository.findByUsername(adminUsername).orElse(null);
if (admin == null) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
}
boolean isSuperAdmin = "ROLE_SUPER_ADMIN".equals(admin.getRole());
boolean isAdmin = "ROLE_ADMIN".equals(admin.getRole());
if (!isSuperAdmin && !isAdmin) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
}
List<Long> ids = request.get("ids");
if (ids == null || ids.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "请提供要删除的会员ID列表"));
}
List<User> users = userRepository.findAllById(ids);
users.forEach(user -> user.setIsActive(false));
userRepository.saveAll(users);
// 过滤掉自己、超级管理员,普通管理员还需要过滤掉其他管理员
final boolean finalIsSuperAdmin = isSuperAdmin;
List<User> toDelete = users.stream()
.filter(user -> !user.getUsername().equals(adminUsername))
.filter(user -> !"ROLE_SUPER_ADMIN".equals(user.getRole()))
.filter(user -> finalIsSuperAdmin || !"ROLE_ADMIN".equals(user.getRole()))
.toList();
int skipped = users.size() - toDelete.size();
// 物理删除:先删除关联的会员信息,清除缓存,再删除用户
for (User user : toDelete) {
userMembershipRepository.deleteByUserId(user.getId());
userService.evictUserCache(user.getUsername()); // 清除缓存
}
userRepository.deleteAll(toDelete);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "批量删除成功");
response.put("deletedCount", users.size());
response.put("message", skipped > 0
? "批量删除成功,已跳过 " + skipped + " 个管理员账号"
: "批量删除成功");
response.put("deletedCount", toDelete.size());
response.put("skippedCount", skipped);
return ResponseEntity.ok(response);
@@ -270,6 +433,160 @@ public class MemberApiController {
}
}
// 封禁/解封会员
@PutMapping("/{id}/ban")
public ResponseEntity<Map<String, Object>> toggleBanMember(
@PathVariable Long id,
@RequestBody Map<String, Boolean> request,
@RequestHeader("Authorization") String token) {
try {
// 验证管理员权限
String adminUsername = extractUsernameFromToken(token);
if (adminUsername == null) {
return ResponseEntity.status(401).body(Map.of("success", false, "message", "用户未登录"));
}
User admin = userRepository.findByUsername(adminUsername).orElse(null);
if (admin == null) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
}
boolean isSuperAdmin = "ROLE_SUPER_ADMIN".equals(admin.getRole());
boolean isAdmin = "ROLE_ADMIN".equals(admin.getRole());
if (!isSuperAdmin && !isAdmin) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要管理员权限"));
}
Optional<User> userOpt = userRepository.findById(id);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
// 不能封禁自己
if (user.getUsername().equals(adminUsername)) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能封禁自己的账号"));
}
// 不能封禁超级管理员
if ("ROLE_SUPER_ADMIN".equals(user.getRole())) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能封禁超级管理员账号"));
}
// 普通管理员不能封禁其他管理员,只有超级管理员可以
if ("ROLE_ADMIN".equals(user.getRole()) && !isSuperAdmin) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "只有超级管理员才能封禁管理员账号"));
}
// 获取要设置的状态true=解封false=封禁)
Boolean isActive = request.get("isActive");
if (isActive == null) {
isActive = !user.getIsActive(); // 如果没传,则切换状态
}
user.setIsActive(isActive);
userService.save(user);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", isActive ? "解封成功" : "封禁成功");
response.put("isActive", isActive);
return ResponseEntity.ok(response);
} catch (Exception e) {
Map<String, Object> error = new HashMap<>();
error.put("error", "操作失败");
error.put("message", e.getMessage());
return ResponseEntity.status(500).body(error);
}
}
// 设置用户角色(仅超级管理员可操作)
@PutMapping("/{id}/role")
public ResponseEntity<Map<String, Object>> setUserRole(
@PathVariable Long id,
@RequestBody Map<String, String> request,
@RequestHeader("Authorization") String token) {
try {
// 验证超级管理员权限
String adminUsername = extractUsernameFromToken(token);
if (adminUsername == null) {
return ResponseEntity.status(401).body(Map.of("success", false, "message", "用户未登录"));
}
User admin = userRepository.findByUsername(adminUsername).orElse(null);
if (admin == null || !"ROLE_SUPER_ADMIN".equals(admin.getRole())) {
return ResponseEntity.status(403).body(Map.of("success", false, "message", "需要超级管理员权限"));
}
Optional<User> userOpt = userRepository.findById(id);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
// 不能修改自己的角色
if (user.getUsername().equals(adminUsername)) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能修改自己的角色"));
}
// 不能修改其他超级管理员的角色
if ("ROLE_SUPER_ADMIN".equals(user.getRole())) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "不能修改超级管理员的角色"));
}
String newRole = request.get("role");
if (newRole == null || newRole.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "请指定角色"));
}
// 验证角色有效性
if (!"ROLE_USER".equals(newRole) && !"ROLE_ADMIN".equals(newRole)) {
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "无效的角色"));
}
String oldRole = user.getRole();
user.setRole(newRole);
userService.save(user);
String action = "ROLE_ADMIN".equals(newRole) ? "设置为管理员" : "取消管理员权限";
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "用户 " + user.getUsername() + "" + action);
response.put("oldRole", oldRole);
response.put("newRole", newRole);
return ResponseEntity.ok(response);
} catch (Exception e) {
Map<String, Object> error = new HashMap<>();
error.put("error", "操作失败");
error.put("message", e.getMessage());
return ResponseEntity.status(500).body(error);
}
}
// 从Token中提取用户名
private String extractUsernameFromToken(String token) {
try {
if (token == null || !token.startsWith("Bearer ")) {
return null;
}
String actualToken = token.substring(7);
if (jwtUtils.isTokenExpired(actualToken)) {
return null;
}
return jwtUtils.getUsernameFromToken(actualToken);
} catch (Exception e) {
return null;
}
}
// 获取所有会员等级配置(用于系统设置和订阅页面)
@GetMapping("/levels")
public ResponseEntity<Map<String, Object>> getMembershipLevels() {

View File

@@ -82,7 +82,7 @@ public class OrderApiController {
// 获取订单列表
Page<Order> orderPage;
if (user.getRole().equals("ROLE_ADMIN")) {
if ((user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN"))) {
// 管理员可以查看所有订单
orderPage = orderService.findAllOrders(pageable, status, type, search);
} else {
@@ -176,7 +176,7 @@ public class OrderApiController {
}
// 检查权限
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN")) && !order.getUser().getId().equals(user.getId())) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "无权限访问此订单");
@@ -254,7 +254,7 @@ public class OrderApiController {
.orElseThrow(() -> new RuntimeException("订单不存在"));
// 检查权限
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN")) && !order.getUser().getId().equals(user.getId())) {
return ResponseEntity.badRequest()
.body(createErrorResponse("无权限操作此订单"));
}
@@ -297,7 +297,7 @@ public class OrderApiController {
.orElseThrow(() -> new RuntimeException("订单不存在"));
// 检查权限
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN")) && !order.getUser().getId().equals(user.getId())) {
return ResponseEntity.badRequest()
.body(createErrorResponse("无权限操作此订单"));
}
@@ -331,7 +331,7 @@ public class OrderApiController {
User user = userService.findByUsername(username);
// 只有管理员可以发货
if (!user.getRole().equals("ROLE_ADMIN")) {
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN"))) {
return ResponseEntity.badRequest()
.body(createErrorResponse("无权限操作此订单"));
}
@@ -364,7 +364,7 @@ public class OrderApiController {
User user = userService.findByUsername(username);
// 只有管理员可以完成订单
if (!user.getRole().equals("ROLE_ADMIN")) {
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN"))) {
return ResponseEntity.badRequest()
.body(createErrorResponse("无权限操作此订单"));
}
@@ -457,7 +457,7 @@ public class OrderApiController {
// 获取统计数据
Map<String, Object> stats;
if (user.getRole().equals("ROLE_ADMIN")) {
if ((user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN"))) {
// 管理员查看所有订单统计
stats = orderService.getOrderStats();
} else {
@@ -491,7 +491,7 @@ public class OrderApiController {
.orElseThrow(() -> new RuntimeException("订单不存在"));
// 检查权限
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN")) && !order.getUser().getId().equals(user.getId())) {
return ResponseEntity.badRequest()
.body(createErrorResponse("无权限删除此订单"));
}
@@ -522,7 +522,7 @@ public class OrderApiController {
User user = userService.findByUsername(username);
// 只有管理员可以批量删除
if (!user.getRole().equals("ROLE_ADMIN")) {
if (!(user.getRole().equals("ROLE_ADMIN") || user.getRole().equals("ROLE_SUPER_ADMIN"))) {
return ResponseEntity.badRequest()
.body(createErrorResponse("无权限批量删除订单"));
}

View File

@@ -115,7 +115,7 @@ public class OrderController {
Order order = orderOpt.get();
// 检查权限:用户只能查看自己的订单,管理员可以查看所有订单
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN") && !order.getUser().getId().equals(user.getId())) {
model.addAttribute("error", "无权限访问此订单");
return "orders/detail";
}
@@ -230,7 +230,7 @@ public class OrderController {
Order order = orderOpt.get();
// 检查权限
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN") && !order.getUser().getId().equals(user.getId())) {
model.addAttribute("error", "无权限操作此订单");
return "redirect:/orders";
}
@@ -273,7 +273,7 @@ public class OrderController {
Order order = orderOpt.get();
// 检查权限
if (!user.getRole().equals("ROLE_ADMIN") && !order.getUser().getId().equals(user.getId())) {
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN") && !order.getUser().getId().equals(user.getId())) {
model.addAttribute("error", "无权限操作此订单");
return "redirect:/orders";
}
@@ -303,7 +303,7 @@ public class OrderController {
User user = userService.findByUsername(username);
// 只有管理员可以发货
if (!user.getRole().equals("ROLE_ADMIN")) {
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN")) {
model.addAttribute("error", "无权限操作此订单");
return "redirect:/orders/" + id;
}
@@ -332,7 +332,7 @@ public class OrderController {
User user = userService.findByUsername(username);
// 只有管理员可以完成订单
if (!user.getRole().equals("ROLE_ADMIN")) {
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN")) {
model.addAttribute("error", "无权限操作此订单");
return "redirect:/orders/" + id;
}
@@ -416,7 +416,7 @@ public class OrderController {
User user = userService.findByUsername(username);
// 只有管理员可以访问
if (!user.getRole().equals("ROLE_ADMIN")) {
if (!user.getRole().equals("ROLE_ADMIN") && !user.getRole().equals("ROLE_SUPER_ADMIN")) {
model.addAttribute("error", "无权限访问");
return "redirect:/orders";
}

View File

@@ -37,10 +37,13 @@ public class PayPalController {
/**
* 创建PayPal支付
* 支持两种模式:
* 1. 传入 paymentId使用已有的支付记录
* 2. 不传入 paymentId创建新的支付记录
*/
@PostMapping("/create")
@Operation(summary = "创建PayPal支付", description = "创建PayPal支付订单并返回支付URL")
public ResponseEntity<Map<String, Object>> createPayment(@RequestBody Map<String, String> request) {
public ResponseEntity<Map<String, Object>> createPayment(@RequestBody Map<String, Object> request) {
try {
logger.info("=== 创建PayPal支付请求 ===");
logger.info("请求参数: {}", request);
@@ -52,19 +55,72 @@ public class PayPalController {
return ResponseEntity.badRequest().body(errorResponse);
}
String username = request.get("username");
String orderId = request.get("orderId");
String amount = request.get("amount");
String method = request.get("method");
Payment payment;
// 创建支付记录
Payment payment = paymentService.createPayment(username, orderId, amount, method);
// 检查是否传入了已有的 paymentId
Object paymentIdObj = request.get("paymentId");
if (paymentIdObj != null) {
Long paymentId = Long.valueOf(paymentIdObj.toString());
logger.info("使用已有的支付记录paymentId: {}", paymentId);
Optional<Payment> existingPayment = paymentService.findById(paymentId);
if (existingPayment.isEmpty()) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "支付记录不存在");
return ResponseEntity.badRequest().body(errorResponse);
}
payment = existingPayment.get();
} else {
// 旧模式:创建新的支付记录
String username = (String) request.get("username");
String orderId = (String) request.get("orderId");
String amount = request.get("amount") != null ? request.get("amount").toString() : null;
String method = (String) request.get("method");
payment = paymentService.createPayment(username, orderId, amount, method);
}
// 调用 PayPal API 创建支付
String paypalUrl = null;
String paypalPaymentId = null;
if (payPalService != null) {
try {
Map<String, Object> paypalResult = payPalService.createPayment(payment);
if (paypalResult.containsKey("paymentUrl")) {
paypalUrl = paypalResult.get("paymentUrl").toString();
}
if (paypalResult.containsKey("paypalPaymentId")) {
paypalPaymentId = paypalResult.get("paypalPaymentId").toString();
}
logger.info("PayPal API 返回: url={}, paypalId={}", paypalUrl, paypalPaymentId);
} catch (Exception e) {
logger.error("调用 PayPal API 失败: {}", e.getMessage());
}
}
// 如果 PayPal API 未配置或调用失败,返回错误
if (paypalUrl == null || paypalUrl.isEmpty()) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", "PayPal服务暂不可用请使用支付宝支付");
return ResponseEntity.badRequest().body(errorResponse);
}
// 保存 PayPal 的 paymentId 到数据库
if (paypalPaymentId != null) {
payment.setExternalTransactionId(paypalPaymentId);
payment.setPaymentUrl(paypalUrl);
paymentService.save(payment);
logger.info("已保存 PayPal paymentId: {} 到支付记录: {}", paypalPaymentId, payment.getId());
}
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("paymentId", payment.getId());
response.put("paymentUrl", payment.getPaymentUrl());
response.put("externalTransactionId", payment.getExternalTransactionId());
response.put("paymentUrl", paypalUrl);
response.put("externalTransactionId", paypalPaymentId);
logger.info("✅ PayPal支付创建成功: {}", response);

View File

@@ -540,65 +540,7 @@ public class PaymentApiController {
}
}
@PostMapping("/alipay/notify")
public String alipayNotify(HttpServletRequest request) {
logger.info("========== [API] 收到支付宝回调请求 ==========");
logger.info("请求方法: {}", request.getMethod());
logger.info("请求URL: {}", request.getRequestURL());
logger.info("Content-Type: {}", request.getContentType());
try {
Map<String, String> params = new java.util.HashMap<>();
request.getParameterMap().forEach((key, values) -> {
if (values != null && values.length > 0) {
params.put(key, values[0]);
}
});
if ("POST".equalsIgnoreCase(request.getMethod())) {
try {
StringBuilder body = new StringBuilder();
try (java.io.BufferedReader reader = request.getReader()) {
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
}
if (body.length() > 0) {
String bodyStr = body.toString();
if (bodyStr.contains("=")) {
String[] pairs = bodyStr.split("&");
for (String pair : pairs) {
String[] keyValue = pair.split("=", 2);
if (keyValue.length == 2) {
try {
params.put(
java.net.URLDecoder.decode(keyValue[0], "UTF-8"),
java.net.URLDecoder.decode(keyValue[1], "UTF-8")
);
} catch (Exception e) {
logger.warn("解析参数失败: {}", pair, e);
}
}
}
}
}
} catch (Exception e) {
logger.warn("读取请求体失败", e);
}
}
logger.info("解析到的参数: {}", params);
boolean success = alipayService.handleNotify(params);
logger.info("处理结果: {}", success ? "success" : "fail");
logger.info("========== [API] 支付宝回调处理完成 ==========");
return success ? "success" : "fail";
} catch (Exception e) {
logger.error("========== [API] 处理支付宝异步通知失败 ==========", e);
return "fail";
}
}
// 注意:支付宝异步通知接口已移至 AlipayCallbackController避免路由冲突
/**
* 创建支付宝支付
@@ -657,7 +599,7 @@ public class PaymentApiController {
User user = userService.findByUsername(username);
// 只有管理员可以删除
if (!"ROLE_ADMIN".equals(user.getRole())) {
if (!"ROLE_ADMIN".equals(user.getRole()) && !"ROLE_SUPER_ADMIN".equals(user.getRole())) {
return ResponseEntity.status(403)
.body(createErrorResponse("无权限删除支付记录"));
}
@@ -688,7 +630,7 @@ public class PaymentApiController {
User user = userService.findByUsername(username);
// 只有管理员可以删除
if (!"ROLE_ADMIN".equals(user.getRole())) {
if (!"ROLE_ADMIN".equals(user.getRole()) && !"ROLE_SUPER_ADMIN".equals(user.getRole())) {
return ResponseEntity.status(403)
.body(createErrorResponse("无权限批量删除支付记录"));
}

View File

@@ -0,0 +1,19 @@
package com.example.demo.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 用于兼容前端直接请求 /api-management 的占位接口,
* 避免被 GlobalExceptionHandler 记录 404 日志。
*/
@RestController
public class SpaForwardController {
@GetMapping("/api-management")
public ResponseEntity<Void> apiManagementPlaceholder() {
// 不返回任何内容,仅表示后端该路径存在
return ResponseEntity.noContent().build(); // HTTP 204
}
}

View File

@@ -111,6 +111,8 @@ public class StoryboardVideoApiController {
.body(Map.of("success", false, "message", "无权访问此任务"));
}
// 状态同步已通过数据库触发器实现,无需代码检查
Map<String, Object> taskData = new HashMap<>();
taskData.put("taskId", task.getTaskId());
taskData.put("status", task.getStatus());

View File

@@ -216,6 +216,8 @@ public class TextToVideoApiController {
return ResponseEntity.status(404).body(response);
}
// 状态同步已通过数据库触发器实现,无需代码检查
Map<String, Object> statusData = new HashMap<>();
statusData.put("taskId", task.getTaskId());
statusData.put("status", task.getStatus());

View File

@@ -85,13 +85,15 @@ public class UserWorkApiController {
}
/**
* 获取我的作品列表只返回有resultUrl的作品
* 获取我的作品列表
* @param includeProcessing 是否包含正在排队和生成中的作品默认为true
*/
@GetMapping("/my-works")
public ResponseEntity<Map<String, Object>> getMyWorks(
@RequestHeader("Authorization") String token,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "1000") int size) {
@RequestParam(defaultValue = "1000") int size,
@RequestParam(defaultValue = "true") boolean includeProcessing) {
Map<String, Object> response = new HashMap<>();
@@ -107,7 +109,13 @@ public class UserWorkApiController {
if (page < 0) page = 0;
if (size <= 0) size = 1000; // 不设上限默认1000条
Page<UserWork> works = userWorkService.getUserWorks(username, page, size);
// 根据参数决定是否包含正在进行中的作品
Page<UserWork> works;
if (includeProcessing) {
works = userWorkService.getAllUserWorks(username, page, size);
} else {
works = userWorkService.getUserWorks(username, page, size);
}
Map<String, Object> workStats = userWorkService.getUserWorkStats(username);
// 调试日志:检查返回的作品数据

View File

@@ -0,0 +1,73 @@
package com.example.demo.filter;
import com.example.demo.service.OnlineStatsService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 访客追踪过滤器
* 记录每个请求的IP地址用于统计在线人数
*/
@Component
public class VisitorTrackingFilter extends OncePerRequestFilter {
@Autowired
private OnlineStatsService onlineStatsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 获取真实IP地址考虑代理情况
String ip = getClientIP(request);
// 记录访问
if (ip != null && !ip.isEmpty()) {
onlineStatsService.recordVisit(ip);
}
// 继续过滤链
filterChain.doFilter(request, 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("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.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_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.getRemoteAddr();
}
// 如果是多个代理取第一个IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View File

@@ -1,7 +1,5 @@
package com.example.demo.interceptor;
import java.time.LocalDateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -10,7 +8,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import com.example.demo.model.User;
import com.example.demo.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
@@ -51,14 +48,11 @@ public class UserActivityInterceptor implements HandlerInterceptor {
/**
* 异步更新用户活跃时间
* 使用专门的方法,不触发缓存清除
*/
private void updateUserActiveTimeAsync(String username) {
try {
User user = userService.findByUsernameOrNull(username);
if (user != null) {
user.setLastActiveTime(LocalDateTime.now());
userService.save(user);
}
userService.updateLastActiveTime(username);
} catch (Exception e) {
// 忽略更新失败
}

View File

@@ -39,6 +39,12 @@ public interface ImageToVideoTaskRepository extends JpaRepository<ImageToVideoTa
* 统计用户任务数量
*/
long countByUsername(String username);
/**
* 统计用户进行中的任务数量PENDING 或 PROCESSING
*/
@Query("SELECT COUNT(t) FROM ImageToVideoTask t WHERE t.username = :username AND t.status IN ('PENDING', 'PROCESSING')")
long countProcessingTasksByUsername(@Param("username") String username);
/**
* 根据状态查找任务列表

View File

@@ -90,4 +90,16 @@ public interface PaymentRepository extends JpaRepository<Payment, Long> {
*/
@Query("SELECT COALESCE(SUM(p.amount), 0) FROM Payment p WHERE p.status = :status AND p.paidAt BETWEEN :startTime AND :endTime")
java.math.BigDecimal sumAmountByStatusAndPaidAtBetween(@Param("status") PaymentStatus status, @Param("startTime") java.time.LocalDateTime startTime, @Param("endTime") java.time.LocalDateTime endTime);
/**
* 统计指定时间范围内成功支付的不同用户数
*/
@Query("SELECT COUNT(DISTINCT p.user.id) FROM Payment p WHERE p.status = :status AND p.paidAt BETWEEN :startTime AND :endTime")
long countDistinctUsersByStatusAndPaidAtBetween(@Param("status") PaymentStatus status, @Param("startTime") java.time.LocalDateTime startTime, @Param("endTime") java.time.LocalDateTime endTime);
/**
* 统计指定时间之前成功支付的不同用户数(用于累计统计)
*/
@Query("SELECT COUNT(DISTINCT p.user.id) FROM Payment p WHERE p.status = :status AND p.paidAt < :beforeTime")
long countDistinctUsersByStatusAndPaidAtBefore(@Param("status") PaymentStatus status, @Param("beforeTime") java.time.LocalDateTime beforeTime);
}

View File

@@ -45,6 +45,12 @@ public interface TextToVideoTaskRepository extends JpaRepository<TextToVideoTask
*/
long countByUsername(String username);
/**
* 统计用户进行中的任务数量PENDING 或 PROCESSING
*/
@Query("SELECT COUNT(t) FROM TextToVideoTask t WHERE t.username = :username AND t.status IN ('PENDING', 'PROCESSING')")
long countProcessingTasksByUsername(@Param("username") String username);
/**
* 根据状态查找任务列表
*/

View File

@@ -15,4 +15,12 @@ public interface UserMembershipRepository extends JpaRepository<UserMembership,
long countByStatus(String status);
long countByStartDateBetween(LocalDateTime startDate, LocalDateTime endDate);
/**
* 统计指定时间之前开始的会员数(用于累计统计)
*/
long countByStartDateBefore(LocalDateTime beforeTime);
// 根据用户ID删除会员信息
void deleteByUserId(Long userId);
}

View File

@@ -3,11 +3,16 @@ package com.example.demo.repository;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import com.example.demo.model.User;
public interface UserRepository extends JpaRepository<User, Long> {
// 分页查询活跃用户isActive=true
Page<User> findByIsActive(Boolean isActive, Pageable pageable);
Optional<User> findByUsername(String username);
Optional<User> findByNickname(String nickname);
Optional<User> findByEmail(String email);
@@ -28,6 +33,11 @@ public interface UserRepository extends JpaRepository<User, Long> {
* 统计今日新增用户数
*/
long countByCreatedAtAfter(LocalDateTime startTime);
/**
* 统计指定时间之前创建的用户数(用于累计统计)
*/
long countByCreatedAtBefore(LocalDateTime beforeTime);
}

View File

@@ -23,7 +23,7 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
/**
* 根据用户名查找作品
*/
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status != 'DELETED' ORDER BY uw.createdAt DESC")
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND uw.status NOT IN ('DELETED', 'FAILED') ORDER BY uw.createdAt DESC")
Page<UserWork> findByUsernameOrderByCreatedAtDesc(@Param("username") String username, Pageable pageable);
/**
@@ -41,10 +41,9 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
/**
* 根据用户名查找正在进行中和排队中的作品
* 增加时间限制只返回最近24小时内的任务避免返回陈旧的僵尸任务
*/
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND (uw.status = 'PROCESSING' OR uw.status = 'PENDING') AND uw.createdAt > :afterTime ORDER BY uw.createdAt DESC")
List<UserWork> findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(@Param("username") String username, @Param("afterTime") LocalDateTime afterTime);
@Query("SELECT uw FROM UserWork uw WHERE uw.username = :username AND (uw.status = 'PROCESSING' OR uw.status = 'PENDING') ORDER BY uw.createdAt DESC")
List<UserWork> findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(@Param("username") String username);
/**
* 查找所有PROCESSING状态的作品用于系统重启清理
@@ -97,6 +96,13 @@ public interface UserWorkRepository extends JpaRepository<UserWork, Long> {
*/
@Query("SELECT COUNT(uw) FROM UserWork uw WHERE uw.username = :username AND uw.status != 'DELETED'")
long countByUsername(@Param("username") String username);
/**
* 统计用户进行中的任务数量(所有类型:文生视频、图生视频、分镜视频等)
* 包括 PROCESSING 和 PENDING 状态
*/
@Query("SELECT COUNT(uw) FROM UserWork uw WHERE uw.username = :username AND (uw.status = 'PROCESSING' OR uw.status = 'PENDING')")
long countProcessingTasksByUsername(@Param("username") String username);
/**
* 统计用户公开作品数量

View File

@@ -1,9 +1,11 @@
package com.example.demo.security;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Component
/**
* 明文密码编码器(仅用于测试,生产环境不要使用)
* 注意:已移除 @Component 注解,避免与 BCryptPasswordEncoder 冲突
*/
public class PlainTextPasswordEncoder implements PasswordEncoder {
@Override

View File

@@ -133,7 +133,16 @@ public class AlipayService {
// 设置业务参数
AlipayTradePrecreateModel model = new AlipayTradePrecreateModel();
model.setOutTradeNo(payment.getOrderId());
model.setTotalAmount(payment.getAmount().toString());
// 如果是美元按汇率转换为人民币支付宝只支持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");
@@ -201,6 +210,21 @@ public class AlipayService {
} else {
String subCode = (String) precreateResponse.get("sub_code");
String subMsg = (String) precreateResponse.get("sub_msg");
// 如果交易已经成功,不需要再生成二维码
if ("ACQ.TRADE_HAS_SUCCESS".equals(subCode)) {
logger.info("交易已成功支付,订单号:{},无需再生成二维码", payment.getOrderId());
// 更新支付状态为成功
payment.setStatus(PaymentStatus.SUCCESS);
payment.setPaidAt(LocalDateTime.now());
paymentRepository.save(payment);
// 返回已支付成功的信息
Map<String, Object> result = new HashMap<>();
result.put("alreadyPaid", true);
result.put("message", "该订单已支付成功");
return result;
}
throw new RuntimeException("二维码生成失败:" + msg + (subMsg != null ? " - " + subMsg : "") + " (code: " + code + (subCode != null ? ", sub_code: " + subCode : "") + ")");
}
} else {

View File

@@ -11,12 +11,9 @@ import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
@@ -156,14 +153,12 @@ public class CosService {
}
/**
* 上传图片字节数组到COS存储在images目录下)
* 上传图片字节数组到COS存储在配置的目录下)
*/
private String uploadImageBytes(byte[] bytes, String filename, String contentType) {
try {
// 构建对象键(图片使用 images 目录
LocalDate now = LocalDate.now();
String datePath = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String key = "images/" + datePath + "/" + filename;
// 添加前缀目录
String key = buildObjectKey(filename);
// 设置元数据
ObjectMetadata metadata = new ObjectMetadata();
@@ -282,13 +277,18 @@ public class CosService {
}
/**
* 生成对象键(带日期目录结构
* 例如: videos/2025/01/14/uuid.mp4
* 生成对象键(添加配置的前缀目录
*/
private String buildObjectKey(String filename) {
LocalDate now = LocalDate.now();
String datePath = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
return "videos/" + datePath + "/" + filename;
String prefix = cosConfig.getPrefix();
if (prefix != null && !prefix.isEmpty()) {
// 确保前缀以 / 结尾
if (!prefix.endsWith("/")) {
prefix = prefix + "/";
}
return prefix + filename;
}
return filename;
}
/**

View File

@@ -2,7 +2,6 @@ package com.example.demo.service;
import com.example.demo.model.Order;
import com.example.demo.model.OrderStatus;
import com.example.demo.model.Payment;
import com.example.demo.model.PaymentStatus;
import com.example.demo.repository.OrderRepository;
import com.example.demo.repository.PaymentRepository;
@@ -39,8 +38,8 @@ public class DashboardService {
overview.put("totalUsers", totalUsers);
// 付费用户数(有成功支付记录的不同用户数)- 优化:直接在数据库层面统计
long payingUsers = paymentRepository.countDistinctUsersByStatus(PaymentStatus.SUCCESS);
overview.put("payingUsers", payingUsers);
long paidUsers = paymentRepository.countDistinctUsersByStatus(PaymentStatus.SUCCESS);
overview.put("paidUsers", paidUsers);
// 今日收入(今日成功支付的金额)- 优化:直接在数据库层面求和
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
@@ -48,16 +47,54 @@ public class DashboardService {
BigDecimal todayRevenue = paymentRepository.sumAmountByStatusAndPaidAtBetween(
PaymentStatus.SUCCESS, todayStart, todayEnd);
overview.put("todayRevenue", todayRevenue);
overview.put("todayRevenue", todayRevenue != null ? todayRevenue : BigDecimal.ZERO);
// 转化率(付费用户数 / 总用户数)
double conversionRate = totalUsers > 0 ? (double) payingUsers / totalUsers * 100 : 0;
double conversionRate = totalUsers > 0 ? (double) paidUsers / totalUsers * 100 : 0;
overview.put("conversionRate", Math.round(conversionRate * 100.0) / 100.0);
// 今日新增用户 - 优化:直接在数据库层面统计
long todayNewUsers = userRepository.countByCreatedAtBetween(todayStart, todayEnd);
overview.put("todayNewUsers", todayNewUsers);
// ===== 计算环比变化(当前值 vs 上月底值)=====
LocalDate today = LocalDate.now();
// 上月底时间点
LocalDate lastMonthEnd = today.withDayOfMonth(1).minusDays(1);
LocalDateTime lastMonthEndTime = lastMonthEnd.atTime(23, 59, 59);
// 用户总数环比:当前总用户数 vs 上月底总用户数
long lastMonthTotalUsers = userRepository.countByCreatedAtBefore(lastMonthEndTime.plusSeconds(1));
double totalUsersChange = lastMonthTotalUsers > 0 ?
((double) totalUsers - lastMonthTotalUsers) / lastMonthTotalUsers * 100 :
(totalUsers > 0 ? 100 : 0);
overview.put("totalUsersChange", Math.round(totalUsersChange * 10.0) / 10.0);
// 付费用户环比:当前付费用户数 vs 上月底付费用户数
long lastMonthPaidUsers = paymentRepository.countDistinctUsersByStatusAndPaidAtBefore(
PaymentStatus.SUCCESS, lastMonthEndTime.plusSeconds(1));
double paidUsersChange = lastMonthPaidUsers > 0 ?
((double) paidUsers - lastMonthPaidUsers) / lastMonthPaidUsers * 100 :
(paidUsers > 0 ? 100 : 0);
overview.put("paidUsersChange", Math.round(paidUsersChange * 10.0) / 10.0);
// 昨日收入(用于对比今日收入)
LocalDateTime yesterdayStart = today.minusDays(1).atStartOfDay();
LocalDateTime yesterdayEnd = today.minusDays(1).atTime(23, 59, 59);
BigDecimal yesterdayRevenue = paymentRepository.sumAmountByStatusAndPaidAtBetween(
PaymentStatus.SUCCESS, yesterdayStart, yesterdayEnd);
if (yesterdayRevenue == null) yesterdayRevenue = BigDecimal.ZERO;
BigDecimal todayRevenueValue = todayRevenue != null ? todayRevenue : BigDecimal.ZERO;
// 收入变化率
double todayRevenueChange = yesterdayRevenue.compareTo(BigDecimal.ZERO) > 0 ?
todayRevenueValue.subtract(yesterdayRevenue)
.divide(yesterdayRevenue, 4, java.math.RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100)).doubleValue() :
(todayRevenueValue.compareTo(BigDecimal.ZERO) > 0 ? 100 : 0);
overview.put("todayRevenueChange", Math.round(todayRevenueChange * 10.0) / 10.0);
return overview;
}

View File

@@ -205,6 +205,151 @@ public class ImageGridService {
return compressedImage;
}
/**
* 拼接分镜图将用户上传的原图和AI生成的分镜图拼接在一起
*
* 规则:
* - 如果原图是16:9横向创建9:16的画布原图在左生成图在右
* - 如果原图是9:16纵向创建16:9的画布原图在上生成图在下
*
* @param originalImageUrl 用户上传的原图URL或Base64
* @param generatedImageUrl AI生成的分镜图URL或Base64
* @return 拼接后的图片Base64
*/
public String mergeStoryboardImages(String originalImageUrl, String generatedImageUrl) {
if (originalImageUrl == null || originalImageUrl.isEmpty()) {
throw new IllegalArgumentException("原图不能为空");
}
if (generatedImageUrl == null || generatedImageUrl.isEmpty()) {
throw new IllegalArgumentException("生成图不能为空");
}
try {
// 加载原图和生成图
BufferedImage originalImage = loadImageFromUrl(originalImageUrl);
BufferedImage generatedImage = loadImageFromUrl(generatedImageUrl);
if (originalImage == null) {
throw new RuntimeException("无法加载原图");
}
if (generatedImage == null) {
throw new RuntimeException("无法加载生成图");
}
int origWidth = originalImage.getWidth();
int origHeight = originalImage.getHeight();
double aspectRatio = (double) origWidth / origHeight;
logger.info("分镜图拼接: 原图尺寸={}x{}, 宽高比={:.2f}, 生成图尺寸={}x{}",
origWidth, origHeight, aspectRatio,
generatedImage.getWidth(), generatedImage.getHeight());
BufferedImage resultImage;
Graphics2D g;
// 判断原图是横向(16:9)还是纵向(9:16)
if (aspectRatio >= 1.0) {
// 横向图片16:9或更宽创建9:16的画布左右拼接
// 画布宽度 = 原图宽度,画布高度 = 画布宽度 * 16 / 9
int canvasWidth = origWidth;
int canvasHeight = (int) (canvasWidth * 16.0 / 9.0);
// 每张图片占画布宽度的一半
int halfWidth = canvasWidth / 2;
int imageHeight = canvasHeight;
resultImage = new BufferedImage(canvasWidth, canvasHeight, BufferedImage.TYPE_INT_RGB);
g = resultImage.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
// 白色背景
g.setColor(java.awt.Color.WHITE);
g.fillRect(0, 0, canvasWidth, canvasHeight);
// 左侧放原图(缩放并居中)
drawImageCentered(g, originalImage, 0, 0, halfWidth, imageHeight);
// 右侧放生成图(缩放并居中)
drawImageCentered(g, generatedImage, halfWidth, 0, halfWidth, imageHeight);
logger.info("横向拼接完成: 画布={}x{}, 每张图={}x{}", canvasWidth, canvasHeight, halfWidth, imageHeight);
} else {
// 纵向图片9:16或更窄创建16:9的画布上下拼接
// 画布高度 = 原图高度,画布宽度 = 画布高度 * 16 / 9
int canvasHeight = origHeight;
int canvasWidth = (int) (canvasHeight * 16.0 / 9.0);
// 每张图片占画布高度的一半
int halfHeight = canvasHeight / 2;
int imageWidth = canvasWidth;
resultImage = new BufferedImage(canvasWidth, canvasHeight, BufferedImage.TYPE_INT_RGB);
g = resultImage.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
// 白色背景
g.setColor(java.awt.Color.WHITE);
g.fillRect(0, 0, canvasWidth, canvasHeight);
// 上侧放原图(缩放并居中)
drawImageCentered(g, originalImage, 0, 0, imageWidth, halfHeight);
// 下侧放生成图(缩放并居中)
drawImageCentered(g, generatedImage, 0, halfHeight, imageWidth, halfHeight);
logger.info("纵向拼接完成: 画布={}x{}, 每张图={}x{}", canvasWidth, canvasHeight, imageWidth, halfHeight);
}
g.dispose();
// 压缩并转换为Base64
BufferedImage compressedImage = compressGridImage(resultImage, 2048);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
javax.imageio.ImageWriter writer = javax.imageio.ImageIO.getImageWritersByFormatName("jpg").next();
javax.imageio.ImageWriteParam param = writer.getDefaultWriteParam();
if (param.canWriteCompressed()) {
param.setCompressionMode(javax.imageio.ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.85f);
}
javax.imageio.IIOImage iioImage = new javax.imageio.IIOImage(compressedImage, null, null);
writer.setOutput(javax.imageio.ImageIO.createImageOutputStream(baos));
writer.write(null, iioImage, param);
writer.dispose();
byte[] imageBytes = baos.toByteArray();
String base64 = Base64.getEncoder().encodeToString(imageBytes);
logger.info("分镜图拼接完成: 最终尺寸={}x{}, 大小={} KB",
compressedImage.getWidth(), compressedImage.getHeight(), imageBytes.length / 1024);
return "data:image/jpeg;base64," + base64;
} catch (Exception e) {
logger.error("拼接分镜图失败", e);
throw new RuntimeException("分镜图拼接失败: " + e.getMessage(), e);
}
}
/**
* 在指定区域内居中绘制图片(保持比例)
*/
private void drawImageCentered(Graphics2D g, BufferedImage img, int x, int y, int width, int height) {
double scaleX = (double) width / img.getWidth();
double scaleY = (double) height / img.getHeight();
double scale = Math.min(scaleX, scaleY);
int scaledWidth = (int) (img.getWidth() * scale);
int scaledHeight = (int) (img.getHeight() * scale);
int imgX = x + (width - scaledWidth) / 2;
int imgY = y + (height - scaledHeight) / 2;
g.drawImage(img, imgX, imgY, scaledWidth, scaledHeight, null);
}
/**
* 从URL加载图片
*/

View File

@@ -58,7 +58,6 @@ public class ImageToVideoService {
@Value("${app.video.output.path:/outputs}")
private String outputPath;
/**
* 创建图生视频任务
*/
@@ -68,6 +67,9 @@ public class ImageToVideoService {
String aspectRatio, int duration, boolean hdMode) {
try {
// 检查用户所有类型任务的总数(统一检查)
userWorkService.checkMaxConcurrentTasks(username);
// 生成任务ID
String taskId = generateTaskId();
@@ -338,6 +340,16 @@ public class ImageToVideoService {
task.updateStatus(ImageToVideoTask.TaskStatus.COMPLETED);
task.updateProgress(100);
taskRepository.save(task);
// 同步更新 UserWork 表的状态和结果URL
try {
userWorkService.updateWorkOnComplete(task.getTaskId(), resultUrl,
com.example.demo.model.UserWork.WorkStatus.COMPLETED);
logger.info("图生视频任务完成UserWork已更新: {}", task.getTaskId());
} catch (Exception e) {
logger.warn("更新UserWork状态失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
}
logger.info("图生视频任务完成: {}", task.getTaskId());
return;
} else if ("failed".equals(status) || "error".equals(status)) {

View File

@@ -0,0 +1,112 @@
package com.example.demo.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 在线统计服务
* 通过IP地址统计当天访问的独立用户数
*/
@Service
public class OnlineStatsService {
private static final Logger logger = LoggerFactory.getLogger(OnlineStatsService.class);
// 存储当天访问过的IP地址使用Set去重
private final Set<String> todayVisitors = ConcurrentHashMap.newKeySet();
// 记录当前日期,用于判断是否需要重置
private volatile LocalDate currentDate = LocalDate.now();
// 系统启动时间
private final LocalDateTime startTime = LocalDateTime.now();
/**
* 记录访问IP
* @param ip 访问者IP地址
*/
public void recordVisit(String ip) {
if (ip == null || ip.isEmpty()) {
return;
}
// 检查是否需要重置(新的一天)
checkAndResetIfNewDay();
// 添加IP到今日访客列表
todayVisitors.add(ip);
}
/**
* 获取当天在线人数独立IP数
* @return 当天访问的独立IP数量
*/
public int getTodayVisitorCount() {
checkAndResetIfNewDay();
return todayVisitors.size();
}
/**
* 检查是否是新的一天,如果是则重置统计
*/
private void checkAndResetIfNewDay() {
LocalDate today = LocalDate.now();
if (!today.equals(currentDate)) {
synchronized (this) {
if (!today.equals(currentDate)) {
logger.info("新的一天开始,重置在线统计。昨日访客数: {}", todayVisitors.size());
todayVisitors.clear();
currentDate = today;
}
}
}
}
/**
* 每天凌晨0点重置统计
*/
@Scheduled(cron = "0 0 0 * * ?")
public void resetDailyStats() {
logger.info("定时任务:重置每日在线统计。昨日访客数: {}", todayVisitors.size());
todayVisitors.clear();
currentDate = LocalDate.now();
}
/**
* 获取系统运行时间(格式化为 X小时X分
* @return 运行时间字符串
*/
public String getUptime() {
Duration duration = Duration.between(startTime, LocalDateTime.now());
long hours = duration.toHours();
long minutes = duration.toMinutes() % 60;
if (hours > 0) {
return hours + "小时" + minutes + "";
} else {
return minutes + "分钟";
}
}
/**
* 获取统计信息
* @return 包含统计数据的Map
*/
public Map<String, Object> getStats() {
checkAndResetIfNewDay();
Map<String, Object> stats = new ConcurrentHashMap<>();
stats.put("todayVisitors", todayVisitors.size());
stats.put("date", currentDate.toString());
stats.put("uptime", getUptime());
return stats;
}
}

View File

@@ -394,18 +394,13 @@ public class OrderService {
}
/**
* 删除订单(软删除,仅管理员可操作)
* 删除订单(仅管理员可操作)
*/
public void deleteOrder(Long orderId) {
try {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new RuntimeException("订单不存在:" + orderId));
// 只有已取消或已退款的订单才能删除
if (order.getStatus() != OrderStatus.CANCELLED && order.getStatus() != OrderStatus.REFUNDED) {
throw new RuntimeException("只有已取消或已退款的订单才能删除");
}
orderRepository.delete(order);
logger.info("订单删除成功,订单号:{}", order.getOrderNumber());

View File

@@ -13,518 +13,242 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.example.demo.model.Order;
import com.example.demo.model.OrderItem;
import com.example.demo.model.OrderStatus;
import com.example.demo.model.OrderType;
import com.example.demo.model.Payment;
import com.example.demo.model.PaymentMethod;
import com.example.demo.model.PaymentStatus;
import com.example.demo.model.User;
import com.example.demo.model.*;
import com.example.demo.repository.PaymentRepository;
@Service
@Transactional
public class PaymentService {
private static final Logger logger = LoggerFactory.getLogger(PaymentService.class);
@Autowired private PaymentRepository paymentRepository;
@Autowired private OrderService orderService;
@Autowired private UserService userService;
@Autowired private AlipayService alipayService;
@Autowired(required = false) private PayPalService payPalService;
@Autowired
private PaymentRepository paymentRepository;
public Payment save(Payment payment) { return paymentRepository.save(payment); }
@Transactional(readOnly = true) public Optional<Payment> findById(Long id) { return paymentRepository.findByIdWithUser(id); }
@Transactional(readOnly = true) public Optional<Payment> findByOrderId(String orderId) { return paymentRepository.findByOrderId(orderId); }
@Transactional(readOnly = true) public Optional<Payment> findByExternalTransactionId(String id) { return paymentRepository.findByExternalTransactionId(id); }
@Transactional(readOnly = true) public List<Payment> findByUserId(Long userId) { return paymentRepository.findByUserIdOrderByCreatedAtDesc(userId); }
@Transactional(readOnly = true) public List<Payment> findAll() { return paymentRepository.findAll(); }
@Transactional(readOnly = true) public List<Payment> findByStatus(PaymentStatus status) { return paymentRepository.findByStatus(status); }
@Transactional(readOnly = true) public long countByUserId(Long userId) { return paymentRepository.countByUserId(userId); }
@Transactional(readOnly = true) public long countByStatus(PaymentStatus status) { return paymentRepository.countByStatus(status); }
@Transactional(readOnly = true) public long countByUserIdAndStatus(Long userId, PaymentStatus status) { return paymentRepository.countByUserIdAndStatus(userId, status); }
@Autowired
private OrderService orderService;
@Autowired
private UserService userService;
@Autowired
private AlipayService alipayService;
@Autowired(required = false)
private PayPalService payPalService;
/**
* 保存支付记录
*/
public Payment save(Payment payment) {
try {
Payment savedPayment = paymentRepository.save(payment);
logger.info("支付记录保存成功支付ID{}", savedPayment.getId());
return savedPayment;
} catch (Exception e) {
logger.error("保存支付记录失败:", e);
throw new RuntimeException("保存支付记录失败:" + e.getMessage());
}
}
/**
* 根据ID查找支付记录包含User信息避免LazyInitializationException
*/
@Transactional(readOnly = true)
public Optional<Payment> findById(Long id) {
return paymentRepository.findByIdWithUser(id);
}
/**
* 根据订单ID查找支付记录
*/
@Transactional(readOnly = true)
public Optional<Payment> findByOrderId(String orderId) {
return paymentRepository.findByOrderId(orderId);
}
/**
* 根据外部交易ID查找支付记录
*/
@Transactional(readOnly = true)
public Optional<Payment> findByExternalTransactionId(String externalTransactionId) {
return paymentRepository.findByExternalTransactionId(externalTransactionId);
}
/**
* 根据用户ID查找支付记录
*/
@Transactional(readOnly = true)
public List<Payment> findByUserId(Long userId) {
return paymentRepository.findByUserIdOrderByCreatedAtDesc(userId);
}
/**
* 查找所有支付记录
*/
@Transactional(readOnly = true)
public List<Payment> findAll() {
return paymentRepository.findAll();
}
/**
* 根据状态查找支付记录
*/
@Transactional(readOnly = true)
public List<Payment> findByStatus(PaymentStatus status) {
return paymentRepository.findByStatus(status);
}
/**
* 更新支付状态
*/
public Payment updatePaymentStatus(Long paymentId, PaymentStatus newStatus) {
try {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new RuntimeException("支付记录不存在:" + paymentId));
PaymentStatus oldStatus = payment.getStatus();
payment.setStatus(newStatus);
if (newStatus == PaymentStatus.SUCCESS) {
payment.setPaidAt(LocalDateTime.now());
// 更新关联订单状态
if (payment.getOrder() != null) {
orderService.confirmPayment(payment.getOrder().getId(), payment.getExternalTransactionId());
}
}
Payment updatedPayment = paymentRepository.save(payment);
logger.info("支付状态更新成功支付ID{},状态:{} -> {}",
paymentId, oldStatus, newStatus);
return updatedPayment;
} catch (Exception e) {
logger.error("更新支付状态失败:", e);
throw new RuntimeException("更新支付状态失败:" + e.getMessage());
}
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
payment.setStatus(newStatus);
if (newStatus == PaymentStatus.SUCCESS) payment.setPaidAt(LocalDateTime.now());
return paymentRepository.save(payment);
}
/**
* 确认支付成功
*/
public Payment confirmPaymentSuccess(Long paymentId, String externalTransactionId) {
try {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new RuntimeException("支付记录不存在:" + paymentId));
payment.setStatus(PaymentStatus.SUCCESS);
payment.setPaidAt(LocalDateTime.now());
payment.setExternalTransactionId(externalTransactionId);
Payment confirmedPayment = paymentRepository.save(payment);
// 更新关联订单状态
if (payment.getOrder() != null) {
orderService.confirmPayment(payment.getOrder().getId(), externalTransactionId);
} else {
// 如果没有关联订单,自动创建一个订单
createOrderFromPayment(confirmedPayment);
}
// 根据支付金额增加积分
addPointsForPayment(confirmedPayment);
logger.info("支付确认成功支付ID{}外部交易ID{}", paymentId, externalTransactionId);
return confirmedPayment;
} catch (Exception e) {
logger.error("确认支付成功失败:", e);
throw new RuntimeException("确认支付成功失败:" + e.getMessage());
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
// 检查是否已经处理过(防止重复增加积分和重复创建订单)
if (payment.getStatus() == PaymentStatus.SUCCESS) {
logger.info("支付记录已经是成功状态,跳过重复处理: paymentId={}", paymentId);
return payment;
}
}
/**
* 确认支付失败
*/
public Payment confirmPaymentFailure(Long paymentId, String failureReason) {
payment.setStatus(PaymentStatus.SUCCESS);
payment.setPaidAt(LocalDateTime.now());
payment.setExternalTransactionId(externalTransactionId);
Payment savedPayment = paymentRepository.save(payment);
// 支付成功后创建订单
try {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new RuntimeException("支付记录不存在:" + paymentId));
payment.setStatus(PaymentStatus.FAILED);
if (failureReason != null && !failureReason.isEmpty()) {
payment.setDescription((payment.getDescription() != null ? payment.getDescription() + "\n" : "") +
"失败原因:" + failureReason);
}
Payment failedPayment = paymentRepository.save(payment);
logger.info("支付确认失败支付ID{},失败原因:{}", paymentId, failureReason);
return failedPayment;
createOrderForPayment(savedPayment);
} catch (Exception e) {
logger.error("确认支付失败失败:", e);
throw new RuntimeException("确认支付失败失败:" + e.getMessage());
logger.error("支付成功但创建订单失败: paymentId={}, error={}", paymentId, e.getMessage(), e);
}
}
/**
* 创建订单支付
*/
public Payment createOrderPayment(Order order, PaymentMethod paymentMethod) {
// 支付成功后增加用户积分
try {
Payment payment = new Payment();
payment.setOrderId(order.getOrderNumber());
payment.setAmount(order.getTotalAmount());
payment.setCurrency(order.getCurrency());
payment.setPaymentMethod(paymentMethod);
payment.setDescription("订单支付 - " + order.getOrderNumber());
payment.setUser(order.getUser());
payment.setOrder(order);
payment.setStatus(PaymentStatus.PENDING);
Payment savedPayment = paymentRepository.save(payment);
logger.info("订单支付创建成功,订单号:{}支付ID{}", order.getOrderNumber(), savedPayment.getId());
return savedPayment;
addPointsForPayment(savedPayment);
} catch (Exception e) {
logger.error("创建订单支付失败:", e);
throw new RuntimeException("创建订单支付失败:" + e.getMessage());
logger.error("支付成功但增加积分失败: paymentId={}, error={}", paymentId, e.getMessage(), e);
}
return savedPayment;
}
/**
* 统计支付记录数量
* 为支付成功的记录创建订单
*/
@Transactional(readOnly = true)
public long countByUserId(Long userId) {
return paymentRepository.countByUserId(userId);
}
/**
* 统计指定状态的支付记录数量
*/
@Transactional(readOnly = true)
public long countByStatus(PaymentStatus status) {
return paymentRepository.countByStatus(status);
}
/**
* 统计用户指定状态的支付记录数量
*/
@Transactional(readOnly = true)
public long countByUserIdAndStatus(Long userId, PaymentStatus status) {
return paymentRepository.countByUserIdAndStatus(userId, status);
}
/**
* 删除支付记录
*/
public void deletePayment(Long paymentId) {
try {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new RuntimeException("支付记录不存在:" + paymentId));
// 只有失败的支付记录才能删除
if (payment.getStatus() != PaymentStatus.FAILED) {
throw new RuntimeException("只有失败的支付记录才能删除");
}
paymentRepository.delete(payment);
logger.info("支付记录删除成功支付ID{}", paymentId);
} catch (Exception e) {
logger.error("删除支付记录失败:", e);
throw new RuntimeException("删除支付记录失败:" + e.getMessage());
private void createOrderForPayment(Payment payment) {
if (payment == null || payment.getUser() == null) {
logger.warn("无法创建订单: payment或user为空");
return;
}
}
/**
* 根据用户名查找支付记录
*/
@Transactional(readOnly = true)
public List<Payment> findByUsername(String username) {
try {
logger.info("PaymentService: 开始查找用户 {} 的支付记录", username);
// 先查找用户
User user = userService.findByUsername(username);
if (user == null) {
logger.error("PaymentService: 用户 {} 不存在", username);
throw new RuntimeException("用户不存在: " + username);
}
logger.info("PaymentService: 找到用户 {}, ID: {}", username, user.getId());
// 查找支付记录
List<Payment> payments = paymentRepository.findByUserIdOrderByCreatedAtDesc(user.getId());
logger.info("PaymentService: 用户 {} 的支付记录数量: {}", username, payments.size());
return payments;
} catch (Exception e) {
logger.error("PaymentService: 根据用户名查找支付记录失败,用户名: {}, 错误: {}", username, e.getMessage(), e);
throw new RuntimeException("查找支付记录失败:" + e.getMessage());
// 检查是否已经关联了订单
if (payment.getOrder() != null) {
logger.info("支付记录已关联订单,跳过创建: paymentId={}, orderId={}", payment.getId(), payment.getOrder().getId());
return;
}
}
/**
* 创建支付
*/
public Payment createPayment(String username, String orderId, String amountStr, String method) {
try {
logger.info("创建支付 - 用户名: '{}', 订单ID: {}, 金额: {}, 支付方式: {}", username, orderId, amountStr, method);
User user;
try {
logger.info("PaymentService - 尝试查找用户: '{}'", username);
user = userService.findByUsername(username);
logger.info("PaymentService - 用户查找结果: {}", user != null ? "找到用户 '" + user.getUsername() + "'" : "未找到用户");
} catch (Exception e) {
logger.error("PaymentService - 用户查找异常: {}", username, e);
// 如果是匿名用户,创建一个临时用户记录
if (username.startsWith("anonymous_")) {
user = createAnonymousUser(username);
} else {
throw new RuntimeException("用户不存在: " + username);
}
}
logger.info("找到用户: {}", user.getUsername());
BigDecimal amount = new BigDecimal(amountStr);
PaymentMethod paymentMethod = PaymentMethod.valueOf(method);
logger.info("金额: {}, 支付方式: {}", amount, paymentMethod);
Payment payment = new Payment();
payment.setUser(user);
payment.setOrderId(orderId);
payment.setAmount(amount);
payment.setCurrency("CNY"); // 设置默认货币为人民币
payment.setPaymentMethod(paymentMethod);
payment.setStatus(PaymentStatus.PENDING);
payment.setCreatedAt(LocalDateTime.now());
Payment savedPayment = save(payment);
logger.info("支付记录创建成功: {}", savedPayment.getId());
// 根据支付方式调用相应的支付服务
if (paymentMethod == PaymentMethod.ALIPAY) {
try {
Map<String, Object> paymentResult = alipayService.createPayment(savedPayment);
if (paymentResult.containsKey("qrCode")) {
savedPayment.setPaymentUrl(paymentResult.get("qrCode").toString());
}
save(savedPayment);
logger.info("支付宝二维码生成成功: {}", paymentResult.get("qrCode"));
} catch (Exception e) {
logger.error("调用支付宝支付接口失败:", e);
// 不抛出异常,让前端处理
}
} else if (paymentMethod == PaymentMethod.PAYPAL) {
try {
if (payPalService == null) {
throw new RuntimeException("PayPal服务未配置");
}
Map<String, Object> paymentResult = payPalService.createPayment(savedPayment);
if (paymentResult.containsKey("paymentUrl")) {
savedPayment.setPaymentUrl(paymentResult.get("paymentUrl").toString());
}
if (paymentResult.containsKey("paypalPaymentId")) {
savedPayment.setExternalTransactionId(paymentResult.get("paypalPaymentId").toString());
}
save(savedPayment);
logger.info("PayPal支付链接生成成功: {}", paymentResult.get("paymentUrl"));
} catch (Exception e) {
logger.error("调用PayPal支付接口失败", e);
throw new RuntimeException("创建PayPal支付失败: " + e.getMessage());
}
}
return savedPayment;
} catch (Exception e) {
logger.error("创建支付失败:", e);
throw new RuntimeException("创建支付失败:" + e.getMessage());
}
}
/**
* 创建匿名用户
*/
private User createAnonymousUser(String username) {
try {
User user = new User();
user.setUsername(username);
user.setEmail(username + "@anonymous.com");
user.setPasswordHash("anonymous");
user.setRole("ROLE_USER");
return userService.save(user);
} catch (Exception e) {
logger.error("创建匿名用户失败:", e);
throw new RuntimeException("创建匿名用户失败:" + e.getMessage());
}
}
/**
* 获取用户支付统计
*/
@Transactional(readOnly = true)
public Map<String, Object> getUserPaymentStats(String username) {
try {
User user = userService.findByUsername(username);
if (user == null) {
throw new RuntimeException("用户不存在");
}
Long userId = user.getId();
Map<String, Object> stats = new HashMap<>();
stats.put("totalPayments", paymentRepository.countByUserId(userId));
stats.put("successfulPayments", paymentRepository.countByUserIdAndStatus(userId, PaymentStatus.SUCCESS));
stats.put("pendingPayments", paymentRepository.countByUserIdAndStatus(userId, PaymentStatus.PENDING));
stats.put("failedPayments", paymentRepository.countByUserIdAndStatus(userId, PaymentStatus.FAILED));
stats.put("cancelledPayments", paymentRepository.countByUserIdAndStatus(userId, PaymentStatus.CANCELLED));
return stats;
} catch (Exception e) {
logger.error("获取用户支付统计失败:", e);
throw new RuntimeException("获取支付统计失败:" + e.getMessage());
}
}
/**
* 根据支付信息增加积分
*/
private void addPointsForPayment(Payment payment) {
try {
BigDecimal amount = payment.getAmount();
String description = payment.getDescription() != null ? payment.getDescription() : "";
Integer pointsToAdd = 0;
// 优先从描述中识别套餐类型
if (description.contains("标准版") || description.contains("standard")) {
// 标准版订阅 - 200积分
pointsToAdd = 200;
logger.info("识别到标准版订阅,奖励 200 积分");
} else if (description.contains("专业版") || description.contains("premium")) {
// 专业版订阅 - 1000积分
pointsToAdd = 1000;
logger.info("识别到专业版订阅,奖励 1000 积分");
} else {
// 如果描述中没有套餐信息,根据金额判断
// 标准版订阅 (59-258元) - 200积分
if (amount.compareTo(new BigDecimal("59.00")) >= 0 && amount.compareTo(new BigDecimal("259.00")) < 0) {
pointsToAdd = 200;
logger.info("根据金额 {} 判断为标准版订阅,奖励 200 积分", amount);
}
// 专业版订阅 (259元以上) - 1000积分
else if (amount.compareTo(new BigDecimal("259.00")) >= 0) {
pointsToAdd = 1000;
logger.info("根据金额 {} 判断为专业版订阅,奖励 1000 积分", amount);
} else {
logger.warn("支付金额 {} 不在已知套餐范围内,不增加积分", amount);
}
}
if (pointsToAdd > 0) {
userService.addPoints(payment.getUser().getId(), pointsToAdd);
logger.info("✅ 用户 {} 支付 {} 元,成功获得 {} 积分",
payment.getUser().getUsername(), amount, pointsToAdd);
} else {
// 如果金额不在套餐范围内给予基础积分1元=1积分
// 这样可以避免用户支付后没有任何积分的情况
int basePoints = amount.intValue(); // 1元=1积分
if (basePoints > 0) {
userService.addPoints(payment.getUser().getId(), basePoints);
logger.info("✅ 用户 {} 支付 {} 元(非套餐金额),获得基础积分 {} 积分按1元=1积分计算",
payment.getUser().getUsername(), amount, basePoints);
} else {
logger.warn("⚠️ 用户 {} 支付 {} 元,金额过小未获得积分(描述: {}",
payment.getUser().getUsername(), amount, description);
}
}
} catch (Exception e) {
logger.error("❌ 增加积分失败:", e);
// 不抛出异常,避免影响支付流程
}
}
/**
* 从支付记录自动创建订单
*/
private void createOrderFromPayment(Payment payment) {
try {
// 生成订单号
String orderNumber = "ORD" + System.currentTimeMillis();
// 创建订单
Order order = new Order();
order.setUser(payment.getUser());
order.setOrderNumber(orderNumber);
order.setOrderNumber("ORD" + System.currentTimeMillis() + payment.getId());
order.setTotalAmount(payment.getAmount());
order.setCurrency("CNY");
order.setStatus(OrderStatus.PAID); // 支付成功,订单状态为已支付
order.setOrderType(OrderType.PAYMENT); // 订单类型为支付订单
order.setCreatedAt(LocalDateTime.now());
order.setUpdatedAt(LocalDateTime.now());
order.setCurrency(payment.getCurrency() != null ? payment.getCurrency() : "CNY");
order.setStatus(OrderStatus.PAID);
order.setPaidAt(LocalDateTime.now());
order.setOrderType(OrderType.SUBSCRIPTION);
order.setNotes(payment.getDescription() != null ? payment.getDescription() : "会员订阅");
// 保存订单
Order savedOrder = orderService.save(order);
// 根据金额设置订单描述
BigDecimal amount = payment.getAmount();
if (amount != null) {
if (amount.compareTo(new BigDecimal("259.00")) >= 0) {
order.setNotes("专业版会员订阅 - " + amount + "");
} else if (amount.compareTo(new BigDecimal("59.00")) >= 0) {
order.setNotes("标准版会员订阅 - " + amount + "");
}
}
// 创建订单项
OrderItem orderItem = new OrderItem();
orderItem.setOrder(savedOrder);
orderItem.setProductName("支付服务 - " + payment.getPaymentMethod().name());
orderItem.setProductDescription("通过" + payment.getPaymentMethod().name() + "完成的支付服务");
orderItem.setQuantity(1);
orderItem.setUnitPrice(payment.getAmount());
orderItem.setSubtotal(payment.getAmount());
Order savedOrder = orderService.createOrder(order);
// 保存订单
orderService.saveOrderItem(orderItem);
// 更新支付记录,关联到新创建的订单
// 关联支付记录和订单
payment.setOrder(savedOrder);
paymentRepository.save(payment);
logger.info("从支付记录自动创建订单成功支付ID{}订单ID{},订单号:{}",
payment.getId(), savedOrder.getId(), orderNumber);
logger.info("✅ 订单创建成功: orderId={}, orderNumber={}, paymentId={}",
savedOrder.getId(), savedOrder.getOrderNumber(), payment.getId());
} catch (Exception e) {
logger.error("从支付记录创建订单失败", e);
throw new RuntimeException("创建订单失败:" + e.getMessage());
logger.error("创建订单失败: paymentId={}, error={}", payment.getId(), e.getMessage(), e);
throw e;
}
}
/**
* 根据支付金额增加用户积分
* 标准版(59元) -> 200积分
* 专业版(259元) -> 1000积分
*/
private void addPointsForPayment(Payment payment) {
if (payment == null || payment.getUser() == null) {
logger.warn("无法增加积分: payment或user为空");
return;
}
java.math.BigDecimal amount = payment.getAmount();
if (amount == null) {
logger.warn("无法增加积分: 支付金额为空, paymentId={}", payment.getId());
return;
}
// 根据金额计算积分
int points = 0;
String planName = "";
// 专业版订阅 (259元以上) -> 1000积分
if (amount.compareTo(new java.math.BigDecimal("259.00")) >= 0) {
points = 1000;
planName = "专业版";
}
// 标准版订阅 (59-258元) -> 200积分
else if (amount.compareTo(new java.math.BigDecimal("59.00")) >= 0) {
points = 200;
planName = "标准版";
}
// 其他金额不增加积分
else {
logger.info("支付金额不在套餐范围内,不增加积分: amount={}", amount);
return;
}
// 增加积分
Long userId = payment.getUser().getId();
logger.info("开始为用户增加积分: userId={}, points={}, plan={}, paymentId={}", userId, points, planName, payment.getId());
userService.addPoints(userId, points);
logger.info("✅ 积分增加成功: userId={}, addedPoints={}, plan={}", userId, points, planName);
}
public Payment confirmPaymentFailure(Long paymentId, String failureReason) {
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
payment.setStatus(PaymentStatus.FAILED);
return paymentRepository.save(payment);
}
public Payment createOrderPayment(Order order, PaymentMethod paymentMethod) {
Payment payment = new Payment();
payment.setOrderId(order.getOrderNumber());
payment.setAmount(order.getTotalAmount());
payment.setCurrency(order.getCurrency());
payment.setPaymentMethod(paymentMethod);
payment.setUser(order.getUser());
payment.setOrder(order);
payment.setStatus(PaymentStatus.PENDING);
return paymentRepository.save(payment);
}
public void deletePayment(Long paymentId) {
Payment payment = paymentRepository.findById(paymentId).orElseThrow(() -> new RuntimeException("Not found"));
paymentRepository.delete(payment);
}
@Transactional(readOnly = true)
public List<Payment> findByUsername(String username) {
User user = userService.findByUsername(username);
if (user == null) throw new RuntimeException("User not found");
return paymentRepository.findByUserIdOrderByCreatedAtDesc(user.getId());
}
@Transactional
public Payment createPayment(String username, String orderId, String amountStr, String method) {
// 检查是否已存在相同 orderId 的支付记录
Optional<Payment> existing = paymentRepository.findByOrderId(orderId);
if (existing.isPresent()) {
Payment existingPayment = existing.get();
// 如果已存在且状态是 PENDING直接返回
if (existingPayment.getStatus() == PaymentStatus.PENDING) {
logger.info("复用已存在的PENDING支付记录: orderId={}, paymentId={}", orderId, existingPayment.getId());
return existingPayment;
}
// 如果是其他状态,生成新的 orderId
orderId = orderId + "_" + System.currentTimeMillis();
logger.info("已存在相同orderId但状态为{}生成新orderId: {}", existingPayment.getStatus(), orderId);
}
User user = null;
if (username != null) { try { user = userService.findByUsername(username); } catch (Exception e) {} }
if (user == null) { user = userService.findByUsernameOrNull(username != null ? username : "anon"); if (user == null) user = createAnonymousUser(username != null ? username : "anon"); }
Payment payment = new Payment();
payment.setUser(user);
payment.setOrderId(orderId);
payment.setAmount(new BigDecimal(amountStr));
payment.setCurrency("CNY");
payment.setPaymentMethod(PaymentMethod.valueOf(method));
payment.setStatus(PaymentStatus.PENDING);
payment.setCreatedAt(LocalDateTime.now());
return paymentRepository.save(payment);
}
private User createAnonymousUser(String username) {
User user = new User();
user.setUsername(username);
user.setEmail(username + "@anon.com");
user.setPasswordHash("anon");
user.setRole("ROLE_USER");
return userService.save(user);
}
@Transactional(readOnly = true)
public Map<String, Object> getUserPaymentStats(String username) {
User user = userService.findByUsername(username);
if (user == null) throw new RuntimeException("User not found");
Map<String, Object> stats = new HashMap<>();
stats.put("totalPayments", paymentRepository.countByUserId(user.getId()));
stats.put("successfulPayments", paymentRepository.countByUserIdAndStatus(user.getId(), PaymentStatus.SUCCESS));
return stats;
}
}

View File

@@ -79,8 +79,11 @@ public class StoryboardVideoService {
@Autowired
private CosService cosService;
// 默认生成6张分镜图
private static final int DEFAULT_STORYBOARD_IMAGES = 6;
@Autowired
private SystemSettingsService systemSettingsService;
// 默认生成1张分镜图
private static final int DEFAULT_STORYBOARD_IMAGES = 1;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -99,6 +102,9 @@ public class StoryboardVideoService {
throw new IllegalArgumentException("文本描述不能为空");
}
// 检查用户所有类型任务的总数(统一检查)
userWorkService.checkMaxConcurrentTasks(username);
// 生成任务ID
String taskId = generateTaskId();
@@ -113,8 +119,25 @@ public class StoryboardVideoService {
task.setImageModel(imageModel);
}
// 上传用户参考图片到COS
if (imageUrl != null && !imageUrl.isEmpty()) {
task.setImageUrl(imageUrl);
String finalImageUrl = imageUrl;
// 如果是Base64图片且COS启用上传到COS
if (imageUrl.startsWith("data:image") && cosService.isEnabled()) {
try {
logger.info("开始上传用户参考图片到COS: taskId={}", taskId);
String cosUrl = cosService.uploadBase64Image(imageUrl, "ref_" + taskId + ".png");
if (cosUrl != null && !cosUrl.isEmpty()) {
finalImageUrl = cosUrl;
logger.info("用户参考图片上传COS成功: taskId={}, url={}", taskId, cosUrl);
} else {
logger.warn("用户参考图片上传COS失败使用原始URL: taskId={}", taskId);
}
} catch (Exception e) {
logger.error("用户参考图片上传COS异常: taskId={}", taskId, e);
}
}
task.setImageUrl(finalImageUrl);
}
// 保存任务(快速完成,事务立即提交)
@@ -162,14 +185,27 @@ public class StoryboardVideoService {
// 判断是否有参考图片
boolean hasReferenceImage = imageUrl != null && !imageUrl.isEmpty();
logger.info("任务参数 - prompt: {}, aspectRatio: {}, hdMode: {}, 有参考图片: {}, imageModel: {}",
prompt, aspectRatio, hdMode, hasReferenceImage, imageModel);
// 从系统设置读取分镜图系统引导词,并拼接到用户提示词前面
String finalPrompt = prompt;
try {
com.example.demo.model.SystemSettings settings = systemSettingsService.getOrCreate();
String storyboardSystemPrompt = settings.getStoryboardSystemPrompt();
if (storyboardSystemPrompt != null && !storyboardSystemPrompt.trim().isEmpty()) {
finalPrompt = storyboardSystemPrompt.trim() + ", " + prompt;
logger.info("已添加系统引导词,最终提示词长度: {}", finalPrompt.length());
}
} catch (Exception e) {
logger.warn("获取系统引导词失败,使用原始提示词: {}", e.getMessage());
}
logger.info("任务参数 - 原始提示词: {}, 最终提示词长度: {}, aspectRatio: {}, hdMode: {}, 有参考图片: {}, imageModel: {}",
prompt, finalPrompt.length(), aspectRatio, hdMode, hasReferenceImage, imageModel);
// 更新任务状态为处理中(使用 TransactionTemplate 确保事务正确关闭)
updateTaskStatusWithTransactionTemplate(taskId);
// 调用AI生图API生成多张分镜图
logger.info("开始生成{}张分镜图({}模式)...", DEFAULT_STORYBOARD_IMAGES, hasReferenceImage ? "图生图" : "文生图");
// 调用AI生图API生成分镜图
logger.info("开始生成分镜图({}模式)...", hasReferenceImage ? "图生图" : "文生图");
// 收集所有图片URL
List<String> imageUrls = new ArrayList<>();
@@ -192,11 +228,12 @@ public class StoryboardVideoService {
}
// 根据是否有参考图片调用不同的API
// 使用拼接后的提示词 finalPrompt
Map<String, Object> apiResponse;
if (hasReferenceImage) {
// 有参考图片使用图生图API
// 有参考图片使用图生图APIbananaAPI
apiResponse = realAIService.submitImageToImageTask(
prompt,
finalPrompt,
imageUrl,
aspectRatio,
hdMode
@@ -204,7 +241,7 @@ public class StoryboardVideoService {
} else {
// 无参考图片使用文生图API
apiResponse = realAIService.submitTextToImageTask(
prompt,
finalPrompt,
aspectRatio,
1, // 每次生成1张图片
hdMode,
@@ -284,19 +321,19 @@ public class StoryboardVideoService {
}
}
// 严格检查:必须生成6张图片才能拼接
// 严格检查:必须生成指定数量的图片
if (imageUrls.size() < DEFAULT_STORYBOARD_IMAGES) {
String errorMsg = String.format("只生成了%d张图片需要%d张才能拼接分镜图",
String errorMsg = String.format("只生成了%d张图片需要%d张",
imageUrls.size(), DEFAULT_STORYBOARD_IMAGES);
logger.error(errorMsg);
throw new RuntimeException(errorMsg);
}
logger.info("成功生成{}张分镜图,开始拼接...", imageUrls.size());
logger.info("成功生成{}张分镜图", imageUrls.size());
// 确保不超过6张图片如果多于6张只取前6张
// 确保不超过指定数量
if (imageUrls.size() > DEFAULT_STORYBOARD_IMAGES) {
logger.warn("生成了{}张图片,多于预期的{}张,只取前{}张进行拼接",
logger.warn("生成了{}张图片,多于预期的{}张,只取前{}张",
imageUrls.size(), DEFAULT_STORYBOARD_IMAGES, DEFAULT_STORYBOARD_IMAGES);
imageUrls = imageUrls.subList(0, DEFAULT_STORYBOARD_IMAGES);
}
@@ -308,22 +345,38 @@ public class StoryboardVideoService {
// 参考sora2实现确保所有图片格式一致
List<String> validatedImages = validateAndNormalizeImages(imageUrls);
// 验证后的图片数量也要满足要求必须6张
// 验证后的图片数量也要满足要求
if (validatedImages.size() < DEFAULT_STORYBOARD_IMAGES) {
String errorMsg = String.format("验证后只有%d张图片需要%d张才能拼接分镜图",
String errorMsg = String.format("验证后只有%d张图片需要%d张",
validatedImages.size(), DEFAULT_STORYBOARD_IMAGES);
logger.error(errorMsg);
throw new RuntimeException(errorMsg);
}
logger.info("开始拼接{}张图片成分镜图网格...", validatedImages.size());
// 拼接多张图片成网格支持4-6张图片的灵活拼接
// 使用验证后的图片列表都是Base64格式
// 处理分镜图
String mergedImageUrl;
long mergeStartTime = System.currentTimeMillis();
String mergedImageUrl = imageGridService.mergeImagesToGrid(validatedImages, 0); // 0表示自动计算列数
// 如果有参考图片,将原图和生成图拼接在一起
if (hasReferenceImage && validatedImages.size() >= 1) {
String generatedImage = validatedImages.get(0);
logger.info("有参考图片,开始拼接原图和生成图...");
// 根据原图比例决定拼接方式:
// - 16:9横向图创建9:16画布左原图右生成图
// - 9:16纵向图创建16:9画布上原图下生成图
mergedImageUrl = imageGridService.mergeStoryboardImages(imageUrl, generatedImage);
logger.info("原图和生成图拼接完成");
} else if (validatedImages.size() == 1) {
// 无参考图片只有1张图片直接使用
mergedImageUrl = validatedImages.get(0);
logger.info("只有1张分镜图直接使用");
} else {
// 多张图片,拼接成网格
logger.info("开始拼接{}张图片成分镜图网格...", validatedImages.size());
mergedImageUrl = imageGridService.mergeImagesToGrid(validatedImages, 0);
}
long mergeTime = System.currentTimeMillis() - mergeStartTime;
logger.info("图片网格拼接完成,耗时: {}ms", mergeTime);
logger.info("分镜图处理完成,耗时: {}ms", mergeTime);
// 检查拼接后的图片URL是否有效
if (mergedImageUrl == null || mergedImageUrl.isEmpty()) {

View File

@@ -1650,11 +1650,13 @@ public class TaskQueueService {
/**
* 更新任务为完成状态(使用独立事务,快速完成)
* 使用 TransactionTemplate 确保事务正确执行(因为私有方法无法使用 @Transactional
* COS上传在事务外执行避免事务超时
*/
private void updateTaskAsCompleted(TaskQueue taskQueue, String resultUrl) {
String taskId = taskQueue.getTaskId();
// 第一步在事务中完成数据库更新不包含COS上传
try {
// 使用 TransactionTemplate 确保在事务中执行
transactionTemplate.executeWithoutResult(status -> {
try {
// 使用悲观锁查询,防止多线程并发重复处理
@@ -1678,87 +1680,116 @@ public class TaskQueueService {
// 扣除冻结的积分(内部已处理重复扣除的情况)
try {
userService.deductFrozenPoints(taskQueue.getTaskId());
userService.deductFrozenPoints(taskId);
} catch (Exception e) {
// 积分扣除失败不影响任务完成状态
}
// 更新原始任务状态
updateOriginalTaskStatus(taskQueue, "COMPLETED", resultUrl, null);
// 更新原始任务状态先用原始URL
updateOriginalTaskStatus(taskQueue, "COMPLETED", resultUrl, null);
// 上传视频到COS如果启用
String finalResultUrl = resultUrl;
if (resultUrl != null && !resultUrl.isEmpty() && cosService.isEnabled()) {
try {
logger.info("开始上传视频到COS: taskId={}", taskQueue.getTaskId());
String cosUrl = cosService.uploadVideoFromUrl(resultUrl, null);
if (cosUrl != null && !cosUrl.isEmpty()) {
finalResultUrl = cosUrl;
// 更新任务的resultUrl为COS URL
updateOriginalTaskStatus(taskQueue, "COMPLETED", cosUrl, null);
} else {
logger.warn("COS上传失败使用原始URL: taskId={}", taskQueue.getTaskId());
// 创建/更新用户作品
if (resultUrl != null && !resultUrl.isEmpty()) {
try {
userWorkService.createWorkFromTask(taskId, resultUrl);
} catch (Exception workException) {
if (workException.getMessage() == null ||
(!workException.getMessage().contains("已存在") &&
!workException.getMessage().contains("Duplicate entry"))) {
logger.warn("创建/更新用户作品失败: {}", taskId, workException);
}
}
}
} catch (Exception cosException) {
logger.error("上传视频到COS失败使用原始URL: taskId={}", taskQueue.getTaskId(), cosException);
// COS上传失败不影响任务完成继续使用原始URL
}
}
// 创建/更新用户作品 - 在最后执行,避免影响主要流程
// 只有在 resultUrl 有效时才更新为 COMPLETED
// 如果 resultUrl 为空,不做处理(等待超时机制处理)
if (finalResultUrl != null && !finalResultUrl.isEmpty()) {
try {
userWorkService.createWorkFromTask(taskQueue.getTaskId(), finalResultUrl);
} catch (Exception workException) {
// 如果是重复创建异常,静默处理
if (workException.getMessage() == null ||
(!workException.getMessage().contains("已存在") &&
!workException.getMessage().contains("Duplicate entry"))) {
logger.warn("创建/更新用户作品失败: {}", taskQueue.getTaskId(), workException);
}
// 作品创建失败不影响任务完成状态
}
} else {
logger.warn("任务返回完成但 resultUrl 为空,保持 user_works 状态不变(等待超时机制处理): {}", taskQueue.getTaskId());
}
// 更新 task_status 表中的状态(保留记录)
// 只有在 resultUrl 有效时才更新为 COMPLETED
// 如果 resultUrl 为空,保持原状态不变(等待超时机制处理)
if (finalResultUrl != null && !finalResultUrl.isEmpty()) {
try {
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskQueue.getTaskId());
if (taskStatus != null) {
taskStatus.setStatus(TaskStatus.Status.COMPLETED);
taskStatus.setProgress(100);
taskStatus.setResultUrl(finalResultUrl);
taskStatus.setCompletedAt(java.time.LocalDateTime.now());
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
logger.info("task_status 表状态已更新为 COMPLETED: {}", taskQueue.getTaskId());
// 更新 task_status 表中的状态
if (resultUrl != null && !resultUrl.isEmpty()) {
try {
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
if (taskStatus != null) {
taskStatus.setStatus(TaskStatus.Status.COMPLETED);
taskStatus.setProgress(100);
taskStatus.setResultUrl(resultUrl);
taskStatus.setCompletedAt(java.time.LocalDateTime.now());
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
logger.info("task_status 表状态已更新为 COMPLETED: {}", taskId);
}
} catch (Exception statusException) {
logger.warn("更新 task_status 状态失败: {}", taskId, statusException);
}
}
} catch (Exception statusException) {
logger.warn("更新 task_status 状态失败: {}", taskQueue.getTaskId(), statusException);
}
}
// 任务完成后从 task_queue 中删除记录task_status 保留)
try {
taskQueueRepository.delete(freshTaskQueue);
logger.info("任务完成,已从 task_queue 中删除: {}", taskQueue.getTaskId());
} catch (Exception deleteException) {
logger.warn("删除 task_queue 记录失败: {}", taskQueue.getTaskId(), deleteException);
}
} catch (Exception e) {
logger.error("更新任务完成状态失败: {}", taskQueue.getTaskId(), e);
// 任务完成后从 task_queue 中删除记录
try {
taskQueueRepository.delete(freshTaskQueue);
logger.info("任务完成,已从 task_queue 中删除: {}", taskId);
} catch (Exception deleteException) {
logger.warn("删除 task_queue 记录失败: {}", taskId, deleteException);
}
} catch (Exception e) {
logger.error("更新任务完成状态失败: {}", taskId, e);
status.setRollbackOnly();
throw e;
}
});
} catch (Exception e) {
logger.error("执行更新任务完成状态事务失败: {}", taskQueue.getTaskId(), e);
// 如果原始任务状态更新失败,至少保证队列状态正确
logger.error("执行更新任务完成状态事务失败: {}", taskId, e);
return; // 事务失败不继续COS上传
}
// 第二步在事务外执行COS上传避免事务超时
if (resultUrl != null && !resultUrl.isEmpty() && cosService.isEnabled()) {
try {
logger.info("开始上传视频到COS事务外: taskId={}", taskId);
String cosUrl = cosService.uploadVideoFromUrl(resultUrl, null);
if (cosUrl != null && !cosUrl.isEmpty()) {
logger.info("COS上传成功: taskId={}, cosUrl={}", taskId, cosUrl);
// 用新事务更新URL为COS URL
updateResultUrlToCos(taskQueue, cosUrl);
} else {
logger.warn("COS上传返回空URL使用原始URL: taskId={}", taskId);
}
} catch (Exception cosException) {
logger.error("上传视频到COS失败使用原始URL: taskId={}", taskId, cosException);
}
}
}
/**
* 更新结果URL为COS URL独立事务
*/
private void updateResultUrlToCos(TaskQueue taskQueue, String cosUrl) {
String taskId = taskQueue.getTaskId();
try {
transactionTemplate.executeWithoutResult(status -> {
try {
// 更新原始任务的resultUrl
updateOriginalTaskStatus(taskQueue, "COMPLETED", cosUrl, null);
// 更新用户作品的resultUrl
try {
userWorkService.createWorkFromTask(taskId, cosUrl);
} catch (Exception e) {
// 忽略
}
// 更新task_status的resultUrl
try {
TaskStatus taskStatus = taskStatusPollingService.getTaskStatus(taskId);
if (taskStatus != null) {
taskStatus.setResultUrl(cosUrl);
taskStatusPollingService.saveOrUpdateTaskStatus(taskStatus);
}
} catch (Exception e) {
logger.warn("更新task_status COS URL失败: {}", taskId, e);
}
logger.info("已更新resultUrl为COS URL: taskId={}", taskId);
} catch (Exception e) {
logger.error("更新COS URL失败: {}", taskId, e);
}
});
} catch (Exception e) {
logger.error("执行更新COS URL事务失败: {}", taskId, e);
}
}

View File

@@ -239,6 +239,8 @@ public class TaskStatusPollingService {
public void pollTaskStatus(TaskStatus task) {
logger.info("轮询任务状态: taskId={}, externalTaskId={}", task.getTaskId(), task.getExternalTaskId());
String[] pendingAction = null;
try {
// 使用正确的 API 端点GET /v2/videos/generations/{task_id}
String url = apiBaseUrl + "/v2/videos/generations/" + task.getExternalTaskId();
@@ -249,8 +251,8 @@ public class TaskStatusPollingService {
if (response.getStatus() == 200) {
JsonNode responseJson = objectMapper.readTree(response.getBody());
logger.info("轮询任务状态成功: taskId={}, response={}", task.getTaskId(), response.getBody());
// 更新任务状态(使用单独的事务方法)
updateTaskStatusWithTransaction(task, responseJson);
// 更新任务状态(使用单独的事务方法),返回需要后续处理的信息
pendingAction = updateTaskStatusWithTransaction(task, responseJson);
} else {
logger.warn("查询任务状态失败: taskId={}, status={}, response={}",
task.getTaskId(), response.getStatus(), response.getBody());
@@ -263,14 +265,32 @@ public class TaskStatusPollingService {
// 更新轮询次数(使用单独的事务方法)
incrementPollCountWithTransaction(task);
}
// 在事务外处理后续操作(避免事务超时)
if (pendingAction != null) {
try {
String actionType = pendingAction[0];
String taskId = pendingAction[1];
String data = pendingAction[2];
if ("COMPLETED".equals(actionType)) {
taskQueueService.handleTaskCompletionByTaskId(taskId, data);
} else if ("FAILED".equals(actionType)) {
taskQueueService.handleTaskFailureByTaskId(taskId, data);
}
} catch (Exception e) {
logger.error("处理任务后续操作失败: {}", e.getMessage(), e);
}
}
}
/**
* 更新任务状态(单独的事务方法)
* 更新任务状态(单独的事务方法)- 只更新 TaskStatus 表
* 返回值null=无需后续处理, [0]=完成需处理, [1]=失败需处理
*/
@Transactional
public void updateTaskStatusWithTransaction(TaskStatus task, JsonNode responseJson) {
updateTaskStatus(task, responseJson);
public String[] updateTaskStatusWithTransaction(TaskStatus task, JsonNode responseJson) {
return updateTaskStatusOnly(task, responseJson);
}
/**
@@ -283,9 +303,10 @@ public class TaskStatusPollingService {
}
/**
* 更新任务状态
* 更新任务状态 - 只更新 TaskStatus 表,返回后续需要处理的信息
* 返回:[taskId, resultUrl] 表示完成,[taskId, null, errorMessage] 表示失败null 表示无需后续处理
*/
private void updateTaskStatus(TaskStatus task, JsonNode responseJson) {
private String[] updateTaskStatusOnly(TaskStatus task, JsonNode responseJson) {
try {
String status = responseJson.path("status").asText();
String progressStr = responseJson.path("progress").asText("0%");
@@ -304,22 +325,21 @@ public class TaskStatusPollingService {
task.markAsCompleted(resultUrl);
logger.info("任务完成: taskId={}, resultUrl={}", task.getTaskId(), resultUrl);
taskStatusRepository.save(task);
// 同步更新业务表、UserWork 等
taskQueueService.handleTaskCompletionByTaskId(task.getTaskId(), resultUrl);
// 返回任务信息,在事务外处理
return new String[]{"COMPLETED", task.getTaskId(), resultUrl};
} else {
logger.warn("任务状态为成功但 resultUrl 为空,保持 PROCESSING: taskId={}", task.getTaskId());
taskStatusRepository.save(task);
}
return; // 已保存,直接返回
return null;
case "failed":
case "error":
task.markAsFailed(errorMessage);
logger.warn("任务失败: taskId={}, error={}", task.getTaskId(), errorMessage);
taskStatusRepository.save(task);
// 同步更新业务表、UserWork 等
taskQueueService.handleTaskFailureByTaskId(task.getTaskId(), errorMessage);
return; // 已保存,直接返回
// 返回任务信息,在事务外处理
return new String[]{"FAILED", task.getTaskId(), errorMessage};
case "processing":
case "in_progress":
@@ -334,9 +354,11 @@ public class TaskStatusPollingService {
}
taskStatusRepository.save(task);
return null;
} catch (Exception e) {
logger.error("更新任务状态时发生错误: taskId={}, error={}", task.getTaskId(), e.getMessage(), e);
return null;
}
}

View File

@@ -61,6 +61,9 @@ public class TextToVideoService {
throw new IllegalArgumentException("视频时长必须在1-60秒之间");
}
// 检查用户所有类型任务的总数(统一检查)
userWorkService.checkMaxConcurrentTasks(username);
// 生成任务ID
String taskId = generateTaskId();
@@ -269,6 +272,16 @@ public class TextToVideoService {
task.updateStatus(TextToVideoTask.TaskStatus.COMPLETED);
task.updateProgress(100);
taskRepository.save(task);
// 同步更新 UserWork 表的状态和结果URL
try {
userWorkService.updateWorkOnComplete(task.getTaskId(), resultUrl,
com.example.demo.model.UserWork.WorkStatus.COMPLETED);
logger.info("文生视频任务完成UserWork已更新: {}", task.getTaskId());
} catch (Exception e) {
logger.warn("更新UserWork状态失败: taskId={}, error={}", task.getTaskId(), e.getMessage());
}
logger.info("文生视频任务完成: {}", task.getTaskId());
return;
} else if ("failed".equals(status) || "error".equals(status)) {

View File

@@ -4,11 +4,15 @@ import java.time.LocalDateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.example.demo.config.CacheConfig;
import com.example.demo.model.PointsFreezeRecord;
import com.example.demo.model.User;
import com.example.demo.repository.PointsFreezeRecordRepository;
@@ -25,16 +29,19 @@ public class UserService {
private final PointsFreezeRecordRepository pointsFreezeRecordRepository;
private final com.example.demo.repository.OrderRepository orderRepository;
private final com.example.demo.repository.PaymentRepository paymentRepository;
private final CacheManager cacheManager;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder,
PointsFreezeRecordRepository pointsFreezeRecordRepository,
com.example.demo.repository.OrderRepository orderRepository,
com.example.demo.repository.PaymentRepository paymentRepository) {
com.example.demo.repository.PaymentRepository paymentRepository,
CacheManager cacheManager) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.pointsFreezeRecordRepository = pointsFreezeRecordRepository;
this.orderRepository = orderRepository;
this.paymentRepository = paymentRepository;
this.cacheManager = cacheManager;
}
@Transactional
@@ -74,11 +81,13 @@ public class UserService {
return userRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("用户不存在"));
}
@Cacheable(value = CacheConfig.USER_CACHE, key = "#username", unless = "#result == null")
@Transactional(readOnly = true)
public User findByUsername(String username) {
return userRepository.findByUsername(username).orElseThrow(() -> new IllegalArgumentException("用户不存在"));
}
@Cacheable(value = CacheConfig.USER_CACHE, key = "#username", unless = "#result == null")
@Transactional(readOnly = true)
public User findByUsernameOrNull(String username) {
return userRepository.findByUsername(username).orElse(null);
@@ -97,7 +106,8 @@ public class UserService {
@Transactional
public User update(Long id, String username, String email, String rawPasswordNullable, String role) {
User user = findById(id);
if (!user.getUsername().equals(username) && userRepository.existsByUsername(username)) {
String oldUsername = user.getUsername();
if (!oldUsername.equals(username) && userRepository.existsByUsername(username)) {
throw new IllegalArgumentException("用户名已存在");
}
if (!user.getEmail().equals(email) && userRepository.existsByEmail(email)) {
@@ -111,11 +121,35 @@ public class UserService {
if (rawPasswordNullable != null && !rawPasswordNullable.isBlank()) {
user.setPasswordHash(rawPasswordNullable);
}
return userRepository.save(user);
User savedUser = userRepository.save(user);
// 手动清除旧用户名和新用户名的缓存
evictUserCache(oldUsername);
if (!oldUsername.equals(username)) {
evictUserCache(username);
}
return savedUser;
}
/**
* 手动清除用户缓存
* 注意:使用 CacheManager 直接操作,避免同类内部调用 @CacheEvict 不生效的问题
*/
public void evictUserCache(String username) {
Cache cache = cacheManager.getCache(CacheConfig.USER_CACHE);
if (cache != null && username != null) {
cache.evict(username);
logger.debug("清除用户缓存: {}", username);
}
}
@Transactional
public void delete(Long id) {
// 先获取用户名用于清除缓存
userRepository.findById(id).ifPresent(user -> {
evictUserCache(user.getUsername());
});
userRepository.deleteById(id);
}
@@ -129,8 +163,9 @@ public class UserService {
/**
* 修改指定用户的密码
*
* 如果用户已经有密码,则需要提供正确的原密码;
* 如果用户当前没有设置密码(例如仅使用邮箱验证码登录),则可以直接设置新密码。
* 原密码为可选:
* - 如果提供了原密码,则验证原密码是否正确
* - 如果未提供原密码,则直接设置新密码
*/
@Transactional
public void changePassword(Long userId, String oldPassword, String newPassword) {
@@ -139,23 +174,31 @@ public class UserService {
if (newPassword == null || newPassword.isBlank()) {
throw new IllegalArgumentException("新密码不能为空");
}
if (newPassword.length() < 6) {
throw new IllegalArgumentException("新密码长度不能少于6");
if (newPassword.length() < 8) {
throw new IllegalArgumentException("新密码长度不能少于8");
}
// 验证密码必须包含英文字母和数字
if (!newPassword.matches(".*[a-zA-Z].*")) {
throw new IllegalArgumentException("新密码必须包含英文字母");
}
if (!newPassword.matches(".*[0-9].*")) {
throw new IllegalArgumentException("新密码必须包含数字");
}
String currentPasswordHash = user.getPasswordHash();
// 如果已经设置过密码,则需要校验原密码
if (currentPasswordHash != null && !currentPasswordHash.isBlank()) {
if (oldPassword == null || oldPassword.isBlank()) {
throw new IllegalArgumentException("原密码不能为空");
}
if (!checkPassword(oldPassword, currentPasswordHash)) {
throw new IllegalArgumentException("原密码不正确");
// 如果提供了原密码,则需要验证
if (oldPassword != null && !oldPassword.isBlank()) {
if (currentPasswordHash != null && !currentPasswordHash.isBlank()) {
if (!checkPassword(oldPassword, currentPasswordHash)) {
throw new IllegalArgumentException("原密码不正确");
}
}
}
// 如果未提供原密码,直接设置新密码
user.setPasswordHash(passwordEncoder.encode(newPassword));
userRepository.save(user);
evictUserCache(user.getUsername()); // 清除缓存
}
/**
@@ -184,7 +227,22 @@ public class UserService {
*/
@Transactional
public User save(User user) {
return userRepository.save(user);
User savedUser = userRepository.save(user);
evictUserCache(user.getUsername()); // 清除缓存
return savedUser;
}
/**
* 更新用户活跃时间(不清除缓存,避免频繁清除影响性能)
* 此方法专门用于 UserActivityInterceptor仅更新 lastActiveTime 字段
*/
@Transactional
public void updateLastActiveTime(String username) {
userRepository.findByUsername(username).ifPresent(user -> {
user.setLastActiveTime(java.time.LocalDateTime.now());
userRepository.save(user);
// 注意:不清除缓存,因为 lastActiveTime 不是缓存的关键数据
});
}
/**
@@ -192,6 +250,7 @@ public class UserService {
*/
@Transactional
public User addPoints(Long userId, Integer points) {
java.util.Objects.requireNonNull(userId, "用户ID不能为空");
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
@@ -201,7 +260,9 @@ public class UserService {
}
user.setPoints(newPoints);
return userRepository.save(user);
User savedUser = userRepository.save(user);
evictUserCache(user.getUsername()); // 清除缓存
return savedUser;
}
/**
@@ -218,7 +279,9 @@ public class UserService {
}
user.setPoints(newPoints);
return userRepository.save(user);
User savedUser = userRepository.save(user);
evictUserCache(user.getUsername()); // 清除缓存
return savedUser;
}
/**
@@ -242,6 +305,7 @@ public class UserService {
// 增加冻结积分
user.setFrozenPoints(user.getFrozenPoints() + points);
userRepository.save(user);
evictUserCache(username); // 清除缓存
// 创建冻结记录
PointsFreezeRecord record = new PointsFreezeRecord(username, taskId, taskType, points, reason);
@@ -278,6 +342,7 @@ public class UserService {
user.setPoints(user.getPoints() - record.getFreezePoints());
user.setFrozenPoints(user.getFrozenPoints() - record.getFreezePoints());
userRepository.save(user);
evictUserCache(record.getUsername()); // 清除缓存
// 更新冻结记录状态
record.updateStatus(PointsFreezeRecord.FreezeStatus.DEDUCTED);
@@ -303,6 +368,7 @@ public class UserService {
// 减少冻结积分(总积分不变)
user.setFrozenPoints(user.getFrozenPoints() - record.getFreezePoints());
userRepository.save(user);
evictUserCache(record.getUsername()); // 清除缓存
// 更新冻结记录状态
record.updateStatus(PointsFreezeRecord.FreezeStatus.RETURNED);
@@ -319,6 +385,7 @@ public class UserService {
user.setPoints(user.getPoints() + points);
userRepository.save(user);
evictUserCache(username); // 清除缓存
}
/**
@@ -331,6 +398,7 @@ public class UserService {
user.setPoints(points);
userRepository.save(user);
evictUserCache(username); // 清除缓存
}
/**

View File

@@ -49,6 +49,31 @@ public class UserWorkService {
@Autowired
private StoryboardVideoTaskRepository storyboardVideoTaskRepository;
// 每个用户最多同时执行的任务数
private static final int MAX_CONCURRENT_TASKS = 3;
/**
* 检查用户是否可以创建新任务
* @param username 用户名
* @throws IllegalStateException 如果用户已达到最大并发任务数
*/
@Transactional(readOnly = true)
public void checkMaxConcurrentTasks(String username) {
long processingCount = userWorkRepository.countProcessingTasksByUsername(username);
if (processingCount >= MAX_CONCURRENT_TASKS) {
throw new IllegalStateException("您当前有 " + processingCount + " 个任务正在执行,最多同时执行 " + MAX_CONCURRENT_TASKS + " 个任务,请等待部分任务完成后再提交");
}
logger.info("用户 {} 当前进行中任务数: {}/{}", username, processingCount, MAX_CONCURRENT_TASKS);
}
/**
* 获取用户当前进行中的任务数量
*/
@Transactional(readOnly = true)
public long getProcessingTaskCount(String username) {
return userWorkRepository.countProcessingTasksByUsername(username);
}
/**
* 从任务创建作品
* 使用 REQUIRES_NEW 传播行为,防止内部异常导致外部事务回滚
@@ -354,16 +379,23 @@ public class UserWorkService {
Pageable pageable = PageRequest.of(page, size);
return userWorkRepository.findByUsernameWithResultUrlOrderByCreatedAtDesc(username, pageable);
}
/**
* 获取用户的所有作品列表(分页),包括正在排队和生成中的作品
* 用于"我的作品"页面,显示所有状态的作品
*/
@Transactional(readOnly = true)
public Page<UserWork> getAllUserWorks(String username, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return userWorkRepository.findByUsernameOrderByCreatedAtDesc(username, pageable);
}
/**
* 获取用户正在进行中的作品包括PROCESSING和PENDING状态
* 只返回最近24小时内的任务避免返回陈旧的僵尸任务
*/
@Transactional(readOnly = true)
public java.util.List<UserWork> getProcessingWorks(String username) {
// 只查询最近24小时内的任务
LocalDateTime afterTime = LocalDateTime.now().minusHours(24);
return userWorkRepository.findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(username, afterTime);
return userWorkRepository.findByUsernameAndProcessingOrPendingOrderByCreatedAtDesc(username);
}
/**
@@ -452,6 +484,26 @@ public class UserWorkService {
}
}
/**
* 任务完成时更新作品状态和结果URL
* 使用 REQUIRES_NEW 传播行为,防止内部异常导致外部事务回滚
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateWorkOnComplete(String taskId, String resultUrl, UserWork.WorkStatus status) {
Optional<UserWork> workOpt = userWorkRepository.findByTaskId(taskId);
if (workOpt.isPresent()) {
UserWork work = workOpt.get();
work.setStatus(status);
work.setResultUrl(resultUrl);
work.setUpdatedAt(LocalDateTime.now());
userWorkRepository.save(work);
logger.info("任务完成,更新作品: taskId={}, status={}, resultUrl={}",
taskId, status, resultUrl != null ? resultUrl.substring(0, Math.min(50, resultUrl.length())) + "..." : "null");
} else {
logger.warn("未找到对应的作品记录: taskId={}", taskId);
}
}
/**
* 获取公开作品列表
*/

View File

@@ -1,5 +1,5 @@
#Updated by API Key Management
#Mon Nov 24 17:13:13 CST 2025
#Sat Dec 06 10:23:35 CST 2025
ai.api.base-url=https\://ai.comfly.chat
ai.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
ai.image.api.base-url=https\://ai.comfly.chat
@@ -7,21 +7,26 @@ ai.image.api.key=sk-6J0Lpb0NYSwCCEbFUym8SZho1kJZPFN9au19VC78vJckTbCc
alipay.app-id=9021000157616562
alipay.charset=UTF-8
alipay.gateway-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
alipay.notify-url=https\://curtly-aphorismatic-ginger.ngrok-free.dev/api/payments/alipay/notify
alipay.notify-url=https\://vionow.com/api/payments/alipay/notify
alipay.private-key=MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCH7wPeptkJlJuoKwDqxvfJJLTOAWVkHa/TLh+wiy1tEtmwcrOwEU3GuqfkUlhij71WJIZi8KBytCwbax1QGZA/oLXvTCGJJrYrsEL624X5gGCCPKWwHRDhewsQ5W8jFxaaMXxth8GKlSW61PZD2cOQClRVEm2xnWFZ+6/7WBI7082g7ayzGCD2eowXsJyWyuEBCUSbHXkSgxVhqj5wUGIXhr8ly+pdUlJmDX5K8UG2rjJYx+0AU5UZJbOAND7d3iyDsOulHDvth50t8MOWDnDCVJ2aAgUB5FZKtOFxOmzNTsMjvzYldFztF0khbypeeMVL2cxgioIgTvjBkUwd55hZAgMBAAECggEAUjk3pARUoEDt7skkYt87nsW/QCUECY0Tf7AUpxtovON8Hgkju8qbuyvIxokwwV2k72hkiZB33Soyy9r8/iiYYoR5yGfKmUV7R+30df03ivYmamD48BCE138v8GZ31Ufv+hEY7MADSCpzihGrbNtaOdSlslfVVmyWKHHfvy9EyD6yHJGYswLpHXC/QX1TuLRRxk6Uup8qENOG/6zjGWMfxoRZFwTt80ml1mKy32YZGyJqDaQpJcdYwAHOPcnJl1emw4E+oVjiLyksl643npuTkgnZXs1iWcWSS8ojF1w/0kVDzcNh9toLg+HDuQlIHOis01VQ7lYcG4oiMOnhX1QHIQKBgQC9fgBuILjBhuCI9fHvLRdzoNC9heD54YK7xGvEV/mv90k8xcmNx+Yg5C57ASaMRtOq3b7muPiCv5wOtMT4tUCcMIwSrTNlcBM6EoTagnaGfpzOMaHGMXO4vbaw+MIynHnvXFj1rZjG1lzkV/9K36LAaHD9ZKVJaBQ9mK+0CIq/3QKBgQC3pL5GbvXj6/4ahTraXzNDQQpPGVgbHxcOioEXL4ibaOPC58puTW8HDbRvVuhl/4EEOBRVX81BSgkN8XHwTSiZdih2iOqByg+o9kixs7nlFn3Iw9BBP2/g+Wqiyi2N+9g17kfWXXVOKYz/eMXLBeOo4KhQE9wqNGyZldYzX2ywrQKBgApJmvBfqmgnUG1fHOFlS06lvm9ro0ktqxFSmp8wP4gEHt/DxSuDXMUQXk2jRFp9ReSS4VhZVnSSvoA15DO0c2uHXzNsX8v0B7cxZjEOwCyRFyZCn4vJB4VSF2cIOlLRF/Wcx9+eqxqwbJ6hAGUqOwXDJc879ZVEp0So03EsvYupAoGAAnI+Wp/VxLB7FQ1bSFdmTmoKYh1bUBks7HOp3o4yiqduCUWfK7L6XKSxF56Xv+wUYuMAWlbJXCpJTpc9xk6w0MKDLXkLbqkrZjvJohxbyJJxIICDQKtAqUWJRxvcWXzWV3mSGWfrTRw+lZSdReQRMUm01EQ/dYx3OeCGFu8Zeo0CgYAlH5YSYdJxZSoDCJeoTrkxUlFoOg8UQ7SrsaLYLwpwcwpuiWJaTrg6jwFocj+XhjQ9RtRbSBHz2wKSLdl+pXbTbqECKk85zMFl6zG3etXtTJU/dD750Ty4i8zt3+JGhvglPrQBY1CfItgml2oXa/VUVMnLCUS0WSZuPRmPYZD8dg\=\=
alipay.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAksEwzuR3ASrKtTzaANqdQYKOoD44itA1TWG/6onvQr8PHNEMgcguLuJNrdeuT2PDg23byzZ9qKfEM2D5U4zbpt0/uCYLfZQyAnAWWyMvnKPoSIgrtBjnxYK6HE6fuQV3geJTcZxvP/z8dGZB0V0s6a53rzbKSLh0p4w0hWfVXlQihq3Xh4vSKB+ojdhEkIblhpWPT42NPbjVNdwPzIhUGpRy3/nsgNqVBu+ZacQ5/rCvzXU1RE0allBbjcvjymKQTS7bAE0i1Mgo1eX8njvElsfQUv5P7xQdrvZagqtIuTdP19cmsSNGdIC9Z5Po3j0z3KWPR7MrKgDuJfzkWtJR4wIDAQAB
alipay.return-url=https\://curtly-aphorismatic-ginger.ngrok-free.dev/api/payments/alipay/return
alipay.return-url=https\://vionow.com/api/payments/alipay/return
alipay.server-url=https\://openapi-sandbox.dl.alipaydev.com/gateway.do
alipay.sign-type=RSA2
app.ffmpeg.path=C\:/Users/UI/AppData/Local/Microsoft/WinGet/Packages/Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe/ffmpeg-8.0-full_build/bin/ffmpeg.exe
app.temp.dir=./temp
jwt.expiration=86400000
jwt.expiration=7200000
jwt.secret=mySecretKey123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
logging.level.com.example.demo=DEBUG
logging.level.org.hibernate.SQL=WARN
logging.level.org.hibernate.orm.jdbc.bind=WARN
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.security=WARN
paypal.cancel-url=https\://vionow.com/api/payment/paypal/cancel
paypal.client-id=Adpi67TvppjhyyWhrALWwJhLFzv5S_vXoUHzWQchqZe48NaONSryg7QHKBubf0PRmkeJoaxGEKV5v9lT
paypal.client-secret=EDzZl-hddwtt2pNt5RpBIICdlrUS8QtcmAttU_kuANL8Vd937SC4xel_K2hArTovVqEtyL2ZS5IcQcQV
paypal.mode=sandbox
paypal.success-url=https\://vionow.com/api/payment/paypal/success
server.port=8080
server.tomcat.accept-count=100
server.tomcat.connection-timeout=20000
@@ -56,11 +61,5 @@ tencent.ses.region=ap-hongkong
tencent.ses.secret-id=AKIDoaEjFbqxxqZAcv8EE6oZCg2IQPG1fCxm
tencent.ses.secret-key=nR83I79FOSpGcqNo7JXkqnU8g7SjsxuG
tencent.ses.template-id=154360
# ============================================
# PayPal支付配置沙箱测试环境
# ============================================
paypal.client-id=Adpi67TvppjhyyWhrALWwJhLFzv5S_vXoUHzWQchqZe48NaONSryg7QHKBubf0PRmkeJoaxGEKV5v9lT
paypal.client-secret=EDzZl-hddwtt2pNt5RpBIICdlrUS8QtcmAttU_kuANL8Vd937SC4xel_K2hArTovVqEtyL2ZS5IcQcQV
paypal.mode=sandbox
paypal.success-url=http://localhost:8080/api/payment/paypal/success
paypal.cancel-url=http://localhost:8080/api/payment/paypal/cancel
alipay.domain=https\://vionow.com

View File

@@ -17,6 +17,20 @@ server.tomcat.max-http-post-size=600MB
# JPA配置 - 禁用open-in-view避免视图层执行SQL查询
spring.jpa.open-in-view=false
# HikariCP连接池配置
# 连接泄漏检测阈值毫秒设置为0禁用检测避免长时间任务触发误报
spring.datasource.hikari.leak-detection-threshold=0
# 最大连接池大小
spring.datasource.hikari.maximum-pool-size=20
# 最小空闲连接数
spring.datasource.hikari.minimum-idle=5
# 连接超时(毫秒)
spring.datasource.hikari.connection-timeout=30000
# 空闲连接超时(毫秒)
spring.datasource.hikari.idle-timeout=600000
# 连接最大存活时间(毫秒)
spring.datasource.hikari.max-lifetime=1800000
# 应用配置
app.upload.path=uploads
app.video.output.path=outputs
@@ -52,6 +66,8 @@ tencent.cos.secret-key=Xrxywju0wfAf3QiqlT2ZvGYgeS6WjnjT
tencent.cos.region=ap-nanjing
# COS存储桶名称例如my-bucket-1234567890
tencent.cos.bucket-name=test-1323844400
# COS文件夹前缀所有文件存储在此目录下
tencent.cos.prefix=test-sx
# ============================================
# PayPal支付配置
@@ -69,3 +85,18 @@ paypal.success-url=https://vionow.com/api/payment/paypal/success
# 支付取消回调URL
paypal.cancel-url=https://vionow.com/api/payment/paypal/cancel
# ============================================
# GZIP 压缩配置(提升传输性能)
# ============================================
server.compression.enabled=true
server.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml
server.compression.min-response-size=1024
# ============================================
# 日志配置
# ============================================
# 关闭 Spring Security DEBUG 日志
logging.level.org.springframework.security=INFO
# 减少 Tomcat HTTP 解析错误日志(扫描器/HTTPS误连等导致的
logging.level.org.apache.coyote.http11.Http11Processor=ERROR
logging.level.org.apache.coyote.AbstractProtocol=ERROR

View File

@@ -5,11 +5,11 @@
-- 注意:生产环境部署时,此文件应保持为空或仅包含必要的系统配置数据
-- ============================================
-- 管理员权限自动设置
-- 超级管理员权限自动设置
-- ============================================
-- 应用启动时自动将 984523799@qq.com 设置为管理员
-- 如果该用户存在,则更新其角色为管理员
-- 应用启动时自动将 984523799@qq.com 设置为超级管理员
-- 如果该用户存在,则更新其角色为超级管理员
UPDATE users
SET role = 'ROLE_ADMIN',
SET role = 'ROLE_SUPER_ADMIN',
updated_at = CURRENT_TIMESTAMP
WHERE email = '984523799@qq.com';

View File

@@ -0,0 +1,48 @@
-- 性能优化索引MySQL 兼容)
-- 注意:此文件需要手动在数据库中执行,如果索引已存在会报错(可忽略)
-- users 表索引
ALTER TABLE users ADD INDEX idx_users_phone (phone);
ALTER TABLE users ADD INDEX idx_users_is_active (is_active);
ALTER TABLE users ADD INDEX idx_users_last_active_time (last_active_time);
ALTER TABLE users ADD INDEX idx_users_created_at (created_at);
-- payments 表索引
ALTER TABLE payments ADD INDEX idx_payments_user_id (user_id);
ALTER TABLE payments ADD INDEX idx_payments_status (status);
ALTER TABLE payments ADD INDEX idx_payments_created_at (created_at);
ALTER TABLE payments ADD INDEX idx_payments_user_status (user_id, status);
-- orders 表索引
ALTER TABLE orders ADD INDEX idx_orders_user_id (user_id);
ALTER TABLE orders ADD INDEX idx_orders_status (status);
ALTER TABLE orders ADD INDEX idx_orders_created_at (created_at);
ALTER TABLE orders ADD INDEX idx_orders_user_status (user_id, status);
-- user_works 表索引(高频查询优化)
ALTER TABLE user_works ADD INDEX idx_user_works_username (username);
ALTER TABLE user_works ADD INDEX idx_user_works_status (status);
ALTER TABLE user_works ADD INDEX idx_user_works_task_id (task_id);
ALTER TABLE user_works ADD INDEX idx_user_works_created_at (created_at);
ALTER TABLE user_works ADD INDEX idx_user_works_username_status (username, status);
ALTER TABLE user_works ADD INDEX idx_user_works_is_public_status (is_public, status);
-- text_to_video_tasks 表索引
ALTER TABLE text_to_video_tasks ADD INDEX idx_text_to_video_tasks_task_id (task_id);
ALTER TABLE text_to_video_tasks ADD INDEX idx_text_to_video_tasks_username (username);
ALTER TABLE text_to_video_tasks ADD INDEX idx_text_to_video_tasks_status (status);
-- image_to_video_tasks 表索引
ALTER TABLE image_to_video_tasks ADD INDEX idx_image_to_video_tasks_task_id (task_id);
ALTER TABLE image_to_video_tasks ADD INDEX idx_image_to_video_tasks_username (username);
ALTER TABLE image_to_video_tasks ADD INDEX idx_image_to_video_tasks_status (status);
-- storyboard_video_tasks 表索引
ALTER TABLE storyboard_video_tasks ADD INDEX idx_storyboard_video_tasks_task_id (task_id);
ALTER TABLE storyboard_video_tasks ADD INDEX idx_storyboard_video_tasks_username (username);
ALTER TABLE storyboard_video_tasks ADD INDEX idx_storyboard_video_tasks_status (status);
-- points_freeze_records 表索引
ALTER TABLE points_freeze_records ADD INDEX idx_points_freeze_records_task_id (task_id);
ALTER TABLE points_freeze_records ADD INDEX idx_points_freeze_records_username (username);
ALTER TABLE points_freeze_records ADD INDEX idx_points_freeze_records_status (status);

View File

@@ -0,0 +1,126 @@
-- ============================================
-- 任务状态级联更新触发器
-- 当 task_status 表状态更新时,自动同步到其他关联表
-- ============================================
DELIMITER //
-- 删除已存在的触发器(如果存在)
DROP TRIGGER IF EXISTS trg_task_status_update//
-- 创建触发器:当 task_status 更新时,级联更新其他表
CREATE TRIGGER trg_task_status_update
AFTER UPDATE ON task_status
FOR EACH ROW
BEGIN
-- 只在状态发生变化时触发
IF NEW.status <> OLD.status THEN
-- 更新 task_queue 表
UPDATE task_queue
SET status = NEW.status,
updated_at = NOW(),
error_message = CASE WHEN NEW.status = 'FAILED' THEN NEW.error_message ELSE error_message END
WHERE task_id = NEW.task_id;
-- 更新 user_works 表
UPDATE user_works
SET status = CASE
WHEN NEW.status = 'COMPLETED' THEN 'COMPLETED'
WHEN NEW.status = 'FAILED' THEN 'FAILED'
WHEN NEW.status = 'PROCESSING' THEN 'PROCESSING'
WHEN NEW.status = 'PENDING' THEN 'PENDING'
WHEN NEW.status = 'CANCELLED' THEN 'CANCELLED'
ELSE status
END,
updated_at = NOW(),
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
WHERE task_id = NEW.task_id;
-- 更新 text_to_video_tasks 表(根据 task_id 前缀判断)
IF NEW.task_id LIKE 'txt2vid_%' THEN
UPDATE text_to_video_tasks
SET status = NEW.status,
updated_at = NOW(),
error_message = CASE WHEN NEW.status = 'FAILED' THEN NEW.error_message ELSE error_message END,
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
WHERE task_id = NEW.task_id;
END IF;
-- 更新 image_to_video_tasks 表
IF NEW.task_id LIKE 'img2vid_%' THEN
UPDATE image_to_video_tasks
SET status = NEW.status,
updated_at = NOW(),
error_message = CASE WHEN NEW.status = 'FAILED' THEN NEW.error_message ELSE error_message END,
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
WHERE task_id = NEW.task_id;
END IF;
-- 更新 storyboard_video_tasks 表(分镜视频,排除分镜图)
IF NEW.task_id LIKE 'storyboard_%' AND NEW.task_id NOT LIKE '%_image' THEN
UPDATE storyboard_video_tasks
SET status = NEW.status,
updated_at = NOW(),
error_message = CASE WHEN NEW.status = 'FAILED' THEN NEW.error_message ELSE error_message END,
result_url = CASE WHEN NEW.status = 'COMPLETED' AND NEW.result_url IS NOT NULL THEN NEW.result_url ELSE result_url END
WHERE task_id = NEW.task_id;
END IF;
-- 分镜图任务taskId 以 _image 结尾)
IF NEW.task_id LIKE '%_image' THEN
-- 分镜图关联到主任务,更新主任务的时间戳和错误信息
UPDATE storyboard_video_tasks
SET updated_at = NOW(),
error_message = CASE WHEN NEW.status = 'FAILED' THEN NEW.error_message ELSE error_message END
WHERE task_id = REPLACE(NEW.task_id, '_image', '');
END IF;
END IF;
END//
DELIMITER ;
-- ============================================
-- 可选:反向触发器(业务表更新时同步到 task_status
-- 根据需要启用
-- ============================================
/*
DELIMITER //
-- text_to_video_tasks 状态更新时同步
DROP TRIGGER IF EXISTS trg_text_to_video_status_update//
CREATE TRIGGER trg_text_to_video_status_update
AFTER UPDATE ON text_to_video_tasks
FOR EACH ROW
BEGIN
IF NEW.status <> OLD.status THEN
UPDATE task_status SET status = NEW.status, updated_at = NOW() WHERE task_id = NEW.task_id;
END IF;
END//
-- image_to_video_tasks 状态更新时同步
DROP TRIGGER IF EXISTS trg_image_to_video_status_update//
CREATE TRIGGER trg_image_to_video_status_update
AFTER UPDATE ON image_to_video_tasks
FOR EACH ROW
BEGIN
IF NEW.status <> OLD.status THEN
UPDATE task_status SET status = NEW.status, updated_at = NOW() WHERE task_id = NEW.task_id;
END IF;
END//
-- storyboard_video_tasks 状态更新时同步
DROP TRIGGER IF EXISTS trg_storyboard_status_update//
CREATE TRIGGER trg_storyboard_status_update
AFTER UPDATE ON storyboard_video_tasks
FOR EACH ROW
BEGIN
IF NEW.status <> OLD.status THEN
UPDATE task_status SET status = NEW.status, updated_at = NOW() WHERE task_id = NEW.task_id;
END IF;
END//
DELIMITER ;
*/

View File

@@ -0,0 +1,11 @@
-- 修复外键约束,添加级联删除
-- 1. 修复 payments 表的外键约束
ALTER TABLE payments DROP FOREIGN KEY payments_ibfk_1;
ALTER TABLE payments ADD CONSTRAINT payments_user_fk
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- 2. 修复 orders 表的外键约束
ALTER TABLE orders DROP FOREIGN KEY orders_ibfk_1;
ALTER TABLE orders ADD CONSTRAINT orders_user_fk
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;

View File

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

View File

@@ -32,7 +32,7 @@ CREATE TABLE IF NOT EXISTS payments (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
paid_at TIMESTAMP NULL,
user_id BIGINT,
FOREIGN KEY (user_id) REFERENCES users(id)
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS orders (
@@ -55,7 +55,7 @@ CREATE TABLE IF NOT EXISTS orders (
delivered_at TIMESTAMP NULL,
cancelled_at TIMESTAMP NULL,
user_id BIGINT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS order_items (

View File

@@ -1,9 +1,9 @@
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org" th:fragment="layout(title, content)">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title th:text="${pageTitle} + ' - ' + ${siteName}">AIGC Demo</title>
<title th:replace="${title}">AIGC Demo</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
@@ -168,22 +168,22 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" th:href="@{/}" th:classappend="${#httpServletRequest.requestURI == '/'} ? 'active' : ''">
<a class="nav-link" th:href="@{/}" th:classappend="${#httpServletRequest != null && #httpServletRequest.requestURI == '/'} ? 'active' : ''">
<i class="fas fa-home me-1"></i>首页
</a>
</li>
<li class="nav-item" sec:authorize="hasRole('ADMIN')">
<a class="nav-link" th:href="@{/settings}" th:classappend="${#strings.startsWith(#httpServletRequest.requestURI, '/settings')} ? 'active' : ''">
<a class="nav-link" th:href="@{/settings}" th:classappend="${#httpServletRequest != null && #strings.startsWith(#httpServletRequest.requestURI, '/settings')} ? 'active' : ''">
<i class="fas fa-gear me-1"></i>系统设置
</a>
</li>
<li class="nav-item" sec:authorize="hasRole('ADMIN')">
<a class="nav-link" th:href="@{/users}" th:classappend="${#strings.startsWith(#httpServletRequest.requestURI, '/users')} ? 'active' : ''">
<a class="nav-link" th:href="@{/users}" th:classappend="${#httpServletRequest != null && #strings.startsWith(#httpServletRequest.requestURI, '/users')} ? 'active' : ''">
<i class="fas fa-users me-1"></i>用户管理
</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" th:href="@{/payment/create}" th:classappend="${#strings.startsWith(#httpServletRequest.requestURI, '/payment')} ? 'active' : ''">
<a class="nav-link" th:href="@{/payment/create}" th:classappend="${#httpServletRequest != null && #strings.startsWith(#httpServletRequest.requestURI, '/payment')} ? 'active' : ''">
<i class="fas fa-credit-card me-1"></i>支付管理
</a>
</li>
@@ -265,7 +265,7 @@
</div>
<!-- Page Content -->
<div th:fragment="content">
<div th:replace="${content}">
<!-- Page specific content will be inserted here -->
</div>
</div>