From cf02b59722cd9984f37935cba1b38de91fa90537 Mon Sep 17 00:00:00 2001 From: xupei Date: Wed, 29 Apr 2026 17:16:48 +0800 Subject: [PATCH] =?UTF-8?q?TASK:Stripe=E6=94=AF=E4=BB=98=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E9=87=8D=E6=9E=84-=E9=80=BB=E8=BE=91=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E5=AE=8C=E5=96=84=E3=80=81Stripe=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/develop_build_manual.yaml | 2 - pom.xml | 2 +- .../ai/da/common/enums/CreditsEventsEnum.java | 2 +- .../ai/da/common/enums/OrderStatusEnum.java | 5 + .../ai/da/common/enums/PaymentInfoType.java | 19 + .../com/ai/da/common/enums/ProductEnum.java | 20 +- .../com/ai/da/common/utils/RedisUtil.java | 20 + .../ai/da/controller/StripeController.java | 36 +- .../da/mapper/primary/entity/OrderInfo.java | 2 - .../ai/da/model/dto/ProductPurchaseDTO.java | 2 + .../com/ai/da/service/OrderInfoService.java | 2 +- .../com/ai/da/service/PaymentInfoService.java | 18 +- .../com/ai/da/service/RefundInfoService.java | 8 + .../java/com/ai/da/service/StripeService.java | 22 - .../da/service/StripeSubscriptionService.java | 59 ++ .../ai/da/service/StripeWebhookService.java | 13 + .../da/service/impl/AccountServiceImpl.java | 16 +- .../da/service/impl/OrderInfoServiceImpl.java | 6 +- .../service/impl/PaymentInfoServiceImpl.java | 650 +++++++++++++--- .../service/impl/RefundInfoServiceImpl.java | 311 +++++++- .../ai/da/service/impl/StripeServiceImpl.java | 729 ++---------------- .../impl/StripeSubscriptionServiceImpl.java | 598 ++++++++++++++ .../impl/StripeWebhookServiceImpl.java | 154 ++++ .../config/StripeWebhookAsyncConfig.java | 35 + .../CheckoutSessionCompletedHandler.java | 292 +++++++ .../CheckoutSessionExpiredHandler.java | 122 +++ .../CustomerSubscriptionUpdateHandler.java | 125 +++ .../stripe/handler/InvoicePaidHandler.java | 180 +++++ .../handler/InvoicePaymentFailedHandler.java | 158 ++++ .../stripe/handler/RefundEventHandler.java | 88 +++ .../stripe/handler/StripeEventDispatcher.java | 63 ++ .../stripe/handler/StripeEventHandler.java | 30 + .../handler/SubscriptionDeletedHandler.java | 94 +++ .../mapper/primary/PaymentInfoMapper.xml | 8 +- 34 files changed, 3032 insertions(+), 859 deletions(-) create mode 100644 src/main/java/com/ai/da/common/enums/PaymentInfoType.java create mode 100644 src/main/java/com/ai/da/service/StripeSubscriptionService.java create mode 100644 src/main/java/com/ai/da/service/StripeWebhookService.java create mode 100644 src/main/java/com/ai/da/service/impl/StripeSubscriptionServiceImpl.java create mode 100644 src/main/java/com/ai/da/service/impl/StripeWebhookServiceImpl.java create mode 100644 src/main/java/com/ai/da/service/stripe/config/StripeWebhookAsyncConfig.java create mode 100644 src/main/java/com/ai/da/service/stripe/handler/CheckoutSessionCompletedHandler.java create mode 100644 src/main/java/com/ai/da/service/stripe/handler/CheckoutSessionExpiredHandler.java create mode 100644 src/main/java/com/ai/da/service/stripe/handler/CustomerSubscriptionUpdateHandler.java create mode 100644 src/main/java/com/ai/da/service/stripe/handler/InvoicePaidHandler.java create mode 100644 src/main/java/com/ai/da/service/stripe/handler/InvoicePaymentFailedHandler.java create mode 100644 src/main/java/com/ai/da/service/stripe/handler/RefundEventHandler.java create mode 100644 src/main/java/com/ai/da/service/stripe/handler/StripeEventDispatcher.java create mode 100644 src/main/java/com/ai/da/service/stripe/handler/StripeEventHandler.java create mode 100644 src/main/java/com/ai/da/service/stripe/handler/SubscriptionDeletedHandler.java diff --git a/.gitea/workflows/develop_build_manual.yaml b/.gitea/workflows/develop_build_manual.yaml index 59b2321d..e32f3ddd 100644 --- a/.gitea/workflows/develop_build_manual.yaml +++ b/.gitea/workflows/develop_build_manual.yaml @@ -135,8 +135,6 @@ jobs: cd ${{ env.REMOTE_DEPLOY_PATH }} echo "停止旧容器..." docker compose down || true - echo "清理Docker资源..." - docker system prune -f echo "构建镜像..." docker compose build --no-cache echo "启动服务..." diff --git a/pom.xml b/pom.xml index 9c14ce4b..c875ac6b 100644 --- a/pom.xml +++ b/pom.xml @@ -240,7 +240,7 @@ com.stripe stripe-java - 26.2.0 + 32.0.0 diff --git a/src/main/java/com/ai/da/common/enums/CreditsEventsEnum.java b/src/main/java/com/ai/da/common/enums/CreditsEventsEnum.java index ab93ba3b..e3185443 100644 --- a/src/main/java/com/ai/da/common/enums/CreditsEventsEnum.java +++ b/src/main/java/com/ai/da/common/enums/CreditsEventsEnum.java @@ -30,7 +30,7 @@ public enum CreditsEventsEnum { INIT_QUARTERLY("init_quarterly", "12000"), INIT_MONTHLY_EDU("init_monthly_edu", "3500"), INIT_TRIAL("init_trial", "100"), - INIT_WEEKLY("init_weekly","6000"), + INIT_DAILY("init_daily","100"), RESET_YEAR_CREDITS("reset_year_credits","6000"), // SUPER_RESOLUTION("Super Resolution","30"), diff --git a/src/main/java/com/ai/da/common/enums/OrderStatusEnum.java b/src/main/java/com/ai/da/common/enums/OrderStatusEnum.java index 02767686..2baed360 100644 --- a/src/main/java/com/ai/da/common/enums/OrderStatusEnum.java +++ b/src/main/java/com/ai/da/common/enums/OrderStatusEnum.java @@ -34,6 +34,11 @@ public enum OrderStatusEnum { * 已退款 */ REFUND_SUCCESS("已退款"), + + /** + * 已部分退款 + */ + PARTIAL_REFUND_SUCCESS("已部分退款"), /** * 退款异常 */ diff --git a/src/main/java/com/ai/da/common/enums/PaymentInfoType.java b/src/main/java/com/ai/da/common/enums/PaymentInfoType.java new file mode 100644 index 00000000..a7c20929 --- /dev/null +++ b/src/main/java/com/ai/da/common/enums/PaymentInfoType.java @@ -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; +} diff --git a/src/main/java/com/ai/da/common/enums/ProductEnum.java b/src/main/java/com/ai/da/common/enums/ProductEnum.java index c68a7385..a37568fd 100644 --- a/src/main/java/com/ai/da/common/enums/ProductEnum.java +++ b/src/main/java/com/ai/da/common/enums/ProductEnum.java @@ -3,6 +3,8 @@ package com.ai.da.common.enums; import lombok.AllArgsConstructor; import lombok.Getter; +import java.util.Arrays; + @Getter @AllArgsConstructor public enum ProductEnum { @@ -23,11 +25,27 @@ public enum ProductEnum { ; /** - * 类型 + * 显示名称(用于与 orderInfo.title 匹配) */ private final String name; private final Long price; 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); + } } diff --git a/src/main/java/com/ai/da/common/utils/RedisUtil.java b/src/main/java/com/ai/da/common/utils/RedisUtil.java index c91411fa..207070a4 100644 --- a/src/main/java/com/ai/da/common/utils/RedisUtil.java +++ b/src/main/java/com/ai/da/common/utils/RedisUtil.java @@ -549,6 +549,26 @@ public class RedisUtil { public final static String STRIPE_EXCEPTION_LOG = "StripeException:"; public final static String SUBSCRIPTION_SENT_EMAIL_TYPE = "SubscriptionEmailSentType:"; + private static final String STRIPE_WEBHOOK_PROCESSED_PREFIX = "StripeWebhook:processed:"; + + /** + * 尝试将 webhook eventId 标记为已处理(SETNX 语义) + * @return true=该事件之前未处理(本次处理),false=该事件已处理过(跳过) + */ + public boolean tryMarkWebhookProcessed(String eventId, long expireSeconds) { + String key = STRIPE_WEBHOOK_PROCESSED_PREFIX + eventId; + Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1", expireSeconds, TimeUnit.SECONDS); + return Boolean.TRUE.equals(result); + } + + /** + * 检查 webhook eventId 是否已处理 + */ + public boolean isWebhookProcessed(String eventId) { + String key = STRIPE_WEBHOOK_PROCESSED_PREFIX + eventId; + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } + public void batchDeleteKeysWithSamePrefix(String prefix){ Set keys = redisTemplate.keys(prefix + "*"); assert keys != null; diff --git a/src/main/java/com/ai/da/controller/StripeController.java b/src/main/java/com/ai/da/controller/StripeController.java index dd12385d..149382d1 100644 --- a/src/main/java/com/ai/da/controller/StripeController.java +++ b/src/main/java/com/ai/da/controller/StripeController.java @@ -1,5 +1,6 @@ package com.ai.da.controller; +import com.ai.da.common.context.UserContext; import com.ai.da.common.response.Response; import com.ai.da.common.utils.DateUtil; 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.vo.CheckCouponsVO; import com.ai.da.service.StripeService; +import com.ai.da.service.StripeSubscriptionService; import com.baomidou.mybatisplus.core.metadata.IPage; import com.paypal.http.HttpResponse; import com.paypal.payments.Refund; @@ -40,6 +42,8 @@ public class StripeController { private StripeService stripeService; @Resource private RedisUtil redisUtil; + @Resource + private StripeSubscriptionService stripeSubscriptionService; @Operation(summary = "创建支付链接") @PostMapping("/createOrder") @@ -53,30 +57,29 @@ public class StripeController { @Operation(summary = "支付通知") @PostMapping("/trade/notify") public void callback(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - try{ - Boolean result = stripeService.notify(request); - if (result){ - response.setStatus(HttpServletResponse.SC_OK); - }else { - response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - } - }catch (Exception e){ - log.error("Stripe Controller层异常捕捉, {}", e.getMessage()); - e.printStackTrace(); + boolean result; + try { + result = stripeService.notify(request); + } catch (Exception e) { + log.error("Stripe Controller层异常捕捉, {}", e.getMessage(), e); 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); redisUtil.addToString(key_2, stackTrace); Long size = redisUtil.getSize(key_1); - // 给我发送邮件 - if (webhookReminderFlag.equals("1") && size == 3){ + if ("1".equals(webhookReminderFlag) && size == 3) { 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); } } - @Operation(summary = "申请退款") +/* @Operation(summary = "申请退款") @GetMapping("/trade/refund/{orderNo}/{reason}") public Response> refund(@PathVariable String orderNo, @PathVariable String reason) throws IOException { String response = stripeService.refund(null,orderNo,reason); @@ -85,7 +88,7 @@ public class StripeController { }else { return Response.fail("Request for refund failed."); } - } + }*/ @Operation(summary = "获取订阅") @GetMapping("/getSubscription") @@ -100,7 +103,8 @@ public class StripeController { @Operation(summary = "取消订阅") @GetMapping("/cancelSubscription") public Response 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"); } diff --git a/src/main/java/com/ai/da/mapper/primary/entity/OrderInfo.java b/src/main/java/com/ai/da/mapper/primary/entity/OrderInfo.java index dd462038..15ae0cee 100644 --- a/src/main/java/com/ai/da/mapper/primary/entity/OrderInfo.java +++ b/src/main/java/com/ai/da/mapper/primary/entity/OrderInfo.java @@ -23,8 +23,6 @@ public class OrderInfo extends BaseEntity{ private String note; - private byte autoRenewal; - private String paymentType;//支付方式 // 可用于标记用户订单是否首次订阅 diff --git a/src/main/java/com/ai/da/model/dto/ProductPurchaseDTO.java b/src/main/java/com/ai/da/model/dto/ProductPurchaseDTO.java index 179dea8a..a6245cad 100644 --- a/src/main/java/com/ai/da/model/dto/ProductPurchaseDTO.java +++ b/src/main/java/com/ai/da/model/dto/ProductPurchaseDTO.java @@ -1,5 +1,6 @@ package com.ai.da.model.dto; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -24,6 +25,7 @@ public class ProductPurchaseDTO { @Schema(description = "EcoMonth || Month || Year") private String subscribeType; + @Hidden @Schema(description = "是否自动续订 one_time || recurring") private Boolean autoRenewal; diff --git a/src/main/java/com/ai/da/service/OrderInfoService.java b/src/main/java/com/ai/da/service/OrderInfoService.java index 7c5deabf..ffe1402c 100644 --- a/src/main/java/com/ai/da/service/OrderInfoService.java +++ b/src/main/java/com/ai/da/service/OrderInfoService.java @@ -16,7 +16,7 @@ public interface OrderInfoService extends IService { OrderInfo createOrderByProductId(Integer productId, String paymentType, HttpServletRequest request); OrderInfo createOrderByProductId(Integer amount, String paymentType, ProductEnum product, - HttpServletRequest request, byte autoRenewal); + HttpServletRequest request); void saveCodeUrl(String orderNo, String codeUrl); diff --git a/src/main/java/com/ai/da/service/PaymentInfoService.java b/src/main/java/com/ai/da/service/PaymentInfoService.java index a9ef0453..8c68221b 100644 --- a/src/main/java/com/ai/da/service/PaymentInfoService.java +++ b/src/main/java/com/ai/da/service/PaymentInfoService.java @@ -9,6 +9,8 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.paypal.orders.Order; import com.stripe.model.Charge; import com.stripe.model.Invoice; +import com.stripe.model.PaymentMethod; +import com.stripe.model.checkout.Session; import java.util.List; import java.util.Map; @@ -23,9 +25,15 @@ public interface PaymentInfoService extends IService { void createPaymentInfoForAliPayHK(AlipayHKCallbackDTO alipayHKCallbackDTO, String type); - PaymentInfo createOrUpdatePaymentInfoForStripe(Invoice invoice); + void createOrUpdatePaymentInfoForStripe(Session session); - PaymentInfo createOrUpdatePaymentInfoForStripe(Charge charge); + Map getPaymentMethodInfo(String sessionId, String subscriptionId); + + PaymentMethod getPaymentMethodBySubscriptionId(String subscriptionId); + + PaymentInfo createOrUpdatePaymentInfoForStripe(Invoice invoice, Map paymentMethodInfo, List discounts); + +// PaymentInfo createOrUpdatePaymentInfoForStripe(Charge charge); List getPaymentInfoByOrderNo(String orderId, String order); @@ -35,5 +43,9 @@ public interface PaymentInfoService extends IService { List 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); } diff --git a/src/main/java/com/ai/da/service/RefundInfoService.java b/src/main/java/com/ai/da/service/RefundInfoService.java index d1cb0fe7..0a38ded6 100644 --- a/src/main/java/com/ai/da/service/RefundInfoService.java +++ b/src/main/java/com/ai/da/service/RefundInfoService.java @@ -24,10 +24,18 @@ public interface RefundInfoService extends IService { List getByChargeId(String chargeId); + RefundInfo getByRefundId(String refundId); + RefundInfo createRefundForStripe(Refund refund); RefundInfo updateRefundStatusForStripe(Refund refund); RefundInfo updateRefundForStripe(Charge charge); + RefundInfo handleRefundCreated(Refund refund); + + RefundInfo handleRefundSucceeded(Refund refund); + + RefundInfo handleRefundFailed(Refund refund); + } diff --git a/src/main/java/com/ai/da/service/StripeService.java b/src/main/java/com/ai/da/service/StripeService.java index 8ee77ccb..27a2457d 100644 --- a/src/main/java/com/ai/da/service/StripeService.java +++ b/src/main/java/com/ai/da/service/StripeService.java @@ -21,42 +21,20 @@ public interface StripeService { SubscriptionInfo getLatestSubscriptionInfoByAccountId(Long accountId); - String refund(String amount, String orderId, String reason); - void checkOrderStatus(String orderNo); List getSubscriptionIds(String name, String userEmail) throws StripeException; - Map getPaymentMethodByInvoiceId(String invoiceId); - - void cancelSubscription(String orderNo, String cancelReason); - void cancelSubscriptionTemp(String subscriptionId); - Map getPaymentMethod(String paymentMethodId); - boolean sendEmail(String subscriptionId, String type, String orderNo); String getLanguage(String language, String country, String type); - /*void updateSubscription(String subscriptionId); - - void resume(String subscriptionId);*/ - // void subscriptionReminder(); - void checkSubscriptionExpiration(); - String createSubscriptionTemp(String name, String email); - String changeCustomerPayment(String name, String email); - - boolean sendRenewalFailEmail(String invoiceId, String subscriptionId, String orderNo); - - List> getCustomerPaymentMethod(String name, String email); - - String detachCustomerAllPaymentMethod(String name, String email); - // Map getIp(HttpServletRequest request); String getStackTrace(Exception e, int maxLines); diff --git a/src/main/java/com/ai/da/service/StripeSubscriptionService.java b/src/main/java/com/ai/da/service/StripeSubscriptionService.java new file mode 100644 index 00000000..ea03ebfb --- /dev/null +++ b/src/main/java/com/ai/da/service/StripeSubscriptionService.java @@ -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); +} diff --git a/src/main/java/com/ai/da/service/StripeWebhookService.java b/src/main/java/com/ai/da/service/StripeWebhookService.java new file mode 100644 index 00000000..dc2c8fce --- /dev/null +++ b/src/main/java/com/ai/da/service/StripeWebhookService.java @@ -0,0 +1,13 @@ +package com.ai.da.service; + +import jakarta.servlet.http.HttpServletRequest; + +public interface StripeWebhookService { + + /** + * 处理 Stripe webhook 回调 + * @param request HTTP 请求 + * @return true=处理成功(返回200),false=处理失败(返回500,Stripe会重试) + */ + Boolean notify(HttpServletRequest request); +} diff --git a/src/main/java/com/ai/da/service/impl/AccountServiceImpl.java b/src/main/java/com/ai/da/service/impl/AccountServiceImpl.java index 246f3e94..5e12062b 100644 --- a/src/main/java/com/ai/da/service/impl/AccountServiceImpl.java +++ b/src/main/java/com/ai/da/service/impl/AccountServiceImpl.java @@ -1646,6 +1646,7 @@ public class AccountServiceImpl extends ServiceImpl impl log.warn("当前用户 {} 在AiDA中没有账号", email); throw new BusinessException("user.has.no.account", ResultEnum.PROMPT.getCode()); } + // 解决循环依赖问题 CreditsService creditsService = SpringUtils.getBean(CreditsService.class); // 2、先判断当前用户是否已经填写过问卷 CreditsDetail record = creditsService.getByAccountIdAndChangeEvent(account.getId(), "Fill out the questionnaire", "+100"); @@ -3400,34 +3401,35 @@ public class AccountServiceImpl extends ServiceImpl impl if (description.equals(ProductEnum.DailySubscription.getName())) { productCredits = ProductEnum.DailySubscription.getCredits(); account.setSystemUser(3); - account.setCredits(BigDecimal.valueOf(Long.parseLong(CreditsEventsEnum.INIT_WEEKLY.getValue()))); } else if (description.equals(ProductEnum.MonthlySubscription.getName())) { productCredits = ProductEnum.MonthlySubscription.getCredits(); account.setSystemUser(2); - account.setCredits(BigDecimal.valueOf(Long.parseLong(CreditsEventsEnum.INIT_MONTHLY.getValue()))); } else if (description.equals(ProductEnum.Eco_MonthlySubscription.getName())) { productCredits = ProductEnum.Eco_MonthlySubscription.getCredits(); account.setSystemUser(2); - account.setCredits(BigDecimal.valueOf(Long.parseLong(CreditsEventsEnum.INIT_MONTHLY_ECO.getValue()))); } else if (description.equals(ProductEnum.AnnualSubscription.getName())) { productCredits = ProductEnum.AnnualSubscription.getCredits(); account.setSystemUser(1); - account.setCredits(BigDecimal.valueOf(Long.parseLong(CreditsEventsEnum.INIT_YEARLY.getValue()))); } else { log.error("未知订阅类型: {}", description); return; } + account.setCredits(BigDecimal.valueOf(productCredits)); accountMapper.updateById(account); CreditsService creditsService = SpringUtils.getBean(CreditsService.class); - // 先判断是否已添加添加积分变更记录 - CreditsDetail creditsDetail = creditsService.queryDetailByTaskId(orderNo); + // 添加积分变更记录(订单续订时的积分变更也需要记录) todo 重置的记录不太准确 + creditsService.insertToCreditsDetail(accountId, + description + "--Stripe", + String.valueOf(productCredits), + "positive", orderNo); + /*CreditsDetail creditsDetail = creditsService.queryDetailByTaskId(orderNo); if (Objects.isNull(creditsDetail)) { creditsService.insertToCreditsDetail(accountId, description + "--Stripe", String.valueOf(productCredits), "positive", orderNo); - } + }*/ } else { log.error("orderNo: {} 无法找到对应的记录", orderNo); } diff --git a/src/main/java/com/ai/da/service/impl/OrderInfoServiceImpl.java b/src/main/java/com/ai/da/service/impl/OrderInfoServiceImpl.java index 5fdce3a2..79da4da9 100644 --- a/src/main/java/com/ai/da/service/impl/OrderInfoServiceImpl.java +++ b/src/main/java/com/ai/da/service/impl/OrderInfoServiceImpl.java @@ -31,6 +31,7 @@ import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; @@ -90,7 +91,7 @@ public class OrderInfoServiceImpl extends ServiceImpl qw = new QueryWrapper<>(); - qw.eq("order_no", orderNo); + qw.eq("order_no", orderNo).in("trade_state", Arrays.asList("paid", "COMPLETED", "")); List paymentInfos = paymentInfoMapper.selectList(qw); Float sum = paymentInfos.stream() .map(PaymentInfo::getPayerTotal) + .filter(Objects::nonNull) .reduce(0f, Float::sum); baseMapper.update( diff --git a/src/main/java/com/ai/da/service/impl/PaymentInfoServiceImpl.java b/src/main/java/com/ai/da/service/impl/PaymentInfoServiceImpl.java index 7d11ecaf..616d6848 100644 --- a/src/main/java/com/ai/da/service/impl/PaymentInfoServiceImpl.java +++ b/src/main/java/com/ai/da/service/impl/PaymentInfoServiceImpl.java @@ -2,9 +2,11 @@ package com.ai.da.service.impl; import com.ai.da.common.context.UserContext; 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.utils.SpringUtils; 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.PaymentInfo; import com.ai.da.mapper.primary.entity.ProductCoupons; @@ -20,13 +22,17 @@ import com.google.gson.Gson; import com.paypal.orders.Order; import com.stripe.Stripe; import com.stripe.exception.StripeException; -import com.stripe.model.Charge; -import com.stripe.model.Invoice; -import com.stripe.model.Subscription; +import com.stripe.model.*; 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 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.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -49,6 +55,9 @@ public class PaymentInfoServiceImpl extends ServiceImpl 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(); - // stripe 的支付金额单位是分 Float divide = new BigDecimal(amountTotal).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).floatValue(); PaymentInfo paymentInfo = new PaymentInfo(); - paymentInfo.setOrderNo(orderId); + paymentInfo.setOrderNo(orderNo); paymentInfo.setPaymentType(PayTypeEnum.STRIPE.getType()); - paymentInfo.setTransactionId(sessionId); + paymentInfo.setTransactionId(invoiceId); paymentInfo.setTradeState(status); paymentInfo.setPayerTotal(divide); Gson gson = new Gson(); - String json = gson.toJson(session); - paymentInfo.setContent(json); - // 获取订单信息 - OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(orderId); - if (!Objects.isNull(orderByOrderNo)){ + paymentInfo.setContent(gson.toJson(session)); + paymentInfo.setType(type); + paymentInfo.setNotified(0); + paymentInfo.setPaymentMethod(paymentMethodInfo.getOrDefault("paymentMethod", "N/A")); + 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.setCity(orderByOrderNo.getCity()); paymentInfo.setIpAddress(orderByOrderNo.getIpAddress()); } + 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 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 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().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 getPaymentMethodMap(PaymentMethod paymentMethod) { + Map 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}") private String privateKey; + + /** + * 为 Stripe Invoice 创建或更新支付记录 + * + * @param invoice Stripe Invoice + * @param paymentMethodInfo 外部传入的支付方式信息(如从 Session 传入),优先使用,为空时内部重新获取 + * @return PaymentInfo 支付记录 + */ @Transactional(rollbackFor = Exception.class) - public PaymentInfo createOrUpdatePaymentInfoForStripe(Invoice invoice){ + public PaymentInfo createOrUpdatePaymentInfoForStripe(Invoice invoice, Map paymentMethodInfo, List discounts) { Stripe.apiKey = privateKey; StripeService stripeService = SpringUtils.getBean(StripeService.class); // 获取transactionId,从sessionId更改为invoiceId @@ -235,29 +405,61 @@ public class PaymentInfoServiceImpl extends ServiceImpl paymentMethod = stripeService.getPaymentMethodByInvoiceId(invoiceId); // 获取订单信息 OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(orderNo); @@ -288,8 +488,8 @@ public class PaymentInfoServiceImpl extends ServiceImpl qw = new QueryWrapper<>(); - // todo 首次支付失败,没有invoiceId,所以如果这个order之后成功支付后,会有多条paymentInfo 是否需要优化?? - qw.eq("transaction_id", charge.getInvoice()); - PaymentInfo paymentInfo = baseMapper.selectOne(qw); - Charge.PaymentMethodDetails paymentMethodDetails = charge.getPaymentMethodDetails(); - String paymentMethod; - String last4 = "N/A"; - switch (paymentMethodDetails.getType()){ - case "alipay": - paymentMethod = "Alipay"; - break; - case "bancontact": - paymentMethod = "BanContact"; - 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()); + private String getPaymentIntentByInvoice(Invoice invoice) { + // 从 invoice.getPayments() 获取(适用于已支付完成的 Invoice) + // SDK 32.0.0: invoice.getPayments() 可能为 null,需逐层判空 + try { + InvoicePaymentCollection payments = invoice.getPayments(); + if (payments != null) { + List invoicePayments = payments.getData(); + if (invoicePayments != null && !invoicePayments.isEmpty()) { + InvoicePayment firstPayment = invoicePayments.getFirst(); + if (firstPayment != null) { + InvoicePayment.Payment payment = firstPayment.getPayment(); + if (payment != null) { + PaymentIntent paymentIntent = payment.getPaymentIntentObject(); + if (paymentIntent != null) { + return paymentIntent.getId(); + } + } + } + } } - int row = baseMapper.insertIgnore(paymentInfo); - log.info("Payment Info insert affect rows:{}", row); - }else { - paymentInfo.setTradeState(charge.getStatus()); - paymentInfo.setPaymentMethod(paymentMethod); - paymentInfo.setLast4(last4); - paymentInfo.setUpdateTime(LocalDateTime.now()); - baseMapper.updateById(paymentInfo); + } catch (Exception e) { + log.warn("[getPaymentIntentByInvoice] 获取 PaymentIntent 失败,invoiceId={},error={}", invoice.getId(), e.getMessage()); + } + return null; + } + + private String getPromotionCodeByInvoice(Invoice invoice) throws StripeException { + // 1. 检索 Invoice 并展开 discounts 字段 + InvoiceRetrieveParams params = InvoiceRetrieveParams.builder() + .addExpand("discounts") // 展开折扣数组 + .build(); + + invoice = Invoice.retrieve(invoice.getId(), params, null); + + // 2. 获取折扣列表(注意:Invoice.Discount 不是 List) + List 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 qw = new QueryWrapper<>(); - qw.eq("transaction_id", charge.getInvoice()); + qw.eq("transaction_id", invoiceId); PaymentInfo paymentInfo = baseMapper.selectOne(qw); - if (Objects.nonNull(paymentInfo)){ - String 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)){ + if (Objects.nonNull(paymentInfo)) { + if (!paymentInfo.getTradeState().equals(status)) { paymentInfo.setTradeState(status); paymentInfo.setUpdateTime(LocalDateTime.now()); 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 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 lines = invoice.getLines().getData(); + if (lines != null && !lines.isEmpty()) { + InvoiceLineItem firstLine = lines.getFirst(); + // 尝试从 line metadata 获取 + Map 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 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 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 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; } diff --git a/src/main/java/com/ai/da/service/impl/RefundInfoServiceImpl.java b/src/main/java/com/ai/da/service/impl/RefundInfoServiceImpl.java index cd4712df..35980c48 100644 --- a/src/main/java/com/ai/da/service/impl/RefundInfoServiceImpl.java +++ b/src/main/java/com/ai/da/service/impl/RefundInfoServiceImpl.java @@ -1,31 +1,59 @@ 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.mapper.primary.AccountMapper; 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.RefundInfo; -import com.ai.da.service.OrderInfoService; -import com.ai.da.service.RefundInfoService; +import com.ai.da.mapper.primary.entity.SubscriptionInfo; +import com.ai.da.service.*; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.google.gson.Gson; +import com.stripe.Stripe; import com.stripe.model.Charge; import com.stripe.model.Refund; +import com.stripe.exception.StripeException; import io.netty.util.internal.StringUtil; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.Resource; +import java.math.BigDecimal; import java.time.Duration; import java.time.Instant; import java.time.LocalDateTime; import java.util.*; +import org.springframework.beans.factory.annotation.Value; @Service +@Slf4j public class RefundInfoServiceImpl extends ServiceImpl implements RefundInfoService { + @Value("${stripe.private-key}") + private String privateKey; + @Resource 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 refundInfoList = getByChargeId(charge.getId()); - if (!refundInfoList.isEmpty()){ - RefundInfo refundInfo = refundInfoList.get(refundInfoList.size() - 1); - if (StringUtil.isNullOrEmpty(refundInfo.getOrderNo())){ - String orderNo = charge.getDescription().replace("AiDA - ", ""); - refundInfo.setOrderNo(orderNo); - refundInfo.setTotalFee(charge.getAmount() / 100f); - refundInfo.setUpdateTime(LocalDateTime.now()); - baseMapper.updateById(refundInfo); - return refundInfo; + String chargeId = charge.getId(); + List refundInfoList = getByChargeId(chargeId); + if (refundInfoList.isEmpty()){ + return null; + } + RefundInfo refundInfo = refundInfoList.get(refundInfoList.size() - 1); + if (StringUtil.isNullOrEmpty(refundInfo.getOrderNo())){ + String orderNo = charge.getDescription() != null ? charge.getDescription().replace("AiDA - ", "") : null; + if (StringUtil.isNullOrEmpty(orderNo)){ + 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 subList = subscriptionInfoMapper.selectList( + new QueryWrapper().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_start,orderNo={},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 中提取并更新 orderNo,refundId={},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] 退款状态已更新为 failed,refundId={}", 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; + } + } diff --git a/src/main/java/com/ai/da/service/impl/StripeServiceImpl.java b/src/main/java/com/ai/da/service/impl/StripeServiceImpl.java index ea3c758c..86ea5be3 100644 --- a/src/main/java/com/ai/da/service/impl/StripeServiceImpl.java +++ b/src/main/java/com/ai/da/service/impl/StripeServiceImpl.java @@ -23,22 +23,18 @@ import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; -import com.baomidou.mybatisplus.core.toolkit.StringUtils; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.google.gson.Gson; import com.stripe.Stripe; import com.stripe.exception.InvalidRequestException; -import com.stripe.exception.SignatureVerificationException; import com.stripe.exception.StripeException; import com.stripe.model.*; import com.stripe.model.Product; import com.stripe.model.checkout.Session; -import com.stripe.net.Webhook; import com.stripe.param.*; import com.stripe.param.checkout.SessionCreateParams; import io.netty.util.internal.StringUtil; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Lazy; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,14 +42,16 @@ import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletRequest; import java.math.BigDecimal; -import java.math.RoundingMode; import java.time.Instant; import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; 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") @Service @Slf4j @@ -62,14 +60,10 @@ public class StripeServiceImpl implements StripeService { @Resource private OrderInfoService orderInfoService; @Resource - private PayPalCheckoutService payPalCheckoutService; - @Resource private PaymentInfoService paymentInfoService; @Resource private CreditsService creditsService; @Resource - private RefundInfoService refundInfoService; - @Resource private AccountService accountService; @Resource private AccountMapper accountMapper; @@ -81,6 +75,8 @@ public class StripeServiceImpl implements StripeService { private ProductCouponsMapper productCouponsMapper; @Resource private RedisUtil redisUtil; + @Resource + private StripeWebhookService stripeWebhookService; @Value("${stripe.private-key}") private String privateKey; @@ -109,6 +105,7 @@ public class StripeServiceImpl implements StripeService { productPurchaseDTO.setAutoRenewal(false); break; case "Subscription": + productPurchaseDTO.setAutoRenewal(true); switch (productPurchaseDTO.getSubscribeType()){ case "Month": productEnum = ProductEnum.MonthlySubscription; @@ -144,16 +141,13 @@ public class StripeServiceImpl implements StripeService { } log.info("生成订单"); String payType; - byte autoRenewal; if (productPurchaseDTO.getAutoRenewal()){ payType = "recurring"; - autoRenewal = 1; }else { payType = "one_time"; - autoRenewal = 0; } OrderInfo orderInfo = orderInfoService.createOrderByProductId(productPurchaseDTO.getQuantity(), - PayTypeEnum.STRIPE.getType(), productEnum, request, autoRenewal); + PayTypeEnum.STRIPE.getType(), productEnum, request); try { 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. if (payType.equals("recurring")){ 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 { sessionBuilder.setMode(SessionCreateParams.Mode.PAYMENT); - sessionBuilder.setPaymentIntentData(SessionCreateParams.PaymentIntentData.builder().setDescription("AiDA - " + orderId).build()); + // Stripe SDK 32.0.0: 使用 PaymentIntentData.setMetadata() 将 orderId 传递到 PaymentIntent + // 对于手动创建的 invoice,metadata 需要在 invoice 创建时单独设置 + sessionBuilder.setPaymentIntentData(SessionCreateParams.PaymentIntentData.builder() + .setDescription("AiDA - " + orderId) + .putMetadata("orderId", orderId) + .build()); // one-time 手动创建发票;订阅会自动创建invoice sessionBuilder.setInvoiceCreation(SessionCreateParams.InvoiceCreation.builder().setEnabled(Boolean.TRUE).build()); } @@ -189,7 +193,8 @@ public class StripeServiceImpl implements StripeService { .setQuantity((long) productPurchaseDTO.getQuantity()) .setPrice(priceId) .build()); - sessionBuilder.putMetadata("orderId", orderId); //通过订单号关联用于检索支付信息(可选) + // 将 orderId 写入 metadata,Stripe Checkout 会自动传递给关联的 PaymentIntent/Subscription + sessionBuilder.putMetadata("orderId", orderId); Session session = Session.create(sessionBuilder.build()); List paymentMethodTypes = session.getPaymentMethodTypes(); @@ -276,422 +281,9 @@ public class StripeServiceImpl implements StripeService { return Price.create(priceCreateParams.build()); } - @Resource - private EmailService emailService; @Override - @Transactional(rollbackFor = Exception.class) public Boolean notify(HttpServletRequest request) { - log.info("stripe异步通知进行中"); - 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 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 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 orderInfos = orderInfoService.getBaseMapper().selectList(queryWrapper); - if (orderInfos.isEmpty()) { - // 4、判断当前订单有没有订阅信息 - QueryWrapper 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 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 qw = new QueryWrapper<>(); - qw.eq("subscription_id", subId); - - List subscriptionInfos = subscriptionInfoMapper.selectList(qw); - if (subscriptionInfos.size() == 1){ - return subscriptionInfos.get(0); - }else if (subscriptionInfos.size() > 1) { - // 如果新建了多个订阅,则筛选出状态为active的订单 - Optional activeSubscriptionInfo = subscriptionInfos.stream() - .filter(sub -> sub.getStatus().equals("active")) - .findFirst(); - - return activeSubscriptionInfo.orElseGet(() -> subscriptionInfos.get(0)); - }else { - return null; - } + return stripeWebhookService.notify(request); } 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 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) { Stripe.apiKey = privateKey; 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) { Stripe.apiKey = privateKey; - // 1、通过orderNo 查询sessionId - // todo transactionId不再是sessionId而是invoiceId,所以这里需要更新 - PaymentInfo paymentInfo = paymentInfoService.getPaymentInfoByOrderNo(orderNo, "DESC").get(0); + List paymentInfos = paymentInfoService.getPaymentInfoByOrderNo(orderNo, "DESC"); + if (paymentInfos == null || paymentInfos.isEmpty()) { + log.warn("核实订单未找到 ===> {}", orderNo); + return; + } + PaymentInfo paymentInfo = paymentInfos.get(0); + String transactionId = paymentInfo.getTransactionId(); + if (transactionId == null) { + log.warn("核实订单 transactionId 为空 ===> {}", orderNo); + return; + } try { - Session session = Session.retrieve(paymentInfo.getTransactionId()); - if (Objects.isNull(session)) { - log.warn("核实订单未创建 ===> {}", orderNo); - return; - } else if (session.getStatus().equals("open") || session.getStatus().equals("expired")) { - // 订单未支付 || 订单过期 ---> 均设置为超时未支付 + Session session = Session.retrieve(transactionId); + String status = session.getStatus(); + if ("open".equals(status) || "expired".equals(status)) { log.info("订单超时未支付 ===> {}", orderNo); - //更新本地订单状态 orderInfoService.updateStatusByOrderNo(orderNo, OrderStatusEnum.TIMEOUT_CLOSED); paymentInfoService.updatePaymentStatusById(paymentInfo.getId(), - session.getStatus(), - new Gson().toJson(session)); - } else if (session.getStatus().equals("complete")) { - // 订单已完成 - processOrder(session); + status, new Gson().toJson(session)); + } else if ("complete".equals(status)) { + // 订单已完成,通过 Checkout 事件处理(积分/订阅)已在 checkout.session.completed 中处理 + // 此处仅确保本地订单状态一致 + String currentStatus = orderInfoService.getOrderByOrderNo(orderNo).getOrderStatus(); + if (!OrderStatusEnum.SUCCESS.getType().equals(currentStatus)) { + orderInfoService.updateStatusByOrderNo(orderNo, OrderStatusEnum.SUCCESS); + } } } catch (StripeException e) { - log.error("根据sessionId获取Stripe Session失败"); - throw new RuntimeException(e); + // transactionId 可能是 invoiceId(Payment Mode),此时无法用 sessionId 查询 + log.warn("根据 transactionId={} 查询 Stripe Session 失败,可能为 invoiceId,error={}", transactionId, e.getMessage()); } - } public List getSubscription(String username, String userEmail) { @@ -933,84 +402,6 @@ public class StripeServiceImpl implements StripeService { return customer.getId(); } - /** - * 使用连续订阅的订单,回调中没有paymentIntentId,所以通过invoiceId间接获取 - * @param invoiceId 发票Id - */ - public Map 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 resp = new HashMap<>(); - resp.put("paymentMethod", "N/A"); - resp.put("last4", "N/A"); - return resp; - } catch (StripeException e) { - throw new RuntimeException(e); - } - } - - public Map 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 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) { SubscriptionInfo subscriptionInfo; long secondsTimestamp = System.currentTimeMillis() / 1000; @@ -1172,7 +563,7 @@ public class StripeServiceImpl implements StripeService { return true; } - public boolean sendRenewalFailEmail(String invoiceId, String subscriptionId, String orderNo){ + /*public boolean sendRenewalFailEmail(String invoiceId, String subscriptionId, String orderNo){ // 1、确认当前订单最后一笔支付为fail // 更新支付信息 PaymentInfo paymentInfo; @@ -1232,7 +623,7 @@ public class StripeServiceImpl implements StripeService { payment.setUpdateTime(LocalDateTime.now()); paymentInfoMapper.updateById(payment); return true; - } + }*/ private void setSubscriptionParams(PaymentInfo paymentInfo, SubscriptionInfo subscriptionInfo, OrderInfo orderByOrderNo, SubscriptionEmailParamsDTO emailParamsDTO, String language) { @@ -1302,7 +693,7 @@ public class StripeServiceImpl implements StripeService { } }*/ - public void checkSubscriptionExpiration(){ + /*public void checkSubscriptionExpiration(){ long epochSecond = Instant.now().getEpochSecond(); QueryWrapper qw = new QueryWrapper<>(); qw.lt("current_period_end", epochSecond); @@ -1315,13 +706,13 @@ public class StripeServiceImpl implements StripeService { subscriptionInfoMapper.updateById(subscriptionInfo); log.info("用户 {} 的订阅 {} 已过期", subscriptionInfo.getAccountId(), subscriptionInfo.getOrderNo()); } - } + }*/ // 新建一个订阅 使用不会成功的付款方式(仅供测试使用) public String createSubscriptionTemp(String name, String email){ Stripe.apiKey = privateKey; 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 paymentMethodCode = "pm_card_mastercard"; @@ -1527,9 +918,17 @@ public class StripeServiceImpl implements StripeService { public PromotionCode createPromotionCode(String couponId, Long maxRedemption){ 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() - .setCoupon(couponId) - .setRestrictions(PromotionCodeCreateParams.Restrictions.builder().build()); + .setPromotion(promotion); // 使用 setPromotion 而不是 setCoupon + if (Objects.nonNull(maxRedemption)){ promotionCodeParams.setMaxRedemptions(maxRedemption); } diff --git a/src/main/java/com/ai/da/service/impl/StripeSubscriptionServiceImpl.java b/src/main/java/com/ai/da/service/impl/StripeSubscriptionServiceImpl.java new file mode 100644 index 00000000..126a946f --- /dev/null +++ b/src/main/java/com/ai/da/service/impl/StripeSubscriptionServiceImpl.java @@ -0,0 +1,598 @@ +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) { + return false; + } + OrderInfo orderByOrderNo = orderInfoService.getOrderByOrderNo(subscriptionInfo.getOrderNo()); + if (orderByOrderNo == null) { + return false; + } + Account account = accountMapper.selectById(subscriptionInfo.getAccountId()); + if (account == null) { + return false; + } + + PaymentInfo paymentInfo = resolvePaymentInfo(subscriptionInfo, orderNo, type); + if (paymentInfo == null) { + return false; + } + + String resolvedType = resolveEmailType(type, paymentInfo); + if (isEmailAlreadySent(subscriptionInfo, resolvedType, paymentInfo)) { + 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(); + // 限制当前时间在订阅区间内,避免处理上个周期内的回调而重复发送邮件 + if (now > passedInfo.getCurrentPeriodStart() && now < passedInfo.getCurrentPeriodEnd() + && "active".equals(passedInfo.getStatus())) { + return passedInfo; + } + return null; + } + if (!StringUtil.isNullOrEmpty(orderNo)) { + long now = Instant.now().getEpochSecond(); + List infos = subscriptionInfoMapper.selectList( + new QueryWrapper() + .eq("order_no", orderNo) + .gt("current_period_start", now) + .lt("current_period_end", now) + ); + if (!infos.isEmpty()) { + List 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 last = new QueryWrapper() + .eq("order_no", orderNo) + .between("create_time", periodStart, periodEnd) + .orderByDesc("id") + .last("LIMIT 1"); + if (!type.contains("fail")) { + last.in("trade_state", "paid", "COMPLETED"); + } + List 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 paymentInfos = paymentInfoMapper.selectList( + new QueryWrapper().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 infos = subscriptionInfoMapper.selectList( +// new QueryWrapper() +// .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.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 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 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 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 infos = subscriptionInfoMapper.selectList( + new QueryWrapper().eq("subscription_id", subId) + ); + if (infos.isEmpty()) { + return null; + } + if (infos.size() == 1) { + return infos.getFirst(); + } + Optional 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 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 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(); + } + + +} diff --git a/src/main/java/com/ai/da/service/impl/StripeWebhookServiceImpl.java b/src/main/java/com/ai/da/service/impl/StripeWebhookServiceImpl.java new file mode 100644 index 00000000..05e8cc69 --- /dev/null +++ b/src/main/java/com/ai/da/service/impl/StripeWebhookServiceImpl.java @@ -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 幂等性检查 TTL:7天 + * 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 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); + } + }); + } +} diff --git a/src/main/java/com/ai/da/service/stripe/config/StripeWebhookAsyncConfig.java b/src/main/java/com/ai/da/service/stripe/config/StripeWebhookAsyncConfig.java new file mode 100644 index 00000000..c212475f --- /dev/null +++ b/src/main/java/com/ai/da/service/stripe/config/StripeWebhookAsyncConfig.java @@ -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; + } +} diff --git a/src/main/java/com/ai/da/service/stripe/handler/CheckoutSessionCompletedHandler.java b/src/main/java/com/ai/da/service/stripe/handler/CheckoutSessionCompletedHandler.java new file mode 100644 index 00000000..746a7cdc --- /dev/null +++ b/src/main/java/com/ai/da/service/stripe/handler/CheckoutSessionCompletedHandler.java @@ -0,0 +1,292 @@ +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 getSupportedEventTypes() { + return Collections.singletonList(EVENT_TYPE); + } + + @Override + public Class 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] 无法提取 orderNo,sessionId={}", 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] 订阅模式无 invoiceId,orderNo={}", orderNo); + return; + } + + try { + Invoice invoice = Invoice.retrieve(invoiceId); + List 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 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); + + stripeSubscriptionService.sendSubscriptionEmail(null, "new", orderNo, null); + + log.info("[checkout.session.completed] 邮件通知完成 类型:new"); + + } 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 metadata = session.getMetadata(); + if (metadata == null || !metadata.containsKey("orderId")) { + log.warn("Session {} 缺少 orderId metadata", session.getId()); + return null; + } + return metadata.get("orderId"); + } + +} \ No newline at end of file diff --git a/src/main/java/com/ai/da/service/stripe/handler/CheckoutSessionExpiredHandler.java b/src/main/java/com/ai/da/service/stripe/handler/CheckoutSessionExpiredHandler.java new file mode 100644 index 00000000..2e86b9b8 --- /dev/null +++ b/src/main/java/com/ai/da/service/stripe/handler/CheckoutSessionExpiredHandler.java @@ -0,0 +1,122 @@ +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 getSupportedEventTypes() { + return Collections.singletonList(EVENT_TYPE); + } + + @Override + public Class 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 laterSuccessOrders = orderInfoService.getBaseMapper().selectList( + new QueryWrapper() + .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 subInfoList = subscriptionInfoMapper.selectList( + new QueryWrapper() + .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); + } else { + // 续费失败 todo 续费不走这里吧? + stripeSubscriptionService.sendSubscriptionEmail(null, "fail_renewal", subInfoList.getFirst().getOrderNo(), null); + 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; + } +} diff --git a/src/main/java/com/ai/da/service/stripe/handler/CustomerSubscriptionUpdateHandler.java b/src/main/java/com/ai/da/service/stripe/handler/CustomerSubscriptionUpdateHandler.java new file mode 100644 index 00000000..8ccd0ad9 --- /dev/null +++ b/src/main/java/com/ai/da/service/stripe/handler/CustomerSubscriptionUpdateHandler.java @@ -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 getSupportedEventTypes() { + return Collections.singletonList(EVENT_TYPE); + } + + @Override + public Class 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() + .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; + }; + } +} diff --git a/src/main/java/com/ai/da/service/stripe/handler/InvoicePaidHandler.java b/src/main/java/com/ai/da/service/stripe/handler/InvoicePaidHandler.java new file mode 100644 index 00000000..12443307 --- /dev/null +++ b/src/main/java/com/ai/da/service/stripe/handler/InvoicePaidHandler.java @@ -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 getSupportedEventTypes() { + return Collections.singletonList(EVENT_TYPE); + } + + @Override + public Class 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(), invoice.getPeriodEnd()); + 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() + .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 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); + } + } +} diff --git a/src/main/java/com/ai/da/service/stripe/handler/InvoicePaymentFailedHandler.java b/src/main/java/com/ai/da/service/stripe/handler/InvoicePaymentFailedHandler.java new file mode 100644 index 00000000..fd7eeb60 --- /dev/null +++ b/src/main/java/com/ai/da/service/stripe/handler/InvoicePaymentFailedHandler.java @@ -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 getSupportedEventTypes() { + return Collections.singletonList(EVENT_TYPE); + } + + @Override + public Class 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() + .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 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); + } + } +} diff --git a/src/main/java/com/ai/da/service/stripe/handler/RefundEventHandler.java b/src/main/java/com/ai/da/service/stripe/handler/RefundEventHandler.java new file mode 100644 index 00000000..1a396619 --- /dev/null +++ b/src/main/java/com/ai/da/service/stripe/handler/RefundEventHandler.java @@ -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.updated(status=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 getSupportedEventTypes() { + return Arrays.asList(EVENT_REFUND_CREATED, EVENT_REFUND_UPDATED, EVENT_REFUND_FAILED); + } + + @Override + public Class 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; + } + } +} diff --git a/src/main/java/com/ai/da/service/stripe/handler/StripeEventDispatcher.java b/src/main/java/com/ai/da/service/stripe/handler/StripeEventDispatcher.java new file mode 100644 index 00000000..6dd6a945 --- /dev/null +++ b/src/main/java/com/ai/da/service/stripe/handler/StripeEventDispatcher.java @@ -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 handlerMap; + + public StripeEventDispatcher(List 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 getRegisteredEventTypes() { + return List.copyOf(handlerMap.keySet()); + } +} diff --git a/src/main/java/com/ai/da/service/stripe/handler/StripeEventHandler.java b/src/main/java/com/ai/da/service/stripe/handler/StripeEventHandler.java new file mode 100644 index 00000000..f0a28984 --- /dev/null +++ b/src/main/java/com/ai/da/service/stripe/handler/StripeEventHandler.java @@ -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 getSupportedEventTypes(); + + /** + * 获取该处理器支持的对象类型 + */ + Class getSupportObjectType(); + + /** + * 处理事件 + * @param eventType 事件类型 + * @param stripeObject Stripe 对象 + * @return true=处理成功,false=处理失败(可重试) + */ + boolean handle(String eventType, StripeObject stripeObject); +} diff --git a/src/main/java/com/ai/da/service/stripe/handler/SubscriptionDeletedHandler.java b/src/main/java/com/ai/da/service/stripe/handler/SubscriptionDeletedHandler.java new file mode 100644 index 00000000..3cd685d0 --- /dev/null +++ b/src/main/java/com/ai/da/service/stripe/handler/SubscriptionDeletedHandler.java @@ -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 getSupportedEventTypes() { + return Collections.singletonList(EVENT_TYPE); + } + + @Override + public Class 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 subInfoList = subscriptionInfoMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper() + .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(), null); + 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; + } + } +} diff --git a/src/main/resources/mapper/primary/PaymentInfoMapper.xml b/src/main/resources/mapper/primary/PaymentInfoMapper.xml index 5a295797..99d1c8e7 100644 --- a/src/main/resources/mapper/primary/PaymentInfoMapper.xml +++ b/src/main/resources/mapper/primary/PaymentInfoMapper.xml @@ -62,7 +62,10 @@ WHEN p.trade_state IN ( 'paid', 'COMPLETED', 'complete', 'liquidated' ) THEN 'Success' WHEN p.trade_state IN ( 'failed', 'expired', 'VOIDED', 'void', 'uncollectible' ) THEN - 'Fail' ELSE 'Pending' + 'Fail' + WHEN p.trade_state IN ( 'Refunded' ) THEN + 'Refunded' + ELSE 'Pending' END AS status FROM t_payment_info p @@ -86,6 +89,7 @@ CASE 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 ('Refunded') THEN 'Refunded' ELSE 'Pending' END = #{status} @@ -132,6 +136,7 @@ CASE 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 ('Refunded') THEN 'Refunded' ELSE 'Pending' END = #{status} @@ -170,6 +175,7 @@ CASE 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 ('Refunded') THEN 'Refunded' ELSE 'Pending' END = #{status}