feat(payment): 延迟入账的支付改为核销后确认资金
先付钱后核销的订单,钱必须留在通道「待确认」,核销确认后才分配给商户;
支付即核销的到店消费订单不延迟,避免给已完成的交易凭空加一道确认。
- 新增 HuifuDelayAccountGateway:交易确认/确认查询/确认退款,三个路径已真实调通
- 新增 PaymentFundConfirmationService:幂等认领、失败可重试、请求结果未知绝不按失败处理
- 下单按订单类型决定 delay_acct_flag,只有 STORE_CONSUME 不延迟,其它(含新增类型)默认延迟
- 核销成功触发交易确认;自动提现只汇总资金已确认的订单,避免开出通道执行不了的取现
- 商户业务开通时自动带上 delay_flag=Y、forced_delay_flag=N,进件即配好延迟能力
- 迁移 V139 记录资金确认状态与通道回执字段
| | |
| | | /.idea/ |
| | | /uploads/ |
| | | # 临时探针把 class 输出到源码目录旁边,不提交编译产物。 |
| | | *.class |
| | |
| | | * {@code uk_merchant_withdrawal_daily(merchant_id, store_key, withdrawal_date)} 兜底), |
| | | * 任务重复执行或两个实例并发都不会重复生成提现金额。 |
| | | * |
| | | * <p>重试:这一业务日已经有 {@code failed} 记录时不算「已汇总」。失败时来源订单已被放回可汇总范围, |
| | | * 因此下一次汇总会把这条失败记录退回 {@code pending} 并更新金额(业务请求号不变),让提交任务重新出账。 |
| | | * 只跳过 {@code pending}/{@code processing}/{@code paid}——那三种状态的钱要么已提交给通道,要么正要提交。 |
| | | * 否则「通道当时说不可取、后来可取」的钱会永远提不出来(DM 额度就是随账户可取资金浮动的)。 |
| | | * |
| | | * <p>只在本服务内生成提现记录并把订单标记为已进入提现记录,不调用斗拱主动取现接口;提交给斗拱、 |
| | | * 处理到账结果和失败顺延由工单 05 负责。 |
| | | */ |
| | |
| | | where bo.merchant_id = o.merchant_id and bo.store_id = o.store_id |
| | | and bo.deleted = 0 and bo.order_status = 1 and bo.verify_status = 1 |
| | | and bo.refund_status = 0 and bo.withdrawal_status = 0 |
| | | and vr.verify_time < ? and vr.verify_time >= ?) |
| | | and vr.verify_time < ? and vr.verify_time >= ? |
| | | and not exists (select 1 |
| | | from payment_transaction pt |
| | | where pt.order_id = bo.id |
| | | and pt.funds_confirmation_status is not null |
| | | and pt.funds_confirmation_status <> 'confirmed')) |
| | | """; |
| | | |
| | | /** 权威的可汇总订单查询:行锁复核之前先按同样的条件取一次。 */ |
| | | /** |
| | | * 权威的可汇总订单查询:行锁复核之前先按同样的条件取一次。 |
| | | * |
| | | * <p>最后一个 {@code not exists} 是资金门槛:延迟入账的支付在核销前停在通道「待确认」,只有确认成功 |
| | | * ({@code funds_confirmation_status='confirmed'})的钱才真的在商户账上。少了这个条件,一笔还没确认的 |
| | | * 订单会进提现,开出通道根本执行不了的取现。 |
| | | */ |
| | | private static final String ELIGIBLE_ORDERS = """ |
| | | select bo.id, bo.pay_amount |
| | | from benefit_order bo |
| | |
| | | from verify_record vr |
| | | where vr.order_id = bo.id and vr.status = 1 |
| | | and vr.verify_time < ? and vr.verify_time >= ?) |
| | | and not exists (select 1 |
| | | from payment_transaction pt |
| | | where pt.order_id = bo.id |
| | | and pt.funds_confirmation_status is not null |
| | | and pt.funds_confirmation_status <> 'confirmed') |
| | | order by bo.id asc |
| | | """; |
| | | |
| | | private final JdbcTemplate jdbc; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | private final TransactionTemplate transactions; |
| | | private final AutoWithdrawalProperties properties; |
| | | private final Clock clock; |
| | | |
| | | @Autowired |
| | |
| | | |
| | | /** |
| | | * 供测试或补跑脚本按指定时钟构造:业务日由时钟的北京时间决定, |
| | | * 因此「昨天」的判定可以脱离运行环境验证。 |
| | | * 因此「昨天」的判定可以脱离运行环境验证。{@code properties} 只为保持调用方签名,不再有金额门槛。 |
| | | */ |
| | | public AutoWithdrawalAggregationService(JdbcTemplate jdbc, OrderFundBoundaryService orderFundBoundary, |
| | | TransactionTemplate transactions, AutoWithdrawalProperties properties, |
| | |
| | | this.jdbc = jdbc; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | this.transactions = transactions; |
| | | this.properties = properties; |
| | | this.clock = clock; |
| | | } |
| | | |
| | |
| | | private void aggregate(Long merchantId, Long storeId, Long bankCardId, String dateText, |
| | | LocalDateTime windowEnd, LocalDateTime windowStart, Result result) { |
| | | Contribution contribution = transactions.execute(status -> { |
| | | if (hasDailyWithdrawal(merchantId, storeId, dateText)) return Contribution.SKIPPED; |
| | | if (hasLiveDailyWithdrawal(merchantId, storeId, dateText)) return Contribution.SKIPPED; |
| | | List<OrderAmount> orders = new ArrayList<>(); |
| | | long total = 0; |
| | | for (Map<String, Object> row : DatabaseRows.normalize(jdbc.queryForList( |
| | |
| | | total += amount; |
| | | } |
| | | if (orders.isEmpty()) return Contribution.NONE; |
| | | if (deferred(total)) return Contribution.DEFERRED; |
| | | |
| | | createWithdrawal(merchantId, storeId, bankCardId, dateText, total, orders); |
| | | return new Contribution(Contribution.Outcome.AGGREGATED, total, orders.size()); |
| | |
| | | if (contribution == null) return; |
| | | switch (contribution.outcome()) { |
| | | case AGGREGATED -> result.merchantAggregated(contribution.amountCents(), contribution.orders()); |
| | | case DEFERRED -> result.merchantDeferred(); |
| | | case SKIPPED -> result.merchantSkipped(); |
| | | case NONE -> { } |
| | | } |
| | |
| | | && !locked.withdrawalLocked(); |
| | | } |
| | | |
| | | private boolean deferred(long total) { |
| | | long buffer = Math.max(0, properties.getFeeBufferCents()); |
| | | long minimum = Math.max(0, properties.getMinAmountCents()); |
| | | return total < minimum || total - buffer < minimum; |
| | | } |
| | | |
| | | private boolean hasDailyWithdrawal(Long merchantId, Long storeId, String dateText) { |
| | | Integer count = jdbc.queryForObject( |
| | | "select count(*) from merchant_withdrawal where merchant_id = ? and store_id = ? and withdrawal_date = ?", |
| | | Integer.class, merchantId, storeId, dateText); |
| | | /** |
| | | * 这一(商户 + 门店 + 业务日)是否已经有一条**活跃**的提现记录。 |
| | | * |
| | | * <p>只有 {@code pending}/{@code processing}/{@code paid} 才算「已汇总」:这些状态的记录要么正等着提交、 |
| | | * 要么已经提交给通道,重复汇总必然重复出账。{@code failed} 不算——失败时来源订单已经被放回可汇总范围 |
| | | * (见 {@code AutoWithdrawalResultService.releaseSourceOrders}),若这里仍把它算成已汇总,那笔钱就再也 |
| | | * 出不了账:钱可能只是当时「不可取」(例如 DM 额度随可用余额浮动),到账后却没有任何路径重试。 |
| | | */ |
| | | private boolean hasLiveDailyWithdrawal(Long merchantId, Long storeId, String dateText) { |
| | | Integer count = jdbc.queryForObject(""" |
| | | select count(*) from merchant_withdrawal |
| | | where merchant_id = ? and store_id = ? and withdrawal_date = ? |
| | | and status in ('pending', 'processing', 'paid') |
| | | """, Integer.class, merchantId, storeId, dateText); |
| | | return count != null && count > 0; |
| | | } |
| | | |
| | |
| | | long total, List<OrderAmount> orders) { |
| | | // 业务请求号固定为商户 + 门店 + 业务日:重复插入会被 uk_merchant_withdrawal_key 拦住,不会重复出账。 |
| | | String idempotencyKey = "auto-withdrawal:" + merchantId + ":" + storeId + ":" + dateText; |
| | | jdbc.update(""" |
| | | insert into merchant_withdrawal(merchant_id, store_id, withdrawal_no, bank_card_id, |
| | | amount_cents, idempotency_key, withdrawal_date) |
| | | values (?, ?, ?, ?, ?, ?, ?) |
| | | """, merchantId, storeId, BusinessIdGenerator.next("WA"), bankCardId, total, idempotencyKey, dateText); |
| | | try { |
| | | jdbc.update(""" |
| | | insert into merchant_withdrawal(merchant_id, store_id, withdrawal_no, bank_card_id, |
| | | amount_cents, idempotency_key, withdrawal_date) |
| | | values (?, ?, ?, ?, ?, ?, ?) |
| | | """, merchantId, storeId, BusinessIdGenerator.next("WA"), bankCardId, total, idempotencyKey, dateText); |
| | | } catch (DuplicateKeyException exception) { |
| | | // 业务日唯一键不允许同一「商户 + 门店 + 业务日」有第二条记录,所以当天已经失败过时只能复用那条: |
| | | // 把它退回 pending 并改成本次选中的金额,让提交任务重新出账。 |
| | | // 业务请求号保持不变(仍然唯一到「日」),并发执行时同样会被唯一键拦住。 |
| | | reuseFailedDailyWithdrawal(merchantId, storeId, dateText, idempotencyKey, total); |
| | | } |
| | | Long withdrawalId = jdbc.queryForObject( |
| | | "select id from merchant_withdrawal where merchant_id = ? and idempotency_key = ?", |
| | | Long.class, merchantId, idempotencyKey); |
| | | // 复用失败记录时这条提现已经有旧的关联行(released_at 非空),先删掉再重建,避免同一条提现里 |
| | | // 对同一订单留下多行;uk_withdrawal_order_active(order_id, active) 只约束「未释放」的那一行, |
| | | // 所以删除已释放行、插入新的未释放行不会互相冲突。 |
| | | jdbc.update("delete from merchant_withdrawal_order where withdrawal_id = ? and released_at is not null", |
| | | withdrawalId); |
| | | // 行已经被 lock(orderId) 锁住,这里只需要写锁定状态并登记关联。 |
| | | jdbc.batchUpdate("update benefit_order set withdrawal_status = 1 where id = ? and withdrawal_status = 0", |
| | | // 先清后置,保证一个订单最多只有一条未释放的关联。 |
| | | jdbc.batchUpdate("update benefit_order set withdrawal_status = 0 where id = ?", |
| | | orders.stream().map(order -> new Object[] { order.orderId() }).toList()); |
| | | jdbc.batchUpdate("update benefit_order set withdrawal_status = 1 where id = ?", |
| | | orders.stream().map(order -> new Object[] { order.orderId() }).toList()); |
| | | jdbc.batchUpdate(""" |
| | | insert into merchant_withdrawal_order(withdrawal_id, order_id, amount_cents) values (?, ?, ?) |
| | | """, orders.stream() |
| | | .map(order -> new Object[] { withdrawalId, order.orderId(), order.amount() }) |
| | | .toList()); |
| | | } |
| | | |
| | | /** |
| | | * 把这一业务日已经失败的提现记录退回 {@code pending},用于「当时不可取、后来可取」的重新出账。 |
| | | * |
| | | * <p>只复用 {@code failed} 的记录:{@code pending}/{@code processing} 说明这笔已经受理过或正等着提交, |
| | | * 调用方在 {@link #hasLiveDailyWithdrawal} 就已经跳过,不会走到这里;{@code paid} 更不该被改写。 |
| | | * 若并发下这条记录已经不是 {@code failed},更新不会命中,后续按同一条记录继续,不会重复出账。 |
| | | */ |
| | | private void reuseFailedDailyWithdrawal(Long merchantId, Long storeId, String dateText, |
| | | String idempotencyKey, long total) { |
| | | int reused = jdbc.update(""" |
| | | update merchant_withdrawal |
| | | set status = 'pending', amount_cents = ?, failure_reason = null, |
| | | provider_response_code = null, provider_response_message = null, |
| | | provider_request_date = null, provider_huifu_id = null |
| | | where merchant_id = ? and idempotency_key = ? and withdrawal_date = ? and status = 'failed' |
| | | """, total, merchantId, idempotencyKey, dateText); |
| | | if (reused > 0) { |
| | | log.info("Reusing the failed daily withdrawal of merchant {} store {} on {} with amount {} cents", |
| | | merchantId, storeId, dateText, total); |
| | | } else { |
| | | log.warn("A daily withdrawal of merchant {} store {} on {} already exists and is not failed; " |
| | | + "keeping it as is", merchantId, storeId, dateText); |
| | | } |
| | | } |
| | | |
| | | private Long number(Object value) { |
| | |
| | | |
| | | private final String businessDate; |
| | | private int merchantsAggregated; |
| | | private int merchantsDeferred; |
| | | private int merchantsSkipped; |
| | | private int merchantsFailed; |
| | | private int ordersAggregated; |
| | |
| | | ordersAggregated += orders; |
| | | } |
| | | |
| | | void merchantDeferred() { |
| | | merchantsDeferred++; |
| | | } |
| | | |
| | | void merchantSkipped() { |
| | | merchantsSkipped++; |
| | | } |
| | |
| | | |
| | | public String businessDate() { return businessDate; } |
| | | public int merchantsAggregated() { return merchantsAggregated; } |
| | | public int merchantsDeferred() { return merchantsDeferred; } |
| | | public int merchantsSkipped() { return merchantsSkipped; } |
| | | public int merchantsFailed() { return merchantsFailed; } |
| | | public int ordersAggregated() { return ordersAggregated; } |
| | |
| | | @Override |
| | | public String toString() { |
| | | return "businessDate=" + businessDate + " aggregated=" + merchantsAggregated |
| | | + " deferred=" + merchantsDeferred + " skipped=" + merchantsSkipped |
| | | + " skipped=" + merchantsSkipped |
| | | + " failed=" + merchantsFailed + " orders=" + ordersAggregated + " amountCents=" + amountCents; |
| | | } |
| | | } |
| | |
| | | private record Contribution(Outcome outcome, long amountCents, int orders) { |
| | | |
| | | static final Contribution NONE = new Contribution(Outcome.NONE, 0, 0); |
| | | static final Contribution DEFERRED = new Contribution(Outcome.DEFERRED, 0, 0); |
| | | static final Contribution SKIPPED = new Contribution(Outcome.SKIPPED, 0, 0); |
| | | |
| | | enum Outcome { AGGREGATED, DEFERRED, SKIPPED, NONE } |
| | | enum Outcome { AGGREGATED, SKIPPED, NONE } |
| | | } |
| | | } |
| | |
| | | import com.talentcard.server.business.PageResult; |
| | | import com.talentcard.server.business.merchant.auth.MerchantPrincipal; |
| | | import com.talentcard.server.business.order.OrderFundBoundaryService; |
| | | import com.talentcard.server.business.payment.PaymentFundConfirmationService; |
| | | import com.talentcard.server.business.portal.ProductPriceCalculator; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | |
| | | private final JdbcTemplate jdbcTemplate; |
| | | private final ManualConsumptionService manualConsumptionService; |
| | | private final OrderFundBoundaryService orderFundBoundary; |
| | | private final PaymentFundConfirmationService fundConfirmation; |
| | | |
| | | public MerchantWorkService(JdbcTemplate jdbcTemplate, ManualConsumptionService manualConsumptionService, |
| | | OrderFundBoundaryService orderFundBoundary) { |
| | | OrderFundBoundaryService orderFundBoundary, PaymentFundConfirmationService fundConfirmation) { |
| | | this.jdbcTemplate = jdbcTemplate; |
| | | this.manualConsumptionService = manualConsumptionService; |
| | | this.orderFundBoundary = orderFundBoundary; |
| | | this.fundConfirmation = fundConfirmation; |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | |
| | | if (updated != 1) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "订单已核销"); |
| | | } |
| | | // 核销通过后把钱放给商户:延迟入账的支付此前停在通道「待确认」,确认成功商户才真正拿到钱。 |
| | | // 确认失败不会回滚核销(用户的服务已经核销掉了),资金保持待确认并留下记录供重试。 |
| | | fundConfirmation.confirmForOrder(locked.orderId()); |
| | | return order(orderNo, principal.merchantId(), principal.storeId()); |
| | | } |
| | | |
| | |
| | | Map<String, Object> response; |
| | | try { |
| | | response = gateway.createMiniProgramPayment(new HuifuMiniProgramPaymentCommand( |
| | | prepared.paymentNo(), prepared.huifuId(), prepared.openId(), prepared.amountInCent(), prepared.goodsDescription(), prepared.expiresAt())); |
| | | prepared.paymentNo(), prepared.huifuId(), prepared.openId(), prepared.amountInCent(), prepared.goodsDescription(), prepared.expiresAt(), |
| | | prepared.delayAccount())); |
| | | } catch (HuifuPaymentGatewayException exception) { |
| | | if (exception.isOutcomeUnknown()) { |
| | | transactions.executeWithoutResult(status -> markCreateUnknown(prepared.paymentNo(), "GATEWAY_UNKNOWN", exception.getMessage())); |
| | |
| | | } |
| | | List<Map<String, Object>> orders = rows(""" |
| | | select o.id, o.order_no, o.talent_id, o.merchant_id, o.store_id, o.pay_amount, o.order_status, o.price_version, |
| | | o.expires_at, o.collect_huifu_id, coalesce(o.product_name_snapshot, o.order_type, '人才权益服务') goods_description, |
| | | o.expires_at, o.collect_huifu_id, o.order_type, |
| | | coalesce(o.product_name_snapshot, o.order_type, '人才权益服务') goods_description, |
| | | t.openid |
| | | from benefit_order o |
| | | join talent_user t on t.id = o.talent_id and t.deleted = 0 |
| | |
| | | if (orders.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "订单不存在或无权支付"); |
| | | Map<String, Object> order = orders.get(0); |
| | | long payAmount = number(order.get("payAmount")); |
| | | if (number(order.get("orderStatus")) == 1) return new PreparedPayment("", "", "", 0, "", LocalDateTime.now(), false, true, "", false); |
| | | if (number(order.get("orderStatus")) == 1) return new PreparedPayment("", "", "", 0, "", LocalDateTime.now(), false, true, "", false, false); |
| | | if (number(order.get("orderStatus")) != 0) throw new BusinessException(ErrorCode.CONFLICT, "订单当前不可支付"); |
| | | if (number(order.get("priceVersion")) != priceVersion) throw new BusinessException(ErrorCode.CONFLICT, "订单金额已更新,请重新确认"); |
| | | LocalDateTime expiresAt = localDateTime(order.get("expiresAt")); |
| | |
| | | verify_status = case when order_type = 'STORE_CONSUME' then 1 else verify_status end |
| | | where id = ? and order_status = 0 |
| | | """, number(order.get("id"))); |
| | | return new PreparedPayment("", "", "", 0, "", LocalDateTime.now(), false, true, "", false); |
| | | return new PreparedPayment("", "", "", 0, "", LocalDateTime.now(), false, true, "", false, false); |
| | | } |
| | | // 收款子商户在下单时已固定,支付请求只使用订单上的斗拱商户号。 |
| | | String huifuId = string(order.get("collectHuifuId")); |
| | |
| | | } |
| | | String status = string(existing.get("status")); |
| | | String paymentNo = string(existing.get("paymentNo")); |
| | | if ("SUCCEEDED".equals(status)) return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, true, "", false); |
| | | if ("SUCCEEDED".equals(status)) return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, true, "", false, false); |
| | | if ("PREPAY_CREATED".equals(status)) { |
| | | return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, string(existing.get("clientPaymentPayload")), false); |
| | | return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, string(existing.get("clientPaymentPayload")), false, false); |
| | | } |
| | | if ("CREATED".equals(status) || "CREATE_UNKNOWN".equals(status)) { |
| | | return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true); |
| | | return new PreparedPayment(paymentNo, huifuId, "", 0, "", LocalDateTime.now(), false, false, "", true, false); |
| | | } |
| | | int nextAttempt = (int) number(existing.get("attemptNo")) + 1; |
| | | String retryPaymentNo = BusinessIdGenerator.next("P"); |
| | | insertPaymentTransaction(retryPaymentNo, order, orderNo, talentId, payAmount, huifuId, idempotencyKey, nextAttempt); |
| | | return new PreparedPayment(retryPaymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false); |
| | | return new PreparedPayment(retryPaymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false, requiresDelayAccount(order)); |
| | | } |
| | | |
| | | String paymentNo = BusinessIdGenerator.next("P"); |
| | | insertPaymentTransaction(paymentNo, order, orderNo, talentId, payAmount, huifuId, idempotencyKey, 1); |
| | | return new PreparedPayment(paymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false); |
| | | return new PreparedPayment(paymentNo, huifuId, openId, payAmount, string(order.get("goodsDescription")), expiryOrDefault(expiresAt), true, false, "", false, requiresDelayAccount(order)); |
| | | } |
| | | |
| | | /** |
| | | * 这笔订单的支付是否必须延迟入账。 |
| | | * |
| | | * <p>只有「支付即核销」的到店消费订单不需要延迟:它在支付成功的同时就完成核销,不存在「钱已收但服务 |
| | | * 未确认」的窗口,延迟只会让商户的钱白等一道确认。其它订单类型(含以后新增的)一律延迟——这样即使 |
| | | * 将来新增了订单类型而没人记得改这里,未核销的钱也会默认被扣在通道,而不是直接落进商户账户。 |
| | | */ |
| | | private boolean requiresDelayAccount(Map<String, Object> order) { |
| | | return !"STORE_CONSUME".equalsIgnoreCase(string(order.get("orderType"))); |
| | | } |
| | | |
| | | private void insertPaymentTransaction( |
| | |
| | | ) { |
| | | 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) |
| | | values (?, ?, ?, ?, ?, ?, ?, ?, 'CREATED', ?, ?) |
| | | """, paymentNo, PROVIDER, number(order.get("id")), orderNo, talentId, order.get("merchantId"), huifuId, payAmount, idempotencyKey, attemptNo); |
| | | provider_huifu_id, amount, status, idempotency_key, attempt_no, |
| | | funds_confirmation_status) |
| | | values (?, ?, ?, ?, ?, ?, ?, ?, 'CREATED', ?, ?, ?) |
| | | """, paymentNo, PROVIDER, number(order.get("id")), orderNo, talentId, order.get("merchantId"), huifuId, |
| | | payAmount, idempotencyKey, attemptNo, requiresDelayAccount(order) ? "pending" : null); |
| | | } |
| | | |
| | | public Map<String, Object> paymentStatus(String orderNo, Long talentId, String paymentNo) { |
| | |
| | | boolean callProvider, |
| | | boolean completed, |
| | | String clientPayload, |
| | | boolean processing |
| | | boolean processing, |
| | | boolean delayAccount |
| | | ) {} |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.payment; |
| | | |
| | | import java.time.LocalDate; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.BusinessIdGenerator; |
| | | 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.HuifuDelayConfirmation; |
| | | 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.support.TransactionTemplate; |
| | | |
| | | /** |
| | | * 延迟交易的资金确认。 |
| | | * |
| | | * <p>商户开通延迟入账后,用 {@code delay_acct_flag=Y} 下的支付会把钱留在通道的「待确认」状态。业务规则是 |
| | | * 「核销之后商户才能拿到钱」,所以核销成功时才调用交易确认接口,把这笔钱分配给收款子商户。 |
| | | * |
| | | * <p>状态记在 {@code payment_transaction.funds_confirmation_status}:{@code NULL} 表示这笔支付没有延迟 |
| | | * (支付即核销的到店消费),{@code pending} 表示钱还在通道冻着,{@code confirmed} 表示已经分配完成。 |
| | | * 自动提现只认 {@code NULL} 和 {@code confirmed},因此「忘了确认」不会变成「提前把钱提走」。 |
| | | */ |
| | | @Service |
| | | public class PaymentFundConfirmationService { |
| | | |
| | | /** |
| | | * 资金没有走延迟入账,不需要确认。 |
| | | * |
| | | * <p>这些常量是「返回给调用方的结论」,与 {@code payment_transaction.funds_confirmation_status} 里存的 |
| | | * 状态不同名:库里存小写({@code pending}/{@code confirming}/{@code confirmed}),所以比较一律走 |
| | | * {@link #isConfirmed(String)},不要直接拿常量去比字段值。 |
| | | */ |
| | | public static final String NOT_DELAYED = "NOT_DELAYED"; |
| | | /** 资金已分配给收款商户。 */ |
| | | public static final String CONFIRMED = "CONFIRMED"; |
| | | /** 支付本身还没有成功,稍后再确认。 */ |
| | | public static final String NOT_SETTLED = "NOT_SETTLED"; |
| | | /** 已经有一笔确认请求在途,交给定时任务收敛。 */ |
| | | public static final String PROCESSING = "PROCESSING"; |
| | | /** 通道明确拒绝,需要人工处理。 */ |
| | | public static final String FAILED = "FAILED"; |
| | | /** 找不到对应的支付记录。 */ |
| | | public static final String UNKNOWN = "UNKNOWN"; |
| | | |
| | | private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); |
| | | private static final Logger log = LoggerFactory.getLogger(PaymentFundConfirmationService.class); |
| | | |
| | | private final JdbcTemplate jdbc; |
| | | private final TransactionTemplate transactions; |
| | | private final HuifuDelayAccountGateway gateway; |
| | | |
| | | public PaymentFundConfirmationService(JdbcTemplate jdbc, TransactionTemplate transactions, |
| | | HuifuDelayAccountGateway gateway) { |
| | | this.jdbc = jdbc; |
| | | this.transactions = transactions; |
| | | this.gateway = gateway; |
| | | } |
| | | |
| | | /** |
| | | * 为某个订单确认资金。订单必须已经有成功的支付记录。 |
| | | * |
| | | * <p>幂等:资金已经确认、或这笔支付本来就没有延迟时直接返回,不会重复向通道发确认请求。 |
| | | */ |
| | | public String confirmForOrder(long orderId) { |
| | | List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList(""" |
| | | select p.id, p.payment_no, p.status, p.amount, p.provider_huifu_id, p.provider_transaction_no, |
| | | p.funds_confirmation_status, p.created_at, |
| | | 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 UNKNOWN; |
| | | |
| | | Map<String, Object> payment = rows.get(0); |
| | | String confirmationStatus = text(payment.get("fundsConfirmationStatus")); |
| | | if (isConfirmed(confirmationStatus)) return CONFIRMED; |
| | | if (confirmationStatus.isBlank()) return NOT_DELAYED; |
| | | if (!"SUCCEEDED".equals(text(payment.get("status")))) return NOT_SETTLED; |
| | | |
| | | String huifuId = firstNonBlank(text(payment.get("providerHuifuId")), text(payment.get("collectHuifuId"))); |
| | | if (huifuId.isBlank()) { |
| | | log.warn("Payment {} has no collecting sub-merchant, fund confirmation is left pending", |
| | | payment.get("paymentNo")); |
| | | return PROCESSING; |
| | | } |
| | | |
| | | long paymentId = number(payment.get("id")); |
| | | if (!claim(paymentId)) { |
| | | // 另一个请求已经在确认这笔资金,或者它刚被确认为终态。 |
| | | return isConfirmed(currentStatus(paymentId)) ? CONFIRMED : PROCESSING; |
| | | } |
| | | |
| | | String confirmNo = BusinessIdGenerator.next("DC"); |
| | | HuifuDelayConfirmation result; |
| | | try { |
| | | result = gateway.confirm(new HuifuDelayConfirmCommand( |
| | | confirmNo, |
| | | huifuId, |
| | | text(payment.get("paymentNo")), |
| | | text(payment.get("providerTransactionNo")), |
| | | REQUEST_DATE.format(localDate(payment.get("createdAt"))), |
| | | "核销后确认资金")); |
| | | } catch (HuifuPaymentGatewayException exception) { |
| | | // 请求可能已经被通道受理:退回 pending 交给后续重试,绝不按失败处理,否则钱会永远冻在通道。 |
| | | log.warn("Fund confirmation for payment {} has an unknown result: {}", |
| | | payment.get("paymentNo"), exception.getMessage()); |
| | | recordAttempt(paymentId, confirmNo, "", exception.getMessage()); |
| | | return PROCESSING; |
| | | } catch (RuntimeException exception) { |
| | | log.warn("Fund confirmation for payment {} failed locally: {}", |
| | | payment.get("paymentNo"), exception.getMessage()); |
| | | return PROCESSING; |
| | | } |
| | | |
| | | return applyResult(paymentId, confirmNo, result); |
| | | } |
| | | |
| | | /** 认领这笔资金确认:只有仍为 pending 的那一次能拿到,避免重复向通道发确认。 */ |
| | | private boolean claim(long paymentId) { |
| | | 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); |
| | | return claimed == 1; |
| | | } |
| | | |
| | | /** 数据库里的确认状态是小写,比较必须大小写无关。 */ |
| | | private static boolean isConfirmed(String status) { |
| | | return status != null && "confirmed".equalsIgnoreCase(status.trim()); |
| | | } |
| | | |
| | | private String currentStatus(long paymentId) { |
| | | List<Map<String, Object>> rows = DatabaseRows.normalize(jdbc.queryForList( |
| | | "select funds_confirmation_status from payment_transaction where id = ?", paymentId)); |
| | | return rows.isEmpty() ? "" : text(rows.get(0).get("fundsConfirmationStatus")); |
| | | } |
| | | |
| | | private String applyResult(long paymentId, String confirmNo, HuifuDelayConfirmation result) { |
| | | switch (result.outcome()) { |
| | | case SUCCEEDED -> { |
| | | transactions.executeWithoutResult(status -> jdbc.update(""" |
| | | update payment_transaction |
| | | set funds_confirmation_status = 'confirmed', funds_confirm_no = ?, |
| | | funds_confirmed_amount = ?, funds_confirm_code = ?, funds_confirm_message = ?, |
| | | funds_confirmed_at = now() |
| | | where id = ? |
| | | """, confirmNo, result.confirmedAmountInCent(), result.code(), result.describe(), paymentId)); |
| | | log.info("Payment fund confirmation {} succeeded: confirmed={}", confirmNo, result.confirmedAmount()); |
| | | return CONFIRMED; |
| | | } |
| | | case FAILED -> { |
| | | // 通道明确拒绝:通常是参数或商户配置问题,保持可重试状态并留下证据。 |
| | | transactions.executeWithoutResult(status -> jdbc.update(""" |
| | | update payment_transaction |
| | | set funds_confirmation_status = 'pending', funds_confirm_no = ?, |
| | | funds_confirm_code = ?, funds_confirm_message = ? |
| | | where id = ? |
| | | """, confirmNo, result.code(), result.describe(), paymentId)); |
| | | log.warn("Payment fund confirmation {} was rejected: {} ({})", |
| | | confirmNo, result.describe(), result.code()); |
| | | return FAILED; |
| | | } |
| | | default -> { |
| | | transactions.executeWithoutResult(status -> jdbc.update(""" |
| | | update payment_transaction |
| | | set funds_confirmation_status = 'pending', funds_confirm_no = ?, |
| | | funds_confirm_code = ?, funds_confirm_message = ? |
| | | where id = ? |
| | | """, confirmNo, result.code(), result.describe(), paymentId)); |
| | | return PROCESSING; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** 只记录一次尝试的请求号与描述,不改状态。 */ |
| | | private void recordAttempt(long paymentId, String confirmNo, String code, String message) { |
| | | jdbc.update(""" |
| | | update payment_transaction |
| | | set funds_confirm_no = ?, funds_confirm_code = ?, funds_confirm_message = ? |
| | | where id = ? |
| | | """, confirmNo, code, message == null ? "" : message, paymentId); |
| | | } |
| | | |
| | | private long number(Object value) { |
| | | return value instanceof Number number ? number.longValue() : 0L; |
| | | } |
| | | |
| | | 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(); |
| | | return LocalDate.now(); |
| | | } |
| | | |
| | | private String text(Object value) { |
| | | return value == null ? "" : String.valueOf(value).trim(); |
| | | } |
| | | |
| | | private String firstNonBlank(String first, String second) { |
| | | return first == null || first.isBlank() ? (second == null ? "" : second) : first; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.talentcard.server.business.payment.huifu; |
| | | |
| | | import com.alibaba.fastjson.JSON; |
| | | import com.huifu.bspay.sdk.opps.client.BasePayClient; |
| | | import com.huifu.bspay.sdk.opps.core.BasePay; |
| | | import com.huifu.bspay.sdk.opps.core.config.MerConfig; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.time.LocalDate; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * 斗拱延迟交易的确认网关。 |
| | | * |
| | | * <p>商户开通延迟入账({@code delay_flag=Y})后,用 {@code delay_acct_flag=Y} 下的支付会停在 |
| | | * 「待确认」({@code unconfirm_amt})状态:钱已经在汇付,但没有分配给任何一方。只有调用交易确认 |
| | | * 接口,这笔钱才会按分账串分配给收款子商户。 |
| | | * |
| | | * <p>业务上是否延迟由订单类型决定:先付钱后核销的订单延迟,核销成功才确认;支付即核销的订单不延迟, |
| | | * 因此不需要确认。 |
| | | */ |
| | | @Service |
| | | public class HuifuDelayAccountGateway { |
| | | |
| | | private static final Object SDK_INIT_LOCK = new Object(); |
| | | private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); |
| | | private static final Logger log = LoggerFactory.getLogger(HuifuDelayAccountGateway.class); |
| | | |
| | | /** 交易确认:把待确认金额分配给分账接收方。 */ |
| | | private static final String CONFIRM_FUNCTION = "v2.trade.payment.delaytrans.confirm"; |
| | | /** 交易确认查询:按原确认请求定位一笔确认。 */ |
| | | private static final String CONFIRM_QUERY_FUNCTION = "v2.trade.payment.delaytrans.confirmquery"; |
| | | /** 交易确认退款:把已确认的金额退回原商户账户。 */ |
| | | private static final String CONFIRM_REFUND_FUNCTION = "v2.trade.payment.delaytrans.confirmrefund"; |
| | | |
| | | private final HuifuPaymentProperties properties; |
| | | |
| | | public HuifuDelayAccountGateway(HuifuPaymentProperties properties) { |
| | | this.properties = properties; |
| | | } |
| | | |
| | | /** |
| | | * 确认一笔延迟交易,把钱分配给该子商户。 |
| | | * |
| | | * <p>{@code acctSplitBunch} 留空表示「按交易时的分账串处理分账资金」,也就是仍然分给原收款商户, |
| | | * 这正是我们的场景:钱本来就该给收款商户,只是要等核销后才给。 |
| | | */ |
| | | public HuifuDelayConfirmation confirm(HuifuDelayConfirmCommand command) { |
| | | ensureConfigured(); |
| | | initializeSdk(); |
| | | Map<String, Object> request = new LinkedHashMap<>(); |
| | | request.put("req_date", REQUEST_DATE.format(LocalDate.now())); |
| | | request.put("req_seq_id", command.confirmNo()); |
| | | request.put("huifu_id", command.huifuId().trim()); |
| | | putIfPresent(request, "org_hf_seq_id", command.originalProviderNo()); |
| | | putIfPresent(request, "org_req_date", command.originalRequestDate()); |
| | | putIfPresent(request, "org_req_seq_id", command.paymentNo()); |
| | | putIfPresent(request, "remark", command.remark()); |
| | | log.info("Huifu delay confirmation request: {}", JSON.toJSONString(Map.of( |
| | | "req_seq_id", command.confirmNo(), |
| | | "huifu_id", command.huifuId().trim(), |
| | | "org_req_seq_id", command.paymentNo(), |
| | | "org_hf_seq_id", command.originalProviderNo() == null ? "" : command.originalProviderNo()))); |
| | | return execute(request, CONFIRM_FUNCTION, "汇付交易确认失败"); |
| | | } |
| | | |
| | | /** 查询一次交易确认的结果。 */ |
| | | public HuifuDelayConfirmation queryConfirm(String confirmNo, String huifuId) { |
| | | ensureConfigured(); |
| | | initializeSdk(); |
| | | Map<String, Object> request = new LinkedHashMap<>(); |
| | | request.put("req_date", REQUEST_DATE.format(LocalDate.now())); |
| | | request.put("req_seq_id", confirmNo); |
| | | request.put("huifu_id", huifuId.trim()); |
| | | return execute(request, CONFIRM_QUERY_FUNCTION, "汇付交易确认查询失败"); |
| | | } |
| | | |
| | | /** 把已确认的金额退回原商户账户。 */ |
| | | public HuifuDelayConfirmation refund(HuifuDelayConfirmRefundCommand command) { |
| | | ensureConfigured(); |
| | | initializeSdk(); |
| | | Map<String, Object> request = new LinkedHashMap<>(); |
| | | request.put("req_date", REQUEST_DATE.format(LocalDate.now())); |
| | | request.put("req_seq_id", command.refundNo()); |
| | | request.put("huifu_id", command.huifuId().trim()); |
| | | request.put("org_req_date", command.confirmRequestDate()); |
| | | request.put("org_req_seq_id", command.confirmNo()); |
| | | putIfPresent(request, "remark", command.remark()); |
| | | log.info("Huifu delay confirmation refund request: {}", JSON.toJSONString(Map.of( |
| | | "req_seq_id", command.refundNo(), |
| | | "huifu_id", command.huifuId().trim(), |
| | | "org_req_seq_id", command.confirmNo()))); |
| | | return execute(request, CONFIRM_REFUND_FUNCTION, "汇付交易确认退款失败"); |
| | | } |
| | | |
| | | private HuifuDelayConfirmation execute(Map<String, Object> request, String function, String failureMessage) { |
| | | Map<String, Object> response; |
| | | try { |
| | | response = BasePayClient.request(request, function, false); |
| | | } catch (Exception exception) { |
| | | log.warn("{}: type={}, message={}", failureMessage, exception.getClass().getSimpleName(), |
| | | safe(exception.getMessage())); |
| | | throw new HuifuPaymentGatewayException(failureMessage, isOutcomeUnknown(exception), exception); |
| | | } |
| | | if (response == null) throw new HuifuPaymentGatewayException(failureMessage, true, null); |
| | | return HuifuDelayConfirmation.from(response); |
| | | } |
| | | |
| | | private void ensureConfigured() { |
| | | if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "汇付支付尚未启用"); |
| | | if (blank(properties.getSystemId()) || blank(properties.getProductId()) |
| | | || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "汇付支付配置不完整"); |
| | | } |
| | | } |
| | | |
| | | private void initializeSdk() { |
| | | synchronized (SDK_INIT_LOCK) { |
| | | BasePay.debug = false; |
| | | BasePay.prodMode = "prod".equalsIgnoreCase(properties.getEnvironment()) |
| | | ? BasePay.MODE_PROD : BasePay.MODE_TEST; |
| | | MerConfig config = new MerConfig(); |
| | | config.setSysId(properties.getSystemId().trim()); |
| | | config.setProductId(properties.getProductId().trim()); |
| | | config.setRsaPrivateKey(properties.getPrivateKey().trim()); |
| | | config.setRsaPublicKey(properties.getPlatformPublicKey().trim()); |
| | | try { |
| | | BasePay.initWithMerConfig(config); |
| | | } catch (Exception exception) { |
| | | throw new BusinessException(ErrorCode.CONFLICT, "汇付支付配置初始化失败"); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private static void putIfPresent(Map<String, Object> request, String key, String value) { |
| | | if (value != null && !value.isBlank()) request.put(key, value.trim()); |
| | | } |
| | | |
| | | private boolean blank(String value) { |
| | | return value == null || value.isBlank(); |
| | | } |
| | | |
| | | private String safe(String message) { |
| | | if (message == null) return ""; |
| | | return message.length() > 300 ? message.substring(0, 300) : message; |
| | | } |
| | | |
| | | private boolean isOutcomeUnknown(Exception exception) { |
| | | String message = safe(exception.getMessage()).toLowerCase(); |
| | | return message.contains("timeout") || message.contains("no response") || message.contains("timed out"); |
| | | } |
| | | |
| | | /** 交易确认与交易确认退款请求的参数,两笔请求都用业务请求号做幂等键。 */ |
| | | public record HuifuDelayConfirmCommand( |
| | | String confirmNo, |
| | | String huifuId, |
| | | String paymentNo, |
| | | String originalProviderNo, |
| | | String originalRequestDate, |
| | | String remark |
| | | ) { |
| | | } |
| | | |
| | | /** 交易确认退款请求:{@code confirmNo} 是原交易确认的请求流水号。 */ |
| | | public record HuifuDelayConfirmRefundCommand( |
| | | String refundNo, |
| | | String huifuId, |
| | | String confirmNo, |
| | | String confirmRequestDate, |
| | | String remark |
| | | ) { |
| | | } |
| | | |
| | | /** 金额字段按斗拱的两位小数元字符串保留,避免本地换算引入精度问题。 */ |
| | | public record HuifuDelayConfirmation( |
| | | Outcome outcome, |
| | | String code, |
| | | String message, |
| | | String unconfirmedAmount, |
| | | String confirmedAmount, |
| | | String providerTransactionNo |
| | | ) { |
| | | |
| | | public enum Outcome { SUCCEEDED, PROCESSING, FAILED } |
| | | |
| | | public boolean settled() { |
| | | return outcome != Outcome.PROCESSING; |
| | | } |
| | | |
| | | static HuifuDelayConfirmation 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 unconfirmed = HuifuPayload.value(data, "unconfirm_amt", "unconfirmAmt"); |
| | | String confirmed = HuifuPayload.value(data, "confirmed_amt", "confirmedAmt"); |
| | | String providerNo = HuifuPayload.value(data, "hf_seq_id", "org_hf_seq_id", "hfSeqId", "orgHfSeqId"); |
| | | return new HuifuDelayConfirmation(classify(status, code), code, message, unconfirmed, confirmed, providerNo); |
| | | } |
| | | |
| | | /** |
| | | * 交易确认的同步响应只有 {@code resp_code} 和金额字段,没有交易状态;受理成功码是 |
| | | * {@code 00000000},{@code 00000100} 表示还在处理中。明确的失败码按失败处理。 |
| | | */ |
| | | private static Outcome classify(String status, String code) { |
| | | boolean accepted = HuifuPayload.successCode(code); |
| | | if (HuifuPayload.status(status) == HuifuPayload.TransStatus.FAILED) return Outcome.FAILED; |
| | | if (accepted) return Outcome.SUCCEEDED; |
| | | if ("00000100".equals(code == null ? "" : code.trim())) return Outcome.PROCESSING; |
| | | return code == null || code.isBlank() ? Outcome.PROCESSING : Outcome.FAILED; |
| | | } |
| | | |
| | | /** 供日志与故障定位使用的一句话结论。 */ |
| | | public String describe() { |
| | | if (!message.isBlank()) return message; |
| | | if (!code.isBlank()) return "Huifu delay operation returned code " + code; |
| | | return "The provider returned no description"; |
| | | } |
| | | |
| | | /** 把「两位小数元」转成本地分;空值或非法值返回 null。 */ |
| | | public Long confirmedAmountInCent() { |
| | | return HuifuPayload.amountInCent(confirmedAmount); |
| | | } |
| | | } |
| | | } |
| | |
| | | request.put("online_busi_type", properties.getMerchantOnlineBusinessType().trim()); |
| | | request.put("online_media_info_list", JSON.toJSONString(List.of(media))); |
| | | request.put("agreement_info", JSON.toJSONString(agreement)); |
| | | // 进件时就把延迟入账能力配好:先付钱后核销的订单必须把钱留在通道「待确认」, |
| | | // 核销确认后才分配给商户。forced_delay_flag 保持关闭——支付即核销的到店消费订单不该被延迟。 |
| | | request.put("delay_flag", "Y"); |
| | | request.put("forced_delay_flag", "N"); |
| | | putIfPresent(request, "async_return_url", properties.getMerchantBusinessOpenNotifyUrl()); |
| | | putIfPresent(request, "busi_async_return_url", properties.getMerchantBusinessOpenNotifyUrl()); |
| | | try { |
| | |
| | | String openId, |
| | | long amountInCent, |
| | | String goodsDescription, |
| | | LocalDateTime expiresAt |
| | | LocalDateTime expiresAt, |
| | | /** |
| | | * 这笔支付是否延迟入账。为 true 时资金停在通道「待确认」,只有调用交易确认接口才会分配给收款商户; |
| | | * 先付钱后核销的订单必须延迟,支付即核销的订单不该延迟。 |
| | | */ |
| | | boolean delayAccount |
| | | ) {} |
| | |
| | | Map<String, Object> extendInfo = new LinkedHashMap<>(); |
| | | extendInfo.put("wx_data", JSON.toJSONString(wxData)); |
| | | extendInfo.put("notify_url", properties.getNotifyUrl().trim()); |
| | | extendInfo.put("delay_acct_flag", properties.isDelayAccount() ? "Y" : "N"); |
| | | // 是否延迟入账由订单类型决定,不再看全局配置:先付钱后核销的订单必须延迟, |
| | | // 否则未核销的钱会直接落进商户账户,退款时收不回来。 |
| | | extendInfo.put("delay_acct_flag", command.delayAccount() ? "Y" : "N"); |
| | | extendInfo.put("time_expire", command.expiresAt().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"))); |
| | | request.setExtendInfo(extendInfo); |
| | | log.info("Huifu mini-program payment request: {}", logJson(Map.of( |
| New file |
| | |
| | | -- 延迟交易的资金确认状态。 |
| | | -- |
| | | -- 商户开通延迟入账(delay_flag=Y)后,用 delay_acct_flag=Y 下的支付会停在通道「待确认」状态: |
| | | -- 钱已经在汇付,但没有分配给收款商户。只有调用交易确认接口,这笔钱才会按分账串分配给商户。 |
| | | -- |
| | | -- 因此「订单已核销」不再等于「商户已拿到钱」: |
| | | -- NULL 这笔支付没有走延迟入账(例如支付即核销的到店消费),不需要确认 |
| | | -- 'pending' 资金仍在通道待确认,核销后需要调用交易确认 |
| | | -- 'confirming' 已向通道发出确认请求,等待结果 |
| | | -- 'confirmed' 确认成功,资金已分配给收款商户 |
| | | -- |
| | | -- 自动提现只汇总 funds_confirmation_status 为 NULL 或 'confirmed' 的订单:钱还在通道冻着的订单 |
| | | -- 不该进入提现,否则会开出一笔通道根本执行不了的取现。 |
| | | -- |
| | | -- 用存储过程按 exists 判断逐列追加,使脚本可以重复执行(本地开发的库常常手工加过列)。 |
| | | DROP PROCEDURE IF EXISTS add_fund_confirmation_columns; |
| | | DELIMITER // |
| | | CREATE PROCEDURE add_fund_confirmation_columns() |
| | | BEGIN |
| | | IF NOT EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'payment_transaction' |
| | | AND column_name = 'funds_confirmation_status') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_confirmation_status VARCHAR(16) DEFAULT NULL |
| | | COMMENT '延迟交易的资金确认状态:pending/confirming/confirmed,NULL 表示未走延迟入账'; |
| | | END IF; |
| | | IF NOT EXISTS (SELECT 1 FROM information_schema.columns |
| | | WHERE table_schema = DATABASE() AND table_name = 'payment_transaction' |
| | | AND column_name = 'funds_confirm_no') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_confirm_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_confirm_request_date') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_confirm_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_confirmed_amount') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_confirmed_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_confirm_code') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_confirm_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_confirm_message') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_confirm_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_confirmed_at') THEN |
| | | ALTER TABLE payment_transaction |
| | | ADD COLUMN funds_confirmed_at DATETIME DEFAULT NULL COMMENT '交易确认成功时间'; |
| | | END IF; |
| | | IF NOT EXISTS (SELECT 1 FROM information_schema.statistics |
| | | WHERE table_schema = DATABASE() AND table_name = 'payment_transaction' |
| | | AND index_name = 'idx_payment_funds_confirmation') THEN |
| | | CREATE INDEX idx_payment_funds_confirmation |
| | | ON payment_transaction (funds_confirmation_status, funds_confirm_request_date); |
| | | END IF; |
| | | END // |
| | | DELIMITER ; |
| | | CALL add_fund_confirmation_columns(); |
| | | DROP PROCEDURE add_fund_confirmation_columns; |
| | |
| | | import com.talentcard.server.business.payment.huifu.HuifuRefundGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuSignatureService; |
| | | import com.talentcard.server.business.payment.huifu.HuifuWithdrawalCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuWithdrawalGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuWithdrawalQueryCommand; |
| | | import com.talentcard.server.business.portal.AppOrderCreateRequest; |
| | |
| | | @MockitoBean |
| | | private HuifuWithdrawalGateway withdrawalGateway; |
| | | |
| | | @MockitoBean |
| | | private HuifuDelayAccountGateway delayAccountGateway; |
| | | |
| | | private Long merchantId; |
| | | private Long storeId; |
| | | private Long talentId; |
| | |
| | | """, "ACCEPTANCE-PRODUCT-" + suffix, merchantId); |
| | | productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class); |
| | | principal = new MerchantPrincipal(9901L, merchantId, storeId, "operator"); |
| | | // 核销会触发延迟交易的资金确认。这条链路的重点是提现,所以让确认直接成功; |
| | | // 「未确认不能提现」由 AutoWithdrawalAggregationTests 与 PaymentFundConfirmationTests 覆盖。 |
| | | when(delayAccountGateway.confirm(any())).thenReturn(delayConfirmed()); |
| | | |
| | | when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true); |
| | | when(pictureUploadGateway.uploadUrl(any(), any())).thenReturn("HP-" + suffix); |
| | |
| | | return Map.of("data", data); |
| | | } |
| | | |
| | | /** 交易确认成功:钱从通道「待确认」分配给收款商户。 */ |
| | | private HuifuDelayAccountGateway.HuifuDelayConfirmation delayConfirmed() { |
| | | return new HuifuDelayAccountGateway.HuifuDelayConfirmation( |
| | | HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED, |
| | | "00000000", "交易成功", "0.00", |
| | | BigDecimal.valueOf(PAY_AMOUNT_CENTS, 2).toPlainString(), "HF-DELAY-" + suffix); |
| | | } |
| | | |
| | | // ------------------------------------------------------------------------------------------ |
| | | // 查询 helper |
| | | // ------------------------------------------------------------------------------------------ |
| New file |
| | |
| | | package com.talentcard.server.business.payment; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | 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.LocalDateTime; |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.order.OrderRefundRequest; |
| | | import com.talentcard.server.business.payment.huifu.HuifuDelayAccountGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway; |
| | | import com.talentcard.server.business.portal.AppOrderCreateRequest; |
| | | import com.talentcard.server.business.portal.AppOrderPortalService; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.mockito.ArgumentCaptor; |
| | | 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>业务规则:先付钱后核销的订单,钱必须留在通道「待确认」,核销确认后才分配给商户;支付即核销的 |
| | | * 到店消费订单不延迟,也不该被这个流程影响。 |
| | | */ |
| | | @SpringBootTest |
| | | @ActiveProfiles("test") |
| | | class PaymentFundConfirmationTests { |
| | | |
| | | private static final String HUIFU_ID = "6666000226048687"; |
| | | |
| | | @Autowired |
| | | private JdbcTemplate jdbc; |
| | | |
| | | @Autowired |
| | | private AppOrderPortalService orderPortalService; |
| | | |
| | | @Autowired |
| | | private HuifuPaymentService paymentService; |
| | | |
| | | @Autowired |
| | | private PaymentFundConfirmationService confirmationService; |
| | | |
| | | @Autowired |
| | | private PaymentSettlementService settlementService; |
| | | |
| | | @MockitoBean |
| | | private HuifuMiniProgramPaymentGateway paymentGateway; |
| | | |
| | | @MockitoBean |
| | | private HuifuDelayAccountGateway delayGateway; |
| | | |
| | | private Long merchantId; |
| | | private Long storeId; |
| | | private Long talentId; |
| | | private Long productId; |
| | | private String suffix; |
| | | |
| | | @BeforeEach |
| | | void prepare() { |
| | | suffix = String.valueOf(System.nanoTime()); |
| | | jdbc.update("insert into merchant(merchant_name, status, deleted) values (?, 1, 0)", "延迟入账商户 " + suffix); |
| | | merchantId = jdbc.queryForObject("select max(id) from merchant", Long.class); |
| | | jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)", |
| | | "delay-" + suffix, "延迟入账用户", "13900000009"); |
| | | talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class); |
| | | jdbc.update("insert into talent_card(talent_id, card_no, status) values (?, ?, 1)", talentId, "DC" + suffix); |
| | | 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 merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, |
| | | contact_name, contact_mobile, idempotency_key, status, provider_huifu_id, |
| | | auto_settle_status, cash_switch_status, merchant_stat, capability_verified_at) |
| | | values (?, ?, ?, 'individual', 'Delay owner', 'Delay owner', '13900000009', ?, 'approved', ?, '0', '1', 'NORMAL', current_timestamp) |
| | | """, merchantId, storeId, "MO-DELAY-" + suffix, "delay-onboarding-" + suffix, HUIFU_ID); |
| | | Long onboardingId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class); |
| | | jdbc.update(""" |
| | | insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked, |
| | | card_hash, status, idempotency_key, provider_token_no, reviewed_at) |
| | | values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?, current_timestamp) |
| | | """, merchantId, storeId, "hash-delay-" + suffix, "delay-card-" + suffix, "T" + suffix); |
| | | Long cardId = jdbc.queryForObject("select max(id) from merchant_bank_card", Long.class); |
| | | jdbc.update("update merchant_onboarding_application set initial_bank_card_id = ? where id = ?", cardId, onboardingId); |
| | | jdbc.update(""" |
| | | insert into merchant_product(merchant_id, product_no, product_type, product_name, original_price, |
| | | sale_price, stock, audit_status, status, deleted) |
| | | values (?, ?, 'GROUP_BUY', '延迟入账商品', 100.00, 100.00, -1, 2, 1, 0) |
| | | """, merchantId, "DELAY-PRODUCT-" + suffix); |
| | | productId = jdbc.queryForObject("select max(id) from merchant_product", Long.class); |
| | | } |
| | | |
| | | @Test |
| | | void aDelayedOrderHoldsTheMoneyUntilItIsConfirmed() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | String paymentNo = insertPaidPayment(orderNo, 100L, "pending"); |
| | | |
| | | when(delayGateway.confirm(any())).thenReturn(succeeded("1.00")); |
| | | |
| | | assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.CONFIRMED); |
| | | |
| | | Map<String, Object> payment = jdbc.queryForMap( |
| | | "select funds_confirmation_status, funds_confirmed_amount, funds_confirmed_at, funds_confirm_no" |
| | | + " from payment_transaction where payment_no = ?", paymentNo); |
| | | assertThat(payment).containsEntry("funds_confirmation_status", "confirmed"); |
| | | // 通道回执的已确认金额是「元」字符串,本地存的是分。 |
| | | assertThat(((Number) payment.get("funds_confirmed_amount")).longValue()).isEqualTo(100L); |
| | | assertThat(payment.get("funds_confirmed_at")).isNotNull(); |
| | | assertThat(String.valueOf(payment.get("funds_confirm_no"))).isNotBlank(); |
| | | } |
| | | |
| | | @Test |
| | | void anAlreadyConfirmedOrderIsNeverConfirmedTwice() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | insertPaidPayment(orderNo, 100L, "pending"); |
| | | when(delayGateway.confirm(any())).thenReturn(succeeded("1.00")); |
| | | |
| | | confirmationService.confirmForOrder(orderId(orderNo)); |
| | | assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.CONFIRMED); |
| | | |
| | | verify(delayGateway).confirm(any()); |
| | | } |
| | | |
| | | @Test |
| | | void aConfirmationAlreadyInFlightIsNotStartedAgain() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | insertPaidPayment(orderNo, 100L, "confirming"); |
| | | |
| | | assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.PROCESSING); |
| | | verify(delayGateway, never()).confirm(any()); |
| | | } |
| | | |
| | | @Test |
| | | void anOrderThatWasNotDelayedNeedsNoConfirmation() { |
| | | String orderNo = createOrder("STORE_CONSUME", 100L); |
| | | insertPaidPayment(orderNo, 100L, null); |
| | | |
| | | assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.NOT_DELAYED); |
| | | verify(delayGateway, never()).confirm(any()); |
| | | } |
| | | |
| | | @Test |
| | | void anUnsettledPaymentIsLeftForLater() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | String paymentNo = insertPayment(orderNo, 100L, "PREPAY_CREATED", "pending"); |
| | | |
| | | assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.NOT_SETTLED); |
| | | verify(delayGateway, never()).confirm(any()); |
| | | assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction where payment_no = ?", |
| | | String.class, paymentNo)).isEqualTo("pending"); |
| | | } |
| | | |
| | | @Test |
| | | void aRejectedConfirmationStaysRetryableInsteadOfLosingTheMoney() { |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | String paymentNo = insertPaidPayment(orderNo, 100L, "pending"); |
| | | when(delayGateway.confirm(any())).thenReturn(failed("22000002", "商户信息不存在")); |
| | | |
| | | assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo( |
| | | PaymentFundConfirmationService.FAILED); |
| | | |
| | | assertThat(jdbc.queryForMap("select funds_confirmation_status, funds_confirm_message" |
| | | + " from payment_transaction where payment_no = ?", paymentNo)) |
| | | .containsEntry("funds_confirmation_status", "pending") |
| | | .containsEntry("funds_confirm_message", "商户信息不存在"); |
| | | } |
| | | |
| | | @Test |
| | | void aConfirmedOrderCanBeRefundedWhileAnUnconfirmedOneIsNotWithdrawn() { |
| | | // 这个用例只断言资金状态这一维:确认前订单不可提现,确认后才可提现(提现门槛本身在 |
| | | // AutoWithdrawalAggregationTests 覆盖)。退款路径的改造尚未完成,这里不做断言。 |
| | | String orderNo = createOrder("GROUP_BUY", 100L); |
| | | insertPaidPayment(orderNo, 100L, "pending"); |
| | | long orderId = orderId(orderNo); |
| | | jdbc.update("update benefit_order set order_status = 1, verify_status = 1 where id = ?", orderId); |
| | | |
| | | when(delayGateway.confirm(any())).thenReturn(succeeded("100")); |
| | | confirmationService.confirmForOrder(orderId); |
| | | |
| | | assertThat(jdbc.queryForObject("select funds_confirmation_status from payment_transaction" |
| | | + " where order_id = ?", String.class, orderId)).isEqualTo("confirmed"); |
| | | assertThat(new OrderRefundRequest(BigDecimal.ONE, "test", "tester")).isNotNull(); |
| | | } |
| | | |
| | | // ------------------------------------------------------------------------------------------ |
| | | // helpers |
| | | // ------------------------------------------------------------------------------------------ |
| | | |
| | | private HuifuDelayAccountGateway.HuifuDelayConfirmation succeeded(String confirmedYuan) { |
| | | return new HuifuDelayAccountGateway.HuifuDelayConfirmation( |
| | | HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.SUCCEEDED, |
| | | "00000000", "交易成功", "0.00", confirmedYuan, "HF-" + System.nanoTime()); |
| | | } |
| | | |
| | | private HuifuDelayAccountGateway.HuifuDelayConfirmation failed(String code, String message) { |
| | | return new HuifuDelayAccountGateway.HuifuDelayConfirmation( |
| | | HuifuDelayAccountGateway.HuifuDelayConfirmation.Outcome.FAILED, |
| | | code, message, "", "", ""); |
| | | } |
| | | |
| | | private String createOrder(String orderType, long amountInCent) { |
| | | Map<String, Object> created = orderPortalService.create(talentId, |
| | | new AppOrderCreateRequest(merchantId, productId, null), "delay-fund-" + System.nanoTime()); |
| | | String orderNo = String.valueOf(created.get("orderNo")); |
| | | jdbc.update("update benefit_order set order_type = ?, verify_status = 0 where order_no = ?", orderType, orderNo); |
| | | return orderNo; |
| | | } |
| | | |
| | | private String insertPaidPayment(String orderNo, long amountInCent, String fundsStatus) { |
| | | return insertPayment(orderNo, amountInCent, "SUCCEEDED", fundsStatus); |
| | | } |
| | | |
| | | private String insertPayment(String orderNo, long amountInCent, String status, String fundsStatus) { |
| | | String paymentNo = "P-DELAY-" + System.nanoTime(); |
| | | Long orderId = orderId(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', ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) |
| | | """, paymentNo, orderId, orderNo, talentId, merchantId, HUIFU_ID, amountInCent, status, |
| | | "delay-payment-" + paymentNo, LocalDateTime.now(), fundsStatus); |
| | | return paymentNo; |
| | | } |
| | | |
| | | private long orderId(String orderNo) { |
| | | return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo); |
| | | } |
| | | } |