package com.talentcard.server.business.merchant;
|
|
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.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.ZoneId;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Set;
|
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.stream.Collectors;
|
|
import com.alibaba.fastjson.JSON;
|
import com.talentcard.server.business.PageResult;
|
import com.talentcard.server.business.merchant.AutoWithdrawalResultService.Application;
|
import com.talentcard.server.business.merchant.auth.MerchantPrincipal;
|
import com.talentcard.server.business.order.OrderRefundRequest;
|
import com.talentcard.server.business.payment.HuifuPaymentCallbackService;
|
import com.talentcard.server.business.payment.HuifuPaymentService;
|
import com.talentcard.server.business.payment.HuifuRefundService;
|
import com.talentcard.server.business.payment.huifu.HuifuIndividualOnboardingGateway;
|
import com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway;
|
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway;
|
import com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway;
|
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentCommand;
|
import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway;
|
import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway;
|
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;
|
import com.talentcard.server.business.portal.AppOrderPortalService;
|
import com.talentcard.server.common.BusinessException;
|
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.beans.factory.annotation.Qualifier;
|
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.TestPropertySource;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
|
/**
|
* 完整链路验收 seam(工单 06):从真实进件入口一路走到提现到账,全部用生产服务而不是直接写库造数。
|
*
|
* <p>链路:{@code submitOnboarding} → 斗拱进件与绑卡结果 → 微信商户配置 → 商户业务开通回调 →
|
* 收款能力快照 → 下单固定收款子商户 → 支付预下单 → 验签回调落账 → 商户核销 → 次日凌晨日汇总 →
|
* 提交主动取现 → 异步通知/主动查询收敛 → 商户端只看到三种状态。
|
*
|
* <p>共享测试库不清空,也不回滚,因此:断言只看本用例自己创建的商户/订单/提现记录;业务日取一个
|
* 足够远的未来日期,避免别的用例留下的记录落进同一个汇总窗口;后台任务在测试 profile 里是关闭的,
|
* 用例自己直接调用汇总和结果处理服务。
|
*
|
* <p>这里用 {@code @MockitoBean} 替换所有斗拱网关,所以整条链路零出网调用。真实沙盒和受控真实小额
|
* 到账验证的步骤、白名单与金额上限约束见 {@code docs/automatic-withdrawal-acceptance.md}。
|
*/
|
@SpringBootTest
|
@ActiveProfiles("test")
|
@TestPropertySource(properties = {
|
// 打开通道与能力校验,才能走到「能力就绪才允许支付」的真实闸口。
|
"app.payment.huifu.enabled=true",
|
"app.payment.huifu.merchant-capability-required=true",
|
// 关掉两个定时任务:它们会扫描全表并改动别的用例正在断言的快照。
|
"app.payment.huifu.capability-refresh.enabled=false",
|
"app.merchant.auto-withdrawal.result-check-enabled=false"
|
})
|
class AutoWithdrawalEndToEndAcceptanceTests {
|
|
/** 未来业务日的随机起点 + 每次调用递增,保证同一次运行内每个用例的业务日互不相同。 */
|
private static final AtomicLong BUSINESS_DATE_OFFSET =
|
new AtomicLong(30_000 + ThreadLocalRandom.current().nextLong(1_000_000));
|
|
/** 单人单笔的极小金额:既高于默认最低提现金额 1000 分,又便于人工核对到账金额。 */
|
private static final long PAY_AMOUNT_CENTS = 1200;
|
|
private static final String BRANCH_CODE = "102100099996";
|
|
@Autowired
|
private JdbcTemplate jdbc;
|
|
@Autowired
|
private MerchantSettlementService settlementService;
|
|
@Autowired
|
private MerchantPaymentReadinessService readinessService;
|
|
@Autowired
|
private AppOrderPortalService appOrderPortalService;
|
|
@Autowired
|
private HuifuPaymentService paymentService;
|
|
@Autowired
|
private HuifuPaymentCallbackService callbackService;
|
|
@Autowired
|
private HuifuRefundService refundService;
|
|
@Autowired
|
private MerchantWorkService merchantWorkService;
|
|
@Autowired
|
private AutoWithdrawalAggregationService aggregation;
|
|
@Autowired
|
private AutoWithdrawalResultService withdrawalResultService;
|
|
/** 路由表真源:用来证明商户端/后台都没有提现写入口(提现状态只能由通道收敛)。 */
|
@Autowired
|
@Qualifier("requestMappingHandlerMapping")
|
private RequestMappingHandlerMapping handlerMapping;
|
|
@MockitoBean
|
private HuifuSignatureService signatureService;
|
|
@MockitoBean
|
private HuifuPictureUploadGateway pictureUploadGateway;
|
|
@MockitoBean
|
private HuifuIndividualOnboardingGateway individualOnboardingGateway;
|
|
@MockitoBean
|
private HuifuMerchantWechatConfigGateway merchantWechatConfigGateway;
|
|
@MockitoBean
|
private HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway;
|
|
@MockitoBean
|
private HuifuMerchantCapabilityGateway capabilityGateway;
|
|
@MockitoBean
|
private HuifuMiniProgramPaymentGateway paymentGateway;
|
|
@MockitoBean
|
private HuifuRefundGateway refundGateway;
|
|
@MockitoBean
|
private HuifuWithdrawalGateway withdrawalGateway;
|
|
@MockitoBean
|
private HuifuDelayAccountGateway delayAccountGateway;
|
|
private Long merchantId;
|
private Long storeId;
|
private Long talentId;
|
private Long productId;
|
private Long onboardingId;
|
private String huifuId;
|
private String suffix;
|
private MerchantPrincipal principal;
|
|
@BeforeEach
|
void prepareMerchantAndProviderAnswers() {
|
suffix = String.valueOf(System.nanoTime());
|
// 测试库的网点目录是空的(测试 profile 关掉了 10 万行导入),进件要求开户行真实存在。
|
jdbc.update("insert ignore into bank_branch_directory(clearing_code, branch_name, bank_name, head_bank_code) "
|
+ "values (?, '中国建设银行攀枝花东区支行营业部', '中国建设银行', '105100000017')", BRANCH_CODE);
|
jdbc.update("insert into talent_user(openid, name, mobile, status, deleted) values (?, ?, ?, 1, 0)",
|
"acceptance-openid-" + suffix, "验收用例用户", "13900000006");
|
talentId = jdbc.queryForObject("select max(id) from talent_user", Long.class);
|
jdbc.update("insert into talent_card(talent_id, card_no, card_type, card_level, status, deleted) "
|
+ "values (?, ?, 'PERSONAL', ?, 1, 0)", talentId, "TC-ACC-" + suffix, "卓越人才卡");
|
jdbc.update("insert into merchant(merchant_name, contact_mobile, address, status, deleted) "
|
+ "values (?, ?, ?, 1, 0)", "Acceptance merchant " + suffix, "08120000006", "攀枝花市东区验收路 1 号");
|
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, "Acceptance store " + suffix);
|
storeId = jdbc.queryForObject("select max(id) from merchant_store", Long.class);
|
jdbc.update("""
|
insert into merchant_product(product_no, merchant_id, product_type, product_name, original_price,
|
sale_price, subsidy_amount, stock, audit_status, status, deleted)
|
values (?, ?, 'food', '验收权益商品', 20.00, 12.00, 0.00, 10, 2, 1, 0)
|
""", "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);
|
// 通道答复:能力就绪(自动结算已关闭 + 取现可用 + 子商户正常)。
|
when(capabilityGateway.query(any())).thenReturn(new HuifuMerchantCapabilityGateway.CapabilityResult(
|
true, "00000000", "成功", "NORMAL", "0", "D1", "1", "DM"));
|
when(individualOnboardingGateway.create(any())).thenReturn(
|
new HuifuIndividualOnboardingGateway.Result(true, shortReference("H", suffix), "MO-APPLY-" + suffix,
|
shortReference("T", suffix), "00000000", "成功"));
|
when(merchantWechatConfigGateway.configure(any(), any())).thenReturn(
|
new HuifuMerchantWechatConfigGateway.Result(true, "00000000", "成功"));
|
when(merchantBusinessOpenGateway.open(any(), any(), any(), any())).thenReturn(
|
new HuifuMerchantBusinessOpenGateway.Result(true, "BO-APPLY-" + suffix, "00000000", "成功"));
|
|
// 两个任务都会扫描全表,所以两个窗口都要收拾干净:把别的用例留下的记录移出任务窗口
|
// (而不是删掉它们),这样断言和 verify 只针对本用例自己创建的记录。
|
jdbc.update("""
|
update merchant_withdrawal set withdrawal_date = null
|
where status = 'pending' and withdrawal_date is not null and merchant_id <> ?
|
""", merchantId);
|
jdbc.update("""
|
update merchant_withdrawal set provider_request_date = null
|
where status = 'processing' and provider_request_date is not null and merchant_id <> ?
|
""", merchantId);
|
}
|
|
@Test
|
void theWholeChainTakesAMerchantFromOnboardingToAPaidWithdrawal() {
|
onboardIndividualMerchant();
|
|
assertThat(applicationRow())
|
.containsEntry("status", "approved")
|
.containsEntry("wechat_config_status", "approved")
|
.containsEntry("business_open_status", "approved")
|
.containsEntry("provider_huifu_id", huifuId);
|
assertThat(cardRow())
|
.containsEntry("status", "approved")
|
.containsEntry("provider_token_no", shortReference("T", suffix));
|
// 进件通过 → 能力快照刷新 → 商户能收款,这是工单 01 的完整判定链。
|
assertThat(onboardingStatus()).isEqualTo("approved");
|
assertThat(capabilityRow())
|
.containsEntry("merchant_stat", "NORMAL")
|
.containsEntry("auto_settle_status", "0")
|
.containsEntry("cash_switch_status", "1");
|
assertThat(readinessService.readiness(merchantId, storeId).reason()).isEmpty();
|
assertThat(readinessService.readiness(merchantId, storeId).ready()).isTrue();
|
assertThat(readinessService.requireCollectingMerchant(merchantId, storeId).huifuId()).isEqualTo(huifuId);
|
|
String orderNo = createPayableOrder();
|
assertThat(orderRow(orderNo))
|
.containsEntry("collect_onboarding_id", onboardingId)
|
.containsEntry("collect_huifu_id", huifuId);
|
assertThat(orderRow(orderNo).get("collect_store_id")).isEqualTo(storeId);
|
|
// 支付预下单只使用订单固定的收款子商户。
|
Map<String, Object> payment = paymentService.beginMiniProgramPayment(
|
orderNo, talentId, 1, "acceptance-payment-" + suffix);
|
String paymentNo = String.valueOf(payment.get("paymentNo"));
|
ArgumentCaptor<HuifuMiniProgramPaymentCommand> paymentCommand =
|
ArgumentCaptor.forClass(HuifuMiniProgramPaymentCommand.class);
|
verify(paymentGateway).createMiniProgramPayment(paymentCommand.capture());
|
assertThat(paymentCommand.getValue().huifuId()).isEqualTo(huifuId);
|
assertThat(payment.get("paymentStatus")).isEqualTo("PAYMENT_PENDING");
|
|
// 验签回调落账:支付流水成功、订单已支付(未核销,核销是商户的动作)。
|
assertThat(callbackService.handle(paymentCallback(paymentNo, amountYuan(PAY_AMOUNT_CENTS)), "signature"))
|
.isEqualTo("RECV_ORD_ID_" + paymentNo);
|
assertThat(paymentRow(paymentNo))
|
.containsEntry("status", "SUCCEEDED")
|
.containsEntry("provider_huifu_id", huifuId);
|
assertThat(orderRow(orderNo))
|
.containsEntry("order_status", 1)
|
.containsEntry("verify_status", 0)
|
.containsEntry("withdrawal_status", 0);
|
|
// 商户核销,并把这笔核销挪到业务日窗口内——验收要的是「次日汇总」,不是等一天。
|
LocalDate businessDate = someBusinessDate();
|
merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "acceptance"), verifyKey(orderNo));
|
Long orderId = orderId(orderNo);
|
verifiedOn(orderId, businessDate);
|
|
assertThat(aggregation.run(businessDate).merchantsAggregated()).isPositive();
|
Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
|
assertThat(withdrawal.amountCents()).isEqualTo(PAY_AMOUNT_CENTS);
|
assertThat(sourceOrderIds(withdrawal.id())).containsExactly(orderId);
|
assertThat(orderRow(orderNo).get("withdrawal_status")).isEqualTo(1);
|
// 还没提交给斗拱的记录,商户端也是「处理中」,不是「待审核」。
|
assertThat(merchantWithdrawals()).singleElement().satisfies(row -> assertThat(row)
|
.containsEntry("status", "processing")
|
.containsEntry("amountCents", PAY_AMOUNT_CENTS));
|
|
when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
|
"resp_code", "00000000", "resp_desc", "成功", "trans_stat", "P", "hf_seq_id", "HF-W-P-" + suffix)));
|
assertThat(withdrawalResultService.submitPending(200)).isPositive();
|
ArgumentCaptor<HuifuWithdrawalCommand> withdrawalCommand =
|
ArgumentCaptor.forClass(HuifuWithdrawalCommand.class);
|
verify(withdrawalGateway).create(withdrawalCommand.capture());
|
assertThat(withdrawalCommand.getValue().withdrawalNo()).isEqualTo(withdrawal.withdrawalNo());
|
assertThat(withdrawalCommand.getValue().amountCents()).isEqualTo(PAY_AMOUNT_CENTS);
|
assertThat(withdrawalCommand.getValue().huifuId()).isEqualTo(huifuId);
|
assertThat(withdrawalCommand.getValue().tokenNo()).isEqualTo(shortReference("T", suffix));
|
// 通道受理但结果未定:查询要用提交时记下的营业日和子商户号。
|
assertThat(withdrawalRow(withdrawal.id()))
|
.containsEntry("status", "processing")
|
.containsEntry("provider_huifu_id", huifuId);
|
|
assertThat(withdrawalResultService.handleNotification(withdrawal.withdrawalNo(),
|
withdrawalNotification("S", "00000000", "到账成功"))).isEqualTo(Application.PAID);
|
assertThat(withdrawalRow(withdrawal.id()))
|
.containsEntry("status", "paid")
|
.containsEntry("amount_cents", PAY_AMOUNT_CENTS);
|
assertThat(withdrawalRow(withdrawal.id()).get("paid_at")).isNotNull();
|
assertOnlyWithdrawalStatus("paid");
|
// 到账之后金额冻结在提现记录里,可提现金额归零。
|
assertThat(settlementService.withdrawalSummary(principal))
|
.containsEntry("availableAmountCents", 0L)
|
.containsEntry("frozenAmountCents", PAY_AMOUNT_CENTS);
|
}
|
|
@Test
|
void aProcessingWithdrawalIsConvergedByTheScheduledQueryInsteadOfByHand() {
|
readyMerchant();
|
LocalDate businessDate = someBusinessDate();
|
String orderNo = paidVerifiedOrder(businessDate);
|
Long orderId = orderId(orderNo);
|
aggregation.run(businessDate);
|
Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
|
|
when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
|
"resp_code", "00000000", "trans_stat", "P", "hf_seq_id", "HF-W-Q-" + suffix)));
|
withdrawalResultService.submitPending(200);
|
assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "processing");
|
assertOnlyWithdrawalStatus("processing");
|
|
// 等待期内的记录不查询:刚提交时通道可能还没落库,过早查询会把「查不到」当成失败。
|
assertThat(withdrawalResultService.reconcileProcessing(200)).isZero();
|
verify(withdrawalGateway, never()).query(any());
|
|
ageProcessing(withdrawal.id());
|
when(withdrawalGateway.query(any())).thenReturn(sync(Map.of(
|
"resp_code", "00000000", "trans_status", "S", "trans_desc", "", "org_hf_seq_id", "HF-W-QS-" + suffix)));
|
assertThat(withdrawalResultService.reconcileProcessing(200)).isPositive();
|
|
ArgumentCaptor<HuifuWithdrawalQueryCommand> query =
|
ArgumentCaptor.forClass(HuifuWithdrawalQueryCommand.class);
|
verify(withdrawalGateway).query(query.capture());
|
assertThat(query.getValue().withdrawalNo()).isEqualTo(withdrawal.withdrawalNo());
|
assertThat(query.getValue().huifuId()).isEqualTo(huifuId);
|
assertThat(withdrawalRow(withdrawal.id()))
|
.containsEntry("status", "paid")
|
.containsEntry("provider_trade_no", "HF-W-QS-" + suffix);
|
assertOnlyWithdrawalStatus("paid");
|
}
|
|
@Test
|
void aFailedWithdrawalDefersItsOrdersIntoTheNextBusinessDay() {
|
readyMerchant();
|
LocalDate businessDate = someBusinessDate();
|
Long firstOrder = orderId(paidVerifiedOrder(businessDate));
|
aggregation.run(businessDate);
|
Withdrawal failed = withdrawalOf(businessDate, firstOrder);
|
|
when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
|
"resp_code", "00000000", "trans_stat", "P", "hf_seq_id", "HF-W-F-" + suffix)));
|
withdrawalResultService.submitPending(200);
|
assertThat(withdrawalResultService.handleNotification(failed.withdrawalNo(),
|
withdrawalNotification("F", "00000000", "账务失败"))).isEqualTo(Application.FAILED);
|
|
assertThat(withdrawalRow(failed.id()))
|
.containsEntry("status", "failed")
|
.containsEntry("failure_reason", "账务失败")
|
.containsEntry("amount_cents", PAY_AMOUNT_CENTS);
|
// 失败顺延:订单解锁回到可汇总范围,关联行留痕但标记为已释放。
|
assertThat(orderRow(firstOrder)).containsEntry("withdrawal_status", 0);
|
assertThat(releasedAt(failed.id(), firstOrder)).isNotNull();
|
// 商户端把失败原因一并展示出来,状态仍然是三种之一。
|
assertThat(merchantWithdrawals()).singleElement().satisfies(row -> assertThat(row)
|
.containsEntry("status", "failed")
|
.containsEntry("reason", "账务失败"));
|
|
// 次日汇总必须把顺延的订单和新订单合并成一条新的提现记录。
|
Long secondOrder = orderId(paidVerifiedOrder(businessDate.plusDays(1)));
|
aggregation.run(businessDate.plusDays(1));
|
|
Withdrawal retried = withdrawalOf(businessDate.plusDays(1), firstOrder);
|
assertThat(retried.amountCents()).isEqualTo(2 * PAY_AMOUNT_CENTS);
|
assertThat(sourceOrderIds(retried.id())).containsExactlyInAnyOrder(firstOrder, secondOrder);
|
assertThat(orderRow(firstOrder).get("withdrawal_status")).isEqualTo(1);
|
assertThat(orderRow(secondOrder).get("withdrawal_status")).isEqualTo(1);
|
}
|
|
@Test
|
void repeatedProviderNotificationsAndRepeatedVerificationNeverChangeSettledState() {
|
readyMerchant();
|
LocalDate businessDate = someBusinessDate();
|
String orderNo = paidVerifiedOrder(businessDate);
|
Long orderId = orderId(orderNo);
|
|
// 核销幂等:同一个幂等键重复提交只产生一条有效核销记录,不同幂等键被订单状态挡住。
|
assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
|
merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "again"), verifyKey(orderNo));
|
assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
|
assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "again"),
|
"acceptance-verify-second-" + suffix + "-" + orderNo))
|
.isInstanceOf(BusinessException.class);
|
assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
|
|
aggregation.run(businessDate);
|
Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
|
when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
|
"resp_code", "00000000", "trans_stat", "S", "hf_seq_id", "HF-W-DUP-" + suffix)));
|
withdrawalResultService.submitPending(200);
|
assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "paid");
|
|
// 重复通知(含通道的第二次账务通知)不能改写终态,也不会释放来源订单。
|
assertThat(withdrawalResultService.handleNotification(withdrawal.withdrawalNo(),
|
withdrawalNotification("S", "00000000", "到账成功"))).isEqualTo(Application.ALREADY_SETTLED);
|
assertThat(withdrawalResultService.handleNotification(withdrawal.withdrawalNo(),
|
withdrawalNotification("F", "00000000", "迟到的失败"))).isEqualTo(Application.ALREADY_SETTLED);
|
assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "paid");
|
assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 1);
|
|
// 重复执行结果处理任务:终态记录不会被重新提交,也就不会用新的业务请求号重复出账。
|
assertThat(withdrawalResultService.submitPending(200)).isZero();
|
verify(withdrawalGateway).create(any());
|
}
|
|
@Test
|
void aRefundUnderWayExcludesTheOrderFromTheDailyWithdrawal() {
|
readyMerchant();
|
LocalDate businessDate = someBusinessDate();
|
// 退款的订单必须是「已支付未核销」:核销过的订单按既有策略不可退。
|
String orderNo = paidOrder();
|
Long orderId = orderId(orderNo);
|
Refund refund = requestRefundUnderWay(orderNo);
|
|
aggregation.run(businessDate);
|
|
// 退款在途(整单排除)的订单不进提现记录,金额留作退款。
|
assertThat(withdrawalCount(businessDate)).isZero();
|
assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 0);
|
|
// 通道确认退款成功后,这笔订单永远退出提现范围。
|
assertThat(callbackService.handle(refundCallback(refund.refundNo(), "S", "00000000"), "signature"))
|
.isEqualTo("RECV_ORD_ID_" + refund.refundNo());
|
assertThat(orderRow(orderId))
|
.containsEntry("refund_status", 2)
|
.containsEntry("withdrawal_status", 0);
|
assertThat(withdrawalCount(businessDate)).isZero();
|
}
|
|
@Test
|
void anOrderLockedIntoAWithdrawalRecordCanNeitherBeRevokedNorRefunded() {
|
readyMerchant();
|
LocalDate businessDate = someBusinessDate();
|
String orderNo = paidVerifiedOrder(businessDate);
|
Long orderId = orderId(orderNo);
|
|
aggregation.run(businessDate);
|
Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
|
assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 1);
|
|
// 已经进入提现记录的订单不能再撤销核销或退款:这是「退款与批次锁定按订单串行化」的终点。
|
assertThatThrownBy(() -> merchantWorkService.revokeVerification(principal,
|
new MerchantRevokeVerificationRequest(String.valueOf(orderId), "too late"), "acceptance-revoke-" + suffix))
|
.isInstanceOf(BusinessException.class)
|
.hasMessageContaining("提现记录");
|
assertThatThrownBy(() -> refundService.request(orderNo,
|
new OrderRefundRequest(BigDecimal.valueOf(PAY_AMOUNT_CENTS, 2), "too late", "tester")))
|
.isInstanceOf(BusinessException.class)
|
.hasMessageContaining("提现记录");
|
assertThat(merchantWorkService.order(orderNo, merchantId, storeId))
|
.containsEntry("canRevoke", false)
|
.containsEntry("revokeReason", "订单已进入提现记录");
|
|
// 撤销与退款都失败后订单状态不变,金额仍在这条提现记录里等待通道结果。
|
assertThat(orderRow(orderId))
|
.containsEntry("verify_status", 1)
|
.containsEntry("refund_status", 0)
|
.containsEntry("withdrawal_status", 1);
|
assertThat(activeVerifyRecords(orderId)).isEqualTo(1);
|
assertThat(withdrawalRow(withdrawal.id())).containsEntry("amount_cents", PAY_AMOUNT_CENTS);
|
}
|
|
@Test
|
void anUnpaidOrderCannotBeVerifiedAndNeverReachesAWithdrawal() {
|
readyMerchant();
|
LocalDate businessDate = someBusinessDate();
|
String orderNo = createPayableOrder();
|
Long orderId = orderId(orderNo);
|
assertThat(orderRow(orderNo)).containsEntry("order_status", 0);
|
|
assertThatThrownBy(() -> merchantWorkService.verify(orderNo, principal,
|
new VerifyRequest("merchant", "unpaid"), verifyKey(orderNo)))
|
.isInstanceOf(BusinessException.class)
|
.hasMessageContaining("未支付");
|
assertThat(activeVerifyRecords(orderId)).isZero();
|
|
aggregation.run(businessDate);
|
assertThat(withdrawalCount(businessDate)).isZero();
|
assertThat(orderRow(orderId)).containsEntry("withdrawal_status", 0);
|
}
|
|
@Test
|
void aForgedOrTamperedPaymentCallbackNeverMarksTheOrderPaid() {
|
readyMerchant();
|
String orderNo = createPayableOrder();
|
String paymentNo = String.valueOf(paymentService.beginMiniProgramPayment(
|
orderNo, talentId, 1, "acceptance-payment-forged-" + suffix).get("paymentNo"));
|
Long orderId = orderId(orderNo);
|
String validPayload = paymentCallback(paymentNo, amountYuan(PAY_AMOUNT_CENTS));
|
|
// 验签不通过:回调被直接拒绝,连审计行都不写,业务状态更不会变。
|
when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(false);
|
assertThatThrownBy(() -> callbackService.handle(validPayload, "forged-signature"))
|
.isInstanceOf(BusinessException.class)
|
.hasMessageContaining("验签失败");
|
assertThat(paymentRow(paymentNo)).containsEntry("status", "PREPAY_CREATED");
|
assertThat(orderRow(orderId)).containsEntry("order_status", 0);
|
assertThat(count("select count(*) from payment_callback_event where provider_request_no = ?", paymentNo))
|
.isZero();
|
|
// 签名有效但金额被改动:同样不履约,落异常账。
|
when(signatureService.verifyCallbackPayload(any(), any())).thenReturn(true);
|
callbackService.handle(paymentCallback(paymentNo, "0.01"), "signature");
|
assertThat(paymentRow(paymentNo)).containsEntry("status", "PREPAY_CREATED");
|
assertThat(orderRow(orderId)).containsEntry("order_status", 0);
|
assertThat(openIssueCount("PAYMENT_AMOUNT_MISMATCH", paymentNo)).isEqualTo(1L);
|
}
|
|
@Test
|
void aMerchantWithoutAConfirmedCapabilityCannotCreateAPayableOrder() {
|
// 用 JDBC 造一条「进件已通过、能力快照还没确认」的商户:支付链路上不调用通道,但也不放行。
|
insertApprovedOnboardingWithoutSnapshot();
|
|
assertThatThrownBy(() -> createPayableOrder())
|
.isInstanceOf(BusinessException.class)
|
.hasMessageContaining("待确认");
|
assertThat(count("select count(*) from benefit_order where merchant_id = ?", merchantId)).isZero();
|
}
|
|
@Test
|
void theWithdrawalSurfaceIsReadOnlyAndNeverShowsAStateOutsideTheThreeAgreedOnes() {
|
// 商户不能自己发起提现:写路由不存在,只有只读的列表与汇总。
|
assertThat(registeredMethods("/api/merchant/withdrawals"))
|
.as("商户端提现只有 GET 列表")
|
.containsExactly("GET");
|
assertThat(registeredPatterns().stream().filter(path -> path.startsWith("/api/merchant/withdrawals")).toList())
|
.as("商户端不存在提现写路由")
|
.allMatch(path -> path.equals("/api/merchant/withdrawals") || path.equals("/api/merchant/withdrawals/summary"));
|
// 后台也不能人工审核或随意改状态:提现管理只有查询。
|
assertThat(registeredMethods("/api/admin/management/merchant-settlement/withdrawals"))
|
.as("后台提现管理只有 GET 查询")
|
.containsExactly("GET");
|
assertThat(registeredPatterns().stream()
|
.filter(path -> path.startsWith("/api/admin/management/merchant-settlement/withdrawals")).toList())
|
.as("后台不存在提现写路由")
|
.containsExactly("/api/admin/management/merchant-settlement/withdrawals");
|
|
// 四种库内状态在商户端只折叠成三种对外状态。
|
readyMerchant();
|
LocalDate businessDate = someBusinessDate();
|
String orderNo = paidVerifiedOrder(businessDate);
|
Long orderId = orderId(orderNo);
|
aggregation.run(businessDate);
|
Withdrawal withdrawal = withdrawalOf(businessDate, orderId);
|
assertThat(withdrawalRow(withdrawal.id())).containsEntry("status", "pending");
|
assertOnlyWithdrawalStatus("processing");
|
|
when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
|
"resp_code", "00000000", "trans_stat", "P", "hf_seq_id", "HF-VIS-" + suffix)));
|
withdrawalResultService.submitPending(200);
|
assertOnlyWithdrawalStatus("processing");
|
|
when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
|
"resp_code", "22000004", "resp_desc", "暂未开通取现权限")));
|
jdbc.update("update merchant_withdrawal set status='pending' where id = ?", withdrawal.id());
|
withdrawalResultService.submitPending(200);
|
assertThat(merchantWithdrawals()).singleElement().satisfies(row -> assertThat(row)
|
.containsEntry("status", "failed")
|
.containsEntry("reason", "暂未开通取现权限"));
|
|
when(withdrawalGateway.create(any())).thenReturn(sync(Map.of(
|
"resp_code", "00000000", "trans_stat", "S", "hf_seq_id", "HF-VIS-S-" + suffix)));
|
jdbc.update("update merchant_withdrawal set status='pending' where id = ?", withdrawal.id());
|
withdrawalResultService.submitPending(200);
|
assertOnlyWithdrawalStatus("paid");
|
}
|
|
// ------------------------------------------------------------------------------------------
|
// 生产路径 helper
|
// ------------------------------------------------------------------------------------------
|
|
/** 每个用例都从真实进件入口把商户做成「可收款」状态,链路起点一致。 */
|
private void readyMerchant() {
|
onboardIndividualMerchant();
|
assertThat(readinessService.requireCollectingMerchant(merchantId, storeId).huifuId()).isEqualTo(huifuId);
|
}
|
|
/** 走真实进件入口,直到进件 approved、结算卡 approved 且能力快照已确认。 */
|
private void onboardIndividualMerchant() {
|
Map<String, Object> submitted = settlementService.submitOnboarding(principal, onboardingRequest(), "acceptance-onboarding-" + suffix);
|
assertThat(submitted).containsEntry("status", "pending");
|
// 商户业务开通是异步的:通道回调(notify_type=W + business_stat=S)才会让进件最终通过。
|
String applicationNo = String.valueOf(submitted.get("applicationNo"));
|
assertThat(settlementService.handleBusinessOpenCallback(Map.of(
|
"req_seq_id", applicationNo + "BO",
|
"sub_resp_code", "00000000",
|
"sub_resp_desc", "成功",
|
"audit_status", "Y",
|
"notify_type", "W",
|
"reg_result_list", List.of(Map.of("business_stat", "S"))))).isTrue();
|
|
Map<String, Object> application = applicationRow();
|
assertThat(application).containsEntry("status", "approved");
|
onboardingId = ((Number) application.get("id")).longValue();
|
huifuId = String.valueOf(application.get("provider_huifu_id"));
|
assertThat(huifuId).isNotBlank();
|
}
|
|
/** 用 JDBC 造一条「进件已通过、能力快照还没确认」的商户:闸口必须挡住支付。 */
|
private void insertApprovedOnboardingWithoutSnapshot() {
|
when(capabilityGateway.query(any())).thenThrow(new RuntimeException("snapshot must not be queried on the payment path"));
|
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)
|
values (?, ?, '中国建设银行', '攀枝花东区支行', '510400', '8888', '**** **** **** 8888', ?, 'approved', ?, ?)
|
""", merchantId, storeId, "hash-" + suffix, "acceptance-card-" + suffix, shortReference("T", suffix));
|
Long cardId = jdbc.queryForObject("select max(id) from merchant_bank_card", 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, initial_bank_card_id)
|
values (?, ?, ?, 'individual', '验收商户负责人', '验收商户负责人', '13900000006', ?, 'approved', ?, ?)
|
""", merchantId, storeId, "MO-ACC-" + suffix, "acceptance-onboarding-snapshot-" + suffix,
|
shortReference("H", suffix), cardId);
|
onboardingId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class);
|
huifuId = shortReference("H", suffix);
|
}
|
|
private MerchantOnboardingRequest onboardingRequest() {
|
return new MerchantOnboardingRequest(
|
"individual",
|
"验收商户负责人",
|
null,
|
"510411199001010011",
|
"long_term",
|
"2020-01-01",
|
null,
|
null,
|
null,
|
null,
|
"四川省攀枝花市东区",
|
"510000",
|
"510400",
|
"510402",
|
"攀枝花市东区验收路 1 号",
|
null,
|
null,
|
null,
|
null,
|
null,
|
null,
|
"https://example.com/acceptance-id-front.png",
|
"https://example.com/acceptance-id-back.png",
|
"攀枝花市东区验收路 1 号",
|
"acceptance-" + suffix + "@example.com",
|
"验收商户负责人",
|
"13900000006",
|
"6217001234567891234",
|
"510000",
|
"510400",
|
BRANCH_CODE,
|
"https://example.com/acceptance-card-front.png",
|
null);
|
}
|
|
/** 下单 → 支付预下单 → 验签回调 → 商户核销,并把这笔核销挪进指定业务日的窗口。 */
|
private String paidVerifiedOrder(LocalDate businessDate) {
|
String orderNo = paidOrder();
|
merchantWorkService.verify(orderNo, principal, new VerifyRequest("merchant", "acceptance"),
|
verifyKey(orderNo));
|
verifiedOn(orderId(orderNo), businessDate);
|
return orderNo;
|
}
|
|
/** 下单 → 支付预下单 → 验签回调:订单已支付、未核销,可以退款。 */
|
private String paidOrder() {
|
String orderNo = createPayableOrder();
|
String paymentNo = String.valueOf(paymentService.beginMiniProgramPayment(
|
orderNo, talentId, 1, "acceptance-payment-" + suffix + "-" + orderNo).get("paymentNo"));
|
callbackService.handle(paymentCallback(paymentNo, amountYuan(PAY_AMOUNT_CENTS)), "signature");
|
assertThat(orderRow(orderNo)).containsEntry("order_status", 1).containsEntry("verify_status", 0);
|
return orderNo;
|
}
|
|
/** 核销幂等键按订单区分:核销键在商户维度唯一,重复用同一个键会被判成「已用于其他订单」。 */
|
private String verifyKey(String orderNo) {
|
return "acceptance-verify-" + suffix + "-" + orderNo;
|
}
|
|
private String createPayableOrder() {
|
when(paymentGateway.createMiniProgramPayment(any())).thenReturn(sync(Map.of(
|
"trans_stat", "S", "resp_code", "00000000", "pay_info", "{\"appId\":\"wxtest\"}")));
|
Map<String, Object> created = appOrderPortalService.create(talentId,
|
new AppOrderCreateRequest(merchantId, productId, null), "acceptance-order-" + suffix + "-" + System.nanoTime());
|
String orderNo = String.valueOf(created.get("orderNo"));
|
// 工单 01 的已知遗留:AppOrderCreateRequest 不携带门店,订单不写 store_id,能力校验只能到商户维度。
|
// 日汇总按「商户 + 门店」出账,所以这里补上门店(订单已经记下收款门店 collect_store_id)。
|
jdbc.update("update benefit_order set store_id = collect_store_id, order_type = 'GROUP_BUY' where order_no = ?", orderNo);
|
assertThat(orderRow(orderNo)).containsEntry("pay_amount", PAY_AMOUNT_CENTS);
|
return orderNo;
|
}
|
|
private Refund requestRefundUnderWay(String orderNo) {
|
when(refundGateway.createRefund(any())).thenReturn(sync(Map.of(
|
"trans_stat", "P", "resp_code", "00000000", "hf_seq_id", "HF-R-P-" + suffix)));
|
Map<String, Object> requested = refundService.request(orderNo,
|
new OrderRefundRequest(BigDecimal.valueOf(PAY_AMOUNT_CENTS, 2), "acceptance", "tester"));
|
return new Refund(String.valueOf(requested.get("refundNo")));
|
}
|
|
// ------------------------------------------------------------------------------------------
|
// 报文 builder
|
// ------------------------------------------------------------------------------------------
|
|
/** 回调报文里的金额是两位小数的元,和通道一致;整数分换算一次,避免用 double 表示金额。 */
|
private String paymentCallback(String paymentNo, String transAmt) {
|
Map<String, Object> data = new LinkedHashMap<>();
|
data.put("resp_code", "00000000");
|
data.put("resp_desc", "成功");
|
data.put("trans_stat", "S");
|
data.put("req_seq_id", paymentNo);
|
data.put("huifu_id", huifuId);
|
data.put("trans_amt", transAmt);
|
data.put("hf_seq_id", "HF-PAY-" + paymentNo);
|
return JSON.toJSONString(data);
|
}
|
|
/** 整数分 → 斗拱要求的两位小数元。 */
|
private String amountYuan(long cents) {
|
return BigDecimal.valueOf(cents, 2).toPlainString();
|
}
|
|
private String refundCallback(String refundNo, String status, String code) {
|
return JSON.toJSONString(Map.of(
|
"resp_code", code,
|
"resp_desc", "退款回调",
|
"trans_stat", status,
|
"req_seq_id", refundNo));
|
}
|
|
private Map<String, Object> withdrawalNotification(String status, String code, String description) {
|
return Map.of(
|
"sub_resp_code", code,
|
"sub_resp_desc", description,
|
"trans_status", status,
|
"msg_type", "02");
|
}
|
|
private Map<String, Object> sync(Map<String, Object> data) {
|
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
|
// ------------------------------------------------------------------------------------------
|
|
private Map<String, Object> orderRow(String orderNo) {
|
return jdbc.queryForMap("select * from benefit_order where order_no = ?", orderNo);
|
}
|
|
private Map<String, Object> orderRow(Long orderId) {
|
return jdbc.queryForMap("select * from benefit_order where id = ?", orderId);
|
}
|
|
private Long orderId(String orderNo) {
|
return jdbc.queryForObject("select id from benefit_order where order_no = ?", Long.class, orderNo);
|
}
|
|
private Map<String, Object> paymentRow(String paymentNo) {
|
return jdbc.queryForMap("select * from payment_transaction where payment_no = ?", paymentNo);
|
}
|
|
private Map<String, Object> withdrawalRow(Long withdrawalId) {
|
return jdbc.queryForMap("select * from merchant_withdrawal where id = ?", withdrawalId);
|
}
|
|
private Map<String, Object> applicationRow() {
|
return jdbc.queryForMap("""
|
select * from merchant_onboarding_application
|
where merchant_id = ? and store_id = ? and initial_bank_card_id is not null
|
order by id desc limit 1
|
""", merchantId, storeId);
|
}
|
|
private Map<String, Object> cardRow() {
|
return jdbc.queryForMap("""
|
select * from merchant_bank_card where merchant_id = ? and store_id = ? order by id desc limit 1
|
""", merchantId, storeId);
|
}
|
|
private Map<String, Object> capabilityRow() {
|
return jdbc.queryForMap("select * from merchant_onboarding_application where id = ?", onboardingId);
|
}
|
|
private String onboardingStatus() {
|
return String.valueOf(settlementService.onboardingStatus(principal).get("status"));
|
}
|
|
private List<Map<String, Object>> merchantWithdrawals() {
|
PageResult<Map<String, Object>> page = settlementService.withdrawals(principal, 1, 50);
|
return page.records();
|
}
|
|
/** 本商户只有一条提现记录,且商户端读到的状态就是期望的那一种。 */
|
private void assertOnlyWithdrawalStatus(String status) {
|
assertThat(merchantWithdrawals()).singleElement()
|
.satisfies(row -> assertThat(row).containsEntry("status", status));
|
}
|
|
/** 某个业务日里包含指定订单的那条提现记录。 */
|
private Withdrawal withdrawalOf(LocalDate businessDate, Long orderId) {
|
Map<String, Object> row = jdbc.queryForMap("""
|
select w.id, w.withdrawal_no, w.amount_cents
|
from merchant_withdrawal w
|
join merchant_withdrawal_order a on a.withdrawal_id = w.id and a.released_at is null
|
where w.merchant_id = ? and w.withdrawal_date = ? and a.order_id = ?
|
""", merchantId, businessDate.toString().replace("-", ""), orderId);
|
return new Withdrawal(((Number) row.get("id")).longValue(), String.valueOf(row.get("withdrawal_no")),
|
((Number) row.get("amount_cents")).longValue());
|
}
|
|
private Integer withdrawalCount(LocalDate businessDate) {
|
return jdbc.queryForObject("""
|
select count(*) from merchant_withdrawal where merchant_id = ? and withdrawal_date = ?
|
""", Integer.class, merchantId, businessDate.toString().replace("-", ""));
|
}
|
|
private List<Long> sourceOrderIds(Long withdrawalId) {
|
return jdbc.queryForList("select order_id from merchant_withdrawal_order where withdrawal_id = ?",
|
Long.class, withdrawalId);
|
}
|
|
private Object releasedAt(Long withdrawalId, Long orderId) {
|
return jdbc.queryForObject("""
|
select released_at from merchant_withdrawal_order where withdrawal_id = ? and order_id = ?
|
""", Object.class, withdrawalId, orderId);
|
}
|
|
private Integer activeVerifyRecords(Long orderId) {
|
return jdbc.queryForObject("select count(*) from verify_record where order_id = ? and status = 1",
|
Integer.class, orderId);
|
}
|
|
private Long openIssueCount(String type, String businessNo) {
|
return jdbc.queryForObject("""
|
select count(*) from payment_reconciliation_issue
|
where issue_type = ? and business_no = ? and status = 'open'
|
""", Long.class, type, businessNo);
|
}
|
|
private long count(String sql, Object... args) {
|
Long value = jdbc.queryForObject(sql, Long.class, args);
|
return value == null ? 0 : value;
|
}
|
|
private Set<String> registeredMethods(String path) {
|
return handlerMapping.getHandlerMethods().entrySet().stream()
|
.filter(entry -> entry.getKey().getPathPatternsCondition() != null
|
&& entry.getKey().getPathPatternsCondition().getPatternValues().contains(path))
|
.flatMap(entry -> entry.getKey().getMethodsCondition().getMethods().stream())
|
.map(Enum::name)
|
.collect(Collectors.toSet());
|
}
|
|
private Set<String> registeredPatterns() {
|
return handlerMapping.getHandlerMethods().keySet().stream()
|
.filter(info -> info.getPathPatternsCondition() != null)
|
.flatMap(info -> info.getPathPatternsCondition().getPatternValues().stream())
|
.collect(Collectors.toSet());
|
}
|
|
// ------------------------------------------------------------------------------------------
|
// 造数与时间 helper
|
// ------------------------------------------------------------------------------------------
|
|
/** 业务日取足够远的未来日期,避开共享测试库里别的用例留下的核销时间。 */
|
private LocalDate someBusinessDate() {
|
return LocalDate.now(ZoneId.of("Asia/Shanghai")).plusDays(BUSINESS_DATE_OFFSET.getAndIncrement());
|
}
|
|
private void verifiedOn(Long orderId, LocalDate businessDate) {
|
jdbc.update("""
|
update verify_record set verify_time = ?
|
where order_id = ? and status = 1
|
""", LocalDateTime.of(businessDate, java.time.LocalTime.of(10, 0)), orderId);
|
}
|
|
private void ageProcessing(Long withdrawalId) {
|
jdbc.update("update merchant_withdrawal set updated_at = date_sub(now(), interval 30 minute) where id = ?",
|
withdrawalId);
|
}
|
|
/** 通道标识限长 32,测试里统一截短。 */
|
private String shortReference(String prefix, String suffix) {
|
return prefix + suffix.substring(Math.max(0, suffix.length() - 8));
|
}
|
|
private record Withdrawal(Long id, String withdrawalNo, long amountCents) { }
|
|
private record Refund(String refundNo) { }
|
}
|