122 Commits

Author SHA1 Message Date
a8c1261c89 BUGFIX:印花优先级不从1开始传导致数组越界 2026-05-13 23:54:29 +08:00
ltx
c35e60dcde 更新 src/main/java/com/ai/da/service/impl/GenerateServiceImpl.java 2026-05-13 20:35:57 +08:00
ltx
ad3bc69e5c 更新 src/main/java/com/ai/da/common/constant/ModelConstants.java 2026-05-13 20:34:45 +08:00
bb682e56fa TASK:Global Award记录访客量 2026-05-13 16:26:47 +08:00
9a4a5d5504 BUGFIX 2026-05-13 13:49:56 +08:00
b4354d5975 配合测试 2026-05-13 11:12:00 +08:00
635d913084 BUGFIX:将发送邮件中,原订单页链接替换为发票链接 2026-05-12 13:19:23 +08:00
61e8901bb1 BUGFIX:将发送邮件中,原订单页链接替换为发票链接 2026-05-08 17:06:52 +08:00
1680debd4b BUGFIX: 续订失败没有发送邮件 2026-05-07 11:45:49 +08:00
bd6ba95a25 BUGFIX: 续订没有更新账号到期时间 2026-05-07 11:20:08 +08:00
75efc341be DEBUG:添加日志打印 2026-05-07 11:14:07 +08:00
921de43b08 DEBUG:添加日志打印 2026-05-07 10:55:09 +08:00
c558ebb3d0 TASK:优化订阅收件人列表创建方式 2026-05-05 11:22:17 +08:00
d20bb27244 BUGFIX:新订阅没发送邮件 2026-05-04 16:16:30 +08:00
6e98f295c5 Merge branch 'dev/3.1_release_merge' into temp_PromotionCode 2026-05-04 11:07:26 +08:00
cf02b59722 TASK:Stripe支付模块重构-逻辑优化与完善、Stripe版本升级 2026-04-29 17:16:48 +08:00
838a8a13b3 TO DEV 2026-04-28 13:19:10 +08:00
c95f3accb9 Merge branch 'release/3.1' into dev/3.1_release_merge 2026-04-28 13:12:26 +08:00
65cde0b8f5 TASK:admin-organization plan优化 2026-04-28 13:11:57 +08:00
b66877425e BUGFIX:为下载flux图片添加重试机制 2026-04-21 17:33:39 +08:00
f6d28fec07 BUGFIX:为下载flux图片添加重试功能 2026-04-21 17:21:41 +08:00
litianxiang
f53fca9a09 Merge remote-tracking branch 'origin/dev/3.1_release_merge' into release/3.1 2026-04-15 15:07:04 +08:00
litianxiang
c8dc38575a Merge remote-tracking branch 'origin/dev/3.1_release_merge' into dev/3.1_release_merge 2026-04-15 14:52:35 +08:00
litianxiang
c00d906083 portfolioUrl 漏加fix 2026-04-15 14:52:18 +08:00
4df3f9cc53 BUGFIX:design印花scale与getDetail获取到的印花scale不一致 2026-04-15 14:43:59 +08:00
litianxiang
b0343be544 配置过滤器 2026-04-15 14:05:57 +08:00
litianxiang
d33cb9f0bf 配置过滤器 2026-04-15 13:47:28 +08:00
litianxiang
40f2735831 配置过滤器 2026-04-15 13:26:15 +08:00
litianxiang
d73442d1dd Merge remote-tracking branch 'origin/release/3.1' into release/3.1 2026-04-13 22:05:59 +08:00
litianxiang
c8164cb997 TO PROD 2026-04-13 22:05:31 +08:00
981fc35be4 BUGFIX:design 没有使用printboard中的元素 2026-04-13 18:04:30 +08:00
01d3806d5f Merge remote-tracking branch 'origin/dev/3.1_release_merge' into dev/3.1_release_merge 2026-04-13 17:52:35 +08:00
107e4e9771 BUGFIX:design 没有使用printboard中的元素 2026-04-13 17:51:15 +08:00
litianxiang
716d720782 GlobalAward返回最大的参赛者编号 2026-04-13 14:38:02 +08:00
litianxiang
6b5bacc49b GlobalAward返回最大的参赛者编号 2026-04-13 14:34:05 +08:00
litianxiang
409bc7b1fd 过滤器配置 2026-04-13 13:09:12 +08:00
litianxiang
ec6a5df8af TO DEV 2026-04-13 11:55:17 +08:00
litianxiang
029b96ae99 GlobalAward下载到浏览器 2026-04-13 11:47:20 +08:00
litianxiang
14002e7331 GlobalAward下载补充和数量接口 2026-04-13 10:22:43 +08:00
14dfe2806c merge dev 2026-04-10 23:27:37 +08:00
798c7b0592 Merge branch 'release/3.1' into dev/3.1_release_merge 2026-04-10 23:09:33 +08:00
9bd10581f4 BUGFIX:获取relight结果时删除了排序记录 2026-04-10 23:03:15 +08:00
1f288fe5e3 BUGFIX 2026-04-10 22:55:44 +08:00
72602eb245 BUGFIX 2026-04-10 22:51:00 +08:00
983d53268d DEBUG 2026-04-10 22:32:55 +08:00
f3aeeb3584 DEBUG 2026-04-10 22:21:56 +08:00
5d3692a204 BUGFIX:支付失败后的邮件通知类型错误(临时处理) 2026-04-08 13:52:38 +08:00
f2a074b2f6 Merge branch 'dev/dev_xp' into dev/3.1_release_merge 2026-03-31 15:19:56 +08:00
6a7a37dcec BUGFIX:只有Printboard时,首次design没有使用元素 2026-03-31 14:12:43 +08:00
litianxiang
c4d2780f0e TO DEV 2026-03-31 13:55:32 +08:00
litianxiang
1da6b7728c Merge remote-tracking branch 'origin/dev/3.1_release_merge' into release/3.1 2026-03-30 17:05:43 +08:00
litianxiang
0faf77899b fix:PRINTBOARD_HIGH_I2I与PRINTBOARD_ADVANCED_I2I使用模型一致导致积分扣除错误 2026-03-27 16:49:10 +08:00
litianxiang
e4940019bf 框选适配py 2026-03-27 15:19:38 +08:00
litianxiang
0da66ff210 print t2i模型替换 2026-03-27 15:16:33 +08:00
litianxiang
5dd862ff79 MOOD_BOARD high 去掉翻译 2026-03-26 16:13:43 +08:00
litianxiang
edaec9884d TO PROD 2026-03-25 22:28:44 +08:00
litianxiang
76eeb2be53 moodboard基础模型修改 2026-03-25 10:39:22 +08:00
litianxiang
cb6f94d2d4 py api fix 2026-03-25 10:19:06 +08:00
litianxiang
28656c44c8 FIX FLUX2 2026-03-24 16:24:43 +08:00
litianxiang
6757a89d04 Pattern模式参数fix 2026-03-24 15:54:53 +08:00
litianxiang
9be1a1e307 加锁解决不同线程读取前还未保存的问题 2026-03-24 15:49:16 +08:00
litianxiang
2168978f61 print pattern也改为flux2 2026-03-24 15:32:06 +08:00
litianxiang
54466b935d debug 2026-03-24 15:23:33 +08:00
litianxiang
c970ebe691 debug 2026-03-24 15:15:59 +08:00
litianxiang
1c5a3a12b9 debug 2026-03-24 15:04:40 +08:00
litianxiang
6e06000083 debug 2026-03-24 14:46:01 +08:00
litianxiang
dea2b3be42 debug 2026-03-24 14:29:08 +08:00
litianxiang
bcf51aea23 debug 2026-03-24 14:20:39 +08:00
litianxiang
0c9d5404c6 flux2失败状态判断 2026-03-24 14:05:27 +08:00
litianxiang
93429839c0 本地flux改为flux2 2026-03-24 13:39:38 +08:00
litianxiang
27859c3e28 Merge remote-tracking branch 'origin/release/3.1' into dev/3.1_release_merge 2026-03-23 14:11:33 +08:00
litianxiang
f02c0930a6 日志切面(controller层报错打印) 2026-03-23 13:56:47 +08:00
litianxiang
d57bb83b25 Merge remote-tracking branch 'origin/release/3.1' into release/3.1 2026-03-23 13:50:44 +08:00
731e34f133 TO DEV 2026-03-23 13:38:10 +08:00
75eca8d6ba Merge branch 'release/3.1' into dev/3.1_release_merge
# Conflicts:
#	src/main/java/com/ai/da/python/PythonService.java
2026-03-23 13:22:18 +08:00
3e53401f76 TASK:返回符合查询条件的金额总计 2026-03-23 11:55:07 +08:00
litianxiang
b6a068ebcd SKETCHBOARD传入的text改为获取第一个,为分割获取style的方式 2026-03-23 11:50:24 +08:00
litianxiang
dc291ea086 加入非空校验 2026-03-14 01:50:39 +08:00
litianxiang
2e846e671a romantic风格缺少下装fix 2026-03-13 10:06:26 +08:00
litianxiang
a5093311f9 当style为洛丽塔时无sketch和谷歌风控问题FIX 2026-03-12 19:06:54 +08:00
litianxiang
aed338a6d7 当style为洛丽塔时无sketch和谷歌风控问题FIX 2026-03-12 18:59:17 +08:00
litianxiang
8bdb49d25c 当style为洛丽塔时无sketch和谷歌风控问题FIX 2026-03-12 18:49:57 +08:00
litianxiang
5d53a8cd42 当style为洛丽塔时无sketch和谷歌风控问题FIX 2026-03-12 18:49:28 +08:00
litianxiang
61b7f3072f 当style为洛丽塔时无sketch和谷歌风控问题FIX 2026-03-12 18:36:21 +08:00
litianxiang
a1f489f3a1 比赛url修改 2026-03-12 17:39:05 +08:00
litianxiang
fc3fd877a8 transpose和rotate获取位置修改 2026-03-05 16:58:17 +08:00
litianxiang
fc72d2c430 transpose和rotate获取位置修改 2026-03-05 13:29:14 +08:00
litianxiang
1ac01dd090 测试token恢复 2026-02-25 16:36:06 +08:00
litianxiang
3bbdf7c672 fix:按编号导出参赛选手文件 2026-02-09 10:33:25 +08:00
litianxiang
0646484fba 按编号导出参赛选手文件 2026-02-09 10:21:40 +08:00
litianxiang
96b8613741 映射temp到服务器 2026-02-06 17:29:42 +08:00
litianxiang
cf30226a51 映射temp到服务器 2026-02-06 17:24:43 +08:00
litianxiang
3c15a3ff68 映射temp到服务器 2026-02-06 17:21:07 +08:00
litianxiang
0c904be227 测试临时token 2026-02-06 11:42:34 +08:00
litianxiang
7759b56123 Merge remote-tracking branch 'origin/dev/3.1_release_merge' into dev/3.1_release_merge 2026-02-05 17:39:15 +08:00
litianxiang
d5bfaa8822 fix:允许图生图不带提示词 2026-02-05 17:38:38 +08:00
967c0cbc01 Merge branch 'dev/dev_xp' into dev/3.1_release_merge 2026-02-05 16:55:24 +08:00
417e34b41a BUGFIX:1.首次design没有使用library和生成的印花2.design overall印花太小 2026-02-05 16:54:45 +08:00
litianxiang
d51aa84647 fix:参赛者根据链接返回文件参数 2026-02-05 09:41:10 +08:00
litianxiang
5895bc6ab6 Revert "fix:参赛者根据链接返回文件参数"
This reverts commit 3301869f20.
2026-02-05 09:40:35 +08:00
litianxiang
3301869f20 fix:参赛者根据链接返回文件参数 2026-02-05 09:40:15 +08:00
litianxiang
1ec42f4ad5 fix:参赛者id逻辑更改 2026-02-04 17:20:22 +08:00
cc506ff7e9 Merge branch 'dev/3.1_release_merge' into release/3.1 2026-02-04 17:05:59 +08:00
litianxiang
f2d43f06f4 Merge remote-tracking branch 'origin/dev/3.1_release_merge' into dev/3.1_release_merge 2026-02-04 15:28:08 +08:00
litianxiang
9251df49f8 比赛新增文件大小和视频时长 2026-02-04 15:27:51 +08:00
430156f4e8 Merge branch 'dev/dev_xp' into dev/3.1_release_merge 2026-02-04 14:48:38 +08:00
litianxiang
d1123aedcc fix:导出为页面下载 2026-02-04 14:45:11 +08:00
8c007077a3 BUGFIX: detail中的merge模式下没有存储partialDesign的图片 2026-02-04 14:43:29 +08:00
litianxiang
d63b4b4e63 fix:参赛选手加入编号 2026-02-04 14:03:30 +08:00
litianxiang
b826f0bf39 参赛选手加入编号,增加导出功能 2026-02-04 13:41:16 +08:00
litianxiang
1decd8e258 参赛选手加入编号,增加导出功能 2026-02-04 13:41:09 +08:00
litianxiang
1286e84488 Merge remote-tracking branch 'origin/dev/3.1_release_merge' into dev-ltx 2026-02-04 13:21:19 +08:00
a252fdf7f9 BUGFIX: detail中的default模式报错 2026-02-03 16:56:15 +08:00
807d802178 TASK:motion 参数数据类型变更 2026-02-02 15:30:04 +08:00
53f1b548be CONFIG 2026-02-02 15:28:54 +08:00
45dd78032a BUGFIX: 1.token过期,重新登录无法解决 2.motion生成参数数据类型变更 2026-02-02 15:04:27 +08:00
c160da5132 BUGFIX: token过期,重新登录无法解决 2026-02-02 14:57:32 +08:00
b23faeeee2 Merge branch 'dev/dev_xp' into dev/3.1_release_merge 2026-02-02 13:32:55 +08:00
67789abca4 TASK:getAllPose id的数据类型改为整型 2026-02-02 13:32:27 +08:00
litianxiang
22880d128d Merge remote-tracking branch 'origin/dev/3.1_release_merge' into dev-ltx 2026-01-30 15:40:25 +08:00
6146112d04 TO PROD 2026-01-27 16:39:39 +08:00
litianxiang
3e334d7956 Merge remote-tracking branch 'origin/dev/3.1_release_merge' into dev-ltx 2026-01-26 11:16:46 +08:00
72 changed files with 5142 additions and 1773 deletions

View File

@@ -99,6 +99,8 @@ jobs:
volumes: volumes:
# 数据挂载 # 数据挂载
- ./log:/log - ./log:/log
- ./temp:/temp
- ./uploads:/temp/uploads
ports: ports:
- '10090:5567' - '10090:5567'
restart: always restart: always
@@ -133,8 +135,6 @@ jobs:
cd ${{ env.REMOTE_DEPLOY_PATH }} cd ${{ env.REMOTE_DEPLOY_PATH }}
echo "停止旧容器..." echo "停止旧容器..."
docker compose down || true docker compose down || true
echo "清理Docker资源..."
docker system prune -f
echo "构建镜像..." echo "构建镜像..."
docker compose build --no-cache docker compose build --no-cache
echo "启动服务..." echo "启动服务..."

View File

@@ -240,7 +240,7 @@
<dependency> <dependency>
<groupId>com.stripe</groupId> <groupId>com.stripe</groupId>
<artifactId>stripe-java</artifactId> <artifactId>stripe-java</artifactId>
<version>26.2.0</version> <version>32.0.0</version>
</dependency> </dependency>
<!-- aws s3 --> <!-- aws s3 -->
@@ -427,6 +427,11 @@
<artifactId>bcpkix-jdk18on</artifactId> <artifactId>bcpkix-jdk18on</artifactId>
<version>1.78.1</version> <version>1.78.1</version>
</dependency> </dependency>
<!-- AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -28,6 +28,11 @@ public class MQPublisher {
amqpTemplate.convertAndSend(rabbitMQProperties.getQueues().getSr(), mm); amqpTemplate.convertAndSend(rabbitMQProperties.getQueues().getSr(), mm);
} }
public void sendGenerateResultMessage(String mm) {
log.info("send generate result message: {}", mm);
amqpTemplate.convertAndSend(rabbitMQProperties.getQueues().getGenerateResult(), mm);
}
/** /**
* *
* @param mailParams 含有的字段 * @param mailParams 含有的字段

View File

@@ -0,0 +1,170 @@
package com.ai.da.common.aspect;
import com.ai.da.common.context.UserContext;
import com.ai.da.model.vo.AuthPrincipalVo;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap;
import java.util.Map;
/**
* Controller日志切面
* 记录所有Controller接口的请求参数和用户信息
*/
@Aspect
@Component
public class ControllerLoggingAspect {
private static final Logger logger = LoggerFactory.getLogger(ControllerLoggingAspect.class);
/**
* 定义切点所有Controller方法
*/
@Pointcut("execution(* com.ai.da.controller..*(..))")
public void controllerMethods() {
}
/**
* Controller方法执行前记录日志
*/
// @Before("controllerMethods()")
public void logControllerBefore(JoinPoint joinPoint) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
// 获取当前用户ID
Long userId = null;
AuthPrincipalVo authPrincipalVo = UserContext.getUserHolder();
if (authPrincipalVo != null) {
userId = authPrincipalVo.getId();
}
// 获取请求参数
Map<String, Object> params = getRequestParams(joinPoint, request);
logger.info("=== 请求开始 ===");
logger.info("用户ID: {}", userId);
logger.info("请求URL: {}", request.getRequestURL().toString());
logger.info("请求方法: {}", request.getMethod());
logger.info("请求IP: {}", getClientIpAddress(request));
logger.info("调用方法: {}.{}", joinPoint.getSignature().getDeclaringType().getSimpleName(), joinPoint.getSignature().getName());
logger.info("请求参数: {}", params);
}
}
/**
* 获取请求参数
*/
private Map<String, Object> getRequestParams(JoinPoint joinPoint, HttpServletRequest request) {
Map<String, Object> params = new HashMap<>();
// 1. 获取Query String参数
String queryString = request.getQueryString();
if (queryString != null && !queryString.isEmpty()) {
params.put("queryString", queryString);
}
// 2. 获取方法参数(包含 @PathVariable, @RequestParam, @RequestBody 等)
Object[] args = joinPoint.getArgs();
if (args != null && args.length > 0) {
Map<String, Object> methodParams = new HashMap<>();
for (int i = 0; i < args.length; i++) {
Object arg = args[i];
// 过滤掉不可序列化的参数
if (arg != null) {
if (isIgnorable(arg)) {
// 对于可忽略的类型,记录类型名
methodParams.put("arg" + i, "[" + arg.getClass().getSimpleName() + "]");
} else {
try {
methodParams.put("arg" + i, arg);
} catch (Exception e) {
methodParams.put("arg" + i, arg.toString());
}
}
}
}
if (!methodParams.isEmpty()) {
params.put("methodParams", methodParams);
}
}
return params;
}
/**
* 判断是否需要过滤的参数类型
*/
private boolean isIgnorable(Object obj) {
return obj instanceof HttpServletRequest
|| obj instanceof HttpServletResponse
|| obj instanceof MultipartFile
|| obj instanceof MultipartFile[];
}
/**
* Controller方法抛出异常时记录日志
*/
@AfterThrowing(pointcut = "controllerMethods()", throwing = "exception")
public void logControllerAfterThrowing(JoinPoint joinPoint, Throwable exception) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
Long userId = null;
AuthPrincipalVo authPrincipalVo = UserContext.getUserHolder();
if (authPrincipalVo != null) {
userId = authPrincipalVo.getId();
}
// 获取请求参数
Map<String, Object> params = new HashMap<>();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
params = getRequestParams(joinPoint, request);
}
logger.error("=== 请求异常 ===");
logger.error("用户ID: {}", userId);
logger.error("调用方法: {}.{}", joinPoint.getSignature().getDeclaringType().getSimpleName(), joinPoint.getSignature().getName());
logger.error("请求参数: {}", params);
logger.error("异常信息: ", exception);
logger.error("=== 异常结束 ===");
}
/**
* 获取客户端真实IP地址
*/
private String getClientIpAddress(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty() && !"unknown".equalsIgnoreCase(xForwardedFor)) {
return xForwardedFor.split(",")[0];
}
String xRealIp = request.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty() && !"unknown".equalsIgnoreCase(xRealIp)) {
return xRealIp;
}
String proxyClientIp = request.getHeader("Proxy-Client-IP");
if (proxyClientIp != null && !proxyClientIp.isEmpty() && !"unknown".equalsIgnoreCase(proxyClientIp)) {
return proxyClientIp;
}
String wlProxyClientIp = request.getHeader("WL-Proxy-Client-IP");
if (wlProxyClientIp != null && !wlProxyClientIp.isEmpty() && !"unknown".equalsIgnoreCase(wlProxyClientIp)) {
return wlProxyClientIp;
}
return request.getRemoteAddr();
}
}

View File

@@ -202,7 +202,7 @@ public class MyTaskScheduler {
} }
} }
// @Scheduled(cron = "0 0 9 * * ?") // @Scheduled(cron = "0 0 9 * * ?")
public void sendTrialOrderExcelToManagements() { public void sendTrialOrderExcelToManagements() {
// 获取前一天日期 // 获取前一天日期
LocalDate yesterday = LocalDate.now().minusDays(1); LocalDate yesterday = LocalDate.now().minusDays(1);

View File

@@ -13,8 +13,9 @@ public class CommonConstant {
public static final Integer MINIO_IMAGE_EXPIRE_TIME = 24 * 60; public static final Integer MINIO_IMAGE_EXPIRE_TIME = 24 * 60;
// 单位 秒 一天过期 in redis // 单位 秒 一天过期 in redis
public static final Long GENERATE_RESULT_EXPIRE_TIME = 24 * 60 * 60L; public static final Long GENERATE_RESULT_EXPIRE_TIME = 24 * 60 * 60L;
// 单位 秒 7天过期 // 单位 秒 7天过期 todo 测试状态下 3小时过期
public static final Long REDIS_SET_EXPIRE_TIME = 24 * 60 * 60 * 7L; // public static final Long REDIS_SET_EXPIRE_TIME = 24 * 60 * 60 * 7L;
public static final Long REDIS_SET_EXPIRE_TIME = 3 * 60 * 60L;
public static class Numbers{ public static class Numbers{
public static final Integer NUMBER_10 = 10; public static final Integer NUMBER_10 = 10;
@@ -23,6 +24,7 @@ public class CommonConstant {
} }
public static final String GENERATE_PATH = "/api/generate_image"; public static final String GENERATE_PATH = "/api/generate_image";
public static final String GENERATE_PATH_FLUX2_KLEIN = "/api/generate_image_flux2_klein";
public static final String GENERATE_SINGLE_LOGO = "/api/generate_single_logo"; public static final String GENERATE_SINGLE_LOGO = "/api/generate_single_logo";

View File

@@ -18,9 +18,9 @@ public class ModelConstants {
// 模型名称常量 // 模型名称常量
public static final String PRINTBOARD_ADVANCED_T2I = "qwen-image"; public static final String PRINTBOARD_ADVANCED_T2I = "qwen-image";
public static final String MOODBOARD_ADVANCED = "doubao-seedream-3-0-t2i-250415"; public static final String MOODBOARD_ADVANCED = "doubao-seedream-4-5-251128";
public static final String PRINTBOARD_HIGH_T2I = "doubao-seedream-3-0-t2i-250415"; public static final String PRINTBOARD_HIGH_T2I = "doubao-seedream-4-0-250828-high";
public static final String PRINTBOARD_HIGH_I2I = "doubao-seededit-3-0-i2i-250628"; public static final String PRINTBOARD_HIGH_I2I = "doubao-seedream-4-0-250828-fast";
public static final String PRINTBOARD_ADVANCED_I2I = "doubao-seedream-4-0-250828"; public static final String PRINTBOARD_ADVANCED_I2I = "doubao-seedream-4-0-250828";
public static final String IMAGEN_MODEL = "imagen-4.0-generate-001"; public static final String IMAGEN_MODEL = "imagen-4.0-generate-001";
public static final String NANO_BANANA = "gemini-2.5-flash-image"; public static final String NANO_BANANA = "gemini-2.5-flash-image";

View File

@@ -30,7 +30,7 @@ public enum CreditsEventsEnum {
INIT_QUARTERLY("init_quarterly", "12000"), INIT_QUARTERLY("init_quarterly", "12000"),
INIT_MONTHLY_EDU("init_monthly_edu", "3500"), INIT_MONTHLY_EDU("init_monthly_edu", "3500"),
INIT_TRIAL("init_trial", "100"), INIT_TRIAL("init_trial", "100"),
INIT_WEEKLY("init_weekly","6000"), INIT_DAILY("init_daily","100"),
RESET_YEAR_CREDITS("reset_year_credits","6000"), RESET_YEAR_CREDITS("reset_year_credits","6000"),
// SUPER_RESOLUTION("Super Resolution","30"), // SUPER_RESOLUTION("Super Resolution","30"),

View File

@@ -34,6 +34,11 @@ public enum OrderStatusEnum {
* 已退款 * 已退款
*/ */
REFUND_SUCCESS("已退款"), REFUND_SUCCESS("已退款"),
/**
* 已部分退款
*/
PARTIAL_REFUND_SUCCESS("已部分退款"),
/** /**
* 退款异常 * 退款异常
*/ */

View File

@@ -0,0 +1,19 @@
package com.ai.da.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum PaymentInfoType {
NEW("new"),
RENEWAL("renewal"),
CREDIT("credit"),
MANUAL("manual");
private final String type;
}

View File

@@ -3,6 +3,8 @@ package com.ai.da.common.enums;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import java.util.Arrays;
@Getter @Getter
@AllArgsConstructor @AllArgsConstructor
public enum ProductEnum { public enum ProductEnum {
@@ -23,11 +25,27 @@ public enum ProductEnum {
; ;
/** /**
* 类型 * 显示名称(用于与 orderInfo.title 匹配)
*/ */
private final String name; private final String name;
private final Long price; private final Long price;
private final Long credits; private final Long credits;
/**
* 根据显示名称获取枚举
*
* @param name 显示名称(与 orderInfo.title 匹配)
* @return 对应的枚举,未找到返回 null
*/
public static ProductEnum getByName(String name) {
if (name == null) {
return null;
}
return Arrays.stream(values())
.filter(pe -> pe.name.equals(name))
.findFirst()
.orElse(null);
}
} }

View File

@@ -61,9 +61,7 @@ public class AuthenticationFilter extends OncePerRequestFilter {
, "/api/account/schoolLogin", "/api/account/enterpriseLogin", "/api/account/organizationNameSearch", , "/api/account/schoolLogin", "/api/account/enterpriseLogin", "/api/account/organizationNameSearch",
"/api/llm/stream", "/api/llm/stream",
//GlobalAwardController //GlobalAwardController
"/api/global-award/uploads/pdf/init", "/api/global-award/uploads/pdf/chunk", "/api/global-award/uploads/pdf/complete", "/api/global-award/uploads/pdf/status", "/api/global-award"
"/api/global-award/uploads/video/init", "/api/global-award/uploads/video/chunk", "/api/global-award/uploads/video/complete", "/api/global-award/uploads/video/status",
"/api/global-award/contestants/save", "/api/global-award/contestants/by-email", "/api/global-award/checkEmail", "/api/global-award/checkCode"
); );
@Override @Override

View File

@@ -34,7 +34,7 @@ public class AccountTask {
accountService.refreshCreditsMonthly(); accountService.refreshCreditsMonthly();
} }
// @Scheduled(cron = "0 */5 * * * *") // Run every 5 minutes // @Scheduled(cron = "0 */5 * * * *") // Run every 5 minutes
public void getPaidUser() { public void getPaidUser() {
// 获取code-create 表中 指定日期之后 订单状态为wc-processing的订单 // 获取code-create 表中 指定日期之后 订单状态为wc-processing的订单
accountService.extendValidityForCC(); accountService.extendValidityForCC();

View File

@@ -45,7 +45,7 @@ public class PaymentTask {
@Resource @Resource
private PayPalCheckoutService payPalCheckoutService; private PayPalCheckoutService payPalCheckoutService;
// @Scheduled(cron = "0/30 * * * * ?") // @Scheduled(cron = "0/30 * * * * ?")
public void orderConfirmForPaypal() throws SerializeException { public void orderConfirmForPaypal() throws SerializeException {
// log.info("PayPal orderConfirm 被执行......"); // log.info("PayPal orderConfirm 被执行......");
@@ -97,7 +97,7 @@ public class PaymentTask {
// //
} }
@Scheduled(cron = "0 */5 * * * *") // Run every 5 minutes @Scheduled(cron = "0 */5 * * * *") // Run every 5 minutes
public void updateAffiliateInfoWithPayment(){ public void updateAffiliateInfoWithPayment(){
// log.info("佣金计算定时器"); // log.info("佣金计算定时器");
affiliateService.updateAffiliateInfoWithPayment(); affiliateService.updateAffiliateInfoWithPayment();
@@ -109,7 +109,7 @@ public class PaymentTask {
affiliateService.syncLinkViewCountToDB(); affiliateService.syncLinkViewCountToDB();
} }
// @Scheduled(cron = "0 0 8 28-31 * ?") // @Scheduled(cron = "0 0 8 28-31 * ?")
public void commissionSummaryReminder(){ public void commissionSummaryReminder(){
// 每个月末的最后一天的早上八点执行 // 每个月末的最后一天的早上八点执行
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();

View File

@@ -40,7 +40,7 @@ public class SubscriptionReminderTask {
REMINDER_DAYS_CONFIG.put("year", 14); REMINDER_DAYS_CONFIG.put("year", 14);
} }
// @Scheduled(cron = "0 0 9 * * ?") // @Scheduled(cron = "0 0 9 * * ?")
public void subscriptionReminder() { public void subscriptionReminder() {
// 获取所有需要通知的订阅 // 获取所有需要通知的订阅
List<SubscriptionInfo> subscriptionInfos = getDueSubscriptions(); List<SubscriptionInfo> subscriptionInfos = getDueSubscriptions();
@@ -97,7 +97,7 @@ public class SubscriptionReminderTask {
return subscriptionInfoMapper.selectList(qw); return subscriptionInfoMapper.selectList(qw);
} }
// @Scheduled(cron = "0 0 9 * * ?") // @Scheduled(cron = "0 0 9 * * ?")
public void trialReminder() { public void trialReminder() {
// 今天的 00:00:00 和 23:59:59 // 今天的 00:00:00 和 23:59:59
LocalDateTime startOfDay = LocalDateTime.now().toLocalDate().atStartOfDay(); LocalDateTime startOfDay = LocalDateTime.now().toLocalDate().atStartOfDay();

File diff suppressed because it is too large Load Diff

View File

@@ -17,17 +17,38 @@ import com.tencentcloudapi.ses.v20201002.models.SendEmailRequest;
import com.tencentcloudapi.ses.v20201002.models.SendEmailResponse; import com.tencentcloudapi.ses.v20201002.models.SendEmailResponse;
import com.tencentcloudapi.ses.v20201002.models.Template; import com.tencentcloudapi.ses.v20201002.models.Template;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List;
import java.util.Objects; import java.util.Objects;
/** /**
* 邮件发送类 * 邮件发送类
*/ */
@Slf4j @Slf4j
@Component
public class SendEmailUtil { public class SendEmailUtil {
@Value("${merchant.email:}")
private String merchantEmailInstance;
@Value("${developer.email: xupei@code-create.com.hk}")
private String developerEmailInstance;
private static String merchantEmail;
private static String developerEmail;
@PostConstruct
public void init() {
merchantEmail = merchantEmailInstance;
developerEmail = developerEmailInstance;
}
/** /**
* 秘钥id * 秘钥id
*/ */
@@ -765,9 +786,7 @@ public class SendEmailUtil {
public static boolean subscriptionEmailReminder(String type, SubscriptionEmailParamsDTO subscriptionEmailParamsDTO, String language, String receiverAddress) { public static boolean subscriptionEmailReminder(String type, SubscriptionEmailParamsDTO subscriptionEmailParamsDTO, String language, String receiverAddress) {
try { try {
String merchantEmail = "kimwong@code-create.com.hk"; String[] receiverEmail = buildMerchantReceiverEmail();
String developer = "xupei3360@163.com";
String[] receiverEmail = {/*merchantEmail,*/ developer};
Credential cred = new Credential(SECRET_ID, SECRET_KEy); Credential cred = new Credential(SECRET_ID, SECRET_KEy);
// 实例化一个http选项可选的没有特殊需求可以跳过 // 实例化一个http选项可选的没有特殊需求可以跳过
HttpProfile httpProfile = new HttpProfile(); HttpProfile httpProfile = new HttpProfile();
@@ -966,9 +985,7 @@ public class SendEmailUtil {
// 实例化一个请求对象,每个接口都会对应一个request对象 // 实例化一个请求对象,每个接口都会对应一个request对象
SendEmailRequest req = new SendEmailRequest(); SendEmailRequest req = new SendEmailRequest();
req.setFromEmailAddress(SEND_ADDRESS); req.setFromEmailAddress(SEND_ADDRESS);
String merchantEmail = "kimwong@code-create.com.hk"; req.setDestination(buildMerchantReceiverEmail());
String developerEmail = "xupei@code-create.com.hk";
req.setDestination(new String[]{/*merchantEmail,*/ developerEmail});
Template template = new Template(); Template template = new Template();
req.setSubject("New Credit Purchase Order"); req.setSubject("New Credit Purchase Order");
template.setTemplateID(CREDITS_PURCHASE_MERCHANT); template.setTemplateID(CREDITS_PURCHASE_MERCHANT);
@@ -1076,4 +1093,25 @@ public class SendEmailUtil {
} }
public static String[] buildMerchantReceiverEmail() {
List<String> emails = new ArrayList<>();
if (!StringUtils.isEmpty(merchantEmail)) {
for (String e : merchantEmail.split(",")) {
String trimmed = e.trim();
if (!trimmed.isEmpty()) {
emails.add(trimmed);
}
}
}
if (!StringUtils.isEmpty(developerEmail)) {
for (String e : developerEmail.split(",")) {
String trimmed = e.trim();
if (!trimmed.isEmpty()) {
emails.add(trimmed);
}
}
}
return emails.toArray(new String[0]);
}
} }

View File

@@ -4,6 +4,8 @@ import com.ai.da.common.response.Response;
import com.ai.da.model.dto.*; import com.ai.da.model.dto.*;
import com.ai.da.model.dto.ContestantDTO; import com.ai.da.model.dto.ContestantDTO;
import com.ai.da.model.vo.CheckOTPVO; import com.ai.da.model.vo.CheckOTPVO;
import com.ai.da.model.vo.ContestantCountVO;
import com.ai.da.model.vo.PageVisitCountVO;
import com.ai.da.service.GlobalAwardService; import com.ai.da.service.GlobalAwardService;
import com.ai.da.service.upload.UploadService; import com.ai.da.service.upload.UploadService;
import com.ai.da.service.upload.UploadTask; import com.ai.da.service.upload.UploadTask;
@@ -11,6 +13,7 @@ import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam; import io.swagger.annotations.ApiParam;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -163,6 +166,52 @@ public class GlobalAwardController {
return Response.success(globalAwardService.checkCode(email, code)); return Response.success(globalAwardService.checkCode(email, code));
} }
@GetMapping("/contestants/export")
@ApiOperation(value = "导出参赛者列表为Excel", notes = "导出所有参赛者信息为xlsx并触发下载")
public void exportContestants(HttpServletResponse response) throws Exception {
byte[] data = globalAwardService.exportContestants();
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=\"contestants.xlsx\"");
response.setContentLength(data.length);
response.getOutputStream().write(data);
response.getOutputStream().flush();
}
@PostMapping("/contestants/export/files")
@ApiOperation(value = "导出参赛者文件为ZIP", notes = "根据参赛者编号范围导出PDF、视频和信息文件为ZIP直接响应给浏览器")
public void exportContestantFiles(@ApiParam(value = "参赛者文件导出请求", required = true) @RequestBody ContestantExportRequest request, HttpServletResponse response) throws Exception {
byte[] zipData = globalAwardService.exportContestantFilesAsZip(request.getMinContestantNumber(), request.getMaxContestantNumber());
if (zipData.length == 0) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
response.getWriter().write("No contestants found in the specified range.");
return;
}
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment; filename=\"contestants.zip\"");
response.setContentLength(zipData.length);
response.getOutputStream().write(zipData);
response.getOutputStream().flush();
}
@GetMapping("/contestants/count")
@ApiOperation(value = "查询参赛者总数", notes = "查询数据库中参赛者的总数量和最大参赛者编号")
public Response<ContestantCountVO> getContestantCount() {
return Response.success(globalAwardService.getContestantCount());
}
@PostMapping("/page/visit")
@ApiOperation(value = "记录比赛页面访问量", notes = "记录比赛页面的访问量,包含两种统计方式:每次访问/刷新计一次以及5秒内刷新只计一次")
public Response<Void> recordPageVisit(@ApiParam(value = "会话ID用于5秒内去重判断", required = false) @RequestParam(value = "sessionId", required = false) String sessionId) {
globalAwardService.recordPageVisit(sessionId);
return Response.success();
}
@GetMapping("/page/visit/count")
@ApiOperation(value = "获取比赛页面访问量", notes = "获取比赛页面的两种访问量rawVisitCount每次访问/刷新计一次)和 uniqueVisitCount5秒内刷新只计一次")
public Response<PageVisitCountVO> getPageVisitCount() {
return Response.success(globalAwardService.getPageVisitCount());
}
} }

View File

@@ -1,5 +1,6 @@
package com.ai.da.controller; package com.ai.da.controller;
import com.ai.da.common.context.UserContext;
import com.ai.da.common.response.Response; import com.ai.da.common.response.Response;
import com.ai.da.common.utils.DateUtil; import com.ai.da.common.utils.DateUtil;
import com.ai.da.common.utils.RedisUtil; import com.ai.da.common.utils.RedisUtil;
@@ -10,6 +11,7 @@ import com.ai.da.model.dto.ProductPurchaseDTO;
import com.ai.da.model.dto.QueryCouponsPageDTO; import com.ai.da.model.dto.QueryCouponsPageDTO;
import com.ai.da.model.vo.CheckCouponsVO; import com.ai.da.model.vo.CheckCouponsVO;
import com.ai.da.service.StripeService; import com.ai.da.service.StripeService;
import com.ai.da.service.StripeSubscriptionService;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.paypal.http.HttpResponse; import com.paypal.http.HttpResponse;
import com.paypal.payments.Refund; import com.paypal.payments.Refund;
@@ -40,6 +42,8 @@ public class StripeController {
private StripeService stripeService; private StripeService stripeService;
@Resource @Resource
private RedisUtil redisUtil; private RedisUtil redisUtil;
@Resource
private StripeSubscriptionService stripeSubscriptionService;
@Operation(summary = "创建支付链接") @Operation(summary = "创建支付链接")
@PostMapping("/createOrder") @PostMapping("/createOrder")
@@ -53,30 +57,29 @@ public class StripeController {
@Operation(summary = "支付通知") @Operation(summary = "支付通知")
@PostMapping("/trade/notify") @PostMapping("/trade/notify")
public void callback(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { public void callback(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
try{ boolean result;
Boolean result = stripeService.notify(request); try {
if (result){ result = stripeService.notify(request);
response.setStatus(HttpServletResponse.SC_OK); } catch (Exception e) {
}else { log.error("Stripe Controller层异常捕捉, {}", e.getMessage(), e);
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}catch (Exception e){
log.error("Stripe Controller层异常捕捉, {}", e.getMessage());
e.printStackTrace();
String key_1 = RedisUtil.STRIPE_EXCEPTION_LOG + DateUtil.dateToStr(new Date(), DateUtil.YYYY_MM_DD_HH); String key_1 = RedisUtil.STRIPE_EXCEPTION_LOG + DateUtil.dateToStr(new Date(), DateUtil.YYYY_MM_DD_HH);
String key_2 = key_1 + ":" + DateUtil.dateToStr(new Date(), DateUtil.YYYY_MM_DD_hh_mm_ss); String key_2 = key_1 + ":" + DateUtil.dateToStr(new Date(), DateUtil.YYYY_MM_DD_HH_MM_SS);
String stackTrace = stripeService.getStackTrace(e, 10); String stackTrace = stripeService.getStackTrace(e, 10);
redisUtil.addToString(key_2, stackTrace); redisUtil.addToString(key_2, stackTrace);
Long size = redisUtil.getSize(key_1); Long size = redisUtil.getSize(key_1);
// 给我发送邮件 if ("1".equals(webhookReminderFlag) && size == 3) {
if (webhookReminderFlag.equals("1") && size == 3){
SendEmailUtil.commonExceptionReminder("Stripe Webhook 回调处理出现异常", new String[]{"xupei3360@163.com"}); SendEmailUtil.commonExceptionReminder("Stripe Webhook 回调处理出现异常", new String[]{"xupei3360@163.com"});
} }
result = false;
}
if (result) {
response.setStatus(HttpServletResponse.SC_OK);
} else {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
} }
} }
@Operation(summary = "申请退款") /* @Operation(summary = "申请退款")
@GetMapping("/trade/refund/{orderNo}/{reason}") @GetMapping("/trade/refund/{orderNo}/{reason}")
public Response<HttpResponse<Refund>> refund(@PathVariable String orderNo, @PathVariable String reason) throws IOException { public Response<HttpResponse<Refund>> refund(@PathVariable String orderNo, @PathVariable String reason) throws IOException {
String response = stripeService.refund(null,orderNo,reason); String response = stripeService.refund(null,orderNo,reason);
@@ -85,7 +88,7 @@ public class StripeController {
}else { }else {
return Response.fail("Request for refund failed."); return Response.fail("Request for refund failed.");
} }
} }*/
@Operation(summary = "获取订阅") @Operation(summary = "获取订阅")
@GetMapping("/getSubscription") @GetMapping("/getSubscription")
@@ -100,7 +103,8 @@ public class StripeController {
@Operation(summary = "取消订阅") @Operation(summary = "取消订阅")
@GetMapping("/cancelSubscription") @GetMapping("/cancelSubscription")
public Response<String> cancelSubscription(@RequestParam String subscriptionId, @RequestParam(required = false) String reason) { public Response<String> cancelSubscription(@RequestParam String subscriptionId, @RequestParam(required = false) String reason) {
stripeService.cancelSubscription(subscriptionId, reason); Long accountId = UserContext.getUserHolder().getId();
stripeSubscriptionService.cancelSubscription(subscriptionId, reason, accountId);
return Response.success("success"); return Response.success("success");
} }

View File

@@ -26,6 +26,9 @@ public class Contestant {
private String email; private String email;
@TableField("contestant_number")
private Integer contestantNumber;
@TableField("first_name") @TableField("first_name")
private String firstName; private String firstName;
@@ -56,6 +59,18 @@ public class Contestant {
@TableField("video_path") @TableField("video_path")
private String videoPath; private String videoPath;
@TableField("video_duration")
private Integer videoDuration;
@TableField("video_size")
private Long videoSize;
@TableField("pdf_size")
private Long pdfSize;
@TableField("portfolio_url")
private String portfolioUrl;
@TableField("created_at") @TableField("created_at")
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@@ -23,8 +23,6 @@ public class OrderInfo extends BaseEntity{
private String note; private String note;
private byte autoRenewal;
private String paymentType;//支付方式 private String paymentType;//支付方式
// 可用于标记用户订单是否首次订阅 // 可用于标记用户订单是否首次订阅

View File

@@ -49,6 +49,15 @@ public class ContestantDTO {
@ApiModelProperty(value = "视频文件路径", required = false, example = "contestants/user@example.com/2024/01/video_1234567890.mp4") @ApiModelProperty(value = "视频文件路径", required = false, example = "contestants/user@example.com/2024/01/video_1234567890.mp4")
private String videoPath; private String videoPath;
@ApiModelProperty(value = "视频时长(秒)", required = false, example = "120")
private Integer videoDuration;
@ApiModelProperty(value = "视频大小(字节)", required = false, example = "10485760")
private Long videoSize;
@ApiModelProperty(value = "PDF 文件大小(字节)", required = false, example = "524288")
private Long pdfSize;
// /** // /**
// * 是否确认覆盖已存在记录false 表示发现已有记录时仅返回 existingRecord不覆盖 // * 是否确认覆盖已存在记录false 表示发现已有记录时仅返回 existingRecord不覆盖
@@ -58,6 +67,8 @@ public class ContestantDTO {
@NotBlank @NotBlank
private String secureToken; private String secureToken;
} }

View File

@@ -0,0 +1,19 @@
package com.ai.da.model.dto;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* 参赛者文件导出请求DTO
*/
@Data
@ApiModel(value = "参赛者文件导出请求", description = "用于导出指定范围的参赛者文件")
public class ContestantExportRequest {
@ApiModelProperty(value = "最小参赛者编号", required = true, example = "10000")
private Integer minContestantNumber;
@ApiModelProperty(value = "最大参赛者编号", required = true, example = "10010")
private Integer maxContestantNumber;
}

View File

@@ -0,0 +1,55 @@
package com.ai.da.model.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* 图片处理请求体
*/
@Data
@Builder
public class ImageProcessRequest {
/**
* OSS桶名bucket_name
*/
private String bucket_name;
/**
* OSS对象名object_name
*/
private String object_name;
/**
* 输入图片路径列表input_image_paths
*/
private List<String> input_image_paths;
/**
* 图像宽度width
*/
private Integer width;
/**
* 图像高度height
*/
private Integer height;
/**
* 文本提示prompt
*/
private String prompt;
/**
* 推理步数steps
*/
private Integer steps;
/**
* 引导系数guidance
*/
private Double guidance;
}

View File

@@ -1,5 +1,6 @@
package com.ai.da.model.dto; package com.ai.da.model.dto;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -24,6 +25,7 @@ public class ProductPurchaseDTO {
@Schema(description = "EcoMonth || Month || Year") @Schema(description = "EcoMonth || Month || Year")
private String subscribeType; private String subscribeType;
@Hidden
@Schema(description = "是否自动续订 one_time || recurring") @Schema(description = "是否自动续订 one_time || recurring")
private Boolean autoRenewal; private Boolean autoRenewal;

View File

@@ -0,0 +1,17 @@
package com.ai.da.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ContestantCountVO {
private Long count;
private Integer maxContestantNumber;
}

View File

@@ -0,0 +1,23 @@
package com.ai.da.model.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PageVisitCountVO {
/**
* 每次访问或刷新都计一次(不去重)
*/
private Long rawVisitCount;
/**
* 5秒内刷新只算一次去重后的真实访客数
*/
private Long uniqueVisitCount;
}

View File

@@ -34,4 +34,8 @@ public class QueryUserConditionsVO extends PageQueryBaseVo {
private Integer systemUser; private Integer systemUser;
private Long subscriptionPlanId;
private Long organizationId;
} }

View File

@@ -2,8 +2,10 @@ package com.ai.da.python;
import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.exceptions.ExceptionUtil; import cn.hutool.core.exceptions.ExceptionUtil;
import com.ai.da.common.RabbitMQ.RabbitMQProperties;
import com.ai.da.common.config.FileProperties; import com.ai.da.common.config.FileProperties;
import com.ai.da.common.config.exception.BusinessException; import com.ai.da.common.config.exception.BusinessException;
import com.ai.da.common.constant.CommonConstant;
import com.ai.da.common.context.UserContext; import com.ai.da.common.context.UserContext;
import com.ai.da.common.enums.*; import com.ai.da.common.enums.*;
import com.ai.da.common.utils.*; import com.ai.da.common.utils.*;
@@ -20,6 +22,7 @@ import com.ai.da.model.vo.*;
import com.ai.da.python.vo.*; import com.ai.da.python.vo.*;
import com.ai.da.service.DesignHistoryService; import com.ai.da.service.DesignHistoryService;
import com.ai.da.service.PythonTAllInfoService; import com.ai.da.service.PythonTAllInfoService;
import com.ai.da.service.RabbitMQService;
import com.ai.da.service.SysFileService; import com.ai.da.service.SysFileService;
import com.alibaba.fastjson.*; import com.alibaba.fastjson.*;
import com.alibaba.fastjson.serializer.SerializerFeature; import com.alibaba.fastjson.serializer.SerializerFeature;
@@ -39,6 +42,7 @@ import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import java.io.File; import java.io.File;
import java.io.FileWriter; import java.io.FileWriter;
import java.io.IOException; import java.io.IOException;
@@ -68,6 +72,8 @@ public class PythonService {
private String accessPythonPort; private String accessPythonPort;
@Value("${minio.bucketName.gradient}") @Value("${minio.bucketName.gradient}")
private String gradientBucketName; private String gradientBucketName;
@Value("${minio.bucketName.users}")
private String userBucketName;
@Value("${access.python.generate_sr_port}") @Value("${access.python.generate_sr_port}")
private String srServicePort; private String srServicePort;
@@ -83,6 +89,12 @@ public class PythonService {
@Resource @Resource
private RedisUtil redisUtil; private RedisUtil redisUtil;
@Resource
private RabbitMQService rabbitMQService;
@Resource
private RabbitMQProperties rabbitMQProperties;
/** /**
* 生成打印的图片 二合一 (废弃于2024/01/02) * 生成打印的图片 二合一 (废弃于2024/01/02)
* *
@@ -279,7 +291,7 @@ public class PythonService {
if (isDuplicate) { if (isDuplicate) {
elementVO.setHasUseMd5List(beforeAssemblyHasUseMd5List); elementVO.setHasUseMd5List(beforeAssemblyHasUseMd5List);
i --; i--;
switch (designPrintPictureType) { switch (designPrintPictureType) {
case PIN: case PIN:
@@ -406,13 +418,14 @@ public class PythonService {
// if (CollectionUtil.isEmpty(pinData)) { // if (CollectionUtil.isEmpty(pinData)) {
// return 0; // return 0;
// } // }
//// long topNum = sketchBoardElements.stream()
//// .filter(skecth -> skecth.getHasPin() == 1 /// / long topNum = sketchBoardElements.stream()
//// && DesignPythonItem.OUTWEAR_DRESS_BLOUSE.contains(skecth.getLevel2Type())).count(); /// / .filter(skecth -> skecth.getHasPin() == 1
//// long bottomNum = sketchBoardElements.stream() /// / && DesignPythonItem.OUTWEAR_DRESS_BLOUSE.contains(skecth.getLevel2Type())).count();
//// .filter(skecth -> skecth.getHasPin() == 1 /// / long bottomNum = sketchBoardElements.stream()
//// && DesignPythonItem.SKIRT_TROUSERS.contains(skecth.getLevel2Type())).count(); /// / .filter(skecth -> skecth.getHasPin() == 1
//// int num = Arrays.asList(topNum, bottomNum).stream().max(Comparator.comparing(Long::valueOf)).get().intValue(); /// / && DesignPythonItem.SKIRT_TROUSERS.contains(skecth.getLevel2Type())).count();
/// / int num = Arrays.asList(topNum, bottomNum).stream().max(Comparator.comparing(Long::valueOf)).get().intValue();
// int num = pinData.size(); // int num = pinData.size();
// return Math.min(num, 8); // return Math.min(num, 8);
// } // }
@@ -560,12 +573,12 @@ public class PythonService {
return 0; return 0;
} else { } else {
long pinNum = printBoardElements.stream().filter(f -> f.getHasPin() == 1).count(); long pinNum = printBoardElements.stream().filter(f -> f.getHasPin() == 1).count();
if (designNum - pinNum < 0){ if (designNum - pinNum < 0) {
return RandomsUtil.randomSysFile(0L, (long) (pinNum/2 + 1)); return RandomsUtil.randomSysFile(0L, (long) (pinNum / 2 + 1));
} else if (designNum - pinNum < designNum/2) { } else if (designNum - pinNum < designNum / 2) {
return RandomsUtil.randomSysFile(0L, designNum - pinNum + 1); return RandomsUtil.randomSysFile(0L, designNum - pinNum + 1);
} else { } else {
return RandomsUtil.randomSysFile(0L, (long) (designNum/2 + 1)); return RandomsUtil.randomSysFile(0L, (long) (designNum / 2 + 1));
} }
} }
} }
@@ -680,8 +693,7 @@ public class PythonService {
// 其他所有情况,都回退到使用系统推荐 // 其他所有情况,都回退到使用系统推荐
String categoryParam = elementVO.getModelSex().toLowerCase() + "_" + styleCategory.toLowerCase(); String categoryParam = elementVO.getModelSex().toLowerCase() + "_" + styleCategory.toLowerCase();
List<String> recommentdUrlList = getSystemSketchByCategory(categoryParam, elementVO.getBrandId(), elementVO.getBrandScale(),elementVO.getStyle()); List<String> recommentdUrlList = getSystemSketchByCategory(categoryParam, elementVO.getBrandId(), elementVO.getBrandScale(), elementVO.getStyle());
if (CollectionUtils.isEmpty(recommentdUrlList)) { if (CollectionUtils.isEmpty(recommentdUrlList)) {
throw new BusinessException("failed.to.obtain.system.sketch.recommendation"); throw new BusinessException("failed.to.obtain.system.sketch.recommendation");
} }
@@ -1039,7 +1051,7 @@ public class PythonService {
if (!CollectionUtils.isEmpty(recommentdUrlList)) { if (!CollectionUtils.isEmpty(recommentdUrlList)) {
String recommendSystemSketch = recommentdUrlList.get(0); String recommendSystemSketch = recommentdUrlList.get(0);
return coverSystemSketchUrlToDesignPythonItem(recommendSystemSketch, category, validateElementVO); return coverSystemSketchUrlToDesignPythonItem(recommendSystemSketch, category, validateElementVO);
}else { } else {
throw new BusinessException("failed.to.obtain.system.sketch.recommendation"); throw new BusinessException("failed.to.obtain.system.sketch.recommendation");
} }
// JSONObject attributeRecognition = getAttributeRecognitionBySameCategory(element, validateElementVO.getModelSex()); // JSONObject attributeRecognition = getAttributeRecognitionBySameCategory(element, validateElementVO.getModelSex());
@@ -1062,7 +1074,7 @@ public class PythonService {
if (!CollectionUtils.isEmpty(recommentdUrlList)) { if (!CollectionUtils.isEmpty(recommentdUrlList)) {
String recommendSystemSketch = recommentdUrlList.get(0); String recommendSystemSketch = recommentdUrlList.get(0);
return coverSystemSketchUrlToDesignPythonItem(recommendSystemSketch, category, validateElementVO); return coverSystemSketchUrlToDesignPythonItem(recommendSystemSketch, category, validateElementVO);
}else { } else {
throw new BusinessException("failed.to.obtain.system.sketch.recommendation"); throw new BusinessException("failed.to.obtain.system.sketch.recommendation");
} }
@@ -1098,7 +1110,7 @@ public class PythonService {
if (!CollectionUtils.isEmpty(recommentdUrlList)) { if (!CollectionUtils.isEmpty(recommentdUrlList)) {
String recommendSystemSketch = recommentdUrlList.get(0); String recommendSystemSketch = recommentdUrlList.get(0);
return coverSystemSketchUrlToDesignPythonItem(recommendSystemSketch, category, validateElementVO); return coverSystemSketchUrlToDesignPythonItem(recommendSystemSketch, category, validateElementVO);
}else { } else {
throw new BusinessException("failed.to.obtain.system.sketch.recommendation"); throw new BusinessException("failed.to.obtain.system.sketch.recommendation");
} }
// JSONObject attributeRecognition = getAttributeRecognitionBySameCategory(element, validateElementVO.getModelSex()); // JSONObject attributeRecognition = getAttributeRecognitionBySameCategory(element, validateElementVO.getModelSex());
@@ -1122,7 +1134,7 @@ public class PythonService {
if (!CollectionUtils.isEmpty(recommentdUrlList)) { if (!CollectionUtils.isEmpty(recommentdUrlList)) {
String recommendSystemSketch = recommentdUrlList.get(0); String recommendSystemSketch = recommentdUrlList.get(0);
return coverSystemSketchUrlToDesignPythonItem(recommendSystemSketch, category, validateElementVO); return coverSystemSketchUrlToDesignPythonItem(recommendSystemSketch, category, validateElementVO);
}else { } else {
throw new BusinessException("failed.to.obtain.system.sketch.recommendation"); throw new BusinessException("failed.to.obtain.system.sketch.recommendation");
} }
// String tableName = getTableName(validateElementVO.getModelSex(), category); // String tableName = getTableName(validateElementVO.getModelSex(), category);
@@ -2190,7 +2202,7 @@ public class PythonService {
basic.setScale_earrings(0.16); basic.setScale_earrings(0.16);
if (Objects.nonNull(designLibraryModelPointVO)) { if (Objects.nonNull(designLibraryModelPointVO)) {
basic.setBody_point_test(getMap(designLibraryModelPointVO)); basic.setBody_point_test(getMap(designLibraryModelPointVO));
}else { } else {
basic.setBody_point_test(getMap(designLibraryModelPoint)); basic.setBody_point_test(getMap(designLibraryModelPoint));
} }
return basic; return basic;
@@ -2851,10 +2863,13 @@ public class PythonService {
gradientString = JSONObject.toJSONString(designSingleItem.getGradient()); gradientString = JSONObject.toJSONString(designSingleItem.getGradient());
} }
/*PrintToPython printToPython = resolveDesignSinglePrint(designSingleItem.getPrintObject().getPrints(), PrintToPython printToPython;
printToPython = resolveDesignSinglePrint(designSingleItem.getPrintObject().getPrints(),
designSingleItem.getPartialDesign().getPartialDesignMinioPath()); designSingleItem.getPartialDesign().getPartialDesignMinioPath());
resolveDesignElement(designSingleItem.getTrims(), printToPython); /*PrintToPython printToPython = resolveDesignSinglePrint(designSingleItem.getPrintObject().getPrints(),
log.info("组装参数【服装:{}的maskUrl: {}】",designSingleItem.getType(), designSingleItem.getMaskUrl());*/ designSingleItem.getPartialDesign().getPartialDesignMinioPath());*/
// resolveDesignElement(designSingleItem.getTrims(), printToPython);
log.info("组装参数【服装:{}的maskUrl: {}】", designSingleItem.getType(), designSingleItem.getMaskUrl());
String partialDesign = designSingleItem.getPartialDesign().getPartialDesignMinioPath(); String partialDesign = designSingleItem.getPartialDesign().getPartialDesignMinioPath();
String mergeImagePath = !StringUtil.isNullOrEmpty(partialDesign) String mergeImagePath = !StringUtil.isNullOrEmpty(partialDesign)
@@ -2863,7 +2878,7 @@ public class PythonService {
designSingleItem.getType(), designSingleItem.getType(),
designSingleItem.getPath(), designSingleItem.getPath(),
designSingleItem.getColor(), designSingleItem.getColor(),
null, printToPython,
// businessId designItemDetailId python端确认没有作用,但是数据库需要存,作用:未知) // businessId designItemDetailId python端确认没有作用,但是数据库需要存,作用:未知)
// designSingleItem.getId(), // designSingleItem.getId(),
businessId, businessId,
@@ -2881,7 +2896,7 @@ public class PythonService {
}); });
if (singleOverall.equals("overall")){ if (singleOverall.equals("overall")) {
String bodyPath; String bodyPath;
if (Objects.nonNull(designLibraryModelPoint)) { if (Objects.nonNull(designLibraryModelPoint)) {
bodyPath = designLibraryModelPoint.getTemplateUrl(); bodyPath = designLibraryModelPoint.getTemplateUrl();
@@ -2897,80 +2912,79 @@ public class PythonService {
private PrintToPython resolveDesignSinglePrint(List<DesignSinglePrint> printObject, String partialDesign) { private PrintToPython resolveDesignSinglePrint(List<DesignSinglePrint> printObject, String partialDesign) {
PrintToPython printToPython = new PrintToPython(); PrintToPython printToPython = new PrintToPython();
DesignPythonItemPrint printSingle = new DesignPythonItemPrint();
DesignPythonItemPrint printOverall = new DesignPythonItemPrint(); DesignPythonItemPrint printOverall = new DesignPythonItemPrint();
printToPython.setSingle(printSingle);
printToPython.setOverall(printOverall); printToPython.setOverall(printOverall);
printToPython.setPartial(StringUtil.isNullOrEmpty(partialDesign) ? null : partialDesign); printToPython.setPartial(StringUtil.isNullOrEmpty(partialDesign) ? null : partialDesign);
if (Objects.isNull(printObject) || printObject.isEmpty()){ if (Objects.isNull(printObject) || printObject.isEmpty()) {
return printToPython; return printToPython;
} }
// 没有印花时的参数设置 // 1. 先对 printObject 按 priority 排序(升序)
// if (printObject.getIfSingle().equals(Boolean.FALSE) && CollectionUtil.isEmpty(printObject.getPrints())) { List<DesignSinglePrint> sortedPrints = printObject.stream()
// return new DesignPythonItemPrint(new ArrayList<>(), false); .sorted(Comparator.comparingInt(DesignSinglePrint::getPriority))
// } .toList();
// DesignPythonItemPrint print = CopyUtil.copyObject(printObject, DesignPythonItemPrint.class);
int size = printObject.size(); // 2. 分别收集单印和非单印的数据
// 占位符填充数组 List<DesignSinglePrint> singlePrints = sortedPrints.stream()
List<List<Float>> locationS = new ArrayList<>(Collections.nCopies(size, null)); .filter(DesignSinglePrint::getIfSingle)
List<List<Float>> scaleS = new ArrayList<>(Collections.nCopies(size, null)); .toList();
List<Double> angleS = new ArrayList<>(Collections.nCopies(size, null));
ArrayList<String> pathsS = new ArrayList<>(Collections.nCopies(size, null));
List<List<Float>> locationO = new ArrayList<>(Collections.nCopies(size, null)); List<DesignSinglePrint> overallPrints = sortedPrints.stream()
List<List<Float>> scaleO = new ArrayList<>(Collections.nCopies(size, null)); .filter(p -> !p.getIfSingle())
List<Double> angleO = new ArrayList<>(Collections.nCopies(size, null)); .toList();
ArrayList<String> pathsO = new ArrayList<>(Collections.nCopies(size, null));
// 设置印花的位置、大小、旋转角度 // 3. 处理单印数据
// 优先级越大越靠近顶层在传输给python的数组中越靠前 if (!singlePrints.isEmpty()) {
// List<DesignSinglePrint> prints = printObject.getPrints(); List<List<Float>> locationS = new ArrayList<>();
printObject.forEach(p -> { List<List<Float>> scaleS = new ArrayList<>();
p.getLocation().set(0, p.getLocation().get(0)); List<Double> angleS = new ArrayList<>();
p.getLocation().set(1, p.getLocation().get(1)); List<String> pathsS = new ArrayList<>();
Integer priority = p.getPriority();
setUriToMinioPath(p); for (DesignSinglePrint p : singlePrints) {
// todo 下标越界问题 setUriToMinioPath(p);
if (p.getIfSingle()){ locationS.add(p.getLocation());
locationS.set(priority - 1, p.getLocation()); scaleS.add(p.getScale());
scaleS.set(priority - 1, p.getScale()); angleS.add(p.getAngle());
angleS.set( priority - 1, p.getAngle()); pathsS.add(p.getMinIOPath());
pathsS.set(priority - 1, p.getMinIOPath());
}else {
locationO.set(priority - 1, p.getLocation());
scaleO.set(priority - 1, p.getScale());
angleO.set(priority - 1, p.getAngle());
pathsO.set(priority - 1, p.getMinIOPath());
} }
// log.info("本次print打点locations###{}###fileVO{}", p.getLocation(), JSON.toJSONString(fileVO));
});
locationS.removeAll(Collections.singleton(null));
scaleS.removeAll(Collections.singleton(null));
angleS.removeAll(Collections.singleton(null));
pathsS.removeAll(Collections.singleton(null));
printSingle.setLocation(locationS);
printSingle.setPrint_scale_list(scaleS);
printSingle.setPrint_angle_list(angleS);
printSingle.setPrint_path_list(pathsS);
locationO.removeAll(Collections.singleton(null)); // 注意:如果 printOverall 中需要设置单印数据,请在这里添加相应的 setter
scaleO.removeAll(Collections.singleton(null)); // 根据您的原始代码,似乎只设置了 overall非单印的数据
angleO.removeAll(Collections.singleton(null)); // 如果需要设置单印,请取消下面的注释并添加对应的字段
pathsO.removeAll(Collections.singleton(null)); // printOverall.setSingleLocation(locationS);
printOverall.setLocation(locationO); // printOverall.setSingleScale(scaleS);
printOverall.setPrint_scale_list(scaleO); // printOverall.setSingleAngle(angleS);
printOverall.setPrint_angle_list(angleO); // printOverall.setSinglePath(pathsS);
printOverall.setPrint_path_list(pathsO); }
// 4. 处理非单印数据(整体印花)
if (!overallPrints.isEmpty()) {
List<List<Float>> locationO = new ArrayList<>();
List<List<Float>> scaleO = new ArrayList<>();
List<Double> angleO = new ArrayList<>();
List<String> pathsO = new ArrayList<>();
for (DesignSinglePrint p : overallPrints) {
setUriToMinioPath(p);
locationO.add(p.getLocation());
scaleO.add(p.getScale());
angleO.add(p.getAngle());
pathsO.add(p.getMinIOPath());
}
printOverall.setLocation(locationO);
printOverall.setPrint_scale_list(scaleO);
printOverall.setPrint_angle_list(angleO);
printOverall.setPrint_path_list(pathsO);
}
return printToPython; return printToPython;
} }
// 对印花类型为Generate的图片路径进行特殊处理 // 对印花类型为Generate的图片路径进行特殊处理
private void setUriToMinioPath(DesignSinglePrint print){ private void setUriToMinioPath(DesignSinglePrint print) {
if (!StringUtil.isNullOrEmpty(print.getDesignType()) && print.getDesignType().equals("Generate")){ if (!StringUtil.isNullOrEmpty(print.getDesignType()) && print.getDesignType().equals("Generate")) {
if (!StringUtil.isNullOrEmpty(print.getPath())){ if (!StringUtil.isNullOrEmpty(print.getPath())) {
try { try {
URI uri = new URI(print.getPath()); URI uri = new URI(print.getPath());
String path = uri.getPath(); // 获取路径部分: /aida-users/87/print/9ac32f65-6043-424d-a146-92c9c6d204ee-4-87.png String path = uri.getPath(); // 获取路径部分: /aida-users/87/print/9ac32f65-6043-424d-a146-92c9c6d204ee-4-87.png
@@ -3330,7 +3344,7 @@ public class PythonService {
throw new BusinessException("system error!"); throw new BusinessException("system error!");
} }
public Boolean generateSketchOrPrint(String params, String port, String servicePath) { public Boolean generateSketchOrPrint(String params, String port, String servicePath, String taskId) {
//限流校验 //限流校验
// AccessLimitUtils.validate("generateSketchOrPrint", 5); // AccessLimitUtils.validate("generateSketchOrPrint", 5);
OkHttpClient client = new OkHttpClient().newBuilder() OkHttpClient client = new OkHttpClient().newBuilder()
@@ -3392,12 +3406,36 @@ public class PythonService {
if (result && jsonObject.get("code").equals(200)) { if (result && jsonObject.get("code").equals(200)) {
log.info("Generate##responseObject###{}", jsonObject); log.info("Generate##responseObject###{}", jsonObject);
// return setGenerateImageList(jsonObject.getJSONObject("data")); // return setGenerateImageList(jsonObject.getJSONObject("data"));
if (servicePath == CommonConstant.GENERATE_PATH_FLUX2_KLEIN) {
//放入结果到mq
JSONObject data = jsonObject.getJSONObject("data");
String outputPath = data.getString("output_path");
Map<String, String> mqMessage = new HashMap<>();
mqMessage.put("tasks_id", taskId);
mqMessage.put("status", "SUCCESS");
mqMessage.put("message", "success");
mqMessage.put("image_url", outputPath);
mqMessage.put("category", "");
String mqMessageBody = JSON.toJSONString(mqMessage);
rabbitMQService.publishMessageToGenerateResult(mqMessageBody);
}
return Boolean.TRUE; return Boolean.TRUE;
} else { } else {
log.info("generateSketchOrPrintPrint失败###{}", jsonObject); log.info("generateSketchOrPrintPrint失败###{}", jsonObject);
log.info("Generate Exception! Code : " + jsonObject.get("code")); log.info("Generate Exception! Code : " + jsonObject.get("code"));
Map<String, String> mqMessage = new HashMap<>();
mqMessage.put("tasks_id", taskId);
mqMessage.put("status", "ERROR");
mqMessage.put("message", "");
mqMessage.put("image_url", "");
mqMessage.put("category", "");
String mqMessageBody = JSON.toJSONString(mqMessage);
rabbitMQService.publishMessageToGenerateResult(mqMessageBody);
return Boolean.FALSE; return Boolean.FALSE;
} }
} }
public Response sendPostToModel(String content, String portAndRoute, String functionName) { public Response sendPostToModel(String content, String portAndRoute, String functionName) {
@@ -3439,7 +3477,10 @@ public class PythonService {
return imageUrlList; return imageUrlList;
}*/ }*/
/** 废弃状态 */
/**
* 废弃状态
*/
public String composeLayers(List<OutfitDetailPythonItem> layersDetail) { public String composeLayers(List<OutfitDetailPythonItem> layersDetail) {
HashMap<String, List<OutfitDetailPythonItem>> layers = new HashMap<>(); HashMap<String, List<OutfitDetailPythonItem>> layers = new HashMap<>();
HashMap<String, HashMap<String, List<OutfitDetailPythonItem>>> content = new HashMap<>(); HashMap<String, HashMap<String, List<OutfitDetailPythonItem>>> content = new HashMap<>();
@@ -3750,7 +3791,7 @@ public class PythonService {
throw new BusinessException("relightImage.interface.exception"); throw new BusinessException("relightImage.interface.exception");
} }
public String imageToSketch(String imagePath, String bucket, String objectName, String styleCode, String styleImageUrl){ public String imageToSketch(String imagePath, String bucket, String objectName, String styleCode, String styleImageUrl) {
OkHttpClient client = new OkHttpClient().newBuilder() OkHttpClient client = new OkHttpClient().newBuilder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.pingInterval(5, TimeUnit.SECONDS)//websocket轮训间隔(单位:秒) .pingInterval(5, TimeUnit.SECONDS)//websocket轮训间隔(单位:秒)
@@ -3797,7 +3838,7 @@ public class PythonService {
String sketchResult = jsonObject.get("data").toString(); String sketchResult = jsonObject.get("data").toString();
log.info("ImageToSketch 结果 {}", sketchResult); log.info("ImageToSketch 结果 {}", sketchResult);
return sketchResult; return sketchResult;
}else { } else {
log.info("ImageToSketch 失败。 Response code {}", responseCode); log.info("ImageToSketch 失败。 Response code {}", responseCode);
throw new BusinessException("ImageToSketch 失败。 Response code " + responseCode); throw new BusinessException("ImageToSketch 失败。 Response code " + responseCode);
} }
@@ -3850,7 +3891,7 @@ public class PythonService {
throw new BusinessException("bright.interface.exception"); throw new BusinessException("bright.interface.exception");
} }
public JSONObject attributeRecognition(List<String> pictureUrls,List<String> ids, List<String> category) { public JSONObject attributeRecognition(List<String> pictureUrls, List<String> ids, List<String> category) {
//限流校验 //限流校验
AccessLimitUtils.validate("attributeRecognition", 20); AccessLimitUtils.validate("attributeRecognition", 20);
OkHttpClient client = new OkHttpClient().newBuilder() OkHttpClient client = new OkHttpClient().newBuilder()
@@ -3882,7 +3923,7 @@ public class PythonService {
} catch (IOException ioException) { } catch (IOException ioException) {
log.error("PythonService###attributeRecognition异常##{}", ExceptionUtil.getThrowableList(ioException)); log.error("PythonService###attributeRecognition异常##{}", ExceptionUtil.getThrowableList(ioException));
} }
log.info("识别python对应的属性标签值结果###{}",bodyStr.trim()); log.info("识别python对应的属性标签值结果###{}", bodyStr.trim());
//去除限流 //去除限流
AccessLimitUtils.validateOut("attributeRecognition"); AccessLimitUtils.validateOut("attributeRecognition");
if (Objects.isNull(response)) { if (Objects.isNull(response)) {
@@ -3966,7 +4007,7 @@ public class PythonService {
throw new BusinessException("design.interface.exception"); throw new BusinessException("design.interface.exception");
} }
public List<String> getSystemSketchByCategory(String category, Long brandId, Double brandScale,String style) { public List<String> getSystemSketchByCategory(String category, Long brandId, Double brandScale, String style) {
//******3.1.2版本临时使用java推荐方案去解决style未使用的问题********** //******3.1.2版本临时使用java推荐方案去解决style未使用的问题**********
// try { // try {
// //使用新库attribute_retrieval_style表命名修改为elementVO.getModelSex().toLowerCase() + "_" + styleCategory.toLowerCase()比如female_skirt,与传入的category保持一致 // //使用新库attribute_retrieval_style表命名修改为elementVO.getModelSex().toLowerCase() + "_" + styleCategory.toLowerCase()比如female_skirt,与传入的category保持一致
@@ -3990,6 +4031,14 @@ public class PythonService {
// throw new BusinessException("system.error"); // throw new BusinessException("system.error");
// } // }
//**********************end*********************************** //**********************end***********************************
//临时补丁
if (category != null && style != null) {
String categoryPrefix = category.split("_")[0];
if ("male".equals(categoryPrefix) &&
(style.equalsIgnoreCase("lolita") || style.equalsIgnoreCase("romantic"))) {
style = null;
}
}
AuthPrincipalVo userHolder = UserContext.getUserHolder(); AuthPrincipalVo userHolder = UserContext.getUserHolder();
OkHttpClient client = new OkHttpClient().newBuilder() OkHttpClient client = new OkHttpClient().newBuilder()
@@ -4111,6 +4160,7 @@ public class PythonService {
//生成失败 //生成失败
throw new BusinessException("segProduct.interface.exception"); throw new BusinessException("segProduct.interface.exception");
} }
/** /**
* 转发 seg_anything 请求到 python 服务 * 转发 seg_anything 请求到 python 服务
* 请求 body 由调用方组装(包含 user_id, image_path, type, points, labels, box 等) * 请求 body 由调用方组装(包含 user_id, image_path, type, points, labels, box 等)
@@ -4123,6 +4173,9 @@ public class PythonService {
.writeTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS)
.build(); .build();
MediaType mediaType = MediaType.parse("application/json"); MediaType mediaType = MediaType.parse("application/json");
content.put("bucket", userBucketName);
content.put("object_name", content.get("user_id") + "/" + "segment" + "/" + UUID.randomUUID() + ".png");
content.remove("user_id");
RequestBody body = RequestBody.create(mediaType, JSON.toJSONString(content)); RequestBody body = RequestBody.create(mediaType, JSON.toJSONString(content));
String url = accessPythonIp + ":" + accessPythonPort + "/api/seg_anything"; String url = accessPythonIp + ":" + accessPythonPort + "/api/seg_anything";
@@ -4153,18 +4206,18 @@ public class PythonService {
if (responseObject != null) { if (responseObject != null) {
JSONObject dataObj = responseObject.getJSONObject("data"); JSONObject dataObj = responseObject.getJSONObject("data");
if (dataObj != null) { if (dataObj != null) {
String output = dataObj.getString("output"); String output = dataObj.getString("output");
if (!StringUtil.isNullOrEmpty(output)) { if (!StringUtil.isNullOrEmpty(output)) {
try { try {
String presigned = minioUtil.getPreSignedUrl(output, 24 * 60); String presigned = minioUtil.getPreSignedUrl(output, 24 * 60);
return presigned; return presigned;
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to generate presigned url for {}: {}", output, e.getMessage()); log.error("Failed to generate presigned url for {}: {}", output, e.getMessage());
throw new BusinessException("segAnything.presign.failed"); throw new BusinessException("segAnything.presign.failed");
}
} }
} }
} }
}
} catch (Exception ex) { } catch (Exception ex) {
log.error("PythonService##segAnything post-process error###{}", ex.getMessage()); log.error("PythonService##segAnything post-process error###{}", ex.getMessage());
@@ -4174,7 +4227,7 @@ public class PythonService {
throw new BusinessException("segAnything.missing.output"); throw new BusinessException("segAnything.missing.output");
} }
throw new BusinessException("segAnything.interface.exception"); throw new BusinessException("segAnything.interface.exception");
} catch (IOException |JSONException e) { } catch (IOException | JSONException e) {
log.error("PythonService##segAnything异常###{}", e.getMessage()); log.error("PythonService##segAnything异常###{}", e.getMessage());
throw new BusinessException("segAnything.interface.exception"); throw new BusinessException("segAnything.interface.exception");
} }
@@ -4182,6 +4235,7 @@ public class PythonService {
log.error("PythonService##segAnything异常response###{}", response); log.error("PythonService##segAnything异常response###{}", response);
throw new BusinessException("segAnything.interface.exception"); throw new BusinessException("segAnything.interface.exception");
} }
public Boolean poseTransformation(JSONObject content, String apiUri) { public Boolean poseTransformation(JSONObject content, String apiUri) {
OkHttpClient client = new OkHttpClient().newBuilder() OkHttpClient client = new OkHttpClient().newBuilder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
@@ -4285,7 +4339,7 @@ public class PythonService {
String modifiedModel = jsonObject.get("data").toString(); String modifiedModel = jsonObject.get("data").toString();
log.info("modifyModelProportion 结果 {}", modifiedModel); log.info("modifyModelProportion 结果 {}", modifiedModel);
return modifiedModel; return modifiedModel;
}else { } else {
log.info("modifyModelProportion 失败。 Response code {}", responseCode); log.info("modifyModelProportion 失败。 Response code {}", responseCode);
throw new BusinessException("modifyModelProportion 失败。 Response code " + responseCode); throw new BusinessException("modifyModelProportion 失败。 Response code " + responseCode);
} }
@@ -4388,10 +4442,11 @@ public class PythonService {
log.info("imageSegmentation 结果 {}", jsonObject.get("data").toString()); log.info("imageSegmentation 结果 {}", jsonObject.get("data").toString());
List<ImageSegmentation.ImageDate> seg = JSONObject.parseObject( List<ImageSegmentation.ImageDate> seg = JSONObject.parseObject(
jsonObject.get("data").toString(), jsonObject.get("data").toString(),
new TypeReference<List<ImageSegmentation.ImageDate>>() {} new TypeReference<List<ImageSegmentation.ImageDate>>() {
}
); );
return seg; return seg;
}else { } else {
log.info("imageSegmentation 失败。 Response code {}", responseCode); log.info("imageSegmentation 失败。 Response code {}", responseCode);
throw new BusinessException("imageSegmentation 失败。 Response code " + responseCode); throw new BusinessException("imageSegmentation 失败。 Response code " + responseCode);
} }

View File

@@ -1,13 +1,13 @@
package com.ai.da.python.vo; package com.ai.da.python.vo;
import com.alibaba.fastjson.annotation.JSONField; import com.alibaba.fastjson.annotation.JSONField;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@Data @Data
public class DesignPythonItemPrint { public class DesignPythonItemPrint {
@@ -53,7 +53,7 @@ public class DesignPythonItemPrint {
if (ifDesign){ if (ifDesign){
this.print_path_list = print_path_list; this.print_path_list = print_path_list;
this.location = Collections.singletonList(Arrays.asList(0.0f, 0.0f)); this.location = Collections.singletonList(Arrays.asList(0.0f, 0.0f));
this.print_scale_list = Collections.singletonList(Arrays.asList(0.0f, 0.0f)); this.print_scale_list = Collections.singletonList(Arrays.asList(1.0f, 1.0f));
this.print_angle_list = Arrays.asList(0.0, 0.0); this.print_angle_list = Arrays.asList(0.0, 0.0);
} }

View File

@@ -2,6 +2,8 @@ package com.ai.da.service;
import com.ai.da.model.dto.ContestantDTO; import com.ai.da.model.dto.ContestantDTO;
import com.ai.da.model.vo.CheckOTPVO; import com.ai.da.model.vo.CheckOTPVO;
import com.ai.da.model.vo.ContestantCountVO;
import com.ai.da.model.vo.PageVisitCountVO;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.util.Map; import java.util.Map;
@@ -20,6 +22,47 @@ public interface GlobalAwardService {
CheckOTPVO checkCode(String email, String otp); CheckOTPVO checkCode(String email, String otp);
void checkSecurityToken(String email, String securityToken); void checkSecurityToken(String email, String securityToken);
/**
* 导出参赛者列表为 Excel二进制
* @return Excel 文件的字节数组
*/
byte[] exportContestants() throws Exception;
/**
* 将参赛者列表导出并保存到服务端本地目录(使用服务配置的 uploadDir/exports
*/
void saveContestantsToLocal() throws Exception;
/**
* 将参赛者文件打包为 ZIP 并返回字节数组(不落盘,直接响应给浏览器)
* @param minContestantNumber 最小参赛者编号
* @param maxContestantNumber 最大参赛者编号
* @return ZIP 文件的字节数组
*/
byte[] exportContestantFilesAsZip(Integer minContestantNumber, Integer maxContestantNumber) throws Exception;
/**
* 查询参赛者总数和最大参赛者编号
* @return 参赛者数量和最大参赛者编号
*/
ContestantCountVO getContestantCount();
/**
* 记录比赛页面的访问量
* <ul>
* <li>rawVisitCount: 每次访问或刷新都计一次(不去重)</li>
* <li>uniqueVisitCount: 5秒内刷新只算一次基于会话去重</li>
* </ul>
* @param sessionId 会话ID用于5秒去重判断
*/
void recordPageVisit(String sessionId);
/**
* 获取比赛页面的两种访问量
* @return 原始访问量和去重访问量
*/
PageVisitCountVO getPageVisitCount();
} }

View File

@@ -16,7 +16,7 @@ public interface OrderInfoService extends IService<OrderInfo> {
OrderInfo createOrderByProductId(Integer productId, String paymentType, HttpServletRequest request); OrderInfo createOrderByProductId(Integer productId, String paymentType, HttpServletRequest request);
OrderInfo createOrderByProductId(Integer amount, String paymentType, ProductEnum product, OrderInfo createOrderByProductId(Integer amount, String paymentType, ProductEnum product,
HttpServletRequest request, byte autoRenewal); HttpServletRequest request);
void saveCodeUrl(String orderNo, String codeUrl); void saveCodeUrl(String orderNo, String codeUrl);

View File

@@ -9,6 +9,8 @@ import com.baomidou.mybatisplus.extension.service.IService;
import com.paypal.orders.Order; import com.paypal.orders.Order;
import com.stripe.model.Charge; import com.stripe.model.Charge;
import com.stripe.model.Invoice; import com.stripe.model.Invoice;
import com.stripe.model.PaymentMethod;
import com.stripe.model.checkout.Session;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -23,9 +25,15 @@ public interface PaymentInfoService extends IService<PaymentInfo> {
void createPaymentInfoForAliPayHK(AlipayHKCallbackDTO alipayHKCallbackDTO, String type); void createPaymentInfoForAliPayHK(AlipayHKCallbackDTO alipayHKCallbackDTO, String type);
PaymentInfo createOrUpdatePaymentInfoForStripe(Invoice invoice); void createOrUpdatePaymentInfoForStripe(Session session);
PaymentInfo createOrUpdatePaymentInfoForStripe(Charge charge); Map<String, String> getPaymentMethodInfo(String sessionId, String subscriptionId);
PaymentMethod getPaymentMethodBySubscriptionId(String subscriptionId);
PaymentInfo createOrUpdatePaymentInfoForStripe(Invoice invoice, Map<String, String> paymentMethodInfo, List<Session.Discount> discounts);
// PaymentInfo createOrUpdatePaymentInfoForStripe(Charge charge);
List<PaymentInfo> getPaymentInfoByOrderNo(String orderId, String order); List<PaymentInfo> getPaymentInfoByOrderNo(String orderId, String order);
@@ -35,5 +43,9 @@ public interface PaymentInfoService extends IService<PaymentInfo> {
List<PaymentInfo> getPaymentInfoByPromCode(Long accountId, String promCode); List<PaymentInfo> getPaymentInfoByPromCode(Long accountId, String promCode);
PaymentInfo updatePaymentRefundStatus(Charge charge); // PaymentInfo updatePaymentRefundStatus(Charge charge);
void updatePaymentRefundStatusByChargeId(Charge charge, String status);
void updatePaymentRefundStatusByInvoiceId(String invoiceId, String status);
} }

View File

@@ -7,6 +7,8 @@ public interface RabbitMQService {
void publishMessageToGenerate(String message); void publishMessageToGenerate(String message);
void publishMessageToGenerateResult(String message);
void publishMessageToSR(String message); void publishMessageToSR(String message);
Integer getMessageCount(String queueUrl); Integer getMessageCount(String queueUrl);

View File

@@ -24,10 +24,18 @@ public interface RefundInfoService extends IService<RefundInfo> {
List<RefundInfo> getByChargeId(String chargeId); List<RefundInfo> getByChargeId(String chargeId);
RefundInfo getByRefundId(String refundId);
RefundInfo createRefundForStripe(Refund refund); RefundInfo createRefundForStripe(Refund refund);
RefundInfo updateRefundStatusForStripe(Refund refund); RefundInfo updateRefundStatusForStripe(Refund refund);
RefundInfo updateRefundForStripe(Charge charge); RefundInfo updateRefundForStripe(Charge charge);
RefundInfo handleRefundCreated(Refund refund);
RefundInfo handleRefundSucceeded(Refund refund);
RefundInfo handleRefundFailed(Refund refund);
} }

View File

@@ -21,42 +21,20 @@ public interface StripeService {
SubscriptionInfo getLatestSubscriptionInfoByAccountId(Long accountId); SubscriptionInfo getLatestSubscriptionInfoByAccountId(Long accountId);
String refund(String amount, String orderId, String reason);
void checkOrderStatus(String orderNo); void checkOrderStatus(String orderNo);
List<String> getSubscriptionIds(String name, String userEmail) throws StripeException; List<String> getSubscriptionIds(String name, String userEmail) throws StripeException;
Map<String, String> getPaymentMethodByInvoiceId(String invoiceId);
void cancelSubscription(String orderNo, String cancelReason);
void cancelSubscriptionTemp(String subscriptionId); void cancelSubscriptionTemp(String subscriptionId);
Map<String, String> getPaymentMethod(String paymentMethodId);
boolean sendEmail(String subscriptionId, String type, String orderNo); boolean sendEmail(String subscriptionId, String type, String orderNo);
String getLanguage(String language, String country, String type); String getLanguage(String language, String country, String type);
/*void updateSubscription(String subscriptionId);
void resume(String subscriptionId);*/
// void subscriptionReminder(); // void subscriptionReminder();
void checkSubscriptionExpiration();
String createSubscriptionTemp(String name, String email); String createSubscriptionTemp(String name, String email);
String changeCustomerPayment(String name, String email);
boolean sendRenewalFailEmail(String invoiceId, String subscriptionId, String orderNo);
List<Map<String,String>> getCustomerPaymentMethod(String name, String email);
String detachCustomerAllPaymentMethod(String name, String email);
// Map getIp(HttpServletRequest request); // Map getIp(HttpServletRequest request);
String getStackTrace(Exception e, int maxLines); String getStackTrace(Exception e, int maxLines);

View File

@@ -0,0 +1,59 @@
package com.ai.da.service;
import com.ai.da.mapper.primary.entity.OrderInfo;
import com.ai.da.mapper.primary.entity.SubscriptionInfo;
import com.stripe.model.Subscription;
/**
* Stripe 订阅服务接口
*
* 订阅事件处理已迁移至策略处理器:
* - customer.subscription.created -> CheckoutSessionCompletedHandler
* - customer.subscription.updated -> SubscriptionUpdatedHandler
* - customer.subscription.deleted -> SubscriptionDeletedHandler
* - customer.subscription.trial_will_end -> SubscriptionUpdatedHandler
*
* 本接口中保留需要被其他组件调用的辅助方法
*/
public interface StripeSubscriptionService {
/**
* 发送订阅相关邮件
* @param subscription Stripe Subscription object (may be null)
* @param type 邮件类型
* @param orderNo 订单号
* @param passedSubscriptionInfo 本地订阅记录 (用于避免事务未提交时重新查询,可为空)
*/
boolean sendSubscriptionEmail(Subscription subscription, String type, String orderNo,
com.ai.da.mapper.primary.entity.SubscriptionInfo passedSubscriptionInfo);
/**
* 发送首次订阅失败邮件
*/
void sendFailedNewOrderEmail(String orderNo);
/**
* 取消订阅
*/
void cancelSubscription(String subscriptionId, String cancelReason, Long accountId);
/**
* 发送续费失败邮件
*/
// void sendRenewalFailEmail(String invoiceId, String subscriptionId, String orderNo);
//
// /**
// * 获取用户最新的订阅信息
// */
// SubscriptionInfo getLatestSubscriptionInfoByAccountId(Long accountId);
//
// /**
// * 更新订阅取消原因
// */
// void updateCancelReason(String subscriptionId, String reason);
//
// /**
// * 创建或更新订阅信息
// */
// SubscriptionInfo createOrUpdateSubscriptionInfo(Subscription subscription);
}

View File

@@ -0,0 +1,13 @@
package com.ai.da.service;
import jakarta.servlet.http.HttpServletRequest;
public interface StripeWebhookService {
/**
* 处理 Stripe webhook 回调
* @param request HTTP 请求
* @return true=处理成功返回200false=处理失败返回500Stripe会重试
*/
Boolean notify(HttpServletRequest request);
}

View File

@@ -244,12 +244,13 @@ public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> impl
AccountLoginVO response = CopyUtil.copyObject(account, AccountLoginVO.class); AccountLoginVO response = CopyUtil.copyObject(account, AccountLoginVO.class);
response.setEmail(account.getUserEmail()); response.setEmail(account.getUserEmail());
String token = LocalCacheUtils.getTokenCache(String.valueOf(account.getId())); String token = LocalCacheUtils.getTokenCache(String.valueOf(account.getId()));
if (StringUtils.isNotBlank(token)) { /*if (StringUtils.isNotBlank(token)) {
//用户已登入 //用户已登入
response.setToken(token); response.setToken(token);
} else { } else {
response.setToken(createAccountToken(account)); response.setToken(createAccountToken(account));
} }*/
response.setToken(createAccountToken(account));
response.setUserId(account.getId()); response.setUserId(account.getId());
response.setSystemUser(account.getSystemUser()); response.setSystemUser(account.getSystemUser());
// 设置头像 // 设置头像
@@ -1645,6 +1646,7 @@ public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> impl
log.warn("当前用户 {} 在AiDA中没有账号", email); log.warn("当前用户 {} 在AiDA中没有账号", email);
throw new BusinessException("user.has.no.account", ResultEnum.PROMPT.getCode()); throw new BusinessException("user.has.no.account", ResultEnum.PROMPT.getCode());
} }
// 解决循环依赖问题
CreditsService creditsService = SpringUtils.getBean(CreditsService.class); CreditsService creditsService = SpringUtils.getBean(CreditsService.class);
// 2、先判断当前用户是否已经填写过问卷 // 2、先判断当前用户是否已经填写过问卷
CreditsDetail record = creditsService.getByAccountIdAndChangeEvent(account.getId(), "Fill out the questionnaire", "+100"); CreditsDetail record = creditsService.getByAccountIdAndChangeEvent(account.getId(), "Fill out the questionnaire", "+100");
@@ -3381,12 +3383,14 @@ public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> impl
Account account = accountMapper.selectById(accountId); Account account = accountMapper.selectById(accountId);
if (!Objects.isNull(account.getValidEndTime()) if (!Objects.isNull(account.getValidEndTime())
&& account.getValidEndTime().equals(currentPeriodEnd * 1000)) { && account.getValidEndTime().equals(currentPeriodEnd * 1000)) {
log.info("accountId:{}未更新账号有效期。current validEnd:{}, new validEnd:{}", accountId, account.getValidEndTime(), currentPeriodEnd);
return false; return false;
} else { } else {
account.setValidEndTime(currentPeriodEnd * 1000); account.setValidEndTime(currentPeriodEnd * 1000);
accountMapper.updateById(account); accountMapper.updateById(account);
log.info("accountId:{} 将账号有效期更新到 {}", accountId, currentPeriodEnd);
return true;
} }
return true;
} }
@Override @Override
@@ -3399,34 +3403,36 @@ public class AccountServiceImpl extends ServiceImpl<AccountMapper, Account> impl
if (description.equals(ProductEnum.DailySubscription.getName())) { if (description.equals(ProductEnum.DailySubscription.getName())) {
productCredits = ProductEnum.DailySubscription.getCredits(); productCredits = ProductEnum.DailySubscription.getCredits();
account.setSystemUser(3); account.setSystemUser(3);
account.setCredits(BigDecimal.valueOf(Long.parseLong(CreditsEventsEnum.INIT_WEEKLY.getValue())));
} else if (description.equals(ProductEnum.MonthlySubscription.getName())) { } else if (description.equals(ProductEnum.MonthlySubscription.getName())) {
productCredits = ProductEnum.MonthlySubscription.getCredits(); productCredits = ProductEnum.MonthlySubscription.getCredits();
account.setSystemUser(2); account.setSystemUser(2);
account.setCredits(BigDecimal.valueOf(Long.parseLong(CreditsEventsEnum.INIT_MONTHLY.getValue())));
} else if (description.equals(ProductEnum.Eco_MonthlySubscription.getName())) { } else if (description.equals(ProductEnum.Eco_MonthlySubscription.getName())) {
productCredits = ProductEnum.Eco_MonthlySubscription.getCredits(); productCredits = ProductEnum.Eco_MonthlySubscription.getCredits();
account.setSystemUser(2); account.setSystemUser(2);
account.setCredits(BigDecimal.valueOf(Long.parseLong(CreditsEventsEnum.INIT_MONTHLY_ECO.getValue())));
} else if (description.equals(ProductEnum.AnnualSubscription.getName())) { } else if (description.equals(ProductEnum.AnnualSubscription.getName())) {
productCredits = ProductEnum.AnnualSubscription.getCredits(); productCredits = ProductEnum.AnnualSubscription.getCredits();
account.setSystemUser(1); account.setSystemUser(1);
account.setCredits(BigDecimal.valueOf(Long.parseLong(CreditsEventsEnum.INIT_YEARLY.getValue())));
} else { } else {
log.error("未知订阅类型: {}", description); log.error("未知订阅类型: {}", description);
return; return;
} }
account.setCredits(BigDecimal.valueOf(productCredits));
accountMapper.updateById(account); accountMapper.updateById(account);
log.info("accountId:{},更新用户角色为{},总积分为{}", accountId, account.getSystemUser(), productCredits);
CreditsService creditsService = SpringUtils.getBean(CreditsService.class); CreditsService creditsService = SpringUtils.getBean(CreditsService.class);
// 先判断是否已添加添加积分变更记录 // 添加积分变更记录(订单续订时的积分变更也需要记录)
CreditsDetail creditsDetail = creditsService.queryDetailByTaskId(orderNo); creditsService.insertToCreditsDetail(accountId,
description + "--Stripe",
String.valueOf(productCredits),
"set", orderNo);
/*CreditsDetail creditsDetail = creditsService.queryDetailByTaskId(orderNo);
if (Objects.isNull(creditsDetail)) { if (Objects.isNull(creditsDetail)) {
creditsService.insertToCreditsDetail(accountId, creditsService.insertToCreditsDetail(accountId,
description + "--Stripe", description + "--Stripe",
String.valueOf(productCredits), String.valueOf(productCredits),
"positive", orderNo); "positive", orderNo);
} }*/
} else { } else {
log.error("orderNo: {} 无法找到对应的记录", orderNo); log.error("orderNo: {} 无法找到对应的记录", orderNo);
} }

View File

@@ -34,7 +34,6 @@ import jakarta.annotation.Resource;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.function.Function; import java.util.function.Function;
@@ -80,9 +79,7 @@ public class AffiliateServiceImpl extends ServiceImpl<AffiliateMapper, Affiliate
affiliate.setPromotionMethod(promotionMethod); affiliate.setPromotionMethod(promotionMethod);
baseMapper.insert(affiliate); baseMapper.insert(affiliate);
// 邮件通知审批者 // 邮件通知审批者
String merchantEmail = "kimwong@code-create.com.hk"; String[] receiverEmail = buildMerchantReceiverEmail();
String developer = "xupei3360@163.com";
String[] receiverEmail = {/*merchantEmail,*/ developer};
SendEmailUtil.affiliateEmailReminder(receiverEmail, new AffiliateEmailParamsDTO(userHolder.getUsername(), promotionMethod), "new"); SendEmailUtil.affiliateEmailReminder(receiverEmail, new AffiliateEmailParamsDTO(userHolder.getUsername(), promotionMethod), "new");
// emailService.affiliateEmailReminder(Arrays.asList(/*merchantEmail,*/ developer), new AffiliateEmailParamsDTO(userHolder.getUsername(), promotionMethod), "new"); // emailService.affiliateEmailReminder(Arrays.asList(/*merchantEmail,*/ developer), new AffiliateEmailParamsDTO(userHolder.getUsername(), promotionMethod), "new");
}else { }else {
@@ -440,9 +437,7 @@ public class AffiliateServiceImpl extends ServiceImpl<AffiliateMapper, Affiliate
affiliateEmailParamsDTO.setUnpaidEarnings(String.valueOf(unpaidCommission)); affiliateEmailParamsDTO.setUnpaidEarnings(String.valueOf(unpaidCommission));
affiliateEmailParamsDTO.setPaidEarnings(String.valueOf(paidCommission)); affiliateEmailParamsDTO.setPaidEarnings(String.valueOf(paidCommission));
String merchantEmail = "kimwong@code-create.com.hk"; String[] receiverEmail = buildMerchantReceiverEmail();
String developer = "xupei3360@163.com";
String[] receiverEmail = {/*merchantEmail,*/ developer};
// 邮件通知 // 邮件通知
SendEmailUtil.affiliateEmailReminder(receiverEmail, affiliateEmailParamsDTO, "summary"); SendEmailUtil.affiliateEmailReminder(receiverEmail, affiliateEmailParamsDTO, "summary");
// emailService.affiliateEmailReminder(Arrays.asList(/*merchantEmail,*/ developer), affiliateEmailParamsDTO, "summary"); // emailService.affiliateEmailReminder(Arrays.asList(/*merchantEmail,*/ developer), affiliateEmailParamsDTO, "summary");
@@ -607,4 +602,8 @@ public class AffiliateServiceImpl extends ServiceImpl<AffiliateMapper, Affiliate
coupon.setUnpaidCommission(unpaidCommission); coupon.setUnpaidCommission(unpaidCommission);
} }
private String[] buildMerchantReceiverEmail() {
return SendEmailUtil.buildMerchantReceiverEmail();
}
} }

View File

@@ -520,14 +520,16 @@ public class CollectionElementServiceImpl extends ServiceImpl<CollectionElementM
.filter(f -> f.getDesignType().equals(DesignTypeEnum.COLLECTION.getRealName())) .filter(f -> f.getDesignType().equals(DesignTypeEnum.COLLECTION.getRealName()))
.map(DesignCollectionPrintElementDTO::getId) .map(DesignCollectionPrintElementDTO::getId)
.collect(Collectors.toList()); .collect(Collectors.toList());
List<CollectionElement> printBoardElements = new ArrayList<>();
elementVO.setPrintBoardElements(printBoardElements);
if (!CollectionUtils.isEmpty(printBoardIds)) { if (!CollectionUtils.isEmpty(printBoardIds)) {
// 从数据库批量查询printBoard元素 // 从数据库批量查询printBoard元素
List<CollectionElement> printBoardElements = collectionElementMapper.selectBatchIds(printBoardIds); printBoardElements.addAll(collectionElementMapper.selectBatchIds(printBoardIds));
// 验证查询结果的完整性 // 验证查询结果的完整性
if (CollectionUtil.isEmpty(printBoardElements) || printBoardElements.size() != printBoardIds.size()) { if (CollectionUtil.isEmpty(printBoardElements) || printBoardElements.size() != printBoardIds.size()) {
throw new BusinessException("get.printBoards.data.is.mismatch"); throw new BusinessException("get.printBoards.data.is.mismatch");
} }
elementVO.setPrintBoardElements(printBoardElements); // elementVO.setPrintBoardElements(printBoardElements);
usedElementIds.addAll(printBoardIds); // 记录已使用的元素ID usedElementIds.addAll(printBoardIds); // 记录已使用的元素ID
} }
// 处理类型为LIBRARY的printBoard元素 // 处理类型为LIBRARY的printBoard元素
@@ -543,7 +545,8 @@ public class CollectionElementServiceImpl extends ServiceImpl<CollectionElementM
Map<Long, DesignCollectionPrintElementDTO> idToMap = designDTO.getPrintBoards() Map<Long, DesignCollectionPrintElementDTO> idToMap = designDTO.getPrintBoards()
.stream() .stream()
.collect(Collectors.toMap(DesignCollectionPrintElementDTO::getId, v -> v)); .collect(Collectors.toMap(DesignCollectionPrintElementDTO::getId, v -> v));
libraryCollectionElements.addAll(covertLibrarysToPrintCollections(librarys, idToMap)); printBoardElements.addAll(covertLibrarysToPrintCollections(librarys, idToMap));
// libraryCollectionElements.addAll(covertLibrarysToPrintCollections(librarys, idToMap));
} }
} }
@@ -559,7 +562,8 @@ public class CollectionElementServiceImpl extends ServiceImpl<CollectionElementM
Map<Long, DesignCollectionPrintElementDTO> idToMap = designDTO.getPrintBoards() Map<Long, DesignCollectionPrintElementDTO> idToMap = designDTO.getPrintBoards()
.stream() .stream()
.collect(Collectors.toMap(DesignCollectionPrintElementDTO::getId, v -> v)); .collect(Collectors.toMap(DesignCollectionPrintElementDTO::getId, v -> v));
generateCollectionElements.addAll(covertGeneratesToPrintCollections(generateDetailList, idToMap)); printBoardElements.addAll(covertGeneratesToPrintCollections(generateDetailList, idToMap));
// generateCollectionElements.addAll(covertGeneratesToPrintCollections(generateDetailList, idToMap));
} }
} }
} }

View File

@@ -787,6 +787,14 @@ public class ConvenientInquiryServiceImpl extends ServiceImpl<QuestionnaireMappe
queryWrapper.lt("create_date", queryUserConditionsVO.getEndTime()); queryWrapper.lt("create_date", queryUserConditionsVO.getEndTime());
} }
if (!Objects.isNull(queryUserConditionsVO.getSubscriptionPlanId())) {
queryWrapper.eq("subscription_plan_id", queryUserConditionsVO.getSubscriptionPlanId());
}
if (!Objects.isNull(queryUserConditionsVO.getOrganizationId())) {
queryWrapper.eq("organization_id", queryUserConditionsVO.getOrganizationId());
}
// 排序 // 排序
if (!StringUtils.isNullOrEmpty(queryUserConditionsVO.getOrder()) && !StringUtils.isNullOrEmpty(queryUserConditionsVO.getOrderBy())) { if (!StringUtils.isNullOrEmpty(queryUserConditionsVO.getOrder()) && !StringUtils.isNullOrEmpty(queryUserConditionsVO.getOrderBy())) {
String orderBy = "id"; String orderBy = "id";
@@ -870,19 +878,20 @@ public class ConvenientInquiryServiceImpl extends ServiceImpl<QuestionnaireMappe
if (!StringUtil.isNullOrEmpty(queryPaymentInfoDTO.getOrder()) && queryPaymentInfoDTO.getOrder().equals("ASC")) { if (!StringUtil.isNullOrEmpty(queryPaymentInfoDTO.getOrder()) && queryPaymentInfoDTO.getOrder().equals("ASC")) {
order = "ASC"; order = "ASC";
} }
String status = StringUtil.isNullOrEmpty(queryPaymentInfoDTO.getStatus()) ? "Success" : queryPaymentInfoDTO.getStatus();
List<PaymentInfoVO> paymentInfoVOS = paymentInfoMapper.queryPaymentInfo(queryPaymentInfoDTO.getPlatform(), queryPaymentInfoDTO.getPayerTotal(), List<PaymentInfoVO> paymentInfoVOS = paymentInfoMapper.queryPaymentInfo(queryPaymentInfoDTO.getPlatform(), queryPaymentInfoDTO.getPayerTotal(),
queryPaymentInfoDTO.getType(), queryPaymentInfoDTO.getStatus(), queryPaymentInfoDTO.getType(), status,
queryPaymentInfoDTO.getCountry(), queryPaymentInfoDTO.getCity(), queryPaymentInfoDTO.getCountry(), queryPaymentInfoDTO.getCity(),
queryPaymentInfoDTO.getStartTime(), queryPaymentInfoDTO.getEndTime(), queryPaymentInfoDTO.getStartTime(), queryPaymentInfoDTO.getEndTime(),
size, offset, order, queryPaymentInfoDTO.getPayer()); size, offset, order, queryPaymentInfoDTO.getPayer());
// 查询数据总量 // 查询数据总量
Long total = paymentInfoMapper.queryPaymentInfoCount(queryPaymentInfoDTO.getPlatform(), queryPaymentInfoDTO.getPayerTotal(), Long total = paymentInfoMapper.queryPaymentInfoCount(queryPaymentInfoDTO.getPlatform(), queryPaymentInfoDTO.getPayerTotal(),
queryPaymentInfoDTO.getType(), queryPaymentInfoDTO.getStatus(), queryPaymentInfoDTO.getType(), status,
queryPaymentInfoDTO.getCountry(), queryPaymentInfoDTO.getCity(), queryPaymentInfoDTO.getCountry(), queryPaymentInfoDTO.getCity(),
queryPaymentInfoDTO.getStartTime(), queryPaymentInfoDTO.getEndTime(), queryPaymentInfoDTO.getPayer()); queryPaymentInfoDTO.getStartTime(), queryPaymentInfoDTO.getEndTime(), queryPaymentInfoDTO.getPayer());
// 查询符合查询条件的总金额 // 查询符合查询条件的总金额
BigDecimal payerTotal = paymentInfoMapper.queryTotalPaymentAmount(queryPaymentInfoDTO.getPlatform(), queryPaymentInfoDTO.getPayerTotal(), BigDecimal payerTotal = paymentInfoMapper.queryTotalPaymentAmount(queryPaymentInfoDTO.getPlatform(), queryPaymentInfoDTO.getPayerTotal(),
queryPaymentInfoDTO.getType(), queryPaymentInfoDTO.getStatus(), queryPaymentInfoDTO.getType(), status,
queryPaymentInfoDTO.getCountry(), queryPaymentInfoDTO.getCity(), queryPaymentInfoDTO.getCountry(), queryPaymentInfoDTO.getCity(),
queryPaymentInfoDTO.getStartTime(), queryPaymentInfoDTO.getEndTime(), queryPaymentInfoDTO.getPayer()); queryPaymentInfoDTO.getStartTime(), queryPaymentInfoDTO.getEndTime(), queryPaymentInfoDTO.getPayer());
// 总页数 // 总页数

View File

@@ -83,7 +83,7 @@ public class CreditsServiceImpl extends ServiceImpl<CreditsDetailMapper, Credits
* *
* @param changeEvent 导致积分变更的事件 * @param changeEvent 导致积分变更的事件
* @param credits 变更的积分 * @param credits 变更的积分
* @param changeType 变更类型 positive->增 negative->减 * @param changeType 变更类型 positive->增 negative->减 set->重置
*/ */
@Override @Override
public void insertToCreditsDetail(Long accountId, String changeEvent, String credits, String changeType, String orderNo) { public void insertToCreditsDetail(Long accountId, String changeEvent, String credits, String changeType, String orderNo) {
@@ -94,9 +94,11 @@ public class CreditsServiceImpl extends ServiceImpl<CreditsDetailMapper, Credits
if ("positive".equals(changeType)) { if ("positive".equals(changeType)) {
// finalCredits = account.getCredits().add(new BigDecimal(credits)); // finalCredits = account.getCredits().add(new BigDecimal(credits));
changeCredits = "+" + credits; changeCredits = "+" + credits;
} else { } else if ("negative".equals(changeType)) {
// finalCredits = account.getCredits().subtract(new BigDecimal(credits)); // finalCredits = account.getCredits().subtract(new BigDecimal(credits));
changeCredits = "-" + credits; changeCredits = "-" + credits;
} else {
changeCredits = credits;
} }
creditsDetail.setAccountId(accountId); creditsDetail.setAccountId(accountId);
creditsDetail.setChangeEvent(changeEvent); creditsDetail.setChangeEvent(changeEvent);
@@ -107,6 +109,7 @@ public class CreditsServiceImpl extends ServiceImpl<CreditsDetailMapper, Credits
creditsDetail.setCreateTime(LocalDateTime.now()); creditsDetail.setCreateTime(LocalDateTime.now());
baseMapper.insert(creditsDetail); baseMapper.insert(creditsDetail);
log.info("creditsDetail inserted");
} }
@Override @Override

View File

@@ -497,6 +497,10 @@ public class DesignItemServiceImpl extends ServiceImpl<DesignItemMapper, DesignI
.collect(Collectors.toMap(DesignSingleItemDTO::getPriority, DesignSingleItemDTO::getOffset)); .collect(Collectors.toMap(DesignSingleItemDTO::getPriority, DesignSingleItemDTO::getOffset));
} }
// 创建 priority 到 DesignSingleItemDTO 的映射,用于获取 transpose 和 rotate
Map<Integer, DesignSingleItemDTO> priorityToItemDTOMap = designSingleItemDTOList.stream()
.collect(Collectors.toMap(DesignSingleItemDTO::getPriority, dto -> dto, (old, newVal) -> old));
List<TDesignPythonOutfitDetail> list = new ArrayList<>(); List<TDesignPythonOutfitDetail> list = new ArrayList<>();
for (int i = 0; i < layers.size(); i++) { for (int i = 0; i < layers.size(); i++) {
JSONObject jsonObject = layers.getJSONObject(i); JSONObject jsonObject = layers.getJSONObject(i);
@@ -525,8 +529,12 @@ public class DesignItemServiceImpl extends ServiceImpl<DesignItemMapper, DesignI
designPythonOutfitDetail.setOffset(String.valueOf(priorityOffset.get(Math.abs(priority)))); designPythonOutfitDetail.setOffset(String.valueOf(priorityOffset.get(Math.abs(priority))));
} }
designPythonOutfitDetail.setPriority(priority); designPythonOutfitDetail.setPriority(priority);
designPythonOutfitDetail.setTranspose(jsonObject.getString("transpose")); // 从前端传入的 DesignSingleItemDTO 中获取 transpose 和 rotate不再从 Python 返回的数据获取
designPythonOutfitDetail.setRotate(jsonObject.getDouble("rotate")); DesignSingleItemDTO itemDTO = priorityToItemDTOMap.get(Math.abs(priority));
if (itemDTO != null) {
designPythonOutfitDetail.setTranspose(itemDTO.getTranspose() != null ? Arrays.toString(itemDTO.getTranspose()) : null);
designPythonOutfitDetail.setRotate(itemDTO.getRotate());
}
list.add(designPythonOutfitDetail); list.add(designPythonOutfitDetail);
} }
@@ -1036,7 +1044,7 @@ public class DesignItemServiceImpl extends ServiceImpl<DesignItemMapper, DesignI
@Override @Override
public Map<String, List<String>> setPriorityAndUndividedLayer(JSONArray layers, DesignSingleIncludeLayersDTO designSingleIncludeLayersDTO) { public Map<String, List<String>> setPriorityAndUndividedLayer(JSONArray layers, DesignSingleIncludeLayersDTO designSingleIncludeLayersDTO) {
String designType = "default"; String designType = "default";
if (Objects.nonNull(designSingleIncludeLayersDTO)) { if (Objects.nonNull(designSingleIncludeLayersDTO)) {
designType = designSingleIncludeLayersDTO.getDesignType(); designType = designSingleIncludeLayersDTO.getDesignType();
} }

View File

@@ -756,7 +756,7 @@ public class DesignServiceImpl extends ServiceImpl<DesignMapper, Design> impleme
print.setPosition("[0.0,0.0]"); print.setPosition("[0.0,0.0]");
// print.setScale(1d); // print.setScale(1d);
// todo mark 将print默认scale置为0.3 // todo mark 将print默认scale置为0.3
print.setScale(Arrays.toString(new Float[]{0.3f, 0.3f})); print.setScale(Arrays.toString(new Float[]{1.0f, 1.0f}));
print.setAngle(0.0); print.setAngle(0.0);
print.setPriority(1); print.setPriority(1);
QueryWrapper<CollectionElement> getPrintboardLevel2TypeQw = new QueryWrapper<>(); QueryWrapper<CollectionElement> getPrintboardLevel2TypeQw = new QueryWrapper<>();
@@ -764,7 +764,20 @@ public class DesignServiceImpl extends ServiceImpl<DesignMapper, Design> impleme
getPrintboardLevel2TypeQw.lambda().orderByDesc(CollectionElement::getCreateDate); getPrintboardLevel2TypeQw.lambda().orderByDesc(CollectionElement::getCreateDate);
getPrintboardLevel2TypeQw.last("limit 1"); getPrintboardLevel2TypeQw.last("limit 1");
CollectionElement one = collectionElementService.getOne(getPrintboardLevel2TypeQw); CollectionElement one = collectionElementService.getOne(getPrintboardLevel2TypeQw);
print.setLevel2Type(one.getLevel2Type()); if (Objects.isNull(one)) {
QueryWrapper<Library> libraryQueryWrapper = new QueryWrapper<>();
libraryQueryWrapper.lambda().eq(Library::getUrl, print.getPath());
libraryQueryWrapper.lambda().orderByDesc(Library::getCreateDate);
getPrintboardLevel2TypeQw.last("limit 1");
Library library = libraryService.getOne(libraryQueryWrapper);
if (Objects.isNull(library)) {
print.setLevel2Type("Pattern");
} else {
print.setLevel2Type(library.getLevel2Type());
}
} else {
print.setLevel2Type(one.getLevel2Type());
}
print.setCreateDate(LocalDateTime.now()); print.setCreateDate(LocalDateTime.now());
designItemDetailPrintService.save(print); designItemDetailPrintService.save(print);
} }

View File

@@ -7,6 +7,7 @@ import com.ai.da.common.response.ResultEnum;
import com.ai.da.common.utils.DateUtil; import com.ai.da.common.utils.DateUtil;
import com.ai.da.common.utils.MailUtil; import com.ai.da.common.utils.MailUtil;
import com.ai.da.common.utils.RedisUtil; import com.ai.da.common.utils.RedisUtil;
import com.ai.da.common.utils.SendEmailUtil;
import com.ai.da.mapper.primary.AccountMapper; import com.ai.da.mapper.primary.AccountMapper;
import com.ai.da.mapper.primary.EmailLogMapper; import com.ai.da.mapper.primary.EmailLogMapper;
import com.ai.da.mapper.primary.EmailTemplateMapper; import com.ai.da.mapper.primary.EmailTemplateMapper;
@@ -585,9 +586,7 @@ public class EmailServiceImpl implements EmailService {
public boolean subscriptionEmailReminder(String type, SubscriptionEmailParamsDTO subscriptionEmailParamsDTO, String language, String receiverAddress) { public boolean subscriptionEmailReminder(String type, SubscriptionEmailParamsDTO subscriptionEmailParamsDTO, String language, String receiverAddress) {
try { try {
String merchantEmail = "kimwong@code-create.com.hk"; List<String> merchantReceiver = buildMerchantReceiverList();
String developer = "xupei3360@163.com";
List<String> merchantReceiver = Arrays.asList(/*merchantEmail,*/ developer);
String merchantSubject = null; String merchantSubject = null;
String merchantTemplate = null; String merchantTemplate = null;
@@ -723,15 +722,13 @@ public class EmailServiceImpl implements EmailService {
private final static String CREDITS_PURCHASE_MERCHANT = "133275_AiDA 积分购买通知-merchant.html"; private final static String CREDITS_PURCHASE_MERCHANT = "133275_AiDA 积分购买通知-merchant.html";
public void creditsPurchaseReminder(String username, String quantity, String amount) { public void creditsPurchaseReminder(String username, String quantity, String amount) {
String merchantEmail = "kimwong@code-create.com.hk";
String developerEmail = "xupei@code-create.com.hk";
JSONObject jsonObject = new JSONObject(); JSONObject jsonObject = new JSONObject();
// 设置试用订单相关数据 // 设置试用订单相关数据
jsonObject.put("username", username); jsonObject.put("username", username);
jsonObject.put("quantity", quantity); jsonObject.put("quantity", quantity);
jsonObject.put("totalFee", amount); jsonObject.put("totalFee", amount);
sendEmail(Arrays.asList(/*merchantEmail,*/ developerEmail), jsonObject, CREDITS_PURCHASE_MERCHANT, "New Credit Purchase Order", null, null); sendEmail(buildMerchantReceiverList(), jsonObject, CREDITS_PURCHASE_MERCHANT, "New Credit Purchase Order", null, null);
} }
private final static String COMMON_EXCEPTION_REMINDER = "135279_common-exception-reminder.html"; private final static String COMMON_EXCEPTION_REMINDER = "135279_common-exception-reminder.html";
@@ -742,6 +739,10 @@ public class EmailServiceImpl implements EmailService {
sendEmail(destination, param, COMMON_EXCEPTION_REMINDER, "AiDA发生异常请及时处理", null, null); sendEmail(destination, param, COMMON_EXCEPTION_REMINDER, "AiDA发生异常请及时处理", null, null);
} }
private List<String> buildMerchantReceiverList() {
return Arrays.asList(SendEmailUtil.buildMerchantReceiverEmail());
}
} }

View File

@@ -48,6 +48,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import okhttp3.*; import okhttp3.*;
import org.apache.commons.io.IOUtils; import org.apache.commons.io.IOUtils;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.apache.http.HttpHost; import org.apache.http.HttpHost;
import org.apache.http.client.config.RequestConfig; import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.CloseableHttpResponse;
@@ -59,10 +61,12 @@ import org.bytedeco.javacv.Java2DFrameConverter;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.*; import java.io.*;
@@ -194,10 +198,13 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
generate.setText(text); generate.setText(text);
Long elementId = generateThroughImageTextDTO.getCollectionElementId(); Long elementId = generateThroughImageTextDTO.getCollectionElementId();
// validateGeneraType(generate, text, elementId); // validateGeneraType(generate, text, elementId);
if (!StringUtil.isNullOrEmpty(text)) { if (!(generateThroughImageTextDTO.getLevel1Type().equals(MOOD_BOARD.getRealName())&&generateThroughImageTextDTO.getModelName().equals("high"))){
text = modifyPrompt(text, generate, generateThroughImageTextDTO.getLevel1Type(), generateThroughImageTextDTO.getAgeGroup()); if (!StringUtil.isNullOrEmpty(text)) {
text = modifyPrompt(text, generate, generateThroughImageTextDTO.getLevel1Type(), generateThroughImageTextDTO.getAgeGroup());
}
} }
// todo 这一步现在还是有必要的吗? // todo 这一步现在还是有必要的吗?
// 2.1 sketch或print在t_collection_element表/t_library表中的信息是否需要更新 如 level2Type // 2.1 sketch或print在t_collection_element表/t_library表中的信息是否需要更新 如 level2Type
CollectionElement collectionElement = collectionElementService.editLevel2Type(elementId, generateThroughImageTextDTO.getLevel2Type(), generateThroughImageTextDTO.getDesignType()); CollectionElement collectionElement = collectionElementService.editLevel2Type(elementId, generateThroughImageTextDTO.getLevel2Type(), generateThroughImageTextDTO.getDesignType());
@@ -218,6 +225,8 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
version = "fast"; version = "fast";
params.put("version", "fast"); params.put("version", "fast");
} }
// 4、将请求信息落库,将本次generate的请求信息添加到t_generate表中
saveGenerateImmediately(generate);
// 3.1 确定不同类型的印花分别调哪个接口 // 3.1 确定不同类型的印花分别调哪个接口
if (generateThroughImageTextDTO.getLevel1Type().equals(PRINT_BOARD.getRealName())) { if (generateThroughImageTextDTO.getLevel1Type().equals(PRINT_BOARD.getRealName())) {
switch (generateThroughImageTextDTO.getLevel2Type()) { switch (generateThroughImageTextDTO.getLevel2Type()) {
@@ -243,15 +252,28 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
jsonString = JSON.toJSONString(generateToPythonDTO, SerializerFeature.WriteMapNullValue); jsonString = JSON.toJSONString(generateToPythonDTO, SerializerFeature.WriteMapNullValue);
} }
} else { } else {
GenerateToPythonDTO generateToPythonDTO = new GenerateToPythonDTO(generateThroughImageTextDTO.getUniqueId(), text, Objects.isNull(collectionElement) ? "" : collectionElement.getUrl(), if (Objects.equals(version, "fast")) {
mode, category, generateThroughImageTextDTO.getGender(), version); GenerateToPythonDTO generateToPythonDTO = new GenerateToPythonDTO(generateThroughImageTextDTO.getUniqueId(), text, Objects.isNull(collectionElement) ? "" : collectionElement.getUrl(),
jsonString = JSON.toJSONString(generateToPythonDTO, SerializerFeature.WriteMapNullValue); mode, category, generateThroughImageTextDTO.getGender(), version);
jsonString = JSON.toJSONString(generateToPythonDTO, SerializerFeature.WriteMapNullValue);
} else {
path = CommonConstant.GENERATE_PATH_FLUX2_KLEIN;
// 构建object_name: {userId}/{category}/{uuid}.png
String objectName = generateThroughImageTextDTO.getUserId() + "/" + category + "/" + UUID.randomUUID() + ".png";
ImageProcessRequest imageProcessRequest = ImageProcessRequest.builder()
.object_name(objectName)
.bucket_name(userBucket)
.prompt(text).build();
jsonString = JSON.toJSONString(imageProcessRequest);
}
} }
Boolean requestResult = pythonService.generateSketchOrPrint(jsonString, port, path); Boolean requestResult = pythonService.generateSketchOrPrint(jsonString, port, path, generateThroughImageTextDTO.getUniqueId());
// 4、将请求信息落库,将本次generate的请求信息添加到t_generate表中
save(generate);
// 5、将本次请求存入redis // 5、将本次请求存入redis
String key = generateResultKey + ":" + generateThroughImageTextDTO.getUniqueId(); String key = generateResultKey + ":" + generateThroughImageTextDTO.getUniqueId();
@@ -266,6 +288,40 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
} }
public void saveGenerateImmediately(Generate generate) {
save(generate);
// 使用 TransactionSynchronizationManager 在事务真正提交后再设锁
// 否则 save() 完成后事务尚未 commitMQ 消费者立即读到 null
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
String lockKey = "generate:lock:" + generate.getUniqueId();
redisUtil.addToString(lockKey, "1", 60L);
log.debug("Save lock set after commit for uniqueId: {}", generate.getUniqueId());
}
});
}
private void waitForSaveLock(String uniqueId) {
String lockKey = "generate:lock:" + uniqueId;
int maxRetries = 30;
int retryIntervalMs = 200;
for (int i = 0; i < maxRetries; i++) {
if (Boolean.TRUE.equals(redisUtil.hasKey(lockKey))) {
log.debug("Save lock acquired for uniqueId: {} after {} retries", uniqueId, i);
return;
}
try {
Thread.sleep(retryIntervalMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("Interrupted while waiting for save lock: {}", uniqueId);
return;
}
}
log.warn("Save lock timeout for uniqueId: {}, proceeding anyway", uniqueId);
}
public GenerateModeEnum getMode(GenerateThroughImageTextDTO generateThroughImageTextDTO) { public GenerateModeEnum getMode(GenerateThroughImageTextDTO generateThroughImageTextDTO) {
if (!StringUtil.isNullOrEmpty(generateThroughImageTextDTO.getText())) { if (!StringUtil.isNullOrEmpty(generateThroughImageTextDTO.getText())) {
if (Objects.nonNull(generateThroughImageTextDTO.getCollectionElementId())) { if (Objects.nonNull(generateThroughImageTextDTO.getCollectionElementId())) {
@@ -284,11 +340,16 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void processGenerateResult(String taskId, String url, String category) { public void processGenerateResult(String taskId, String url, String category) {
log.info("============ProcessGenerateResult listening==========");
log.debug("taskId: " + taskId);
String status = null;
// 1、处理模型返回的数据 // 1、处理模型返回的数据
GenerateDetail generateDetail = new GenerateDetail(); GenerateDetail generateDetail = new GenerateDetail();
GenerateCollectionItemVO generateCollectionItemVO = new GenerateCollectionItemVO(); GenerateCollectionItemVO generateCollectionItemVO = new GenerateCollectionItemVO();
Generate generate; Generate generate;
try { try {
// 等待 HTTP 线程写入完成后再查库
waitForSaveLock(taskId);
generate = selectByUniqueId(taskId); generate = selectByUniqueId(taskId);
} catch (MybatisPlusException e) { } catch (MybatisPlusException e) {
log.error(e.getMessage()); log.error(e.getMessage());
@@ -311,14 +372,15 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
generateDetail.setUrl(url); generateDetail.setUrl(url);
generateDetail.setGenerateId(generate.getId()); generateDetail.setGenerateId(generate.getId());
generateDetail.setCreateDate(LocalDateTime.now()); generateDetail.setCreateDate(LocalDateTime.now());
generateDetail.setMd5(md5); generateDetail.setMd5("");
// 将相应的url保存到数据库 // 将相应的url保存到数据库
generateDetailMapper.insert(generateDetail); generateDetailMapper.insert(generateDetail);
log.debug("generateDetail: " + generateDetail.toString());
// String uuid = taskId.substring(0, taskId.substring(0, taskId.lastIndexOf("-")).lastIndexOf("-")); // String uuid = taskId.substring(0, taskId.substring(0, taskId.lastIndexOf("-")).lastIndexOf("-"));
String key = generateResultKey + ":" + taskId; String key = generateResultKey + ":" + taskId;
String imageName = url.substring(url.lastIndexOf("/") + 1); String imageName = url.substring(url.lastIndexOf("/") + 1);
String status = imageName.equals("white_image.jpg") ? "Invalid" : "Success"; status = imageName.equals("white_image.jpg") ? "Invalid" : "Success";
if (StringUtil.isNullOrEmpty(category)) { if (StringUtil.isNullOrEmpty(category)) {
Generate generateRecord = selectByUniqueId(taskId); Generate generateRecord = selectByUniqueId(taskId);
category = generateRecord.getLevel2Type(); category = generateRecord.getLevel2Type();
@@ -326,6 +388,8 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
GenerateResultVO generateResultVO = new GenerateResultVO(taskId, generateDetail.getId(), url, status, category); GenerateResultVO generateResultVO = new GenerateResultVO(taskId, generateDetail.getId(), url, status, category);
// 更新redis // 更新redis
redisUtil.addToString(key, new Gson().toJson(generateResultVO), CommonConstant.GENERATE_RESULT_EXPIRE_TIME); redisUtil.addToString(key, new Gson().toJson(generateResultVO), CommonConstant.GENERATE_RESULT_EXPIRE_TIME);
log.debug("generateResultVO: " + generateResultVO.toString());
// 执行积分扣除 // 执行积分扣除
// ** 注:如果生成的图片都是空白 则不扣积分 // ** 注:如果生成的图片都是空白 则不扣积分
@@ -785,8 +849,9 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
long requestEndTime = System.currentTimeMillis(); long requestEndTime = System.currentTimeMillis();
log.info("HTTP请求完成 - 响应状态: {}, 耗时: {}ms, taskId: {}", log.info("HTTP请求完成 - 响应状态: {}, 耗时: {}ms, taskId: {}",
response.code(), (requestEndTime - requestStartTime), taskId); response.code(), (requestEndTime - requestStartTime), taskId);
String result = response.body().string();
if (!response.isSuccessful()) { if (!response.isSuccessful()) {
log.warn("Google API响应失败状态码: {} for taskId: {}", response.code(), taskId); log.warn("Google API响应失败状态码: {} for taskId: {},结果:{}", response.code(), taskId, result);
if (attempt < maxRetries) { if (attempt < maxRetries) {
Thread.sleep(retryDelay * attempt); // 递增延迟 Thread.sleep(retryDelay * attempt); // 递增延迟
continue; continue;
@@ -795,7 +860,7 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
} }
} }
String result = response.body().string();
// log.info("Google 响应结果:{}", result); // log.info("Google 响应结果:{}", result);
com.alibaba.fastjson.JSONObject jsonResponse = JSON.parseObject(result); com.alibaba.fastjson.JSONObject jsonResponse = JSON.parseObject(result);
@@ -1065,6 +1130,12 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
String result = response.body().string(); String result = response.body().string();
if (response.code() != 200) {
log.error("Google API 请求失败 - taskId: {}, 尝试: {}, URL: {}, 状态码: {}, 响应结果: {}",
taskId, attempt, endpoint, response.code(), result);
throw new BusinessException("system.error");
}
// log.info("Google 响应结果:{}", result); // log.info("Google 响应结果:{}", result);
com.alibaba.fastjson.JSONObject jsonResponse = JSON.parseObject(result); com.alibaba.fastjson.JSONObject jsonResponse = JSON.parseObject(result);
@@ -1203,7 +1274,7 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
* @param modelName advanced high normal * @param modelName advanced high normal
*/ */
private HashMap<String, String> chooseModelAndPrompt(GenerateThroughImageTextDTO generateDTO, String modelName) { private HashMap<String, String> chooseModelAndPrompt(GenerateThroughImageTextDTO generateDTO, String modelName) {
if (StringUtil.isNullOrEmpty(modelName)){ if (StringUtil.isNullOrEmpty(modelName)) {
throw new BusinessException("system error"); throw new BusinessException("system error");
} }
HashMap<String, String> modelAndPromptMap = new HashMap<>(); HashMap<String, String> modelAndPromptMap = new HashMap<>();
@@ -1221,7 +1292,7 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
String style = generateDTO.getText().substring(0, firstCommaIndex).trim(); String style = generateDTO.getText().substring(0, firstCommaIndex).trim();
String prompt = generateDTO.getText().substring(firstCommaIndex + 1).trim(); String prompt = generateDTO.getText().substring(firstCommaIndex + 1).trim();
prompt = getPrintboardPrompt(style, prompt,modelName); prompt = getPrintboardPrompt(style, prompt, modelName, isUseImage);
modelAndPromptMap.put(ModelConstants.PROMPT, prompt); modelAndPromptMap.put(ModelConstants.PROMPT, prompt);
@@ -1260,8 +1331,31 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
modelAndPromptMap.put(ModelConstants.USE_MODEL, ModelConstants.LOCAL_MODEL); modelAndPromptMap.put(ModelConstants.USE_MODEL, ModelConstants.LOCAL_MODEL);
} }
} else if (ModelConstants.SKETCHBOARD.equals(generateDTO.getLevel1Type())) { } else if (ModelConstants.SKETCHBOARD.equals(generateDTO.getLevel1Type())) {
String prompt = generateDTO.getText() + "rules:front view sketch only,plain white background, single garment only, orthographic, centered on white background, borderless canvas, thin monochrome black line art.\n" + String style = "";
String userPrompt = "";
// 找到第一个逗号的位置
int firstCommaIndex = generateDTO.getText().indexOf(",");
if (firstCommaIndex != -1) {
// 截取第一个逗号前的内容作为style
style = generateDTO.getText().substring(0, firstCommaIndex).trim();
// 截取第一个逗号后的所有内容作为userPrompt去除首尾空格
userPrompt = generateDTO.getText().substring(firstCommaIndex + 1).trim();
if ("Lolita".equals(style)) {
style = "洛丽塔";
}
} else {
// 兼容无逗号的情况style为空全部内容作为userPrompt
userPrompt = generateDTO.getText().trim();
}
String prompt = userPrompt + "rules:front view sketch only,plain white background, single garment only, orthographic, centered on white background, borderless canvas, thin monochrome black line art.\n" +
" No clothes hanger, no fake clothes hanger, no human-related lines, no color fill, no words, no text, no black background, no boundary or frame."; " No clothes hanger, no fake clothes hanger, no human-related lines, no color fill, no words, no text, no black background, no boundary or frame.";
if (!style.trim().isEmpty() && !"all".equalsIgnoreCase(style)) {
prompt += ".sketch style:" + style.trim();
}
modelAndPromptMap.put(ModelConstants.PROMPT, prompt); modelAndPromptMap.put(ModelConstants.PROMPT, prompt);
if (isUseImage) { if (isUseImage) {
if (ModelConstants.ADVANCED.equals(modelName)) { if (ModelConstants.ADVANCED.equals(modelName)) {
@@ -1459,6 +1553,13 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
if (imagePath != null) { if (imagePath != null) {
requestBuilder.image(finalImagePath1); requestBuilder.image(finalImagePath1);
} }
if (useModel.equals(ModelConstants.PRINTBOARD_HIGH_I2I)|| useModel.equals(ModelConstants.PRINTBOARD_HIGH_T2I)) {
GenerateImagesRequest.OptimizePromptOptions optimizePromptOptions = new GenerateImagesRequest.OptimizePromptOptions();
optimizePromptOptions.setMode("fast");
requestBuilder.optimizePromptOptions(optimizePromptOptions);
//由于PRINTBOARD_HIGH_T2I,PRINTBOARD_HIGH_I2I与PRINTBOARD_ADVANCED_I2I使用模型一致为了区别积分扣除PRINTBOARD_HIGH_I2I加入了-fast或者-high但传入模型时需要去掉-fast或者-high用PRINTBOARD_ADVANCED_I2I的常量做替代
requestBuilder.model(ModelConstants.PRINTBOARD_ADVANCED_I2I);
}
// 保存生成记录到数据库 // 保存生成记录到数据库
Generate generate = new Generate( Generate generate = new Generate(
@@ -1570,7 +1671,7 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
} }
private String getPrintboardPrompt(String style, String userInput, String modelName) { private String getPrintboardPrompt(String style, String userInput, String modelName, boolean isUseImage) {
String systemPrompt = null; String systemPrompt = null;
String prompt; String prompt;
@@ -1596,12 +1697,16 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
"Flat textile pattern printed directly on fabric surface, no three-dimensional objects, no items placed on cloth. \n" + "Flat textile pattern printed directly on fabric surface, no three-dimensional objects, no items placed on cloth. \n" +
"Real style: fabric print, realistic woven/printed pattern, detailed surface pattern only"; "Real style: fabric print, realistic woven/printed pattern, detailed surface pattern only";
} }
}else { } else {
throw new BusinessException("style error:"+ style); throw new BusinessException("style error:" + style);
} }
if (userInput == null || userInput.trim().isEmpty()) { if (userInput == null || userInput.trim().isEmpty()) {
throw new BusinessException("prompt null"); if (isUseImage) {
prompt = "Theme: Image content" + "\nRequirement: " + systemPrompt;
} else {
throw new BusinessException("prompt null");
}
} else { } else {
prompt = "Theme: " + userInput.trim() + "\nRequirement: " + systemPrompt; prompt = "Theme: " + userInput.trim() + "\nRequirement: " + systemPrompt;
} }
@@ -2001,7 +2106,9 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
public Generate selectByUniqueId(String uniqueId) { public Generate selectByUniqueId(String uniqueId) {
QueryWrapper<Generate> qw = new QueryWrapper<>(); QueryWrapper<Generate> qw = new QueryWrapper<>();
qw.eq("unique_id", uniqueId); qw.eq("unique_id", uniqueId);
log.debug("selectByUniqueId: " + uniqueId);
Generate one = getOne(qw);
log.debug("Generate: " + one);
return getOne(qw); return getOne(qw);
} }
@@ -3827,11 +3934,48 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
} }
public byte[] downloadVideoOrImage(String url) { public byte[] downloadVideoOrImage(String url) {
try (CloseableHttpClient client = HttpClients.createDefault(); int maxRetries = 3;
InputStream in = client.execute(new HttpGet(url)).getEntity().getContent()) { int retryDelayMs = 1000;
return IOUtils.toByteArray(in); IOException lastException = null;
} catch (IOException e) {
throw new RuntimeException(e); for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
return downloadWithTimeout(url, 30000, 60000);
} catch (IOException e) {
lastException = e;
log.warn("下载失败 (尝试 {}/{}): {}", attempt, maxRetries, e.getMessage());
if (attempt < maxRetries) {
try {
Thread.sleep((long) retryDelayMs * attempt); // 递增延迟
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
}
throw new RuntimeException("下载失败,已重试 " + maxRetries + "", lastException);
}
private byte[] downloadWithTimeout(String url, int connectTimeout, int socketTimeout) throws IOException {
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(connectTimeout)
.setSocketTimeout(socketTimeout)
.setConnectionRequestTimeout(connectTimeout)
.build();
try (CloseableHttpClient client = HttpClients.custom()
.setDefaultRequestConfig(requestConfig)
.build();
CloseableHttpResponse response = client.execute(new HttpGet(url))) {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode != 200) {
throw new IOException("HTTP Error: " + statusCode);
}
return IOUtils.toByteArray(response.getEntity().getContent());
} }
} }
@@ -4172,11 +4316,11 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
// 处理不同状态 // 处理不同状态
switch (statusEnum) { switch (statusEnum) {
case TASK_NOT_FOUND: case TASK_NOT_FOUND:
// 审核没过 // 审核没过
case REQUEST_MODERATED: case REQUEST_MODERATED:
// 审核没过 // 审核没过
case CONTENT_MODERATED: case CONTENT_MODERATED:
// 出错 // 出错
case ERROR: case ERROR:
return "Fail"; return "Fail";
case PENDING_F: case PENDING_F:
@@ -4295,7 +4439,7 @@ public class GenerateServiceImpl extends ServiceImpl<GenerateMapper, Generate> i
MotionModeEnum motionModeEnum = MotionModeEnum.of(poseTransformDTO.getMode()); MotionModeEnum motionModeEnum = MotionModeEnum.of(poseTransformDTO.getMode());
switch (motionModeEnum) { switch (motionModeEnum) {
case POSE_TO_VIDEO: case POSE_TO_VIDEO:
params.put("pose_id", poseTransformDTO.getPoseId()); params.put("pose_id", poseTransformDTO.getPoseId().toString());
params.put("image_url", poseTransformDTO.getProductImage()); params.put("image_url", poseTransformDTO.getProductImage());
break; break;
case PROMPT_TO_VIDEO: case PROMPT_TO_VIDEO:

View File

@@ -12,6 +12,8 @@ import com.ai.da.mapper.primary.entity.Notification;
import com.ai.da.model.dto.ContestantDTO; import com.ai.da.model.dto.ContestantDTO;
import com.ai.da.model.dto.PublishSysNotificationDTO; import com.ai.da.model.dto.PublishSysNotificationDTO;
import com.ai.da.model.vo.CheckOTPVO; import com.ai.da.model.vo.CheckOTPVO;
import com.ai.da.model.vo.ContestantCountVO;
import com.ai.da.model.vo.PageVisitCountVO;
import com.ai.da.service.GlobalAwardService; import com.ai.da.service.GlobalAwardService;
import com.ai.da.service.MessageCenterService; import com.ai.da.service.MessageCenterService;
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSON;
@@ -22,13 +24,26 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.zip.ZipEntry;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
@Service @Service
@Slf4j @Slf4j
@@ -46,7 +61,7 @@ public class GlobalAwardServiceImpl implements GlobalAwardService {
private final RedisUtil redisUtil; private final RedisUtil redisUtil;
@Value("${file.upload.dir:uploads}") @Value("${file.upload.temp.dir}")
private String uploadDir; private String uploadDir;
private static final DateTimeFormatter YYYY_MM_DD = DateTimeFormatter.ofPattern("yyyy/MM"); private static final DateTimeFormatter YYYY_MM_DD = DateTimeFormatter.ofPattern("yyyy/MM");
@@ -128,6 +143,7 @@ public class GlobalAwardServiceImpl implements GlobalAwardService {
} }
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> saveContestant(ContestantDTO request) { public Map<String, Object> saveContestant(ContestantDTO request) {
Map<String,Object> resp = new HashMap<>(); Map<String,Object> resp = new HashMap<>();
if (request.getEmail() == null) { if (request.getEmail() == null) {
@@ -142,26 +158,60 @@ public class GlobalAwardServiceImpl implements GlobalAwardService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (existing == null) { if (existing == null) {
Contestant toInsert = Contestant.builder() // 通过行锁 + 重试机制保证 contestant_number 在并发下自增分配
.email(request.getEmail()) final int maxAttempts = 5;
.firstName(request.getFirstName()) for (int attempt = 1; attempt <= maxAttempts; attempt++) {
.lastName(request.getLastName()) try {
.gender(request.getGender()) // 获取当前最大 contestant_number 并加行锁LIMIT 1 FOR UPDATE
.occupation(request.getOccupation()) QueryWrapper<Contestant> qMax = new QueryWrapper<>();
.age(request.getAge()) qMax.isNotNull("contestant_number");
.countryRegionCity(request.getCountryRegionCity()) qMax.orderByDesc("contestant_number");
.phoneNumber(request.getPhoneNumber()) qMax.last("LIMIT 1 FOR UPDATE");
.designTitle(request.getDesignTitle()) Contestant last = contestantMapper.selectOne(qMax);
.designDescription(request.getDesignDescription()) Integer nextNumber = (last == null || last.getContestantNumber() == null) ? 10000 : last.getContestantNumber() + 1;
.pdfPath(request.getPdfPath())
.videoPath(request.getVideoPath()) Contestant toInsert = Contestant.builder()
.createdAt(now) .email(request.getEmail())
.updatedAt(now) .firstName(request.getFirstName())
.build(); .lastName(request.getLastName())
contestantMapper.insert(toInsert); .gender(request.getGender())
resp.put("success", true); .occupation(request.getOccupation())
sendSiteMsg(toInsert.getId(), toInsert.getEmail()); .age(request.getAge())
return resp; .countryRegionCity(request.getCountryRegionCity())
.phoneNumber(request.getPhoneNumber())
.designTitle(request.getDesignTitle())
.designDescription(request.getDesignDescription())
.pdfPath(request.getPdfPath())
.videoPath(request.getVideoPath())
.videoDuration(request.getVideoDuration())
.videoSize(request.getVideoSize())
.pdfSize(request.getPdfSize())
.contestantNumber(nextNumber)
.portfolioUrl(request.getPortfolioUrl())
.createdAt(now)
.updatedAt(now)
.build();
contestantMapper.insert(toInsert);
resp.put("success", true);
sendSiteMsg(toInsert.getId(), toInsert.getEmail());
return resp;
} catch (Exception e) {
log.warn("Attempt {} to assign contestant_number failed", attempt, e);
String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase();
if ((msg.contains("duplicate") || msg.contains("uniq_contestant_number") || msg.contains("contestant_number")) && attempt < maxAttempts) {
try {
Thread.sleep(100L);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
continue;
}
throw e;
}
}
throw new BusinessException("Failed to assign contestant number after retries.");
} else { } else {
// update existing contestant // update existing contestant
existing.setFirstName(request.getFirstName()); existing.setFirstName(request.getFirstName());
@@ -175,6 +225,10 @@ public class GlobalAwardServiceImpl implements GlobalAwardService {
existing.setDesignDescription(request.getDesignDescription()); existing.setDesignDescription(request.getDesignDescription());
existing.setPdfPath(request.getPdfPath()); existing.setPdfPath(request.getPdfPath());
existing.setVideoPath(request.getVideoPath()); existing.setVideoPath(request.getVideoPath());
existing.setVideoDuration(request.getVideoDuration());
existing.setVideoSize(request.getVideoSize());
existing.setPdfSize(request.getPdfSize());
existing.setPortfolioUrl(request.getPortfolioUrl());
existing.setUpdatedAt(now); existing.setUpdatedAt(now);
contestantMapper.updateById(existing); contestantMapper.updateById(existing);
resp.put("success", true); resp.put("success", true);
@@ -182,6 +236,132 @@ public class GlobalAwardServiceImpl implements GlobalAwardService {
} }
} }
@Override
public byte[] exportContestants() throws Exception {
List<Contestant> list = contestantMapper.selectList(new QueryWrapper<>());
try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("contestants");
int rowIdx = 0;
Row header = sheet.createRow(rowIdx++);
String[] headers = new String[] {
"contestantNumber", "email", "firstName", "lastName", "gender", "occupation",
"age", "countryRegionCity", "phoneNumber", "designTitle", "designDescription",
"pdfPath", "videoPath", "videoDuration", "videoSizeMB", "pdfSizeMB", "portfolioUrl", "createdAt", "updatedAt"
};
for (int i = 0; i < headers.length; i++) {
Cell c = header.createCell(i);
c.setCellValue(headers[i]);
}
for (Contestant cst : list) {
Row r = sheet.createRow(rowIdx++);
int ci = 0;
r.createCell(ci++).setCellValue(cst.getContestantNumber() == null ? "" : cst.getContestantNumber().toString());
r.createCell(ci++).setCellValue(cst.getEmail() == null ? "" : cst.getEmail());
r.createCell(ci++).setCellValue(cst.getFirstName() == null ? "" : cst.getFirstName());
r.createCell(ci++).setCellValue(cst.getLastName() == null ? "" : cst.getLastName());
r.createCell(ci++).setCellValue(cst.getGender() == null ? "" : cst.getGender());
r.createCell(ci++).setCellValue(cst.getOccupation() == null ? "" : cst.getOccupation());
r.createCell(ci++).setCellValue(cst.getAge() == null ? "" : cst.getAge().toString());
r.createCell(ci++).setCellValue(cst.getCountryRegionCity() == null ? "" : cst.getCountryRegionCity());
r.createCell(ci++).setCellValue(cst.getPhoneNumber() == null ? "" : cst.getPhoneNumber());
r.createCell(ci++).setCellValue(cst.getDesignTitle() == null ? "" : cst.getDesignTitle());
r.createCell(ci++).setCellValue(cst.getDesignDescription() == null ? "" : cst.getDesignDescription());
r.createCell(ci++).setCellValue(cst.getPdfPath() == null ? "" : cst.getPdfPath());
r.createCell(ci++).setCellValue(cst.getVideoPath() == null ? "" : cst.getVideoPath());
r.createCell(ci++).setCellValue(cst.getVideoDuration() == null ? "" : cst.getVideoDuration().toString());
if (cst.getVideoSize() == null) {
r.createCell(ci++).setCellValue("");
} else {
double vMb = cst.getVideoSize() / 1024.0 / 1024.0;
r.createCell(ci++).setCellValue(String.format("%.2f", vMb));
}
if (cst.getPdfSize() == null) {
r.createCell(ci++).setCellValue("");
} else {
double pMb = cst.getPdfSize() / 1024.0 / 1024.0;
r.createCell(ci++).setCellValue(String.format("%.2f", pMb));
}
r.createCell(ci++).setCellValue(cst.getPortfolioUrl() == null ? "" : cst.getPortfolioUrl());
r.createCell(ci++).setCellValue(cst.getCreatedAt() == null ? "" : cst.getCreatedAt().toString());
r.createCell(ci++).setCellValue(cst.getUpdatedAt() == null ? "" : cst.getUpdatedAt().toString());
}
workbook.write(out);
out.flush();
return out.toByteArray();
} catch (IOException e) {
log.error("export contestants failed", e);
throw e;
}
}
@Override
public void saveContestantsToLocal() throws Exception {
List<Contestant> list = contestantMapper.selectList(new QueryWrapper<>());
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
String ts = LocalDateTime.now().format(fmt);
Path exportDir = Paths.get(uploadDir == null ? "uploads" : uploadDir, "exports");
Files.createDirectories(exportDir);
Path outPath = exportDir.resolve("contestants_" + ts + ".xlsx");
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(outPath.toFile())) {
Sheet sheet = workbook.createSheet("contestants");
int rowIdx = 0;
Row header = sheet.createRow(rowIdx++);
String[] headers = new String[] {
"contestantNumber", "email", "firstName", "lastName", "gender", "occupation",
"age", "countryRegionCity", "phoneNumber", "designTitle", "designDescription",
"pdfPath", "videoPath", "videoDuration", "videoSizeMB", "pdfSizeMB", "portfolioUrl", "createdAt", "updatedAt"
};
for (int i = 0; i < headers.length; i++) {
Cell c = header.createCell(i);
c.setCellValue(headers[i]);
}
for (Contestant cst : list) {
Row r = sheet.createRow(rowIdx++);
int ci = 0;
r.createCell(ci++).setCellValue(cst.getContestantNumber() == null ? "" : cst.getContestantNumber().toString());
r.createCell(ci++).setCellValue(cst.getEmail() == null ? "" : cst.getEmail());
r.createCell(ci++).setCellValue(cst.getFirstName() == null ? "" : cst.getFirstName());
r.createCell(ci++).setCellValue(cst.getLastName() == null ? "" : cst.getLastName());
r.createCell(ci++).setCellValue(cst.getGender() == null ? "" : cst.getGender());
r.createCell(ci++).setCellValue(cst.getOccupation() == null ? "" : cst.getOccupation());
r.createCell(ci++).setCellValue(cst.getAge() == null ? "" : cst.getAge().toString());
r.createCell(ci++).setCellValue(cst.getCountryRegionCity() == null ? "" : cst.getCountryRegionCity());
r.createCell(ci++).setCellValue(cst.getPhoneNumber() == null ? "" : cst.getPhoneNumber());
r.createCell(ci++).setCellValue(cst.getDesignTitle() == null ? "" : cst.getDesignTitle());
r.createCell(ci++).setCellValue(cst.getDesignDescription() == null ? "" : cst.getDesignDescription());
r.createCell(ci++).setCellValue(cst.getPdfPath() == null ? "" : cst.getPdfPath());
r.createCell(ci++).setCellValue(cst.getVideoPath() == null ? "" : cst.getVideoPath());
r.createCell(ci++).setCellValue(cst.getVideoDuration() == null ? "" : cst.getVideoDuration().toString());
if (cst.getVideoSize() == null) {
r.createCell(ci++).setCellValue("");
} else {
double vMb = cst.getVideoSize() / 1024.0 / 1024.0;
r.createCell(ci++).setCellValue(String.format("%.2f", vMb));
}
if (cst.getPdfSize() == null) {
r.createCell(ci++).setCellValue("");
} else {
double pMb = cst.getPdfSize() / 1024.0 / 1024.0;
r.createCell(ci++).setCellValue(String.format("%.2f", pMb));
}
r.createCell(ci++).setCellValue(cst.getPortfolioUrl() == null ? "" : cst.getPortfolioUrl());
r.createCell(ci++).setCellValue(cst.getCreatedAt() == null ? "" : cst.getCreatedAt().toString());
r.createCell(ci++).setCellValue(cst.getUpdatedAt() == null ? "" : cst.getUpdatedAt().toString());
}
workbook.write(fos);
fos.flush();
log.info("Exported contestants to local file: {}", outPath.toString());
} catch (IOException e) {
log.error("save contestants to local failed", e);
throw e;
}
}
@Override @Override
public ContestantDTO getContestantByID(String id) { public ContestantDTO getContestantByID(String id) {
if (id == null) { if (id == null) {
@@ -204,6 +384,10 @@ public class GlobalAwardServiceImpl implements GlobalAwardService {
dto.setDesignDescription(existing.getDesignDescription()); dto.setDesignDescription(existing.getDesignDescription());
dto.setPdfPath(existing.getPdfPath()); dto.setPdfPath(existing.getPdfPath());
dto.setVideoPath(existing.getVideoPath()); dto.setVideoPath(existing.getVideoPath());
dto.setVideoDuration(existing.getVideoDuration());
dto.setPdfSize(existing.getPdfSize());
dto.setVideoSize(existing.getVideoSize());
dto.setPortfolioUrl(existing.getPortfolioUrl());
return dto; return dto;
} }
@@ -295,6 +479,178 @@ public class GlobalAwardServiceImpl implements GlobalAwardService {
// 这里推送消息是在接受到视频生成结束后发生的所以UserContext中没有用户信息 // 这里推送消息是在接受到视频生成结束后发生的所以UserContext中没有用户信息
messageCenterService.pushMessage("system", userId); messageCenterService.pushMessage("system", userId);
} }
@Override
public byte[] exportContestantFilesAsZip(Integer minContestantNumber, Integer maxContestantNumber) throws Exception {
if (minContestantNumber == null || maxContestantNumber == null) {
throw new BusinessException("minContestantNumber and maxContestantNumber are required.");
}
if (minContestantNumber > maxContestantNumber) {
throw new BusinessException("minContestantNumber cannot be greater than maxContestantNumber.");
}
// 1. 根据 contestantNumber 范围查询参赛者
QueryWrapper<Contestant> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda()
.ge(Contestant::getContestantNumber, minContestantNumber)
.le(Contestant::getContestantNumber, maxContestantNumber)
.orderByAsc(Contestant::getContestantNumber);
List<Contestant> contestants = contestantMapper.selectList(queryWrapper);
if (contestants.isEmpty()) {
log.info("No contestants found in range [{}, {}]", minContestantNumber, maxContestantNumber);
return new byte[0];
}
// 2. 在内存中构建 ZIP
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos)) {
for (Contestant contestant : contestants) {
Integer contestantNumber = contestant.getContestantNumber();
if (contestantNumber == null) {
log.warn("Contestant {} has no contestantNumber, skipping", contestant.getId());
continue;
}
String dirPrefix = contestantNumber + "/";
// 添加 PDF 文件
String pdfPath = contestant.getPdfPath();
if (StringUtils.isNotBlank(pdfPath)) {
addMinioFileToZip(zos, pdfPath, dirPrefix + "design.pdf");
}
// 添加视频文件
String videoPath = contestant.getVideoPath();
if (StringUtils.isNotBlank(videoPath)) {
String fileName = videoPath.contains("/") ?
videoPath.substring(videoPath.lastIndexOf("/") + 1) : "video.mp4";
addMinioFileToZip(zos, videoPath, dirPrefix + fileName);
}
// 添加参赛者信息 txt 文件
StringBuilder sb = new StringBuilder();
sb.append("=== Contestant Information ===\n\n");
sb.append("ID: ").append(nullSafe(contestant.getId())).append("\n");
sb.append("Email: ").append(nullSafe(contestant.getEmail())).append("\n");
sb.append("Contestant Number: ").append(contestantNumber).append("\n");
sb.append("First Name: ").append(nullSafe(contestant.getFirstName())).append("\n");
sb.append("Last Name: ").append(nullSafe(contestant.getLastName())).append("\n");
sb.append("Gender: ").append(nullSafe(contestant.getGender())).append("\n");
sb.append("Occupation: ").append(nullSafe(contestant.getOccupation())).append("\n");
sb.append("Age: ").append(contestant.getAge() != null ? contestant.getAge() : "N/A").append("\n");
sb.append("Country/Region/City: ").append(nullSafe(contestant.getCountryRegionCity())).append("\n");
sb.append("Phone Number: ").append(nullSafe(contestant.getPhoneNumber())).append("\n");
sb.append("Design Title: ").append(nullSafe(contestant.getDesignTitle())).append("\n");
sb.append("Design Description: ").append(nullSafe(contestant.getDesignDescription())).append("\n");
sb.append("PDF Path: ").append(nullSafe(pdfPath)).append("\n");
sb.append("PDF Size (bytes): ").append(contestant.getPdfSize() != null ? contestant.getPdfSize() : "N/A").append("\n");
sb.append("Video Path: ").append(nullSafe(videoPath)).append("\n");
sb.append("Video Duration (seconds): ").append(contestant.getVideoDuration() != null ? contestant.getVideoDuration() : "N/A").append("\n");
sb.append("Video Size (bytes): ").append(contestant.getVideoSize() != null ? contestant.getVideoSize() : "N/A").append("\n");
sb.append("Portfolio URL: ").append(nullSafe(contestant.getPortfolioUrl())).append("\n");
sb.append("Created At: ").append(contestant.getCreatedAt() != null ? contestant.getCreatedAt() : "N/A").append("\n");
sb.append("Updated At: ").append(contestant.getUpdatedAt() != null ? contestant.getUpdatedAt() : "N/A").append("\n");
ZipEntry infoEntry = new ZipEntry(dirPrefix + "contestant_info.txt");
zos.putNextEntry(infoEntry);
zos.write(sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
zos.closeEntry();
log.info("Added contestant {} info to zip", contestantNumber);
}
zos.finish();
log.info("ZIP built for {} contestants, size: {} bytes", contestants.size(), baos.size());
return baos.toByteArray();
}
}
/**
* 将 MinIO 文件流式写入 ZIP不落盘
* @param zos ZIP 输出流
* @param minioPath MinIO 路径(格式: bucketName/objectPath
* @param entryName ZIP 条目名称
*/
private void addMinioFileToZip(java.util.zip.ZipOutputStream zos, String minioPath, String entryName) {
if (StringUtils.isBlank(minioPath)) {
return;
}
int index = minioPath.indexOf("/");
if (index == -1) {
log.warn("Invalid MinIO path: {}", minioPath);
return;
}
String bucketName = minioPath.substring(0, index);
String objectName = minioPath.substring(index + 1);
try (InputStream in = minioUtil.download(bucketName, objectName)) {
ZipEntry entry = new ZipEntry(entryName);
zos.putNextEntry(entry);
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
zos.write(buffer, 0, bytesRead);
}
zos.closeEntry();
log.info("Added {} to zip ({} bytes)", entryName, entry.getSize());
} catch (Exception e) {
log.error("Failed to add {} to zip: {}", entryName, e.getMessage());
}
}
@Override
public ContestantCountVO getContestantCount() {
long count = contestantMapper.selectCount(null);
Integer maxContestantNumber = null;
QueryWrapper<Contestant> qMax = new QueryWrapper<>();
qMax.isNotNull("contestant_number");
qMax.orderByDesc("contestant_number");
qMax.last("LIMIT 1");
Contestant last = contestantMapper.selectOne(qMax);
if (last != null) {
maxContestantNumber = last.getContestantNumber();
}
return ContestantCountVO.builder()
.count(count)
.maxContestantNumber(maxContestantNumber)
.build();
}
private String nullSafe(String value) {
return value != null ? value : "N/A";
}
private static final String RAW_VISIT_COUNT_KEY = "GLOBAL_AWARD:visit:raw";
private static final String UNIQUE_VISIT_SET_KEY = "GLOBAL_AWARD:visit:unique";
private static final String SESSION_VISIT_KEY_PREFIX = "GLOBAL_AWARD:visit:session:";
private static final long SESSION_DEDUP_SECONDS = 5L;
@Override
public void recordPageVisit(String sessionId) {
redisUtil.increaseCount(RAW_VISIT_COUNT_KEY);
if (StringUtils.isNotBlank(sessionId)) {
String sessionKey = SESSION_VISIT_KEY_PREFIX + sessionId;
if (!redisUtil.hasKey(sessionKey)) {
redisUtil.increaseCount(UNIQUE_VISIT_SET_KEY);
redisUtil.addToString(sessionKey, "1", SESSION_DEDUP_SECONDS);
}
} else {
redisUtil.increaseCount(UNIQUE_VISIT_SET_KEY);
}
}
@Override
public PageVisitCountVO getPageVisitCount() {
Long raw = redisUtil.getIncrementCount(RAW_VISIT_COUNT_KEY);
Long unique = redisUtil.getIncrementCount(UNIQUE_VISIT_SET_KEY);
return PageVisitCountVO.builder()
.rawVisitCount(raw != null ? raw : 0L)
.uniqueVisitCount(unique != null ? unique : 0L)
.build();
}
} }

View File

@@ -31,6 +31,7 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@@ -90,7 +91,7 @@ public class OrderInfoServiceImpl extends ServiceImpl<OrderInfoMapper, OrderInfo
} }
public OrderInfo createOrderByProductId(Integer amount, String paymentType, ProductEnum product, public OrderInfo createOrderByProductId(Integer amount, String paymentType, ProductEnum product,
HttpServletRequest request, byte autoRenewal) { HttpServletRequest request) {
//获取商品信息 //获取商品信息
// Product product = productMapper.selectById(amount); // Product product = productMapper.selectById(amount);
@@ -276,10 +277,11 @@ public class OrderInfoServiceImpl extends ServiceImpl<OrderInfoMapper, OrderInfo
public void updateTotalFeeByOrderNo(String orderNo) { public void updateTotalFeeByOrderNo(String orderNo) {
QueryWrapper<PaymentInfo> qw = new QueryWrapper<>(); QueryWrapper<PaymentInfo> qw = new QueryWrapper<>();
qw.eq("order_no", orderNo); qw.eq("order_no", orderNo).in("trade_state", Arrays.asList("paid", "COMPLETED", ""));
List<PaymentInfo> paymentInfos = paymentInfoMapper.selectList(qw); List<PaymentInfo> paymentInfos = paymentInfoMapper.selectList(qw);
Float sum = paymentInfos.stream() Float sum = paymentInfos.stream()
.map(PaymentInfo::getPayerTotal) .map(PaymentInfo::getPayerTotal)
.filter(Objects::nonNull)
.reduce(0f, Float::sum); .reduce(0f, Float::sum);
baseMapper.update( baseMapper.update(

View File

@@ -2,9 +2,11 @@ package com.ai.da.service.impl;
import com.ai.da.common.context.UserContext; import com.ai.da.common.context.UserContext;
import com.ai.da.common.enums.PayTypeEnum; import com.ai.da.common.enums.PayTypeEnum;
import com.ai.da.common.enums.PaymentInfoType;
import com.ai.da.common.response.PageBaseResponse; import com.ai.da.common.response.PageBaseResponse;
import com.ai.da.common.utils.SpringUtils; import com.ai.da.common.utils.SpringUtils;
import com.ai.da.mapper.primary.PaymentInfoMapper; import com.ai.da.mapper.primary.PaymentInfoMapper;
import com.ai.da.mapper.primary.ProductCouponsMapper;
import com.ai.da.mapper.primary.entity.OrderInfo; import com.ai.da.mapper.primary.entity.OrderInfo;
import com.ai.da.mapper.primary.entity.PaymentInfo; import com.ai.da.mapper.primary.entity.PaymentInfo;
import com.ai.da.mapper.primary.entity.ProductCoupons; import com.ai.da.mapper.primary.entity.ProductCoupons;
@@ -20,13 +22,17 @@ import com.google.gson.Gson;
import com.paypal.orders.Order; import com.paypal.orders.Order;
import com.stripe.Stripe; import com.stripe.Stripe;
import com.stripe.exception.StripeException; import com.stripe.exception.StripeException;
import com.stripe.model.Charge; import com.stripe.model.*;
import com.stripe.model.Invoice;
import com.stripe.model.Subscription;
import com.stripe.model.checkout.Session; import com.stripe.model.checkout.Session;
import com.stripe.net.RequestOptions;
import com.stripe.param.InvoicePaymentListParams;
import com.stripe.param.InvoiceRetrieveParams;
import com.stripe.param.SubscriptionRetrieveParams;
import com.stripe.param.checkout.SessionRetrieveParams;
import io.netty.util.internal.StringUtil; import io.netty.util.internal.StringUtil;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy; import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -49,6 +55,9 @@ public class PaymentInfoServiceImpl extends ServiceImpl<PaymentInfoMapper, Payme
@Resource @Resource
private OrderInfoService orderInfoService; private OrderInfoService orderInfoService;
@Resource
private ProductCouponsMapper productCouponsMapper;
/** /**
* 记录支付日志:微信支付 * 记录支付日志:微信支付
* @param plainText * @param plainText
@@ -194,38 +203,199 @@ public class PaymentInfoServiceImpl extends ServiceImpl<PaymentInfoMapper, Payme
baseMapper.insert(paymentInfo); baseMapper.insert(paymentInfo);
} }
public void createOrUpdatePaymentInfoForStripe(Session session){ /**
String orderId = session.getMetadata().get("orderId"); * 为 Stripe Checkout Session 创建支付记录
String status = session.getStatus(); *
// 获取transactionId,从sessionId更改为invoiceId * 策略:根据 session.getMode() 分流:
* - mode=subscription直接获取 session.getInvoice(),委托给 Invoice 版本(最完整)
* - mode=payment从 session.getPaymentIntentObject() 获取支付信息,兼容无 Invoice 场景
*
* @param session Stripe Checkout Session
*/
public void createOrUpdatePaymentInfoForStripe(Session session) {
Stripe.apiKey = privateKey;
String sessionId = session.getId(); String sessionId = session.getId();
String orderNo = session.getMetadata().get("orderId");
String mode = session.getMode();
String type = PaymentInfoType.CREDIT.getType();
// 从 Session 的 PaymentIntent 获取支付方式信息(两种 mode 都适用)
Map<String, String> paymentMethodInfo = handlePaymentMethodBySession(session, mode);
String invoiceId = session.getInvoice();
Invoice invoice = null;
if (!StringUtil.isNullOrEmpty(invoiceId)) {
try {
invoice = Invoice.retrieve(invoiceId);
} catch (StripeException e) {
log.warn("[createOrUpdatePaymentInfoForStripe(Session)] 订阅模式获取 Invoice 失败,降级为 payment 模式处理sessionId={}error={}",
sessionId, e.getMessage());
}
}
// subscription mode获取 Invoice委托给 Invoice 方法(传入已获取的 paymentMethodInfo
if ("subscription".equals(mode)) {
if (invoice != null) {
createOrUpdatePaymentInfoForStripe(invoice, paymentMethodInfo, session.getDiscounts());
log.info("[createOrUpdatePaymentInfoForStripe(Session)] subscription 模式通过 Invoice 创建支付记录invoiceId={}", invoiceId);
return;
}
type = PaymentInfoType.NEW.getType();
}
// payment mode / 降级:使用 session 自有字段创建支付记录
String status = session.getPaymentStatus();
Long amountTotal = session.getAmountTotal(); Long amountTotal = session.getAmountTotal();
// stripe 的支付金额单位是分
Float divide = new BigDecimal(amountTotal).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).floatValue(); Float divide = new BigDecimal(amountTotal).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).floatValue();
PaymentInfo paymentInfo = new PaymentInfo(); PaymentInfo paymentInfo = new PaymentInfo();
paymentInfo.setOrderNo(orderId); paymentInfo.setOrderNo(orderNo);
paymentInfo.setPaymentType(PayTypeEnum.STRIPE.getType()); paymentInfo.setPaymentType(PayTypeEnum.STRIPE.getType());
paymentInfo.setTransactionId(sessionId); paymentInfo.setTransactionId(invoiceId);
paymentInfo.setTradeState(status); paymentInfo.setTradeState(status);
paymentInfo.setPayerTotal(divide); paymentInfo.setPayerTotal(divide);
Gson gson = new Gson(); Gson gson = new Gson();
String json = gson.toJson(session); paymentInfo.setContent(gson.toJson(session));
paymentInfo.setContent(json); paymentInfo.setType(type);
// 获取订单信息 paymentInfo.setNotified(0);
OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(orderId); paymentInfo.setPaymentMethod(paymentMethodInfo.getOrDefault("paymentMethod", "N/A"));
if (!Objects.isNull(orderByOrderNo)){ paymentInfo.setLast4(paymentMethodInfo.getOrDefault("last4", "N/A"));
paymentInfo.setHostedInvoiceUrl(invoice == null ? null : invoice.getHostedInvoiceUrl());
paymentInfo.setCreateTime(LocalDateTime.now());
OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(orderNo);
if (!Objects.isNull(orderByOrderNo)) {
paymentInfo.setCountry(orderByOrderNo.getCountry()); paymentInfo.setCountry(orderByOrderNo.getCountry());
paymentInfo.setCity(orderByOrderNo.getCity()); paymentInfo.setCity(orderByOrderNo.getCity());
paymentInfo.setIpAddress(orderByOrderNo.getIpAddress()); paymentInfo.setIpAddress(orderByOrderNo.getIpAddress());
} }
baseMapper.insert(paymentInfo); baseMapper.insert(paymentInfo);
log.info("[createOrUpdatePaymentInfoForStripe(Session)] payment 模式创建支付记录sessionId={}orderNo={}", sessionId, orderNo);
}
/**
* 统一获取支付方式信息
* @param sessionId 可选的 sessionId
* @param subscriptionId 可选的 subscriptionId
* @return paymentMethodInfo Map包含 paymentMethod 和 last4
*/
public Map<String, String> getPaymentMethodInfo(String sessionId, String subscriptionId) {
PaymentMethod paymentMethod = null;
if (!StringUtil.isNullOrEmpty(sessionId)) {
paymentMethod = getPaymentMethodBySessionId(sessionId);
} else if (!StringUtil.isNullOrEmpty(subscriptionId)) {
paymentMethod = getPaymentMethodBySubscriptionId(subscriptionId);
}
return getPaymentMethodMap(paymentMethod);
}
/**
* 从 Session 获取支付方式信息
* @param session Stripe Checkout Session
* @param mode session 模式subscription 或 payment
* @return paymentMethodInfo Map
*/
public Map<String, String> handlePaymentMethodBySession(Session session, String mode) {
PaymentMethod paymentMethod;
if ("subscription".equals(mode)) {
String subscriptionId = session.getSubscription();
paymentMethod = getPaymentMethodBySubscriptionId(subscriptionId);
} else {
paymentMethod = getPaymentMethodBySessionId(session.getId());
}
return getPaymentMethodMap(paymentMethod);
}
public String getPromotionCodeByPromotionCodeId(String promotionCodeId) {
if (StringUtil.isNullOrEmpty(promotionCodeId)) {
return null;
}
ProductCoupons promotionCode = productCouponsMapper.selectOne(new QueryWrapper<ProductCoupons>().lambda().eq(ProductCoupons::getPromotionCodeId, promotionCodeId));
if (promotionCode == null) {
return null;
}
return promotionCode.getPromotionCode();
}
/**
* 从 sessionId 获取 PaymentMethod
* @param sessionId Stripe Session ID
* @return PaymentMethod 对象
*/
private PaymentMethod getPaymentMethodBySessionId(String sessionId) {
Stripe.apiKey = privateKey;
SessionRetrieveParams params = SessionRetrieveParams.builder()
.addExpand("payment_intent")
.addExpand("payment_intent.payment_method")
.build();
Session fullSession;
try {
fullSession = Session.retrieve(sessionId, params, RequestOptions.builder().build());
} catch (StripeException e) {
throw new RuntimeException(e);
}
PaymentIntent paymentIntent = fullSession.getPaymentIntentObject();
return paymentIntent != null ? paymentIntent.getPaymentMethodObject() : null;
}
/**
* 从 subscriptionId 获取 PaymentMethod
* @param subscriptionId Stripe Subscription ID
* @return PaymentMethod 对象
*/
public PaymentMethod getPaymentMethodBySubscriptionId(String subscriptionId) {
Stripe.apiKey = privateKey;
SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()
.addExpand("default_payment_method")
.build();
try {
Subscription subscription = Subscription.retrieve(subscriptionId, params, RequestOptions.builder().build());
return subscription.getDefaultPaymentMethodObject();
} catch (StripeException e) {
throw new RuntimeException(e);
}
}
@NotNull
private static Map<String, String> getPaymentMethodMap(PaymentMethod paymentMethod) {
Map<String, String> paymentMethodInfo = new HashMap<>();
if (paymentMethod != null && paymentMethod.getCard() != null) {
String brand = paymentMethod.getCard().getBrand();
brand = brand.substring(0, 1).toUpperCase() + brand.substring(1);
paymentMethodInfo.put("paymentMethod", brand + " " + paymentMethod.getCard().getFunding() + " Card");
paymentMethodInfo.put("last4", paymentMethod.getCard().getLast4());
} else if (paymentMethod != null) {
paymentMethodInfo.put("paymentMethod", StringUtils.capitalize(paymentMethod.getType()));
paymentMethodInfo.put("last4", "N/A");
} else {
paymentMethodInfo.put("paymentMethod", "N/A");
paymentMethodInfo.put("last4", "N/A");
}
return paymentMethodInfo;
} }
@Value("${stripe.private-key}") @Value("${stripe.private-key}")
private String privateKey; private String privateKey;
/**
* 为 Stripe Invoice 创建或更新支付记录
*
* @param invoice Stripe Invoice
* @param paymentMethodInfo 外部传入的支付方式信息(如从 Session 传入),优先使用,为空时内部重新获取
* @return PaymentInfo 支付记录
*/
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public PaymentInfo createOrUpdatePaymentInfoForStripe(Invoice invoice){ public PaymentInfo createOrUpdatePaymentInfoForStripe(Invoice invoice, Map<String, String> paymentMethodInfo, List<Session.Discount> discounts) {
Stripe.apiKey = privateKey; Stripe.apiKey = privateKey;
StripeService stripeService = SpringUtils.getBean(StripeService.class); StripeService stripeService = SpringUtils.getBean(StripeService.class);
// 获取transactionId,从sessionId更改为invoiceId // 获取transactionId,从sessionId更改为invoiceId
@@ -235,29 +405,61 @@ public class PaymentInfoServiceImpl extends ServiceImpl<PaymentInfoMapper, Payme
qw.eq("transaction_id", invoiceId); qw.eq("transaction_id", invoiceId);
PaymentInfo paymentInfo = baseMapper.selectOne(qw); PaymentInfo paymentInfo = baseMapper.selectOne(qw);
String status = invoice.getStatus(); String status = invoice.getStatus();
// 判断是否有优惠码 // 判断是否有优惠码 续订不会使用优惠码,故取消这部分代码
// Stripe SDK 32.0.0: invoice.getDiscount() 已移除,使用 invoice.getDiscountObject().get() 替代
String promotionCode = null; String promotionCode = null;
if (Objects.nonNull(invoice.getDiscount()) && !StringUtil.isNullOrEmpty(invoice.getDiscount().getPromotionCode())){ if (!CollectionUtils.isEmpty(discounts)) {
ProductCoupons productCoupon = stripeService.getProductCoupon(null, invoice.getDiscount().getPromotionCode()); promotionCode = getPromotionCodeByPromotionCodeId(discounts.getFirst().getPromotionCode());
promotionCode = productCoupon.getPromotionCode();
} }
// 判断当前支付是否已经被记录,确保同一个支付不会被重复记录 // 判断当前支付是否已经被记录,确保同一个支付不会被重复记录
if (Objects.isNull(paymentInfo)){ if (Objects.isNull(paymentInfo)){
String orderNo; String orderNo = null;
String billingReason = invoice.getBillingReason();
String paymentIntentIdForCharge = null;
try { try {
if (invoice.getBillingReason().equals("manual")){ if ("manual".equals(billingReason)){
// 手动创建的发票针对one-time支付 // 手动创建的发票针对one-time支付
// orderNo = invoice.getLines().getData().get(0).getPrice().getMetadata().get("orderId"); // 获取 PaymentIntent 用于后续获取 chargeId 和支付方式
// 当支付失败时chargeId为空 paymentIntentIdForCharge = getPaymentIntentByInvoice(invoice);
String chargeId = invoice.getCharge(); if (!StringUtil.isNullOrEmpty(paymentIntentIdForCharge)) {
orderNo = Charge.retrieve(chargeId).getDescription().replace("AiDA - ", ""); PaymentIntent paymentIntent = PaymentIntent.retrieve(paymentIntentIdForCharge);
}else { String chargeId = paymentIntent.getLatestCharge();
String subscriptionId = invoice.getSubscription(); if (!StringUtil.isNullOrEmpty(chargeId)) {
// 从subscription中获取orderNo Charge charge = Charge.retrieve(chargeId);
orderNo = Subscription.retrieve(subscriptionId).getDescription().replace("AiDA - ", ""); String description = charge.getDescription();
orderNo = description != null ? description.replace("AiDA - ", "") : null;
}
}
if (StringUtil.isNullOrEmpty(orderNo)) {
orderNo = extractOrderNoFromInvoiceLines(invoice);
}
} else {
// Stripe SDK 32.0.0: invoice.getSubscription() 已移除
// 方案A直接从 invoice.getParent().getSubscriptionDetails().getMetadata() 获取 orderIdSDK 32.0.0 新方式)
String orderNoFromParent = getOrderNoFromInvoiceParent(invoice);
if (!StringUtil.isNullOrEmpty(orderNoFromParent)) {
orderNo = orderNoFromParent;
log.info("[createOrUpdatePaymentInfoForStripe] 从 invoice.getParent().getSubscriptionDetails() 获取到 orderNo={}", orderNo);
} else {
// 方案B从 subscription 获取 orderNo
String subscriptionId = getSubscriptionByInvoice(invoice);
if (!StringUtil.isNullOrEmpty(subscriptionId)) {
try {
Subscription subscription = Subscription.retrieve(subscriptionId);
orderNo = getOrderNoBySubscription(subscription);
} catch (StripeException e) {
log.warn("[createOrUpdatePaymentInfoForStripe] 获取 Subscription 失败subscriptionId={}, error={}", subscriptionId, e.getMessage());
}
}
// 方案C备用方案从 invoice metadata 获取
if (StringUtil.isNullOrEmpty(orderNo)) {
orderNo = extractOrderNoFromInvoiceMetadata(invoice);
}
}
} }
} catch (StripeException e) { } catch (StripeException e) {
throw new RuntimeException(e); log.error("[createOrUpdatePaymentInfoForStripe] 获取订单号失败invoiceId={}, error={}", invoiceId, e.getMessage());
throw new RuntimeException("Failed to retrieve orderNo from invoice: " + invoiceId, e);
} }
Long amountTotal; Long amountTotal;
if (status.equals("paid")){ if (status.equals("paid")){
@@ -268,11 +470,9 @@ public class PaymentInfoServiceImpl extends ServiceImpl<PaymentInfoMapper, Payme
// stripe 的支付金额单位是分,在我们数据库中金额单位为 元 // stripe 的支付金额单位是分,在我们数据库中金额单位为 元
Float divide = new BigDecimal(amountTotal).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).floatValue(); Float divide = new BigDecimal(amountTotal).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).floatValue();
String type = invoice.getBillingReason().equals("subscription_create") ? "new" : String type = invoice.getBillingReason().equals("subscription_create") ? PaymentInfoType.NEW.getType() :
invoice.getBillingReason().equals("subscription_cycle") ? "renewal" : invoice.getBillingReason(); invoice.getBillingReason().equals("subscription_cycle") ? PaymentInfoType.RENEWAL.getType() : invoice.getBillingReason();
// 获取支付方式
Map<String, String> paymentMethod = stripeService.getPaymentMethodByInvoiceId(invoiceId);
// 获取订单信息 // 获取订单信息
OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(orderNo); OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(orderNo);
@@ -288,8 +488,8 @@ public class PaymentInfoServiceImpl extends ServiceImpl<PaymentInfoMapper, Payme
paymentInfo.setContent(json); paymentInfo.setContent(json);
paymentInfo.setType(type); paymentInfo.setType(type);
paymentInfo.setNotified(0); paymentInfo.setNotified(0);
paymentInfo.setPaymentMethod(paymentMethod.get("paymentMethod")); paymentInfo.setPaymentMethod(paymentMethodInfo.get("paymentMethod"));
paymentInfo.setLast4(paymentMethod.get("last4")); paymentInfo.setLast4(paymentMethodInfo.get("last4"));
paymentInfo.setHostedInvoiceUrl(invoice.getHostedInvoiceUrl()); paymentInfo.setHostedInvoiceUrl(invoice.getHostedInvoiceUrl());
paymentInfo.setPromotionCode(promotionCode); paymentInfo.setPromotionCode(promotionCode);
paymentInfo.setCreateTime(LocalDateTime.now()); paymentInfo.setCreateTime(LocalDateTime.now());
@@ -300,87 +500,85 @@ public class PaymentInfoServiceImpl extends ServiceImpl<PaymentInfoMapper, Payme
} }
int row = baseMapper.insertIgnore(paymentInfo); int row = baseMapper.insertIgnore(paymentInfo);
log.info("Payment Info insert affect rows:{}", row); log.info("Payment Info insert affect rows:{}", row);
}else { orderInfoService.updateTotalFeeByOrderNo(orderNo);
} else {
paymentInfo.setTradeState(status); paymentInfo.setTradeState(status);
paymentInfo.setPromotionCode(promotionCode); paymentInfo.setPromotionCode(promotionCode);
paymentInfo.setUpdateTime(LocalDateTime.now()); paymentInfo.setUpdateTime(LocalDateTime.now());
baseMapper.updateById(paymentInfo); baseMapper.updateById(paymentInfo);
} }
return paymentInfo; return paymentInfo;
} }
public PaymentInfo createOrUpdatePaymentInfoForStripe(Charge charge){ private String getPaymentIntentByInvoice(Invoice invoice) {
Stripe.apiKey = privateKey; // 从 invoice.getPayments() 获取(适用于已支付完成的 Invoice
QueryWrapper<PaymentInfo> qw = new QueryWrapper<>(); // SDK 32.0.0: invoice.getPayments() 可能为 null需逐层判空
// todo 首次支付失败没有invoiceId所以如果这个order之后成功支付后会有多条paymentInfo 是否需要优化?? try {
qw.eq("transaction_id", charge.getInvoice()); InvoicePaymentCollection payments = invoice.getPayments();
PaymentInfo paymentInfo = baseMapper.selectOne(qw); if (payments != null) {
Charge.PaymentMethodDetails paymentMethodDetails = charge.getPaymentMethodDetails(); List<InvoicePayment> invoicePayments = payments.getData();
String paymentMethod; if (invoicePayments != null && !invoicePayments.isEmpty()) {
String last4 = "N/A"; InvoicePayment firstPayment = invoicePayments.getFirst();
switch (paymentMethodDetails.getType()){ if (firstPayment != null) {
case "alipay": InvoicePayment.Payment payment = firstPayment.getPayment();
paymentMethod = "Alipay"; if (payment != null) {
break; PaymentIntent paymentIntent = payment.getPaymentIntentObject();
case "bancontact": if (paymentIntent != null) {
paymentMethod = "BanContact"; return paymentIntent.getId();
break; }
case "card": }
Charge.PaymentMethodDetails.Card card = paymentMethodDetails.getCard(); }
String brand = card.getBrand(); }
brand = brand.substring(0, 1).toUpperCase() + brand.substring(1);
paymentMethod = brand + " " + card.getFunding() + "card";
last4 = card.getLast4();
break;
case "eps":
Charge.PaymentMethodDetails.Eps eps = paymentMethodDetails.getEps();
paymentMethod = eps.getBank();
break;
case "giropay":
paymentMethod = "GiroPay";
break;
case "ideal":
Charge.PaymentMethodDetails.Ideal ideal = paymentMethodDetails.getIdeal();
paymentMethod = ideal.getBank();
break;
case "link":
paymentMethod = "Link";
break;
default:
paymentMethod = "N/A";
}
if (Objects.isNull(paymentInfo)){
Stripe.apiKey = privateKey;
String orderNo = charge.getDescription().replace("AiDA - ", "");
OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(orderNo);
Float divide = new BigDecimal(charge.getAmount()).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).floatValue();
paymentInfo = new PaymentInfo();
paymentInfo.setOrderNo(orderNo);
paymentInfo.setTransactionId(charge.getInvoice());
paymentInfo.setPaymentType(PayTypeEnum.STRIPE.getType());
paymentInfo.setTradeState(charge.getStatus());
paymentInfo.setPayerTotal(divide);
paymentInfo.setNotified(0);
paymentInfo.setPaymentMethod(paymentMethod);
paymentInfo.setLast4(last4);
paymentInfo.setCreateTime(LocalDateTime.now());
if (!Objects.isNull(orderByOrderNo)){
paymentInfo.setCountry(orderByOrderNo.getCountry());
paymentInfo.setCity(orderByOrderNo.getCity());
paymentInfo.setIpAddress(orderByOrderNo.getIpAddress());
} }
int row = baseMapper.insertIgnore(paymentInfo); } catch (Exception e) {
log.info("Payment Info insert affect rows:{}", row); log.warn("[getPaymentIntentByInvoice] 获取 PaymentIntent 失败invoiceId={}error={}", invoice.getId(), e.getMessage());
}else { }
paymentInfo.setTradeState(charge.getStatus()); return null;
paymentInfo.setPaymentMethod(paymentMethod); }
paymentInfo.setLast4(last4);
paymentInfo.setUpdateTime(LocalDateTime.now()); private String getPromotionCodeByInvoice(Invoice invoice) throws StripeException {
baseMapper.updateById(paymentInfo); // 1. 检索 Invoice 并展开 discounts 字段
InvoiceRetrieveParams params = InvoiceRetrieveParams.builder()
.addExpand("discounts") // 展开折扣数组
.build();
invoice = Invoice.retrieve(invoice.getId(), params, null);
// 2. 获取折扣列表注意Invoice.Discount 不是 List<Discount>
List<Discount> invoiceDiscounts = invoice.getDiscountObjects();
if (invoiceDiscounts == null || invoiceDiscounts.isEmpty()) {
log.info("No discounts applied to this invoice");
return null;
} }
return paymentInfo; // 3. 遍历每个折扣(通常只有一个)
for (Discount discount : invoiceDiscounts) {
// // 获取 source 对象(包含优惠券信息)
// Discount.Source source = discount.getSource();
//
// if (source != null && "coupon".equals(source.getType())) {
// // source.coupon 可能是 ID 字符串,也可能是已展开的 Coupon 对象
// Object couponObj = source.getCoupon();
//
// if (couponObj instanceof String) {
// String couponId = (String) couponObj;
// // 需要通过 ID 单独检索 Coupon
// Coupon coupon = Coupon.retrieve(couponId);
// System.out.println("Coupon ID: " + coupon.getId());
// System.out.println("Coupon name: " + coupon.getName());
// } else if (couponObj instanceof Coupon) {
// Coupon coupon = (Coupon) couponObj;
// System.out.println("Coupon ID: " + coupon.getId());
// }
// }
// // 获取其他折扣信息
// Long start = discount.getStart(); // 折扣开始时间
// Long end = discount.getEnd(); // 折扣结束时间(可能为 null
return discount.getPromotionCode(); // 关联的促销码 ID
}
return null;
} }
@@ -439,26 +637,234 @@ public class PaymentInfoServiceImpl extends ServiceImpl<PaymentInfoMapper, Payme
return baseMapper.selectPaidPaymentsByAccountAndPromotion(accountId, promCode); return baseMapper.selectPaidPaymentsByAccountAndPromotion(accountId, promCode);
} }
public PaymentInfo updatePaymentRefundStatus(Charge charge){
// 判断当前退款是部分退款还是全部退款 /**
* 通过 chargeId 更新支付记录的退款状态
* 从 charge 获取关联的 invoiceId再更新 paymentInfo
*
* @param charge Stripe Charge 对象
* @param status 新的交易状态
*/
@Override
public void updatePaymentRefundStatusByChargeId(Charge charge, String status) {
if (charge == null) {
log.warn("[updatePaymentRefundStatusByChargeId] charge 为空,跳过");
return;
}
String chargeId = charge.getId();
Stripe.apiKey = privateKey;
String invoiceId = extractInvoiceIdFromCharge(charge);
if (!StringUtil.isNullOrEmpty(invoiceId)) {
updatePaymentRefundStatusByInvoiceId(invoiceId, status);
} else {
log.warn("[updatePaymentRefundStatusByChargeId] 无法从 charge 获取 invoiceIdchargeId={}", chargeId);
}
}
/**
* 通过 invoiceId 更新 paymentInfo 表的退款状态
*
* @param invoiceId Stripe Invoice ID对应 paymentInfo.transactionId
* @param status 新的交易状态
*/
@Override
public void updatePaymentRefundStatusByInvoiceId(String invoiceId, String status) {
if (StringUtil.isNullOrEmpty(invoiceId)) {
log.warn("[updatePaymentRefundStatusByInvoiceId] invoiceId 为空,跳过");
return;
}
QueryWrapper<PaymentInfo> qw = new QueryWrapper<>(); QueryWrapper<PaymentInfo> qw = new QueryWrapper<>();
qw.eq("transaction_id", charge.getInvoice()); qw.eq("transaction_id", invoiceId);
PaymentInfo paymentInfo = baseMapper.selectOne(qw); PaymentInfo paymentInfo = baseMapper.selectOne(qw);
if (Objects.nonNull(paymentInfo)){ if (Objects.nonNull(paymentInfo)) {
String status ; if (!paymentInfo.getTradeState().equals(status)) {
if (Objects.equals(charge.getAmount(), charge.getAmountRefunded())){
status = "Refunded";
}else if (charge.getAmount() > charge.getAmountRefunded()){
status = "Partial refund";
}else {
status = "Refund Exception";
log.warn("{}, 退款金额高于付款金额, ChargeId为{}", status, charge.getId());
}
if (!paymentInfo.getTradeState().equals(status)){
paymentInfo.setTradeState(status); paymentInfo.setTradeState(status);
paymentInfo.setUpdateTime(LocalDateTime.now()); paymentInfo.setUpdateTime(LocalDateTime.now());
baseMapper.updateById(paymentInfo); baseMapper.updateById(paymentInfo);
log.info("[updatePaymentRefundStatusByInvoiceId] 支付记录状态已更新invoiceId={}status={}", invoiceId, status);
} }
} else {
log.warn("[updatePaymentRefundStatusByInvoiceId] 未找到对应的支付记录invoiceId={}", invoiceId);
}
}
/**
* 从 Charge 中提取 invoiceId
* Stripe SDK 32.0.0 (API 2026-03-25.dahlia):
* - Charge 没有 invoice 字段charge.getInvoice() 在新版本中不可用)
* - Charge 有 payment_intent 字段(可展开)
* 路径: Charge → payment_intent → InvoicePayment.list(payment.payment_intent=xxx) → invoice
*
* @param charge Stripe Charge
* @return invoiceId 或 null
*/
private String extractInvoiceIdFromCharge(Charge charge) {
if (charge == null) {
return null;
}
// 方案1从 charge.metadata 中获取(如果存储了相关信息)
Map<String, String> metadata = charge.getMetadata();
if (metadata != null && metadata.containsKey("invoiceId")) {
return metadata.get("invoiceId");
}
// 方案2路径 Charge → payment_intent → InvoicePayment → invoice
String paymentIntentId = charge.getPaymentIntent();
if (!StringUtil.isNullOrEmpty(paymentIntentId)) {
return extractInvoiceIdFromPaymentIntentById(paymentIntentId);
}
return null;
}
/**
* 通过 PaymentIntentId 查找关联的 invoiceId
* 路径: PaymentIntent → InvoicePayment.list(payment.payment_intent=xxx) → invoice
* SDK 32.0.0 InvoicePayment.list() 支持 payment.payment_intent 过滤参数
*
* @param paymentIntentId Stripe PaymentIntent ID
* @return invoiceId 或 null
*/
private String extractInvoiceIdFromPaymentIntentById(String paymentIntentId) {
if (StringUtil.isNullOrEmpty(paymentIntentId)) {
return null;
}
try {
InvoicePaymentListParams params = InvoicePaymentListParams.builder()
.setPayment(
InvoicePaymentListParams.Payment.builder()
.setPaymentIntent(paymentIntentId)
.setType(InvoicePaymentListParams.Payment.Type.PAYMENT_INTENT)
.build()
)
.setLimit(1L)
.build();
InvoicePaymentCollection payments = InvoicePayment.list(params);
if (payments != null && payments.getData() != null && !payments.getData().isEmpty()) {
InvoicePayment payment = payments.getData().get(0);
String invoiceId = payment.getInvoice();
if (!StringUtil.isNullOrEmpty(invoiceId)) {
return invoiceId;
}
}
} catch (StripeException e) {
log.warn("[extractInvoiceIdFromPaymentIntentById] 通过 InvoicePayment.list 查找 invoice 失败paymentIntentId={}, error={}",
paymentIntentId, e.getMessage());
}
return null;
}
/**
* 从 Invoice lines 中提取订单号
* Stripe SDK 32.0.0: 兼容处理
*
* @param invoice Stripe Invoice
* @return orderNo 或 null
*/
private String extractOrderNoFromInvoiceLines(Invoice invoice) {
try {
List<InvoiceLineItem> lines = invoice.getLines().getData();
if (lines != null && !lines.isEmpty()) {
InvoiceLineItem firstLine = lines.getFirst();
// 尝试从 line metadata 获取
Map<String, String> lineMetadata = firstLine.getMetadata();
if (lineMetadata != null && lineMetadata.containsKey("orderId")) {
return lineMetadata.get("orderId");
}
}
} catch (Exception e) {
log.warn("[extractOrderNoFromInvoiceLines] 提取订单号失败invoiceId={}, error={}",
invoice.getId(), e.getMessage());
}
return null;
}
/**
* 从 Invoice metadata 中提取订单号
* Stripe SDK 32.0.0: 兼容处理
*
* @param invoice Stripe Invoice
* @return orderNo 或 null
*/
private String extractOrderNoFromInvoiceMetadata(Invoice invoice) {
Map<String, String> metadata = invoice.getMetadata();
if (metadata != null && metadata.containsKey("orderId")) {
return metadata.get("orderId");
}
return null;
}
/**
* 从 Invoice 中获取 subscriptionId
* Stripe SDK 32.0.0: 使用 invoice.getParent().getSubscriptionDetails().getSubscription() 替代已移除的 invoice.getSubscription()
*
* @param invoice Stripe Invoice
* @return subscriptionId 或 null
*/
private String getSubscriptionByInvoice(Invoice invoice) {
try {
Invoice.Parent parent = invoice.getParent();
if (parent != null && "subscription_details".equals(parent.getType())) {
Invoice.Parent.SubscriptionDetails subscriptionDetails = parent.getSubscriptionDetails();
if (subscriptionDetails != null) {
return subscriptionDetails.getSubscription();
}
}
} catch (Exception e) {
log.warn("[getSubscriptionByInvoice] 从 invoice.getParent() 获取 subscriptionId 失败invoiceId={}, error={}",
invoice.getId(), e.getMessage());
}
return null;
}
/**
* 从 Invoice.getParent().getSubscriptionDetails().getMetadata() 直接获取 orderNo
* Stripe SDK 32.0.0: 这是获取订阅关联的 orderNo 的推荐方式
* 当通过 Checkout Session 创建订阅时metadata 会自动传递到 Subscription再传递到 Invoice
*
* @param invoice Stripe Invoice
* @return orderNo 或 null
*/
private String getOrderNoFromInvoiceParent(Invoice invoice) {
try {
Invoice.Parent parent = invoice.getParent();
if (parent != null && "subscription_details".equals(parent.getType())) {
Invoice.Parent.SubscriptionDetails subscriptionDetails = parent.getSubscriptionDetails();
if (subscriptionDetails != null) {
// SDK 32.0.0: subscriptionDetails.getMetadata() 可以直接获取 subscription 创建时设置的 metadata
Map<String, String> subscriptionMetadata = subscriptionDetails.getMetadata();
if (subscriptionMetadata != null && subscriptionMetadata.containsKey("orderId")) {
return subscriptionMetadata.get("orderId");
}
}
}
} catch (Exception e) {
log.warn("[getOrderNoFromInvoiceParent] 从 invoice.getParent().getSubscriptionDetails().getMetadata() 获取 orderNo 失败invoiceId={}, error={}",
invoice.getId(), e.getMessage());
}
return null;
}
/**
* 从 Subscription 中获取 orderNo
* 优先从 subscription metadata 获取,其次从 description 中获取
*
* @param subscription Stripe Subscription
* @return orderNo 或 null
*/
private String getOrderNoBySubscription(Subscription subscription) {
if (subscription == null) {
return null;
}
// 方案1从 subscription metadata 获取SDK 32.0.0 推荐方式)
Map<String, String> metadata = subscription.getMetadata();
if (metadata != null && metadata.containsKey("orderId")) {
return metadata.get("orderId");
}
// 方案2从 description 获取(旧方式,保持兼容)
String description = subscription.getDescription();
if (!StringUtil.isNullOrEmpty(description) && description.startsWith("AiDA - ")) {
return description.replace("AiDA - ", "");
} }
return null; return null;
} }

View File

@@ -31,6 +31,11 @@ public class RabbitMQServiceImpl implements RabbitMQService {
mqPublisher.sendGenerateMessage(message); mqPublisher.sendGenerateMessage(message);
} }
@Override
public void publishMessageToGenerateResult(String message) {
mqPublisher.sendGenerateResultMessage(message);
}
@Override @Override
public void publishMessageToSR(String message) { public void publishMessageToSR(String message) {
mqPublisher.sendSRMessage(message); mqPublisher.sendSRMessage(message);

View File

@@ -1,31 +1,59 @@
package com.ai.da.service.impl; package com.ai.da.service.impl;
import com.ai.da.common.enums.CreditsEventsEnum;
import com.ai.da.common.enums.OrderStatusEnum;
import com.ai.da.common.enums.ProductEnum;
import com.ai.da.common.utils.OrderNoUtils; import com.ai.da.common.utils.OrderNoUtils;
import com.ai.da.mapper.primary.AccountMapper;
import com.ai.da.mapper.primary.RefundInfoMapper; import com.ai.da.mapper.primary.RefundInfoMapper;
import com.ai.da.mapper.primary.SubscriptionInfoMapper;
import com.ai.da.mapper.primary.entity.Account;
import com.ai.da.mapper.primary.entity.OrderInfo; import com.ai.da.mapper.primary.entity.OrderInfo;
import com.ai.da.mapper.primary.entity.RefundInfo; import com.ai.da.mapper.primary.entity.RefundInfo;
import com.ai.da.service.OrderInfoService; import com.ai.da.mapper.primary.entity.SubscriptionInfo;
import com.ai.da.service.RefundInfoService; import com.ai.da.service.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.stripe.Stripe;
import com.stripe.model.Charge; import com.stripe.model.Charge;
import com.stripe.model.Refund; import com.stripe.model.Refund;
import com.stripe.exception.StripeException;
import io.netty.util.internal.StringUtil; import io.netty.util.internal.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import org.springframework.beans.factory.annotation.Value;
@Service @Service
@Slf4j
public class RefundInfoServiceImpl extends ServiceImpl<RefundInfoMapper, RefundInfo> implements RefundInfoService { public class RefundInfoServiceImpl extends ServiceImpl<RefundInfoMapper, RefundInfo> implements RefundInfoService {
@Value("${stripe.private-key}")
private String privateKey;
@Resource @Resource
private OrderInfoService orderInfoService; private OrderInfoService orderInfoService;
@Resource
private CreditsService creditsService;
@Resource
private AccountService accountService;
@Resource
private PaymentInfoService paymentInfoService;
@Resource
private AccountMapper accountMapper;
@Resource
private SubscriptionInfoMapper subscriptionInfoMapper;
@Resource
private StripeSubscriptionService stripeSubscriptionService;
/** /**
* 根据订单号创建退款订单 * 根据订单号创建退款订单
@@ -217,18 +245,275 @@ public class RefundInfoServiceImpl extends ServiceImpl<RefundInfoMapper, RefundI
} }
public RefundInfo updateRefundForStripe(Charge charge){ public RefundInfo updateRefundForStripe(Charge charge){
List<RefundInfo> refundInfoList = getByChargeId(charge.getId()); String chargeId = charge.getId();
if (!refundInfoList.isEmpty()){ List<RefundInfo> refundInfoList = getByChargeId(chargeId);
RefundInfo refundInfo = refundInfoList.get(refundInfoList.size() - 1); if (refundInfoList.isEmpty()){
if (StringUtil.isNullOrEmpty(refundInfo.getOrderNo())){ return null;
String orderNo = charge.getDescription().replace("AiDA - ", ""); }
refundInfo.setOrderNo(orderNo); RefundInfo refundInfo = refundInfoList.get(refundInfoList.size() - 1);
refundInfo.setTotalFee(charge.getAmount() / 100f); if (StringUtil.isNullOrEmpty(refundInfo.getOrderNo())){
refundInfo.setUpdateTime(LocalDateTime.now()); String orderNo = charge.getDescription() != null ? charge.getDescription().replace("AiDA - ", "") : null;
baseMapper.updateById(refundInfo); if (StringUtil.isNullOrEmpty(orderNo)){
return refundInfo; return null;
}
refundInfo.setOrderNo(orderNo);
refundInfo.setTotalFee(charge.getAmount() / 100f);
baseMapper.updateById(refundInfo);
}
// 处理退款成功后的业务逻辑
// 判断是否为全额退款amount == amountRefunded
if (charge.getAmount() != null && charge.getAmountRefunded() != null
&& charge.getAmount().equals(charge.getAmountRefunded())) {
handleRefundSuccess(refundInfo);
}
return refundInfo;
}
/**
* 处理全额退款成功后的业务逻辑
* 根据订单类型执行不同操作:
* - 积分购买订单:扣减 t_account.credits并在 t_credits_detail 添加变动记录
* - 订阅订单:扣减 t_account.credits根据订阅类型在 t_credits_detail 添加变动记录,
* 并将 t_account.valid_start_time 设置为 t_subscription_info.current_period_start
*/
@Transactional(rollbackFor = Exception.class)
public void handleRefundSuccess(RefundInfo refundInfo) {
String orderNo = refundInfo.getOrderNo();
if (StringUtil.isNullOrEmpty(orderNo)) {
return;
}
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
if (orderInfo == null) {
log.warn("[handleFullRefundSuccess] 未找到订单跳过orderNo={}", orderNo);
return;
}
String title = orderInfo.getTitle();
Long accountId = orderInfo.getAccountId();
Account account = accountMapper.selectById(accountId);
// 判断订单类型
if (title != null && title.startsWith("积分购买")) {
// 积分购买订单退款
handleCreditsPurchaseRefund(orderNo, orderInfo, account);
} else {
// 订阅订单退款
handleSubscriptionRefund(orderNo, orderInfo, account);
}
// 更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatusEnum.REFUND_SUCCESS);
log.info("[RefundInfoService] 退款成功订单状态已更新orderNo={}", orderNo);
}
/**
* 处理积分购买订单的退款
* 扣减 t_account.credits在 t_credits_detail 添加变动记录
*/
private void handleCreditsPurchaseRefund(String orderNo, OrderInfo orderInfo, Account account) {
Long accountId = orderInfo.getAccountId();
// 根据购买金额 / 单价计算积分数量
float creditsToRefund = orderInfo.getTotalFee() / Float.parseFloat(CreditsEventsEnum.PRICE.getValue());
int creditsQty = (int) creditsToRefund;
BigDecimal existingCredits = account.getCredits();
BigDecimal refundCredits = new BigDecimal(CreditsEventsEnum.BUY_CREDITS.getValue())
.multiply(new BigDecimal(creditsQty));
BigDecimal newCredits = existingCredits.subtract(refundCredits);
if (newCredits.compareTo(BigDecimal.ZERO) < 0) {
newCredits = BigDecimal.ZERO;
}
// 更新 t_account.credits
accountService.updateCreditsAndEndTime(account, newCredits.toString(), null, null);
// 更新 t_credits_detail
creditsService.insertToCreditsDetail(
accountId,
CreditsEventsEnum.REFUND.getName() + "--Stripe",
refundCredits.toString(),
"negative",
orderNo
);
log.info("[handleCreditsPurchaseRefund] 积分购买退款完成orderNo={}accountId={}creditsRefunded={}",
orderNo, accountId, refundCredits);
}
/**
* 处理订阅订单的退款
* 扣减 t_account.credits根据订阅类型在 t_credits_detail 添加变动记录,
* 并将 t_account.valid_start_time 设置为 t_subscription_info.current_period_start
*/
private void handleSubscriptionRefund(String orderNo, OrderInfo orderInfo, Account account) {
Long accountId = orderInfo.getAccountId();
String title = orderInfo.getTitle();
// 根据 orderInfo.title 在 ProductEnum 中匹配订阅类型
ProductEnum productEnum = ProductEnum.getByName(title);
if (productEnum == null) {
log.warn("[handleSubscriptionRefund] 无法匹配订阅类型跳过积分扣减orderNo={}title={}", orderNo, title);
return;
}
// 扣减对应订阅类型的积分
BigDecimal existingCredits = account.getCredits();
BigDecimal refundCredits = new BigDecimal(productEnum.getCredits());
BigDecimal newCredits = existingCredits.subtract(refundCredits);
if (newCredits.compareTo(BigDecimal.ZERO) < 0) {
newCredits = BigDecimal.ZERO;
}
// 根据 orderNo 查询 t_subscription_info将 t_account.valid_start_time 设置为 current_period_start
List<SubscriptionInfo> subList = subscriptionInfoMapper.selectList(
new QueryWrapper<SubscriptionInfo>().eq("order_no", orderNo)
);
if (!subList.isEmpty()) {
SubscriptionInfo subscriptionInfo = subList.getFirst();
if (subscriptionInfo.getStatus().equals("active")) {
stripeSubscriptionService.cancelSubscription(subscriptionInfo.getSubscriptionId(), "Refunded", accountId);
}
Long periodStart = subscriptionInfo.getCurrentPeriodStart();
if (periodStart != null) {
account.setSystemUser(0);
account.setValidEndTime(periodStart * 1000);
account.setCredits(newCredits);
account.setUpdateDate(new java.util.Date());
accountMapper.updateById(account);
log.info("[handleSubscriptionRefund] 已将 valid_start_time 设置为 current_period_startorderNo={}periodStart={}",
orderNo, periodStart);
}
} else {
log.warn("[handleSubscriptionRefund] 未找到订阅记录,跳过 valid_start_time 更新orderNo={}", orderNo);
}
// 在 t_credits_detail 添加变动记录systemUser 设为 0
creditsService.insertToCreditsDetail(
accountId,
CreditsEventsEnum.REFUND.getName() + "--Stripe",
refundCredits.toString(),
"negative",
orderNo
);
log.info("[handleSubscriptionRefund] 订阅退款完成orderNo={}accountId={}creditsRefunded={}",
orderNo, accountId, refundCredits);
}
/**
* refund.created 事件处理
* 在 t_refund_info 表中创建退款记录
*/
@Override
public RefundInfo handleRefundCreated(Refund refund) {
String refundId = refund.getId();
RefundInfo existing = getByRefundId(refundId);
if (existing != null) {
log.info("[handleRefundCreated] 退款记录已存在跳过创建refundId={}", refundId);
return existing;
}
RefundInfo refundInfo = new RefundInfo();
refundInfo.setRefundId(refundId);
refundInfo.setRefundNo(OrderNoUtils.getRefundNo());
refundInfo.setChargeId(refund.getCharge());
refundInfo.setRefund(refund.getAmount() / 100f);
refundInfo.setReason(refund.getReason());
refundInfo.setRefundStatus("pending");
refundInfo.setCreateTime(LocalDateTime.now());
baseMapper.insert(refundInfo);
log.info("[handleRefundCreated] 退款记录已创建refundId={}chargeId={}", refundId, refund.getCharge());
return refundInfo;
}
/**
* refund.updated (status=succeeded) 事件处理
* 找到该笔退款对应的 invoice从而修改 paymentInfo 表中 transactionId 为 invoiceId 的记录,将状态改为 refunded
*/
@Override
public RefundInfo handleRefundSucceeded(Refund refund) {
String refundId = refund.getId();
RefundInfo refundInfo = getByRefundId(refundId);
if (refundInfo == null) {
log.warn("[handleRefundSucceeded] 未找到退款记录先创建refundId={}", refundId);
refundInfo = handleRefundCreated(refund);
}
// 获取 charge 并从中提取 orderNo
String chargeId = refund.getCharge();
Charge charge = null;
if (!StringUtil.isNullOrEmpty(chargeId)) {
Stripe.apiKey = privateKey;
try {
charge = Charge.retrieve(chargeId);
String description = charge.getDescription();
String orderNo = description != null ? description.replace("AiDA - ", "") : null;
if (!StringUtil.isNullOrEmpty(orderNo) && !orderNo.equals(refundInfo.getOrderNo())) {
if (!"succeeded".equals(refundInfo.getRefundStatus())) {
refundInfo.setRefundStatus("succeeded");
}
refundInfo.setOrderNo(orderNo);
refundInfo.setUpdateTime(LocalDateTime.now());
baseMapper.updateById(refundInfo);
log.info("[handleRefundSucceeded] 从 charge 中提取并更新 orderNorefundId={}orderNo={}", refundId, orderNo);
}
} catch (StripeException e) {
log.error("[handleRefundSucceeded] 获取 charge 失败chargeId={}error={}", chargeId, e.getMessage(), e);
} }
} }
return null;
// 通过 charge 更新 paymentInfo 状态为 refunded
if (charge != null) {
paymentInfoService.updatePaymentRefundStatusByChargeId(charge, "Refunded");
}
// 如果是全额退款,执行后续业务逻辑
if (!StringUtil.isNullOrEmpty(refundInfo.getOrderNo())) {
handleRefundSuccess(refundInfo);
}
return refundInfo;
} }
/**
* refund.failed 事件处理
* 修改 t_refund_info 表状态,同时邮件通知商家
*/
@Override
public RefundInfo handleRefundFailed(Refund refund) {
String refundId = refund.getId();
RefundInfo refundInfo = getByRefundId(refundId);
if (refundInfo == null) {
log.warn("[handleRefundFailed] 未找到退款记录先创建refundId={}", refundId);
refundInfo = handleRefundCreated(refund);
}
if (!"failed".equals(refundInfo.getRefundStatus())) {
refundInfo.setRefundStatus("failed");
refundInfo.setUpdateTime(LocalDateTime.now());
baseMapper.updateById(refundInfo);
log.info("[handleRefundFailed] 退款状态已更新为 failedrefundId={}", refundId);
}
// 发送退款失败邮件通知商家
try {
String reason = refund.getFailureReason();
String orderNo = refundInfo.getOrderNo() != null ? refundInfo.getOrderNo() : "";
String amount = String.valueOf(refundInfo.getRefund());
// SendEmailUtil.sendRefundFailedNotification(refundId, reason, orderNo, amount);
log.info("[handleRefundFailed] 已发送退款失败通知邮件refundId={}", refundId);
} catch (Exception e) {
log.error("[handleRefundFailed] 发送退款失败通知邮件异常refundId={}error={}", refundId, e.getMessage(), e);
}
return refundInfo;
}
} }

View File

@@ -23,22 +23,18 @@ import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.stripe.Stripe; import com.stripe.Stripe;
import com.stripe.exception.InvalidRequestException; import com.stripe.exception.InvalidRequestException;
import com.stripe.exception.SignatureVerificationException;
import com.stripe.exception.StripeException; import com.stripe.exception.StripeException;
import com.stripe.model.*; import com.stripe.model.*;
import com.stripe.model.Product; import com.stripe.model.Product;
import com.stripe.model.checkout.Session; import com.stripe.model.checkout.Session;
import com.stripe.net.Webhook;
import com.stripe.param.*; import com.stripe.param.*;
import com.stripe.param.checkout.SessionCreateParams; import com.stripe.param.checkout.SessionCreateParams;
import io.netty.util.internal.StringUtil; import io.netty.util.internal.StringUtil;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Lazy;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -46,14 +42,16 @@ import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
/**
* Stripe 核心服务实现
*
* Stripe SDK 32.0.0 版本差异说明:
* - Subscription.getCurrentPeriodStart/End() 已移除,改用 subscription.getItems().getData().get(0).getCurrentPeriodStart/End()
*/
@SuppressWarnings("LoggingSimilarMessage") @SuppressWarnings("LoggingSimilarMessage")
@Service @Service
@Slf4j @Slf4j
@@ -62,14 +60,10 @@ public class StripeServiceImpl implements StripeService {
@Resource @Resource
private OrderInfoService orderInfoService; private OrderInfoService orderInfoService;
@Resource @Resource
private PayPalCheckoutService payPalCheckoutService;
@Resource
private PaymentInfoService paymentInfoService; private PaymentInfoService paymentInfoService;
@Resource @Resource
private CreditsService creditsService; private CreditsService creditsService;
@Resource @Resource
private RefundInfoService refundInfoService;
@Resource
private AccountService accountService; private AccountService accountService;
@Resource @Resource
private AccountMapper accountMapper; private AccountMapper accountMapper;
@@ -81,6 +75,8 @@ public class StripeServiceImpl implements StripeService {
private ProductCouponsMapper productCouponsMapper; private ProductCouponsMapper productCouponsMapper;
@Resource @Resource
private RedisUtil redisUtil; private RedisUtil redisUtil;
@Resource
private StripeWebhookService stripeWebhookService;
@Value("${stripe.private-key}") @Value("${stripe.private-key}")
private String privateKey; private String privateKey;
@@ -109,6 +105,7 @@ public class StripeServiceImpl implements StripeService {
productPurchaseDTO.setAutoRenewal(false); productPurchaseDTO.setAutoRenewal(false);
break; break;
case "Subscription": case "Subscription":
productPurchaseDTO.setAutoRenewal(true);
switch (productPurchaseDTO.getSubscribeType()){ switch (productPurchaseDTO.getSubscribeType()){
case "Month": case "Month":
productEnum = ProductEnum.MonthlySubscription; productEnum = ProductEnum.MonthlySubscription;
@@ -144,16 +141,13 @@ public class StripeServiceImpl implements StripeService {
} }
log.info("生成订单"); log.info("生成订单");
String payType; String payType;
byte autoRenewal;
if (productPurchaseDTO.getAutoRenewal()){ if (productPurchaseDTO.getAutoRenewal()){
payType = "recurring"; payType = "recurring";
autoRenewal = 1;
}else { }else {
payType = "one_time"; payType = "one_time";
autoRenewal = 0;
} }
OrderInfo orderInfo = orderInfoService.createOrderByProductId(productPurchaseDTO.getQuantity(), OrderInfo orderInfo = orderInfoService.createOrderByProductId(productPurchaseDTO.getQuantity(),
PayTypeEnum.STRIPE.getType(), productEnum, request, autoRenewal); PayTypeEnum.STRIPE.getType(), productEnum, request);
try { try {
Long id = UserContext.getUserHolder().getId(); Long id = UserContext.getUserHolder().getId();
@@ -172,10 +166,20 @@ public class StripeServiceImpl implements StripeService {
// Alipay - Not supported when using Checkout in subscription mode or setup mode. // Alipay - Not supported when using Checkout in subscription mode or setup mode.
if (payType.equals("recurring")){ if (payType.equals("recurring")){
sessionBuilder.setMode(SessionCreateParams.Mode.SUBSCRIPTION); sessionBuilder.setMode(SessionCreateParams.Mode.SUBSCRIPTION);
sessionBuilder.setSubscriptionData(SessionCreateParams.SubscriptionData.builder().setDescription("AiDA - " + orderId).build()); // Stripe SDK 32.0.0: 使用 SubscriptionData.setMetadata() 将 orderId 传递到 Subscription
// Stripe 会将该 metadata 自动传递给 Subscription 及其生成的 Invoice
sessionBuilder.setSubscriptionData(SessionCreateParams.SubscriptionData.builder()
.setDescription("AiDA - " + orderId)
.putMetadata("orderId", orderId)
.build());
}else { }else {
sessionBuilder.setMode(SessionCreateParams.Mode.PAYMENT); sessionBuilder.setMode(SessionCreateParams.Mode.PAYMENT);
sessionBuilder.setPaymentIntentData(SessionCreateParams.PaymentIntentData.builder().setDescription("AiDA - " + orderId).build()); // Stripe SDK 32.0.0: 使用 PaymentIntentData.setMetadata() 将 orderId 传递到 PaymentIntent
// 对于手动创建的 invoicemetadata 需要在 invoice 创建时单独设置
sessionBuilder.setPaymentIntentData(SessionCreateParams.PaymentIntentData.builder()
.setDescription("AiDA - " + orderId)
.putMetadata("orderId", orderId)
.build());
// one-time 手动创建发票;订阅会自动创建invoice // one-time 手动创建发票;订阅会自动创建invoice
sessionBuilder.setInvoiceCreation(SessionCreateParams.InvoiceCreation.builder().setEnabled(Boolean.TRUE).build()); sessionBuilder.setInvoiceCreation(SessionCreateParams.InvoiceCreation.builder().setEnabled(Boolean.TRUE).build());
} }
@@ -189,7 +193,8 @@ public class StripeServiceImpl implements StripeService {
.setQuantity((long) productPurchaseDTO.getQuantity()) .setQuantity((long) productPurchaseDTO.getQuantity())
.setPrice(priceId) .setPrice(priceId)
.build()); .build());
sessionBuilder.putMetadata("orderId", orderId); //通过订单号关联用于检索支付信息(可选) // 将 orderId 写入 metadataStripe Checkout 会自动传递给关联的 PaymentIntent/Subscription
sessionBuilder.putMetadata("orderId", orderId);
Session session = Session.create(sessionBuilder.build()); Session session = Session.create(sessionBuilder.build());
List<String> paymentMethodTypes = session.getPaymentMethodTypes(); List<String> paymentMethodTypes = session.getPaymentMethodTypes();
@@ -276,422 +281,9 @@ public class StripeServiceImpl implements StripeService {
return Price.create(priceCreateParams.build()); return Price.create(priceCreateParams.build());
} }
@Resource
private EmailService emailService;
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Boolean notify(HttpServletRequest request) { public Boolean notify(HttpServletRequest request) {
log.info("stripe异步通知进行中"); return stripeWebhookService.notify(request);
String payload = null;
String sigHeader = null;
String endpointSecret = signSecret;
try {
sigHeader = request.getHeader("Stripe-Signature");
payload = payPalCheckoutService.getBody(request);
} catch (Exception e) {
log.info("stripe 支付回调参数解析异常errorMsg {}", e.getMessage());
log.info("request sigHeader = {}", sigHeader);
log.info("request body = {}", JSON.toJSONString(payload));
e.printStackTrace();
return Boolean.FALSE;
}
Event event;
try {
assert sigHeader != null;
event = Webhook.constructEvent(payload, sigHeader, endpointSecret);
} catch (SignatureVerificationException e) {
log.info("stripe 验签,获取事件异常, errorMsg={}", e.getMessage());
log.info("request sigHeader = {}", sigHeader);
log.info("request body = {}", JSON.toJSONString(payload));
e.printStackTrace();
return Boolean.FALSE;
}
//获取自定义参数
// Deserialize the nested object inside the event
assert event != null;
EventDataObjectDeserializer dataObjectDeserializer = event.getDataObjectDeserializer();
StripeObject stripeObject ;
if (dataObjectDeserializer.getObject().isPresent()) {
stripeObject = dataObjectDeserializer.getObject().get();
} else {
log.info("stripe 验签失败!");
log.info("request sigHeader = {}", sigHeader);
log.info("request body = {}", JSON.toJSONString(payload));
return Boolean.FALSE;
}
log.info("stripe验签成功");
boolean response = Boolean.TRUE;
log.info("回调事件 {}", event.getType());
if (stripeObject instanceof Session){
Session session = (Session) stripeObject;
if (event.getType().equals("checkout.session.completed")) {
response = processOrder(session);
}else if (event.getType().equals("checkout.session.expired")){
String orderNo = session.getMetadata().get("orderId");
// 会话过期 未支付 且之后没有支付成功的订单
response = processExpiredOrder(orderNo);
}
} else if (stripeObject instanceof Subscription){
Subscription subscription = (Subscription) stripeObject;
if (event.getType().equals("customer.subscription.created")){
// 添加数据到t_subscription_info表 需记录订阅id。需要判断订阅的状态是否active吗 ??
createSubscription(subscription);
log.info("创建连续订阅");
} else if (event.getType().equals("customer.subscription.updated")){
// 更新订阅信息
SubscriptionInfo subscriptionInfo = updateSubscription(subscription);
log.info("订阅更新");
if (subscription.getStatus().equals("active")){
response = sendEmail(subscription.getId(), null, null);
}
// 续订支付失败,邮件通知用户
if (subscription.getStatus().equals("past_due")){
// 发送续订失败邮件
response = sendRenewalFailEmail(null, subscription.getId(), subscriptionInfo.getOrderNo());
}
} else if (event.getType().equals("customer.subscription.deleted")){
SubscriptionInfo subscriptionInfo = updateSubscription(subscription);
if (Objects.isNull(subscriptionInfo)){
return true;
}
log.info("用户 {} 取消连续订阅 {}", subscriptionInfo.getAccountId(), subscription.getId());
if (subscriptionInfo.getCancelNotified() == (byte)0){
log.info("取消订阅 邮件通知商家");
response = sendEmail(subscription.getId(), "cancel", null);
if (response){
subscriptionInfo.setCancelNotified((byte)1);
subscriptionInfoMapper.updateById(subscriptionInfo);
// 更新订单信息
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(subscriptionInfo.getOrderNo());
orderInfo.setAutoRenewal((byte)0);
}
}
}/* else if (event.getType().equals("customer.subscription.paused")){
updateSubscription(subscription);
} else if (event.getType().equals("customer.subscription.resumed")){
updateSubscription(subscription);
log.info("用户订阅恢复");
}*/
} else if (stripeObject instanceof Invoice) {
Invoice invoice = (Invoice) stripeObject;
if (event.getType().equals("invoice.paid")) {
// 新增支付成功的信息,返回orderNo表示该回调第一次被记录
PaymentInfo paymentInfo = paymentInfoService.createOrUpdatePaymentInfoForStripe(invoice);
/* 在sendEmail方法中有做判断这里的判断取消
// 当前支付没有被通知时才需要发送通知邮件
if (paymentInfo.getNotified().equals(0)) {
}*/
// 更新t_order_info中的total_fee,记录该订单的累计付款金额
orderInfoService.updateTotalFeeByOrderNo(paymentInfo.getOrderNo());
// 邮件通知商家和用户
String billingReason = invoice.getBillingReason();
switch (billingReason) {
case "subscription_create":
response = sendEmail(invoice.getSubscription(), "new", null);
break;
case "subscription_cycle":
response = sendEmail(invoice.getSubscription(), "renewal", null);
break;
case "manual":
boolean b = invoice.getLines().getData().get(0).getDescription().endsWith("Subscription");
if (b) {
// 非自动续订式订阅Stripe不会创建Subscription,所以invoice中不会有subscriptionId
response = sendEmail(null, "new", paymentInfo.getOrderNo());
}
break;
}
} else if (event.getType().equals("invoice.payment_failed")) {
// 更新支付信息
QueryWrapper<PaymentInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("transaction_id", invoice.getId());
PaymentInfo paymentInfo = paymentInfoService.getBaseMapper().selectOne(queryWrapper);
if (!Objects.isNull(paymentInfo)){
String type = invoice.getBillingReason().equals("subscription_create") ? "new" :
invoice.getBillingReason().equals("subscription_cycle") ? "renewal" : invoice.getBillingReason();
Gson gson = new Gson();
String json = gson.toJson(invoice);
paymentInfo.setContent(json);
paymentInfo.setType(type);
paymentInfo.setHostedInvoiceUrl(invoice.getHostedInvoiceUrl());
paymentInfoService.updateById(paymentInfo);
// 发送续订失败邮件
response = sendRenewalFailEmail(invoice.getId(), null, paymentInfo.getOrderNo());
}else {
// 新增支付信息
PaymentInfo paymentInfoFail = paymentInfoService.createOrUpdatePaymentInfoForStripe(invoice);
// 发送新订阅失败邮件
response = sendEmail(paymentInfoFail.getOrderNo());
}
}
}else if (stripeObject instanceof Charge) {
Charge charge = (Charge) stripeObject;
String orderNo = charge.getDescription().replace("AiDA - ", "");
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
if (Objects.isNull(orderInfo)){
// 说明该回调不是从AiDA订阅获得
return true;
}
if (event.getType().equals("charge.failed")){
// 添加支付信息 && 更新支付信息
// 支付失败时无法通过invoice_id获取支付方式所以使用charge.failed回调添加支付信息
paymentInfoService.createOrUpdatePaymentInfoForStripe(charge);
orderInfo.setOrderStatus(OrderStatusEnum.FAILURE.getType());
orderInfo.setNote(charge.getFailureMessage());
orderInfoService.updateById(orderInfo);
}else if (event.getType().equals("charge.succeeded")){
orderInfo.setOrderStatus(OrderStatusEnum.SUCCESS.getType());
orderInfo.setNote("");
orderInfoService.updateById(orderInfo);
}else if (event.getType().equals("charge.refunded")){
// 更新退款信息
RefundInfo refundInfo = refundInfoService.updateRefundForStripe(charge);
// 更新 t_payment_info的支付状态
if (Objects.nonNull(refundInfo)){
paymentInfoService.updatePaymentRefundStatus(charge);
}
}
}else if (stripeObject instanceof Refund){
Refund refund = (Refund) stripeObject;
if (event.getType().equals("refund.created")){
// 新增退款信息
refundInfoService.createRefundForStripe(refund);
}else if (event.getType().equals("refund.updated")){
// 根据***id更新退款记录信息
RefundInfo refundInfo = refundInfoService.updateRefundStatusForStripe(refund);
if (Objects.isNull(refundInfo)){
// 等事件先创建,再更新。回调事件的顺序随机
response = false;
}
}
}
log.info("回调事件 {} 处理完成", event.getType());
return response;
}
public boolean processOrder(Session session) {
Stripe.apiKey = privateKey;
String orderNo = session.getMetadata().get("orderId");
float totalAmount = new BigDecimal(session.getAmountTotal()).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).floatValue();
boolean resp = true;
try {
//处理重复通知
//接口调用的幂等性:无论接口被调用多少次,以下业务执行一次
// String orderStatus = orderInfoService.getOrderStatus(orderNo);
OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(orderNo);
String orderStatus = orderByOrderNo.getOrderStatus();
// 当订单状态处于未支付或超时已关闭时,更新订单状态,其他状态均不更新订单状态
if (!OrderStatusEnum.NOT_PAY.getType().equals(orderStatus) && !OrderStatusEnum.TIMEOUT_CLOSED.getType().equals(orderStatus)) {
log.info("订单状态 : {}", orderStatus);
}else {
//更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatusEnum.SUCCESS);
log.info("Stripe 订单:{} 状态更新成功", orderNo);
}
if (orderByOrderNo.getTitle().startsWith("积分购买")){
// 查询当前订单的积分是否已添加
CreditsDetail creditsDetail = creditsService.queryDetailByTaskId(orderNo);
if (Objects.isNull(creditsDetail)){
float quantity = totalAmount / ProductEnum.CreditsProduct.getPrice();
// 更新积分
creditsService.buyCredits(orderByOrderNo.getAccountId(), quantity);
// 添加积分变更记录
creditsService.insertToCreditsDetail(orderByOrderNo.getAccountId(),
CreditsEventsEnum.BUY_CREDITS.getName() + "--Stripe",
String.valueOf((Long.parseLong(CreditsEventsEnum.BUY_CREDITS.getValue()) * quantity)),
"positive", orderNo);
log.info("用户:{} 积分信息更新成功", orderByOrderNo.getAccountId());
}
}else if (orderByOrderNo.getTitle().endsWith("Subscription") && orderByOrderNo.getAutoRenewal() == (byte)0){
String invoiceId = session.getInvoice();
Invoice invoice = Invoice.retrieve(invoiceId);
InvoiceLineItem invoiceLineItem = invoice.getLines().getData().get(0);
String description = invoiceLineItem.getDescription();
Long amount = invoiceLineItem.getAmount();
log.info("单次订阅 description : {}, amount: {} 分", description, amount);
boolean b = createSubscriptionAndUpdateAccount(orderNo, orderByOrderNo.getAccountId(), description, amount);
// 邮件通知用户
if (b){
resp = sendEmail(null, "new", orderNo);
}
log.info("单次订阅订单:{} 处理完成", orderNo);
}
} catch (Exception e) {
log.info(e.getMessage());
resp = false;
}
return resp;
}
private boolean processExpiredOrder(String orderNo) {
// 支付失败 通知商家的条件 1、会话过期 2、支付失败 3、这个用户在这个支付失败后再无支付成功的订阅
// 1、获取当前订单的支付状态
// String orderNo = session.getMetadata().get("orderId");
OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(orderNo);
// 2、确认订单状态为支付失败
boolean resp = true;
if (!Objects.isNull(orderByOrderNo) && orderByOrderNo.getOrderStatus().equals(OrderStatusEnum.FAILURE.getType())) {
// 3、判断失败订单之后再无成功的订单
QueryWrapper<OrderInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("account_id", orderByOrderNo.getAccountId());
queryWrapper.gt("create_time", orderByOrderNo.getCreateTime());
queryWrapper.eq("order_status", OrderStatusEnum.SUCCESS.getType());
queryWrapper.likeLeft("title", "Subscription");
List<OrderInfo> orderInfos = orderInfoService.getBaseMapper().selectList(queryWrapper);
if (orderInfos.isEmpty()) {
// 4、判断当前订单有没有订阅信息
QueryWrapper<SubscriptionInfo> qw = new QueryWrapper<>();
qw.eq("order_no", orderNo);
SubscriptionInfo subscriptionInfo = subscriptionInfoMapper.selectOne(qw);
// 发送邮件通知商家用户支付失败
if (Objects.isNull(subscriptionInfo)
|| subscriptionInfo.getStatus().equals("incomplete")
|| subscriptionInfo.getStatus().equals("incomplete_expired")) {
resp = sendEmail(orderNo);
}else {
// todo 续订失败 应该不会走这里
resp = sendEmail(subscriptionInfo.getSubscriptionId(), "fail_renewal", null);
}
}
}
return resp;
}
@Transactional(rollbackFor = Exception.class)
public SubscriptionInfo createSubscription(Subscription subscription){
// 确认当前subscription是否已经记录
SubscriptionInfo subscriptionInfo = getSubscriptionInfoBySubId(subscription.getId());
// SubscriptionInfo subscriptionInfo = subscriptionInfoMapper.selectOne(qw);
if (Objects.isNull(subscriptionInfo)) {
String description = subscription.getDescription();
String orderNo = description.replace("AiDA - ", "");
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
if (Objects.isNull(orderInfo)){
log.warn("未知订阅:{}", subscription.getId());
return null;
}
// 从回调信息中获取recurring type
SubscriptionItem subscriptionItem = subscription.getItems().getData().get(0);
String interval = subscriptionItem.getPrice().getRecurring().getInterval();
subscriptionInfo = new SubscriptionInfo();
subscriptionInfo.setAccountId(orderInfo.getAccountId());
subscriptionInfo.setOrderNo(orderNo);
subscriptionInfo.setSubscriptionId(subscription.getId());
subscriptionInfo.setType(interval);
subscriptionInfo.setStatus(subscription.getStatus());
subscriptionInfo.setNextPayDate(DateUtil.changeTimeStampFormat(subscription.getCurrentPeriodEnd(), "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy_EEEE));
subscriptionInfo.setCurrentPeriodStart(subscription.getCurrentPeriodStart());
subscriptionInfo.setCurrentPeriodEnd(subscription.getCurrentPeriodEnd());
subscriptionInfo.setCreateTime(LocalDateTime.now());
int rows = subscriptionInfoMapper.insertIgnore(subscriptionInfo);
log.info("Subscription info insert affect rows : {}", rows);
if (subscriptionInfo.getStatus().equals("active")){
log.info("创建订阅更新账号信息");
// 更新账号到期时间
boolean b = accountService.updateAccountValidity(subscriptionInfo.getAccountId(), subscriptionInfo.getCurrentPeriodEnd());
// 更新账号身份和积分
if (b) accountService.updateUserRoleAndCredits(subscriptionInfo.getAccountId(), orderNo);
}
}
return subscriptionInfo;
}
/**
* 非自动续订订阅
* Stripe不会自动创建Subscription,所以没有subscription相关的回调无法触发订阅相关的处理代码
*/
public boolean createSubscriptionAndUpdateAccount(String orderNo, Long accountId, String description, Long amount){
QueryWrapper<SubscriptionInfo> qw = new QueryWrapper<>();
qw.eq("order_no", orderNo);
SubscriptionInfo subscriptionInfo = subscriptionInfoMapper.selectOne(qw);
if (Objects.isNull(subscriptionInfo)) {
String interval;
// 获取当前时间戳(秒级)
long currentPeriodStart = Instant.now().getEpochSecond();;
long currentPeriodEnd;
// InvoiceLineItem invoiceLineItem = invoice.getLines().getData().get(0);
if (description.equals(ProductEnum.DailySubscription.getName())
&& amount.equals(ProductEnum.DailySubscription.getPrice() * 100)){
interval = "day";
// 获取一天后的时间戳(秒级)
ZonedDateTime now = ZonedDateTime.now();
currentPeriodEnd = now.plusDays(1).toEpochSecond();
}else if (description.equals(ProductEnum.MonthlySubscription.getName())
&& amount.equals(ProductEnum.MonthlySubscription.getPrice() * 100)){
interval = "month";
// 获取一天后的时间戳(秒级)
ZonedDateTime now = ZonedDateTime.now();
currentPeriodEnd = now.plusMonths(1).toEpochSecond();
} else if (description.equals(ProductEnum.Eco_MonthlySubscription.getName())
&& amount.equals(ProductEnum.Eco_MonthlySubscription.getPrice() * 100)){
interval = "month";
// 获取一天后的时间戳(秒级)
ZonedDateTime now = ZonedDateTime.now();
currentPeriodEnd = now.plusMonths(1).toEpochSecond();
} else if (description.equals(ProductEnum.AnnualSubscription.getName())
&& amount.equals(ProductEnum.AnnualSubscription.getPrice() * 100)){
interval = "year";
// 获取一天后的时间戳(秒级)
ZonedDateTime now = ZonedDateTime.now();
currentPeriodEnd = now.plusYears(1).toEpochSecond();
}else {
log.error("未知订阅类型");
return false;
}
subscriptionInfo = new SubscriptionInfo();
subscriptionInfo.setAccountId(accountId);
subscriptionInfo.setOrderNo(orderNo);
subscriptionInfo.setType(interval);
subscriptionInfo.setStatus("canceled");
subscriptionInfo.setCurrentPeriodStart(currentPeriodStart);
subscriptionInfo.setCurrentPeriodEnd(currentPeriodEnd);
subscriptionInfo.setCreateTime(LocalDateTime.now());
subscriptionInfoMapper.insertIgnore(subscriptionInfo);
log.info("创建订阅, 更新账号信息");
// 更新账号到期时间
boolean b = accountService.updateAccountValidity(subscriptionInfo.getAccountId(), subscriptionInfo.getCurrentPeriodEnd());
// 更新账号身份和积分
if (b) accountService.updateUserRoleAndCredits(subscriptionInfo.getAccountId(), orderNo);
return true;
}
return true;
}
public SubscriptionInfo getSubscriptionInfoBySubId(String subId){
QueryWrapper<SubscriptionInfo> qw = new QueryWrapper<>();
qw.eq("subscription_id", subId);
List<SubscriptionInfo> subscriptionInfos = subscriptionInfoMapper.selectList(qw);
if (subscriptionInfos.size() == 1){
return subscriptionInfos.get(0);
}else if (subscriptionInfos.size() > 1) {
// 如果新建了多个订阅则筛选出状态为active的订单
Optional<SubscriptionInfo> activeSubscriptionInfo = subscriptionInfos.stream()
.filter(sub -> sub.getStatus().equals("active"))
.findFirst();
return activeSubscriptionInfo.orElseGet(() -> subscriptionInfos.get(0));
}else {
return null;
}
} }
public SubscriptionInfo getLatestSubscriptionInfoByAccountId(Long accountId){ public SubscriptionInfo getLatestSubscriptionInfoByAccountId(Long accountId){
@@ -705,67 +297,6 @@ public class StripeServiceImpl implements StripeService {
} }
} }
@Transactional(rollbackFor = Exception.class)
public SubscriptionInfo updateSubscription(Subscription subscription){
// 获取当前是否有已经记录的subscriptionInfo
SubscriptionInfo subscriptionInfo = createSubscription(subscription);
// 用于标志数据有没有变动,避免在没有改动的情况下频繁的更新数据库
boolean flag = false;
if (!subscriptionInfo.getStatus().equals(subscription.getStatus())){
subscriptionInfo.setStatus(subscription.getStatus());
flag = true;
}
if (!subscriptionInfo.getCurrentPeriodStart().equals(subscription.getCurrentPeriodStart())){
subscriptionInfo.setCurrentPeriodStart(subscription.getCurrentPeriodStart());
flag = true;
}
if (!subscriptionInfo.getCurrentPeriodEnd().equals(subscription.getCurrentPeriodEnd())){
subscriptionInfo.setCurrentPeriodEnd(subscription.getCurrentPeriodEnd());
subscriptionInfo.setNextPayDate(DateUtil.changeTimeStampFormat(subscription.getCurrentPeriodEnd(), "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy_EEEE));
log.info("更新订阅更新账号信息");
// 更新账号到期时间
accountService.updateAccountValidity(subscriptionInfo.getAccountId(), subscriptionInfo.getCurrentPeriodEnd());
// 更新账号身份和积分
accountService.updateUserRoleAndCredits(subscriptionInfo.getAccountId(), subscriptionInfo.getOrderNo());
log.info("更新 {} 账号到期时间为:{}", subscriptionInfo.getAccountId(), DateUtil.changeTimeStampFormat(subscriptionInfo.getCurrentPeriodEnd(), "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy_EEEE));
flag = true;
}
if (subscriptionInfo.getStatus().equals("active")){
// 更新账号到期时间
boolean b = accountService.updateAccountValidity(subscriptionInfo.getAccountId(), subscriptionInfo.getCurrentPeriodEnd());
// 更新账号身份和积分
if (b) accountService.updateUserRoleAndCredits(subscriptionInfo.getAccountId(), subscriptionInfo.getOrderNo());
}
if (flag){
subscriptionInfo.setUpdateTime(LocalDateTime.now());
subscriptionInfoMapper.updateById(subscriptionInfo);
}
return subscriptionInfo;
}
// 取消连续订阅 将订阅从pause状态转为cancel状态使用定时器定期检索DB中过期且不续订的订阅
public void cancelSubscription(String subscriptionId, String cancelReason) {
Stripe.apiKey = privateKey;
log.info("cancel subscription");
Long accountId = UserContext.getUserHolder().getId();
com.ai.da.mapper.primary.entity.Account account = accountMapper.selectById(accountId);
List<Subscription> subscriptions = getSubscription(account.getUserName(), account.getUserEmail());
// 获取status = active的订阅
subscriptions.forEach(subscription -> {
if (subscription.getId().equals(subscriptionId)) {
try {
Subscription cancel = subscription.cancel();
cancel.getStatus();
log.info("用户 {} 申请取消连续订阅 {}", accountId, subscriptionId);
// 更新数据库
updateCancelReason(subscriptionId, cancelReason);
} catch (StripeException e) {
log.error("订阅 {} 取消失败, error message : {}", subscription.getId(), e.getMessage());
}
}
});
}
public void cancelSubscriptionTemp(String subscriptionId) { public void cancelSubscriptionTemp(String subscriptionId) {
Stripe.apiKey = privateKey; Stripe.apiKey = privateKey;
try { try {
@@ -779,101 +310,39 @@ public class StripeServiceImpl implements StripeService {
} }
} }
public String refund(String amount, String orderNo, String reason) {
Refund refund;
RefundInfo refundByOrderNo = refundInfoService.createRefundByOrderNo(orderNo, reason);
try {
Stripe.apiKey = privateKey;
// todo transactionId不再是sessionId而是invoiceId所以这里需要更新
// 根据orderId找到对应的sessionId
String sessionId = paymentInfoService.getPaymentInfoByOrderNo(orderNo, "DESC").get(0).getTransactionId();
if (StringUtils.isNotEmpty(sessionId)) { //根据会话编号退款
Session session = Session.retrieve(sessionId);
RefundCreateParams params;
if (amount != null && !amount.equals("0")) { //指定退款金额
BigDecimal actualAmount = new BigDecimal(amount).multiply(BigDecimal.valueOf(100)); //api默认单位分
params = RefundCreateParams.builder()
.setPaymentIntent(session.getPaymentIntent())
.setAmount(actualAmount.longValue())
.build();
} else { //全额退款
params = RefundCreateParams.builder()
.setPaymentIntent(session.getPaymentIntent())
.build();
}
refund = Refund.create(params);
log.info("根据会话编号退款成功");
} else {
log.error("当前订单不存在");
return "退款异常";
}
} catch (Exception e) {
//e.getMessage.contain("charge_already_refunded") 已退款
//e.getMessage.contain("resource_missing") 退款编号错误
//e.getMessage.contain("amount on charge ($n)") 金额应小于n
log.error("退款异常:", e);
return "退款异常";
}
if ("succeeded".equals(refund.getStatus())) {
//进行数据库操作,修改状态为已退款(配合回调和退款查询确定退款成功)
//更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatusEnum.REFUND_SUCCESS);
refundInfoService.updateRefundForPayPal(
refundByOrderNo.getId(),
refund.getId(),
new Gson().toJson(refund),
AliPayTradeStateEnum.REFUND_SUCCESS.getType()); //退款成功
// 更新积分状态
OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(orderNo);
creditsService.creditsRefund(orderByOrderNo.getAccountId(), (int) (orderByOrderNo.getTotalFee() / Float.parseFloat(CreditsEventsEnum.PRICE.getValue())), orderNo);
} else {
//更新订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatusEnum.REFUND_ABNORMAL);
//更新退款单
refundInfoService.updateRefundForPayPal(
refundByOrderNo.getId(),
refund.getId(),
new Gson().toJson(refund),
AliPayTradeStateEnum.REFUND_ERROR.getType()); //退款失败
}
log.info("记录退款订单");
return "退款成功";
}
public void checkOrderStatus(String orderNo) { public void checkOrderStatus(String orderNo) {
Stripe.apiKey = privateKey; Stripe.apiKey = privateKey;
// 1、通过orderNo 查询sessionId List<PaymentInfo> paymentInfos = paymentInfoService.getPaymentInfoByOrderNo(orderNo, "DESC");
// todo transactionId不再是sessionId而是invoiceId所以这里需要更新 if (paymentInfos == null || paymentInfos.isEmpty()) {
PaymentInfo paymentInfo = paymentInfoService.getPaymentInfoByOrderNo(orderNo, "DESC").get(0); log.warn("核实订单未找到 ===> {}", orderNo);
return;
}
PaymentInfo paymentInfo = paymentInfos.get(0);
String transactionId = paymentInfo.getTransactionId();
if (transactionId == null) {
log.warn("核实订单 transactionId 为空 ===> {}", orderNo);
return;
}
try { try {
Session session = Session.retrieve(paymentInfo.getTransactionId()); Session session = Session.retrieve(transactionId);
if (Objects.isNull(session)) { String status = session.getStatus();
log.warn("核实订单未创建 ===> {}", orderNo); if ("open".equals(status) || "expired".equals(status)) {
return;
} else if (session.getStatus().equals("open") || session.getStatus().equals("expired")) {
// 订单未支付 || 订单过期 ---> 均设置为超时未支付
log.info("订单超时未支付 ===> {}", orderNo); log.info("订单超时未支付 ===> {}", orderNo);
//更新本地订单状态
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatusEnum.TIMEOUT_CLOSED); orderInfoService.updateStatusByOrderNo(orderNo, OrderStatusEnum.TIMEOUT_CLOSED);
paymentInfoService.updatePaymentStatusById(paymentInfo.getId(), paymentInfoService.updatePaymentStatusById(paymentInfo.getId(),
session.getStatus(), status, new Gson().toJson(session));
new Gson().toJson(session)); } else if ("complete".equals(status)) {
} else if (session.getStatus().equals("complete")) { // 订单已完成,通过 Checkout 事件处理(积分/订阅)已在 checkout.session.completed 中处理
// 订单已完成 // 此处仅确保本地订单状态一致
processOrder(session); String currentStatus = orderInfoService.getOrderByOrderNo(orderNo).getOrderStatus();
if (!OrderStatusEnum.SUCCESS.getType().equals(currentStatus)) {
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatusEnum.SUCCESS);
}
} }
} catch (StripeException e) { } catch (StripeException e) {
log.error("根据sessionId获取Stripe Session失败"); // transactionId 可能是 invoiceIdPayment Mode此时无法用 sessionId 查询
throw new RuntimeException(e); log.warn("根据 transactionId={} 查询 Stripe Session 失败,可能为 invoiceIderror={}", transactionId, e.getMessage());
} }
} }
public List<Subscription> getSubscription(String username, String userEmail) { public List<Subscription> getSubscription(String username, String userEmail) {
@@ -933,84 +402,6 @@ public class StripeServiceImpl implements StripeService {
return customer.getId(); return customer.getId();
} }
/**
* 使用连续订阅的订单回调中没有paymentIntentId,所以通过invoiceId间接获取
* @param invoiceId 发票Id
*/
public Map<String, String> getPaymentMethodByInvoiceId(String invoiceId) {
try {
Stripe.apiKey = privateKey;
Invoice invoice = Invoice.retrieve(invoiceId);
if (!StringUtil.isNullOrEmpty(invoice.getPaymentIntent())){
PaymentIntent paymentIntent = PaymentIntent.retrieve(invoice.getPaymentIntent());
if (!StringUtil.isNullOrEmpty(paymentIntent.getPaymentMethod())){
PaymentMethod paymentMethod = PaymentMethod.retrieve(paymentIntent.getPaymentMethod());
return getPaymentMethod(paymentMethod.getId());
}
}
HashMap<String, String> resp = new HashMap<>();
resp.put("paymentMethod", "N/A");
resp.put("last4", "N/A");
return resp;
} catch (StripeException e) {
throw new RuntimeException(e);
}
}
public Map<String, String> getPaymentMethod(String paymentMethodId){
Stripe.apiKey = privateKey;
String paymentMethod = null;
String last4 = null;
try {
PaymentMethod retrieve = PaymentMethod.retrieve(paymentMethodId);
switch (retrieve.getType()){
case "alipay":
paymentMethod = "Alipay";
last4 = "N/A";
break;
case "bancontact":
paymentMethod = "BanContact";
break;
case "card":
PaymentMethod.Card card = retrieve.getCard();
String brand = card.getBrand();
brand = brand.substring(0, 1).toUpperCase() + brand.substring(1);
paymentMethod = brand + " " + card.getFunding() + "card";
last4 = card.getLast4();
break;
case "eps":
PaymentMethod.Eps eps = retrieve.getEps();
paymentMethod = eps.getBank();
last4 = "N/A";
break;
case "giropay":
paymentMethod = "GiroPay";
last4 = "N/A";
break;
case "ideal":
PaymentMethod.Ideal ideal = retrieve.getIdeal();
paymentMethod = ideal.getBank();
last4 = "N/A";
break;
case "link":
paymentMethod = "Link";
last4 = "N/A";
break;
default:
paymentMethod = "N/A";
last4 = "N/A";
}
HashMap<String, String> resp = new HashMap<>();
resp.put("paymentMethod", paymentMethod);
resp.put("last4", last4);
return resp;
} catch (StripeException e) {
throw new RuntimeException(e);
}
// return null;
}
public boolean sendEmail(String subscriptionId, String type, String orderNo) { public boolean sendEmail(String subscriptionId, String type, String orderNo) {
SubscriptionInfo subscriptionInfo; SubscriptionInfo subscriptionInfo;
long secondsTimestamp = System.currentTimeMillis() / 1000; long secondsTimestamp = System.currentTimeMillis() / 1000;
@@ -1060,11 +451,12 @@ public class StripeServiceImpl implements StripeService {
String periodEnd = DateUtil.changeTimeStampFormat(subscriptionInfo.getCurrentPeriodEnd(), "seconds", CommonConstant.TIME_FORMAT_yyyy_MM_dd_HH_mm_ss); String periodEnd = DateUtil.changeTimeStampFormat(subscriptionInfo.getCurrentPeriodEnd(), "seconds", CommonConstant.TIME_FORMAT_yyyy_MM_dd_HH_mm_ss);
qwPI.lambda().eq(PaymentInfo::getOrderNo, subscriptionInfo.getOrderNo()) qwPI.lambda().eq(PaymentInfo::getOrderNo, subscriptionInfo.getOrderNo())
.eq(PaymentInfo::getTradeState, "paid")
.between(PaymentInfo::getCreateTime, periodStart, periodEnd) .between(PaymentInfo::getCreateTime, periodStart, periodEnd)
.orderByDesc(PaymentInfo::getId); .orderByDesc(PaymentInfo::getId);
List<PaymentInfo> paymentInfos = paymentInfoMapper.selectList(qwPI); List<PaymentInfo> paymentInfos = paymentInfoMapper.selectList(qwPI);
if (paymentInfos.isEmpty()) { if (paymentInfos.isEmpty()) {
log.info("不发送邮件原因【根据order_no:{},查询到的paymentInfos为空】", orderNo); log.info("不发送邮件原因【根据order_no:{},查询到的成功的paymentInfos为空】", orderNo);
return false; return false;
} }
PaymentInfo paymentInfo = paymentInfos.get(0); PaymentInfo paymentInfo = paymentInfos.get(0);
@@ -1094,7 +486,8 @@ public class StripeServiceImpl implements StripeService {
emailParamsDTO.setEmail(account.getUserEmail()); emailParamsDTO.setEmail(account.getUserEmail());
emailParamsDTO.setCountry(paymentInfo.getCountry()); emailParamsDTO.setCountry(paymentInfo.getCountry());
emailParamsDTO.setOrderId(paymentInfo.getId().toString()); emailParamsDTO.setOrderId(paymentInfo.getId().toString());
emailParamsDTO.setOrderRef("\"" + orderListLink + paymentInfo.getId().toString() + "\""); // emailParamsDTO.setOrderRef("\"" + orderListLink + paymentInfo.getId().toString() + "\"");
emailParamsDTO.setOrderRef("\"" + paymentInfo.getHostedInvoiceUrl() + "\"");
emailParamsDTO.setCreateDate(String.valueOf(paymentInfo.getCreateTime()).replace("T", " ")); emailParamsDTO.setCreateDate(String.valueOf(paymentInfo.getCreateTime()).replace("T", " "));
emailParamsDTO.setQuantity(String.valueOf(1)); emailParamsDTO.setQuantity(String.valueOf(1));
emailParamsDTO.setTotalFee(paymentInfo.getPayerTotal().toString()); emailParamsDTO.setTotalFee(paymentInfo.getPayerTotal().toString());
@@ -1172,7 +565,7 @@ public class StripeServiceImpl implements StripeService {
return true; return true;
} }
public boolean sendRenewalFailEmail(String invoiceId, String subscriptionId, String orderNo){ /*public boolean sendRenewalFailEmail(String invoiceId, String subscriptionId, String orderNo){
// 1、确认当前订单最后一笔支付为fail // 1、确认当前订单最后一笔支付为fail
// 更新支付信息 // 更新支付信息
PaymentInfo paymentInfo; PaymentInfo paymentInfo;
@@ -1232,7 +625,7 @@ public class StripeServiceImpl implements StripeService {
payment.setUpdateTime(LocalDateTime.now()); payment.setUpdateTime(LocalDateTime.now());
paymentInfoMapper.updateById(payment); paymentInfoMapper.updateById(payment);
return true; return true;
} }*/
private void setSubscriptionParams(PaymentInfo paymentInfo, SubscriptionInfo subscriptionInfo, OrderInfo orderByOrderNo, private void setSubscriptionParams(PaymentInfo paymentInfo, SubscriptionInfo subscriptionInfo, OrderInfo orderByOrderNo,
SubscriptionEmailParamsDTO emailParamsDTO, String language) { SubscriptionEmailParamsDTO emailParamsDTO, String language) {
@@ -1302,7 +695,7 @@ public class StripeServiceImpl implements StripeService {
} }
}*/ }*/
public void checkSubscriptionExpiration(){ /*public void checkSubscriptionExpiration(){
long epochSecond = Instant.now().getEpochSecond(); long epochSecond = Instant.now().getEpochSecond();
QueryWrapper<SubscriptionInfo> qw = new QueryWrapper<>(); QueryWrapper<SubscriptionInfo> qw = new QueryWrapper<>();
qw.lt("current_period_end", epochSecond); qw.lt("current_period_end", epochSecond);
@@ -1315,13 +708,13 @@ public class StripeServiceImpl implements StripeService {
subscriptionInfoMapper.updateById(subscriptionInfo); subscriptionInfoMapper.updateById(subscriptionInfo);
log.info("用户 {} 的订阅 {} 已过期", subscriptionInfo.getAccountId(), subscriptionInfo.getOrderNo()); log.info("用户 {} 的订阅 {} 已过期", subscriptionInfo.getAccountId(), subscriptionInfo.getOrderNo());
} }
} }*/
// 新建一个订阅 使用不会成功的付款方式(仅供测试使用) // 新建一个订阅 使用不会成功的付款方式(仅供测试使用)
public String createSubscriptionTemp(String name, String email){ public String createSubscriptionTemp(String name, String email){
Stripe.apiKey = privateKey; Stripe.apiKey = privateKey;
try { try {
OrderInfo orderInfo = orderInfoService.createOrderByProductId(1, PayTypeEnum.STRIPE.getType(), ProductEnum.DailySubscription, null, (byte)0); OrderInfo orderInfo = orderInfoService.createOrderByProductId(1, PayTypeEnum.STRIPE.getType(), ProductEnum.DailySubscription, null);
// String customerId = getCustomer(name, email); // String customerId = getCustomer(name, email);
String paymentMethodCode = "pm_card_mastercard"; String paymentMethodCode = "pm_card_mastercard";
@@ -1527,9 +920,17 @@ public class StripeServiceImpl implements StripeService {
public PromotionCode createPromotionCode(String couponId, Long maxRedemption){ public PromotionCode createPromotionCode(String couponId, Long maxRedemption){
Stripe.apiKey = privateKey; Stripe.apiKey = privateKey;
// 1. 构建 Promotion 对象,设置 type 为 "coupon" 并传入 couponId
PromotionCodeCreateParams.Promotion promotion = PromotionCodeCreateParams.Promotion.builder()
.setCoupon(couponId)// 设置关联的优惠券ID
.setType(PromotionCodeCreateParams.Promotion.Type.COUPON)
.build();
// 2. 构建主参数,通过 setPromotion 传入
PromotionCodeCreateParams.Builder promotionCodeParams = PromotionCodeCreateParams.builder() PromotionCodeCreateParams.Builder promotionCodeParams = PromotionCodeCreateParams.builder()
.setCoupon(couponId) .setPromotion(promotion); // 使用 setPromotion 而不是 setCoupon
.setRestrictions(PromotionCodeCreateParams.Restrictions.builder().build());
if (Objects.nonNull(maxRedemption)){ if (Objects.nonNull(maxRedemption)){
promotionCodeParams.setMaxRedemptions(maxRedemption); promotionCodeParams.setMaxRedemptions(maxRedemption);
} }

View File

@@ -0,0 +1,607 @@
package com.ai.da.service.impl;
import com.ai.da.common.config.exception.BusinessException;
import com.ai.da.common.constant.CommonConstant;
import com.ai.da.common.enums.ProductEnum;
import com.ai.da.common.utils.DateUtil;
import com.ai.da.common.utils.RedisUtil;
import com.ai.da.common.utils.SendEmailUtil;
import com.ai.da.mapper.primary.AccountMapper;
import com.ai.da.mapper.primary.PaymentInfoMapper;
import com.ai.da.mapper.primary.SubscriptionInfoMapper;
import com.ai.da.mapper.primary.entity.Account;
import com.ai.da.mapper.primary.entity.OrderInfo;
import com.ai.da.mapper.primary.entity.PaymentInfo;
import com.ai.da.mapper.primary.entity.SubscriptionInfo;
import com.ai.da.model.dto.SubscriptionEmailParamsDTO;
import com.ai.da.model.enums.Language;
import com.ai.da.service.*;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.stripe.Stripe;
import com.stripe.exception.StripeException;
import com.stripe.model.Customer;
import com.stripe.model.CustomerCollection;
import com.stripe.model.Subscription;
import com.stripe.param.CustomerCreateParams;
import com.stripe.param.CustomerListParams;
import io.netty.util.internal.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Stripe 订阅服务实现
*
* 本类负责订阅相关的业务辅助方法,供其他组件调用。
* 订阅事件处理已迁移至策略处理器:
* - InvoicePaidHandler处理 invoice.paid
* - CheckoutSessionCompletedHandler处理 checkout.session.completed (subscription)
* - SubscriptionDeletedHandler处理 customer.subscription.deleted
* - SubscriptionUpdatedHandler处理 customer.subscription.updated
*
* Stripe SDK 32.0.0 版本差异说明:
* - SubscriptionItem.getPrice().getRecurring().getInterval() 访问方式保持一致
* - Subscription.getItems().getData() 访问方式保持一致
*/
@Service
@Slf4j
public class StripeSubscriptionServiceImpl implements StripeSubscriptionService {
@Resource
private AccountService accountService;
@Resource
private AccountMapper accountMapper;
@Resource
private SubscriptionInfoMapper subscriptionInfoMapper;
@Resource
private OrderInfoService orderInfoService;
@Resource
private PaymentInfoMapper paymentInfoMapper;
@Resource
private RedisUtil redisUtil;
@Value("${stripe.private-key}")
private String privateKey;
@Value("${orderList.link}")
private String orderListLink;
/**
* 发送订阅相关邮件
* @param subscription Stripe Subscription object (may be null)
* @param type 邮件类型
* @param orderNo 订单号
* @param passedSubscriptionInfo 本地订阅记录 (用于避免事务未提交时重新查询,可为空)
*/
@Override
public boolean sendSubscriptionEmail(Subscription subscription, String type, String orderNo,
SubscriptionInfo passedSubscriptionInfo) {
SubscriptionInfo subscriptionInfo = resolveSubscriptionInfo(subscription, type, orderNo, passedSubscriptionInfo);
if (subscriptionInfo == null) {
log.info("subscriptionInfo为null不发送邮件");
return false;
}
OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(subscriptionInfo.getOrderNo());
if (orderByOrderNo == null) {
log.info("orderByOrderNo为null不发送邮件");
return false;
}
Account account = accountMapper.selectById(subscriptionInfo.getAccountId());
if (account == null) {
log.info("account为null不发送邮件");
return false;
}
PaymentInfo paymentInfo = resolvePaymentInfo(subscriptionInfo, orderNo, type);
if (paymentInfo == null) {
log.info("paymentInfo为null不发送邮件");
return false;
}
String resolvedType = resolveEmailType(type, paymentInfo);
if (isEmailAlreadySent(subscriptionInfo, resolvedType, paymentInfo)) {
log.info("邮件已发送,取消重复发送");
return true;
}
String language = resolveLanguage(account.getLanguage(), account.getCountry(), resolvedType);
SubscriptionEmailParamsDTO params = buildEmailParams(paymentInfo, subscriptionInfo, orderByOrderNo, account, language);
boolean success = SendEmailUtil.subscriptionEmailReminder(resolvedType, params, language, account.getUserEmail());
if (success) {
markEmailSent(subscriptionInfo, resolvedType, paymentInfo);
}
return success;
}
/**
* 解析订阅信息
* @param subscription Stripe Subscription object (may be null)
* @param type 邮件类型
* @param orderNo 订单号
* @param passedInfo 本地订阅记录 (用于避免事务未提交时重新查询,可为空)
*/
private SubscriptionInfo resolveSubscriptionInfo(Subscription subscription, String type, String orderNo,
SubscriptionInfo passedInfo) {
if (subscription != null) {
return getSubscriptionInfoBySubId(subscription.getId());
}
// renewal 场景:从 InvoicePaidHandler 直接传入已更新的 SubscriptionInfo避免事务未提交导致查询不到
if (passedInfo != null) {
long now = Instant.now().getEpochSecond();
boolean inPeriod = now > passedInfo.getCurrentPeriodStart() && now < passedInfo.getCurrentPeriodEnd();
// 续订失败的场景可能订单状态已被更新为past_due
boolean validStatus = "past_due".equals(passedInfo.getStatus()) || "active".equals(passedInfo.getStatus());
if (inPeriod && validStatus) {
return passedInfo;
}
return null;
}
if (!StringUtil.isNullOrEmpty(orderNo)) {
long now = Instant.now().getEpochSecond();
List<SubscriptionInfo> infos = subscriptionInfoMapper.selectList(
new QueryWrapper<SubscriptionInfo>()
.eq("order_no", orderNo)
.gt("current_period_start", now)
.lt("current_period_end", now)
);
if (!infos.isEmpty()) {
List<SubscriptionInfo> activeOnes = infos.stream()
.filter(s -> "active".equals(s.getStatus()))
.toList();
// todo 逻辑奇怪
// if ("cancel".equals(type) || "reminder_expire".equals(type)) {
// return infos.getFirst();
// }
// // todo 逻辑奇怪,待删除
// if (activeOnes.isEmpty() && "cancel".equals(type)) {
// return null;
// }
return activeOnes.isEmpty() ? null : activeOnes.getFirst();
}
}
return null;
}
/**
* 解析支付信息
*/
private PaymentInfo resolvePaymentInfo(SubscriptionInfo subscriptionInfo, String orderNo, String type) {
String periodStart = DateUtil.changeTimeStampFormat(subscriptionInfo.getCurrentPeriodStart(), "seconds", CommonConstant.TIME_FORMAT_yyyy_MM_dd_HH_mm_ss);
String periodEnd = DateUtil.changeTimeStampFormat(subscriptionInfo.getCurrentPeriodEnd(), "seconds", CommonConstant.TIME_FORMAT_yyyy_MM_dd_HH_mm_ss);
QueryWrapper<PaymentInfo> last = new QueryWrapper<PaymentInfo>()
.eq("order_no", orderNo)
.between("create_time", periodStart, periodEnd)
.orderByDesc("id")
.last("LIMIT 1");
if (!type.contains("fail")) {
last.in("trade_state", "paid", "COMPLETED", "Refunded");
}
List<PaymentInfo> infos = paymentInfoMapper.selectList(last);
return infos.isEmpty() ? null : infos.getFirst();
}
/**
* 发送首次订阅失败邮件
*/
@Override
public void sendFailedNewOrderEmail(String orderNo) {
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
if (orderInfo == null) {
return;
}
Account account = accountMapper.selectById(orderInfo.getAccountId());
if (account == null) {
return;
}
List<PaymentInfo> paymentInfos = paymentInfoMapper.selectList(
new QueryWrapper<PaymentInfo>().eq("order_no", orderNo).orderByDesc("id").last("LIMIT 1")
);
if (paymentInfos.isEmpty()) {
return;
}
PaymentInfo paymentInfo = paymentInfos.getFirst();
SubscriptionEmailParamsDTO params = new SubscriptionEmailParamsDTO();
params.setUsername(account.getUserName());
params.setOrderId(paymentInfo.getId().toString());
params.setCreateDate(String.valueOf(paymentInfo.getCreateTime()).replace("T", " "));
params.setQuantity("1");
params.setTotalFee(paymentInfo.getPayerTotal() != null ? paymentInfo.getPayerTotal().toString() : "0");
params.setFailMessage(orderInfo.getNote());
params.setPaymentMethod(paymentInfo.getPaymentMethod());
params.setLast4(paymentInfo.getLast4());
SendEmailUtil.subscriptionEmailReminder("fail_new", params, account.getLanguage(), account.getUserEmail());
paymentInfo.setNotified(1);
paymentInfo.setUpdateTime(LocalDateTime.now());
paymentInfoMapper.updateById(paymentInfo);
}
// /**
// * 获取用户最新的订阅信息
// */
// @Override
// public SubscriptionInfo getLatestSubscriptionInfoByAccountId(Long accountId) {
// List<SubscriptionInfo> infos = subscriptionInfoMapper.selectList(
// new QueryWrapper<SubscriptionInfo>()
// .eq("account_id", accountId)
// .orderByDesc("id")
// .last("LIMIT 1")
// );
// return infos.isEmpty() ? null : infos.get(0);
// }
//
// /**
// * 更新订阅取消原因
// */
// @Override
// public void updateCancelReason(String subscriptionId, String reason) {
// SubscriptionInfo info = getSubscriptionInfoBySubId(subscriptionId);
// if (info != null) {
// info.setCancelReason(reason);
// subscriptionInfoMapper.updateById(info);
// }
// }
/**
* 发送续费失败邮件
*/
// @Override
// public void sendRenewalFailEmail(String invoiceId, String subscriptionId, String orderNo) {
// PaymentInfo paymentInfo = resolvePaymentInfoForRenewalFail(invoiceId, subscriptionId, orderNo);
// if (paymentInfo == null || !Integer.valueOf(0).equals(paymentInfo.getNotified())) {
// return;
// }
// SubscriptionInfo subscriptionInfo = resolveSubscriptionInfoForRenewalFail(subscriptionId, orderNo);
// if (subscriptionInfo == null || !"past_due".equals(subscriptionInfo.getStatus())) {
// return;
// }
// Account account = accountMapper.selectById(subscriptionInfo.getAccountId());
// if (account == null) {
// return;
// }
// OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(subscriptionInfo.getOrderNo());
// SubscriptionEmailParamsDTO params = new SubscriptionEmailParamsDTO();
// params.setUsername(account.getUserName());
// params.setOrderId(paymentInfo.getId().toString());
// params.setCreateDate(String.valueOf(paymentInfo.getCreateTime()).replace("T", " "));
// params.setQuantity("1");
// params.setTotalFee(paymentInfo.getPayerTotal() != null ? paymentInfo.getPayerTotal().toString() : "0");
//
// params.setPaymentMethod(paymentInfo.getPaymentMethod());
// params.setLast4(paymentInfo.getLast4());
// params.setSubscriptionId(subscriptionInfo.getId().toString());
// params.setFailMessage(orderByOrderNo != null ? orderByOrderNo.getNote() : "");
// params.setSubscriptionType(subscriptionInfo.getType());
// params.setStartDate(orderByOrderNo != null ? DateUtil.changeTimeStampFormat(orderByOrderNo.getCreateTime()) : "");
//
// boolean success = SendEmailUtil.subscriptionEmailReminder("fail_renewal", params, account.getLanguage(), account.getUserEmail());
// if (success) {
// paymentInfo.setNotified(1);
// paymentInfo.setUpdateTime(LocalDateTime.now());
// paymentInfoMapper.updateById(paymentInfo);
// }
// }
/**
* 解析邮件类型
*/
private String resolveEmailType(String type, PaymentInfo paymentInfo) {
if (!StringUtil.isNullOrEmpty(type)) {
return type;
}
// todo 判断逻辑不对
return (paymentInfo != null && !StringUtil.isNullOrEmpty(paymentInfo.getType()))
? paymentInfo.getType() : "new";
}
/**
* 检查邮件是否已发送
*/
private boolean isEmailAlreadySent(SubscriptionInfo subscriptionInfo, String type, PaymentInfo paymentInfo) {
/*if ("cancel".equals(type)) {
return false;
}*/
String key = RedisUtil.SUBSCRIPTION_SENT_EMAIL_TYPE + subscriptionInfo.getId();
Boolean alreadySent = redisUtil.isElementExistsInSet(key, type);
return Boolean.TRUE.equals(alreadySent) && paymentInfo != null && Integer.valueOf(1).equals(paymentInfo.getNotified());
}
/**
* 标记邮件已发送
*/
private void markEmailSent(SubscriptionInfo subscriptionInfo, String type, PaymentInfo paymentInfo) {
if (!type.startsWith("reminder") && !type.equals("cancel") && paymentInfo != null) {
paymentInfo.setNotified(1);
paymentInfo.setUpdateTime(LocalDateTime.now());
paymentInfoMapper.updateById(paymentInfo);
}
String key = RedisUtil.SUBSCRIPTION_SENT_EMAIL_TYPE + subscriptionInfo.getId();
redisUtil.addToSet(key, type, CommonConstant.REDIS_SET_EXPIRE_TIME);
}
/**
* 解析语言
*/
private String resolveLanguage(String language, String country, String type) {
if (StringUtil.isNullOrEmpty(language)) {
return Language.ENGLISH.name();
}
if (!StringUtil.isNullOrEmpty(type) && type.startsWith("reminder")
&& Language.CHINESE_SIMPLIFIED.name().equals(language)
&& !StringUtil.isNullOrEmpty(country)
&& ("Hong Kong, China".equals(country) || "Taiwan, China".equals(country))) {
return "zh-Hant";
}
return language;
}
/**
* 构建邮件参数
*/
private SubscriptionEmailParamsDTO buildEmailParams(PaymentInfo paymentInfo, SubscriptionInfo subscriptionInfo,
OrderInfo orderByOrderNo, Account account, String language) {
SubscriptionEmailParamsDTO params = new SubscriptionEmailParamsDTO();
params.setUsername(account.getUserName());
params.setEmail(account.getUserEmail());
params.setCountry(paymentInfo.getCountry());
params.setOrderId(paymentInfo.getId().toString());
// params.setOrderRef("\"" + orderListLink + paymentInfo.getId().toString() + "\"");
params.setOrderRef("\"" + paymentInfo.getHostedInvoiceUrl() + "\"");
params.setCreateDate(String.valueOf(paymentInfo.getCreateTime()).replace("T", " "));
params.setQuantity("1");
params.setTotalFee(paymentInfo.getPayerTotal() != null ? paymentInfo.getPayerTotal().toString() : "0");
params.setLastOrderDate(DateUtil.changeTimeStampFormat(subscriptionInfo.getCurrentPeriodStart(), "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy));
params.setEndOfPrepaidTerm(DateUtil.changeTimeStampFormat(subscriptionInfo.getCurrentPeriodEnd(), "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy));
params.setPaymentMethod(paymentInfo.getPaymentMethod());
params.setLast4(paymentInfo.getLast4());
params.setSubscriptionId(subscriptionInfo.getId().toString());
params.setFailMessage(orderByOrderNo != null ? orderByOrderNo.getNote() : "");
params.setSubscriptionType(subscriptionInfo.getType());
params.setStartDate(DateUtil.changeTimeStampFormat(orderByOrderNo != null ? orderByOrderNo.getCreateTime() : null));
if (orderByOrderNo != null && orderByOrderNo.getTitle() != null) {
switch (orderByOrderNo.getTitle()) {
case "AiDA Monthly Subscription":
params.setRenewalFee(String.valueOf(ProductEnum.MonthlySubscription.getPrice()));
break;
case "AiDA Eco Monthly Subscription":
params.setRenewalFee(String.valueOf(ProductEnum.Eco_MonthlySubscription.getPrice()));
break;
case "AiDA Annual Subscription":
params.setRenewalFee(String.valueOf(ProductEnum.AnnualSubscription.getPrice()));
break;
case "AiDA Daily Subscription":
params.setRenewalFee(String.valueOf(ProductEnum.DailySubscription.getPrice()));
break;
default:
params.setRenewalFee("?");
}
}
if ("active".equals(subscriptionInfo.getStatus())) {
params.setEndDate("ENGLISH".equals(language) ? "When cancelled" : "手动取消订阅时");
} else {
params.setEndDate(DateUtil.changeTimeStampFormat(subscriptionInfo.getCurrentPeriodEnd(), "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy_EEEE));
}
String nextPayDate = StringUtil.isNullOrEmpty(subscriptionInfo.getNextPayDate()) ? "N/A"
: DateUtil.changeTimeStampFormat(subscriptionInfo.getCurrentPeriodEnd(), "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy);
params.setNextPayDate(nextPayDate);
params.setRenewalTime(DateUtil.changeTimeStampFormat(subscriptionInfo.getCurrentPeriodEnd(), "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy));
String days = "month".equals(subscriptionInfo.getType()) ? "7"
: "year".equals(subscriptionInfo.getType()) ? "14" : "N/A";
params.setDays(days);
return params;
}
/**
* 解析续费失败的支付信息
*/
// private PaymentInfo resolvePaymentInfoForRenewalFail(String invoiceId, String subscriptionId, String orderNo) {
// QueryWrapper<PaymentInfo> qw = new QueryWrapper<>();
// if (!StringUtil.isNullOrEmpty(invoiceId)) {
// qw.eq("transaction_id", invoiceId);
// return paymentInfoMapper.selectOne(qw);
// }
// qw.eq("order_no", orderNo).orderByDesc("id").last("LIMIT 1");
// List<PaymentInfo> infos = paymentInfoMapper.selectList(qw);
// if (infos.isEmpty() || !"failed".equals(infos.get(0).getTradeState())) {
// return null;
// }
// return infos.get(0);
// }
/**
* 解析续费失败的订阅信息
*/
// private SubscriptionInfo resolveSubscriptionInfoForRenewalFail(String subscriptionId, String orderNo) {
// QueryWrapper<SubscriptionInfo> qw = new QueryWrapper<>();
// if (!StringUtil.isNullOrEmpty(subscriptionId)) {
// qw.eq("subscription_id", subscriptionId);
// } else {
// qw.eq("order_no", orderNo);
// }
// return subscriptionInfoMapper.selectOne(qw);
// }
/**
* 创建或更新订阅信息
*
* Stripe SDK 32.0.0 版本差异说明:
* - subscription.getItems().getData().get(0).getPrice().getRecurring().getInterval() 保持一致
* - subscription.getCurrentPeriodStart/End() 已移除,改用 subscription.getItems().getData().get(0).getCurrentPeriodStart/End()
*
* @param subscription Stripe Subscription
* @return SubscriptionInfo
*/
// @Transactional(rollbackFor = Exception.class)
// @Override
// public SubscriptionInfo createOrUpdateSubscriptionInfo(Subscription subscription) {
// SubscriptionInfo info = getSubscriptionInfoBySubId(subscription.getId());
// // Stripe SDK 32.0.0: subscription.getCurrentPeriodStart/End() 已移除
// // 改用 subscription.getItems().getData().get(0).getCurrentPeriodStart/End()
// SubscriptionItem subscriptionItem = subscription.getItems().getData().get(0);
// long currentPeriodStart = subscriptionItem.getCurrentPeriodStart();
// long currentPeriodEnd = subscriptionItem.getCurrentPeriodEnd();
//
// if (info == null) {
// String orderNo = extractOrderNoFromSubscription(subscription);
// if (orderNo == null) {
// return null;
// }
// OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
// if (orderInfo == null) {
// return null;
// }
// info = new SubscriptionInfo();
// info.setAccountId(orderInfo.getAccountId());
// info.setOrderNo(orderNo);
// info.setSubscriptionId(subscription.getId());
// info.setType(subscriptionItem.getPrice().getRecurring().getInterval());
// info.setStatus(subscription.getStatus());
// info.setCurrentPeriodStart(currentPeriodStart);
// info.setCurrentPeriodEnd(currentPeriodEnd);
// info.setNextPayDate(DateUtil.changeTimeStampFormat(currentPeriodEnd, "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy_EEEE));
// info.setCreateTime(LocalDateTime.now());
// subscriptionInfoMapper.insert(info);
// } else {
// boolean dirty = false;
// if (!Objects.equals(info.getStatus(), subscription.getStatus())) {
// info.setStatus(subscription.getStatus());
// dirty = true;
// }
// if (!Objects.equals(info.getCurrentPeriodStart(), currentPeriodStart)) {
// info.setCurrentPeriodStart(currentPeriodStart);
// dirty = true;
// }
// if (!Objects.equals(info.getCurrentPeriodEnd(), currentPeriodEnd)) {
// info.setCurrentPeriodEnd(currentPeriodEnd);
// info.setNextPayDate(DateUtil.changeTimeStampFormat(currentPeriodEnd, "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy_EEEE));
// accountService.updateAccountValidity(info.getAccountId(), currentPeriodEnd);
// accountService.updateUserRoleAndCredits(info.getAccountId(), info.getOrderNo());
// dirty = true;
// }
// if ("active".equals(info.getStatus()) || "trialing".equals(info.getStatus())) {
// accountService.updateAccountValidity(info.getAccountId(), info.getCurrentPeriodEnd());
// accountService.updateUserRoleAndCredits(info.getAccountId(), info.getOrderNo());
// }
// if (dirty) {
// info.setUpdateTime(LocalDateTime.now());
// subscriptionInfoMapper.updateById(info);
// }
// }
// return info;
// }
/**
* 根据订阅ID获取订阅信息
*/
private SubscriptionInfo getSubscriptionInfoBySubId(String subId) {
List<SubscriptionInfo> infos = subscriptionInfoMapper.selectList(
new QueryWrapper<SubscriptionInfo>().eq("subscription_id", subId)
);
if (infos.isEmpty()) {
return null;
}
if (infos.size() == 1) {
return infos.getFirst();
}
Optional<SubscriptionInfo> active = infos.stream()
.filter(s -> "active".equals(s.getStatus()))
.findFirst();
return active.orElse(infos.getFirst());
}
/**
* 从 Subscription 中提取订单号
*/
// private String extractOrderNoFromSubscription(Subscription subscription) {
// String description = subscription.getDescription();
// if (!StringUtil.isNullOrEmpty(description) && description.startsWith("AiDA - ")) {
// return description.replace("AiDA - ", "");
// }
// Map<String, String> metadata = subscription.getMetadata();
// if (metadata != null && metadata.containsKey("orderId")) {
// return metadata.get("orderId");
// }
// return null;
// }
public void cancelSubscription(String subscriptionId, String cancelReason, Long accountId) {
Stripe.apiKey = privateKey;
log.info("申请取消连续订阅 subscriptionId={}", subscriptionId);
try {
// 1. 直接通过订阅ID检索订阅对象
Subscription subscription = Subscription.retrieve(subscriptionId);
com.ai.da.mapper.primary.entity.Account account = accountMapper.selectById(accountId);
String expectedCustomerId = getCustomer(account.getUserName(), account.getUserEmail());
// 2. 验证订阅是否属于指定客户(安全校验)
if (!expectedCustomerId.equals(subscription.getCustomer())) {
throw new IllegalArgumentException(
String.format("Subscription %s does not belong to customer %s",
subscriptionId, account.getUserEmail())
);
}
// 3. 执行取消操作
// 方式A立即取消
subscription.cancel();
// 方式B周期末取消推荐使用 cancelAt 参数替代)
// SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()
// .setCancelAtPeriodEnd(true)
// .build();
// subscription.update(params);
String reasonKey = "stripe:cancel:reason:" + subscriptionId;
// 取消原因1天过期
redisUtil.addToString(reasonKey, cancelReason != null ? cancelReason : "", 24 * 60 * 60L);
log.info("用户 {} 申请取消连续订阅 {}", accountId, subscriptionId);
} catch (StripeException e) {
log.error("订阅 {} 取消失败error={}", subscriptionId, e.getMessage());
throw new BusinessException("Subscription cancel failed");
}
}
public String getCustomer(String username, String userEmail) throws StripeException {
CustomerCollection list = Customer.list(CustomerListParams.builder().setEmail(userEmail).build());
List<Customer> data = list.getData();
if (!data.isEmpty()) {
return data.get(0).getId();
}
return createCustomer(username, userEmail);
}
private String createCustomer(String name, String userEmail) throws StripeException {
Stripe.apiKey = privateKey;
// Customer允许重复使用
CustomerCreateParams params =
CustomerCreateParams.builder()
.setName(name)
.setEmail(userEmail)
.build();
Customer customer = Customer.create(params);
return customer.getId();
}
}

View File

@@ -0,0 +1,154 @@
package com.ai.da.service.impl;
import com.ai.da.common.utils.RedisUtil;
import com.ai.da.service.PayPalCheckoutService;
import com.ai.da.service.stripe.handler.StripeEventDispatcher;
import com.stripe.exception.SignatureVerificationException;
import com.stripe.model.Event;
import com.stripe.model.EventDataObjectDeserializer;
import com.stripe.model.StripeObject;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
/**
* Stripe Webhook 服务实现
* 核心职责:
* 1. 签名验证(使用 StripeWebhook.constructEvent()
* 2. 幂等性检查Redis Key: stripe:event:{eventId}TTL 7天
* 3. 异步处理(@Async 或 CompletableFuture
* 4. 事件分发(策略模式 + EventDispatcher
*
* 版本差异说明Stripe SDK 26.2.0 -> 32.0.0
* - Event.getDataObjectDeserializer() 行为保持一致
* - constructEvent() 签名保持一致
*/
@Service
@Slf4j
public class StripeWebhookServiceImpl implements com.ai.da.service.StripeWebhookService {
/**
* Stripe Webhook 幂等性检查 TTL7天
* Stripe 回溯 webhook 最多 72 小时,设置为 7 天确保覆盖所有场景
*/
private static final long WEBHOOK_IDEMPOTENCY_TTL_SECONDS = TimeUnit.DAYS.toSeconds(7);
@Resource
private StripeEventDispatcher eventDispatcher;
@Resource
private PayPalCheckoutService payPalCheckoutService;
@Resource
private RedisUtil redisUtil;
@Value("${stripe.webhook-sign-secret}")
private String signSecret;
@Override
public Boolean notify(HttpServletRequest request) {
long startTime = System.currentTimeMillis();
String sigHeader = null;
String payload = null;
try {
// 1. 解析请求参数
sigHeader = request.getHeader("Stripe-Signature");
payload = payPalCheckoutService.getBody(request);
} catch (Exception e) {
log.error("Stripe webhook 参数解析异常:{}", e.getMessage(), e);
return Boolean.FALSE;
}
// 2. 签名验证
Event event;
try {
// Stripe SDK 32.0.0 兼容性constructEvent 签名保持一致
event = com.stripe.net.Webhook.constructEvent(payload, sigHeader, signSecret);
} catch (SignatureVerificationException e) {
log.error("Stripe webhook 验签失败:{}", e.getMessage(), e);
return Boolean.FALSE; // 返回 400 让 Stripe 不重试
} catch (Exception e) {
log.error("Stripe webhook 解析事件异常:{}", e.getMessage(), e);
return Boolean.FALSE;
}
String eventId = event.getId();
String eventType = event.getType();
log.info("[StripeWebhook] 接收事件eventId={}type={}created={}",
eventId, eventType, event.getCreated());
// 3. 幂等性检查
if (!redisUtil.tryMarkWebhookProcessed(eventId, WEBHOOK_IDEMPOTENCY_TTL_SECONDS)) {
log.info("[StripeWebhook] 事件已处理过跳过eventId={}", eventId);
return Boolean.TRUE;
}
// 4. 解析事件数据对象
EventDataObjectDeserializer deserializer = event.getDataObjectDeserializer();
Optional<StripeObject> optionalObject;
try {
optionalObject = deserializer.getObject();
} catch (Exception e) {
log.error("[StripeWebhook] 解析事件数据对象异常eventId={}error={}", eventId, e.getMessage(), e);
// 移除幂等标记,允许 Stripe 重试
redisUtil.removeFromString("StripeWebhook:processed:" + eventId);
return Boolean.FALSE;
}
if (optionalObject.isEmpty()) {
log.error("[StripeWebhook] 无法解析事件数据对象eventId={}type={}", eventId, eventType);
// 移除幂等标记,允许 Stripe 重试
redisUtil.removeFromString("StripeWebhook:processed:" + eventId);
return Boolean.FALSE;
}
StripeObject stripeObject = optionalObject.get();
// 5. 异步处理事件
try {
processEventAsync(eventId, eventType, stripeObject);
} catch (Exception e) {
log.error("[StripeWebhook] 启动异步处理异常eventId={}error={}", eventId, e.getMessage(), e);
return Boolean.FALSE;
}
long elapsed = System.currentTimeMillis() - startTime;
log.info("[StripeWebhook] 事件已接收并转发处理eventId={}type={},耗时={}ms",
eventId, eventType, elapsed);
return Boolean.TRUE;
}
/**
* 异步处理事件
* 使用 CompletableFuture 确保请求快速返回
*/
private void processEventAsync(String eventId, String eventType, StripeObject stripeObject) {
CompletableFuture.runAsync(() -> {
long startTime = System.currentTimeMillis();
try {
log.info("[StripeWebhook-Async] 开始处理eventId={}type={}", eventId, eventType);
boolean success = eventDispatcher.dispatch(eventType, stripeObject);
long elapsed = System.currentTimeMillis() - startTime;
if (success) {
log.info("[StripeWebhook-Async] 处理成功eventId={}type={},耗时={}ms",
eventId, eventType, elapsed);
} else {
log.warn("[StripeWebhook-Async] 处理失败eventId={}type={},耗时={}ms",
eventId, eventType, elapsed);
}
} catch (Exception e) {
log.error("[StripeWebhook-Async] 处理异常eventId={}type={}error={}",
eventId, eventType, e.getMessage(), e);
}
});
}
}

View File

@@ -905,15 +905,15 @@ public class UserLikeGroupServiceImpl extends ServiceImpl<UserLikeGroupMapper, U
} }
} }
// 将构建好的结果对象添加到返回列表 // 将构建好的结果对象添加到返回列表
results.add(magicToolResultVO); // results.add(magicToolResultVO);
} else if (Objects.isNull(magicToolResultVO)) { } else if (Objects.isNull(magicToolResultVO)) {
// 如果Redis中没有结果对象创建执行中状态的结果对象 // 如果Redis中没有结果对象创建执行中状态的结果对象
magicToolResultVO = new MagicToolResultVO(taskId, "Executing"); magicToolResultVO = new MagicToolResultVO(taskId, "Executing");
results.add(magicToolResultVO); // results.add(magicToolResultVO);
} else { }/* else {
// 如果Redis中有结果对象但URL为空直接添加到返回列表 // 如果Redis中有结果对象但URL为空直接添加到返回列表
results.add(magicToolResultVO); results.add(magicToolResultVO);
} }*/
// 收集任务状态用于统计 // 收集任务状态用于统计
if (!StringUtil.isNullOrEmpty(magicToolResultVO.getStatus())) collect.add(magicToolResultVO.getStatus()); if (!StringUtil.isNullOrEmpty(magicToolResultVO.getStatus())) collect.add(magicToolResultVO.getStatus());
@@ -1461,12 +1461,16 @@ public class UserLikeGroupServiceImpl extends ServiceImpl<UserLikeGroupMapper, U
if (StringUtil.isNullOrEmpty(fluxResult)) { if (StringUtil.isNullOrEmpty(fluxResult)) {
toProductImageResult.setStatus("Fail"); toProductImageResult.setStatus("Fail");
toProductImageResultMapper.updateById(toProductImageResult); toProductImageResultMapper.updateById(toProductImageResult);
sortRank(toProductImageResult); if (toProductImageResult.getIsLike() != null && toProductImageResult.getIsLike() == 1) {
sortRank(toProductImageResult);
}
results.add(new MagicToolResultVO(taskId, "Fail")); results.add(new MagicToolResultVO(taskId, "Fail"));
} else if (fluxResult.equals("Fail") || fluxResult.equals("Pending")) { } else if (fluxResult.equals("Fail") || fluxResult.equals("Pending")) {
toProductImageResult.setStatus(fluxResult); toProductImageResult.setStatus(fluxResult);
toProductImageResultMapper.updateById(toProductImageResult); toProductImageResultMapper.updateById(toProductImageResult);
sortRank(toProductImageResult); if (fluxResult.equals("Fail") && toProductImageResult.getIsLike() != null && toProductImageResult.getIsLike() == 1) {
sortRank(toProductImageResult);
}
results.add(new MagicToolResultVO(taskId, fluxResult)); results.add(new MagicToolResultVO(taskId, fluxResult));
} else { } else {
results.add(processFluxResult(fluxResult, toProductImageResult, taskId, toProductImageRecord.getPrompt())); results.add(processFluxResult(fluxResult, toProductImageResult, taskId, toProductImageRecord.getPrompt()));
@@ -2203,10 +2207,14 @@ public class UserLikeGroupServiceImpl extends ServiceImpl<UserLikeGroupMapper, U
childCollectionQw.lambda().orderByAsc(CollectionSort::getSort); childCollectionQw.lambda().orderByAsc(CollectionSort::getSort);
List<CollectionSort> childSortList = collectionSortMapper.selectList(childCollectionQw); List<CollectionSort> childSortList = collectionSortMapper.selectList(childCollectionQw);
List<AllCollectionVO> childList = new ArrayList<>(); List<AllCollectionVO> childList = new ArrayList<>();
// 收集需要删除的失败记录ID用于后续统一清理并重新排序
List<Long> failedSortIds = new ArrayList<>();
for (CollectionSort userLikeSort : childSortList) { for (CollectionSort userLikeSort : childSortList) {
if (userLikeSort.getRelationType().equals(CollectionType.TO_PRODUCT_IMAGE.getValue())) { if (userLikeSort.getRelationType().equals(CollectionType.TO_PRODUCT_IMAGE.getValue())) {
ToProductImageResult toProductImageResult = toProductImageResultMapper.selectById(userLikeSort.getRelationId()); ToProductImageResult toProductImageResult = toProductImageResultMapper.selectById(userLikeSort.getRelationId());
if (isGenerateTaskFailed(toProductImageResult.getStatus(), toProductImageResult.getCreateTime())) { if (isGenerateTaskFailed(toProductImageResult.getStatus(), toProductImageResult.getCreateTime())) {
failedSortIds.add(userLikeSort.getId());
log.info("【获取内容】TO_PRODUCT_IMAGE结果失败relationId={}即将从collection_sort中删除", userLikeSort.getRelationId());
continue; continue;
} }
toProductImageResult.setUrl(getMinioUrl(toProductImageResult.getUrl())); toProductImageResult.setUrl(getMinioUrl(toProductImageResult.getUrl()));
@@ -2238,6 +2246,8 @@ public class UserLikeGroupServiceImpl extends ServiceImpl<UserLikeGroupMapper, U
} else if (userLikeSort.getRelationType().equals(CollectionType.RELIGHT.getValue())) { } else if (userLikeSort.getRelationType().equals(CollectionType.RELIGHT.getValue())) {
ToProductImageResult toProductImageResult = toProductImageResultMapper.selectById(userLikeSort.getRelationId()); ToProductImageResult toProductImageResult = toProductImageResultMapper.selectById(userLikeSort.getRelationId());
if (isGenerateTaskFailed(toProductImageResult.getStatus(), toProductImageResult.getCreateTime())) { if (isGenerateTaskFailed(toProductImageResult.getStatus(), toProductImageResult.getCreateTime())) {
failedSortIds.add(userLikeSort.getId());
log.info("【获取内容】RELIGHT结果失败relationId={}即将从collection_sort中删除", userLikeSort.getRelationId());
continue; continue;
} }
toProductImageResult.setUrl(getMinioUrl(toProductImageResult.getUrl())); toProductImageResult.setUrl(getMinioUrl(toProductImageResult.getUrl()));
@@ -2269,6 +2279,8 @@ public class UserLikeGroupServiceImpl extends ServiceImpl<UserLikeGroupMapper, U
} else if (userLikeSort.getRelationType().equals(CollectionType.POSE_TRANSFORM.getValue())) { } else if (userLikeSort.getRelationType().equals(CollectionType.POSE_TRANSFORM.getValue())) {
PoseTransformation item = poseTransformationMapper.selectById(userLikeSort.getRelationId()); PoseTransformation item = poseTransformationMapper.selectById(userLikeSort.getRelationId());
if (isGenerateTaskFailed(item.getTaskStatus(), item.getCreateTime())) { if (isGenerateTaskFailed(item.getTaskStatus(), item.getCreateTime())) {
failedSortIds.add(userLikeSort.getId());
log.info("【获取内容】POSE_TRANSFORM结果失败relationId={}即将从collection_sort中删除", userLikeSort.getRelationId());
continue; continue;
} }
PoseTransformationVO poseTransformationVO = new PoseTransformationVO(); PoseTransformationVO poseTransformationVO = new PoseTransformationVO();
@@ -2293,6 +2305,114 @@ public class UserLikeGroupServiceImpl extends ServiceImpl<UserLikeGroupMapper, U
childList.add(poseTransformationVO); childList.add(poseTransformationVO);
} }
} }
// 统一处理失败的记录从collection_sort表中删除失败的记录并重新排序
if (CollectionUtil.isNotEmpty(failedSortIds)) {
Long parentId = collectionSort.getId();
Long projectId = projectDTO.getId();
log.info("【获取内容】检测到{}条失败记录需要清理parentId={}, projectId={}", failedSortIds.size(), parentId, projectId);
for (Long failedSortId : failedSortIds) {
CollectionSort failedRecord = collectionSortMapper.selectById(failedSortId);
if (failedRecord != null) {
String relationType = failedRecord.getRelationType();
Long relationId = failedRecord.getRelationId();
collectionSortService.deleteCollectionSort(relationId, relationType, projectId, parentId);
log.info("【获取内容】已删除失败记录relationId={}, relationType={}", relationId, relationType);
}
}
// 重新查询子列表,获取更新后的排序
childSortList = collectionSortMapper.selectList(childCollectionQw);
// 重新构建childList使用更新后的sort值
childList = new ArrayList<>();
for (CollectionSort userLikeSort : childSortList) {
if (userLikeSort.getRelationType().equals(CollectionType.TO_PRODUCT_IMAGE.getValue())) {
ToProductImageResult toProductImageResult = toProductImageResultMapper.selectById(userLikeSort.getRelationId());
if (isGenerateTaskFailed(toProductImageResult.getStatus(), toProductImageResult.getCreateTime())) {
continue;
}
toProductImageResult.setUrl(getMinioUrl(toProductImageResult.getUrl()));
ToProductImageResultVO toProductImageResultVO = CopyUtil.copyObject(toProductImageResult, ToProductImageResultVO.class);
ToProductImageRecord toProductImageRecord = toProductImageRecordMapper.selectById(toProductImageResult.getToProductImageRecordId());
if (Objects.isNull(toProductImageRecord)) {
continue;
}
toProductImageResultVO.setPrompt(toProductImageRecord.getPrompt());
if (toProductImageResultVO.getElementType().equals("ProductElement")) {
ToProductElement toProductElement = toProductElementMapper.selectById(toProductImageResultVO.getElementId());
toProductImageResultVO.setSourceUrl(getMinioUrl(toProductElement.getUrl()));
} else if ((toProductImageResultVO.getElementType().equals("DesignOutfit"))) {
TDesignPythonOutfit tDesignPythonOutfit = designPythonOutfitMapper.selectById(toProductImageResultVO.getElementId());
toProductImageResultVO.setSourceUrl(getMinioUrl(tDesignPythonOutfit.getDesignUrl()));
} else {
ToProductImageResult toProductImageResult1 = toProductImageResultMapper.selectById(toProductImageResultVO.getElementId());
toProductImageResultVO.setSourceUrl(getMinioUrl(toProductImageResult1.getUrl()));
}
toProductImageResultVO.setCollectionType(CollectionType.TO_PRODUCT_IMAGE.getValue());
toProductImageResultVO.setSort(userLikeSort.getSort());
toProductImageResultVO.setUserLikeSortId(userLikeSort.getId());
toProductImageResultVO.setRelationType(userLikeSort.getRelationType());
toProductImageResultVO.setParentId(userLikeSort.getParentId());
childList.add(toProductImageResultVO);
} else if (userLikeSort.getRelationType().equals(CollectionType.RELIGHT.getValue())) {
ToProductImageResult toProductImageResult = toProductImageResultMapper.selectById(userLikeSort.getRelationId());
if (isGenerateTaskFailed(toProductImageResult.getStatus(), toProductImageResult.getCreateTime())) {
continue;
}
toProductImageResult.setUrl(getMinioUrl(toProductImageResult.getUrl()));
ToProductImageResultVO toProductImageResultVO = CopyUtil.copyObject(toProductImageResult, ToProductImageResultVO.class);
ToProductImageRecord toProductImageRecord = toProductImageRecordMapper.selectById(toProductImageResult.getToProductImageRecordId());
if (Objects.isNull(toProductImageRecord)) {
continue;
}
toProductImageResultVO.setPrompt(toProductImageRecord.getPrompt());
if (toProductImageResultVO.getElementType().equals("ProductElement")) {
ToProductElement toProductElement = toProductElementMapper.selectById(toProductImageResultVO.getElementId());
toProductImageResultVO.setSourceUrl(getMinioUrl(toProductElement.getUrl()));
} else if ((toProductImageResultVO.getElementType().equals("DesignOutfit"))) {
TDesignPythonOutfit tDesignPythonOutfit = designPythonOutfitMapper.selectById(toProductImageResultVO.getElementId());
toProductImageResultVO.setSourceUrl(getMinioUrl(tDesignPythonOutfit.getDesignUrl()));
} else {
ToProductImageResult toProductImageResult1 = toProductImageResultMapper.selectById(toProductImageResultVO.getElementId());
toProductImageResultVO.setSourceUrl(getMinioUrl(toProductImageResult1.getUrl()));
}
toProductImageResultVO.setCollectionType(CollectionType.RELIGHT.getValue());
toProductImageResultVO.setSort(userLikeSort.getSort());
toProductImageResultVO.setUserLikeSortId(userLikeSort.getId());
toProductImageResultVO.setRelationType(userLikeSort.getRelationType());
toProductImageResultVO.setParentId(userLikeSort.getParentId());
childList.add(toProductImageResultVO);
} else if (userLikeSort.getRelationType().equals(CollectionType.POSE_TRANSFORM.getValue())) {
PoseTransformation item = poseTransformationMapper.selectById(userLikeSort.getRelationId());
if (isGenerateTaskFailed(item.getTaskStatus(), item.getCreateTime())) {
continue;
}
PoseTransformationVO poseTransformationVO = new PoseTransformationVO();
poseTransformationVO.setId(item.getId());
poseTransformationVO.setTaskId(item.getUniqueId());
poseTransformationVO.setProductImage(getMinioUrl(item.getProductImage()));
poseTransformationVO.setLastFrameProductImage(getMinioUrl(item.getLastFrameProductImage()));
poseTransformationVO.setPrompt(item.getPrompt());
poseTransformationVO.setGifUrl(getMinioUrl(item.getGifUrl()));
poseTransformationVO.setVideoUrl(getMinioUrl(item.getVideoUrl()));
poseTransformationVO.setFirstFrameUrl(getMinioUrl(item.getFirstFrameUrl()));
poseTransformationVO.setIsLiked(item.getIsLiked());
poseTransformationVO.setCollectionType(CollectionType.POSE_TRANSFORM.getValue());
poseTransformationVO.setSort(userLikeSort.getSort());
poseTransformationVO.setUserLikeSortId(userLikeSort.getId());
poseTransformationVO.setRelationType(userLikeSort.getRelationType());
poseTransformationVO.setResultType(CollectionType.POSE_TRANSFORM.getValue());
poseTransformationVO.setParentId(userLikeSort.getParentId());
poseTransformationVO.setModelName(item.getModelName());
poseTransformationVO.setPoseId(item.getPoseId());
poseTransformationVO.setStatus(item.getTaskStatus());
childList.add(poseTransformationVO);
}
}
log.info("【获取内容】失败记录清理完成重新排序后childList.size={}", childList.size());
}
o.setChildList(childList); o.setChildList(childList);
list.add(o); list.add(o);

View File

@@ -0,0 +1,35 @@
package com.ai.da.service.stripe.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* Stripe Webhook 异步处理线程池配置
*/
@Configuration
@EnableAsync
public class StripeWebhookAsyncConfig {
/**
* Stripe Webhook 专用线程池
*/
@Bean(name = "stripeWebhookExecutor")
public Executor stripeWebhookExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("stripe-webhook-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}

View File

@@ -0,0 +1,295 @@
package com.ai.da.service.stripe.handler;
import com.ai.da.common.constant.CommonConstant;
import com.ai.da.common.enums.CreditsEventsEnum;
import com.ai.da.common.enums.OrderStatusEnum;
import com.ai.da.common.utils.DateUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.ai.da.common.enums.ProductEnum;
import com.ai.da.mapper.primary.SubscriptionInfoMapper;
import com.ai.da.mapper.primary.entity.CreditsDetail;
import com.ai.da.mapper.primary.entity.OrderInfo;
import com.ai.da.mapper.primary.entity.SubscriptionInfo;
import com.ai.da.service.*;
import com.stripe.exception.StripeException;
import com.stripe.model.Invoice;
import com.stripe.model.InvoiceLineItem;
import com.stripe.model.checkout.Session;
import io.netty.util.internal.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* checkout.session.completed 事件处理器
* 业务场景:
* 1. mode == subscription - 首次订阅成功
* 2. mode == payment - 单次购买成功/积分购买
* 业务动作:更新订单状态、新建支付记录、累加积分、更新用户角色、发送通知
*/
@Component
@Slf4j
@Order(20)
public class CheckoutSessionCompletedHandler implements StripeEventHandler {
private static final String EVENT_TYPE = "checkout.session.completed";
private static final String MODE_SUBSCRIPTION = "subscription";
private static final String MODE_PAYMENT = "payment";
@Resource
private OrderInfoService orderInfoService;
@Resource
private PaymentInfoService paymentInfoService;
@Resource
private CreditsService creditsService;
@Resource
private SubscriptionInfoMapper subscriptionInfoMapper;
@Resource
private AccountService accountService;
@Resource
private StripeSubscriptionService stripeSubscriptionService;
@Override
public List<String> getSupportedEventTypes() {
return Collections.singletonList(EVENT_TYPE);
}
@Override
public Class<Session> getSupportObjectType() {
return Session.class;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean handle(String eventType, com.stripe.model.StripeObject stripeObject) {
Session session = (Session) stripeObject;
String eventId = session.getId();
long startTime = System.currentTimeMillis();
try {
log.info("[checkout.session.completed] 开始处理sessionId={}", eventId);
String orderNo = extractOrderNoFromSession(session);
if (orderNo == null) {
log.warn("[checkout.session.completed] 无法提取 orderNosessionId={}", eventId);
return true; // 非业务异常,返回成功
}
// 更新订单状态
updateOrderStatus(orderNo);
// 根据 mode 判断业务类型
String mode = session.getMode();
if (MODE_SUBSCRIPTION.equals(mode)) {
// 首次订阅成功
handleSubscriptionMode(session, orderNo);
} else if (MODE_PAYMENT.equals(mode)) {
// 单次购买成功/积分购买
handlePaymentMode(session, orderNo);
}
log.info("[checkout.session.completed] 处理完成sessionId={}orderNo={}mode={},耗时={}ms",
eventId, orderNo, mode, System.currentTimeMillis() - startTime);
return true;
} catch (Exception e) {
log.error("[checkout.session.completed] 处理异常sessionId={}error={}", eventId, e.getMessage(), e);
return false;
}
}
/**
* 更新订单状态
*/
private void updateOrderStatus(String orderNo) {
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
if (orderInfo == null) {
log.warn("[checkout.session.completed] 订单不存在orderNo={}", orderNo);
return;
}
String orderStatus = orderInfo.getOrderStatus();
if (OrderStatusEnum.NOT_PAY.getType().equals(orderStatus)
|| OrderStatusEnum.TIMEOUT_CLOSED.getType().equals(orderStatus)) {
orderInfoService.updateStatusByOrderNo(orderNo, OrderStatusEnum.SUCCESS);
log.info("[checkout.session.completed] 订单状态已更新orderNo={}", orderNo);
}
}
/**
* 处理订阅模式(首次订阅成功)
* 统一处理自动续订和非自动续订:从 session 获取 invoice直接取 periodStart/periodEnd 创建订阅记录
*/
private void handleSubscriptionMode(Session session, String orderNo) {
// 创建支付记录
paymentInfoService.createOrUpdatePaymentInfoForStripe(session);
String invoiceId = session.getInvoice();
if (StringUtil.isNullOrEmpty(invoiceId)) {
log.warn("[checkout.session.completed] 订阅模式无 invoiceIdorderNo={}", orderNo);
return;
}
try {
Invoice invoice = Invoice.retrieve(invoiceId);
List<InvoiceLineItem> lines = invoice.getLines().getData();
if (lines == null || lines.isEmpty()) {
return;
}
InvoiceLineItem lineItem = lines.getFirst();
long periodStart = lineItem.getPeriod().getStart();
long periodEnd = lineItem.getPeriod().getEnd();
String interval = getIntervalFromLineItem(lineItem);
String subscriptionId = getSubscriptionByInvoice(invoice);
String status = "active";
// 避免重复创建
if (!StringUtil.isNullOrEmpty(subscriptionId)) {
QueryWrapper<SubscriptionInfo> qw = new QueryWrapper<>();
qw.eq("subscription_id", subscriptionId);
if (subscriptionInfoMapper.selectCount(qw) > 0) {
return;
}
}
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
if (orderInfo == null) {
return;
}
SubscriptionInfo subscriptionInfo = new SubscriptionInfo();
subscriptionInfo.setSubscriptionId(subscriptionId);
subscriptionInfo.setAccountId(orderInfo.getAccountId());
subscriptionInfo.setOrderNo(orderNo);
subscriptionInfo.setType(interval);
subscriptionInfo.setStatus(status);
subscriptionInfo.setCurrentPeriodStart(periodStart);
subscriptionInfo.setCurrentPeriodEnd(periodEnd);
subscriptionInfo.setNextPayDate(DateUtil.changeTimeStampFormat(periodEnd, "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy_EEEE));
subscriptionInfo.setCreateTime(LocalDateTime.now());
subscriptionInfoMapper.insertIgnore(subscriptionInfo);
accountService.updateAccountValidity(orderInfo.getAccountId(), periodEnd);
accountService.updateUserRoleAndCredits(orderInfo.getAccountId(), orderNo);
log.info("[checkout.session.completed] 订阅记录创建完成orderNo={}subscriptionId={}periodEnd={}",
orderNo, subscriptionId, periodEnd);
boolean sent = stripeSubscriptionService.sendSubscriptionEmail(null, "new", orderNo, subscriptionInfo);
if (sent) {
log.info("[checkout.session.completed] 邮件通知完成 类型new");
} else {
log.info("[checkout.session.completed] 邮件通知未完成");
}
} catch (StripeException e) {
log.error("[checkout.session.completed] 处理订阅记录失败orderNo={}error={}", orderNo, e.getMessage());
}
}
/**
* 从 Invoice 中获取 subscriptionId
* Stripe SDK 32.0.0: 使用 invoice.getParent().getSubscriptionDetails().getSubscription() 替代已移除的 invoice.getSubscription()
*
* @param invoice Stripe Invoice
* @return subscriptionId 或 null
*/
private String getSubscriptionByInvoice(Invoice invoice) {
try {
Invoice.Parent parent = invoice.getParent();
if (parent != null && "subscription_details".equals(parent.getType())) {
Invoice.Parent.SubscriptionDetails subscriptionDetails = parent.getSubscriptionDetails();
if (subscriptionDetails != null) {
return subscriptionDetails.getSubscription();
}
}
} catch (Exception e) {
log.warn("[getSubscriptionByInvoice] 从 invoice.getParent() 获取 subscriptionId 失败invoiceId={}, error={}",
invoice.getId(), e.getMessage());
}
return null;
}
/**
* 从 InvoiceLineItem 描述中提取订阅周期类型
*/
private String getIntervalFromLineItem(InvoiceLineItem lineItem) {
String description = lineItem.getDescription();
if (description == null) {
return "month";
}
if (description.contains("Daily") || description.contains("Day")) {
return "day";
} else if (description.contains("Annual") || description.contains("Year")) {
return "year";
}
return "month";
}
/**
* 处理支付模式(单次购买/积分购买)
*/
private void handlePaymentMode(Session session, String orderNo) {
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
if (orderInfo == null) {
return;
}
// 创建支付记录
paymentInfoService.createOrUpdatePaymentInfoForStripe(session);
// 积分购买处理
if (orderInfo.getTitle() != null && orderInfo.getTitle().startsWith("积分购买")) {
CreditsDetail detail = creditsService.queryDetailByTaskId(orderNo);
if (detail == null) {
processCreditsPurchase(orderInfo, session);
}
}
}
/**
* 处理积分购买
*/
private void processCreditsPurchase(OrderInfo orderInfo, Session session) {
float totalAmount = new BigDecimal(session.getAmountTotal())
.divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).floatValue();
float quantity = totalAmount / ProductEnum.CreditsProduct.getPrice();
creditsService.buyCredits(orderInfo.getAccountId(), quantity);
creditsService.insertToCreditsDetail(
orderInfo.getAccountId(),
CreditsEventsEnum.BUY_CREDITS.getName() + "--Stripe",
String.valueOf(Long.parseLong(CreditsEventsEnum.BUY_CREDITS.getValue()) * (long) quantity),
"positive",
orderInfo.getOrderNo()
);
log.info("[checkout.session.completed] 积分购买处理完成accountId={}quantity={}orderNo={}",
orderInfo.getAccountId(), quantity, orderInfo.getOrderNo());
}
/**
* 从 Session 中提取订单号
*/
private String extractOrderNoFromSession(Session session) {
Map<String, String> metadata = session.getMetadata();
if (metadata == null || !metadata.containsKey("orderId")) {
log.warn("Session {} 缺少 orderId metadata", session.getId());
return null;
}
return metadata.get("orderId");
}
}

View File

@@ -0,0 +1,118 @@
package com.ai.da.service.stripe.handler;
import com.ai.da.mapper.primary.SubscriptionInfoMapper;
import com.ai.da.mapper.primary.entity.OrderInfo;
import com.ai.da.mapper.primary.entity.SubscriptionInfo;
import com.ai.da.service.OrderInfoService;
import com.ai.da.service.PaymentInfoService;
import com.ai.da.service.StripeSubscriptionService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.util.Collections;
import java.util.List;
/**
* checkout.session.expired 事件处理器
* 业务场景Checkout Session 过期
* 业务动作:处理过期订单,发送失败通知邮件
*/
@Component
@Slf4j
@Order(70)
public class CheckoutSessionExpiredHandler implements StripeEventHandler {
private static final String EVENT_TYPE = "checkout.session.expired";
@Resource
private OrderInfoService orderInfoService;
@Resource
private PaymentInfoService paymentInfoService;
@Resource
private StripeSubscriptionService stripeSubscriptionService;
@Resource
private SubscriptionInfoMapper subscriptionInfoMapper;
@Override
public List<String> getSupportedEventTypes() {
return Collections.singletonList(EVENT_TYPE);
}
@Override
public Class<com.stripe.model.checkout.Session> getSupportObjectType() {
return com.stripe.model.checkout.Session.class;
}
@Override
public boolean handle(String eventType, com.stripe.model.StripeObject stripeObject) {
com.stripe.model.checkout.Session session = (com.stripe.model.checkout.Session) stripeObject;
String eventId = session.getId();
long startTime = System.currentTimeMillis();
try {
log.info("[checkout.session.expired] 开始处理sessionId={}", eventId);
String orderNo = extractOrderNoFromSession(session);
if (orderNo == null) {
log.info("[checkout.session.expired] 无法提取 orderNo跳过sessionId={}", eventId);
return true;
}
OrderInfo orderInfo = orderInfoService.getOrderByOrderNo(orderNo);
if (orderInfo == null) {
log.info("[checkout.session.expired] 订单不存在跳过orderNo={}", orderNo);
return true;
}
// todo 确认订单状态是否会更新为失败
// 仅处理失败状态的订单
if (!com.ai.da.common.enums.OrderStatusEnum.FAILURE.getType().equals(orderInfo.getOrderStatus())) {
log.info("[checkout.session.expired] 订单状态非失败跳过orderNo={}status={}", orderNo, orderInfo.getOrderStatus());
return true;
}
// 检查后续是否有成功的订阅订单
List<OrderInfo> laterSuccessOrders = orderInfoService.getBaseMapper().selectList(
new QueryWrapper<OrderInfo>()
.eq("account_id", orderInfo.getAccountId())
.gt("create_time", orderInfo.getCreateTime())
.eq("order_status", com.ai.da.common.enums.OrderStatusEnum.SUCCESS.getType())
.likeLeft("title", "Subscription")
);
// todo 支付未完成时,不会自动产生订阅类型的回调;这里逻辑待验证
if (laterSuccessOrders.isEmpty()) {
// 没有后续成功订单,发送失败通知
List<SubscriptionInfo> subInfoList = subscriptionInfoMapper.selectList(
new QueryWrapper<SubscriptionInfo>()
.eq("order_no", orderNo)
);
// TODO 确认订阅状态是否会更新为未完成或过期未完成
if (subInfoList.isEmpty()
|| "incomplete".equals(subInfoList.getFirst().getStatus())
|| "incomplete_expired".equals(subInfoList.getFirst().getStatus())) {
// 首次订阅失败
stripeSubscriptionService.sendFailedNewOrderEmail(orderNo);
log.info("[checkout.session.expired] 首次订阅失败邮件已发送orderNo={}", orderNo);
}
}
log.info("[checkout.session.expired] 处理完成sessionId={},耗时={}ms",
eventId, System.currentTimeMillis() - startTime);
return true;
} catch (Exception e) {
log.error("[checkout.session.expired] 处理异常sessionId={}error={}", eventId, e.getMessage(), e);
return false;
}
}
private String extractOrderNoFromSession(com.stripe.model.checkout.Session session) {
if (session.getMetadata() != null && session.getMetadata().containsKey("orderId")) {
return session.getMetadata().get("orderId");
}
return null;
}
}

View File

@@ -0,0 +1,125 @@
package com.ai.da.service.stripe.handler;
import com.ai.da.common.constant.CommonConstant;
import com.ai.da.common.utils.DateUtil;
import com.ai.da.mapper.primary.SubscriptionInfoMapper;
import com.ai.da.mapper.primary.entity.SubscriptionInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.util.Collections;
import java.util.List;
/**
* customer.subscription.updated 事件处理器
* 业务场景:订阅计划变更(升级/降级)、订阅属性变更
* 业务动作:同步本地订阅信息
*/
@Component
@Slf4j
@Order(50)
public class CustomerSubscriptionUpdateHandler implements StripeEventHandler {
private static final String EVENT_TYPE = "customer.subscription.updated";
@Resource
private SubscriptionInfoMapper subscriptionInfoMapper;
@Override
public List<String> getSupportedEventTypes() {
return Collections.singletonList(EVENT_TYPE);
}
@Override
public Class<com.stripe.model.Subscription> getSupportObjectType() {
return com.stripe.model.Subscription.class;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean handle(String eventType, com.stripe.model.StripeObject stripeObject) {
com.stripe.model.Subscription subscription = (com.stripe.model.Subscription) stripeObject;
String subscriptionId = subscription.getId();
long startTime = System.currentTimeMillis();
try {
log.info("[customer.subscription.updated] 开始处理subscriptionId={}", subscriptionId);
var subInfoList = subscriptionInfoMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SubscriptionInfo>()
.eq("subscription_id", subscriptionId)
);
if (subInfoList.isEmpty()) {
log.info("[customer.subscription.updated] 未找到本地订阅记录跳过subscriptionId={}", subscriptionId);
return true;
}
SubscriptionInfo subscriptionInfo = subInfoList.getFirst();
String status = subscription.getStatus();
if (status != null) {
subscriptionInfo.setStatus(mapStripeStatus(status));
}
updateSubscriptionPeriod(subscription, subscriptionInfo);
Boolean cancelAtPeriodEnd = subscription.getCancelAtPeriodEnd();
if (cancelAtPeriodEnd != null && cancelAtPeriodEnd) {
subscriptionInfo.setCancelNotified((byte) 0);
subscriptionInfo.setNextPayDate("--");
}
subscriptionInfo.setUpdateTime(java.time.LocalDateTime.now());
subscriptionInfoMapper.updateById(subscriptionInfo);
log.info("[customer.subscription.updated] 处理完成subscriptionId={}status={},耗时={}ms",
subscriptionId, status, System.currentTimeMillis() - startTime);
return true;
} catch (Exception e) {
log.error("[customer.subscription.updated] 处理异常subscriptionId={}error={}", subscriptionId, e.getMessage(), e);
return false;
}
}
private void updateSubscriptionPeriod(com.stripe.model.Subscription subscription, SubscriptionInfo subscriptionInfo) {
try {
var items = subscription.getItems();
if (items != null && !items.getData().isEmpty()) {
var item = items.getData().getFirst();
Long currentPeriodStart = item.getCurrentPeriodStart();
Long currentPeriodEnd = item.getCurrentPeriodEnd();
if (currentPeriodStart != null) {
subscriptionInfo.setCurrentPeriodStart(currentPeriodStart);
}
if (currentPeriodEnd != null) {
subscriptionInfo.setCurrentPeriodEnd(currentPeriodEnd);
subscriptionInfo.setNextPayDate(DateUtil.changeTimeStampFormat(currentPeriodEnd, "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy_EEEE));
}
}
} catch (Exception e) {
log.debug("[customer.subscription.updated] 从 items 获取周期失败subscriptionId={}", subscription.getId());
}
}
private String mapStripeStatus(String stripeStatus) {
if (stripeStatus == null) {
return "unknown";
}
return switch (stripeStatus) {
case "active" -> "active";
case "past_due" -> "past_due";
case "canceled" -> "canceled";
case "trialing" -> "trialing";
case "incomplete" -> "incomplete";
case "incomplete_expired" -> "incomplete_expired";
case "unpaid" -> "unpaid";
default -> stripeStatus;
};
}
}

View File

@@ -0,0 +1,180 @@
package com.ai.da.service.stripe.handler;
import com.ai.da.common.constant.CommonConstant;
import com.ai.da.common.utils.DateUtil;
import com.ai.da.mapper.primary.SubscriptionInfoMapper;
import com.ai.da.mapper.primary.entity.PaymentInfo;
import com.ai.da.mapper.primary.entity.SubscriptionInfo;
import com.ai.da.service.AccountService;
import com.ai.da.service.PaymentInfoService;
import com.ai.da.service.StripeSubscriptionService;
import com.stripe.model.Invoice;
import com.stripe.model.InvoiceLineItem;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* invoice.paid 事件处理器
* 业务场景:订阅续费支付成功 / 发票已支付
* 业务动作:更新订阅有效期、发送续费成功通知
*/
@Component
@Slf4j
@Order(30)
public class InvoicePaidHandler implements StripeEventHandler {
private static final String EVENT_TYPE = "invoice.paid";
@Resource
private StripeSubscriptionService stripeSubscriptionService;
@Resource
private PaymentInfoService paymentInfoService;
@Resource
private SubscriptionInfoMapper subscriptionInfoMapper;
@Resource
private AccountService accountService;
@Override
public List<String> getSupportedEventTypes() {
return Collections.singletonList(EVENT_TYPE);
}
@Override
public Class<Invoice> getSupportObjectType() {
return Invoice.class;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean handle(String eventType, com.stripe.model.StripeObject stripeObject) {
Invoice invoice = (Invoice) stripeObject;
String invoiceId = invoice.getId();
long startTime = System.currentTimeMillis();
try {
log.info("[invoice.paid] 开始处理invoiceId={}", invoiceId);
String billingReason = invoice.getBillingReason();
if (!"subscription_cycle".equals(billingReason)
&& !"subscription_update".equals(billingReason)
&& !"subscription".equals(billingReason)) {
log.info("[invoice.paid] 非订阅续费发票跳过invoiceId={}billingReason={}", invoiceId, billingReason);
return true;
}
String subscriptionId = getSubscriptionByInvoice(invoice);
if (subscriptionId == null) {
log.info("[invoice.paid] 无法获取订阅ID跳过invoiceId={}", invoiceId);
return true;
}
SubscriptionInfo subscriptionInfo = findSubscriptionInfo(subscriptionId);
if (subscriptionInfo == null) {
log.info("[invoice.paid] 未找到本地订阅记录跳过subscriptionId={}", subscriptionId);
return true;
}
// 创建或更新支付记录(来自 PaymentInfoServiceImpl.createOrUpdatePaymentInfoForStripe(Invoice)
createOrUpdatePaymentInfo(invoice, subscriptionId);
// 更新订阅信息
updateSubscriptionPeriod(invoice, subscriptionInfo);
// 更新用户积分、账号到期时间,添加积分详细记录
accountService.updateAccountValidity(subscriptionInfo.getAccountId(), subscriptionInfo.getCurrentPeriodEnd());
accountService.updateUserRoleAndCredits(subscriptionInfo.getAccountId(), subscriptionInfo.getOrderNo());
// 发送通知邮件
sendRenewalNotification(subscriptionInfo);
log.info("[invoice.paid] 处理完成invoiceId={}subscriptionId={},耗时={}ms",
invoiceId, subscriptionId, System.currentTimeMillis() - startTime);
return true;
} catch (Exception e) {
log.error("[invoice.paid] 处理异常invoiceId={}error={}", invoiceId, e.getMessage(), e);
return false;
}
}
/**
* 从 Invoice 中获取 subscriptionId
* Stripe SDK 32.0.0: 使用 invoice.getParent().getSubscriptionDetails().getSubscription() 替代已移除的 invoice.getSubscription()
*
* @param invoice Stripe Invoice
* @return subscriptionId 或 null
*/
private String getSubscriptionByInvoice(Invoice invoice) {
try {
Invoice.Parent parent = invoice.getParent();
if (parent != null && "subscription_details".equals(parent.getType())) {
Invoice.Parent.SubscriptionDetails subscriptionDetails = parent.getSubscriptionDetails();
if (subscriptionDetails != null) {
return subscriptionDetails.getSubscription();
}
}
} catch (Exception e) {
log.warn("[getSubscriptionByInvoice] 从 invoice.getParent() 获取 subscriptionId 失败invoiceId={}, error={}",
invoice.getId(), e.getMessage());
}
return null;
}
private SubscriptionInfo findSubscriptionInfo(String subscriptionId) {
var list = subscriptionInfoMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SubscriptionInfo>()
.eq("subscription_id", subscriptionId)
);
return list.isEmpty() ? null : list.get(0);
}
private void updateSubscriptionPeriod(Invoice invoice, SubscriptionInfo subscriptionInfo) {
InvoiceLineItem.Period period = invoice.getLines().getData().getFirst().getPeriod();
Long periodStart = period.getStart();
Long periodEnd = period.getEnd();
if (periodStart != null) {
subscriptionInfo.setCurrentPeriodStart(periodStart);
}
if (periodEnd != null) {
subscriptionInfo.setCurrentPeriodEnd(periodEnd);
subscriptionInfo.setNextPayDate(DateUtil.changeTimeStampFormat(periodEnd, "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy_EEEE));
}
subscriptionInfo.setUpdateTime(java.time.LocalDateTime.now());
subscriptionInfoMapper.updateById(subscriptionInfo);
log.info("[invoice.paid] 订阅有效期已更新subscriptionId={}periodStart={}periodEnd={}",
subscriptionInfo.getSubscriptionId(), periodStart, periodEnd);
}
private void sendRenewalNotification(SubscriptionInfo subscriptionInfo) {
try {
if (subscriptionInfo.getSubscriptionId() != null) {
stripeSubscriptionService.sendSubscriptionEmail(null, "renewal", subscriptionInfo.getOrderNo(), subscriptionInfo);
}
} catch (Exception e) {
log.warn("[invoice.paid] 发送续费通知失败error={}", e.getMessage());
}
}
private void createOrUpdatePaymentInfo(Invoice invoice, String subscriptionId) {
try {
Map<String, String> paymentMethodInfo = paymentInfoService.getPaymentMethodInfo(null, subscriptionId);
PaymentInfo paymentInfo = paymentInfoService.createOrUpdatePaymentInfoForStripe(invoice, paymentMethodInfo, null);
if (paymentInfo != null) {
log.info("[invoice.paid] 支付记录已创建/更新invoiceId={}, paymentId={}, orderNo={}, tradeState={}",
invoice.getId(), paymentInfo.getId(), paymentInfo.getOrderNo(), paymentInfo.getTradeState());
}
} catch (Exception e) {
log.error("[invoice.paid] 创建/更新支付记录失败invoiceId={}, error={}", invoice.getId(), e.getMessage(), e);
throw new RuntimeException("创建/更新支付记录失败: " + invoice.getId(), e);
}
}
}

View File

@@ -0,0 +1,158 @@
package com.ai.da.service.stripe.handler;
import com.ai.da.common.constant.CommonConstant;
import com.ai.da.common.utils.DateUtil;
import com.ai.da.mapper.primary.SubscriptionInfoMapper;
import com.ai.da.mapper.primary.entity.PaymentInfo;
import com.ai.da.mapper.primary.entity.SubscriptionInfo;
import com.ai.da.service.PaymentInfoService;
import com.ai.da.service.StripeSubscriptionService;
import com.stripe.model.Invoice;
import com.stripe.model.InvoiceLineItem;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* invoice.payment_failed 事件处理器
* 业务场景:订阅续费扣款失败
* 业务动作:更新订阅状态为 past_due、发送续费失败通知
*/
@Component
@Slf4j
@Order(31)
public class InvoicePaymentFailedHandler implements StripeEventHandler {
private static final String EVENT_TYPE = "invoice.payment_failed";
@Resource
private StripeSubscriptionService stripeSubscriptionService;
@Resource
private PaymentInfoService paymentInfoService;
@Resource
private SubscriptionInfoMapper subscriptionInfoMapper;
@Override
public List<String> getSupportedEventTypes() {
return Collections.singletonList(EVENT_TYPE);
}
@Override
public Class<Invoice> getSupportObjectType() {
return Invoice.class;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean handle(String eventType, com.stripe.model.StripeObject stripeObject) {
Invoice invoice = (Invoice) stripeObject;
String invoiceId = invoice.getId();
long startTime = System.currentTimeMillis();
try {
log.info("[invoice.payment_failed] 开始处理invoiceId={}", invoiceId);
String subscriptionId = getSubscriptionByInvoice(invoice);
if (subscriptionId == null) {
log.info("[invoice.payment_failed] 无法获取订阅ID跳过invoiceId={}", invoiceId);
return true;
}
SubscriptionInfo subscriptionInfo = findSubscriptionInfo(subscriptionId);
if (subscriptionInfo == null) {
log.info("[invoice.payment_failed] 未找到本地订阅记录跳过subscriptionId={}", subscriptionId);
return true;
}
createOrUpdatePaymentInfo(invoice, subscriptionId);
InvoiceLineItem.Period period = invoice.getLines().getData().getFirst().getPeriod();
Long periodStart = period.getStart();
Long periodEnd = period.getEnd();
if (periodStart != null) {
subscriptionInfo.setCurrentPeriodStart(periodStart);
}
if (periodEnd != null) {
subscriptionInfo.setCurrentPeriodEnd(periodEnd);
subscriptionInfo.setNextPayDate(DateUtil.changeTimeStampFormat(periodEnd, "seconds", CommonConstant.TIME_FORMAT_MMM_dd_yyyy_EEEE));
}
if (invoice.getBillingReason().equals("subscription_cycle")) {
sendPaymentFailedNotification(subscriptionInfo);
}
subscriptionInfo.setStatus("past_due");
subscriptionInfo.setUpdateTime(java.time.LocalDateTime.now());
subscriptionInfoMapper.updateById(subscriptionInfo);
log.info("[invoice.payment_failed] 处理完成invoiceId={}subscriptionId={},耗时={}ms",
invoiceId, subscriptionId, System.currentTimeMillis() - startTime);
return true;
} catch (Exception e) {
log.error("[invoice.payment_failed] 处理异常invoiceId={}error={}", invoiceId, e.getMessage(), e);
return false;
}
}
/**
* 从 Invoice 中获取 subscriptionId
* Stripe SDK 32.0.0: 使用 invoice.getParent().getSubscriptionDetails().getSubscription() 替代已移除的 invoice.getSubscription()
*
* @param invoice Stripe Invoice
* @return subscriptionId 或 null
*/
private String getSubscriptionByInvoice(Invoice invoice) {
try {
Invoice.Parent parent = invoice.getParent();
if (parent != null && "subscription_details".equals(parent.getType())) {
Invoice.Parent.SubscriptionDetails subscriptionDetails = parent.getSubscriptionDetails();
if (subscriptionDetails != null) {
return subscriptionDetails.getSubscription();
}
}
} catch (Exception e) {
log.warn("[getSubscriptionByInvoice] 从 invoice.getParent() 获取 subscriptionId 失败invoiceId={}, error={}",
invoice.getId(), e.getMessage());
}
return null;
}
private SubscriptionInfo findSubscriptionInfo(String subscriptionId) {
var list = subscriptionInfoMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SubscriptionInfo>()
.eq("subscription_id", subscriptionId)
);
return list.isEmpty() ? null : list.get(0);
}
private void sendPaymentFailedNotification(SubscriptionInfo subscriptionInfo) {
try {
if (subscriptionInfo.getSubscriptionId() != null) {
stripeSubscriptionService.sendSubscriptionEmail(null, "fail_renewal", subscriptionInfo.getOrderNo(), subscriptionInfo);
}
} catch (Exception e) {
log.warn("[invoice.payment_failed] 发送通知失败error={}", e.getMessage());
}
}
private void createOrUpdatePaymentInfo(Invoice invoice, String subscriptionId) {
try {
Map<String, String> paymentMethodInfo = paymentInfoService.getPaymentMethodInfo(null, subscriptionId);
PaymentInfo paymentInfo = paymentInfoService.createOrUpdatePaymentInfoForStripe(invoice, paymentMethodInfo, null);
if (paymentInfo != null) {
log.info("[invoice.payment_failed] 支付记录已创建/更新invoiceId={}, paymentId={}, orderNo={}, tradeState={}",
invoice.getId(), paymentInfo.getId(), paymentInfo.getOrderNo(), paymentInfo.getTradeState());
}
} catch (Exception e) {
log.error("[invoice.payment_failed] 创建/更新支付记录失败invoiceId={}, error={}", invoice.getId(), e.getMessage(), e);
throw new RuntimeException("创建/更新支付记录失败: " + invoice.getId(), e);
}
}
}

View File

@@ -0,0 +1,88 @@
package com.ai.da.service.stripe.handler;
import com.ai.da.mapper.primary.entity.RefundInfo;
import com.ai.da.service.RefundInfoService;
import com.stripe.model.Refund;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.util.Arrays;
import java.util.List;
/**
* Stripe Refund 事件处理器
* 支持多事件类型refund.created / refund.updated / refund.failed
*
* 业务场景:
* - refund.created退款创建时在 t_refund_info 表中创建记录
* - refund.updatedstatus=succeeded退款完成时更新退款状态
* 并找到该笔退款对应的 invoice修改 paymentInfo 表中 transactionId 为 invoiceId 的记录,将状态改为 refunded
* - refund.failed退款失败时修改 t_refund_info 表状态,并邮件通知商家
*/
@Component
@Slf4j
@Order(60)
public class RefundEventHandler implements StripeEventHandler {
private static final String EVENT_REFUND_CREATED = "refund.created";
private static final String EVENT_REFUND_UPDATED = "refund.updated";
private static final String EVENT_REFUND_FAILED = "refund.failed";
@Resource
private RefundInfoService refundInfoService;
@Override
public List<String> getSupportedEventTypes() {
return Arrays.asList(EVENT_REFUND_CREATED, EVENT_REFUND_UPDATED, EVENT_REFUND_FAILED);
}
@Override
public Class<Refund> getSupportObjectType() {
return Refund.class;
}
@Override
public boolean handle(String eventType, com.stripe.model.StripeObject stripeObject) {
Refund refund = (Refund) stripeObject;
String refundId = refund.getId();
long startTime = System.currentTimeMillis();
try {
if (EVENT_REFUND_CREATED.equals(eventType)) {
log.info("[refund.created] 开始处理refundId={}", refundId);
RefundInfo refundInfo = refundInfoService.handleRefundCreated(refund);
log.info("[refund.created] 处理完成refundId={}orderNo={},耗时={}ms",
refundId, refundInfo != null ? refundInfo.getOrderNo() : "N/A", System.currentTimeMillis() - startTime);
} else if (EVENT_REFUND_UPDATED.equals(eventType)) {
log.info("[refund.updated] 开始处理refundId={}status={}", refundId, refund.getStatus());
String status = refund.getStatus();
if ("succeeded".equals(status)) {
RefundInfo refundInfo = refundInfoService.handleRefundSucceeded(refund);
log.info("[refund.updated] 退款成功处理完成refundId={}orderNo={},耗时={}ms",
refundId, refundInfo != null ? refundInfo.getOrderNo() : "N/A", System.currentTimeMillis() - startTime);
} else {
// 其他状态(如 pending、canceled 等),仅更新状态
RefundInfo refundInfo = refundInfoService.updateRefundStatusForStripe(refund);
log.info("[refund.updated] 退款状态已更新refundId={}status={},耗时={}ms",
refundId, status, System.currentTimeMillis() - startTime);
}
} else if (EVENT_REFUND_FAILED.equals(eventType)) {
log.info("[refund.failed] 开始处理refundId={}reason={}", refundId, refund.getFailureReason());
RefundInfo refundInfo = refundInfoService.handleRefundFailed(refund);
log.info("[refund.failed] 处理完成refundId={},耗时={}ms",
refundId, System.currentTimeMillis() - startTime);
} else {
log.warn("[RefundEventHandler] 未知事件类型eventType={}", eventType);
}
return true;
} catch (Exception e) {
log.error("[{}] 处理异常refundId={}error={}", eventType, refundId, e.getMessage(), e);
return false;
}
}
}

View File

@@ -0,0 +1,63 @@
package com.ai.da.service.stripe.handler;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.stream.Collectors;
/**
* Stripe 事件分发器
* 基于策略模式,根据事件类型自动路由到对应的处理器
* 使用 Spring 的 @Order 注解控制处理器优先级
*/
@Component
public class StripeEventDispatcher {
private final Map<String, StripeEventHandler> handlerMap;
public StripeEventDispatcher(List<StripeEventHandler> handlers) {
// 相同 eventType 取优先级最高的 handler@Order 小的优先)
this.handlerMap = Collections.unmodifiableMap(
handlers.stream()
.sorted(Comparator.comparingInt(h -> {
Order order = h.getClass().getAnnotation(Order.class);
return order != null ? order.value() : Integer.MAX_VALUE;
}))
.flatMap(h -> h.getSupportedEventTypes().stream()
.filter(et -> et != null && !et.isEmpty())
.map(et -> new AbstractMap.SimpleEntry<>(et, h)))
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(existing, replacement) -> existing
))
);
}
/**
* 根据事件类型分发处理
* @param eventType 事件类型
* @param stripeObject Stripe 对象
* @return true=处理成功false=处理失败
*/
public boolean dispatch(String eventType, com.stripe.model.StripeObject stripeObject) {
StripeEventHandler handler = handlerMap.get(eventType);
if (handler == null) {
return true; // 未注册的事件类型默认成功
}
if (!handler.getSupportObjectType().isInstance(stripeObject)) {
return true; // 类型不匹配但不影响业务
}
return handler.handle(eventType, stripeObject);
}
/**
* 获取已注册的事件类型列表
*/
public List<String> getRegisteredEventTypes() {
return List.copyOf(handlerMap.keySet());
}
}

View File

@@ -0,0 +1,30 @@
package com.ai.da.service.stripe.handler;
import com.stripe.model.StripeObject;
import java.util.List;
/**
* Stripe Webhook 事件处理器接口
* 使用策略模式,支持 Spring 自动注册
*/
public interface StripeEventHandler {
/**
* 获取该处理器支持的所有事件类型
*/
List<String> getSupportedEventTypes();
/**
* 获取该处理器支持的对象类型
*/
Class<? extends StripeObject> getSupportObjectType();
/**
* 处理事件
* @param eventType 事件类型
* @param stripeObject Stripe 对象
* @return true=处理成功false=处理失败(可重试)
*/
boolean handle(String eventType, StripeObject stripeObject);
}

View File

@@ -0,0 +1,94 @@
package com.ai.da.service.stripe.handler;
import com.ai.da.common.utils.RedisUtil;
import com.ai.da.mapper.primary.SubscriptionInfoMapper;
import com.ai.da.mapper.primary.entity.SubscriptionInfo;
import com.ai.da.service.StripeSubscriptionService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.util.Collections;
import java.util.List;
/**
* customer.subscription.deleted 事件处理器
* 业务场景:取消订阅(立即取消)
* 业务动作:更新订阅状态、撤销权限、发送通知
*/
@Component
@Slf4j
@Order(40)
public class SubscriptionDeletedHandler implements StripeEventHandler {
private static final String EVENT_TYPE = "customer.subscription.deleted";
@Resource
private StripeSubscriptionService stripeSubscriptionService;
@Resource
private SubscriptionInfoMapper subscriptionInfoMapper;
@Resource
private RedisUtil redisUtil;
@Override
public List<String> getSupportedEventTypes() {
return Collections.singletonList(EVENT_TYPE);
}
@Override
public Class<com.stripe.model.Subscription> getSupportObjectType() {
return com.stripe.model.Subscription.class;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean handle(String eventType, com.stripe.model.StripeObject stripeObject) {
com.stripe.model.Subscription subscription = (com.stripe.model.Subscription) stripeObject;
String eventId = subscription.getId();
long startTime = System.currentTimeMillis();
try {
log.info("[customer.subscription.deleted] 开始处理subscriptionId={}", eventId);
// 查找本地订阅记录
List<SubscriptionInfo> subInfoList = subscriptionInfoMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SubscriptionInfo>()
.eq("subscription_id", eventId)
);
if (subInfoList.isEmpty()) {
log.info("[customer.subscription.deleted] 取消订阅未找到本地记录跳过subscriptionId={}", eventId);
return true;
}
SubscriptionInfo subscriptionInfo = subInfoList.getFirst();
// 发送取消订阅通知邮件
if (subscriptionInfo.getCancelNotified() == 0) {
boolean sent = stripeSubscriptionService.sendSubscriptionEmail(null, "cancel", subscriptionInfo.getOrderNo(), subscriptionInfo);
if (sent) {
subscriptionInfo.setCancelNotified((byte) 1);
log.info("[customer.subscription.deleted] 取消订阅通知已发送subscriptionId={}accountId={}",
eventId, subscriptionInfo.getAccountId());
}
}
String reasonKey = "stripe:cancel:reason:" + subscriptionInfo.getSubscriptionId();
String cancelReason = redisUtil.getFromString(reasonKey);
subscriptionInfo.setStatus("canceled");
subscriptionInfo.setNextPayDate("--");
subscriptionInfo.setCancelReason(cancelReason);
subscriptionInfo.setUpdateTime(java.time.LocalDateTime.now());
subscriptionInfoMapper.updateById(subscriptionInfo);
log.info("[customer.subscription.deleted] 处理完成subscriptionId={},耗时={}ms",
eventId, System.currentTimeMillis() - startTime);
return true;
} catch (Exception e) {
log.error("[customer.subscription.deleted] 处理异常subscriptionId={}error={}", eventId, e.getMessage(), e);
return false;
}
}
}

View File

@@ -181,4 +181,10 @@ file.upload.max.size.video=104857600
# 上传任务过期时间(小时) # 上传任务过期时间(小时)
file.upload.task.expiry.hours=24 file.upload.task.expiry.hours=24
global.award.link=https://develop.aida.com.hk/award/contestants?id= global.award.link=https://aida-global-design-awards.com.hk/contestants?id=
# merchant email receivers (comma-separated, multiple supported)
# dev/local: developer.email 不配置,使用默认值 xupei3360@163.com
# prod: 两个都配置
merchant.email=
developer.email=xupei@code-create.com.hk,yizhang@aidlab.hk

View File

@@ -179,4 +179,9 @@ file.upload.max.size.video=104857600
# 上传任务过期时间(小时) # 上传任务过期时间(小时)
file.upload.task.expiry.hours=24 file.upload.task.expiry.hours=24
global.award.link=https://www.aida.com.hk/award/contestants?id= global.award.link=https://aida-global-design-awards.com.hk/contestants?id=
# merchant email receivers (comma-separated, multiple supported)
# prod: includes merchant email
merchant.email=kimwong@code-create.com.hk
developer.email=xupei3360@163.com

View File

@@ -51,43 +51,41 @@
a.user_email email, a.user_email email,
p.payment_type platform, p.payment_type platform,
p.payer_total, p.payer_total,
p.type, CASE
WHEN o.title LIKE '%Subscription' THEN 'Subscription'
ELSE 'Credits'
END AS type,
p.payment_method, p.payment_method,
p.last4, p.last4,
p.country, p.country,
p.city, p.city,
p.create_time, p.create_time,
CASE CASE
WHEN p.trade_state IN ('paid', 'COMPLETED', 'complete', 'liquidated') THEN 'Success'
WHEN p.trade_state IN ( 'paid', 'COMPLETED', 'complete', 'liquidated' ) THEN WHEN p.trade_state IN ('failed', 'expired', 'VOIDED', 'void', 'uncollectible') THEN 'Fail'
'Success' WHEN p.trade_state IN ('Refunded') THEN 'Refunded'
WHEN p.trade_state IN ( 'failed', 'expired', 'VOIDED', 'void', 'uncollectible' ) THEN ELSE 'Pending'
'Fail' ELSE 'Pending'
END AS status END AS status
FROM FROM t_payment_info p
t_payment_info p LEFT JOIN t_order_info o ON p.order_no = o.order_no
LEFT JOIN LEFT JOIN t_account a ON a.id = o.account_id
t_order_info o ON p.order_no = o.order_no WHERE 1 = 1
LEFT JOIN
t_account a ON a.id = o.account_id
WHERE
1 = 1
<if test="paymentType != null and paymentType != ''"> <if test="paymentType != null and paymentType != ''">
AND p.payment_type = #{paymentType} AND p.payment_type = #{paymentType}
</if> </if>
<if test="payerTotal != null and payerTotal != ''"> <if test="payerTotal != null and payerTotal != ''">
AND p.payer_total = #{payerTotal} AND p.payer_total = #{payerTotal}
</if> </if>
<if test="type != null and type != ''"> <!-- 修复1删除 type 过滤条件,因为 type 是计算字段 -->
AND p.type = #{type}
</if>
<if test="status != null and status != ''"> <if test="status != null and status != ''">
AND AND (
CASE (p.trade_state IN ('paid', 'COMPLETED', 'complete', 'liquidated') AND #{status} = 'Success')
WHEN p.trade_state IN ('paid', 'COMPLETED', 'complete', 'liquidated') THEN 'Success' OR (p.trade_state IN ('failed', 'expired', 'VOIDED', 'void', 'uncollectible') AND #{status} = 'Fail')
WHEN p.trade_state IN ('failed', 'expired', 'VOIDED', 'void', 'uncollectible') THEN 'Fail' OR (p.trade_state IN ('Refunded') AND #{status} = 'Refunded')
ELSE 'Pending' OR (p.trade_state NOT IN ('paid', 'COMPLETED', 'complete', 'liquidated',
END = #{status} 'failed', 'expired', 'VOIDED', 'void', 'uncollectible', 'Refunded')
AND #{status} = 'Pending')
)
</if> </if>
<if test="country != null and country != ''"> <if test="country != null and country != ''">
AND p.country = #{country} AND p.country = #{country}
@@ -102,9 +100,8 @@
AND a.user_name = #{payer} AND a.user_name = #{payer}
</if> </if>
AND p.transaction_id NOT LIKE 'cs_test%' AND p.transaction_id NOT LIKE 'cs_test%'
ORDER BY ORDER BY p.id ${order} <!-- 建议使用白名单校验 -->
p.id ${order} LIMIT #{limit} OFFSET #{offset} <!-- 修复:改为 #{} -->
LIMIT ${limit} OFFSET ${offset}
</select> </select>
<select id="queryPaymentInfoCount" resultType="java.lang.Long"> <select id="queryPaymentInfoCount" resultType="java.lang.Long">
@@ -132,6 +129,7 @@
CASE CASE
WHEN p.trade_state IN ('paid', 'COMPLETED', 'complete', 'liquidated') THEN 'Success' WHEN p.trade_state IN ('paid', 'COMPLETED', 'complete', 'liquidated') THEN 'Success'
WHEN p.trade_state IN ('failed', 'expired', 'VOIDED', 'void', 'uncollectible') THEN 'Fail' WHEN p.trade_state IN ('failed', 'expired', 'VOIDED', 'void', 'uncollectible') THEN 'Fail'
WHEN p.trade_state IN ('Refunded') THEN 'Refunded'
ELSE 'Pending' ELSE 'Pending'
END = #{status} END = #{status}
</if> </if>
@@ -170,6 +168,7 @@
CASE CASE
WHEN p.trade_state IN ('paid', 'COMPLETED', 'complete', 'liquidated') THEN 'Success' WHEN p.trade_state IN ('paid', 'COMPLETED', 'complete', 'liquidated') THEN 'Success'
WHEN p.trade_state IN ('failed', 'expired', 'VOIDED', 'void', 'uncollectible') THEN 'Fail' WHEN p.trade_state IN ('failed', 'expired', 'VOIDED', 'void', 'uncollectible') THEN 'Fail'
WHEN p.trade_state IN ('Refunded') THEN 'Refunded'
ELSE 'Pending' ELSE 'Pending'
END = #{status} END = #{status}
</if> </if>

View File

@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ai.da.mapper.primary.SubscriptionInfoMapper"> <mapper namespace="com.ai.da.mapper.primary.SubscriptionInfoMapper">
<insert id="insertIgnore" parameterType="com.ai.da.mapper.primary.entity.SubscriptionInfo"> <insert id="insertIgnore" parameterType="com.ai.da.mapper.primary.entity.SubscriptionInfo"
useGeneratedKeys="true" keyProperty="id">
INSERT IGNORE INTO INSERT IGNORE INTO
t_subscription_info (account_id, order_no, subscription_id, type, status, cancel_notified, t_subscription_info (account_id, order_no, subscription_id, type, status, cancel_notified,
next_pay_date, current_period_start, current_period_end, create_time) next_pay_date, current_period_start, current_period_end, create_time)

View File

@@ -17,3 +17,5 @@
</mapper> </mapper>

View File

@@ -11,18 +11,18 @@
#paypal.webhook_id=51V87014T6406322F #paypal.webhook_id=51V87014T6406322F
# aida-sandbox-kim # aida-sandbox-kim
#paypal.client-id=AbDDH8jnTrKqjnWLFgEu6LogYzVz2ZLuirE4W54t1M4lrofrP5OzXfhbxqktLLFB-rAO9KeYQVYFJ_tO paypal.client-id=AbDDH8jnTrKqjnWLFgEu6LogYzVz2ZLuirE4W54t1M4lrofrP5OzXfhbxqktLLFB-rAO9KeYQVYFJ_tO
#paypal.client-secret=EOOoiIAe_dyR2YhY7qCIqWipZvYXCDrmBlFYchphuvkPFms1spsBGTlStlrx580y4hN-EukWwF9m_LAs paypal.client-secret=EOOoiIAe_dyR2YhY7qCIqWipZvYXCDrmBlFYchphuvkPFms1spsBGTlStlrx580y4hN-EukWwF9m_LAs
#paypal.receiver.email=sb-4xe8i29784722@business.example.com paypal.receiver.email=sb-4xe8i29784722@business.example.com
#paypal.mode=sandbox paypal.mode=sandbox
#paypal.webhook_id=1WH327112B602422N paypal.webhook_id=1WH327112B602422N
# aida-live-kim # aida-live-kim
paypal.client-id=ASWSIZ3MXJU5w5VOeOHeigWcSw6iinl30ZCipruziKpHclxP0ryf8-7VKG1Ba2VwZwa2DMvGEzTfCTgz #paypal.client-id=ASWSIZ3MXJU5w5VOeOHeigWcSw6iinl30ZCipruziKpHclxP0ryf8-7VKG1Ba2VwZwa2DMvGEzTfCTgz
paypal.client-secret=EHQg_K5PSqmp4FJlzEcOEH_kFkmq4aBzaI7jridw53L6cOQRULBAnfv2KakRfrsqaU1PDSkO4Co9Vyxc #paypal.client-secret=EHQg_K5PSqmp4FJlzEcOEH_kFkmq4aBzaI7jridw53L6cOQRULBAnfv2KakRfrsqaU1PDSkO4Co9Vyxc
paypal.receiver.email=kimwong@code-create.com.hk #paypal.receiver.email=kimwong@code-create.com.hk
paypal.mode=live #paypal.mode=live
paypal.webhook_id=1D107312EX592781K #paypal.webhook_id=1D107312EX592781K
##### Stripe ##### Stripe