feat(merchant): 分阶段开通对所有商户生效
放开 04 保留的那道灰度门槛:supports() 从「huifu_id 等于某张指定商户」改为
「已关联结算银行卡且未被整体拒绝」。新商户从此走进同一套分阶段视图。
这是策略变更而非清理,连带处理三件事:
- 基本资料被通道驳回时落到 rejected。分阶段刷新原来只会写 pending/approved,
永远不会写 rejected——那样审核被拒的商户拿不到「重新提交」入口(supports() 对
rejected 返回 false 正是为了让出旧模型的那个入口)。后续配置失败不算拒绝,
仍然保留原商户号和银行卡做局部重试。
- refresh 对不支持的申请直接返回视图,不接管。否则一条迟到或重复的刷新会把它从
rejected 翻回 pending,重提入口随之消失。
- 视图的 status 不再只看阶段计算:已 rejected 的申请显示 rejected 并带通道给的
驳回原因,不能被阶段计算盖成 pending。
测试随之改写——原来那 5 项断言的是「通道配置失败就释放银行卡、让商户整单重来」的
旧策略,与规格「基本进件成功后的局部失败保留原商户号和银行卡」相悖:
- MerchantOnboardingRetryReleaseTests:改为断言进件与银行卡都被保留、失败的步骤由
刷新重试;新增一项钉住「被拒绝的申请交回旧模型」。
- MerchantSettlementCallbackIdempotencyTests:回调现在只触发刷新、状态由通道证据
推导,幂等性来自刷新的可重复性;token 也不再由回调写入,而是刷新时按卡号 hash
与通道卡列表核对得出。三项断言相应改写,保证本身(重复通知不改变状态、迟到通知
不降级、token 不被覆盖)都保留。
验证:全量 214 项,仅 docs/agents/testing.md 所列 3 项基线失败,无新增。
| | |
| | | /** Six independent evidence-backed stages; provider calls never hold application row locks. */ |
| | | @Service |
| | | public class MerchantOnboardingProgressService { |
| | | /** 分阶段进度的灰度门槛;03 票把它换成通用规则时一并删除。 */ |
| | | public static final String RECOVERY_HUIFU_ID = "6666000241335656"; |
| | | private static final Map<String,String> LABELS = new LinkedHashMap<>(); |
| | | static { |
| | | LABELS.put("basic", "基础审核"); LABELS.put("wechat", "微信支付"); |
| | |
| | | public MerchantOnboardingProgressService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway gateway) { this.jdbc=jdbc; this.gateway=gateway; } |
| | | |
| | | /** |
| | | * 分阶段进度当前只对指定存量商户放行。 |
| | | * 分阶段进度适用于「已关联结算银行卡、且未被整体拒绝」的申请。 |
| | | * |
| | | * <p><b>这仍是一道灰度门槛,不是通用规则。</b>把判断换成「有结算卡且未被拒绝」会同时给所有商户 |
| | | * 切换行为:进件失败不再释放银行卡(`MerchantOnboardingRetryReleaseTests`)、回调改走分阶段刷新 |
| | | * (`MerchantSettlementCallbackIdempotencyTests`),共 5 个测试会红。那是策略变更,属于 03 票 |
| | | * 「分阶段手动推进 + 局部失败保留原商户号和银行卡」,不在 04 的清理范围内。 |
| | | * <p>原先这里按 huifu_id 白名单只放行一张指定存量商户,新商户走不进这套视图。现在按申请自身的 |
| | | * 状态判断:银行卡关联是分阶段视图核实原卡的前提({@code application()} 也用它做条件); |
| | | * 而被拒绝的申请仍留在旧的单状态模型,商户端才有重新提交的入口。 |
| | | * |
| | | * <p>这一条同时决定「进件失败后不重建商户与银行卡」的适用范围:支持的申请走局部刷新与重试, |
| | | * 不支持的才允许关闭重来。旧策略里「通道配置失败就释放银行卡、让商户整单重提」已被规格 |
| | | * 明确取代——基本进件成功后要保留原商户号和银行卡。 |
| | | */ |
| | | public boolean supports(Map<String,Object> application) { return RECOVERY_HUIFU_ID.equals(text(application.get("providerHuifuId"))); } |
| | | public boolean supports(Map<String,Object> application) { |
| | | return application.get("initialBankCardId") != null && !"rejected".equals(text(application.get("status"))); |
| | | } |
| | | |
| | | public Map<String,Object> view(Long id) { return view(application(id)); } |
| | | |
| | | @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED) |
| | | public Map<String,Object> refresh(Long applicationId, boolean retry) { |
| | | Map<String,Object> application=application(applicationId); |
| | | // 被拒绝的申请不归分阶段模型管:刷下去会把它从 rejected 翻回 pending,商户端的 |
| | | // 「重新提交」入口就没了(supports() 对 rejected 返回 false,正是为了让出那个入口)。 |
| | | if (!supports(application)) return view(application); |
| | | jdbc.update("insert ignore into merchant_onboarding_progress(application_id,stages_json) values (?, '{}')", applicationId); |
| | | String owner=UUID.randomUUID().toString(); |
| | | if(jdbc.update("update merchant_onboarding_progress set sync_owner=?,sync_until=date_add(now(),interval 5 minute) where application_id=? and (sync_owner is null or sync_until<now())", owner, applicationId)==0) return view(application); |
| | |
| | | advanceDelay(applicationId,text(application.get("providerHuifuId")),owner,stages,retry); |
| | | } |
| | | jdbc.update("update merchant_onboarding_progress set stages_json=? where application_id=? and sync_owner=?",JSON.toJSONString(stages),applicationId,owner); |
| | | // 基本资料被通道驳回时要落到 rejected:一是让商户端出现「重新提交」入口(supports() 对 |
| | | // rejected 返回 false,交回旧的单状态模型),二是别把一次明确拒绝一直显示成「审核中」。 |
| | | // 后续配置失败不算拒绝——那种情况保留原商户号和银行卡,局部重试即可。 |
| | | boolean basicRejected = "failed".equals(text(stages.get("basic"))); |
| | | String applicationStatus = basicRejected ? "rejected" : complete(stages) ? "approved" : "pending"; |
| | | jdbc.update(""" |
| | | update merchant_onboarding_application a join merchant_onboarding_progress p on p.application_id=a.id |
| | | set a.status=?,a.reviewed_at=case when ? then now() else null end |
| | | where a.id=? and p.sync_owner=? |
| | | """,complete(stages) ? "approved" : "pending",complete(stages),applicationId,owner); |
| | | """,applicationStatus,!"pending".equals(applicationStatus),applicationId,owner); |
| | | if (basicRejected) jdbc.update("update merchant_onboarding_application set reject_reason=? where id=?","基本资料审核未通过,请核对资料后重新提交",applicationId); |
| | | } |
| | | } catch (RuntimeException exception) { |
| | | jdbc.update("update merchant_onboarding_progress set sync_error=? where application_id=? and sync_owner=?","查询结果暂未确认,已保留最后有效进度,请稍后刷新",applicationId,owner); |
| | |
| | | progress.add(Map.of("key",entry.getKey(),"label",entry.getValue(),"status",status,"reason",stageReason(entry.getKey(),status), |
| | | "qrCode", "realname".equals(entry.getKey()) ? text(stages.get("realnameQr")) : "")); |
| | | } |
| | | // 被通道明确驳回基本资料的申请仍显示 rejected(并带通道给的驳回原因),不能被阶段计算 |
| | | // 盖成 pending——那样商户端看不到「重新提交」。 |
| | | boolean rejected = "rejected".equals(text(application.get("status"))); |
| | | result.put("stages",progress); result.put("reason","请刷新进度,核实原申请与各项能力"); |
| | | result.put("retryable", "failed".equals(stages.get("delay"))); |
| | | result.put("status",complete(stages) ? "approved" : "pending"); |
| | | result.put("reason",complete(stages) ? "收款能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力,并按待办理项补齐缺失能力"); |
| | | result.put("status",rejected ? "rejected" : complete(stages) ? "approved" : "pending"); |
| | | result.put("reason",rejected ? text(application.get("rejectReason")) |
| | | : complete(stages) ? "收款能力已核实,沿用原商户号和结算银行卡" : "请刷新进度核实各项能力,并按待办理项补齐缺失能力"); |
| | | return result; |
| | | } |
| | | |
| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | |
| | | import static org.mockito.ArgumentMatchers.*; |
| | | import static org.mockito.Mockito.*; |
| | | import java.util.Map; |
| | | |
| | | import com.talentcard.server.business.merchant.auth.MerchantPrincipal; |
| | | import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.boot.test.context.SpringBootTest; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.test.context.ActiveProfiles; |
| | | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| | | |
| | | /** |
| | | * 通道侧配置失败的进件,重新提交时要连银行卡一起收口。 |
| | | * 通道侧配置失败的进件**不再关单重来**:保留原商户号和银行卡,失败的步骤由刷新重试。 |
| | | * |
| | | * <p>只把进件标成 rejected 不够:银行卡校验是独立的一条,`status='pending'` 的卡本身就足以拦住复用, |
| | | * 报「该银行卡已有审核中的进件或已通过的申请,请勿重复提交」。那张卡之所以停在 pending, |
| | | * 正是这条失败的进件留下的——实际就踩到过这个坑,所以两者必须一起收口。 |
| | | * <p>原先的做法是把进件和银行卡一起标成 rejected,好让商户用同一张卡整单重提;规格已明确改成 |
| | | * 「基本进件成功后的局部失败保留原商户号和银行卡」。这类申请现在走分阶段模型, |
| | | * {@code closeRetryableApplication} 对它只刷新进度、不释放任何东西。 |
| | | * |
| | | * <p>反过来,**进行中**的申请(配置还在跑或还没开始)不能被动到,否则正常流程会被重复提交搅乱。 |
| | | * <p>例外是被通道**明确驳回基本资料**的申请:它交回旧的单状态模型({@code supports()} 对 |
| | | * {@code rejected} 返回 false),商户端才拿得到「重新提交」入口。 |
| | | */ |
| | | @SpringBootTest |
| | | @ActiveProfiles("test") |
| | |
| | | |
| | | @Autowired |
| | | private MerchantSettlementService settlementService; |
| | | |
| | | // 分阶段刷新会先推进开通步骤,这几个网关都要挡住,否则测试会真的打到汇付。 |
| | | @MockitoBean private HuifuOnboardingProgressGateway progressGateway; |
| | | @MockitoBean private com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway wechatConfigGateway; |
| | | @MockitoBean private com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway businessOpenGateway; |
| | | |
| | | private Long merchantId; |
| | | private Long storeId; |
| | |
| | | } |
| | | |
| | | @Test |
| | | void aFailedProvisioningReleasesBothTheApplicationAndItsBankCard() { |
| | | void aFailedProvisioningKeepsTheApplicationAndItsBankCard() { |
| | | seedApplication("failed", "not_started"); |
| | | stubProvider(); |
| | | |
| | | settlementService.closeRetryableApplication(principal()); |
| | | |
| | | assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("rejected"); |
| | | // 关键:卡也要放行,否则商户用同一张卡重新提交仍会被拦。 |
| | | assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("rejected"); |
| | | // 关键变化:不再释放。进件与银行卡都留着,失败的开通步骤由刷新去重试—— |
| | | // 释放掉就等于让商户整单重来,与「保留原商户号和银行卡」相悖。 |
| | | assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("pending"); |
| | | assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("pending"); |
| | | } |
| | | |
| | | @Test |
| | | void aFailedBusinessOpenAlsoReleasesTheCard() { |
| | | void aFailedBusinessOpenKeepsTheCardForRetry() { |
| | | seedApplication("approved", "failed"); |
| | | stubProvider(); |
| | | |
| | | settlementService.closeRetryableApplication(principal()); |
| | | |
| | | assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("rejected"); |
| | | assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("rejected"); |
| | | assertThat(status("merchant_onboarding_application", applicationId)).isEqualTo("pending"); |
| | | assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("pending"); |
| | | } |
| | | |
| | | @Test |
| | |
| | | assertThat(status("merchant_bank_card", bankCardId)).isEqualTo("pending"); |
| | | } |
| | | |
| | | /** 被驳回的申请回到旧的单状态模型,刷新不再接管它——否则会被翻回 pending,重提入口消失。 */ |
| | | @Test |
| | | void aRejectedApplicationIsLeftToTheSingleStatusModel() { |
| | | seedApplication("failed", "failed"); |
| | | jdbc.update("update merchant_onboarding_application set status='rejected', reject_reason='基本资料审核未通过' where id=?", applicationId); |
| | | |
| | | Map<String,Object> view = settlementService.refreshProgress(applicationId, false); |
| | | |
| | | assertThat(view).containsEntry("status", "rejected"); |
| | | verifyNoInteractions(progressGateway); |
| | | } |
| | | |
| | | private void stubProvider() { |
| | | when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); |
| | | when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N")); |
| | | when(progressGateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000")); |
| | | } |
| | | |
| | | private void seedApplication(String wechatConfigStatus, String businessOpenStatus) { |
| | | String applicationNo = "MO-RT-" + suffix; |
| | | String applicationNo = "MO-RT-" + suffix + "-" + wechatConfigStatus; |
| | | jdbc.update(""" |
| | | insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, |
| | | contact_name, contact_mobile, idempotency_key, status, |
| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | import static org.mockito.ArgumentMatchers.anyString; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.util.HashMap; |
| | | import java.util.Map; |
| | |
| | | import org.springframework.boot.test.context.SpringBootTest; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.test.context.ActiveProfiles; |
| | | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| | | |
| | | /** Duplicated and out-of-order Huifu callbacks must not corrupt an already decided onboarding. */ |
| | | @SpringBootTest |
| | |
| | | |
| | | @Autowired |
| | | private MerchantSettlementService settlementService; |
| | | |
| | | private static final String HUIFU_ID = "6666000000000002"; |
| | | |
| | | // 回调现在触发分阶段刷新,刷新又会先推进开通步骤——这几个网关都要挡住,否则测试会打到汇付。 |
| | | @MockitoBean private com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway progressGateway; |
| | | @MockitoBean private com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway wechatConfigGateway; |
| | | @MockitoBean private com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway businessOpenGateway; |
| | | |
| | | private Long merchantId; |
| | | private Long storeId; |
| | |
| | | storeId = jdbc.queryForObject("select max(id) from merchant_store", 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) |
| | | values (?, ?, ?, 'individual', 'Callback owner', 'Callback owner', '13700000000', ?, 'pending') |
| | | """, merchantId, storeId, applicationNo, "callback-onboarding-" + suffix); |
| | | contact_name, contact_mobile, idempotency_key, status, provider_huifu_id) |
| | | values (?, ?, ?, 'individual', 'Callback owner', 'Callback owner', '13700000000', ?, 'pending', ?) |
| | | """, merchantId, storeId, applicationNo, "callback-onboarding-" + suffix, HUIFU_ID); |
| | | applicationId = jdbc.queryForObject("select max(id) from merchant_onboarding_application", Long.class); |
| | | jdbc.update(""" |
| | | insert into merchant_bank_card(merchant_id, store_id, bank_name, branch_name, city_code, card_tail, card_masked, |
| | |
| | | jdbc.update("update merchant_onboarding_application set initial_bank_card_id=? where id=?", bankCardId, applicationId); |
| | | } |
| | | |
| | | /** |
| | | * 重复的审核回调不会改变状态。 |
| | | * |
| | | * <p>分阶段模型下回调不再直接写状态,而是触发一次进度刷新——状态由**通道证据**推导。所以幂等性 |
| | | * 不再来自「只处理未决申请」,而是来自「刷新是只读且可重复的」;而一旦刷新把基本资料判成被驳回, |
| | | * 申请就交回旧的单状态模型,后续重复回调被那条「已决定就不再动」的守卫挡掉。 |
| | | */ |
| | | @Test |
| | | void repeatedAuditCallbacksKeepTheSameOnboardingState() { |
| | | stubBasicAudit("N", "N"); |
| | | Map<String, Object> callback = new HashMap<>(); |
| | | callback.put("req_seq_id", applicationNo); |
| | | callback.put("huifu_id", HUIFU_ID); |
| | | callback.put("audit_status", "N"); |
| | | callback.put("sub_resp_code", "10000001"); |
| | | callback.put("audit_desc", "证照不清晰"); |
| | |
| | | assertThat(first).containsEntry("status", "rejected"); |
| | | assertThat(first.get("rejectReason")).isNotNull(); |
| | | |
| | | // The provider retries the identical notification before the late approval arrives. |
| | | assertThat(settlementService.handleOnboardingCallback(new HashMap<>(callback))).isTrue(); |
| | | assertThat(onboardingState()).isEqualTo(first); |
| | | } |
| | | |
| | | /** 一条迟到的失败通知不能把通道已确认的结果降级:状态以刷新时的通道证据为准。 */ |
| | | @Test |
| | | void aLateNotificationCannotOverwriteADecidedOnboarding() { |
| | | jdbc.update("update merchant_onboarding_application set status='approved', provider_huifu_id='6666000000000001', reject_reason='' where id=?", applicationId); |
| | | void aLateNotificationCannotOverwriteAnOnboardingConfirmedByTheChannel() { |
| | | stubBasicAudit("Y", "S"); |
| | | |
| | | Map<String, Object> lateRejection = new HashMap<>(); |
| | | lateRejection.put("req_seq_id", applicationNo); |
| | | lateRejection.put("huifu_id", HUIFU_ID); |
| | | lateRejection.put("audit_status", "N"); |
| | | lateRejection.put("sub_resp_code", "10000001"); |
| | | lateRejection.put("audit_desc", "证照不清晰"); |
| | | assertThat(settlementService.handleOnboardingCallback(lateRejection)).isTrue(); |
| | | |
| | | assertThat(onboardingState()) |
| | | .containsEntry("status", "approved") |
| | | .containsEntry("providerHuifuId", "6666000000000001") |
| | | .containsEntry("rejectReason", ""); |
| | | Map<String, Object> state = onboardingState(); |
| | | assertThat(state.get("status")).isNotEqualTo("rejected"); |
| | | assertThat(java.util.Objects.toString(state.get("rejectReason"), "")).isEmpty(); |
| | | } |
| | | |
| | | /** |
| | | * 绑卡结果以通道查询为准,重复的通过通知不会用另一个 token 覆盖已经生效的那个。 |
| | | * |
| | | * <p>分阶段模型下 token 由刷新时的商户详情核对得出(卡号 hash 与 {@code qry_cash_card_info_list} |
| | | * 匹配上才落 token),不再由回调直接写入——回调只负责触发刷新。 |
| | | */ |
| | | @Test |
| | | void anApprovalCallbackBindsTheTokenOnlyOnce() { |
| | | void aRepeatedApprovalCannotReplaceTheTokenConfirmedByTheChannel() { |
| | | stubBasicAudit("Y", "S"); |
| | | // 卡的核对要以通道返回的绑卡状态为前提:bind_card_status=S 才会去比对卡号 hash。 |
| | | when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_status","S")); |
| | | jdbc.update("update merchant_bank_card set card_hash=? where id=?", sha256("622200001234"), bankCardId); |
| | | when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N", |
| | | "qry_cash_card_info_list",java.util.List.of(Map.of("card_no","622200001234","token_no","TOKEN-0001")), |
| | | "qry_settle_config_list",java.util.List.of(Map.of("settle_status","1","settle_cycle","D1","settle_pattern","P0","min_amt","0","remained_amt","0","token_no","TOKEN-0001")))); |
| | | |
| | | Map<String, Object> approval = new HashMap<>(); |
| | | approval.put("req_seq_id", applicationNo); |
| | | approval.put("huifu_id", HUIFU_ID); |
| | | approval.put("audit_status", "Y"); |
| | | approval.put("sub_resp_code", "00000000"); |
| | | approval.put("resp_desc", "成功"); |
| | | approval.put("huifu_id", "6666000000000002"); |
| | | approval.put("apply_no", "APPLY-0001"); |
| | | approval.put("token_no", "TOKEN-0001"); |
| | | |
| | | approval.put("token_no", "TOKEN-9999"); |
| | | assertThat(settlementService.handleOnboardingCallback(approval)).isTrue(); |
| | | assertThat(onboardingState()) |
| | | .containsEntry("providerHuifuId", "6666000000000002") |
| | | .containsEntry("status", "pending"); |
| | | assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-0001"); |
| | | |
| | | // A duplicated approval carrying a different token must not replace the stored token. |
| | | Map<String, Object> repeated = new HashMap<>(approval); |
| | | repeated.put("token_no", "TOKEN-9999"); |
| | | repeated.put("token_no", "TOKEN-8888"); |
| | | assertThat(settlementService.handleOnboardingCallback(repeated)).isTrue(); |
| | | |
| | | assertThat(cardState()).containsEntry("status", "approved").containsEntry("providerTokenNo", "TOKEN-0001"); |
| | | } |
| | | |
| | | private void stubBasicAudit(String applyStatus, String regStatus) { |
| | | when(progressGateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status",applyStatus,"reg_status",regStatus)); |
| | | when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N")); |
| | | when(progressGateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000")); |
| | | } |
| | | |
| | | private static String sha256(String value) { |
| | | try { return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8))); } |
| | | catch (java.security.NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } |
| | | } |
| | | |
| | | @Test |
| | | void cardBindingCallbackOnlyMovesAPendingCardOnce() { |
| | | String requestNo = jdbc.queryForObject("select provider_request_no from merchant_bank_card where id=?", String.class, bankCardId); |