feat(payment): 撤销核销把已确认的资金退回延迟户
核销成功时钱已经从延迟户划给商户(进基本户),撤销核销此前只翻库、不碰钱:订单回到「待核销」,
钱却留在商户手里,等 D1 自动结算到银行卡之后就再也追不回来。现在撤销核销走通道的【交易确认退款】
把已确认的金额退回延迟户,这笔钱才重新变成不可结算、不可取现。
- 只有回退成功(或钱本来就在延迟户)才允许翻单,其余情况一律停在「已核销」,并按原因给商户话:
已结算到银行卡 / 正在退回中 / 暂时无法发起 / 请人工核对
- 回退结果用一套独立分类,不复用交易确认的 classify:23000003 在确认路径上是幂等成功(有真实回执
复验过),在回退路径上含义未定,既不能算成功也不能算失败,停在 reverting 交人工
- 认领与落结果各走独立事务:通道调用不是事务性的,这些写入跟着外层事务回滚会把通道的真实结果丢掉
- 迁移 V147 记录回退的请求号/日期/返回码/金额/时间;上一轮确认的回执保留下来作为证据
同时修掉审计发现的三个同类隐患:
- 放款前在订单行锁内复查「订单仍处于已核销」:否则撤销核销之后,补偿任务或在途的确认请求会把钱
再打给商户,而订单已经回到待核销
- 退款不再把语义未定的业务码判成失败:判失败会解锁订单让用户再退一次,而通道那边第一次其实已经
出款时,第二次就是重复出款
- 确认被认领后进程中断(部署、崩溃)会留下 confirming 孤儿:补偿任务只挑得到它、从不重发,那笔钱
永远冻在延迟户;隔天收掉这种孤儿重新确认一次(重发是幂等的)
回退结果未知时没有自动收敛通道(【交易确认退款查询】的合同还没有真实回执验证过),停在 reverting
交人工——这是有意的保守选择:把失败当成功会让订单翻回待核销而钱还在商户基本户。
| | |
| | | 核销成功时触发交易确认。资金确认状态记在 `payment_transaction.funds_confirmation_status`: |
| | | `NULL` 表示未走延迟入账,`pending` 表示钱还在延迟户,`confirmed` 表示已分账到商户基本户。 |
| | | |
| | | 撤销核销必须走反方向:调用通道的**交易确认退款**把已确认的金额退回延迟户,状态回到 `pending`, |
| | | 商户重新核销时会再确认一次。回退期间状态是 `reverting`(钱在哪一边未定),此时不允许撤销核销。 |
| | | 钱被 D1 结算划到商户银行卡之后就退不回来,撤销核销只能被拒绝——所以撤销窗口实际上止于下一次自动结算。 |
| | | |
| | | ## 结算 |
| | | |
| | | 出款由斗拱按结算周期自动完成,平台**不生成提现记录**,也不再发起主动取现。商户进件时通过 |
| | |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | private final com.talentcard.server.business.order.OrderVerificationService orderVerificationService; |
| | | private final HuifuRefundService refundService; |
| | | private final PaymentFundConfirmationService fundConfirmation; |
| | | |
| | | public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService, |
| | | OrderFundBoundaryService orderFundBoundary, |
| | | com.talentcard.server.business.order.OrderVerificationService orderVerificationService, |
| | | HuifuRefundService refundService) { |
| | | HuifuRefundService refundService, |
| | | PaymentFundConfirmationService fundConfirmation) { |
| | | this.jdbcTemplate = jdbcTemplate; |
| | | this.manualConsumptionService = manualConsumptionService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | this.orderVerificationService = orderVerificationService; |
| | | this.refundService = refundService; |
| | | this.fundConfirmation = fundConfirmation; |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | |
| | | if (verifyTime == null || Duration.between(verifyTime, LocalDateTime.now()).toHours() >= REVOCATION_HOURS) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "已过可撤销时间"); |
| | | } |
| | | // 核销成功时钱已经确认给了商户(基本户),撤销核销必须先把钱退回通道延迟户。回退没成功就不许 |
| | | // 翻单:否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销一次。 |
| | | String revertOutcome = fundConfirmation.revertForOrder(orderId); |
| | | if (!PaymentFundConfirmationService.moneyBackInDelayAccount(revertOutcome)) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, |
| | | PaymentFundConfirmationService.revertRefusalMessage(revertOutcome)); |
| | | } |
| | | String reason = request.reason() == null || request.reason().isBlank() ? "商户撤销核销" : request.reason().trim(); |
| | | int updated = jdbcTemplate.update(""" |
| | | update verify_record set status = 2, revoked_at = now(), revoked_by = ?, revoke_reason = ?, revoke_idempotency_key = ? |
| | |
| | | import com.talentcard.server.business.order.OrderFundBoundaryService; |
| | | import com.talentcard.server.business.order.OrderRefundRequest; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPayload; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; |
| | | import com.talentcard.server.business.payment.huifu.HuifuRefundCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuRefundGateway; |
| | |
| | | String providerNo = textValue(data.get("hf_seq_id")); |
| | | boolean codeSuccess = "00000000".equals(code) || "10000".equals(code) || "0".equals(code) || "SUCCESS".equalsIgnoreCase(code); |
| | | boolean success = codeSuccess && (status.isBlank() || "S".equalsIgnoreCase(status) || "SUCCESS".equalsIgnoreCase(status) || "PAID".equalsIgnoreCase(status)); |
| | | boolean failed = !success && !("P".equalsIgnoreCase(status) || "PROCESSING".equalsIgnoreCase(status) || "PENDING".equalsIgnoreCase(status)); |
| | | // 语义未定的码一律不判失败。判失败会把订单解锁成「可以再退一次」,万一通道那边第一次其实 |
| | | // 已经出款,第二次就是重复出款;停在在途最多是让对账/人工多收敛一轮。 |
| | | boolean failed = !success && !HuifuPayload.undeterminedCode(code) |
| | | && !("P".equalsIgnoreCase(status) || "PROCESSING".equalsIgnoreCase(status) || "PENDING".equalsIgnoreCase(status)); |
| | | return new ProviderResult(success, failed, code, message, providerNo); |
| | | } |
| | | private static String textValue(Object value) { return value == null ? "" : String.valueOf(value).trim(); } |
| | |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmRefundCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayConfirmation; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayRevert; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.TransactionDefinition; |
| | | import org.springframework.transaction.support.TransactionTemplate; |
| | | |
| | | /** |
| | |
| | | * <p>状态记在 {@code payment_transaction.funds_confirmation_status}:{@code NULL} 表示这笔支付没有延迟 |
| | | * (支付即核销的到店消费),{@code pending} 表示钱还在通道冻着,{@code confirmed} 表示已经分配完成。 |
| | | * 自动提现只认 {@code NULL} 和 {@code confirmed},因此「忘了确认」不会变成「提前把钱提走」。 |
| | | * |
| | | * <p>撤销核销走反方向({@link #revertForOrder}):调用交易确认退款把已确认的钱退回延迟户,成功后状态 |
| | | * 退回 {@code pending},商户重新核销时会再确认一次。回退期间用 {@code reverting} 占位。 |
| | | */ |
| | | @Service |
| | | public class PaymentFundConfirmationService { |
| | |
| | | public static final String FAILED = "FAILED"; |
| | | /** 找不到对应的支付记录。 */ |
| | | public static final String UNKNOWN = "UNKNOWN"; |
| | | /** |
| | | * 订单此刻不再是「已核销」,不能把钱放给商户。 |
| | | * |
| | | * <p>撤销核销会把订单翻回待核销,此时钱必须留在延迟户:确认在放款前复查这个前提, |
| | | * 避免「订单已回到待核销、钱却已经打给商户」。 |
| | | */ |
| | | public static final String NOT_VERIFIED = "NOT_VERIFIED"; |
| | | |
| | | /** 钱已退回(或本来就在)通道延迟户,撤销核销可以继续。 */ |
| | | public static final String REVERTED = "REVERTED"; |
| | | /** 这笔资金不需要回退:没有支付记录、支付没有走延迟入账,或钱本来就在延迟户。 */ |
| | | public static final String REVERT_NOT_NEEDED = "REVERT_NOT_NEEDED"; |
| | | /** 通道明确拒绝回退(例如资金已结算到银行卡、基本户余额不足),钱没有动。 */ |
| | | public static final String REVERT_REJECTED = "REVERT_REJECTED"; |
| | | /** 回退结果未定或仍在处理中:钱的位置不确定,撤销核销必须停在「已核销」。 */ |
| | | public static final String REVERT_IN_FLIGHT = "REVERT_IN_FLIGHT"; |
| | | /** |
| | | * 返回码在这条路径上语义未定:通道给了话,但我们不知道它是什么意思,需要人工核对。 |
| | | * |
| | | * <p>和 {@link #REVERT_IN_FLIGHT} 分开是因为给商户的话不同:在途可以说「稍后重试」, |
| | | * 语义未定只能请他联系平台——它也可能是「已结算」,让商户反复重试是没有意义的。 |
| | | */ |
| | | public static final String REVERT_NEEDS_REVIEW = "REVERT_NEEDS_REVIEW"; |
| | | /** 回退请求根本没发出去(本地配置或 SDK 出错):钱没动,可以重试。 */ |
| | | public static final String REVERT_NOT_SENT = "REVERT_NOT_SENT"; |
| | | /** 找不到支付记录,或缺少定位原确认请求的键。 */ |
| | | public static final String REVERT_UNKNOWN = "REVERT_UNKNOWN"; |
| | | |
| | | private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); |
| | | private static final Logger log = LoggerFactory.getLogger(PaymentFundConfirmationService.class); |
| | |
| | | private final TransactionTemplate transactions; |
| | | private final HuifuDelayAccountGateway gateway; |
| | | |
| | | /** |
| | | * 资金侧的写入用独立事务提交。 |
| | | * |
| | | * <p>撤销核销会在自己的事务里调用本服务,而通道调用不是事务性的:认领和结果如果跟着外层事务一起 |
| | | * 回滚,通道那边已经退回延迟户的钱在本地就再也看不到了——状态还写着 {@code confirmed},钱其实已经 |
| | | * 不在商户基本户。用独立事务提交,判断依据就永远留在库里。 |
| | | */ |
| | | private final TransactionTemplate moneyTransactions; |
| | | |
| | | public PaymentFundConfirmationService(JdbcTemplate jdbc, TransactionTemplate transactions, |
| | | HuifuDelayAccountGateway gateway) { |
| | | this.jdbc = jdbc; |
| | | this.transactions = transactions; |
| | | this.gateway = gateway; |
| | | // 注入的 TransactionTemplate 是容器里的单例,支付、退款和自动提现共用它,不能改它的传播行为, |
| | | // 否则那些服务会跟着变成 REQUIRES_NEW、外层事务被静默挂起。这里自己新建一个。 |
| | | this.moneyTransactions = new TransactionTemplate(transactions.getTransactionManager()); |
| | | this.moneyTransactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | long paymentId = number(payment.get("id")); |
| | | if (!claim(paymentId)) { |
| | | ClaimOutcome claimOutcome = transactions.execute(status -> claimConfirmation(orderId, paymentId)); |
| | | if (claimOutcome == ClaimOutcome.ORDER_NOT_VERIFIED) return NOT_VERIFIED; |
| | | if (claimOutcome != ClaimOutcome.CLAIMED) { |
| | | // 另一个请求已经在确认这笔资金,或者它刚被确认为终态。 |
| | | return isConfirmed(currentStatus(paymentId)) ? CONFIRMED : PROCESSING; |
| | | } |
| | |
| | | return applyResult(paymentId, confirmNo, result); |
| | | } |
| | | |
| | | /** 认领这笔资金确认:只有仍为 pending 的那一次能拿到,避免重复向通道发确认。 */ |
| | | /** |
| | | * 撤销核销:把这笔已经确认给商户的资金退回通道延迟户。 |
| | | * |
| | | * <p>调用方只有在拿到 {@link #REVERTED} 或 {@link #REVERT_NOT_NEEDED} 时才可以撤销核销。其余结果都 |
| | | * 表示钱的位置不确定、或仍在商户手里:此时翻单会造成「订单显示待核销、钱已经结算给商户」。 |
| | | * |
| | | * <p>方法自身不开事务,认领与落结果各走独立事务(见 {@link #moneyTransactions}):通道调用不是 |
| | | * 事务性的,让这些写入跟着外层事务回滚,会把通道的真实结果丢掉。 |
| | | */ |
| | | public String revertForOrder(long orderId) { |
| | | List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList(""" |
| | | select p.id, p.payment_no, p.funds_confirmation_status, p.funds_confirm_no, |
| | | p.funds_confirm_request_date, p.funds_confirmed_amount, p.provider_huifu_id, |
| | | o.collect_huifu_id |
| | | from payment_transaction p |
| | | join benefit_order o on o.id = p.order_id |
| | | where p.order_id = ? |
| | | order by p.id desc |
| | | limit 1 |
| | | """, orderId)); |
| | | // 没有支付记录:这笔订单在通道里没有钱(历史数据或手工数据),没有已确认的资金需要回退。 |
| | | if (rows.isEmpty()) return REVERT_NOT_NEEDED; |
| | | |
| | | Map<String, Object> payment = rows.get(0); |
| | | String fundsStatus = text(payment.get("fundsConfirmationStatus")); |
| | | // 没有走延迟入账(支付即核销的到店消费),或钱本来就在延迟户:同样不需要回退。 |
| | | if (fundsStatus.isBlank() || statusIs(fundsStatus, "pending")) return REVERT_NOT_NEEDED; |
| | | // 回退在途或确认在途:钱在哪一边未定,等它收敛后再撤销。 |
| | | if (statusIs(fundsStatus, "reverting") || statusIs(fundsStatus, "confirming")) return REVERT_IN_FLIGHT; |
| | | if (!statusIs(fundsStatus, "confirmed")) return REVERT_UNKNOWN; |
| | | |
| | | String confirmNo = text(payment.get("fundsConfirmNo")); |
| | | String confirmRequestDate = text(payment.get("fundsConfirmRequestDate")); |
| | | if (confirmNo.isBlank() || confirmRequestDate.isBlank()) { |
| | | // 没有原确认的定位键就撤销不了确认;钱确实在商户手里,不能翻单,只能人工处理。 |
| | | log.warn("Payment {} is confirmed but has no confirmation reference, the revert needs a human", |
| | | payment.get("paymentNo")); |
| | | return REVERT_UNKNOWN; |
| | | } |
| | | String huifuId = firstNonBlank(text(payment.get("providerHuifuId")), text(payment.get("collectHuifuId"))); |
| | | if (huifuId.isBlank()) { |
| | | log.warn("Payment {} has no collecting sub-merchant, the revert needs a human", payment.get("paymentNo")); |
| | | return REVERT_UNKNOWN; |
| | | } |
| | | |
| | | long paymentId = number(payment.get("id")); |
| | | String revertNo = BusinessIdGenerator.next("DR"); |
| | | String requestDate = REQUEST_DATE.format(LocalDate.now()); |
| | | if (!claimRevert(paymentId, revertNo, requestDate)) { |
| | | // 另一个请求已经在回退这笔钱。 |
| | | return REVERT_IN_FLIGHT; |
| | | } |
| | | |
| | | HuifuDelayRevert result; |
| | | try { |
| | | result = gateway.refund(new HuifuDelayConfirmRefundCommand( |
| | | revertNo, huifuId, confirmNo, confirmRequestDate, "撤销核销退回延迟户")); |
| | | } catch (HuifuPaymentGatewayException exception) { |
| | | // 请求可能已经被通道受理:停在 reverting 交给人工核对,绝不按失败处理,也绝不放行撤销核销。 |
| | | log.warn("Fund revert for payment {} has an unknown result: {}", |
| | | payment.get("paymentNo"), exception.getMessage()); |
| | | recordRevertAttempt(paymentId, revertNo, "", exception.getMessage()); |
| | | return REVERT_IN_FLIGHT; |
| | | } catch (RuntimeException exception) { |
| | | // 通道根本没被调到(配置不完整、SDK 初始化失败之类):钱没动,可以重试。不能说成「已结算」, |
| | | // 商户照着这句话去找平台就白跑了。 |
| | | log.warn("Fund revert for payment {} failed before reaching the provider: {}", |
| | | payment.get("paymentNo"), exception.getMessage()); |
| | | abandonRevertClaim(paymentId, exception.getMessage()); |
| | | return REVERT_NOT_SENT; |
| | | } |
| | | |
| | | return applyRevertResult(paymentId, revertNo, result, payment); |
| | | } |
| | | |
| | | /** |
| | | * 认领这笔资金确认:只有仍为 pending 的那一次能拿到,避免重复向通道发确认。 |
| | | * |
| | | * <p>同时收掉「认领之后进程就没了」留下的孤儿:状态停在 {@code confirming}、认领日期又不是今天的 |
| | | * 那些请求,说明那次确认没走到落结果就中断了(部署、崩溃、超时被杀)。补偿任务只挑得到这种行而 |
| | | * 不会重发,没有这一步这笔钱就永远冻在延迟户里。重新确认是幂等的——通道会按「已无未确认金额」 |
| | | * 返回,{@link HuifuDelayConfirmation} 把那个码当成功,所以隔天补发一次是安全的。 |
| | | * |
| | | * <p>认领日期为空的行不动:那说明它不是我们认领出来的(手工数据),宁可留着给人工看。 |
| | | */ |
| | | private boolean claim(long paymentId) { |
| | | String today = REQUEST_DATE.format(LocalDate.now()); |
| | | int claimed = jdbc.update(""" |
| | | update payment_transaction |
| | | set funds_confirmation_status = 'confirming', funds_confirm_request_date = ? |
| | | where id = ? and funds_confirmation_status = 'pending' |
| | | """, REQUEST_DATE.format(LocalDate.now()), paymentId); |
| | | where id = ? |
| | | and (funds_confirmation_status = 'pending' |
| | | or (funds_confirmation_status = 'confirming' |
| | | and funds_confirm_request_date is not null |
| | | and funds_confirm_request_date < ?)) |
| | | """, today, paymentId, today); |
| | | return claimed == 1; |
| | | } |
| | | |
| | | /** 数据库里的确认状态是小写,比较必须大小写无关。 */ |
| | | /** |
| | | * 在订单行锁里复查「订单此刻仍然已核销」,再认领资金确认。 |
| | | * |
| | | * <p>撤销核销锁的是同一行,因此这个判断在整个认领期间是稳定的:撤销核销提交在前,这里会看到 |
| | | * {@code verify_status} 已经为 0 而拒绝认领;认领在前,撤销核销会看到 {@code confirming} 而拒绝翻单。 |
| | | * 两个方向都不会出现「订单回到待核销、钱却打给了商户」。 |
| | | */ |
| | | private ClaimOutcome claimConfirmation(long orderId, long paymentId) { |
| | | List<Map<String, Object>> orders = DatabaseRows.normalize(jdbc.queryForList(""" |
| | | select verify_status, order_status, refund_status |
| | | from benefit_order |
| | | where id = ? and deleted = 0 |
| | | limit 1 for update |
| | | """, orderId)); |
| | | if (orders.isEmpty()) return ClaimOutcome.ORDER_NOT_VERIFIED; |
| | | Map<String, Object> order = orders.get(0); |
| | | if (number(order.get("verifyStatus")) != 1 || number(order.get("orderStatus")) != 1 |
| | | || number(order.get("refundStatus")) != 0) { |
| | | return ClaimOutcome.ORDER_NOT_VERIFIED; |
| | | } |
| | | return claim(paymentId) ? ClaimOutcome.CLAIMED : ClaimOutcome.NOT_CLAIMED; |
| | | } |
| | | |
| | | private enum ClaimOutcome { CLAIMED, NOT_CLAIMED, ORDER_NOT_VERIFIED } |
| | | |
| | | /** 认领这笔资金回退:只有仍为 confirmed 的那一次能拿到,避免重复向通道发回退请求。 */ |
| | | private boolean claimRevert(long paymentId, String revertNo, String requestDate) { |
| | | int claimed = moneyTransactions.execute(status -> jdbc.update(""" |
| | | update payment_transaction |
| | | set funds_confirmation_status = 'reverting', funds_revert_no = ?, |
| | | funds_revert_request_date = ?, funds_revert_code = null, funds_revert_message = null |
| | | where id = ? and funds_confirmation_status = 'confirmed' |
| | | """, revertNo, requestDate, paymentId)); |
| | | return claimed == 1; |
| | | } |
| | | |
| | | /** |
| | | * 落回退结果。 |
| | | * |
| | | * <p>只有通道明确说成功才把状态退回 {@code pending}(等于「钱在延迟户」)。明确拒绝退回 |
| | | * {@code confirmed},其余一律停在 {@code reverting}:这条路径上没有真实回执证明码的含义之前不猜, |
| | | * 因为把失败当成功会让订单翻回待核销而钱还在商户基本户。 |
| | | */ |
| | | private String applyRevertResult(long paymentId, String revertNo, HuifuDelayRevert result, |
| | | Map<String, Object> payment) { |
| | | switch (result.outcome()) { |
| | | case SUCCEEDED -> { |
| | | // 上一轮的确认回执(funds_confirm_*)保留下来作为「曾经确认过」的证据;商户重新核销时 |
| | | // 会走一次全新的交易确认。 |
| | | Long revertedAmount = cents(payment.get("fundsConfirmedAmount")); |
| | | moneyTransactions.executeWithoutResult(status -> jdbc.update(""" |
| | | update payment_transaction |
| | | set funds_confirmation_status = 'pending', funds_revert_no = ?, |
| | | funds_revert_code = ?, funds_revert_message = ?, |
| | | funds_reverted_amount = ?, funds_reverted_at = now() |
| | | where id = ? and funds_confirmation_status = 'reverting' |
| | | """, revertNo, result.code(), result.describe(), revertedAmount, paymentId)); |
| | | log.info("Fund revert {} succeeded: the money is back in the delay account", revertNo); |
| | | return REVERTED; |
| | | } |
| | | case FAILED -> { |
| | | // 通道明确拒绝:钱没有动,状态退回可重试的 confirmed,并把回执留作证据。 |
| | | moneyTransactions.executeWithoutResult(status -> jdbc.update(""" |
| | | update payment_transaction |
| | | set funds_confirmation_status = 'confirmed', |
| | | funds_revert_code = ?, funds_revert_message = ? |
| | | where id = ? and funds_confirmation_status = 'reverting' |
| | | """, result.code(), result.describe(), paymentId)); |
| | | log.warn("Fund revert {} was rejected: {} ({})", revertNo, result.describe(), result.code()); |
| | | return REVERT_REJECTED; |
| | | } |
| | | case NEEDS_REVIEW -> { |
| | | // 通道给了话但语义未定(例如 23000003):不能假定钱回到了延迟户,留在 reverting 等人工。 |
| | | // 通道流水号要进日志:人工去控台核对时靠它定位这笔回退。 |
| | | recordRevertAttempt(paymentId, revertNo, result.code(), result.describe()); |
| | | log.warn("Fund revert {} has an undetermined result, the order stays verified:" |
| | | + " provider={} code={} desc={}", |
| | | revertNo, result.providerTransactionNo(), result.code(), result.describe()); |
| | | return REVERT_NEEDS_REVIEW; |
| | | } |
| | | default -> { |
| | | // 通道还在处理:同样是钱的位置未定,但它是正常的在途,商户稍后重试就能拿到结果。 |
| | | recordRevertAttempt(paymentId, revertNo, result.code(), result.describe()); |
| | | return REVERT_IN_FLIGHT; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** 只记录一次回退尝试的请求号与描述,不改状态。 */ |
| | | private void recordRevertAttempt(long paymentId, String revertNo, String code, String message) { |
| | | moneyTransactions.executeWithoutResult(status -> jdbc.update(""" |
| | | update payment_transaction |
| | | set funds_revert_no = ?, funds_revert_code = ?, funds_revert_message = ? |
| | | where id = ? |
| | | """, revertNo, code, message == null ? "" : message, paymentId)); |
| | | } |
| | | |
| | | /** |
| | | * 放弃这次回退认领:请求根本没发出去(本地配置或 SDK 出错),钱没有动,退回 confirmed 让商户重试。 |
| | | * |
| | | * <p>请求号与请求日期要清掉:它们是给通道侧定位用的,这次通道根本没收到。失败原因留在 |
| | | * {@code funds_revert_code/message} 里,免得排查时只看到一条干干净净的 confirmed。 |
| | | */ |
| | | private void abandonRevertClaim(long paymentId, String message) { |
| | | moneyTransactions.executeWithoutResult(status -> jdbc.update(""" |
| | | update payment_transaction |
| | | set funds_confirmation_status = 'confirmed', funds_revert_no = null, |
| | | funds_revert_request_date = null, funds_revert_code = 'LOCAL_ERROR', |
| | | funds_revert_message = ? |
| | | where id = ? and funds_confirmation_status = 'reverting' |
| | | """, message == null ? "" : message, paymentId)); |
| | | } |
| | | |
| | | /** 这笔资金是否确实在通道延迟户——撤销核销只有在这个前提下才允许翻单。 */ |
| | | public static boolean moneyBackInDelayAccount(String outcome) { |
| | | return REVERTED.equals(outcome) || REVERT_NOT_NEEDED.equals(outcome); |
| | | } |
| | | |
| | | /** |
| | | * 回退没有成功时给商户的话。 |
| | | * |
| | | * <p>不能只说「撤销失败」:商户需要分清是钱已经结算到银行卡(要联系平台),还是通道还在处理 |
| | | * (稍后重试就行),否则只会反复点。文案跟结论常量放在一起,免得将来新增一种结论时漏配。 |
| | | */ |
| | | public static String revertRefusalMessage(String outcome) { |
| | | if (REVERT_REJECTED.equals(outcome)) return "这笔订单的资金已结算到商户银行卡,无法撤销核销"; |
| | | if (REVERT_IN_FLIGHT.equals(outcome)) return "这笔订单的资金正在退回中,请稍后重试"; |
| | | if (REVERT_NOT_SENT.equals(outcome)) return "撤销核销暂时无法发起,请稍后重试"; |
| | | return "这笔订单的资金状态无法核对,请联系平台处理"; |
| | | } |
| | | |
| | | /** 数据库里的状态是小写,比较必须大小写无关。 */ |
| | | private static boolean statusIs(String status, String expected) { |
| | | return status != null && expected.equalsIgnoreCase(status.trim()); |
| | | } |
| | | |
| | | private static boolean isConfirmed(String status) { |
| | | return status != null && "confirmed".equalsIgnoreCase(status.trim()); |
| | | return statusIs(status, "confirmed"); |
| | | } |
| | | |
| | | private String currentStatus(long paymentId) { |
| | |
| | | return value instanceof Number number ? number.longValue() : 0L; |
| | | } |
| | | |
| | | /** 库里的金额字段(分);空值保持 null,好和「没记过金额」区分开。 */ |
| | | private static Long cents(Object value) { |
| | | return value instanceof Number number ? number.longValue() : null; |
| | | } |
| | | |
| | | private LocalDate localDate(Object value) { |
| | | if (value instanceof java.sql.Timestamp timestamp) return timestamp.toLocalDateTime().toLocalDate(); |
| | | if (value instanceof java.time.LocalDateTime dateTime) return dateTime.toLocalDate(); |
| | |
| | | * |
| | | * <p>业务上是否延迟由订单类型决定:先付钱后核销的订单延迟,核销成功才确认;支付即核销的订单不延迟, |
| | | * 因此不需要确认。 |
| | | * |
| | | * <p>核销被撤销时走反方向:交易确认退款把已确认给商户的钱退回延迟户,重新变成不可结算、不可取现, |
| | | * 等商户重新核销时再确认一次。这条路径的结果分类与确认分开,见 {@link HuifuDelayRevert}。 |
| | | */ |
| | | @Service |
| | | public class HuifuDelayAccountGateway { |
| | |
| | | return execute(request, CONFIRM_QUERY_FUNCTION, "汇付交易确认查询失败"); |
| | | } |
| | | |
| | | /** 把已确认的金额退回原商户账户。 */ |
| | | public HuifuDelayConfirmation refund(HuifuDelayConfirmRefundCommand command) { |
| | | /** 把已确认的金额退回原商户账户(延迟户),撤销核销用它把钱要回来。 */ |
| | | public HuifuDelayRevert refund(HuifuDelayConfirmRefundCommand command) { |
| | | ensureConfigured(); |
| | | initializeSdk(); |
| | | Map<String, Object> request = new LinkedHashMap<>(); |
| | |
| | | "req_seq_id", command.refundNo(), |
| | | "huifu_id", command.huifuId().trim(), |
| | | "org_req_seq_id", command.confirmNo()))); |
| | | return execute(request, CONFIRM_REFUND_FUNCTION, "汇付交易确认退款失败"); |
| | | return executeRevert(request, CONFIRM_REFUND_FUNCTION, "汇付交易确认退款失败"); |
| | | } |
| | | |
| | | private HuifuDelayConfirmation execute(Map<String, Object> request, String function, String failureMessage) { |
| | | private Map<String, Object> call(Map<String, Object> request, String function, String failureMessage) { |
| | | Map<String, Object> response; |
| | | try { |
| | | response = BasePayClient.request(request, function, false); |
| | |
| | | throw new HuifuPaymentGatewayException(failureMessage, isOutcomeUnknown(exception), exception); |
| | | } |
| | | if (response == null) throw new HuifuPaymentGatewayException(failureMessage, true, null); |
| | | return HuifuDelayConfirmation.from(response); |
| | | return response; |
| | | } |
| | | |
| | | private HuifuDelayConfirmation execute(Map<String, Object> request, String function, String failureMessage) { |
| | | return HuifuDelayConfirmation.from(call(request, function, failureMessage)); |
| | | } |
| | | |
| | | private HuifuDelayRevert executeRevert(Map<String, Object> request, String function, String failureMessage) { |
| | | return HuifuDelayRevert.from(call(request, function, failureMessage)); |
| | | } |
| | | |
| | | private void ensureConfigured() { |
| | |
| | | return HuifuPayload.amountInCent(confirmedAmount); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 交易确认退款(撤销核销回退资金)的结果。 |
| | | * |
| | | * <p>刻意不复用 {@link HuifuDelayConfirmation} 的分类:同一个业务码在确认和回退两条路径上语义 |
| | | * 不同。{@code 23000003} 在确认路径上是「已无未确认金额」——那是既有真实回执复验过的幂等成功, |
| | | * 而在回退路径上它既可能是「已无已确认金额」(同样是幂等),也可能是被拒的原因(例如基本户 |
| | | * 余额不足)。这里一律不猜:判成成功会让订单翻回「待核销」而钱还在商户基本户,是资金事故; |
| | | * 判成失败又没有回执依据。所以语义未定的码单独归到 {@link Outcome#NEEDS_REVIEW},停在 |
| | | * 「已核销」等人工核对。 |
| | | */ |
| | | public record HuifuDelayRevert( |
| | | Outcome outcome, |
| | | String code, |
| | | String message, |
| | | String providerTransactionNo |
| | | ) { |
| | | |
| | | public enum Outcome { |
| | | /** 钱已退回商户延迟户。 */ |
| | | SUCCEEDED, |
| | | /** 通道还在处理,结果未定。 */ |
| | | PROCESSING, |
| | | /** 通道明确拒绝(例如资金已结算到银行卡、余额不足),钱没有动。 */ |
| | | FAILED, |
| | | /** 返回码在这条路径上语义未定,不能当作成功或失败,需要人工核对。 */ |
| | | NEEDS_REVIEW |
| | | } |
| | | |
| | | static HuifuDelayRevert from(Map<String, Object> response) { |
| | | Map<String, Object> data = HuifuPayload.businessData(response); |
| | | String code = HuifuPayload.code(data); |
| | | String message = HuifuPayload.firstNonBlank( |
| | | HuifuPayload.value(data, "sub_resp_desc", "trans_desc"), HuifuPayload.message(data)); |
| | | String status = HuifuPayload.value(data, "trans_stat", "trans_status", "transStat", "transStatus"); |
| | | String providerNo = HuifuPayload.value(data, "hf_seq_id", "org_hf_seq_id", "hfSeqId", "orgHfSeqId"); |
| | | return new HuifuDelayRevert(classify(status, code), code, message, providerNo); |
| | | } |
| | | |
| | | /** |
| | | * 在回退路径上语义未定的业务码见 {@link HuifuPayload#undeterminedCode(String)}。 |
| | | * |
| | | * <p>它们必须排在交易状态之前判断:通道给这些码时会同时给 {@code trans_stat=F}, |
| | | * 先看状态就会把它们当成明确失败(交易确认那条路上正是这样踩过一次坑)。 |
| | | */ |
| | | private static Outcome classify(String status, String code) { |
| | | String normalizedCode = code == null ? "" : code.trim(); |
| | | if (HuifuPayload.undeterminedCode(normalizedCode)) return Outcome.NEEDS_REVIEW; |
| | | if (HuifuPayload.successCode(normalizedCode)) return Outcome.SUCCEEDED; |
| | | if (HuifuPayload.status(status) == HuifuPayload.TransStatus.FAILED) return Outcome.FAILED; |
| | | if ("00000100".equals(normalizedCode)) return Outcome.PROCESSING; |
| | | return normalizedCode.isBlank() ? Outcome.PROCESSING : Outcome.FAILED; |
| | | } |
| | | |
| | | /** 供日志与故障定位使用的一句话结论。 */ |
| | | public String describe() { |
| | | if (!message.isBlank()) return message; |
| | | if (!code.isBlank()) return "Huifu delay confirmation refund returned code " + code; |
| | | return "The provider returned no description"; |
| | | } |
| | | } |
| | | } |
| | |
| | | return "00000000".equals(value) || "10000".equals(value) || "0".equals(value) || "SUCCESS".equalsIgnoreCase(value); |
| | | } |
| | | |
| | | /** |
| | | * 在延时交易的资金路径上语义未定的业务码:既不能当作成功,也不能当作失败。 |
| | | * |
| | | * <p>同一个码按接口和场景重载。{@code 23000003} 在交易确认页写作「确认金额大于可确认金额」或 |
| | | * 「已无未确认金额」;{@code 23000004} 在同一页写作「需要传入分账串」。交易确认那条路已经用真实 |
| | | * 回执验证过「{@code 23000003} 同时带 {@code trans_stat=F}」实际是幂等成功,说明只看交易状态 |
| | | * 会判错;而它在退款、交易确认退款方向上的含义还没有回执验证过。 |
| | | * |
| | | * <p>因此遇到这些码的下游一律不要下结论,两个方向的错都很贵:判成功等于放行可能没动的钱, |
| | | * 判失败等于让本地以为钱没动、于是再动一次(重复出款)。 |
| | | */ |
| | | public static boolean undeterminedCode(String code) { |
| | | String value = text(code); |
| | | return "23000003".equals(value) || "23000004".equals(value); |
| | | } |
| | | |
| | | /** Provider amount in yuan with two decimals, converted to integer cents. Null when unusable. */ |
| | | public static Long amountInCent(String yuan) { |
| | | String value = text(yuan); |
| New file |
| | |
| | | -- 撤销核销的资金回退。 |
| | | -- |
| | | -- 核销会把延迟入账的钱从通道「待确认」确认给商户(钱进商户基本户)。撤销核销必须把钱要回来, |
| | | -- 靠的是通道的【交易确认退款】接口:把已确认的金额退回商户的延迟户,重新变成不可结算、不可取现。 |
| | | -- |
| | | -- 回退期间 funds_confirmation_status 用 'reverting' 占位: |
| | | -- 'pending' 钱在通道延迟户(从未确认过,或已回退成功) |
| | | -- 'confirming' 已发出交易确认请求,结果未定 |
| | | -- 'confirmed' 确认成功,钱在商户基本户 |
| | | -- 'reverting' 已发出交易确认退款请求,结果未定——既不能当作钱在基本户,也不能当作钱已回到 |
| | | -- 延迟户,因此撤销核销必须停在「已核销」等它收敛 |
| | | -- |
| | | -- 回退失败(例如钱已被 D1 结算划到商户银行卡、基本户余额不足)会把状态退回 'confirmed', |
| | | -- 并把返回码与描述留在下面这几列里作为证据;回退成功后退回 'pending',商户重新核销时会走一次 |
| | | -- 全新的交易确认,因此上一轮的确认回执(funds_confirm_*)要保留下来,不要清空。 |
| | | -- |
| | | -- 用存储过程按 exists 判断逐列追加,使脚本可以重复执行(本地开发的库常常手工加过列)。 |
| | | DROP PROCEDURE IF EXISTS add_fund_revert_columns; |
| | | DELIMITER // |
| | | CREATE PROCEDURE add_fund_revert_columns() |
| | | BEGIN |
| | | IF NOT EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'payment_transaction' |
| | | AND column_name = 'funds_revert_no') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_revert_no VARCHAR(64) DEFAULT NULL COMMENT '交易确认退款(撤销核销回退)请求流水号'; |
| | | END IF; |
| | | IF NOT EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'payment_transaction' |
| | | AND column_name = 'funds_revert_request_date') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_revert_request_date CHAR(8) DEFAULT NULL COMMENT '交易确认退款请求日期(yyyyMMdd)'; |
| | | END IF; |
| | | IF NOT EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'payment_transaction' |
| | | AND column_name = 'funds_revert_code') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_revert_code VARCHAR(64) DEFAULT NULL COMMENT '交易确认退款返回码'; |
| | | END IF; |
| | | IF NOT EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'payment_transaction' |
| | | AND column_name = 'funds_revert_message') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_revert_message VARCHAR(512) DEFAULT NULL COMMENT '交易确认退款返回描述'; |
| | | END IF; |
| | | IF NOT EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'payment_transaction' |
| | | AND column_name = 'funds_reverted_amount') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_reverted_amount BIGINT DEFAULT NULL COMMENT '回退的金额(分),取自上一轮确认回执的已确认金额'; |
| | | END IF; |
| | | IF NOT EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'payment_transaction' |
| | | AND column_name = 'funds_reverted_at') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_reverted_at DATETIME DEFAULT NULL COMMENT '资金退回延迟户的时间'; |
| | | END IF; |
| | | -- 状态列多了一个 'reverting':注释只在还是旧文案时刷新一次。 |
| | | IF EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'payment_transaction' |
| | | AND column_name = 'funds_confirmation_status' |
| | | AND column_comment LIKE '%confirmed,NULL 表示%') THEN |
| | | ALTER TABLE payment_transaction |
| | | MODIFY COLUMN funds_confirmation_status VARCHAR(16) DEFAULT NULL |
| | | COMMENT '延迟交易的资金确认状态:pending/confirming/confirmed/reverting,NULL 表示未走延迟入账'; |
| | | END IF; |
| | | END // |
| | | DELIMITER ; |
| | | CALL add_fund_revert_columns(); |
| | | DROP PROCEDURE add_fund_revert_columns; |
| | |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.ArgumentMatchers.argThat; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.LocalDate; |
| | | import java.time.LocalDateTime; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.order.OrderRefundRequest; |
| | |
| | | import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway; |
| | | import com.talentcard.server.business.portal.AppOrderCreateRequest; |
| | | import com.talentcard.server.business.portal.AppOrderPortalService; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.mockito.ArgumentCaptor; |
| | |
| | | void aDelayedOrderHoldsTheMoneyUntilItIsConfirmed() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | String paymentNo = insertPaidPayment(orderNo, 100L, "pending"); |
| | | markVerified(orderId(orderNo)); |
| | | |
| | | when(delayGateway.confirm(any())).thenReturn(succeeded("1.00")); |
| | | |
| | |
| | | void anAlreadyConfirmedOrderIsNeverConfirmedTwice() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | insertPaidPayment(orderNo, 100L, "pending"); |
| | | markVerified(orderId(orderNo)); |
| | | when(delayGateway.confirm(any())).thenReturn(succeeded("1.00")); |
| | | |
| | | confirmationService.confirmForOrder(orderId(orderNo)); |
| | |
| | | void aConfirmationAlreadyInFlightIsNotStartedAgain() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | insertPaidPayment(orderNo, 100L, "confirming"); |
| | | markVerified(orderId(orderNo)); |
| | | |
| | | assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.PROCESSING); |
| | |
| | | @Test |
| | | void theRetrySkipsOrdersWhoseFundsAreAlreadyConfirmed() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | insertPaidPayment(orderNo, 100L, "confirmed"); |
| | | jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId(orderNo)); |
| | | String paymentNo = insertPaidPayment(orderNo, 100L, "confirmed"); |
| | | markVerified(orderId(orderNo)); |
| | | // 补偿任务扫的是整库,会顺带处理其它用例留下的「已核销 + 待确认」订单,所以给它们一个可用的 |
| | | // 回执,并把断言收窄到本单:要证明的是这笔已经确认过的钱不会被再确认一次。 |
| | | when(delayGateway.confirm(any())).thenReturn(processing()); |
| | | |
| | | confirmationRetry.retry(50); |
| | | |
| | | verify(delayGateway, never()).confirm(any()); |
| | | assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction" |
| | | + " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed"); |
| | | verify(delayGateway, never()).confirm(argThat(command -> paymentNo.equals(command.paymentNo()))); |
| | | } |
| | | |
| | | @Test |
| | |
| | | void aRejectedConfirmationStaysRetryableInsteadOfLosingTheMoney() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | String paymentNo = insertPaidPayment(orderNo, 100L, "pending"); |
| | | markVerified(orderId(orderNo)); |
| | | when(delayGateway.confirm(any())).thenReturn(failed("22000002", "商户信息不存在")); |
| | | |
| | | assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( |
| | |
| | | assertThat(new OrderRefundRequest(BigDecimal.ONE, "test", "tester")).isNotNull(); |
| | | } |
| | | |
| | | /** |
| | | * 撤销核销要把已确认给商户的钱退回通道延迟户,这是「撤销之后钱又变成不可结算」的唯一保证。 |
| | | */ |
| | | @Test |
| | | void aRevertPutsTheMoneyBackInTheDelayAccount() { |
| | | String paymentNo = confirmedDelayOrder(100L); |
| | | when(delayGateway.refund(any())).thenReturn(revertSucceeded()); |
| | | |
| | | assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo( |
| | | PaymentFundConfirmationService.REVERTED); |
| | | |
| | | Map<String, Object> payment = jdbc.queryForMap(""" |
| | | select funds_confirmation_status, funds_revert_no, funds_revert_request_date, |
| | | funds_reverted_amount, funds_reverted_at, funds_confirm_no |
| | | from payment_transaction where payment_no = ? |
| | | """, paymentNo); |
| | | // 钱回到延迟户:状态退回 pending,商户重新核销时会走一次全新的交易确认。 |
| | | assertThat(payment).containsEntry("funds_confirmation_status", "pending"); |
| | | assertThat(String.valueOf(payment.get("funds_revert_no"))).isNotBlank(); |
| | | assertThat(String.valueOf(payment.get("funds_revert_request_date"))).isNotBlank(); |
| | | assertThat(((Number) payment.get("funds_reverted_amount")).longValue()).isEqualTo(100L); |
| | | assertThat(payment.get("funds_reverted_at")).isNotNull(); |
| | | // 上一轮确认的回执要留着:它是「这笔钱曾经确认过」的证据。 |
| | | assertThat(String.valueOf(payment.get("funds_confirm_no"))).isNotBlank(); |
| | | } |
| | | |
| | | /** |
| | | * 通道明确拒绝(例如钱已被 D1 结算划到商户银行卡):钱没有动,状态回到可重试的 confirmed, |
| | | * 回执留作证据。调用方据此拒绝撤销核销。 |
| | | */ |
| | | @Test |
| | | void aRejectedRevertKeepsTheMoneyWithTheMerchant() { |
| | | String paymentNo = confirmedDelayOrder(100L); |
| | | when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert( |
| | | HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.FAILED, "22000003", "账户余额不足", "")); |
| | | |
| | | assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo( |
| | | PaymentFundConfirmationService.REVERT_REJECTED); |
| | | |
| | | assertThat(jdbc.queryForMap("select funds_confirmation_status, funds_revert_message" |
| | | + " from payment_transaction where payment_no = ?", paymentNo)) |
| | | .containsEntry("funds_confirmation_status", "confirmed") |
| | | .containsEntry("funds_revert_message", "账户余额不足"); |
| | | } |
| | | |
| | | /** |
| | | * 返回码语义未定时绝不能算成功。 |
| | | * |
| | | * <p>{@code 23000003} 在交易确认那条路上是「已无未确认金额」=幂等成功(有真实回执复验过), |
| | | * 但在回退这条路上没有回执证明它的含义。判成成功会让订单翻回「待核销」而钱还在商户基本户, |
| | | * 因此只能停在 reverting 等人工核对。 |
| | | */ |
| | | @Test |
| | | void anUndeterminedRevertCodeNeverCountsAsSuccess() { |
| | | String paymentNo = confirmedDelayOrder(100L); |
| | | when(delayGateway.refund(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayRevert( |
| | | HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.NEEDS_REVIEW, "23000003", "已无已确认金额", "")); |
| | | |
| | | assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo( |
| | | PaymentFundConfirmationService.REVERT_NEEDS_REVIEW); |
| | | |
| | | assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction" |
| | | + " where payment_no = ?", String.class, paymentNo)).isEqualTo("reverting"); |
| | | } |
| | | |
| | | /** |
| | | * 请求根本没发出去(配置不完整、SDK 初始化失败)时,钱没有动,可以重试。 |
| | | * |
| | | * <p>不能报成「已结算到银行卡」:那是另一回事,商户照着这句话去找平台就白跑了。 |
| | | */ |
| | | @Test |
| | | void aRevertThatNeverReachedTheProviderIsRetryable() { |
| | | String paymentNo = confirmedDelayOrder(100L); |
| | | when(delayGateway.refund(any())).thenThrow(new BusinessException(ErrorCode.CONFLICT, "汇付支付配置不完整")); |
| | | |
| | | assertThat(confirmationService.revertForOrder(orderIdOfPayment(paymentNo))).isEqualTo( |
| | | PaymentFundConfirmationService.REVERT_NOT_SENT); |
| | | |
| | | Map<String, Object> payment = jdbc.queryForMap("select funds_confirmation_status, funds_revert_no," |
| | | + " funds_revert_code, funds_revert_message from payment_transaction where payment_no = ?", paymentNo); |
| | | assertThat(payment).containsEntry("funds_confirmation_status", "confirmed") |
| | | .containsEntry("funds_revert_code", "LOCAL_ERROR"); |
| | | // 请求号是给通道侧定位用的,这次通道没收到,不能留在库里冒充一次回退。 |
| | | assertThat(payment.get("funds_revert_no")).isNull(); |
| | | assertThat(String.valueOf(payment.get("funds_revert_message"))).isNotBlank(); |
| | | } |
| | | |
| | | /** 钱还在延迟户:没有已确认的资金要回退,也不该白打一次通道。 */ |
| | | @Test |
| | | void moneyStillInTheDelayAccountNeedsNoRevert() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | insertPaidPayment(orderNo, 100L, "pending"); |
| | | markVerified(orderId(orderNo)); |
| | | |
| | | assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.REVERT_NOT_NEEDED); |
| | | verify(delayGateway, never()).refund(any()); |
| | | } |
| | | |
| | | /** 支付即核销的到店消费不走延迟入账,钱从来不在延迟户,也就没有回退这回事。 */ |
| | | @Test |
| | | void aNonDelayedPaymentNeedsNoRevert() { |
| | | String orderNo = createOrder("STORE_CONSUME", 100L); |
| | | insertPaidPayment(orderNo, 100L, null); |
| | | markVerified(orderId(orderNo)); |
| | | |
| | | assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.REVERT_NOT_NEEDED); |
| | | verify(delayGateway, never()).refund(any()); |
| | | } |
| | | |
| | | /** 已经在回退中的资金不重复发请求:钱的位置未定,撤销核销要等它收敛。 */ |
| | | @Test |
| | | void aRevertInFlightIsNotStartedAgain() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | insertPaidPayment(orderNo, 100L, "reverting"); |
| | | markVerified(orderId(orderNo)); |
| | | |
| | | assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.REVERT_IN_FLIGHT); |
| | | verify(delayGateway, never()).refund(any()); |
| | | } |
| | | |
| | | /** 没有原确认的定位键就撤销不了确认:保持原状交人工,不能假装钱已经退回延迟户。 */ |
| | | @Test |
| | | void aConfirmedPaymentWithoutTheConfirmationReferenceNeedsAHuman() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | String paymentNo = insertPaidPayment(orderNo, 100L, "confirmed"); |
| | | markVerified(orderId(orderNo)); |
| | | |
| | | assertThat(confirmationService.revertForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.REVERT_UNKNOWN); |
| | | verify(delayGateway, never()).refund(any()); |
| | | assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction" |
| | | + " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed"); |
| | | } |
| | | |
| | | /** |
| | | * 放款前必须复查订单仍是「已核销」:撤销核销会把订单翻回待核销,钱必须留在延迟户。 |
| | | * |
| | | * <p>没有这道复查,补偿任务(或在途的确认请求)会在撤销之后把钱打给商户,而订单已经回到待核销。 |
| | | */ |
| | | @Test |
| | | void aConfirmationIsRefusedOnceTheOrderIsNoLongerVerified() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | String paymentNo = insertPaidPayment(orderNo, 100L, "pending"); |
| | | jdbc.update("update benefit_order set order_status = 1, verify_status = 0 where id = ?", orderId(orderNo)); |
| | | |
| | | assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.NOT_VERIFIED); |
| | | verify(delayGateway, never()).confirm(any()); |
| | | assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction" |
| | | + " where payment_no = ?", String.class, paymentNo)).isEqualTo("pending"); |
| | | } |
| | | |
| | | // ------------------------------------------------------------------------------------------ |
| | | // helpers |
| | | // ------------------------------------------------------------------------------------------ |
| | |
| | | "00000000", "交易成功", "0.00", confirmedYuan, "HF-" + System.nanoTime()); |
| | | } |
| | | |
| | | private HuifuDelayAccountGateway.HuifuDelayRevert revertSucceeded() { |
| | | return new HuifuDelayAccountGateway.HuifuDelayRevert( |
| | | HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED, |
| | | "00000000", "交易成功", "HF-R-" + System.nanoTime()); |
| | | } |
| | | |
| | | /** 资金确认与资金回退都要求订单处于「已核销」。 */ |
| | | private void markVerified(long orderId) { |
| | | jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId); |
| | | } |
| | | |
| | | /** |
| | | * 造一笔「延迟入账 + 资金已确认给商户」的订单,返回支付流水号。 |
| | | * |
| | | * <p>确认走真实服务而不是直接改字段:撤销时用来定位原确认的请求号与请求日期,正是它写下的。 |
| | | */ |
| | | private String confirmedDelayOrder(long amountInCent) { |
| | | String orderNo = createOrder("GROUP_BUY", amountInCent); |
| | | String paymentNo = insertPaidPayment(orderNo, amountInCent, "pending"); |
| | | long orderId = orderId(orderNo); |
| | | markVerified(orderId); |
| | | when(delayGateway.confirm(any())).thenReturn(succeeded(BigDecimal.valueOf(amountInCent, 2).toPlainString())); |
| | | confirmationService.confirmForOrder(orderId); |
| | | return paymentNo; |
| | | } |
| | | |
| | | private long orderIdOfPayment(String paymentNo) { |
| | | return jdbc.queryForObject("select order_id from payment_transaction where payment_no = ?", |
| | | Long.class, paymentNo); |
| | | } |
| | | |
| | | /** |
| | | * 通道说「已无未确认金额」表示这笔延迟交易此前已经确认过(重新确认不会重复分账)。 |
| | | * 必须按成功处理:否则补偿任务会一直认为没确认,每隔几分钟无意义地打一次通道。 |
| | |
| | | void nothingLeftToConfirmCountsAsAlreadyConfirmed() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | String paymentNo = insertPaidPayment(orderNo, 100L, "pending"); |
| | | markVerified(orderId(orderNo)); |
| | | when(delayGateway.confirm(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayConfirmation( |
| | | HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED, |
| | | "23000003", "已无未确认金额", "", "", "")); |
| | |
| | | + " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed"); |
| | | } |
| | | |
| | | /** |
| | | * 认领之后进程就没了(部署、崩溃)会留下 {@code confirming} 的孤儿:补偿任务此前只挑得到它、 |
| | | * 从不重发,这笔钱就永远冻在延迟户。隔天必须能重新确认一次。 |
| | | */ |
| | | @Test |
| | | void aConfirmationAbandonedByACrashIsRetriedTheNextDay() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | String paymentNo = insertPaidPayment(orderNo, 100L, "confirming"); |
| | | long orderId = orderId(orderNo); |
| | | markVerified(orderId); |
| | | jdbc.update("update payment_transaction set funds_confirm_request_date = ? where payment_no = ?", |
| | | LocalDate.now().minusDays(1).format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo); |
| | | when(delayGateway.confirm(any())).thenReturn(succeeded("1.00")); |
| | | |
| | | assertThat(confirmationService.confirmForOrder(orderId)).isEqualTo( |
| | | PaymentFundConfirmationService.CONFIRMED); |
| | | assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction" |
| | | + " where payment_no = ?", String.class, paymentNo)).isEqualTo("confirmed"); |
| | | } |
| | | |
| | | /** 今天认领的确认不重发:它可能还在通道里处理。 */ |
| | | @Test |
| | | void aConfirmationClaimedTodayIsNotSentAgain() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | String paymentNo = insertPaidPayment(orderNo, 100L, "confirming"); |
| | | markVerified(orderId(orderNo)); |
| | | jdbc.update("update payment_transaction set funds_confirm_request_date = ? where payment_no = ?", |
| | | LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE), paymentNo); |
| | | |
| | | assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.PROCESSING); |
| | | verify(delayGateway, never()).confirm(any()); |
| | | } |
| | | |
| | | /** 通道还在处理:状态保持 pending,供确认补偿任务下一轮再试。 */ |
| | | private HuifuDelayAccountGateway.HuifuDelayConfirmation processing() { |
| | | return new HuifuDelayAccountGateway.HuifuDelayConfirmation( |
| | |
| | | .isEqualTo(1); |
| | | } |
| | | |
| | | /** |
| | | * 语义未定的业务码不能判成失败:判失败会把订单解锁,用户再退一次就成了重复出款。 |
| | | * |
| | | * <p>{@code 23000003} 在交易确认那条路上已经用真实回执验证过会伴随 {@code trans_stat=F}, |
| | | * 所以在退款这条路上也只能停在「退款中」,等通道查单/对账/人工给答案。 |
| | | */ |
| | | @Test |
| | | void anUndeterminedRefundCodeDoesNotUnlockTheOrderForAnotherRefund() { |
| | | RefundingOrder order = refundingOrder(1); |
| | | when(refundGateway.queryRefund(any(), any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "F", "sub_resp_code", "23000003", "sub_resp_desc", "已无未确认金额"))); |
| | | |
| | | recordOf(order.orderNo()); |
| | | |
| | | assertThat(jdbc.queryForObject("select refund_status from order_refund where refund_no = ?", Integer.class, order.refundNo())) |
| | | .as("语义未定的码既不能判成功也不能判失败") |
| | | .isEqualTo(1); |
| | | assertThat(jdbc.queryForObject("select refund_status from benefit_order where order_no = ?", Integer.class, order.orderNo())) |
| | | .as("订单不能被解锁去再退一次") |
| | | .isEqualTo(1); |
| | | } |
| | | |
| | | @Test |
| | | void anAlreadyRefundedOrderIsNotWaitingToBeVerifiedAndAsksTheProviderNothing() { |
| | | RefundingOrder order = refundingOrder(2); |
| New file |
| | |
| | | package com.talentcard.server.business.payment; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.merchant.MerchantRevokeVerificationRequest; |
| | | import com.talentcard.server.business.merchant.MerchantWorkService; |
| | | import com.talentcard.server.business.merchant.VerifyRequest; |
| | | import com.talentcard.server.business.merchant.auth.MerchantPrincipal; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.boot.test.context.SpringBootTest; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.test.context.ActiveProfiles; |
| | | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| | | |
| | | /** |
| | | * 撤销核销的资金侧:钱必须真的回到通道延迟户,订单才允许翻回「待核销」。 |
| | | * |
| | | * <p>核销时钱已经从延迟户划给商户(进了基本户),撤销核销要靠通道的交易确认退款把它退回来。 |
| | | * 回退没成功就不许翻单——否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销一次; |
| | | * 等 D1 自动结算把钱划到商户银行卡之后,通道那边也退不回来了。 |
| | | */ |
| | | @SpringBootTest |
| | | @ActiveProfiles("test") |
| | | class RevokeVerificationFundTests { |
| | | |
| | | private static final String HUIFU_ID = "6666000226048687"; |
| | | |
| | | @Autowired |
| | | private JdbcTemplate jdbc; |
| | | |
| | | @Autowired |
| | | private MerchantWorkService merchantWorkService; |
| | | |
| | | @MockitoBean |
| | | private HuifuDelayAccountGateway delayGateway; |
| | | |
| | | private Long merchantId; |
| | | private Long storeId; |
| | | private Long talentId; |
| | | private MerchantPrincipal principal; |
| | | |
| | | @BeforeEach |
| | | void prepareMerchant() { |
| | | String suffix = String.valueOf(System.nanoTime()); |
| | | jdbc.update("insert into merchant(merchant_name, status, can_login_merchant_app, deleted) values (?, 1, 1, 0)", |
| | | "撤销核销商户 " + suffix); |
| | | merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); |
| | | jdbc.update("insert into merchant_store(merchant_id, store_name, status, deleted) values (?, ?, 1, 0)", |
| | | merchantId, "撤销核销门店 " + suffix); |
| | | storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class); |
| | | jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", |
| | | "revoke-fund-" + suffix, "撤销核销用户", "13900000010"); |
| | | talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class); |
| | | principal = new MerchantPrincipal(9101L, merchantId, storeId, "operator"); |
| | | } |
| | | |
| | | /** 撤销核销要把钱退回延迟户:退回成功才翻单,这笔钱才算重新变成不可结算。 */ |
| | | @Test |
| | | void revokesOnlyAfterTheMoneyComesBackToTheDelayAccount() { |
| | | long orderId = verifiedOrderWithConfirmedMoney(); |
| | | when(delayGateway.refund(any())).thenReturn(revert(HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.SUCCEEDED, |
| | | "00000000", "交易成功")); |
| | | |
| | | Map<String, Object> revoked = merchantWorkService.revokeVerification(principal, |
| | | new MerchantRevokeVerificationRequest(String.valueOf(orderId), "test reversal"), "revoke-back-" + orderId); |
| | | |
| | | assertThat(revoked).containsEntry("orderStatus", "pending_verification"); |
| | | assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId)) |
| | | .isZero(); |
| | | // 钱回到延迟户,商户下次核销时会走一次全新的交易确认。 |
| | | assertThat(fundsStatus(orderId)).isEqualTo("pending"); |
| | | } |
| | | |
| | | /** |
| | | * 通道没把钱退回来就不许翻单:否则订单显示「待核销」,钱却已经结算给商户,商户还能拿着这张单再核销。 |
| | | */ |
| | | @Test |
| | | void refusesToRevokeWhileTheMoneyHasNotComeBack() { |
| | | long orderId = verifiedOrderWithConfirmedMoney(); |
| | | when(delayGateway.refund(any())).thenReturn(revert( |
| | | HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.NEEDS_REVIEW, "23000003", "已无已确认金额")); |
| | | |
| | | assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal, |
| | | new MerchantRevokeVerificationRequest(String.valueOf(orderId), "test reversal"), "revoke-held-" + orderId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | // 语义未定时不能报成「已结算」,也不能让商户反复重试:请他联系平台。 |
| | | .hasMessageContaining("无法核对"); |
| | | |
| | | assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId)) |
| | | .isEqualTo(1); |
| | | // 钱的位置没定:状态停在 reverting,等人工核对,不能当成已经退回延迟户。 |
| | | assertThat(fundsStatus(orderId)).isEqualTo("reverting"); |
| | | } |
| | | |
| | | /** 钱已经结算到银行卡时,商户要知道原因,而不是只看到一句「撤销失败」。 */ |
| | | @Test |
| | | void tellsTheMerchantWhenTheSettledMoneyCannotBeCalledBack() { |
| | | long orderId = verifiedOrderWithConfirmedMoney(); |
| | | when(delayGateway.refund(any())).thenReturn(revert( |
| | | HuifuDelayAccountGateway.HuifuDelayRevert.Outcome.FAILED, "22000003", "账户余额不足")); |
| | | |
| | | assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal, |
| | | new MerchantRevokeVerificationRequest(String.valueOf(orderId), "test reversal"), "revoke-settled-" + orderId)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("已结算到商户银行卡"); |
| | | |
| | | assertThat(jdbc.queryForObject("select verify_status from benefit_order where id = ?", Integer.class, orderId)) |
| | | .isEqualTo(1); |
| | | // 明确拒绝:钱没有动,状态回到可重试的 confirmed,商户补足后还能再撤。 |
| | | assertThat(fundsStatus(orderId)).isEqualTo("confirmed"); |
| | | } |
| | | |
| | | private HuifuDelayAccountGateway.HuifuDelayRevert revert( |
| | | HuifuDelayAccountGateway.HuifuDelayRevert.Outcome outcome, String code, String message) { |
| | | return new HuifuDelayAccountGateway.HuifuDelayRevert(outcome, code, message, "HF-R-" + System.nanoTime()); |
| | | } |
| | | |
| | | /** |
| | | * 造一笔「已核销 + 资金已确认给商户」的延迟入账订单,返回订单主键。 |
| | | * |
| | | * <p>核销走真实服务:它会写下核销记录并调用交易确认,撤销时才有核销记录可撤、有确认可退。 |
| | | */ |
| | | private long verifiedOrderWithConfirmedMoney() { |
| | | String orderNo = "ORDER-REVOKE-" + System.nanoTime(); |
| | | jdbc.update(""" |
| | | insert into benefit_order(order_no, talent_id, merchant_id, store_id, order_type, order_amount, |
| | | subsidy_amount, pay_amount, order_status, verify_status, created_at, deleted) |
| | | values (?, ?, ?, ?, 'GROUP_BUY', 10000, 1000, 9000, 1, 0, ?, 0) |
| | | """, orderNo, talentId, merchantId, storeId, java.sql.Timestamp.valueOf(LocalDateTime.now())); |
| | | Long orderId = jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo); |
| | | jdbc.update(""" |
| | | insert into payment_transaction(payment_no, provider, order_id, order_no, talent_id, merchant_id, |
| | | provider_huifu_id, amount, status, idempotency_key, attempt_no, |
| | | paid_at, funds_confirmation_status) |
| | | values (?, 'HUIFU', ?, ?, ?, ?, ?, 9000, 'SUCCEEDED', ?, 1, now(), 'pending') |
| | | """, "P-VERIFY-" + System.nanoTime(), orderId, orderNo, talentId, merchantId, HUIFU_ID, |
| | | "verify-payment-" + orderNo); |
| | | when(delayGateway.confirm(any())).thenReturn(new HuifuDelayAccountGateway.HuifuDelayConfirmation( |
| | | HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED, |
| | | "00000000", "交易成功", "0.00", "90.00", "HF-C-" + System.nanoTime())); |
| | | merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "test verification"), |
| | | "verify-" + orderNo); |
| | | return orderId; |
| | | } |
| | | |
| | | private String fundsStatus(long orderId) { |
| | | return jdbc.queryForObject("select funds_confirmation_status from payment_transaction where order_id = ?", |
| | | String.class, orderId); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.payment.huifu; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | |
| | | import java.util.LinkedHashMap; |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayRevert; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway.HuifuDelayRevert.Outcome; |
| | | import org.junit.jupiter.api.Test; |
| | | |
| | | /** |
| | | * 资金回退(交易确认退款)的结果分类。 |
| | | * |
| | | * <p>这一组用例盯的是「同一个业务码在两条路径上语义不同」:交易确认把 {@code 23000003} 当作 |
| | | * 「已无未确认金额」=幂等成功(有真实回执复验过),但回退路径上没有回执证明它的含义,一律不能算成功 |
| | | * ——判成成功会让订单翻回「待核销」而钱还在商户基本户,属于资金事故。 |
| | | */ |
| | | class HuifuDelayRevertClassificationTests { |
| | | |
| | | @Test |
| | | void aSuccessCodeFromTheProviderIsTheOnlySuccess() { |
| | | assertThat(outcomeOf("00000000", "S")).isEqualTo(Outcome.SUCCEEDED); |
| | | // 顶层网关码也代表成功调用。 |
| | | assertThat(outcomeOf("10000", "S")).isEqualTo(Outcome.SUCCEEDED); |
| | | } |
| | | |
| | | /** |
| | | * 语义未定的码既不算成功也不算失败:{@code 23000003} 在确认路径上是幂等成功, |
| | | * 在回退路径上可能是「已无已确认金额」,也可能是被拒的原因(例如余额不足)。 |
| | | */ |
| | | @Test |
| | | void codesWithUndeterminedMeaningAreNeverSuccess() { |
| | | assertThat(outcomeOf("23000003", "F")).isEqualTo(Outcome.NEEDS_REVIEW); |
| | | assertThat(outcomeOf("23000004", "F")).isEqualTo(Outcome.NEEDS_REVIEW); |
| | | assertThat(outcomeOf("23000003", "S")).isEqualTo(Outcome.NEEDS_REVIEW); |
| | | } |
| | | |
| | | @Test |
| | | void aDeclaredFailureIsAFailure() { |
| | | assertThat(outcomeOf("22000002", "F")).isEqualTo(Outcome.FAILED); |
| | | assertThat(outcomeOf("", "F")).isEqualTo(Outcome.FAILED); |
| | | } |
| | | |
| | | @Test |
| | | void aRequestStillInFlightIsNotASettledResult() { |
| | | assertThat(outcomeOf("00000100", "P")).isEqualTo(Outcome.PROCESSING); |
| | | assertThat(outcomeOf("", "")).isEqualTo(Outcome.PROCESSING); |
| | | // 状态说成功但没有任何业务码:两个证据不一致,按未定处理,不按成功放行。 |
| | | assertThat(outcomeOf("", "S")).isEqualTo(Outcome.PROCESSING); |
| | | } |
| | | |
| | | @Test |
| | | void theProviderDescriptionIsKeptForTroubleshooting() { |
| | | HuifuDelayRevert revert = HuifuDelayRevert.from(response("22000003", "F", "账户余额不足")); |
| | | assertThat(revert.describe()).isEqualTo("账户余额不足"); |
| | | assertThat(revert.code()).isEqualTo("22000003"); |
| | | } |
| | | |
| | | private Outcome outcomeOf(String code, String transStat) { |
| | | return HuifuDelayRevert.from(response(code, transStat, "返回描述")).outcome(); |
| | | } |
| | | |
| | | private Map<String, Object> response(String code, String transStat, String description) { |
| | | Map<String, Object> response = new LinkedHashMap<>(); |
| | | response.put("sub_resp_code", code); |
| | | response.put("sub_resp_desc", description); |
| | | response.put("trans_stat", transStat); |
| | | return response; |
| | | } |
| | | } |