fix(merchant): 提现失败后可重试,且不再丢掉通道流水号
- 提现失败当天不再被当成「已汇总」:失败记录退回 pending 复用同一条,
否则钱后来可取了也没有任何出账路径;只跳过 pending/processing/paid
- 自动提现去掉最低提现金额与手续费缓冲:斗拱主动取现没有最低金额也不收手续费
- 结果收敛与 enabled 解耦:关掉自动汇总仍要提交并收敛已有记录
- 新增后台补跑接口 POST /merchant-settlement/auto-withdrawal/run?businessDate=
- 修复支付查询落账取错字段:查询回执的通道流水号是 org_hf_seq_id,
取 hf_seq_id 会把它静默写成 NULL,退款与排查失去定位依据
- 修复收款能力刷新构造 TransactionTemplate 时的共享单例改写与 storeId 拆箱
| | |
| | | @PostMapping("/merchant-settlement/onboardings/{id}/payment-capability/refresh") public ApiResponse<Map<String,Object>> refreshPaymentCapability(@PathVariable Long id) { return ApiResponse.success(settlementService.refreshPaymentCapability(id)); } |
| | | @GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.bankCards(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); } |
| | | @GetMapping("/merchant-settlement/withdrawals") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> withdrawals(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.withdrawals(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); } |
| | | /** |
| | | * 按业务日补跑一次自动提现汇总(运维排查与验收用)。日期格式 yyyyMMdd,必须显式传。 |
| | | */ |
| | | @PostMapping("/merchant-settlement/auto-withdrawal/run") public ApiResponse<Map<String,Object>> runAutoWithdrawalAggregation(@RequestParam String businessDate) { return ApiResponse.success(settlementService.runAutoWithdrawalAggregation(parseBusinessDate(businessDate))); } |
| | | |
| | | private static java.time.LocalDate parseBusinessDate(String businessDate) { |
| | | if (businessDate == null || businessDate.isBlank()) throw new com.talentcard.server.common.BusinessException(com.talentcard.server.common.ErrorCode.BAD_REQUEST, "请指定业务日 businessDate(yyyyMMdd)"); |
| | | try { |
| | | return java.time.LocalDate.parse(businessDate.trim(), java.time.format.DateTimeFormatter.BASIC_ISO_DATE); |
| | | } catch (java.time.format.DateTimeParseException exception) { |
| | | throw new com.talentcard.server.common.BusinessException(com.talentcard.server.common.ErrorCode.BAD_REQUEST, "业务日格式应为 yyyyMMdd"); |
| | | } |
| | | } |
| | | @GetMapping("/payment-reconciliation/issues") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> reconciliationIssues(@RequestParam(defaultValue = "open") String status, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { return ApiResponse.success(reconciliationService.issues(status, page, size)); } |
| | | @PatchMapping("/payment-reconciliation/issues/{id}") public ApiResponse<Map<String,Object>> closeReconciliationIssue(@PathVariable Long id, @RequestBody Map<String,String> body) { return ApiResponse.success(reconciliationService.close(id, body.get("status"), body.get("operator"))); } |
| | | @PostMapping("/payment-reconciliation/run") public ApiResponse<Map<String,Object>> runPaymentReconciliation(@RequestParam(defaultValue = "5") int pendingAfterMinutes) { return ApiResponse.success(reconciliationService.run(pendingAfterMinutes)); } |
| | |
| | | package com.talentcard.server.business.admin; |
| | | |
| | | import java.time.LocalDate; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | |
| | | |
| | | import com.talentcard.server.business.DatabaseRows; |
| | | import com.talentcard.server.business.PageResult; |
| | | import com.talentcard.server.business.merchant.AutoWithdrawalAggregationService; |
| | | import com.talentcard.server.business.merchant.AutoWithdrawalResultService; |
| | | import com.talentcard.server.business.merchant.AutoWithdrawalScheduler; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import com.talentcard.server.common.SensitiveDataCrypto; |
| | |
| | | private final JdbcTemplate jdbc; |
| | | private final SensitiveDataCrypto sensitiveDataCrypto; |
| | | private final MerchantPaymentReadinessService readinessService; |
| | | public MerchantSettlementAdminService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService) { this.jdbc = jdbc; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; } |
| | | private final AutoWithdrawalScheduler autoWithdrawalScheduler; |
| | | public MerchantSettlementAdminService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, MerchantPaymentReadinessService readinessService, AutoWithdrawalScheduler autoWithdrawalScheduler) { this.jdbc = jdbc; this.sensitiveDataCrypto = sensitiveDataCrypto; this.readinessService = readinessService; this.autoWithdrawalScheduler = autoWithdrawalScheduler; } |
| | | |
| | | /** |
| | | * 按业务日补跑一次日汇总并立刻提交、收敛结果。 |
| | | * |
| | | * <p>定时任务汇总的是「前一自然日」,当天核销的订单要等到第二天凌晨才会出账;排查和验收都等不起, |
| | | * 所以这里提供一次显式补跑。它不接受「不传日期」:隐式取当天容易误刷历史,日期必须由调用方给定。 |
| | | */ |
| | | public Map<String, Object> runAutoWithdrawalAggregation(LocalDate businessDate) { |
| | | if (businessDate == null) throw new BusinessException(ErrorCode.BAD_REQUEST, "请指定业务日 businessDate(yyyyMMdd)"); |
| | | AutoWithdrawalAggregationService.Result result = autoWithdrawalScheduler.runForBusinessDate(businessDate); |
| | | Map<String, Object> view = new LinkedHashMap<>(); |
| | | view.put("businessDate", result.businessDate()); |
| | | view.put("merchantsAggregated", result.merchantsAggregated()); |
| | | view.put("merchantsSkipped", result.merchantsSkipped()); |
| | | view.put("merchantsFailed", result.merchantsFailed()); |
| | | view.put("ordersAggregated", result.ordersAggregated()); |
| | | view.put("amountCents", result.amountCents()); |
| | | return view; |
| | | } |
| | | |
| | | @Transactional(readOnly = true) |
| | | public PageResult<Map<String, Object>> onboardings(int page, int size, String merchantName, String contactMobile, String merchantType, String status, String startDate, String endDate) { |
| | |
| | | import org.springframework.boot.context.properties.ConfigurationProperties; |
| | | |
| | | /** |
| | | * 每日自动提现汇总与结果处理的阈值和开关。金额都是整数分,和库里的金额列一致。 |
| | | * 每日自动提现汇总与结果处理的开关。汇总按整数分计算,和库里的金额列一致。 |
| | | * |
| | | * <p>低于最低提现金额,或者扣掉手续费缓冲后低于最低提现金额,这一天的汇总就顺延到下一次任务, |
| | | * 不生成提现记录(CONTEXT.md「可提现订单」)。 |
| | | * <p>不设最低提现金额:斗拱主动取现没有最低金额,取现也不收手续费(手续费在支付时已经内扣), |
| | | * 所以只要当天有已核销订单就出账,金额小也照提(CONTEXT.md「可提现订单」)。 |
| | | */ |
| | | @ConfigurationProperties(prefix = "app.merchant.auto-withdrawal") |
| | | public class AutoWithdrawalProperties { |
| | | |
| | | /** 最低提现金额(分)。斗拱主动取现的最小金额以目标产品为准,部署时按产品配置。 */ |
| | | private long minAmountCents = 1000; |
| | | |
| | | /** 手续费缓冲(分):可提金额减去缓冲后仍要达到最低金额,否则顺延。 */ |
| | | private long feeBufferCents = 0; |
| | | |
| | | /** 任务是否启用。凌晨的定时任务按北京时间触发,本地开发可以关掉。 */ |
| | | private boolean enabled = true; |
| | |
| | | /** 结果处理任务(补交待提交记录 + 查询处理中记录)是否启用。 */ |
| | | private boolean resultCheckEnabled = true; |
| | | |
| | | public long getMinAmountCents() { return minAmountCents; } |
| | | public void setMinAmountCents(long minAmountCents) { this.minAmountCents = minAmountCents; } |
| | | public long getFeeBufferCents() { return feeBufferCents; } |
| | | public void setFeeBufferCents(long feeBufferCents) { this.feeBufferCents = feeBufferCents; } |
| | | public boolean isEnabled() { return enabled; } |
| | | public void setEnabled(boolean enabled) { this.enabled = enabled; } |
| | | public String getCron() { return cron; } |
| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | import java.time.LocalDate; |
| | | |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.scheduling.annotation.Scheduled; |
| | |
| | | submitPending(); |
| | | } |
| | | |
| | | /** |
| | | * 按指定业务日补跑一次汇总,然后立刻提交并收敛结果。供后台运维排查和验收使用: |
| | | * 正常任务汇总的是「前一自然日」,无法用来验证当天核销的订单。 |
| | | * |
| | | * <p>补跑只汇总入参指定的那一天,不会顺带处理别的日期;重复调用同一天由 |
| | | * {@code uk_merchant_withdrawal_daily} 唯一键兜底,不会重复出账。 |
| | | */ |
| | | public AutoWithdrawalAggregationService.Result runForBusinessDate(LocalDate businessDate) { |
| | | AutoWithdrawalAggregationService.Result result = aggregation.run(businessDate); |
| | | log.info("Auto withdrawal manual aggregation {}", result); |
| | | submitPending(); |
| | | reconcile(); |
| | | return result; |
| | | } |
| | | |
| | | @Scheduled(fixedDelayString = "${app.merchant.auto-withdrawal.result-check-fixed-delay-ms:300000}") |
| | | public void processResults() { |
| | | if (!properties.isEnabled() || !properties.isResultCheckEnabled()) return; |
| | | // 结果收敛不受 enabled 控制:enabled 管的是「要不要自动汇总出账」,而这里做的是「把已经生成的 |
| | | // 记录提交出去并收敛状态」。用后台补跑、或把 enabled 关掉只做手动汇总时,这一步仍然必须运行, |
| | | // 否则记录会永远停在 pending。 |
| | | if (!properties.isResultCheckEnabled()) return; |
| | | submitPending(); |
| | | reconcile(); |
| | | } |
| | |
| | | this.jdbc = jdbc; |
| | | this.capabilityGateway = capabilityGateway; |
| | | this.huifuPaymentProperties = huifuPaymentProperties; |
| | | this.refreshTransaction = refreshTransaction; |
| | | // 注入的 TransactionTemplate 是容器里的单例,支付、退款和自动提现都共用它。这里绝不能改它的 |
| | | // 传播行为,否则那些服务会跟着被改成 REQUIRES_NEW,外层事务被静默挂起。两个模板都自己新建。 |
| | | // The snapshot must survive a failed provider query in the caller's transaction, so it runs |
| | | // in its own transaction instead of joining the caller's. |
| | | this.refreshTransaction = new TransactionTemplate(refreshTransaction.getTransactionManager()); |
| | | this.refreshTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); |
| | | // The provider is an HTTP call: never make payment or onboarding hold a database transaction |
| | | // (and its row locks) while waiting for it. |
| | |
| | | return new Application( |
| | | ((Number) row.get("id")).longValue(), |
| | | ((Number) row.get("merchantId")).longValue(), |
| | | ((Number) row.get("storeId")).longValue(), |
| | | number(row.get("storeId")), |
| | | clean(row.get("status")), |
| | | clean(row.get("providerHuifuId")), |
| | | clean(row.get("merchantStat")), |
| | |
| | | ); |
| | | } |
| | | |
| | | /** 进件的 store_id 允许为空(历史数据):这里不能直接拆箱,否则 NullPointerException 会冒到调用方。 */ |
| | | private static long number(Object value) { |
| | | return value instanceof Number number ? number.longValue() : 0L; |
| | | } |
| | | |
| | | private List<Map<String, Object>> rows(String sql, Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql, args)); } |
| | | |
| | | private String clean(Object value) { return value == null ? "" : String.valueOf(value).trim(); } |
| | |
| | | String status = HuifuPayload.value(data, "trans_stat", "transStat"); |
| | | String code = HuifuPayload.code(data); |
| | | String message = HuifuPayload.message(data); |
| | | String providerNo = HuifuPayload.value(data, "hf_seq_id", "hfSeqId"); |
| | | // 支付查询回执里通道全局流水号的字段是 org_hf_seq_id;hf_seq_id 只出现在异步通知里。 |
| | | // 这里保留 hf_seq_id 兜底,避免通道换字段名后又被静默丢成 NULL。 |
| | | String providerNo = HuifuPayload.value(data, "org_hf_seq_id", "orgHfSeqId", "hf_seq_id", "hfSeqId"); |
| | | String huifuId = HuifuPayload.value(data, "huifu_id", "huifuId"); |
| | | TransStatus classification = HuifuPayload.status(status); |
| | | boolean success = classification == TransStatus.SUCCESS && HuifuPayload.successCode(code); |
| | |
| | | |
| | | try { |
| | | jdbc.update(""" |
| | | insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, merchant_id, order_type, |
| | | insert into benefit_order(order_no, talent_id, card_id, card_level_snapshot, merchant_id, store_id, order_type, |
| | | order_amount, subsidy_amount, base_pay_amount, pay_amount, |
| | | coupon_id, coupon_amount, expires_at, price_version, |
| | | merchant_name_snapshot, merchant_address_snapshot, merchant_mobile_snapshot, |
| | |
| | | product_id, product_name_snapshot, product_cover_url_snapshot, |
| | | collect_onboarding_id, collect_store_id, collect_huifu_id, |
| | | order_status, verify_status) |
| | | values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| | | values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| | | """, |
| | | orderNo, |
| | | talentId, |
| | | talentCard.get("id"), |
| | | talentCard.get("cardLevel"), |
| | | request.merchantId(), |
| | | // 订单的门店必须落库:日汇总按「商户 + 门店」出账(benefit_order.store_id), |
| | | // 这里为空的话这笔已核销订单永远汇总不到,也就永远不会生成提现记录。 |
| | | MerchantPaymentReadinessService.CollectingMerchant.storeIdOf(collectingMerchant), |
| | | normalizeOrderType(product.get("productType")), |
| | | originalAmount, |
| | | subsidyAmount, |
| | |
| | | |
| | | /** |
| | | * 每日自动提现汇总 seam:按北京时间统计前一个自然日;只汇总已核销、未退款且未进入其它提现记录的订单; |
| | | * 同一商户生成一条日汇总记录并内部保存来源订单关联;最低金额和手续费缓冲不足时顺延;重复执行不重复出账。 |
| | | * 同一商户生成一条日汇总记录并内部保存来源订单关联;不设最低金额(当天有已核销订单就出账);重复执行不重复出账。 |
| | | * |
| | | * <p>任务按设计会扫描全部商户,而多个 Spring 测试共用同一个不清空的 schema,因此断言只看本用例自己创建的 |
| | | * 商户与业务日;每个用例的业务日也互不相同。 |
| | |
| | | } |
| | | |
| | | @Test |
| | | void anAmountBelowTheMinimumIsDeferredToTheNextRun() { |
| | | void aTinyAmountIsStillAggregatedBecauseThereIsNoMinimumAnyMore() { |
| | | LocalDate businessDate = someBusinessDate(); |
| | | Long small = verifiedOrder(businessDate, 500); |
| | | // 斗拱主动取现没有最低金额,取现也不收手续费,所以再小的金额也要出账,不再顺延。 |
| | | Long tiny = verifiedOrder(businessDate, 1); |
| | | |
| | | service.run(businessDate); |
| | | |
| | | assertThat(dailyWithdrawalOrNull(businessDate)).isNull(); |
| | | assertThat(lockedOrderIds()).isEmpty(); |
| | | |
| | | // 第二天新增一笔订单,两天的金额一起顺延到下一次汇总。 |
| | | Long later = verifiedOrder(businessDate.plusDays(1), 1500); |
| | | service.run(businessDate.plusDays(1)); |
| | | |
| | | assertThat(dailyWithdrawal(businessDate.plusDays(1))).containsEntry("amount_cents", 2000L); |
| | | assertThat(sourceOrderIds(businessDate.plusDays(1))).contains(small, later); |
| | | assertThat(lockedOrderIds()).containsExactlyInAnyOrder(small, later); |
| | | } |
| | | @Test |
| | | void anAmountThatWouldNotCoverTheFeeBufferIsDeferred() { |
| | | AutoWithdrawalProperties properties = new AutoWithdrawalProperties(); |
| | | properties.setMinAmountCents(1000); |
| | | properties.setFeeBufferCents(500); |
| | | AutoWithdrawalAggregationService buffered = new AutoWithdrawalAggregationService( |
| | | jdbc, new OrderFundBoundaryService(jdbc), |
| | | new TransactionTemplate(new DataSourceTransactionManager(jdbc.getDataSource())), |
| | | properties); |
| | | |
| | | LocalDate businessDate = someBusinessDate(); |
| | | Long exact = verifiedOrder(businessDate, 1200); |
| | | |
| | | buffered.run(businessDate); |
| | | |
| | | // 1200 分虽然过了最低金额,但扣掉 500 分缓冲后不足最低金额,仍然顺延。 |
| | | assertThat(dailyWithdrawalOrNull(businessDate)).isNull(); |
| | | assertThat(lockedOrderIds()).isEmpty(); |
| | | |
| | | Long extra = verifiedOrder(businessDate, 300); |
| | | buffered.run(businessDate); |
| | | |
| | | assertThat(dailyWithdrawal(businessDate)).containsEntry("amount_cents", 1500L); |
| | | assertThat(sourceOrderIds(businessDate)).contains(exact, extra); |
| | | assertThat(lockedOrderIds()).containsExactlyInAnyOrder(exact, extra); |
| | | assertThat(dailyWithdrawal(businessDate)).containsEntry("amount_cents", 1L); |
| | | assertThat(sourceOrderIds(businessDate)).containsExactly(tiny); |
| | | assertThat(lockedOrderIds()).containsExactly(tiny); |
| | | } |
| | | |
| | | @Test |
| | |
| | | assertThat(sourceOrderIds(businessDate)).containsExactlyInAnyOrder(first, second); |
| | | assertThat(lockedOrderIds()).containsExactlyInAnyOrder(first, second); |
| | | assertThat(lockedOrderIds()).doesNotContain(late); |
| | | } |
| | | |
| | | /** |
| | | * 通道当时说不可取(例如 DM 额度随账户可取资金浮动),钱到账后必须还能再出一次账。 |
| | | * |
| | | * <p>失败时来源订单被放回可汇总范围,所以这一业务日再次汇总必须复用那条失败记录:状态退回 |
| | | * {@code pending`、金额更新。若这里仍然按「已汇总」跳过,这笔钱就再没有任何出账路径。 |
| | | */ |
| | | @Test |
| | | void aFailedDailyWithdrawalIsRetriedOnceTheFundsBecomeAvailable() { |
| | | LocalDate businessDate = someBusinessDate(); |
| | | Long first = verifiedOrder(businessDate, 2000); |
| | | service.run(businessDate); |
| | | |
| | | // 通道拒绝这笔出账。失败时 AutoWithdrawalResultService.releaseSourceOrders 会同时做两件事: |
| | | // 给关联打 released_at、并解锁订单的 withdrawal_status,这里按同样效果模拟(本用例只覆盖汇总侧)。 |
| | | Map<String, Object> withdrawal = dailyWithdrawal(businessDate); |
| | | String idempotencyKey = (String) withdrawal.get("idempotency_key"); |
| | | long withdrawalId = ((Number) withdrawal.get("id")).longValue(); |
| | | jdbc.update("update merchant_withdrawal set status = 'failed', failure_reason = ? where id = ?", |
| | | "DM取现额度不足", withdrawalId); |
| | | jdbc.update("update merchant_withdrawal_order set released_at = now() where withdrawal_id = ?", withdrawalId); |
| | | jdbc.update("update benefit_order set withdrawal_status = 0 where id = ?", first); |
| | | |
| | | // 失败之后又核销了一笔,两笔都应当进入同一条重试记录。 |
| | | Long second = verifiedOrder(businessDate, 3000); |
| | | service.run(businessDate); |
| | | |
| | | assertThat(dailyWithdrawalCount(businessDate)).as("失败重试不会新建第二条日汇总记录").isEqualTo(1); |
| | | Map<String, Object> retried = dailyWithdrawal(businessDate); |
| | | assertThat(retried).containsEntry("status", "pending"); |
| | | assertThat(retried).containsEntry("amount_cents", 5000L); |
| | | assertThat(retried).containsEntry("idempotency_key", idempotencyKey); |
| | | assertThat(sourceOrderIds(businessDate)).containsExactlyInAnyOrder(first, second); |
| | | assertThat(lockedOrderIds()).containsExactlyInAnyOrder(first, second); |
| | | } |
| | | |
| | | @Test |
| | |
| | | |
| | | service.run(businessDate); |
| | | |
| | | // 另一个门店只有 500 分,低于最低金额,它的订单不会被主门店的汇总带走。 |
| | | // 两个门店各出一条日汇总:主门店只有主订单,另一个门店的 500 分单独出账(已无最低金额), |
| | | // 谁也不会把对方的订单带走。 |
| | | assertThat(dailyWithdrawalCountForStore(businessDate, storeId)).isEqualTo(1); |
| | | assertThat(dailyWithdrawalCountForStore(businessDate, otherStoreId)).isEqualTo(1); |
| | | assertThat(dailyWithdrawal(businessDate)).containsEntry("amount_cents", 2000L).containsEntry("store_id", storeId); |
| | | assertThat(sourceOrderIds(businessDate)).containsExactly(mainOrder); |
| | | assertThat(lockedOrderIds()).containsExactly(mainOrder).doesNotContain(otherOrder); |
| | | assertThat(lockedOrderIds()).containsExactlyInAnyOrder(mainOrder, otherOrder); |
| | | } |
| | | |
| | | @Test |
| | |
| | | void aProviderQuerySettlesThePaymentAndVerifiesStoreConsumption() { |
| | | String orderNo = createOrder("STORE_CONSUME"); |
| | | String paymentNo = insertPendingPayment(orderNo, 6000L, huifuId); |
| | | // 支付查询回执里通道全局流水号的字段是 org_hf_seq_id(hf_seq_id 只出现在异步通知里)。 |
| | | // 用真实字段名,才能保证这条断言真的守住落库行为。 |
| | | when(paymentGateway.queryMiniProgramPayment(any())).thenReturn(providerResponse(Map.of( |
| | | "trans_stat", "S", "resp_code", "00000000", "trans_amt", "60.00", |
| | | "huifu_id", huifuId, "hf_seq_id", "HF-QUERY-1"))); |
| | | "huifu_id", huifuId, "org_hf_seq_id", "HF-QUERY-1"))); |
| | | |
| | | Map<String, Object> result = reconciliationService.reconcile(5); |
| | | |
| | |
| | | .containsEntry("order_status", 1) |
| | | .containsEntry("verify_status", 1); |
| | | assertThat(jdbc.queryForObject("select status from payment_transaction where payment_no = ?", String.class, paymentNo)).isEqualTo("SUCCEEDED"); |
| | | assertThat(jdbc.queryForObject("select provider_transaction_no from payment_transaction where payment_no = ?", String.class, paymentNo)) |
| | | .as("查询落账必须把通道全局流水号写进 provider_transaction_no,否则退款和排查都失去定位依据") |
| | | .isEqualTo("HF-QUERY-1"); |
| | | } |
| | | |
| | | @Test |