ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

设计支付业务的思路

2026/9/4 5:02:52 拓冰建站 浏览量
设计支付业务的思路 一、支付系统幂等性1.1、重试机制Stripe 的 Webhook 投递遵循至少一次at-least-once语义。如果服务器响应超时10s、返回了 4xx/5xx或者 Stripe 那边网络抖动它就会自动重试。重试间隔是指数退避的但最坏情况下你可能在几分钟内收到同一事件的多次推送。1.2、不做幂等的后果如果无脑处理第一次收到事件更新订单状态为成功给用户开通 VIP第二次收到同一个事件又开通一次 VIP——虽然不会重复扣款Stripe 那边是幂等的但你的业务侧可能会重复发权益、重复发通知、甚至重复计积分。1.3、解决方案我的做法是基于本地支付记录做状态机校验用 sessionId 定位到本地支付记录如果记录不存在直接丢弃防御性编程如果状态已经是 SUCCEEDED说明已经处理过直接返回——这就是幂等性的核心只有状态是待支付时才更新状态并开通会员同时用Transactional把更新支付状态和开通会员包在一个事务里避免中间崩溃导致状态不一致。OverrideTransactional(rollbackForException.class)publicvoidhandlePaymentSuccess(Sessionsession){StringsessionIdsession.getId();StringuserIdsession.getMetadata().get(userId);StringpaymentIntentIdsession.getPaymentIntent();// 1. 防御Webhook 里的 session 必须能在本地找到对应记录PaymentRecordrecordfindPaymentRecordBySessionId(sessionId);if(recordnull){log.warn(支付记录不存在, sessionId{},sessionId);return;// 可能是脏数据直接丢弃}// 2. 幂等性核心状态机校验// Stripe 可能因超时/重试多次推送同一事件// 如果本地已经是成功状态说明上次已经处理完毕直接跳过if(PaymentStatusEnum.SUCCEEDED.getValue().equals(record.getStatus())){log.info(支付记录已处理, sessionId{},sessionId);return;}// 3. 执行业务状态更新 权益发放// 包在 Transactional 内保证原子性updatePaymentStatus(record.getId(),PaymentStatusEnum.SUCCEEDED,paymentIntentId);upgradeUserToVip(Long.valueOf(userId));log.info(支付成功用户已升级为 VIP, userId{}, sessionId{},userId,sessionId);}/** * 升级用户为 VIP */privatevoidupgradeUserToVip(LonguserId){UserusernewUser();user.setId(userId);user.setVipTime(LocalDateTime.now());user.setUserRole(UserConstant.VIP_ROLE);userMapper.update(user);}2、退款逻辑支付成功是正向流程收钱 → 给权益顺序执行就行。但退款是逆向流程要先找 Stripe 把钱退了再把自己的系统里已经发出去的 VIP、配额收回来。这两个动作必须一起成功或一起失败否则就会出现钱退了 VIP 还在或者VIP 没了钱没退的诡异状态。2.1、核心思路退款比支付难因为你要同时操作两个系统Stripe 和你的数据库。核心思路先验资、再退钱、最后收权益本地操作全包事务。2.2、具体操作实现退款功能时我给自己列了四条铁律少一条都可能出事故先验资再操作 —— 确认用户有支付记录、有 Stripe paymentIntentId、当前是 VIP外部调用明确判状态 ——Stripe 返回的 Refund 对象必须 status “succeeded”不能假设调用成功就是退款成功本地权益回收必须原子化 —— 更新退款记录、撤销 VIP、恢复配额要么全成功要么全失败日志留痕 —— 退款涉及资损必须记录 refundId 和 userId方便后续对账和审计OverrideTransactional(rollbackForException.class)publicbooleanhandleRefund(LonguserId,Stringreason)throwsStripeException{// 1. 前置校验非 VIP 用户直接拒绝避免无效请求打到 StripeUserusergetUserOrThrow(userId);validateIsVip(user);// 2. 找到最近一次成功支付记录作为退款的依据// 没有记录 没付过钱直接抛异常PaymentRecordpaymentRecordfindLatestSuccessfulPayment(userId);if(paymentRecordnull){thrownewBusinessException(ErrorCode.NOT_FOUND_ERROR,未找到支付记录);}// 3. 必须有 Stripe 的 paymentIntentId否则无法向 Stripe 发起退款if(paymentRecord.getStripePaymentIntentId()null){thrownewBusinessException(ErrorCode.OPERATION_ERROR,支付记录无效);}// 4. 调用 Stripe 退款——这是不可回退的操作// 一旦 Stripe 返回 succeeded钱就已经退给用户了RefundrefundcreateStripeRefund(paymentRecord.getStripePaymentIntentId());if(!succeeded.equals(refund.getStatus())){returnfalse;// Stripe 退款失败本地不做任何变更}// 5. 本地事务区只有 Stripe 退款成功后才执行本地状态变更// 更新退款记录、撤销 VIP、恢复默认配额// 如果中间任何一步抛异常Spring 事务会回滚保证本地数据一致性updateRefundRecord(paymentRecord.getId(),reason);revokeVipStatus(userId);log.info(退款成功已取消 VIP 身份, userId{}, refundId{},userId,refund.getId());returntrue;}/** * 获取用户或抛出异常 */privateUsergetUserOrThrow(LonguserId){UseruseruserMapper.selectOneById(userId);if(usernull){thrownewBusinessException(ErrorCode.NOT_FOUND_ERROR,用户不存在);}returnuser;/** * 验证用户是 VIP */privatevoidvalidateIsVip(Useruser){if(!UserConstant.VIP_ROLE.equals(user.getUserRole())){thrownewBusinessException(ErrorCode.OPERATION_ERROR,您不是会员无法退款);}}/** * 查询最近的成功支付记录 */privatePaymentRecordfindLatestSuccessfulPayment(LonguserId){QueryWrapperqueryWrapperQueryWrapper.create().eq(userId,userId).eq(status,PaymentStatusEnum.SUCCEEDED.getValue()).eq(productType,ProductTypeEnum.VIP_PERMANENT.getValue()).orderBy(createTime,false).limit(1);returnpaymentRecordMapper.selectOneByQuery(queryWrapper);}/** * 创建 Stripe 退款 */privateRefundcreateStripeRefund(StringpaymentIntentId)throwsStripeException{RefundCreateParamsparamsRefundCreateParams.builder().setPaymentIntent(paymentIntentId)// 要退哪笔款.setReason(RefundCreateParams.Reason.REQUESTED_BY_CUSTOMER)// 退款原因.build();//向 Stripe 服务器发请求Stripe 会//找到对应的 PaymentIntent//把这笔钱原路退回到用户的银行卡/支付账户//返回一个 Refund 对象里面包含退款状态returnRefund.create(params);}/** * 更新退款记录 */privatevoidupdateRefundRecord(LongrecordId,Stringreason){PaymentRecordupdateRecordnewPaymentRecord();updateRecord.setId(recordId);updateRecord.setStatus(PaymentStatusEnum.REFUNDED.getValue());updateRecord.setRefundTime(LocalDateTime.now());updateRecord.setRefundReason(reason);paymentRecordMapper.update(updateRecord);}/** * 撤销用户 VIP 身份 */privatevoidrevokeVipStatus(LonguserId){UserupdateUsernewUser();updateUser.setId(userId);updateUser.setVipTime(null);updateUser.setUserRole(UserConstant.DEFAULT_ROLE);userMapper.update(updateUser);}