feat(merchant): 进件把商户建成平台自己的下级商户,进件后自动配置才做得成
平台用汇付号 6666000226048687 签名调接口。此前进件只传 upper_huifu_id(渠道商号
6666000220591054),商户因此归属渠道商名下,平台身份随后对它的所有操作都被判
20002 数据权限认证失败:
v2.merchant.busi.open 商户业务开通 -> 20002
v2.merchant.busi.config 微信小程序支付配置 -> 20002
v2.merchant.basicdata.query / status.query -> 20002
实测(ProvisioningProbe,带不带 product_id 都一样):同一个身份操作**自己**的数据全部
00000000 成功(余额、结算记录、head/config 均正常),操作**别人名下的**商户一律 20002。
所以问题不是"权限没开",而是商户**归属错了**——平台管不到不归自己的商户。
结果就是进件能通过、商户建得出来,但微信支付永远配不上,商户得自己去汇付控台点,
而这正是进件功能要消灭的事情。
改动:
- 新增 HuifuParentMerchant:把 head_office_flag=0 + head_huifu_id 成对写进进件请求,
上级为空时保持原普通进件行为。企业进件与个人进件共用这一份规则。
- HuifuPaymentProperties 新增 onboarding-parent-huifu-id(默认 6666000226048687,
置空即退回普通进件),application.yml 同步。
- HuifuMerchantBusinessOpenGateway 在挂靠模式下加 use_chains_flag=3:
子商户复用平台的微信商户号发起交易,不必逐个单独申请微信商户号。
前置条件:上级汇付号需先通过「开通下级商户权限配置接口」
(v2/merchant/busi/head/config) 取得开通下级商户的权限——已提交,apply_no=2026091429695315。
回归:新增 HuifuOnboardingParentMerchantTests,连同支付相关测试共 44 项通过。
| | |
| | | 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()))); |
| | | certificateBeginDate(request.establishedDate()), normalizeRegisteredCapital(request.companyType(), request.registeredCapital()), |
| | | huifuPaymentProperties.getOnboardingParentHuifuId())); |
| | | 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)); |
| | | request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), settlementCardPictureId, |
| | | huifuPaymentProperties.getOnboardingParentHuifuId())); |
| | | 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); |
| | |
| | | String settlementCertificateFrontPictureId, |
| | | String settlementCertificateBackPictureId, |
| | | String foundDate, |
| | | String registeredCapital |
| | | String registeredCapital, |
| | | /** 平台自己的汇付号:非空则把该商户建成平台的下级商户(head_office_flag=0 + head_huifu_id)。 */ |
| | | String parentHuifuId |
| | | ) {} |
| | |
| | | request.put("req_seq_id", command.applicationNo()); |
| | | request.put("req_date", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)); |
| | | request.put("upper_huifu_id", properties.getOnboardingUpperHuifuId().trim()); |
| | | // 建成平台自己的下级商户:只有挂在自己名下,平台才有权为该商户配置微信支付与业务开通。 |
| | | HuifuParentMerchant.apply(request, command.parentHuifuId()); |
| | | request.put("reg_name", command.companyName()); |
| | | request.put("short_name", command.shortName()); |
| | | request.put("receipt_name", command.shortName()); |
| | |
| | | String bankProvinceCode, |
| | | String bankCityCode, |
| | | String bankCode, |
| | | String settlementCardFrontPictureId |
| | | String settlementCardFrontPictureId, |
| | | /** 平台自己的汇付号:非空则把该商户建成平台的下级商户(head_office_flag=0 + head_huifu_id)。 */ |
| | | String parentHuifuId |
| | | ) {} |
| | |
| | | request.put("req_seq_id", command.applicationNo()); |
| | | request.put("req_date", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)); |
| | | request.put("upper_huifu_id", properties.getOnboardingUpperHuifuId().trim()); |
| | | // 建成平台自己的下级商户:只有挂在自己名下,平台才有权为该商户配置微信支付与业务开通。 |
| | | HuifuParentMerchant.apply(request, command.parentHuifuId()); |
| | | request.put("reg_name", command.name()); |
| | | request.put("short_name", command.name()); |
| | | request.put("mcc", command.mcc()); |
| | |
| | | // 核销确认后才分配给商户。forced_delay_flag 保持关闭——支付即核销的到店消费订单不该被延迟。 |
| | | request.put("delay_flag", "Y"); |
| | | request.put("forced_delay_flag", "N"); |
| | | // 子商户复用平台的微信商户号发起交易,不必为每个商户单独申请微信商户号。 |
| | | // 前提是上级(平台)已开通微信功能——平台的 wx_conf_list 三个 payScene 都是 openFlag=Y。 |
| | | // 3 = 仅微信;文档:使用上级微信、支付宝商户号需对应开通微信、支付宝功能。 |
| | | if (properties.isOnboardingUnderParentMerchant()) request.put("use_chains_flag", "3"); |
| | | putIfPresent(request, "async_return_url", properties.getMerchantBusinessOpenNotifyUrl()); |
| | | putIfPresent(request, "busi_async_return_url", properties.getMerchantBusinessOpenNotifyUrl()); |
| | | try { |
| New file |
| | |
| | | package com.talentcard.server.business.payment.huifu; |
| | | |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * 进件请求里「把新商户建成平台下级商户」的公共组装逻辑。 |
| | | * |
| | | * <p>汇付的进件接口用两个字段表达商户归属: |
| | | * <ul> |
| | | * <li>{@code head_office_flag=0} —— 本笔进件是一个下级商户;</li> |
| | | * <li>{@code head_huifu_id} —— 它的上级商户汇付号({@code head_office_flag=0} 时必填)。</li> |
| | | * </ul> |
| | | * |
| | | * <p>这两个字段必须成对出现。只传 {@code upper_huifu_id}(渠道商号)时商户归属渠道商, |
| | | * 平台自己的签名身份随后无权操作它——业务开通与微信支付配置都会返回 |
| | | * {@code 20002 数据权限认证失败},商户就只能自己去汇付控台配置。 |
| | | * |
| | | * <p>企业进件与个人进件共用同一规则,所以抽在这里,避免两条路各写一遍后走偏。 |
| | | */ |
| | | final class HuifuParentMerchant { |
| | | |
| | | private HuifuParentMerchant() { |
| | | } |
| | | |
| | | /** |
| | | * 当 {@code parentHuifuId} 非空时,把请求标成该上级的下级商户;为空则保持普通商户进件。 |
| | | * |
| | | * <p>前置条件:上级汇付号必须已通过「开通下级商户权限配置接口」 |
| | | * ({@code v2/merchant/busi/head/config})取得开通下级商户的权限,否则通道会拒绝本笔进件。 |
| | | */ |
| | | static void apply(Map<String, Object> request, String parentHuifuId) { |
| | | if (parentHuifuId == null || parentHuifuId.isBlank()) return; |
| | | request.put("head_office_flag", "0"); |
| | | request.put("head_huifu_id", parentHuifuId.trim()); |
| | | } |
| | | } |
| | |
| | | private String merchantWechatConfigNotifyUrl = ""; |
| | | private String merchantBusinessOpenNotifyUrl = ""; |
| | | private String onboardingUpperHuifuId = ""; |
| | | /** |
| | | * 进件时把新商户挂到这个汇付号下,作为它的下级商户({@code head_office_flag=0} + {@code head_huifu_id})。 |
| | | * |
| | | * <p>留空则按普通商户进件,商户归属由 {@code upper_huifu_id}(渠道商号)决定, |
| | | * 而平台自己的身份**无权操作**这类商户——实测对它们调用商户业务开通、微信配置、 |
| | | * 商户信息查询一律返回 {@code 20002 数据权限认证失败},进件后的自动配置全部做不了。 |
| | | * |
| | | * <p>填成平台自己的汇付号({@code merchant-id})后,商户建在平台名下,平台就能自动完成 |
| | | * 「微信小程序支付配置 + 商户业务开通」,商户端无需参与。 |
| | | * |
| | | * <p>前置条件:该汇付号必须先通过「开通下级商户权限配置接口」 |
| | | * ({@code v2/merchant/busi/head/config})获得开通下级商户的权限。 |
| | | */ |
| | | private String onboardingParentHuifuId = ""; |
| | | private String merchantWechatFeeRate = "0.60"; |
| | | private String merchantOnlineBusinessType = "H5311L"; |
| | | private String merchantOnlineMediaName = "东区人才卡"; |
| | |
| | | return merchantId; |
| | | } |
| | | public void setOnboardingUpperHuifuId(String onboardingUpperHuifuId) { this.onboardingUpperHuifuId = onboardingUpperHuifuId; } |
| | | public String getOnboardingParentHuifuId() { return onboardingParentHuifuId == null ? "" : onboardingParentHuifuId.trim(); } |
| | | public void setOnboardingParentHuifuId(String onboardingParentHuifuId) { this.onboardingParentHuifuId = onboardingParentHuifuId; } |
| | | /** |
| | | * 是否把新商户建成平台的下级商户。同时配好「上级商户号发起微信交易」, |
| | | * 让子商户复用平台的微信商户号,不必逐个单独申请。 |
| | | */ |
| | | public boolean isOnboardingUnderParentMerchant() { return !getOnboardingParentHuifuId().isBlank(); } |
| | | public String getMerchantWechatFeeRate() { return merchantWechatFeeRate; } |
| | | public void setMerchantWechatFeeRate(String merchantWechatFeeRate) { this.merchantWechatFeeRate = merchantWechatFeeRate; } |
| | | public String getMerchantOnlineBusinessType() { return merchantOnlineBusinessType; } |
| | |
| | | merchant-wechat-config-notify-url: ${HUIFU_MERCHANT_WECHAT_CONFIG_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-wechat-config-notify} |
| | | merchant-business-open-notify-url: ${HUIFU_MERCHANT_BUSINESS_OPEN_NOTIFY_URL:http://221.182.45.100:11451/api/payments/huifu/merchant-business-open-notify} |
| | | onboarding-upper-huifu-id: ${HUIFU_ONBOARDING_UPPER_HUIFU_ID:6666000220591054} |
| | | # 把新商户建成平台自己的下级商户(head_office_flag=0 + head_huifu_id)。 |
| | | # 不建在自己名下的话,平台无权操作该商户,进件后的「微信支付配置 + 业务开通」全部 |
| | | # 会返回 20002 数据权限认证失败,商户就得自己去汇付控台配置 —— 那正是要避免的。 |
| | | # 置空则退回普通进件(归属由 upper-huifu-id 决定),仅用于排查。 |
| | | onboarding-parent-huifu-id: ${HUIFU_ONBOARDING_PARENT_HUIFU_ID:6666000226048687} |
| | | merchant-wechat-fee-rate: ${HUIFU_MERCHANT_WECHAT_FEE_RATE:0.60} |
| | | merchant-online-business-type: ${HUIFU_MERCHANT_ONLINE_BUSINESS_TYPE:H5311L} |
| | | merchant-online-media-name: ${HUIFU_MERCHANT_ONLINE_MEDIA_NAME:东区人才卡} |
| New file |
| | |
| | | package com.talentcard.server.business.payment.huifu; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | |
| | | import java.util.Map; |
| | | |
| | | import org.junit.jupiter.api.Test; |
| | | |
| | | /** |
| | | * 进件请求里的「上级商户」组装规则。 |
| | | * |
| | | * <p>背景:平台用自己的汇付号签名调接口。如果进件时不把新商户挂到平台名下 |
| | | * ({@code head_office_flag=0} + {@code head_huifu_id}),商户就归属到渠道商名下, |
| | | * 平台随后对它的**一切**操作都会返回 {@code 20002 数据权限认证失败**——实测 |
| | | * {@code v2.merchant.busi.open}(业务开通)和 {@code v2.merchant.busi.config}(微信小程序 |
| | | * 支付配置)两个接口、带不带 {@code product_id} 都一样。结果就是进件通过了,但微信支付 |
| | | * 永远配不上,商户只能自己去汇付控台点——这正是进件功能要消灭的事情。 |
| | | * |
| | | * <p>所以这两个字段的有无必须被钉住。 |
| | | */ |
| | | class HuifuOnboardingParentMerchantTests { |
| | | |
| | | @Test |
| | | void anOnboardingWithoutAParentStaysAnOrdinaryMerchant() { |
| | | Map<String, Object> request = new java.util.LinkedHashMap<>(); |
| | | |
| | | HuifuParentMerchant.apply(request, ""); |
| | | |
| | | assertThat(request).doesNotContainKeys("head_office_flag", "head_huifu_id"); |
| | | } |
| | | |
| | | @Test |
| | | void aBlankParentIsTreatedAsNoParent() { |
| | | Map<String, Object> request = new java.util.LinkedHashMap<>(); |
| | | |
| | | HuifuParentMerchant.apply(request, " "); |
| | | |
| | | assertThat(request).doesNotContainKeys("head_office_flag", "head_huifu_id"); |
| | | } |
| | | |
| | | @Test |
| | | void aParentTurnsTheMerchantIntoItsDirectSubMerchant() { |
| | | Map<String, Object> request = new java.util.LinkedHashMap<>(); |
| | | |
| | | HuifuParentMerchant.apply(request, "6666000226048687"); |
| | | |
| | | // 0 = 下级商户;上级商户号必填,平台之后才有权为它配置微信支付与业务开通。 |
| | | assertThat(request).containsEntry("head_office_flag", "0"); |
| | | assertThat(request).containsEntry("head_huifu_id", "6666000226048687"); |
| | | } |
| | | } |