| New file |
| | |
| | | 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.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; |
| | | |
| | | 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"); |
| | | |
| | | 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); |
| | | } |
| | | |
| | | // ------------------------------------------------------------------------------------------ |
| | | // 查询 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) { } |
| | | } |