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.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; /** * 延迟交易的资金确认。 * *

商户开通延迟入账后,用 {@code delay_acct_flag=Y} 下的支付会把钱留在通道的「待确认」状态。业务规则是 * 「核销之后商户才能拿到钱」,所以核销成功时才调用交易确认接口,把这笔钱分配给收款子商户。 * *

状态记在 {@code payment_transaction.funds_confirmation_status}:{@code NULL} 表示这笔支付没有延迟 * (支付即核销的到店消费),{@code pending} 表示钱还在通道冻着,{@code confirmed} 表示已经分配完成。 * 自动提现只认 {@code NULL} 和 {@code confirmed},因此「忘了确认」不会变成「提前把钱提走」。 * *

撤销核销走反方向({@link #revertForOrder}):调用交易确认退款把已确认的钱退回延迟户,成功后状态 * 退回 {@code pending},商户重新核销时会再确认一次。回退期间用 {@code reverting} 占位。 */ @Service public class PaymentFundConfirmationService { /** * 资金没有走延迟入账,不需要确认。 * *

这些常量是「返回给调用方的结论」,与 {@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"; /** * 订单此刻不再是「已核销」,不能把钱放给商户。 * *

撤销核销会把订单翻回待核销,此时钱必须留在延迟户:确认在放款前复查这个前提, * 避免「订单已回到待核销、钱却已经打给商户」。 */ 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"; /** * 返回码在这条路径上语义未定:通道给了话,但我们不知道它是什么意思,需要人工核对。 * *

和 {@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 JdbcTemplate jdbc; private final TransactionTemplate transactions; private final HuifuDelayAccountGateway gateway; /** * 资金侧的写入用独立事务提交。 * *

撤销核销会在自己的事务里调用本服务,而通道调用不是事务性的:认领和结果如果跟着外层事务一起 * 回滚,通道那边已经退回延迟户的钱在本地就再也看不到了——状态还写着 {@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); } /** * 为某个订单确认资金。订单必须已经有成功的支付记录。 * *

幂等:资金已经确认、或这笔支付本来就没有延迟时直接返回,不会重复向通道发确认请求。 */ public String confirmForOrder(long orderId) { List> 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 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")); 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; } 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); } /** * 撤销核销:把这笔已经确认给商户的资金退回通道延迟户。 * *

调用方只有在拿到 {@link #REVERTED} 或 {@link #REVERT_NOT_NEEDED} 时才可以撤销核销。其余结果都 * 表示钱的位置不确定、或仍在商户手里:此时翻单会造成「订单显示待核销、钱已经结算给商户」。 * *

方法自身不开事务,认领与落结果各走独立事务(见 {@link #moneyTransactions}):通道调用不是 * 事务性的,让这些写入跟着外层事务回滚,会把通道的真实结果丢掉。 */ public String revertForOrder(long orderId) { List> 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 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 的那一次能拿到,避免重复向通道发确认。 * *

同时收掉「认领之后进程就没了」留下的孤儿:状态停在 {@code confirming}、认领日期又不是今天的 * 那些请求,说明那次确认没走到落结果就中断了(部署、崩溃、超时被杀)。补偿任务只挑得到这种行而 * 不会重发,没有这一步这笔钱就永远冻在延迟户里。重新确认是幂等的——通道会按「已无未确认金额」 * 返回,{@link HuifuDelayConfirmation} 把那个码当成功,所以隔天补发一次是安全的。 * *

认领日期为空的行不动:那说明它不是我们认领出来的(手工数据),宁可留着给人工看。 */ 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' or (funds_confirmation_status = 'confirming' and funds_confirm_request_date is not null and funds_confirm_request_date < ?)) """, today, paymentId, today); return claimed == 1; } /** * 在订单行锁里复查「订单此刻仍然已核销」,再认领资金确认。 * *

撤销核销锁的是同一行,因此这个判断在整个认领期间是稳定的:撤销核销提交在前,这里会看到 * {@code verify_status} 已经为 0 而拒绝认领;认领在前,撤销核销会看到 {@code confirming} 而拒绝翻单。 * 两个方向都不会出现「订单回到待核销、钱却打给了商户」。 */ private ClaimOutcome claimConfirmation(long orderId, long paymentId) { List> 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 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; } /** * 落回退结果。 * *

只有通道明确说成功才把状态退回 {@code pending}(等于「钱在延迟户」)。明确拒绝退回 * {@code confirmed},其余一律停在 {@code reverting}:这条路径上没有真实回执证明码的含义之前不猜, * 因为把失败当成功会让订单翻回待核销而钱还在商户基本户。 */ private String applyRevertResult(long paymentId, String revertNo, HuifuDelayRevert result, Map 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 让商户重试。 * *

请求号与请求日期要清掉:它们是给通道侧定位用的,这次通道根本没收到。失败原因留在 * {@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); } /** * 回退没有成功时给商户的话。 * *

不能只说「撤销失败」:商户需要分清是钱已经结算到银行卡(要联系平台),还是通道还在处理 * (稍后重试就行),否则只会反复点。文案跟结论常量放在一起,免得将来新增一种结论时漏配。 */ 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 statusIs(status, "confirmed"); } private String currentStatus(long paymentId) { List> 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; } /** 库里的金额字段(分);空值保持 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(); 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; } }