| | |
| | | |
| | | @Service |
| | | public class MerchantSettlementService { |
| | | private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(MerchantSettlementService.class); |
| | | /** 商家档案给不出可识别经营类型时的兜底 MCC(「其它」)。 */ |
| | | private static final String FALLBACK_MCC = "7399"; |
| | | private static final Map<String, String> MCC_BY_MERCHANT_TYPE = Map.ofEntries( |
| | | Map.entry("food", "5812"), Map.entry("scenic", "7991"), Map.entry("hotel", "7011"), Map.entry("apartment", "7011"), |
| | | Map.entry("cinema", "7832"), Map.entry("fuel", "5541"), Map.entry("parking", "7523"), Map.entry("carWash", "7542"), |
| | |
| | | private final HuifuPaymentProperties huifuPaymentProperties; |
| | | private final HuifuPictureUploadGateway pictureUploadGateway; |
| | | private final MerchantPaymentReadinessService paymentReadinessService; |
| | | @org.springframework.beans.factory.annotation.Autowired |
| | | private MerchantOnboardingProgressService onboardingProgressService; |
| | | @org.springframework.beans.factory.annotation.Autowired |
| | | private MerchantRealnameService realnameService; |
| | | @org.springframework.beans.factory.annotation.Autowired |
| | | private com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway onboardingQueryGateway; |
| | | |
| | | 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 refreshProgress(((Number)records.get(0).get("id")).longValue(),retry); |
| | | } |
| | | |
| | | /** |
| | | * 刷新分阶段进度:**先推进已满足前置条件的开通步骤,再查询回报结果**。 |
| | | * |
| | | * <p>分阶段模型下这条路径是商户与后台推进开通的唯一入口。渠道侧的微信商户配置与 |
| | | * 业务开通原本只由进件审核回调触发,而分阶段申请的回调走的是进度刷新——不在这里补上 |
| | | * 推进,新商户的微信开通就永远不会被发起。{@code continuePostOnboardingProvisioning} |
| | | * 自身按状态判断是否还需要做,重复调用无害。 |
| | | */ |
| | | public Map<String, Object> refreshProgress(Long applicationId, boolean retry) { |
| | | List<Map<String,Object>> records=rows("select * from merchant_onboarding_application where id=? and initial_bank_card_id is not null",applicationId); |
| | | if(records.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND,"Onboarding application not found"); |
| | | String applicationNo=clean(records.get(0).get("applicationNo")); |
| | | if (!applicationNo.isBlank()) continuePostOnboardingProvisioning(applicationNo); |
| | | return onboardingProgressService.refresh(applicationId,retry); |
| | | } |
| | | |
| | | /** 微信实名认证:商户主动推进的下一步。未决申请不会重复提交,见 MerchantRealnameService。 */ |
| | | public Map<String, Object> submitRealname(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 realnameService.submit(((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() && onboardingProgressService.supports(active.get(0))) { |
| | | supplyMissingStorePhotos(active.get(0), request); |
| | | 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位统一社会信用代码"); |
| | |
| | | normalizeRegisteredCapital(request.companyType(), request.registeredCapital()); |
| | | } else { |
| | | if (blank(request.registeredDistrictCode()) || blank(request.registeredAddress()) || blank(request.legalIdFrontUrl()) || blank(request.legalIdBackUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请补全个人商户进件资料"); |
| | | // 汇付对个人(小微)商户的微信业务开通与微信实名认证都要门店照片(门头照 F22 + 内景照 F24)。 |
| | | // 放在提交时校验:这两张图是拿到微信收款能力的硬条件,缺了也能把进件提上去, |
| | | // 但后面微信开通和实名一定被通道驳回,那时商户得把整份进件重走一遍。 |
| | | if (blank(request.storeHeaderUrl()) || blank(request.storeIndoorUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传店铺照片与店内环境照,微信支付开通与实名认证都需要"); |
| | | } |
| | | boolean privateSettlement = !enterprise || "个体工商户".equals(clean(request.companyType())); |
| | | if (privateSettlement && blank(request.settlementCardFrontUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传结算银行卡卡号面"); |
| | |
| | | String no = BusinessIdGenerator.next("MO"); |
| | | String certificateValidityType = certificateValidityType(request.subjectCertificateValidityType()); |
| | | String certificateBeginDate = certificateBeginDate(request.subjectCertificateBeginDate()); |
| | | jdbc.update("insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, credit_code, license_validity_type, license_begin_date, license_end_date, subject_id_no_masked, subject_id_no_encrypted, subject_cert_validity_type, subject_cert_begin_date, subject_cert_end_date, registered_region, registered_province_code, registered_city_code, registered_district_code, registered_address, merchant_name, merchant_short_name, company_type, business_license_url, established_date, registered_capital, legal_id_front_url, legal_id_back_url, legal_certificate_address, administrator_email, contact_name, contact_mobile, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), certificateValidityType(request.licenseValidityType()), certificateBeginDate(request.licenseBeginDate()), certificateEndDate(request.licenseEndDate()), maskId(request.subjectIdNumber()), sensitiveDataCrypto.encrypt(clean(request.subjectIdNumber()).toUpperCase()), certificateValidityType, certificateBeginDate, certificateEndDate(request.subjectCertificateEndDate()), clean(request.registeredRegion()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), clean(request.registeredAddress()), clean(request.merchantName()), clean(request.merchantShortName()), clean(request.companyType()), clean(request.businessLicenseUrl()), clean(request.establishedDate()), clean(request.registeredCapital()), clean(request.legalIdFrontUrl()), clean(request.legalIdBackUrl()), clean(request.legalCertificateAddress()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), key.trim()); |
| | | jdbc.update("insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, credit_code, license_validity_type, license_begin_date, license_end_date, subject_id_no_masked, subject_id_no_encrypted, subject_cert_validity_type, subject_cert_begin_date, subject_cert_end_date, registered_region, registered_province_code, registered_city_code, registered_district_code, registered_address, merchant_name, merchant_short_name, company_type, business_license_url, established_date, registered_capital, legal_id_front_url, legal_id_back_url, legal_certificate_address, legal_mobile, administrator_email, contact_name, contact_mobile, store_header_url, store_indoor_url, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), certificateValidityType(request.licenseValidityType()), certificateBeginDate(request.licenseBeginDate()), certificateEndDate(request.licenseEndDate()), maskId(request.subjectIdNumber()), sensitiveDataCrypto.encrypt(clean(request.subjectIdNumber()).toUpperCase()), certificateValidityType, certificateBeginDate, certificateEndDate(request.subjectCertificateEndDate()), clean(request.registeredRegion()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), clean(request.registeredAddress()), clean(request.merchantName()), clean(request.merchantShortName()), clean(request.companyType()), clean(request.businessLicenseUrl()), clean(request.establishedDate()), clean(request.registeredCapital()), clean(request.legalIdFrontUrl()), clean(request.legalIdBackUrl()), clean(request.legalCertificateAddress()), clean(request.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), clean(request.storeHeaderUrl()), clean(request.storeIndoorUrl()), key.trim()); |
| | | String cardNumber = request.bankCardNumber().trim(); |
| | | String cardKey = key.trim() + ":initial"; |
| | | if (cardKey.length() > 128) cardKey = cardKey.substring(0, 128); |
| | |
| | | if ("individual".equals(request.subjectType())) submitIndividualOnboarding(principal, no, bankCardId, request, branch); |
| | | else submitEnterpriseOnboarding(principal, no, bankCardId, request, branch, privateSettlement); |
| | | return onboarding(rows("select * from merchant_onboarding_application where application_no = ?", no).get(0)); |
| | | } |
| | | |
| | | /** |
| | | * 存量个人进件缺门店照片时,允许商户在重新提交里补上。 |
| | | * |
| | | * <p>门店照片是个人(小微)主体开通微信支付与发起微信实名的硬材料,而分阶段模型下重复提交 |
| | | * **不会新建进件**(也就不会在通道上多出一家商户),商户端因此没有别的补图入口——真实开发库里 |
| | | * 已经有拿到汇付商户号的个人进件,就是这个形态。 |
| | | * |
| | | * <p>只补空着的那两项,已有值不覆盖;进件、银行卡与通道申请一律不动。 |
| | | */ |
| | | private void supplyMissingStorePhotos(Map<String, Object> application, MerchantOnboardingRequest request) { |
| | | if (request == null || !"individual".equalsIgnoreCase(clean(application.get("subjectType")))) return; |
| | | if (clean(application.get("storeHeaderUrl")).isBlank() && !blank(request.storeHeaderUrl())) { |
| | | jdbc.update("update merchant_onboarding_application set store_header_url=? where id=?", request.storeHeaderUrl().trim(), application.get("id")); |
| | | } |
| | | if (clean(application.get("storeIndoorUrl")).isBlank() && !blank(request.storeIndoorUrl())) { |
| | | jdbc.update("update merchant_onboarding_application set store_indoor_url=? where id=?", request.storeIndoorUrl().trim(), application.get("id")); |
| | | } |
| | | } |
| | | |
| | | private void submitEnterpriseOnboarding(MerchantPrincipal principal, String applicationNo, Long bankCardId, MerchantOnboardingRequest request, Map<String, Object> branch, boolean privateSettlement) { |
| | |
| | | String legalFrontPictureId = pictureUploadGateway.uploadUrl(request.legalIdFrontUrl(), "F40"); |
| | | String legalBackPictureId = pictureUploadGateway.uploadUrl(request.legalIdBackUrl(), "F41"); |
| | | String settlementCardPictureId = pictureUploadGateway.uploadUrl(request.settlementCardFrontUrl(), "F13"); |
| | | // 店铺照片:个人(小微)商户进件的必填项,三种经营类型都要传,文件类型固定 F22。 |
| | | String storeHeaderPictureId = pictureUploadGateway.uploadUrl(request.storeHeaderUrl(), "F22"); |
| | | 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)); |
| | | mcc(principal.merchantId(), "individual", request.companyType()), 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, storeHeaderPictureId)); |
| | | 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); |
| | |
| | | List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where application_no=? limit 1 for update", requestNo); |
| | | if (applications.isEmpty()) return false; |
| | | Map<String, Object> application = applications.get(0); |
| | | if (onboardingProgressService.supports(application)) { |
| | | if (!clean(data.get("huifu_id")).equals(clean(application.get("providerHuifuId")))) return false; |
| | | refreshRecoveryAfterCommit(((Number)application.get("id")).longValue()); |
| | | return true; |
| | | } |
| | | if ("approved".equals(clean(application.get("status"))) || "rejected".equals(clean(application.get("status")))) return true; |
| | | String code = clean(data.get("sub_resp_code")); if (code.isBlank()) code = clean(data.get("resp_code")); |
| | | String message = clean(data.get("audit_desc")); if (message.isBlank()) message = clean(data.get("sub_resp_desc")); if (message.isBlank()) message = clean(data.get("resp_desc")); |
| | |
| | | @Transactional |
| | | public boolean handleBusinessOpenCallback(Map<String, Object> callback) { |
| | | Map<String, Object> data = callbackData(callback); |
| | | Long recoveryId=onboardingProgressService.acceptDelayCallback(data); |
| | | if (recoveryId != null) { |
| | | refreshRecoveryAfterCommit(recoveryId); |
| | | return true; |
| | | } |
| | | String requestNo = clean(data.get("req_seq_id")); |
| | | if (requestNo.isBlank()) return false; |
| | | List<Map<String, Object>> applications = rows("select * from merchant_onboarding_application where business_open_request_no=? limit 1 for update", requestNo); |
| | |
| | | String auditStatus = clean(data.get("audit_status")); |
| | | String businessStatus = wechatBusinessStatus(data); |
| | | String applyNo = clean(data.get("apply_no")); |
| | | // 这个异步通知里还带着微信实名状态与授权二维码(notify_type=W),原来两个字段一个都没读, |
| | | // 等于把通道主动送来的实名进展丢掉了。有实名状态就落到 realname_status,并借一次进度刷新 |
| | | // 把二维码与阶段状态一并取回——二维码只在查询里给,通知里不解析它。 |
| | | String realnameState = clean(data.get("real_name_applyment_stat")); |
| | | if (!realnameState.isBlank()) { |
| | | jdbc.update("update merchant_onboarding_application set realname_status=? where id=?", realnameStatus(realnameState), application.get("id")); |
| | | if (onboardingProgressService.supports(application)) refreshRecoveryAfterCommit(((Number) application.get("id")).longValue()); |
| | | } |
| | | boolean rejected = List.of("N", "F", "C").contains(auditStatus.toUpperCase()) || "F".equalsIgnoreCase(businessStatus) || !providerSuccess(code); |
| | | if (rejected) { |
| | | String reason = providerReason(code, message); |
| | |
| | | String huifuId = clean(application.get("providerHuifuId")); |
| | | if (huifuId.isBlank() || "rejected".equals(clean(application.get("status")))) return; |
| | | |
| | | String wechatStatus = clean(application.get("wechatConfigStatus")); |
| | | if (wechatStatus.isBlank() || "not_started".equals(wechatStatus) || "failed".equals(wechatStatus)) { |
| | | String requestNo = clean(application.get("wechatConfigRequestNo")); |
| | | if (requestNo.isBlank()) requestNo = provisioningRequestNo(applicationNo, "WC"); |
| | | jdbc.update("update merchant_onboarding_application set status='pending', wechat_config_status='processing', wechat_config_request_no=?, reject_reason=null, reviewed_at=null where id=?", requestNo, application.get("id")); |
| | | try { |
| | | HuifuMerchantWechatConfigGateway.Result result = merchantWechatConfigGateway.configure(requestNo, huifuId); |
| | | if (!result.accepted()) { |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status='failed', wechat_config_response_code=?, wechat_config_response_message=?, reject_reason=? where id=?", result.code(), result.message(), "微信商户配置失败:" + providerReason(result.code(), result.message()), application.get("id")); |
| | | return; |
| | | } |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status='approved', wechat_config_response_code=?, wechat_config_response_message=? where id=?", result.code(), result.message(), application.get("id")); |
| | | } catch (HuifuPaymentGatewayException exception) { |
| | | String status = exception.isOutcomeUnknown() ? "processing" : "failed"; |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status=?, reject_reason=? where id=?", status, exception.isOutcomeUnknown() ? "微信商户配置结果待确认" : "微信商户配置请求失败", application.get("id")); |
| | | return; |
| | | } catch (BusinessException exception) { |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status='failed', reject_reason=? where id=?", "微信商户配置失败:" + exception.getMessage(), application.get("id")); |
| | | return; |
| | | } |
| | | } else if (!"approved".equals(wechatStatus)) { |
| | | return; |
| | | } |
| | | advanceBusinessOpen(applicationNo, application, huifuId); |
| | | // 微信小程序配置必须排在业务开通**之后**,而且两步互不阻断:微信子商户号是业务开通创建的, |
| | | // 先做配置会在通道侧报「根据渠道号或微信子商户号未查到入驻记录」(2026-09-16 新商户实测, |
| | | // 当时配置失败还把业务开通整个跳过了,于是微信支付、人才端绑定、实名全部连坐)。 |
| | | advanceWechatConfig(applicationNo, fresh(application), huifuId); |
| | | completePostOnboardingProvisioning(application.get("id")); |
| | | } |
| | | |
| | | application = rows("select * from merchant_onboarding_application where id=? limit 1", application.get("id")).get(0); |
| | | private Map<String, Object> fresh(Map<String, Object> application) { |
| | | return rows("select * from merchant_onboarding_application where id=? limit 1", application.get("id")).get(0); |
| | | } |
| | | |
| | | /** |
| | | * 商户业务开通。排在微信小程序配置之前:这一步受理时通道就为三个线上场景创建了微信子商户号, |
| | | * 微信小程序配置要挂在这个子商户号下面。 |
| | | */ |
| | | private void advanceBusinessOpen(String applicationNo, Map<String, Object> application, String huifuId) { |
| | | String businessStatus = clean(application.get("businessOpenStatus")); |
| | | // 通道侧已经开通微信业务时不再重新提交,否则会为同一商户开出第二份业务开通申请。 |
| | | // 同时把本地状态收口成 approved——**包括 processing**:业务开通回的是 90000000(审核中), |
| | | // 没有回调时(本地开发收不到异步通知)没人把它推成 approved,而 approved 才是能力快照刷新 |
| | | // 与「整单完成」的入口,于是商户明明已经开通却一直停在审核中。 |
| | | if (!"approved".equals(businessStatus) && wechatBusinessEffective(huifuId)) { |
| | | jdbc.update("update merchant_onboarding_application set business_open_status='approved', business_open_response_message=? where id=?", "通道已开通微信业务,按已开通收口", application.get("id")); |
| | | businessStatus = "approved"; |
| | | } |
| | | if (businessStatus.isBlank() || "not_started".equals(businessStatus) || "failed".equals(businessStatus)) { |
| | | String requestNo = clean(application.get("businessOpenRequestNo")); |
| | | if (requestNo.isBlank()) requestNo = provisioningRequestNo(applicationNo, "BO"); |
| | | // 上一次是**明确失败**时必须换新号重试(通道按流水号去重);其余情况沿用已存的号, |
| | | // 结果未知时换号会给同一商户开出第二份业务开通申请。 |
| | | String requestNo = "failed".equals(businessStatus) ? provisioningRequestNo(applicationNo, "BO", true) : clean(application.get("businessOpenRequestNo")); |
| | | if (requestNo.isBlank()) requestNo = provisioningRequestNo(applicationNo, "BO", false); |
| | | String shortName = clean(application.get("merchantShortName")); |
| | | if (shortName.isBlank()) shortName = clean(application.get("merchantName")); |
| | | if (shortName.isBlank()) shortName = clean(application.get("subjectName")); |
| | | Long merchantId = ((Number) application.get("merchantId")).longValue(); |
| | | String subjectType = clean(application.get("subjectType")); |
| | | boolean individual = "individual".equalsIgnoreCase(subjectType); |
| | | // mcc 对「个体工商户、小微商户」是必填(官方原文),企业才可以用 fee_rule_id 代替。 |
| | | // 个体工商户走的是企业进件接口,所以不是 individual,但同样要带 mcc。 |
| | | // 注意:个人主体的 company_type 存的是进件时选的经营行业,不会等于「个体工商户」。 |
| | | boolean mccRequired = individual || "个体工商户".equals(clean(application.get("companyType"))); |
| | | // 个人(小微)商户的门店照片必须随业务开通一起交上去。缺图就直接收口成失败、 |
| | | // 不发这次请求:发出去也只会被通道驳回,还会在通道侧留下一条缺材料的申请。 |
| | | // 商户在进件页补上照片重新提交即可(见 supplyMissingStorePhotos),下次刷新会带材料重发。 |
| | | String storeHeaderFileId = ""; |
| | | String storeIndoorFileId = ""; |
| | | if (individual) { |
| | | String storeHeaderUrl = clean(application.get("storeHeaderUrl")); |
| | | String storeIndoorUrl = clean(application.get("storeIndoorUrl")); |
| | | if (storeHeaderUrl.isBlank() || storeIndoorUrl.isBlank()) { |
| | | jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "商户业务开通失败:个人商户开通微信支付需要店铺照片与店内环境照,请在进件页补上这两张图后重新提交", application.get("id")); |
| | | return; |
| | | } |
| | | try { |
| | | storeHeaderFileId = pictureUploadGateway.uploadUrl(storeHeaderUrl, "F22"); |
| | | storeIndoorFileId = pictureUploadGateway.uploadUrl(storeIndoorUrl, "F24"); |
| | | } catch (BusinessException | HuifuPaymentGatewayException exception) { |
| | | jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "门店照片上传失败:" + exception.getMessage(), application.get("id")); |
| | | return; |
| | | } |
| | | } |
| | | 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), "individual".equalsIgnoreCase(clean(application.get("subjectType")))); |
| | | HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId, subjectType, clean(application.get("companyType"))), individual, mccRequired, storeHeaderFileId, storeIndoorFileId); |
| | | 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; |
| | |
| | | } catch (BusinessException exception) { |
| | | jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "商户业务开通失败:" + exception.getMessage(), application.get("id")); |
| | | } |
| | | } else if ("approved".equals(businessStatus)) { |
| | | completePostOnboardingProvisioning(application.get("id")); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 微信小程序支付配置(人才端 AppID 绑定)。排在业务开通之后,见 {@link #advanceBusinessOpen}。 |
| | | * |
| | | * <p>这里不再因为本步失败就跳过后续步骤——两步各自记状态、各自可重试。原先配置一失败就 |
| | | * {@code return},业务开通永远轮不到,而通道侧的失败原因只会指向配置这一步,看不出是顺序问题。 |
| | | */ |
| | | private void advanceWechatConfig(String applicationNo, Map<String, Object> application, String huifuId) { |
| | | String wechatStatus = clean(application.get("wechatConfigStatus")); |
| | | // 本地状态可能与通道脱节(通道侧已经配好、本地还是 not_started)。微信商户配置是**添加**接口、 |
| | | // 成功后不支持修改删除(每种参数最多 5 组),配置已在通道生效时再提一次会留下重复配置。 |
| | | if (needsProvisioningStep(wechatStatus) && wechatConfigEffective(huifuId)) { |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status='approved', wechat_config_response_message=? where id=?", "通道已有配置,按已配置收口", application.get("id")); |
| | | return; |
| | | } |
| | | if (!needsProvisioningStep(wechatStatus)) return; |
| | | // 业务开通被**受理**(90000000 审核中)不等于微信子商户号已经建好:微信入驻是异步的,受理那一刻 |
| | | // 通道侧查不到子商户号,此时发配置必然被驳回「根据渠道号或微信子商户号未查到入驻记录」, |
| | | // 还会把 wechat_config_status 记成 failed,让人以为是配置本身有问题。 |
| | | // 所以闸门用通道证据而不是本地状态:`qry_wx_conf_list` 里真的出现线上场景才算开通。 |
| | | // 代价是要多刷一次(入驻完成后再来一次就会带上配置),换来的是不发注定被拒的请求。 |
| | | if (!wechatBusinessEffective(huifuId)) return; |
| | | // 同理:上一次是明确失败时必须换新号,否则通道侧会把它当成重复请求。 |
| | | String requestNo = "failed".equals(wechatStatus) ? provisioningRequestNo(applicationNo, "WC", true) : clean(application.get("wechatConfigRequestNo")); |
| | | if (requestNo.isBlank()) requestNo = provisioningRequestNo(applicationNo, "WC", false); |
| | | jdbc.update("update merchant_onboarding_application set status='pending', wechat_config_status='processing', wechat_config_request_no=?, reject_reason=null, reviewed_at=null where id=?", requestNo, application.get("id")); |
| | | try { |
| | | HuifuMerchantWechatConfigGateway.Result result = merchantWechatConfigGateway.configure(requestNo, huifuId); |
| | | if (!result.accepted()) { |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status='failed', wechat_config_response_code=?, wechat_config_response_message=?, reject_reason=? where id=?", result.code(), result.message(), "微信商户配置失败:" + providerReason(result.code(), result.message()), application.get("id")); |
| | | return; |
| | | } |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status='approved', wechat_config_response_code=?, wechat_config_response_message=? where id=?", result.code(), result.message(), application.get("id")); |
| | | } catch (HuifuPaymentGatewayException exception) { |
| | | String status = exception.isOutcomeUnknown() ? "processing" : "failed"; |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status=?, reject_reason=? where id=?", status, exception.isOutcomeUnknown() ? "微信商户配置结果待确认" : "微信商户配置请求失败", application.get("id")); |
| | | } catch (BusinessException exception) { |
| | | jdbc.update("update merchant_onboarding_application set wechat_config_status='failed', reject_reason=? where id=?", "微信商户配置失败:" + exception.getMessage(), application.get("id")); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | private String responseCode(Map<String, Object> data) { String code = clean(data.get("sub_resp_code")); return code.isBlank() ? clean(data.get("resp_code")) : code; } |
| | | private String responseMessage(Map<String, Object> data) { String message = clean(data.get("audit_desc")); if (message.isBlank()) message = clean(data.get("sub_resp_desc")); return message.isBlank() ? clean(data.get("resp_desc")) : message; } |
| | | private String provisioningRequestNo(String applicationNo, String suffix) { String value = applicationNo + suffix; return value.length() <= 32 ? value : value.substring(0, 32); } |
| | | /** |
| | | * 通道侧实名申请单状态映射到本地 realname_status。 |
| | | * |
| | | * <p>这个异步通知只给申请单状态、不给授权状态,所以 {@code APPLYMENT_STATE_PASSED} 只能算 |
| | | * processing——授权是否完成要等查询到 {@code authorize_stat=1} 才能定,不能凭这一步就写 approved。 |
| | | */ |
| | | private static boolean needsProvisioningStep(String status) { |
| | | return status.isBlank() || "not_started".equals(status) || "failed".equals(status); |
| | | } |
| | | |
| | | /** |
| | | * 微信小程序支付配置是否已经在通道生效:人才端 AppID 是否绑定在 {@code fee_type=01} 的配置项下。 |
| | | * 与分阶段进度用的是同一份证据。查询失败按「未生效」处理——宁可多提一次也不要漏配。 |
| | | */ |
| | | private boolean wechatConfigEffective(String huifuId) { |
| | | try { |
| | | Map<String, Object> config = onboardingQueryGateway.queryWechatConfig(huifuId); |
| | | if (config == null || !"00000000".equals(clean(config.get("resp_code")))) return false; |
| | | String appId = huifuPaymentProperties.getMiniProgramAppId().trim(); |
| | | for (Map<String, Object> entry : providerList(config.get("atu_config_bo_list"))) { |
| | | if (!"01".equals(clean(entry.get("fee_type")))) continue; |
| | | for (Map<String, Object> item : providerList(entry.get("wx_config_list"))) { |
| | | if ("1".equals(clean(item.get("config_type"))) && appId.equals(clean(item.get("config_value")))) return true; |
| | | } |
| | | } |
| | | return false; |
| | | } catch (RuntimeException exception) { |
| | | log.warn("Huifu merchant WeChat config probe failed: huifuId={}", huifuId, exception); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 微信业务是否已经在通道开通:商户详情的 {@code qry_wx_conf_list} 里出现线上三场景之一即视为已开通。 |
| | | * 业务开通是申请型接口,重复提交会给同一商户开出第二份申请,所以提交前必须先查。 |
| | | */ |
| | | private boolean wechatBusinessEffective(String huifuId) { |
| | | try { |
| | | Map<String, Object> detail = onboardingQueryGateway.queryCapabilities(huifuId); |
| | | if (detail == null || !"00000000".equals(clean(detail.get("resp_code")))) return false; |
| | | return providerList(detail.get("qry_wx_conf_list")).stream() |
| | | .anyMatch(item -> List.of("4", "5", "12").contains(clean(item.get("pay_scene")))); |
| | | } catch (RuntimeException exception) { |
| | | log.warn("Huifu merchant business open probe failed: huifuId={}", huifuId, exception); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** 通道返回的列表字段可能是 JSON 字符串也可能是已解析的数组,两种都要能吃。 */ |
| | | @SuppressWarnings("unchecked") |
| | | private static List<Map<String, Object>> providerList(Object raw) { |
| | | if (raw == null) return List.of(); |
| | | Object parsed = raw instanceof String value ? (value.isBlank() ? null : com.alibaba.fastjson.JSON.parse(value)) : raw; |
| | | if (!(parsed instanceof List<?> list)) return List.of(); |
| | | return list.stream().filter(Map.class::isInstance).map(item -> (Map<String, Object>) item).toList(); |
| | | } |
| | | |
| | | private static String realnameStatus(String applymentStat) { |
| | | return switch (applymentStat) { |
| | | case "APPLYMENT_STATE_REJECTED" -> "failed"; |
| | | case "APPLYMENT_STATE_NOAPPLY", "APPLYMENT_STATE_CANCELED" -> "not_started"; |
| | | default -> "processing"; |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * 通道请求流水号。 |
| | | * |
| | | * <p>{@code retry} 为真时在尾部追加随机后缀——**明确失败后的重试必须换新号**:通道按流水号 |
| | | * 去重,被驳回的申请沿用旧号会被当成重复请求。进件与实名走的是同一条规则 |
| | | * (见 {@code MerchantRealnameService.requestNo})。受理结果未知时不换号,避免为同一次提交 |
| | | * 开出第二份申请。 |
| | | * |
| | | * <p>随机后缀必须保住:{@code applicationNo} 长度不保证,先按剩余宽度裁前缀再接后缀, |
| | | * 否则超长截断会把随机位一起切掉,两次重试又变成同一个号。 |
| | | */ |
| | | private String provisioningRequestNo(String applicationNo, String suffix, boolean retry) { |
| | | if (!retry) { |
| | | String value = applicationNo + suffix; |
| | | return value.length() <= 32 ? value : value.substring(0, 32); |
| | | } |
| | | String random = String.format("%05d", new java.util.Random().nextInt(100_000)); |
| | | String head = applicationNo + suffix + "R"; |
| | | int room = 32 - random.length(); |
| | | return (head.length() <= room ? head : head.substring(0, room)) + random; |
| | | } |
| | | 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")), "retryable", retryable(row), "submittedAt", String.valueOf(row.get("createdAt"))); } |
| | | private void refreshRecoveryAfterCommit(Long id) { |
| | | if (TransactionSynchronizationManager.isActualTransactionActive()) { |
| | | TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { |
| | | @Override public void afterCommit() { onboardingProgressService.refresh(id, false); } |
| | | }); |
| | | } else onboardingProgressService.refresh(id, false); |
| | | } |
| | | |
| | | private Map<String, Object> onboarding(Map<String, Object> row) { |
| | | if (onboardingProgressService.supports(row)) return onboardingProgressService.view(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"))); |
| | | } |
| | | |
| | | /** |
| | | * 这笔进件是不是「通道侧已经明确失败、可以重新提交」。 |
| | |
| | | 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 (onboardingProgressService.supports(application)) { |
| | | refreshRecoveryAfterCommit(((Number)application.get("id")).longValue()); |
| | | return; |
| | | } |
| | | if (!retryable(application)) return; |
| | | if (onboardingProgressService.supports(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")); |
| | |
| | | default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "公司类型无效"); |
| | | }; |
| | | } |
| | | /** |
| | | * 进件主体所属行业的 MCC。 |
| | | * |
| | | * <p><b>行业以系统里的商家档案为准</b>(2026-09-16 业务口径:「行业改不了,只有我们系统动态地去匹配 |
| | | * 行业」)。商家档案上的经营类型是我们自己维护的,也是匹配微信行业类目与费率的依据, |
| | | * 不该被进件表单上的一次选择盖掉;已进件商户也不提供补改行业的入口。 |
| | | * |
| | | * <p>商户在个人进件表单第一步选的「行业类型」只在**档案给不出具体行业时**兜底 |
| | | * (档案未配、或配成「其它」未识别 → {@link #FALLBACK_MCC}),避免档案缺数据时一律落「其它」。 |
| | | * |
| | | * <p>{@code mcc} 是进件的必填项;业务开通只在没有 {@code fee_rule_id} 时才用它兜底。 |
| | | */ |
| | | private String mcc(Long merchantId, String subjectType, String industryType) { |
| | | String configured = mcc(merchantId); |
| | | // 个体工商户走企业进件接口,表单上没有「行业类型」这一项,只有个人主体才有申报值。 |
| | | if (!"individual".equalsIgnoreCase(clean(subjectType))) return configured; |
| | | return preferConfiguredMcc(configured, industryType); |
| | | } |
| | | |
| | | /** |
| | | * 系统档案优先、商户申报兜底。取值为静态形式,便于直接断言这条规则本身 |
| | | * (见 {@code MerchantOnboardingIndustryMccTests})。 |
| | | */ |
| | | static String preferConfiguredMcc(String configuredMcc, String declaredIndustry) { |
| | | if (!FALLBACK_MCC.equals(cleanStatus(configuredMcc))) return configuredMcc == null ? "" : configuredMcc.trim(); |
| | | String declared = mccForIndustry(declaredIndustry); |
| | | return declared.isBlank() ? FALLBACK_MCC : declared; |
| | | } |
| | | |
| | | /** |
| | | * 经营行业选中的 MCC。取值为静态形式,便于直接断言这条规则本身 |
| | | * (见 {@code MerchantOnboardingIndustryMccTests});返回空串表示这个行业识别不出来,交给商家档案兜底。 |
| | | */ |
| | | static String mccForIndustry(String industryType) { |
| | | String industry = MerchantTypeCatalog.canonicalCode(cleanStatus(industryType)); |
| | | return "other".equals(industry) ? "" : MCC_BY_MERCHANT_TYPE.getOrDefault(industry, FALLBACK_MCC); |
| | | } |
| | | |
| | | private String mcc(Long merchantId) { |
| | | List<Map<String, Object>> merchants = rows("select merchant_type_code, merchant_type from merchant where id=? and deleted=0 limit 1", merchantId); |
| | | if (merchants.isEmpty()) throw new BusinessException(ErrorCode.NOT_FOUND, "商家不存在"); |
| | | String code = clean(merchants.get(0).get("merchantTypeCode")); |
| | | if (code.isBlank()) code = MerchantTypeCatalog.canonicalCode(clean(merchants.get(0).get("merchantType"))); |
| | | return MCC_BY_MERCHANT_TYPE.getOrDefault(code, "7399"); |
| | | return MCC_BY_MERCHANT_TYPE.getOrDefault(code, FALLBACK_MCC); |
| | | } |
| | | private String normalizeRegisteredCapital(String companyType, String value) { |
| | | String text = clean(value); boolean required = List.of("国营企业", "私营企业", "外资企业", "其它组织", "事业单位").contains(clean(companyType)); |