| | |
| | | private final HuifuPaymentProperties huifuPaymentProperties; |
| | | private final HuifuPictureUploadGateway pictureUploadGateway; |
| | | private final MerchantPaymentReadinessService paymentReadinessService; |
| | | @org.springframework.beans.factory.annotation.Autowired |
| | | private MerchantOnboardingProgressService onboardingProgressService; |
| | | |
| | | public Map<String, Object> refreshOnboarding(MerchantPrincipal principal, boolean retry) { |
| | | store(principal); |
| | | List<Map<String,Object>> records=rows("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",principal.merchantId(),principal.storeId()); |
| | | if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found"); |
| | | return onboardingProgressService.refresh(((Number)records.get(0).get("id")).longValue(),retry); |
| | | } |
| | | |
| | | public MerchantSettlementService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, HuifuIndividualOnboardingGateway individualOnboardingGateway, HuifuEnterpriseOnboardingGateway enterpriseOnboardingGateway, HuifuMerchantWechatConfigGateway merchantWechatConfigGateway, HuifuMerchantBusinessOpenGateway merchantBusinessOpenGateway, HuifuUserBusinessOpenGateway userBusinessOpenGateway, HuifuPaymentProperties huifuPaymentProperties, HuifuPictureUploadGateway pictureUploadGateway, MerchantPaymentReadinessService paymentReadinessService) { |
| | | this.jdbc = jdbc; |
| | |
| | | List<Map<String, Object>> repeated = rows("select * from merchant_onboarding_application where merchant_id = ? and idempotency_key = ? limit 1", principal.merchantId(), key.trim()); |
| | | if (!repeated.isEmpty()) return onboarding(repeated.get(0)); |
| | | List<Map<String, Object>> active = rows("select * from merchant_onboarding_application where merchant_id = ? and store_id = ? and initial_bank_card_id is not null and status in ('pending','approved') order by id desc limit 1", principal.merchantId(), principal.storeId()); |
| | | if (!active.isEmpty()) return onboarding(active.get(0)); |
| | | if (!active.isEmpty() && !retryable(active.get(0))) return onboarding(active.get(0)); |
| | | boolean enterprise = "enterprise".equals(request.subjectType()); |
| | | if (enterprise && (request.creditCode() == null || !request.creditCode().trim().matches("[0-9A-Z]{18}"))) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入正确的18位统一社会信用代码"); |
| | | if (request.subjectIdNumber() == null || !request.subjectIdNumber().trim().matches("(?:\\d{15}|\\d{17}[0-9X])")) throw new BusinessException(ErrorCode.BAD_REQUEST, enterprise ? "请输入正确的法人身份证号" : "请输入正确的负责人身份证号"); |
| | |
| | | if (branchRows.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请选择真实开户行"); |
| | | Map<String, Object> branch = branchRows.get(0); |
| | | if (clean(branch.get("headBankCode")).isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "所选开户行缺少汇付银行编码"); |
| | | // 上一次进件在通道侧已经明确失败(典型:本地开发没有公网 IP,收不到回调,商户配置报权限错), |
| | | // 这条记录会永远停在 pending。此时必须让商户能用同一张卡重新提交: |
| | | // 把旧进件标成 rejected,既解开下面「银行卡已有审核中/已通过进件」的占用, |
| | | // 也让按 store 查 active 进件的逻辑不再把它当成进行中的申请。 |
| | | closeRetryableApplication(principal); |
| | | String no = BusinessIdGenerator.next("MO"); |
| | | String certificateValidityType = certificateValidityType(request.subjectCertificateValidityType()); |
| | | String certificateBeginDate = certificateBeginDate(request.subjectCertificateBeginDate()); |
| | |
| | | privateSettlement ? "1" : "0", privateSettlement ? request.subjectName().trim() : request.merchantName().trim(), request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), clean(branch.get("clearingCode")), clean(branch.get("branchName")), |
| | | privateSettlement ? request.subjectIdNumber().trim().toUpperCase() : "", privateSettlement ? certificateValidityType(request.subjectCertificateValidityType()) : "", privateSettlement ? certificateBeginDate(request.subjectCertificateBeginDate()) : "", privateSettlement ? certificateEndDate(request.subjectCertificateEndDate()) : "", privateSettlement ? request.contactMobile().trim() : "", |
| | | registrationAccountPictureId, settlementCardPictureId, settlementCertFrontPictureId, settlementCertBackPictureId, |
| | | certificateBeginDate(request.establishedDate()), normalizeRegisteredCapital(request.companyType(), request.registeredCapital()), |
| | | huifuPaymentProperties.getOnboardingParentHuifuId())); |
| | | certificateBeginDate(request.establishedDate()), normalizeRegisteredCapital(request.companyType(), request.registeredCapital()))); |
| | | applyOnboardingResult(applicationNo, bankCardId, result.accepted(), result.huifuId(), result.applyNo(), result.tokenNo(), result.code(), result.message()); |
| | | } catch (HuifuPaymentGatewayException exception) { |
| | | jdbc.update("update merchant_onboarding_application set reject_reason=? where application_no=? and status='pending'", "Huifu submission outcome is pending confirmation", applicationNo); |
| | |
| | | HuifuIndividualOnboardingGateway.Result result = individualOnboardingGateway.create(new HuifuIndividualOnboardingCommand( |
| | | applicationNo, request.subjectName().trim(), request.subjectIdNumber().trim().toUpperCase(), certificateValidityType(request.subjectCertificateValidityType()), certificateBeginDate(request.subjectCertificateBeginDate()), certificateEndDate(request.subjectCertificateEndDate()), request.contactMobile().trim(), clean(request.registeredAddress()), request.administratorEmail().trim(), |
| | | mcc(principal.merchantId()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), legalFrontPictureId, legalBackPictureId, |
| | | request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), settlementCardPictureId, |
| | | huifuPaymentProperties.getOnboardingParentHuifuId())); |
| | | request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), settlementCardPictureId)); |
| | | applyOnboardingResult(applicationNo, bankCardId, result.accepted(), result.huifuId(), result.applyNo(), result.tokenNo(), result.code(), result.message()); |
| | | } catch (HuifuPaymentGatewayException exception) { |
| | | jdbc.update("update merchant_onboarding_application set reject_reason=? where application_no=? and status='pending'", "Huifu submission outcome is pending confirmation", applicationNo); |
| | |
| | | jdbc.update("update merchant_onboarding_application set status='pending', wechat_config_status='failed', wechat_config_response_code=?, wechat_config_response_message=?, reject_reason=?, reviewed_at=null where id=?", code, message, "微信商户配置失败:" + reason, application.get("id")); |
| | | } else { |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status='approved', wechat_config_response_code=?, wechat_config_response_message=?, reject_reason=null where id=?", code, message, application.get("id")); |
| | | continuePostOnboardingProvisioning(clean(application.get("applicationNo"))); |
| | | continueProvisioningAfterCommit(clean(application.get("applicationNo"))); |
| | | } |
| | | return true; |
| | | } |
| | |
| | | jdbc.update("update merchant_onboarding_application set status='pending', business_open_status='failed', business_open_apply_no=coalesce(nullif(?,''),business_open_apply_no), business_open_response_code=?, business_open_response_message=?, reject_reason=?, reviewed_at=null where id=?", applyNo, code, message, "商户业务开通失败:" + reason, application.get("id")); |
| | | } else if ("S".equalsIgnoreCase(businessStatus)) { |
| | | jdbc.update("update merchant_onboarding_application set business_open_status='approved', business_open_apply_no=coalesce(nullif(?,''),business_open_apply_no), business_open_response_code=?, business_open_response_message=?, reject_reason=null where id=?", applyNo, code, message, application.get("id")); |
| | | completePostOnboardingProvisioning(application.get("id")); |
| | | completeProvisioningAfterCommit(application.get("id")); |
| | | } else { |
| | | jdbc.update("update merchant_onboarding_application set business_open_status='processing', business_open_apply_no=coalesce(nullif(?,''),business_open_apply_no), business_open_response_code=?, business_open_response_message=? where id=?", applyNo, code, message, application.get("id")); |
| | | } |
| | |
| | | Long merchantId = ((Number) application.get("merchantId")).longValue(); |
| | | jdbc.update("update merchant_onboarding_application set status='pending', business_open_status='processing', business_open_request_no=?, reject_reason=null, reviewed_at=null where id=?", requestNo, application.get("id")); |
| | | try { |
| | | HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId)); |
| | | HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId), "individual".equalsIgnoreCase(clean(application.get("subjectType")))); |
| | | if (!result.accepted()) { |
| | | jdbc.update("update merchant_onboarding_application set business_open_status='failed', business_open_apply_no=nullif(?,''), business_open_response_code=?, business_open_response_message=?, reject_reason=? where id=?", clean(result.applyNo()), result.code(), result.message(), "商户业务开通失败:" + providerReason(result.code(), result.message()), application.get("id")); |
| | | return; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 把「继续做后续配置」推迟到当前事务提交之后。 |
| | | * |
| | | * <p>两个回调都是先 {@code select ... for update} 锁住进件记录,再走到后续配置;而后续配置会 |
| | | * **同步调用汇付的 HTTP 接口**(微信小程序配置、商户业务开通)。在事务里调外部接口意味着: |
| | | * 拿着这条进件记录的行锁和一条数据库连接,去等一个不受控的网络响应。慢一点就把连接池占住, |
| | | * 进而让**整张 merchant_onboarding_application 上的查询全部排队**——2026-09-15 后台全站 |
| | | * 不可用就是这个形态(一个空闲未提交事务持着元数据锁,DDL 无限等锁,10 条连接被借光)。 |
| | | * |
| | | * <p>提交后再做还有第二个好处:回调只负责把通道结果落库,后续配置自己有重试入口 |
| | | * (业务开通回调、主动轮询),不会被回调事务的成败牵连。 |
| | | */ |
| | | private void continueProvisioningAfterCommit(String applicationNo) { |
| | | runAfterCommit(() -> continuePostOnboardingProvisioning(applicationNo)); |
| | | } |
| | | |
| | | private void completeProvisioningAfterCommit(Object applicationId) { |
| | | runAfterCommit(() -> completePostOnboardingProvisioning(applicationId)); |
| | | } |
| | | |
| | | /** 有活动事务就注册 afterCommit,否则立即执行(例如被非事务入口直接调用)。 */ |
| | | private void runAfterCommit(Runnable task) { |
| | | if (!TransactionSynchronizationManager.isSynchronizationActive()) { |
| | | task.run(); |
| | | return; |
| | | } |
| | | TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { |
| | | @Override |
| | | public void afterCommit() { |
| | | task.run(); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 进件审核通过后补一次收款能力快照。 |
| | | * |
| | | * <p>必须在当前事务提交之后再刷新:能力快照用 {@code REQUIRES_NEW} 在独立事务里写,而微信商户配置 |
| | |
| | | * {@code CannotAcquireLockException} 失败,快照永远补不上,商户虽然进件通过却始终无法收款。 |
| | | */ |
| | | private void refreshCapabilityAfterCommit(Long applicationId) { |
| | | if (!TransactionSynchronizationManager.isSynchronizationActive()) { |
| | | paymentReadinessService.refreshQuietly(applicationId); |
| | | return; |
| | | } |
| | | TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { |
| | | @Override |
| | | public void afterCommit() { |
| | | paymentReadinessService.refreshQuietly(applicationId); |
| | | } |
| | | }); |
| | | runAfterCommit(() -> paymentReadinessService.refreshQuietly(applicationId)); |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | |
| | | private String provisioningRequestNo(String applicationNo, String suffix) { String value = applicationNo + suffix; return value.length() <= 32 ? value : value.substring(0, 32); } |
| | | private void requireKey(String key) { if (key == null || key.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少幂等键"); } |
| | | private void store(MerchantPrincipal principal) { if (rows("select id from merchant_store where id = ? and merchant_id = ? and status = 1 and deleted = 0", principal.storeId(), principal.merchantId()).isEmpty()) throw new BusinessException(ErrorCode.FORBIDDEN, "当前门店不可操作"); } |
| | | private Map<String, Object> onboarding(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "applicationNo", row.get("applicationNo"), "status", row.get("status"), "subjectType", clean(row.get("subjectType")), "reason", clean(row.get("rejectReason")), "submittedAt", String.valueOf(row.get("createdAt"))); } |
| | | private Map<String, Object> onboarding(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "applicationNo", row.get("applicationNo"), "status", row.get("status"), "subjectType", clean(row.get("subjectType")), "reason", clean(row.get("rejectReason")), "retryable", retryable(row), "submittedAt", String.valueOf(row.get("createdAt"))); } |
| | | |
| | | /** |
| | | * 这笔进件是不是「通道侧已经明确失败、可以重新提交」。 |
| | | * |
| | | * <p>进件成功后还有两步通道侧配置:微信小程序支付配置、商户业务开通。任何一步被通道 |
| | | * 明确拒绝(`failed`),后续流程就不会再自己往前走,而记录会一直停在 pending—— |
| | | * 本地开发收不到回调时尤其明显。不把它判成可重试,商户就永远卡在自己的 pending 里: |
| | | * 进件页会跳转到"审核中",后端也会直接返回旧记录。 |
| | | */ |
| | | private boolean retryable(Map<String, Object> row) { return isRetryable(row); } |
| | | |
| | | /** 取值为静态形式,便于直接断言这条规则本身(见 MerchantOnboardingRetryTests)。 */ |
| | | static boolean isRetryable(Map<String, Object> row) { |
| | | return "failed".equals(cleanStatus(row.get("wechatConfigStatus"))) || "failed".equals(cleanStatus(row.get("businessOpenStatus"))); |
| | | } |
| | | |
| | | private static String cleanStatus(Object value) { return value == null ? "" : String.valueOf(value).trim(); } |
| | | |
| | | /** |
| | | * 商户重新提交时,把上一次「通道侧已明确失败」的进件连同它的银行卡一起收口。 |
| | | * |
| | | * <p>只把进件标成 rejected 是不够的:银行卡校验是独立的一条, |
| | | * `status='pending'` 的卡本身就足以拦住复用("该银行卡已有审核中的进件或已通过的申请")。 |
| | | * 而那张卡之所以停在 pending,正是这条失败的进件留下的,所以两者必须一起收口, |
| | | * 否则商户连同一张卡都不能用——实际就踩到过这个坑。 |
| | | */ |
| | | void closeRetryableApplication(MerchantPrincipal principal) { |
| | | List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where merchant_id = ? and store_id = ? and initial_bank_card_id is not null and status in ('pending','approved') order by id desc limit 1", principal.merchantId(), principal.storeId()); |
| | | if (applications.isEmpty()) return; |
| | | Map<String, Object> application = applications.get(0); |
| | | if (!retryable(application)) return; |
| | | Object bankCardId = application.get("initialBankCardId"); |
| | | jdbc.update("update merchant_bank_card set status='rejected', reject_reason=?, reviewed_at=now() where id=? and status in ('pending','approved')", "通道侧配置失败,商户重新提交进件", bankCardId); |
| | | jdbc.update("update merchant_onboarding_application set status='rejected', reject_reason=?, reviewed_at=now() where id=?", "通道侧配置失败,商户重新提交进件", application.get("id")); |
| | | } |
| | | private Map<String, Object> bankCard(Map<String, Object> row) { return Map.of("id", String.valueOf(row.get("id")), "bankName", row.get("bankName"), "branchName", row.get("branchName"), "cardMasked", row.get("cardMasked"), "status", row.get("status"), "reason", clean(row.get("rejectReason")), "createdAt", String.valueOf(row.get("createdAt"))); } |
| | | private Map<String, Object> branch(String id, String branchName, String bankName) { return Map.of("id", id, "branchName", branchName, "bankName", bankName); } |
| | | private Object[] appendPaging(List<Object> params, int size, int offset) { Object[] values = new Object[params.size() + 2]; for (int index = 0; index < params.size(); index++) values[index] = params.get(index); values[params.size()] = size; values[params.size() + 1] = offset; return values; } |