# 商户进件到微信收款能力:企业进件核查 + 个人主体补齐 日期:2026-09-16 核查方式:只读代码 + 汇付官方字段合同(`$huifu-merchant-onboarding` 技能 references)+ 仓内归档的 微信实名与业务开通文档(`.scratch/merchant-wechat-onboarding/`)。 本文件是**交付记录**:先给核查结论,再给本次改动与剩下的待办/待确认项。 --- ## 一、结论速览 | 主体 | 之前的状态 | 本次结论 | | --- | --- | --- | | 企业 | 六阶段闭环完整,能拿到微信收款能力 | **完善**。4 处次要缺口已记录,均不阻塞收款 | | 个人(小微) | 链路通、但**材料缺一半**,微信业务开通与微信实名都不可能通过 | 本次补齐门店材料与行业 MCC,**代码侧完成**;剩两端页面验收与真实联调 | 个人主体原先的问题不是"少一两个字段",而是**在"进件成功 → 微信收款"这条路上有两处必然卡死**: 门店照片一个字段都没收(而微信业务开通与微信实名都要),以及商户在进件表单里选的行业被静默丢弃。 详见第三节。 --- ## 二、企业进件核查:闭环完整 链路已闭环,且关键判定都以通道证据为准,不是本地标志位: - **六阶段进度模型**:`MerchantOnboardingProgressService` 的 `basic / wechat / appId / realname / delay / settlement` 六个阶段,各自 `confirmed / pending / failed / missing / action_required / unknown`。 判定读的是通道返回的 `pay_scene` 三场景、`fee_rate`、`fee_min_amt`、`settle_status/settle_cycle`、 `delay_flag/forced_delay_flag`、`authorize_stat` 等真实字段。 - **进件 → 配置 → 开通 → 实名**:`submitEnterpriseOnboarding` → `v2.merchant.busi.config`(微信小程序配置) → `v2.merchant.busi.open`(微信业务 + 延迟入账 Y / 强制延迟 N)→ `v2.merchant.busi.realname`(走法人)。 - **刷新语义**:`refreshOnboarding` 先查通道、再在满足前置条件时继续推进;提交前用 `wechatConfigEffective` / `wechatBusinessEffective` 探活,避免给同一商户开出第二份申请。 - **局部重试**:`isRetryable` 只看 `wechat_config_status` / `business_open_status` 是否显式 `failed`, 基本进件已通过时保留商户号与银行卡关系。 - **收款就绪闸门**:`MerchantPaymentReadinessService` 唯一决策点,要求可取现 + `merchant_stat` 正常 + 分阶段进度里 `wechat` 与 `realname` 双 `confirmed`;订单/支付侧三个入口都过它。 - **权限**:商户端按 `merchant_id + store_id` 定位申请,不接受前端传入的任意汇付商户号。 - **测试**:进件/回调/能力/重试/实名各有一组集成测试,另有汇付报文契约测试。 ### 企业侧记录在案、本次**未改**的 3 处缺口 1. `cash_config[].fee_rate` 传的是空串,官方要求"需保留小数点后两位,不收费请填写 0.00"。 同一条 `cash_config` 由企业与个人共用,`fix_amt=0.00` 已满足"至少填一项"。 2. 个体工商户(`subject_type=enterprise` + `company_type=个体工商户` → 企业接口 + 对私结算) 这条组合没有测试覆盖。企业/个人两条主线都有测试,唯独这个组合是靠代码读出来的。 3. 个人主体没有单独收「经营详细地址」(见第三节最后一条)。 > 原先记的 `settle_config` 缺 `fixed_ratio` / `constant_amt` 已于 2026-09-16 按业务口径修掉, > 不再列为缺口;见第五节「业务拍板落地」。 --- ## 三、个人(小微)主体:原状与缺口 ### 原先已经能跑的部分 商户端「企业/个人」主体 picker → `v2.merchant.basicdata.indv`(个人商户进件)→ `subject_type='individual'` 落库 → 对私结算凭证(F13 银行卡卡号面)→ 绑卡 `v2.user.busi.open` 且 `card_type=1` → 微信业务开通带 `micro_biz_type` → 实名 `contact_type=LEGAL`。**骨架是通的。** ### 4 处缺口(本次修复) | # | 缺口 | 证据 | 后果 | | --- | --- | --- | --- | | 1 | **门店照片(F22 门头照 / F24 内景照)完全没有** | 业务开通文档 `micro_biz_type` 条目:个人商户需要填写,且"需要提供 F22 店铺门头照图片;F24 店铺内景图片,补充资料填写在 extended_material_list 对象中";实名文档小微路径另有 `store_header_copy` / `store_indoor_copy` | 微信业务开通缺材料,微信实名缺材料 → **个人商户拿不到微信收款能力** | | 2 | **商户在进件表单选的"行业类型"被静默丢弃** | 表单把它塞进 `companyType`,而个人进件的 `mcc` 取的是 `merchant.merchant_type_code`(商家档案),个人网关根本不读 `company_type` | 档案没配好就一律落 `7399`(其它),通道侧微信行业类目与费率跟着一起错;商户页面上明明选过行业 | | 3 | 实名的**小微门店字段一个都没发** | 实名文档小微商户字段:`micro_biz_type` / `store_name` / `store_header_copy` / `store_indoor_copy` / `store_address_code` / `store_address` / `identification_front_copy` / `identification_back_copy` | 同上,缺材料必被驳回 | | 4 | **个人进件零测试覆盖** | 全仓没有构造 `subject_type='individual'` 的进件测试 | 上述三处缺口不会被任何测试拦住 | ### 1 处缺口(本次仍未做,需业务决定) 5. **个人主体没有单独收"经营详细地址"**:表单只有省市区,`registered_address` 存的是证件地址, 被同时当作 `legal_addr`(证件地址,正确)与 `detail_addr`(经营详细地址,语义不符但 `scene_type=ONLINE` 时非必填)。 同步进件时补一列更干净;本次没动,避免扩大范围。 实名侧的 `store_address` 先用商家档案上的门店地址,档案为空才退回这个证件地址。 > 原先记的「`micro_biz_type` 取值」已由业务拍板:**保持 `MICRO_TYPE_ONLINE`,不改配置文件**。 --- ## 四、本次改动清单 ### 后端 | 文件 | 改动 | 为什么 | | --- | --- | --- | | `db/migration/V146__merchant_onboarding_store_photos.sql` | 新增 `store_header_url` / `store_indoor_url` | 两条链路都要这两张图,原先没有存放位置 | | `MerchantOnboardingSchemaInitializer` | 补同两列 | 开发库 `flyway.enabled=false`,靠初始化器补建 | | `MerchantOnboardingRequest` | 新增 `storeHeaderUrl` / `storeIndoorUrl` | 进件请求接收门店照 | | `MerchantSettlementService` | ① 个人主体提交时校验两张门店照;② 进件 INSERT 带上两列;③ 新增 `mcc(merchantId, subjectType, industryType)` 与可直接断言的 `mccForIndustry`;④ 个人进件时上传 `store_header_pic`(F22)并下发;⑤ 业务开通前为个人主体上传 F22/F24 并下发,缺图收口成 `business_open_status='failed'` 并给出补齐指引;⑥ 新增 `supplyMissingStorePhotos`:存量个人进件缺图时,商户重新提交可以只把这两项补上 | ① 缺了照样能提交,但微信开通与实名一定被驳回,那时商户要重走整份进件;② ③ 个人主体的 MCC 改用商户自己选的行业,识别不出来才退回商家档案;④ `store_header_pic` 在进件侧是必填,原先一条都没传;⑤ 门店照随业务开通一起交,缺图不发请求——发出去也是被驳回,还会在通道侧留下缺材料的申请;⑥ 见下方「存量记录」 | | `HuifuIndividualOnboardingGateway` | 下发 `store_header_pic`;证件有效期改为**显式按 `validity_type` 判断**截止日期(长期有效不发 `legal_cert_end_date`,`card_info.cert_end_date` 同理) | 必填项漏发会被驳回;长期有效不填截止日期是确认过的口径,不该只靠"空就不发"隐式成立 | | `HuifuEnterpriseOnboardingGateway` | `settleConfig()` 补 `fixed_ratio` / `constant_amt` = 0.00(企业与个人共用);`legal_cert_end_date` 同样改为按有效期类型判断 | 两项在 D1 下是条件必填,不传会被判缺参数;平台不收这笔钱所以填 0.00 | | `HuifuMerchantBusinessOpenGateway` | 个人主体的 F22/F24 放进 `extended_material_list`;企业主体一个都不放 | 官方要求挂在 `extended_material_list`;不给企业商户凭空加材料要求 | | `HuifuMerchantRealnameCommand` | 新增嵌套 `MicroStore`(非 null 即个人/小微主体) | 实名的字段名与业务开通不同,必须分开建模 | | `HuifuMerchantRealnameGateway` | 小微主体下发 `store_name` / `store_header_copy` / `store_indoor_copy` / `store_address_code` / `store_address` / `identification_front_copy` / `identification_back_copy`;`identification_address` 改为只对企业下发 | 文档写明 `identification_address`「主体类型为企业时,需要填写。其他主体类型,无需上传」 | | `MerchantRealnameService` | 小微材料缺项一次说清(门店门头照/店内环境照/门店名称/身份证正反面/门店地址);现传现取 F22/F24/F40/F41 的 fileId | 实名被驳回要换业务申请编号重来,缺材料宁可前置拦住 | ### 前端 | 文件 | 改动 | | --- | --- | | `rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue` | 个人主体第 1 步新增「店铺照片」「店内环境照」两个上传项:校验、图片上传分支、草稿保存与恢复、提交映射(仅个人主体提交这两个字段)。「店铺照片」的提示按三种小微经营类型说明该传什么图 | | `talent-card-admin/src/views/MerchantOnboardingDetailView.vue` | 个人详情补展示经营行业、经营地区、个人证件地址、邮箱、身份证正反面、结算卡卡号面、店铺照片、店内环境照——运营核对开通失败原因时要看得到 | ### 测试 | 测试 | 钉住什么 | | --- | --- | | `MerchantOnboardingIndustryMccTests`(新增,3 项) | 行业→MCC 映射(含别名与规范化代码)、识别不出来时返回空串交给档案兜底 | | `HuifuIndividualOnboardingRequiredFieldsTests`(新增,5 项) | `store_header_pic` 在进件侧必传(线上小微也一样);长期有效不发两个截止日期、非长期都发;D1 结算带 `fixed_ratio`/`constant_amt`=0.00,企业与个人同一份配置 | | `HuifuBusinessOpenRequiredFieldsTests`(+4 项) | 个人主体 `extended_material_list` 带 F22/F24;缺一张只发一张、不塞占位图;企业主体不带 | | `HuifuMerchantRealnameTests`(+2 项) | 小微实名的 8 个门店/证件字段与**字段名**(`*_copy` 不是 `*_pic`);企业不带小微材料、`identification_address` 不下发 | | `MerchantRealnameServiceTests`(原有,本次 +1 项二维码) | 个人主体四张图各按官方文件类型上传、门店名称/地址/省市编码取值;企业不带小微材料也不传门店照;缺门店材料在任何通道调用前报错;授权二维码无论通道回 JPEG 还是 PNG 都要拼成可用的 data URL,非图片内容返回空串 | | `MerchantExistingOnboardingTests`(存量补齐 +2 项) | 重新提交能补上空着的门店照、不新建进件、原汇付商户号与银行卡关系不动;已有值不被覆盖 | | `MerchantExistingOnboardingTests`(开通 +5 项,另删 1 项被取代的) | 配置要等通道确认微信业务才发(两阶段:先只提交业务开通、通道开通后才发配置且业务开通不重复提交)、配置失败不影响业务开通收口、延迟入账失败保留通道描述、业务开通被驳回后重试换新流水号、微信配置被驳回后重试换新流水号 | | `HuifuBusinessOpenRequiredFieldsTests`(新增 2 项费率号) | 小微用 775 不用企业的 756;个体工商户用 761 且仍带 mcc | --- ## 四之二、存量记录:为什么必须给一条「补齐门店照片」的路 核查时查了本机开发库(`talent_card`),**不是推测**: ``` subject_type=individual n=10 with_huifu=3 subject_type=enterprise n=3 with_huifu=2 ``` 10 条个人进件里有 3 条已经拿到真实汇付商户号(`6666000239896857`、`6666000235462868`、 `6666000241386200`),而**全部 10 条的 `business_open_status` 都是 `not_started`**——一次业务开通都没做过, 门店照片列全为空(新列)。 这暴露了两个问题,都必须处理: 1. 这些记录一旦走到业务开通,就会撞上新的门店照校验。而分阶段模型下 `submitOnboarding` 对 `supports()` 的申请是**直接返回原记录、不新建进件**(这正是"不重复申请"的设计), 于是商户端也没有别的补图入口 → 永久卡死。 2. 汇付商户号是真金白银开出来的(每条都在合作伙伴控台留了记录、不可删除), 所以不能"关掉重新进件"绕过。 因此 `supplyMissingStorePhotos` 是必需的,不是顺手加的:重复提交时不新建进件, 但**只把空着的门店照片补上**,其余一律不动。这条路径同样适用将来的场景: 商户换了门店、想把旧照片换成新的(已有值不覆盖,要改图得走别的入口)。 ### 顺带查到的环境问题:开发库缺 `legal_mobile` 列 查库时发现本机开发库 `talent_card.merchant_onboarding_application` **有 `realname_*`、也有本次新增的 `store_header_url/store_indoor_url`,唯独没有 `legal_mobile`**。这是 `MerchantOnboardingSchemaInitializer` 设计内的失败模式:`addColumn` 把 `lock_wait_timeout` 压到 5 秒,拿不到锁就放弃这一列并记一条日志。 **影响**(两处都会直接失败,且报错信息不会指向真正的原因): - `submitOnboarding` 的 INSERT 显式列出 `legal_mobile` → 开发环境**任何新进件提交都会报 `Unknown column 'legal_mobile'`**。 - `MerchantRealnameService` 从 `select *` 读 `legalMobile` → 读不到,实名会被拦成 「发起实名还缺:法人手机号」,看起来像商户没填。 **2026-09-16 排障结论**(用户重启后仍在报错,逐项查证): 1. 开发库 Flyway 是关的(`flyway.enabled=false`、`flyway_schema_history` 0 行),列只由初始化器补; 2. 初始化器**确实在尝试**:用与 `hasColumn` 完全相同的 JDBC metadata 查询(原样 pattern)查 `legal_mobile` 返回 0 行,说明不是"误判成已存在而跳过"; 3. 表里物理列顺序为 `... settlement_card_front_url, realname_status, ..., store_header_url, store_indoor_url`——`legal_mobile` 本该夹在 `settlement_card_front_url` 与 `realname_status` 之间。 **同一轮循环里它后面的列都加成功、只有它没有**,排除"初始化器没跑"; 4. 用**同样的 5 秒超时**手工执行那条 ALTER 一次成功(当时表上无锁等待、无未提交事务)。 结论:启动瞬间抢排他元数据锁失败(旧连接池/IDE 会话仍占表),5 秒拿不到就放弃——就是 初始化器注释里记的 2026-09-15 那个形态。代码侧已加固:`addColumn` 的失败日志从 `warn` 升为 `error`,并直接打印补救用的 `alter table ... add column ...` 语句。原先那句 "缺列只影响重试功能,不影响整个服务" 对 `legal_mobile` 已经不成立——它写在进件 INSERT 的显式列清单里。 **已处理**:手工补上了 `legal_mobile`,并把初始化器要建的 49 列与库里实际的列做了一次完整比对, **已无缺失**(`merchant_bank_card` 的列与两张辅助表也都在)。SQL 按列名解析,无需重启即可提交进件。 --- ## 五、验收与未验证项 - 相关测试:`MerchantOnboardingIndustryMccTests` 3 项、`HuifuIndividualOnboardingRequiredFieldsTests` 5 项、 `HuifuBusinessOpenRequiredFieldsTests` 9 项、`HuifuMerchantRealnameTests` 7 项、 `MerchantRealnameServiceTests` 11 项、`MerchantExistingOnboardingTests` 21 项,全部通过; V146 迁移在测试库上实际执行通过(初始化器与迁移都实际建出了新列)。 - **全量回归**:243 项,1 failure + 2 errors,**与改动前记录的 3 项基线失败完全一致**,无新增失败。 过程中一度新增过一项失败(`MerchantExistingOnboardingTests.refreshingAdvancesTheWechatProvisioningSteps`), 原因是业务开通网关的 `open()` 多了两个参数、测试桩还是旧签名;已改测试桩并复跑确认。 (`target/surefire-reports` 里的 `AutoWithdrawalAggregationTests.txt` 是 9 月 14 日的陈旧文件, 对应源码已不存在,不参与本轮统计。) - **未验证**:商户端 `.uvue` 与后台 `.vue` 的编译与页面验收。本仓前端没有 CLI 构建链 (产物在 `unpackage/dist/`,走 HBuilderX),改动是按既有 `upload-box` 模式机械复制的, 需要按工单在真机/开发者工具上过一遍。 - **未验证**:真实通道联调。个人主体至今没有任何一笔真实进件,门店照是否被通道按预期接受 都要用真实个人商户验证,**不能用"模拟测试通过"代替**。 - 真实联调仍受既有约束:不反复创建真实新商户试错,失败先查原申请。 --- ## 六、业务拍板落地(2026-09-16) 五个待确认项已由业务逐条答复,全部落地: | # | 答复 | 落地情况 | | --- | --- | --- | | 1 | `micro_biz_type` 保持 `MICRO_TYPE_ONLINE`,不改配置文件 | 不动 `application.yml`。实物已按此口径写进商户端提示与代码注释 | | 2 | `settle_config` 的 `fixed_ratio` / `constant_amt` 都填 `0.00` | `HuifuEnterpriseOnboardingGateway.settleConfig()` 补上两项(企业与个人共用) | | 3 | 证件有效期类型传 `1`(长期有效)时 `legal_cert_end_date` 不需要填写 | 两个进件网关都改成**显式按有效期类型判断**,不再只是"空就不发";个人进件的 `card_info.cert_end_date` 同样处理 | | 4 | `store_address_code` 传 6 位地市级行政区划编码(省 2 位 + 市 4 位) | **无需改动**:本来就取商户端"经营地区"选择器返回的 `registered_city_code`,形如 `510100`,正是省 2 位 + 市 4 位 | | 5 | 三种小微经营类型的 `store_header_pic`(F22)在进件时**都必填**,只是图片内容不同:线上=店铺首页截图、流动经营/便民服务=经营现场照、门店场所=实体门头照 | ①个人进件请求补上 `store_header_pic`(原先只有业务开通的 `extended_material_list`,进件侧完全没传);②商户端该字段改名「店铺照片」,提示按三种情况分别说明;③业务开通侧继续带 F22/F24 到 `extended_material_list` | ### 2026-09-16 追加两条口径(第二批复核时确认) | # | 口径 | 落地情况 | | --- | --- | --- | | 6 | **行业由系统动态匹配,商户改不了**(已进件商户也不提供补改入口) | 改的是 MCC 的**取值优先级**:此前是"商户在进件表单选的行业类型"优先于商家档案,与这条口径相反。改为**系统档案优先**——经营类型是我们自己维护的、也是匹配微信行业类目与费率的依据,不该被表单上的一次选择盖掉;商户申报的行业类型只在档案给不出具体行业(未配或配成「其它」)时兜底,避免档案缺数据时一律落 7399。见 `MerchantSettlementService.preferConfiguredMcc` 与 `MerchantOnboardingIndustryMccTests` | | 7 | 「只认延迟失败的重试入口」这件事**暂时不做** | 不改代码。现状:`retryable` 仍等于「延迟入账失败」,业务开通/微信配置失败时两端没有专门的重试按钮,靠「刷新进度」隐式重试(刷新本身会重发失败的步骤) | 第 5 条是这一轮里唯一**真实漏发**的字段:`store_header_pic` 在进件的官方说明里是必填, 而项目此前一条都没传过。三处消费同一张图的路径现在都齐了——进件 `store_header_pic`、 业务开通 `extended_material_list` 的 F22、实名 `store_header_copy`。 原有测试全部随之复跑;第 2/3/5 条由 `HuifuIndividualOnboardingRequiredFieldsTests` 5 项钉住, 第 6 条由 `MerchantOnboardingIndustryMccTests` 5 项钉住。 --- ## 七、仍需官方确认 **只剩这一条**(其余此前列过的都已由业务答复并落地,见第六节的两张表): - 业务开通的 `wx_real_name_info.micro_biz_type` 与独立实名接口的 `micro_biz_type` 是否都需要: 本项目 `is_check_real_name=N`(不在业务开通里发起实名),却仍在 `wx_conf_list` 里带 `micro_biz_type`。 两处都不缺材料,保留即可;若确认业务开通侧不需要,去掉的是冗余而不是能力。 > 这一条**未被业务答复过**,只是不阻断开通,所以留在这里。 ### 已定的两条(原「剩余待讨论」,2026-09-16 关闭) - ~~两端的「重试」入口只认延迟失败~~ → **暂时不做**(见第六节第 7 条)。 - ~~已进件商户能否补改经营行业~~ → **不提供补改入口,行业由系统动态匹配**(见第六节第 6 条)。 先前记的「452 进件时选的「其它」与实际行业不一致」这条顾虑也随之消解:按新口径,取值以系统档案为准, 商户表单上的申报值只是档案缺数据时的兜底,本来就不该作为口径依据。 --- ## 七之二、开通顺序缺陷:微信配置排在业务开通之前(2026-09-16 修复) ### 现象 用户端看到「微信支付 未确认 / 人才端绑定 待补齐 / 实名与授权 待补齐 / 延迟入账 失败」。 查库(申请 id=452,个人主体,汇付号 `6666000241804122`,2026-09-16 新提交): ``` wechat_config_status = failed reject_reason = 微信商户配置失败:根据渠道号或微信子商户号未查到入驻记录 business_open_status = not_started ← 业务开通一次都没提交过 ``` ### 根因 `continuePostOnboardingProvisioning` 是**先**「微信小程序配置」(`v2.merchant.busi.config`)、 **后**「商户业务开通」(`v2.merchant.busi.open`)。而**微信子商户号是业务开通创建的**: - 通道那句回执就是证据——「根据渠道号或微信子商户号未查到入驻记录」,配置要挂的子商户号还不存在; - 仓库自己归档的真实通道记录(`evidence-2026-09-15.md`):存量商户业务开通成功后读回 `wx_conf_result_list` 三个场景 `wx_conf_status=S`、原因「入驻成功」、`sub_mer_id=W15737921333057628112`; 当初那两个探针的执行顺序也正是 `OpenExistingMerchantWechat`(补开)→ `ConfigureExistingMerchantWechat`(绑定 AppID); - 配置失败后**直接 `return`**,业务开通永远轮不到,于是微信支付、人才端绑定、实名全部连坐 (实名还要以「微信支付已确认」为硬前置)。 之前没暴露,是因为只验证过存量/参考商户——它们的微信业务早就开通,配置有东西可挂。 **这个顺序不修,任何新商户(企业、个人都一样)都开不出微信收款能力。** ### 改动 | 位置 | 改动 | | --- | --- | | `MerchantSettlementService.continuePostOnboardingProvisioning` | 拆成 `advanceBusinessOpen` → `advanceWechatConfig` 两步顺序执行,**任何一步失败都不再阻断另一步**,各自记状态、各自可重试 | | 同文件新增 `businessOpenAccepted` | 业务开通未被受理(`business_open_status` 不是 `processing`/`approved`)时**不发配置请求**:通道侧没有子商户号,发了必然被驳回,只会留下一条会误导人的「微信配置失败」。保持原状态,等业务开通推进后再配 | | `MerchantOnboardingProgressService.advanceDelay` | 延迟入账失败时把通道描述写进 `merchant_onboarding_attempt.response_message`(列已存在于 V144,无需迁移)。原先只存 `response_code`,而这条失败回的是 `99999999`(通道「失败:<原因>」通用码),描述一丢就完全查不出原因 | | `HuifuMerchantBusinessOpenGateway` | `wx_conf_list` 的 `mcc` 改为**独立于 `fee_rule_id` 下发**:新增 `mccRequired` 参数,个体工商户与小微商户(个人主体)都传 true。原先 mcc 写在 `fee_rule_id` 的 `else` 分支里,平台配了规则号 756 之后小微的 mcc **永远发不出去** | `needsProvisioningStep` 只认 `blank/not_started/failed`(`processing` 由进度刷新负责提升为 `approved`)——那是刻意的分工,本次没动。 ### 修复顺序后暴露的第二层:小微缺 `mcc` 按新顺序刷新后,业务开通被真的提交了,通道回: ``` {"resp_desc":"wx_conf_list格式错误","resp_code":"00000001"} ``` 查官方文档与请求示例: - `wx_conf_list[].mcc` 一行写「**个体工商户、小微商户必填**;企业使用 `fee_rule_id` 代替原有 mcc, mcc 可不填」; - 官方请求示例里 `mcc` 与 `fee_rule_id` **是同时出现的**: `"wx_conf_list":"[{\"pay_scene\":\"1\",\"fee_rate\":\"0.38\",\"mcc\":\"111\",...,\"fee_rule_id\":\"758\",...}]"`。 而网关里 mcc 原本写在 `fee_rule_id` 的 `else` 分支中,`merchant-wechat-fee-rule-id` 默认就是 `756`(`application.yml:100`)——**小微/个体工商户的 mcc 一次都没发过**。代码注释甚至写着 「个人主体属于小微商户,所以必须带上」,但那条分支在有规则号时根本到不了。 顺带确认 452 将上报的 MCC:进件时行业选的是「其它」(`mccForIndustry` 返回空 → 退回商家档案), 档案经营类型是 `food` → `5812`(餐饮),值是对的。 ### 对已有数据的效果 id=452 不需要重新进件、不会在控台多出商户记录:`initial_bank_card_id` 非空、`status=pending`, 走的就是分阶段可推进模型。修完之后点一次「刷新进度」即会 **业务开通 → 微信配置** 依次推进;延迟入账会随业务开通受理而自愈(`busi.open` 的请求体本就带 `delay_flag=Y/forced_delay_flag=N`,受理后读回即置为 confirmed);最后实名仍需商户本人发起并扫码授权。 ### 第三次失败:`wx_real_name_info` 被双重编码 补上 `mcc` 后重发,通道**仍回** `wx_conf_list格式错误`(流水号已换成新的 `...BOR36652`, 说明换号修复生效)。与那条**成功过**的企业报文逐项对比,个人主体只多了两个字段: `mcc` 与 `wx_real_name_info`。加上 mcc 没解决,嫌疑就集中到 `wx_real_name_info`。 决定性证据来自技能字段目录(`merchant-onboarding-field-catalog.md`)的类型列: ``` request.data.wx_conf_list[] | String | jsonArray格式 request.data.wx_conf_list[].mcc | String | 个体工商户、小微商户必填 request.data.wx_conf_list[].wx_real_name_info | Object | 实名时需要填写 jsonObject字符串 request.data.wx_conf_list[].wx_real_name_info.micro_biz_type | String | 32 ``` `wx_conf_list[]` 本身是 **String**(整个数组按 jsonArray 字符串发,已验证可用),而 `wx_real_name_info` 的类型列是 **Object**,子字段按 `wx_real_name_info.xxx` 逐层展开。 代码却写了 `JSON.toJSONString(realName)` —— 在数组元素里又编码了一层字符串,通道按对象取字段取不到, 回的就是「格式错误」。网关自己的 javadoc 写着「父字段列 + 同名子表的写法约定即为嵌套对象」, **实现与它自己的注释不一致**。 改法:`wechat.put("wx_real_name_info", realName)`(嵌套 Map)。 测试从"把值当字符串 parse"改为断言该值是 `Map` 实例。 > 记录一个易混淆点:技能目录里 `material_card_info` 是**已登记的官网勘误**——类型列写 `Object` > 但项目已确认按「jsonObject 字符串」发。`wx_real_name_info` 不在这份勘误名单里,所以按类型列 > 走嵌套对象。两者不能互相外推。 ### 顺手补的观测能力 业务开通网关现在会把 `wx_conf_list` 一起打进日志(不记 `extended_material_list`——里面是图片标识, 按高敏感数据不入日志)。这一轮定位靠手工反推报文,多烧了两轮真实请求,加这一行之后同类问题一眼可见。 ### 第四次失败:费率规则号按主体类型分,不能全平台共用 嵌套修好后格式被接受了,通道改回: ```json {"resp_desc":"微信费率规则号填写不正确,请检查!","resp_code":"99999999"} ``` `fee_rule_id=756` 是**企业**那一条。`.scratch/merchant-wechat-onboarding/wx-fee-rule-id.xlsx` (汇付《微信费率规则ID》对照表)的列头是 `支付场景(pay_sence) | 业务开通类型(fee_type) | 主体类型 | CID结算规则ID(fee_rule_id) | 适用行业 | 细分行业` ——CID 是**按「场景 × 类型 × 主体类型」三维定位**的。本平台只开线上公众号/小程序/线上反扫 (场景 4/5/12)、类型恒为 01,对应表中的「4、线上公众号 5、线上小程序 / 01」区块: | 主体类型 | CID | | --- | --- | | 企业 | 756 | | 个体户 | 761 | | 事业单位 | 773 | | 社会组织 | 774 | | **小微** | **775** | (同表「1、线下反扫 2、线下公众号 3、线下小程序 / 02」区块里小微是 770——那是线下场景, 我们不发那些 pay_scene,不适用;这也解释了为什么不能套用。) 改动:`HuifuPaymentProperties` 新增 `merchantWechatFeeRuleIdIndividual`(默认 775)与 `merchantWechatFeeRuleIdSoleProprietor`(默认 761),`application.yml` 各配一个可覆盖的键; `HuifuMerchantBusinessOpenGateway.feeRuleId(individualMerchant, mccRequired)` 按主体类型选号—— 个体工商户落的是企业进件接口(`individualMerchant=false`),所以判据用 `mccRequired` (它覆盖的正是「个体工商户与小微商户」这一组)。企业仍用原来的 `merchant-wechat-fee-rule-id`。 ### 网络往返记录 个人商户这条路径一共试了 4 次真实**写**请求(另加 1 次只读查询核对回执),每次剥出一个 **独立的字段缺陷**: | # | 现象 | 真因 | | --- | --- | --- | | 1 | 配置报「未查到入驻记录」,业务开通从未提交 | 顺序反了:微信配置排在业务开通之前 | | 2 | `wx_conf_list格式错误` | 小微缺 `mcc`(写在 `fee_rule_id` 的 else 分支里,配了号就发不出) | | 3 | `wx_conf_list格式错误`(仍在) | `wx_real_name_info` 双重编码:类型列是 `Object`,代码发了 JSON 字符串 | | 4 | `微信费率规则号填写不正确` | `fee_rule_id` 按主体类型分,给小微发了企业的 756 | | 5 | 5/6 阶段绿,`wechat` 永远未确认(读回执才定位) | 判定要求 `fee_min_amt` 必须出现且为 0,而通道不设最低收费时不返回该字段 | 五处都是"文档/对照表写了、代码没照做"或"代码和自己的注释不一致",且**只在个人主体上才暴露**: 企业那条路径既不缺 mcc、不带 `wx_real_name_info`、费率号正好是对的,`fee_min_amt` 也是有的, 所以之前用存量商户验证时全被掩盖。这也是"用企业主体验证过通道能力"不能替代"新主体流程已验收" 的一个具体例证。 ### 第五步:业务开通受理成功,配置的闸门改用通道证据 费率号换成 775 之后,业务开通**受理成功**: ``` requestNo=MO202609161129468536BOR39236 wxConfList=[{"pay_scene":"4","fee_rule_id":"775","mcc":"5812","fee_rate":"0.70", "is_check_real_name":"N","wx_real_name_info":{"micro_biz_type":"MICRO_TYPE_ONLINE"}}, …5…, …12…] response={"resp_desc":"审核中","apply_no":"2026091630948832","resp_code":"90000000"} ``` 四个字段缺陷全部修掉。紧接着的配置请求仍报「根据渠道号或微信子商户号未查到入驻记录」—— 暴露了**上一轮闸门取错了依据**:我原本用本地状态(`processing`)当闸门,理由是"业务开通被受理时 子商户号当场就建好了"。实际**微信入驻是异步的**:`90000000` 只表示申请被受理,那一刻通道侧还查不到 子商户号。所以闸门必须取通道证据(`qry_wx_conf_list` 里真的出现线上场景),不能取本地状态。 改法:`advanceWechatConfig` 的闸门由 `businessOpenAccepted(本地状态)` 换成 `wechatBusinessEffective(通道查询)`, 并删掉随之无用的 `businessOpenAccepted`。代价是要多刷一次(入驻完成后再刷才带上配置),换来的是 不发注定被拒的请求、也不留下会误导人的「配置失败」。 ### 顺带修掉的:`processing` 永远不会被推进到 `approved` 写测试时发现 `needsProvisioningStep` 只认 `blank/not_started/failed`,**不含 `processing`**, 而 `advanceBusinessOpen` 的通道探针原本也套在这个条件下——于是一旦本地是 `processing`, 后面无论通道怎么确认都不会收口成 `approved`。而 `approved` 正是 `completePostOnboardingProvisioning` (能力快照刷新的入口)与「整单完成」的判据。**没有回调时(本地开发收不到异步通知)整单永远完不成, 能力快照也永远不刷新**,商户明明开通了却推不到收款就绪。 改法:探针条件改为 `!"approved".equals(businessStatus) && wechatBusinessEffective(huifuId)`, `processing` 也能按通道证据收口。这正是规格里"无回调场景能按人工操作推进到真实已完成"那一条。 ### 第六步:`fee_min_amt` 缺失被判成"没设最低收费 = 未生效" 配置成功后商户端 6 项里 5 项绿,只剩「微信支付 · 待补齐」,而 `sync_error` 是空的 ——说明通道查询成功,是数据没通过那条判定。用 `.scratch/merchant-wechat-onboarding/QueryCapabilities.java` (只读探针,只允许查询端点)拉了一次真实回执,`qry_wx_conf_list` 是: ```json [{"fee_charge_type":"1","fee_rate":"0.70","out_fee_flag":"2","pay_scene":"4", "wx_mer_infos":[{"bank_mer_code":"922609119","is_default":"1","sub_mer_id":"W15737921333057637173"}]}, {…"5"…},{…"12"…}] ``` 三场景、费率 0.70、内扣、子商户号与默认标识**全部满足**——微信业务确实开好了。 问题在于**响应里没有 `fee_min_amt` 字段**,而判定写的是 `zero(item.get("fee_min_amt"))`, `decimal` 里带 `value != null` 判空 → 缺失即 false → 该阶段永远不成立。 `fee_min_amt` 是可选字段、文档写明「大于 0」,不设最低收费时我们本来就不下发它、通道也就不返回它。 改法:新增 `zeroUnlessAbsent`,给"字段缺失就等于没设"的可选金额字段用;只放宽"缺失", 真设了非 0 的最低收费仍判未生效。两个测试互为对照。 ### 附:被取代的测试用例 `MerchantExistingOnboardingTests.refreshingAdvancesTheWechatProvisioningSteps` 断言"一次刷新里 业务开通与配置都提交",这在闸门改成通道证据后**不可能成立**(配置要等通道确认,而通道确认时 本地业务开通已被探针收口、不会再提交)。该用例的意图(刷新必须真的发起开通步骤,而不只是查) 已由新的 `theWechatConfigWaitsUntilTheChannelConfirmsTheWechatBusiness` 两阶段断言覆盖, 故删除旧用例并在新用例的 javadoc 里说明承接关系。 ### 第七步:授权二维码被判成非法——`qrCode` 写死了 PNG 实名申请受理成功(`resp_code=00000000`、`applyment_id=2000002830540567`),但商户端没有二维码, 而"本人扫码授权"是完成实名的唯一途径。用只读探针查 `v2.merchant.busi.realname.query`: ``` "applyment_stat":"APPLYMENT_STATE_WAITTING_FOR_CONFIRM_CONTACT", "authorize_stat":"0", "qrcode_data":"/9j/4AAQSkZJRgABAQAAAQABAAD…" ← 长度 30216,JPEG ``` 通道**确实给了码**,而且两端页面本来就在渲染 `stage.qrCode`(商户端 ``、后台 ``)。 问题在中间的转换: ```java // 改前 String value = raw.replaceFirst("^data:image/png;base64,", ""); if (value.length() > 200000 || !value.startsWith("iVBORw0KGgo") || ...) return ""; return "data:image/png;base64," + value; ``` `iVBORw0KGgo` 是 **PNG** 的 base64 magic,而通道回的是 JPEG 的 magic `/9j/`。 于是这段被判成非法 → 返回空串 → 两端都空白。体积(30216 < 200000)和字符集都过关, **只有格式假设错了**。 改法:按 magic 判定真实图片类型再拼 data URL(PNG `iVBORw0KGgo` / JPEG `/9j/`), 已带前缀的 `data:image/(png|jpeg);base64,` 也认、不重复叠加;非图片内容仍返回空串, 不把任意内容拼进 `img src`。 状态映射本身是对的:`APPLYMENT_STATE_WAITTING_FOR_CONFIRM_CONTACT` 落到 `action_required`(待办理), 与"本人扫码授权"的语义一致,未改。 ### 测试微信配置要等通道确认(两阶段:先只提交业务开通、 通道开通后才发配置且业务开通不重复提交)、配置失败不影响业务开通收口、延迟入账失败保留通道描述、 业务开通被驳回后重试换新流水号、微信配置被驳回后重试换新流水号、`fee_min_amt` 缺失算生效、 非 0 最低收费仍不算生效,另加存量补齐 2 项(补图不新建进件、已有值不覆盖)。 `HuifuBusinessOpenRequiredFieldsTests` 新增 3 项:个体工商户带 mcc 且用 761、小微用 775 不用 756、 企业配了规则号就不带 mcc(互为对照)。 ### 重试换号(同批次一并修掉) 原先这两步失败后重试**复用同一个流水号**(`businessOpenRequestNo` / `wechatConfigRequestNo` 保持不变)。 通道按流水号去重,被驳回的申请沿用旧号会被当成重复请求;进件与实名本来就走"驳回换新号" (`MerchantRealnameService.requestNo` 每次带随机后缀、延迟入账每次用新 UUID)。 改动:`provisioningRequestNo` 增加 `retry` 参数,明确失败时在尾部追加 5 位随机数 (`applicationNo + "BO" + "R" + 随机数`);受理结果未知(`processing`)时不换号,避免为同一次提交 开出第二份申请。随机后缀必须保住——先按剩余宽度裁前缀再接后缀,否则超长截断会把随机位一起切掉, 两次重试又变成同一个号。 ### 剩余待讨论 (无。原两条已于 2026-09-16 关闭:重试入口暂不做,行业不提供补改入口、由系统动态匹配,见第六节。) --- ## 八、外部资料提示 命中外部资料的字段(本次未读取正文,需人工核验): - `request.data.mcc` → 汇付 MCC 编码表: - `request.data.prov_id / area_id / district_id` 与实名的 `store_address_code` → 地区码表: - `request.data.card_info.bank_code` → 银行编码表: - 实名文档原始地址(仓内归档): `.scratch/merchant-wechat-onboarding/huifu-api_shjj_wxsmrz.md`