From 5c33cf1f2622d4470b861cb041024ead1775c828 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 16 九月 2026 18:05:27 +0800
Subject: [PATCH] feat(payment): 撤销核销把已确认的资金退回延迟户
---
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java | 12
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java | 16
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java | 82 ++++
talent-card-server/src/main/resources/db/migration/V147__payment_fund_revert.sql | 70 ++++
talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java | 22 +
talent-card-server/src/test/java/com/talentcard/server/business/payment/RevokeVerificationFundTests.java | 160 +++++++++
CONTEXT.md | 4
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuDelayRevertClassificationTests.java | 71 ++++
talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java | 6
talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java | 289 ++++++++++++++++
talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java | 241 ++++++++++++++
11 files changed, 957 insertions(+), 16 deletions(-)
diff --git a/CONTEXT.md b/CONTEXT.md
index e35c2d1..f4979aa 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -17,6 +17,10 @@
核销成功时触发交易确认。资金确认状态记在 `payment_transaction.funds_confirmation_status`:
`NULL` 表示未走延迟入账,`pending` 表示钱还在延迟户,`confirmed` 表示已分账到商户基本户。
+撤销核销必须走反方向:调用通道的**交易确认退款**把已确认的金额退回延迟户,状态回到 `pending`,
+商户重新核销时会再确认一次。回退期间状态是 `reverting`(钱在哪一边未定),此时不允许撤销核销。
+钱被 D1 结算划到商户银行卡之后就退不回来,撤销核销只能被拒绝——所以撤销窗口实际上止于下一次自动结算。
+
## 结算
出款由斗拱按结算周期自动完成,平台**不生成提现记录**,也不再发起主动取现。商户进件时通过
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
index 324c506..0e52884 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantWorkService.java
@@ -38,16 +38,19 @@
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)
@@ -280,6 +283,13 @@
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 = ?
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
index 48787ca..3df6f34 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/HuifuRefundService.java
@@ -6,6 +6,7 @@
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;
@@ -287,7 +288,10 @@
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(); }
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java
index f62aec9..43c8fa3 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/PaymentFundConfirmationService.java
@@ -9,12 +9,15 @@
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;
/**
@@ -26,6 +29,9 @@
* <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 {
@@ -48,6 +54,33 @@
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);
@@ -56,11 +89,24 @@
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);
}
/**
@@ -95,7 +141,9 @@
}
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;
}
@@ -125,19 +173,243 @@
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) {
@@ -196,6 +468,11 @@
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();
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java
index 83730d6..496ec68 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuDelayAccountGateway.java
@@ -24,6 +24,9 @@
*
* <p>业务上是否延迟由订单类型决定:先付钱后核销的订单延迟,核销成功才确认;支付即核销的订单不延迟,
* 因此不需要确认。
+ *
+ * <p>核销被撤销时走反方向:交易确认退款把已确认给商户的钱退回延迟户,重新变成不可结算、不可取现,
+ * 等商户重新核销时再确认一次。这条路径的结果分类与确认分开,见 {@link HuifuDelayRevert}。
*/
@Service
public class HuifuDelayAccountGateway {
@@ -81,8 +84,8 @@
return execute(request, CONFIRM_QUERY_FUNCTION, "汇付交易确认查询失败");
}
- /** 把已确认的金额退回原商户账户。 */
- public HuifuDelayConfirmation refund(HuifuDelayConfirmRefundCommand command) {
+ /** 把已确认的金额退回原商户账户(延迟户),撤销核销用它把钱要回来。 */
+ public HuifuDelayRevert refund(HuifuDelayConfirmRefundCommand command) {
ensureConfigured();
initializeSdk();
Map<String, Object> request = new LinkedHashMap<>();
@@ -96,10 +99,10 @@
"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);
@@ -109,7 +112,15 @@
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() {
@@ -239,4 +250,65 @@
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";
+ }
+ }
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java
index 38dcf33..467ee1d 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPayload.java
@@ -60,6 +60,22 @@
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);
diff --git a/talent-card-server/src/main/resources/db/migration/V147__payment_fund_revert.sql b/talent-card-server/src/main/resources/db/migration/V147__payment_fund_revert.sql
new file mode 100644
index 0000000..3452280
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V147__payment_fund_revert.sql
@@ -0,0 +1,70 @@
+-- 撤销核销的资金回退。
+--
+-- 核销会把延迟入账的钱从通道「待确认」确认给商户(钱进商户基本户)。撤销核销必须把钱要回来,
+-- 靠的是通道的【交易确认退款】接口:把已确认的金额退回商户的延迟户,重新变成不可结算、不可取现。
+--
+-- 回退期间 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;
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java
index 19833f2..ab9af53 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PaymentFundConfirmationTests.java
@@ -2,12 +2,15 @@
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;
@@ -16,6 +19,8 @@
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;
@@ -108,6 +113,7 @@
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"));
@@ -128,6 +134,7 @@
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));
@@ -141,6 +148,7 @@
void aConfirmationAlreadyInFlightIsNotStartedAgain() {
String orderNo = createOrder("GROUP_BUY", 100L);
insertPaidPayment(orderNo, 100L, "confirming");
+ markVerified(orderId(orderNo));
assertThat(confirmationService.confirmForOrder(orderId(orderNo))).isEqualTo(
PaymentFundConfirmationService.PROCESSING);
@@ -169,12 +177,17 @@
@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
@@ -204,6 +217,7 @@
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(
@@ -232,6 +246,161 @@
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
// ------------------------------------------------------------------------------------------
@@ -242,6 +411,37 @@
"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);
+ }
+
/**
* 通道说「已无未确认金额」表示这笔延迟交易此前已经确认过(重新确认不会重复分账)。
* 必须按成功处理:否则补偿任务会一直认为没确认,每隔几分钟无意义地打一次通道。
@@ -250,6 +450,7 @@
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", "已无未确认金额", "", "", ""));
@@ -260,6 +461,40 @@
+ " 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(
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java
index 0ae6efe..7440025 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/PendingRefundConfirmationTests.java
@@ -112,6 +112,28 @@
.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);
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/RevokeVerificationFundTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/RevokeVerificationFundTests.java
new file mode 100644
index 0000000..c45f22f
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/RevokeVerificationFundTests.java
@@ -0,0 +1,160 @@
+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);
+ }
+}
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuDelayRevertClassificationTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuDelayRevertClassificationTests.java
new file mode 100644
index 0000000..a50caec
--- /dev/null
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuDelayRevertClassificationTests.java
@@ -0,0 +1,71 @@
+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;
+ }
+}
--
Gitblit v1.7.1