feat(merchant): 个人主体进件打通到微信收款能力,并修掉开通链路六处缺陷
个人(小微)主体原先在「进件成功 → 微信收款」这条路上必然卡死:门店照片一个字段都没收
(微信业务开通与微信实名都要它),商户在进件表单里选的行业也被静默丢弃。本次补齐并打通,
真实个人商户(huifu_id 6666000241804122)已走完全程:六个阶段全确认、授权二维码可扫。
个人主体补齐:
- 门店照片(F22/F24)贯链。新增 store_header_url/store_indoor_url(V146 + 初始化器)、
商户端两个上传项、进件侧 store_header_pic、业务开通 extended_material_list 的 F22/F24、
小微实名的 store_header_copy/store_indoor_copy。个人主体提交时校验,缺图不发业务开通请求。
- 行业驱动 MCC。个人主体在进件表单选的行业类型原本落在 company_type 上却没人读,上报的
mcc 只认商家档案,档案没配好就一律落 7399(其它)。改为优先用进件时选的行业。
- 小微实名的 8 个门店/证件字段(字段名是 *_copy,与业务开通的 *_pic 不是一套),
缺项在任何通道调用之前一次说清——实名被驳回要换业务申请编号重来。
- 存量个人进件缺门店照片时,重新提交可以只把这两项补上:不新建进件,原商户号与银行卡关系不动。
- 个体工商户与小微企业:settle_config 补 fixed_ratio/constant_amt=0.00(D1 下条件必填);
证件长期有效时按有效期类型**显式**不发截止日期;费率规则号按主体类型选(企业 756 /
个体工商户 761 / 小微 775),application.yml 各配一个可覆盖的键。
- 两端页面:商户端新增「店铺照片」「店内环境照」上传项(按三种小微经营类型说明该传什么图);
后台个人详情补展示经营行业/经营地区/证件地址/结算凭证/身份证与门店照片。
开通链路六处缺陷(前五处只在个人主体上暴露,企业路径恰好全躲开了):
- 顺序反了。微信小程序配置排在业务开通之前,而微信子商户号是业务开通创建的,配置必然被
「根据渠道号或微信子商户号未查到入驻记录」驳回;而配置一失败就 return,业务开通永远轮不到,
微信支付、人才端绑定、实名全部连坐。改为两步顺序执行且互不阻断。配置的闸门取**通道证据**
(qry_wx_conf_list 里真出现线上场景)而不是本地受理状态——微信入驻是异步的,90000000
(审核中)不代表子商户号已经建好。
- 小微缺 mcc。mcc 原本写在 fee_rule_id 的 else 分支里,平台配了规则号 756 之后小微的 mcc
永远发不出去(通道回「wx_conf_list格式错误」)。官方写明「个体工商户、小微商户必填」,
且请求示例里 mcc 与 fee_rule_id 是同时出现的。
- wx_real_name_info 双重编码。字段目录里它的类型列是 Object(wx_conf_list[] 本身才是 String
的 jsonArray 字符串),代码却 JSON.toJSONString 又编了一层,通道按对象取字段取不到。
- 费率规则号按主体类型分。官方《微信费率规则ID》对照表是「支付场景 × 业务开通类型 × 主体类型」
三维定位,拿企业的 756 发给小微会被回「微信费率规则号填写不正确」。
- fee_min_amt 缺失被判成未生效。该字段可选、文档写明「大于 0」,不设最低收费时我们本就不下发、
通道也不返回,而判定要求它必须出现且为 0,于是微信业务明明开好了,阶段永远确认不了。
- 授权二维码写死了 PNG。通道回的是 JPEG(magic /9j/),被测成非法返回空串,两端都空白,
而本人扫码授权是完成实名的唯一途径。改为按 magic 判定真实格式。
另外把「processing 永远不会被推进到 approved」一并收口——缺这个收口时,收不到异步回调的部署里
整单永远完不成、能力快照也刷不到,商户明明开通了却推不到收款就绪;并给业务开通日志补记
wx_conf_list(不含图片标识,那是高敏感数据),这一轮定位靠手工反推报文,多烧了两次真实请求。
测试:新增 3 个测试类与 17 项用例,覆盖门店照/行业 MCC/小微实名字段/开通顺序与闸门/驳回换号
重试/失败原因保留/二维码格式/MCC 与费率号取值;一个被取代的用例(断言"一次刷新里开通与配置
都提交",在闸门改成通道证据后不可能成立)删除,并在新用例的 javadoc 里写明承接关系。
全量 243 项,仅 docs/agents/testing.md 所列 3 项基线失败,无新增。
文档:新增 .scratch/merchant-individual-onboarding/audit.md,记完整核查与逐轮排障(六处缺陷
各自的通道回执与证据链,以及待讨论项);CONTEXT.md 的「商户进件」定义补上个人主体与门店照片;
旧规格里「不扩展个人/小微进件」的口径作废并指向新文档。
未验证:两端页面级验收(本仓无 CLI 构建链,产物走 HBuilderX);门店照片与 store_address_code
在真实通道上的接受度仍待下一个商户复核。
| New file |
| | |
| | | # 商户进件到微信收款能力:企业进件核查 + 个人主体补齐 |
| | | |
| | | 日期: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` | |
| | | |
| | | 第 5 条是这一轮里唯一**真实漏发**的字段:`store_header_pic` 在进件的官方说明里是必填, |
| | | 而项目此前一条都没传过。三处消费同一张图的路径现在都齐了——进件 `store_header_pic`、 |
| | | 业务开通 `extended_material_list` 的 F22、实名 `store_header_copy`。 |
| | | |
| | | 原有测试全部随之复跑:新增 `HuifuIndividualOnboardingRequiredFieldsTests` 5 项钉住上面第 2/3/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 修复) |
| | | |
| | | ### 现象 |
| | | |
| | | 用户端看到「微信支付 未确认 / 人才端绑定 待补齐 / 实名与授权 待补齐 / 延迟入账 失败」。 |
| | | 查库(申请 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`(商户端 `<image :src>`、后台 `<img :src>`)。 |
| | | 问题在中间的转换: |
| | | |
| | | ```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`)时不换号,避免为同一次提交 |
| | | 开出第二份申请。随机后缀必须保住——先按剩余宽度裁前缀再接后缀,否则超长截断会把随机位一起切掉, |
| | | 两次重试又变成同一个号。 |
| | | |
| | | ### 剩余待讨论 |
| | | |
| | | - **两端的「重试」入口只认延迟失败**(`retryable = delay == 'failed'`),"业务开通失败"或 |
| | | "微信配置失败"没有对应的重试动作,只能靠「刷新进度」隐式重试——商户看到失败原因却没有可点的操作。 |
| | | - **已进件商户能否补改经营行业**:452 进件时选的是「其它」(MCC 退回商家档案后是 `5812` 餐饮, |
| | | 值是对的,但商户申报的行业与实际不一致)。要补救这类数据得先定这条路径。 |
| | | |
| | | --- |
| | | |
| | | ## 八、外部资料提示 |
| | | |
| | | 命中外部资料的字段(本次未读取正文,需人工核验): |
| | | |
| | | - `request.data.mcc` → 汇付 MCC 编码表:<https://paas.huifu.com/open/doc/api/#/csfl/api_csfl_hfmccbm> |
| | | - `request.data.prov_id / area_id / district_id` 与实名的 `store_address_code` → 地区码表: |
| | | <https://paas.huifu.com/open/doc/api/#/csfl/api_csfl_dqbm> |
| | | - `request.data.card_info.bank_code` → 银行编码表:<https://paas.huifu.com/open/doc/api/#/csfl/api_csfl_yhbm> |
| | | - 实名文档原始地址(仓内归档): |
| | | `.scratch/merchant-wechat-onboarding/huifu-api_shjj_wxsmrz.md` |
| | |
| | | |
| | | **2026-09-16 修订(影响全文):** 用户确认不再需要 6666000241335656 做测试,工单 02(存量商户恢复)关闭。因此下文中“先验收指定存量商户、再开展新商户流程”的两阶段框架、阶段切换条件与相关用户故事**整体作废**:新企业商户的分阶段流程(03)与微信实名能力(04)两票并行、互不阻塞。指定存量商户只保留为**通道能力的验证来源**——微信业务三场景即时入驻成功、延迟入账生效、人才端 AppID 绑定成功均已用真实接口证明并归档在 [核验证据](evidence-2026-09-15.md)。它的“待实名”状态按下方「收款就绪的前提」处理,不再是任何一票的验收条件或阻塞项。 |
| | | |
| | | **2026-09-16 修订(主体范围):** 用户另行指派「个人进件」,本规格中「本阶段支持企业商户、不扩展个人/小微进件」 |
| | | (Implementation Decisions 与 Out of Scope 各一处)**就此作废**。个人主体的规格、缺口与本次交付见 |
| | | [个人主体进件到微信收款能力](../merchant-individual-onboarding/audit.md)。本规格其余部分(企业主体的分阶段流程)继续有效。 |
| | | |
| | | ## Problem Statement |
| | | |
| | | 企业商户希望在商户端一次提交资料后,完成汇付进件以及人才端收款所需的微信业务配置。当前基本进件、微信配置和业务开通的状态没有形成完整闭环:本地开发环境收不到回调,查看进度只读本地记录,申请会卡在处理中;配置失败后的重试又可能重新提交整份进件。 |
| | |
| | | |
| | | **商户端**:商户办理进件、扫描人才码创建到店消费订单,以及扫描预购订单核销码完成核销的小程序;付款由人才用户在人才端完成。 |
| | | |
| | | **商户进件**:企业商户提交主体、经营和结算资料,申请成为汇付商户的过程。基本资料审核通过后,微信支付仍可能处于开通中或开通失败。 |
| | | **商户进件**:商户提交主体、经营和结算资料,申请成为汇付商户的过程。主体分企业(含个体工商户) |
| | | 与个人(小微)两种:企业交营业执照与法人资料,个人交身份证与**店铺照片**(线上经营传店铺首页截图、 |
| | | 有实体门店传门头照、流动经营传经营现场照)、**店内环境照**——个人主体的微信业务开通与微信实名认证 |
| | | 都要这两张图。基本资料审核通过后,微信支付仍可能处于开通中或开通失败。 |
| | | |
| | | **微信支付开通**:商户取得在平台小程序内接受微信付款所需业务能力的过程。开通失败可以单独重试,不否定已经通过的基本资料审核。 |
| | | |
| | |
| | | <view v-if="merchantType == 'individual'"> |
| | | <view class="field-block"><text class="field-label required">行业类型</text><picker class="date-picker" mode="selector" :range="industryTypeOptions" :value="industryTypeIndex" @change="onIndustryTypeChange"><view class="select-shell" :class="invalidField == 'industryType' ? 'field-invalid' : ''"><text :class="industryType.length == 0 ? 'select-placeholder' : ''">{{industryType.length > 0 ? industryType : '请选择'}}</text><image class="down-icon" src="/static/icons/lucide/chevron-down.svg" mode="aspectFit"></image></view></picker><text v-if="invalidField == 'industryType'" class="field-error">{{errorMessage}}</text></view> |
| | | <view class="field-block"><text class="field-label required">经营地区(省、市、区)</text><picker class="region-picker" mode="region" @change="onRegisteredRegionChange"><view class="select-shell" :class="invalidField == 'registeredRegion' ? 'field-invalid' : ''"><text class="ellipsis-text" :class="registeredRegion.length == 0 ? 'select-placeholder' : ''">{{registeredRegion.length > 0 ? registeredRegion : '请选择'}}</text><image class="down-icon" src="/static/icons/lucide/chevron-down.svg" mode="aspectFit"></image></view></picker><text v-if="invalidField == 'registeredRegion'" class="field-error">{{errorMessage}}</text></view> |
| | | <view class="field-block"><text class="field-label required">店铺照片</text><view class="upload-box" :class="invalidField == 'storeHeaderUrl' ? 'field-invalid' : ''" @tap="chooseDocument('storeHeader')"><image v-if="storeHeaderUrl.length > 0" class="document-image" :src="storeHeaderUrl" mode="aspectFill"></image><text v-else>{{uploadingField == 'storeHeader' ? '上传中...' : '添加图片'}}</text></view><text class="field-hint">线上经营传店铺首页截图;有实体门店传门头照(招牌清晰可见);流动经营、上门服务传经营现场照</text><text v-if="invalidField == 'storeHeaderUrl'" class="field-error">{{errorMessage}}</text></view> |
| | | <view class="field-block"><text class="field-label required">店内环境照</text><view class="upload-box" :class="invalidField == 'storeIndoorUrl' ? 'field-invalid' : ''" @tap="chooseDocument('storeIndoor')"><image v-if="storeIndoorUrl.length > 0" class="document-image" :src="storeIndoorUrl" mode="aspectFill"></image><text v-else>{{uploadingField == 'storeIndoor' ? '上传中...' : '添加图片'}}</text></view><text class="field-hint">店内环境或工作区域照片,需再传一张,不能与上一张相同</text><text v-if="invalidField == 'storeIndoorUrl'" class="field-error">{{errorMessage}}</text></view> |
| | | </view> |
| | | </view> |
| | | |
| | |
| | | const draftNotice = ref(false) |
| | | const submitError = ref('') |
| | | |
| | | const submitting = ref(false); const uploadingField = ref(''); const merchantType = ref('enterprise'); const merchantName = ref(''); const merchantShortName = ref(''); const companyType = ref(''); const companyTypeIndex = ref(-1); const companyTypeOptions = ['政府机构', '国营企业', '私营企业', '外资企业', '个体工商户', '其它组织', '事业单位', '业主委员会']; const industryType = ref(''); const industryTypeIndex = ref(0); const industryTypeOptions = ['餐饮服务', '酒店服务', '景区服务', '加油服务', '停车服务', '其它']; const businessLicenseUrl = ref(''); const creditCode = ref(''); const businessValidity = ref('固定期限'); const businessStartDate = ref(''); const businessEndDate = ref(''); const establishedDate = ref(''); const registeredRegion = ref(''); const registeredProvinceCode = ref(''); const registeredCityCode = ref(''); const registeredDistrictCode = ref(''); const registeredAddress = ref(''); const registeredCapital = ref(''); const subjectName = ref(''); const legalIdFrontUrl = ref(''); const legalIdBackUrl = ref(''); const subjectIdNumber = ref(''); const subjectValidity = ref('10年'); const subjectStartDate = ref(''); const subjectEndDate = ref(''); const legalCertificateAddress = ref(''); const legalMobile = ref(''); const contactName = ref(''); const contactMobile = ref(''); const administratorEmail = ref(''); const bankCardNumber = ref(''); const bankProvince = ref(''); const bankProvinceCode = ref(''); const bankProvinceIndex = ref(0); const bankCity = ref(''); const bankCityCode = ref(''); const bankCityNames = ref([] as string[]); const bankCityCodes = ref([] as string[]); const bankCityIndex = ref(0); const bankBranch = ref(''); const bankBranchCode = ref(''); const bankName = ref(''); const settlementProofUrl = ref(''); const invalidField = ref(''); const errorMessage = ref('') |
| | | const submitting = ref(false); const uploadingField = ref(''); const merchantType = ref('enterprise'); const merchantName = ref(''); const merchantShortName = ref(''); const companyType = ref(''); const companyTypeIndex = ref(-1); const companyTypeOptions = ['政府机构', '国营企业', '私营企业', '外资企业', '个体工商户', '其它组织', '事业单位', '业主委员会']; const industryType = ref(''); const industryTypeIndex = ref(0); const industryTypeOptions = ['餐饮服务', '酒店服务', '景区服务', '加油服务', '停车服务', '其它']; const businessLicenseUrl = ref(''); const creditCode = ref(''); const businessValidity = ref('固定期限'); const businessStartDate = ref(''); const businessEndDate = ref(''); const establishedDate = ref(''); const registeredRegion = ref(''); const registeredProvinceCode = ref(''); const registeredCityCode = ref(''); const registeredDistrictCode = ref(''); const registeredAddress = ref(''); const registeredCapital = ref(''); const subjectName = ref(''); const legalIdFrontUrl = ref(''); const legalIdBackUrl = ref(''); const subjectIdNumber = ref(''); const subjectValidity = ref('10年'); const subjectStartDate = ref(''); const subjectEndDate = ref(''); const legalCertificateAddress = ref(''); const legalMobile = ref(''); const contactName = ref(''); const contactMobile = ref(''); const administratorEmail = ref(''); const bankCardNumber = ref(''); const bankProvince = ref(''); const bankProvinceCode = ref(''); const bankProvinceIndex = ref(0); const bankCity = ref(''); const bankCityCode = ref(''); const bankCityNames = ref([] as string[]); const bankCityCodes = ref([] as string[]); const bankCityIndex = ref(0); const bankBranch = ref(''); const bankBranchCode = ref(''); const bankName = ref(''); const settlementProofUrl = ref(''); const storeHeaderUrl = ref(''); const storeIndoorUrl = ref(''); const invalidField = ref(''); const errorMessage = ref('') |
| | | const merchantTypeOptions = ['企业', '个人'] |
| | | const merchantTypeIndex = ref(0) |
| | | const validityOptions = ['固定期限', '长期'] |
| | |
| | | } else { |
| | | if (industryType.value.length == 0) return 'industryType|请选择行业类型' |
| | | if (registeredRegion.value.length == 0) return 'registeredRegion|请选择经营地区' |
| | | // 店铺照片是个人(小微)商户进件的必填项,微信业务开通与微信实名认证都要用它。 |
| | | // 在这里拦住,好过进件提交上去以后在开通或实名那一步被驳回——那时得重走整份进件。 |
| | | if (storeHeaderUrl.value.length == 0) return 'storeHeaderUrl|请上传店铺照片(线上为店铺首页截图,门店为门头照)' |
| | | if (storeIndoorUrl.value.length == 0) return 'storeIndoorUrl|请上传店内环境照' |
| | | } |
| | | } |
| | | if (step == 2) { |
| | |
| | | } |
| | | const goPrevStep = () => { saveDraft(); if (currentStep.value > 1) currentStep.value = currentStep.value - 1; resetScroll() } |
| | | |
| | | const draftPayload = (): any => ({ ownerOperatorId: currentOperatorId(), merchantType: merchantType.value, merchantName: merchantName.value, merchantShortName: merchantShortName.value, companyType: companyType.value, companyTypeIndex: companyTypeIndex.value, industryType: industryType.value, industryTypeIndex: industryTypeIndex.value, businessLicenseUrl: businessLicenseUrl.value, creditCode: creditCode.value, businessValidity: businessValidity.value, businessStartDate: businessStartDate.value, businessEndDate: businessEndDate.value, establishedDate: establishedDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: registeredAddress.value, registeredCapital: registeredCapital.value, subjectName: subjectName.value, legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, subjectIdNumber: subjectIdNumber.value, subjectValidity: subjectValidity.value, subjectStartDate: subjectStartDate.value, subjectEndDate: subjectEndDate.value, legalCertificateAddress: legalCertificateAddress.value, legalMobile: legalMobile.value, contactName: contactName.value, contactMobile: contactMobile.value, administratorEmail: administratorEmail.value, bankCardNumber: bankCardNumber.value, bankProvince: bankProvince.value, bankProvinceCode: bankProvinceCode.value, bankProvinceIndex: bankProvinceIndex.value, bankCity: bankCity.value, bankCityCode: bankCityCode.value, bankCityIndex: bankCityIndex.value, bankBranch: bankBranch.value, bankBranchCode: bankBranchCode.value, bankName: bankName.value, settlementProofUrl: settlementProofUrl.value, currentStep: currentStep.value }) |
| | | const draftPayload = (): any => ({ ownerOperatorId: currentOperatorId(), merchantType: merchantType.value, merchantName: merchantName.value, merchantShortName: merchantShortName.value, companyType: companyType.value, companyTypeIndex: companyTypeIndex.value, industryType: industryType.value, industryTypeIndex: industryTypeIndex.value, businessLicenseUrl: businessLicenseUrl.value, creditCode: creditCode.value, businessValidity: businessValidity.value, businessStartDate: businessStartDate.value, businessEndDate: businessEndDate.value, establishedDate: establishedDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: registeredAddress.value, registeredCapital: registeredCapital.value, subjectName: subjectName.value, legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, subjectIdNumber: subjectIdNumber.value, subjectValidity: subjectValidity.value, subjectStartDate: subjectStartDate.value, subjectEndDate: subjectEndDate.value, legalCertificateAddress: legalCertificateAddress.value, legalMobile: legalMobile.value, contactName: contactName.value, contactMobile: contactMobile.value, administratorEmail: administratorEmail.value, bankCardNumber: bankCardNumber.value, bankProvince: bankProvince.value, bankProvinceCode: bankProvinceCode.value, bankProvinceIndex: bankProvinceIndex.value, bankCity: bankCity.value, bankCityCode: bankCityCode.value, bankCityIndex: bankCityIndex.value, bankBranch: bankBranch.value, bankBranchCode: bankBranchCode.value, bankName: bankName.value, settlementProofUrl: settlementProofUrl.value, storeHeaderUrl: storeHeaderUrl.value, storeIndoorUrl: storeIndoorUrl.value, currentStep: currentStep.value }) |
| | | const saveDraft = () => { uni.setStorageSync(draftStorageKey(), JSON.stringify(draftPayload())) } |
| | | const clearDraft = () => { uni.removeStorageSync(draftStorageKey()) } |
| | | const restoreDraft = () => { |
| | |
| | | if (draft.bankBranchCode != null) bankBranchCode.value = draft.bankBranchCode as string |
| | | if (draft.bankName != null) bankName.value = draft.bankName as string |
| | | if (draft.settlementProofUrl != null) settlementProofUrl.value = draft.settlementProofUrl as string |
| | | if (draft.storeHeaderUrl != null) storeHeaderUrl.value = draft.storeHeaderUrl as string |
| | | if (draft.storeIndoorUrl != null) storeIndoorUrl.value = draft.storeIndoorUrl as string |
| | | if (bankProvince.value.length > 0 && bankProvinceIndex.value >= 0 && bankProvinceIndex.value < areaCityNames.length) { bankCityNames.value = areaCityNames[bankProvinceIndex.value]; bankCityCodes.value = areaCityCodes[bankProvinceIndex.value] } |
| | | merchantTypeIndex.value = merchantType.value == 'individual' ? 1 : 0 |
| | | const restoredBusinessValidity = validityOptions.indexOf(businessValidity.value); if (restoredBusinessValidity >= 0) businessValidityIndex.value = restoredBusinessValidity |
| | |
| | | const onIndustryTypeChange = (event: any) => { const index = parseInt(event.detail.value as string); if (index < 0 || index >= industryTypeOptions.length) return; industryTypeIndex.value = index; industryType.value = industryTypeOptions[index]; clearFieldError('industryType') } |
| | | const onBusinessStartChange = (e: any) => { businessStartDate.value = e.detail.value as string; clearFieldError('businessStartDate') }; const onBusinessEndChange = (e: any) => { businessEndDate.value = e.detail.value as string; clearFieldError('businessEndDate') }; const onEstablishedDateChange = (e: any) => { establishedDate.value = e.detail.value as string; clearFieldError('establishedDate') }; const onSubjectStartChange = (e: any) => { subjectStartDate.value = e.detail.value as string; clearFieldError('subjectStartDate') }; const onSubjectEndChange = (e: any) => { subjectEndDate.value = e.detail.value as string; clearFieldError('subjectEndDate') } |
| | | const onRegisteredRegionChange = (e: any) => { const values = e.detail.value as Array<string>; const codes = e.detail.code as Array<string>; if (values.length != 3 || codes.length != 3) return; registeredRegion.value = values[0] + ' ' + values[1] + ' ' + values[2]; registeredProvinceCode.value = codes[0]; registeredCityCode.value = codes[1]; registeredDistrictCode.value = codes[2]; clearFieldError('registeredRegion') } |
| | | const chooseDocument = (kind: string) => { if (uploadingField.value.length > 0) return; uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (r: any) => { const files = r.tempFiles as Array<any>; if (files.length == 0) return; if ((files[0].size as number) > 2097152) { uni.showToast({ title: '图片不能超过2MB', icon: 'none' }); return }; uploadingField.value = kind; uploadMerchantProductImage(files[0].path as string, (url: string) => { uploadingField.value = ''; if (kind == 'businessLicense') { businessLicenseUrl.value = url; clearFieldError('businessLicenseUrl') } else if (kind == 'legalIdFront') { legalIdFrontUrl.value = url; clearFieldError('legalIdFrontUrl') } else if (kind == 'legalIdBack') { legalIdBackUrl.value = url; clearFieldError('legalIdBackUrl') } else { settlementProofUrl.value = url; clearFieldError('settlementProofUrl') } }, (error: any) => { uploadingField.value = ''; uni.showToast({ title: error.message != null ? error.message : '图片上传失败,请重试', icon: 'none' }) }) } }) } |
| | | const chooseDocument = (kind: string) => { if (uploadingField.value.length > 0) return; uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (r: any) => { const files = r.tempFiles as Array<any>; if (files.length == 0) return; if ((files[0].size as number) > 2097152) { uni.showToast({ title: '图片不能超过2MB', icon: 'none' }); return }; uploadingField.value = kind; uploadMerchantProductImage(files[0].path as string, (url: string) => { uploadingField.value = ''; if (kind == 'businessLicense') { businessLicenseUrl.value = url; clearFieldError('businessLicenseUrl') } else if (kind == 'legalIdFront') { legalIdFrontUrl.value = url; clearFieldError('legalIdFrontUrl') } else if (kind == 'legalIdBack') { legalIdBackUrl.value = url; clearFieldError('legalIdBackUrl') } else if (kind == 'storeHeader') { storeHeaderUrl.value = url; clearFieldError('storeHeaderUrl') } else if (kind == 'storeIndoor') { storeIndoorUrl.value = url; clearFieldError('storeIndoorUrl') } else { settlementProofUrl.value = url; clearFieldError('settlementProofUrl') } }, (error: any) => { uploadingField.value = ''; uni.showToast({ title: error.message != null ? error.message : '图片上传失败,请重试', icon: 'none' }) }) } }) } |
| | | const onBankProvinceChange = (event: any) => { const index = parseInt(event.detail.value as string); if (index < 0 || index >= areaProvinceNames.length) return; bankProvinceIndex.value = index; bankProvince.value = areaProvinceNames[index]; bankProvinceCode.value = areaProvinceCodes[index]; bankCityNames.value = areaCityNames[index]; bankCityCodes.value = areaCityCodes[index]; bankCityIndex.value = 0; bankCity.value = ''; bankCityCode.value = ''; bankBranch.value = ''; bankBranchCode.value = ''; bankName.value = '' } |
| | | const onBankCityChange = (event: any) => { const index = parseInt(event.detail.value as string); if (index < 0 || index >= bankCityNames.value.length) return; bankCityIndex.value = index; bankCity.value = bankCityNames.value[index]; bankCityCode.value = bankCityCodes.value[index]; bankBranch.value = ''; bankBranchCode.value = ''; bankName.value = '' } |
| | | const chooseBankBranch = () => { if (bankCityCode.value.length == 0) { uni.showToast({ title: '请先选择开户城市', icon: 'none' }); return }; uni.navigateTo({ url: '/pages/bank-branch-select/bank-branch-select?city=' + encodeURIComponent(bankProvince.value + ' ' + bankCity.value) + '&cityCode=' + encodeURIComponent(bankCityCode.value) }) } |
| | |
| | | } |
| | | } |
| | | submitting.value = true |
| | | submitOnboardingRemote({ subjectType: merchantType.value, subjectName: subjectName.value.trim(), creditCode: creditCode.value, subjectCertificateValidityType: subjectValidity.value == '长期有效' ? 'long_term' : 'fixed', subjectCertificateBeginDate: subjectStartDate.value, subjectCertificateEndDate: subjectEndDate.value, subjectIdNumber: subjectIdNumber.value, licenseValidityType: businessValidity.value == '长期' ? 'long_term' : 'fixed', licenseBeginDate: businessStartDate.value, licenseEndDate: businessEndDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: merchantType.value == 'enterprise' ? registeredAddress.value.trim() : legalCertificateAddress.value.trim(), merchantName: merchantName.value.trim(), merchantShortName: merchantShortName.value.trim(), companyType: merchantType.value == 'enterprise' ? companyType.value : industryType.value, businessLicenseUrl: businessLicenseUrl.value, establishedDate: establishedDate.value, registeredCapital: registeredCapital.value.trim(), legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, legalCertificateAddress: legalCertificateAddress.value.trim(), legalMobile: legalMobile.value.trim(), administratorEmail: administratorEmail.value.trim(), contactName: merchantType.value == 'enterprise' ? contactName.value.trim() : subjectName.value.trim(), contactMobile: contactMobile.value, bankCardNumber: bankCardNumber.value, bankProvinceCode: bankProvinceCode.value, bankCityCode: bankCityCode.value, bankBranchCode: bankBranchCode.value, settlementCardFrontUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? '' : settlementProofUrl.value, registrationAccountUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? settlementProofUrl.value : '' }, 'onboarding-' + Date.now(), (result: SettlementOnboarding) => { submitting.value = false; if (result.status == 'approved') clearDraft(); uni.redirectTo({ url: '/pages/merchant-onboarding-pending/merchant-onboarding-pending' }) }, (error: any) => { submitting.value = false; submitError.value = error.message != null ? error.message : '提交失败,请重试'; uni.showToast({ title: submitError.value, icon: 'none' }) }) |
| | | submitOnboardingRemote({ subjectType: merchantType.value, subjectName: subjectName.value.trim(), creditCode: creditCode.value, subjectCertificateValidityType: subjectValidity.value == '长期有效' ? 'long_term' : 'fixed', subjectCertificateBeginDate: subjectStartDate.value, subjectCertificateEndDate: subjectEndDate.value, subjectIdNumber: subjectIdNumber.value, licenseValidityType: businessValidity.value == '长期' ? 'long_term' : 'fixed', licenseBeginDate: businessStartDate.value, licenseEndDate: businessEndDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: merchantType.value == 'enterprise' ? registeredAddress.value.trim() : legalCertificateAddress.value.trim(), merchantName: merchantName.value.trim(), merchantShortName: merchantShortName.value.trim(), companyType: merchantType.value == 'enterprise' ? companyType.value : industryType.value, businessLicenseUrl: businessLicenseUrl.value, establishedDate: establishedDate.value, registeredCapital: registeredCapital.value.trim(), legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, legalCertificateAddress: legalCertificateAddress.value.trim(), legalMobile: legalMobile.value.trim(), administratorEmail: administratorEmail.value.trim(), contactName: merchantType.value == 'enterprise' ? contactName.value.trim() : subjectName.value.trim(), contactMobile: contactMobile.value, bankCardNumber: bankCardNumber.value, bankProvinceCode: bankProvinceCode.value, bankCityCode: bankCityCode.value, bankBranchCode: bankBranchCode.value, settlementCardFrontUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? '' : settlementProofUrl.value, registrationAccountUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? settlementProofUrl.value : '', storeHeaderUrl: merchantType.value == 'individual' ? storeHeaderUrl.value : '', storeIndoorUrl: merchantType.value == 'individual' ? storeIndoorUrl.value : '' }, 'onboarding-' + Date.now(), (result: SettlementOnboarding) => { submitting.value = false; if (result.status == 'approved') clearDraft(); uni.redirectTo({ url: '/pages/merchant-onboarding-pending/merchant-onboarding-pending' }) }, (error: any) => { submitting.value = false; submitError.value = error.message != null ? error.message : '提交失败,请重试'; uni.showToast({ title: submitError.value, icon: 'none' }) }) |
| | | } |
| | | |
| | | onShow(() => { |
| | |
| | | <div class="detail-field"><span>个人证件有效期开始日期</span><strong>{{ dateText(detail.subjectCertBeginDate) }}</strong></div> |
| | | <div class="detail-field"><span>个人证件有效期截止日期</span><strong>{{ dateText(detail.subjectCertEndDate) }}</strong></div> |
| | | <div class="detail-field"><span>手机号</span><strong>{{ text(detail.contactMobile) }}</strong></div> |
| | | <!-- 个人主体的 company_type 存的是进件时选的经营行业(企业主体那一列才是公司类型), |
| | | 它同时决定上报汇付的 MCC,所以运营要能在这里核对。 --> |
| | | <div class="detail-field"><span>经营行业</span><strong>{{ text(detail.companyType) }}</strong></div> |
| | | <div class="detail-field"><span>经营地区(省、市、区)</span><strong>{{ text(detail.registeredRegion) }}</strong></div> |
| | | <div class="detail-field detail-field--auto-height"><span>个人证件地址</span><strong>{{ text(detail.legalCertificateAddress) }}</strong></div> |
| | | <div class="detail-field"><span>邮箱</span><strong>{{ text(detail.administratorEmail) }}</strong></div> |
| | | <div class="detail-field"><span>个人身份证人像面</span><strong><a v-if="detail.legalIdFrontUrl" :href="detail.legalIdFrontUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div> |
| | | <div class="detail-field"><span>个人身份证国徽面</span><strong><a v-if="detail.legalIdBackUrl" :href="detail.legalIdBackUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div> |
| | | <div class="detail-field"><span>结算银行卡卡号面</span><strong><a v-if="detail.settlementCardFrontUrl" :href="detail.settlementCardFrontUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div> |
| | | <!-- 微信业务开通与微信实名认证都要这张店铺照片,运营核对开通失败原因时要看得到。 --> |
| | | <div class="detail-field"><span>店铺照片</span><strong><a v-if="detail.storeHeaderUrl" :href="detail.storeHeaderUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div> |
| | | <div class="detail-field"><span>店内环境照</span><strong><a v-if="detail.storeIndoorUrl" :href="detail.storeIndoorUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div> |
| | | </div> |
| | | </section> |
| | | </div> |
| | |
| | | List<Map<String,Object>> wechat = configurations(capabilities.get("qry_wx_conf_list")); |
| | | boolean valid = List.of("4", "5", "12").stream().allMatch(scene -> wechat.stream().anyMatch(item -> |
| | | scene.equals(text(item.get("pay_scene"))) && decimal(item.get("fee_rate"), "0.70") |
| | | && zero(item.get("fee_min_amt")) && "1".equals(text(item.get("fee_charge_type"))) |
| | | // fee_min_amt 是可选字段,且文档写明「大于 0」——不设最低收费时我们**不下发**它, |
| | | // 通道也就不返回它。缺失必须按 0 处理,否则这条判定永远不成立:商户微信业务 |
| | | // 明明已经开好(三场景、费率、内扣、子商户号都对),阶段却永远停在未确认 |
| | | // (2026-09-16 拿真实商户的通道回执核对出来的)。 |
| | | && zeroUnlessAbsent(item.get("fee_min_amt")) |
| | | && "1".equals(text(item.get("fee_charge_type"))) |
| | | && "2".equals(text(item.get("out_fee_flag"))) && item.containsKey("wx_mer_infos") |
| | | && configurations(item.get("wx_mer_infos")).stream().anyMatch(merchant -> |
| | | !text(merchant.get("bank_mer_code")).isBlank() && "1".equals(text(merchant.get("is_default")))))); |
| | |
| | | if (!applyNo.isBlank() && !List.of("succeeded","confirmed").contains(text(attempt.get("status")))) { |
| | | Map<String,Object> result=gateway.queryApplication(huifuId,applyNo); |
| | | if (result != null && "00000000".equals(text(result.get("resp_code"))) && List.of("N","F").contains(text(result.get("apply_status")))) { |
| | | jdbc.update("update merchant_onboarding_attempt set status='failed',response_code=? where id=? and status<>'confirmed'",text(result.get("resp_code")),attempt.get("id")); |
| | | jdbc.update("update merchant_onboarding_attempt set status='failed',response_code=?,response_message=nullif(?,'') where id=? and status<>'confirmed'",text(result.get("resp_code")),channelMessage(result),attempt.get("id")); |
| | | attempt.put("status","failed"); |
| | | } |
| | | } |
| | |
| | | Map<String,Object> result=gateway.configureDelay(huifuId,requestNo,date); |
| | | String code=result==null ? "" : text(result.get("resp_code")); |
| | | String status=code.isBlank() ? "unknown" : List.of("00000000","00000100","90000000").contains(code) ? "pending" : "failed"; |
| | | jdbc.update("update merchant_onboarding_attempt set status=?,apply_no=nullif(?,''),response_code=? where request_no=?",status,result==null ? "" : text(result.get("apply_no")),code,requestNo); |
| | | // 通道给的描述一起落库。原先只存 response_code,而延迟入账这一条失败回的是 99999999 |
| | | // (通道的「失败:<原因>」通用码),描述一丢就完全看不出为什么失败,排查只能靠猜。 |
| | | jdbc.update("update merchant_onboarding_attempt set status=?,apply_no=nullif(?,''),response_code=?,response_message=nullif(?,'') where request_no=?",status,result==null ? "" : text(result.get("apply_no")),code,channelMessage(result),requestNo); |
| | | if ("failed".equals(status)) stages.put("delay","failed"); |
| | | } catch (RuntimeException ignored) { |
| | | // The durable unknown attempt blocks resubmission after a crash, timeout or invalid response. |
| | |
| | | } |
| | | private List<Map<String,Object>> rows(String sql,Object... args) { return DatabaseRows.normalize(jdbc.queryForList(sql,args)); } |
| | | private static String text(Object value) { return value==null ? "" : String.valueOf(value); } |
| | | |
| | | /** |
| | | * 通道返回的描述。业务描述可能落在 {@code sub_resp_desc},网关层描述落在 {@code resp_desc}, |
| | | * 两者都取不到就留空。列定长 512,超长按列宽截断,避免整条 update 报「Data too long」。 |
| | | */ |
| | | private static String channelMessage(Map<String,Object> result) { |
| | | if (result == null) return ""; |
| | | String message = text(result.get("sub_resp_desc")); |
| | | if (message.isBlank()) message = text(result.get("resp_desc")); |
| | | if (message.isBlank()) message = text(result.get("resp_msg")); |
| | | return message.length() <= 512 ? message : message.substring(0, 512); |
| | | } |
| | | private static String stageReason(String key, String status) { |
| | | if ("confirmed".equals(status)) return "已取得通道生效证据"; |
| | | if ("realname".equals(key) && "action_required".equals(status)) return "请法人或授权联系人完成微信实名及授权,办理后刷新;没有二维码时请联系平台运营"; |
| | |
| | | return "action_required"; |
| | | } |
| | | |
| | | /** |
| | | * 把通道返回的 base64 图片转成能直接放进 {@code <img src>} 的 data URL。 |
| | | * |
| | | * <p>通道回的是**裸 base64**,而且是 JPEG 还是 PNG 并不固定:实测小微实名的授权二维码就是 |
| | | * **JPEG**(`/9j/4AAQSkZJRg…`)。这里原先写死了 PNG 的 magic(`iVBORw0KGgo`)并统一标成 |
| | | * `image/png`,于是 JPEG 的码被判成非法、返回空串——两端都拿不到二维码,而"本人扫码授权" |
| | | * 是完成实名的唯一途径(2026-09-16 实测:申请单已受理、通道已回码,页面却是空的)。 |
| | | * |
| | | * <p>仍然只接受图片 base64(按 magic 判定),不把任意内容拼进 img src。 |
| | | */ |
| | | private static String qrCode(String raw) { |
| | | String value = raw.replaceFirst("^data:image/png;base64,", ""); |
| | | if (value.length() > 200000 || !value.startsWith("iVBORw0KGgo") || !value.matches("[A-Za-z0-9+/=\\r\\n]+")) return ""; |
| | | return "data:image/png;base64," + value; |
| | | String value = raw.replaceFirst("^data:image/(png|jpe?g);base64,", ""); |
| | | if (value.length() > 200000 || !value.matches("[A-Za-z0-9+/=\\r\\n]+")) return ""; |
| | | String mime = value.startsWith("iVBORw0KGgo") ? "image/png" |
| | | : value.startsWith("/9j/") ? "image/jpeg" |
| | | : ""; |
| | | return mime.isBlank() ? "" : "data:" + mime + ";base64," + value; |
| | | } |
| | | @SuppressWarnings("unchecked") |
| | | private static List<Map<String,Object>> configurations(Object raw) { |
| | |
| | | private static boolean zero(Object value) { |
| | | return decimal(value, "0"); |
| | | } |
| | | |
| | | /** |
| | | * 给「字段缺失就等于没设」的可选金额字段用。 |
| | | * |
| | | * <p>与 {@link #zero} 的区别只在 null:{@code fee_min_amt} 这类字段不设时通道不返回, |
| | | * 而"不返回"的语义正是"没设最低收费"(即 0),不能按"取不到就不算数"处理。 |
| | | */ |
| | | private static boolean zeroUnlessAbsent(Object value) { |
| | | return value == null || zero(value); |
| | | } |
| | | private static boolean decimal(Object value, String expected) { |
| | | return value != null && new java.math.BigDecimal(text(value)).compareTo(new java.math.BigDecimal(expected)) == 0; |
| | | } |
| | |
| | | @NotBlank @Pattern(regexp = "\\d{6}") String bankCityCode, |
| | | @NotBlank @Pattern(regexp = "\\d{12}") String bankBranchCode, |
| | | @Size(max = 512) String settlementCardFrontUrl, |
| | | @Size(max = 512) String registrationAccountUrl |
| | | @Size(max = 512) String registrationAccountUrl, |
| | | // 门店照片:个人(小微)商户拿到微信收款能力必须交的两张图。门头照在「流动经营/便民服务」 |
| | | // 场景下是经营现场照。企业主体不填这两项。 |
| | | @Size(max = 512) String storeHeaderUrl, |
| | | @Size(max = 512) String storeIndoorUrl |
| | | ) {} |
| | |
| | | columns.put("realname_apply_no", "varchar(32) default null"); |
| | | columns.put("realname_response_code", "varchar(32) default null"); |
| | | columns.put("realname_response_message", "varchar(512) default null"); |
| | | // 门店照片:个人(小微)商户的微信业务开通与微信实名都要这两张图, |
| | | // 企业主体用不到。见 V146 的说明。 |
| | | columns.put("store_header_url", "varchar(512) default null"); |
| | | columns.put("store_indoor_url", "varchar(512) default null"); |
| | | for (Map.Entry<String, String> column : columns.entrySet()) { |
| | | if (!hasColumn("merchant_onboarding_application", column.getKey())) addColumn("merchant_onboarding_application", column.getKey(), column.getValue()); |
| | | } |
| | |
| | | jdbc.execute("alter table " + tableName + " add column " + columnName + " " + definition); |
| | | log.info("Merchant onboarding schema: added {}.{}", tableName, columnName); |
| | | } catch (Exception exception) { |
| | | log.warn("Merchant onboarding schema: could not add {}.{} within {}s (table busy); it will be retried on the next restart. cause={}", |
| | | tableName, columnName, DDL_LOCK_WAIT_TIMEOUT_SECONDS, exception.getClass().getSimpleName()); |
| | | // 这里必须是 error 而不是 warn:进件的 INSERT/UPDATE 都显式列出列名,缺列不是"只影响重试功能", |
| | | // 而是商户一提交进件就报 Unknown column。原先只记 warn、应用照常启动,于是症状变成 |
| | | // "重启了还是报错、又看不出原因"(2026-09-16 在开发库的 legal_mobile 上实际发生过: |
| | | // 同一轮循环里它后面的列都加成功了,只有它抢锁失败)。日志里直接给出补救语句。 |
| | | log.error("Merchant onboarding schema: could not add {}.{} within {}s (table busy); the column is" |
| | | + " referenced by the onboarding INSERT, so onboarding submissions will fail until it" |
| | | + " exists. Retried on the next restart, or run manually: alter table {} add column {} {}." |
| | | + " cause={}", |
| | | tableName, columnName, DDL_LOCK_WAIT_TIMEOUT_SECONDS, tableName, columnName, definition, |
| | | exception.getClass().getSimpleName()); |
| | | } |
| | | } |
| | | |
| | |
| | | import com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentGatewayException; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.ErrorCode; |
| | | import com.talentcard.server.common.SensitiveDataCrypto; |
| | |
| | | private final HuifuMerchantRealnameGateway realnameGateway; |
| | | private final HuifuOnboardingProgressGateway progressGateway; |
| | | private final MerchantOnboardingProgressService progressService; |
| | | private final HuifuPictureUploadGateway pictureUploadGateway; |
| | | private final HuifuPaymentProperties huifuPaymentProperties; |
| | | |
| | | public MerchantRealnameService(JdbcTemplate jdbc, SensitiveDataCrypto sensitiveDataCrypto, |
| | | HuifuMerchantRealnameGateway realnameGateway, HuifuOnboardingProgressGateway progressGateway, |
| | | MerchantOnboardingProgressService progressService) { |
| | | MerchantOnboardingProgressService progressService, HuifuPictureUploadGateway pictureUploadGateway, |
| | | HuifuPaymentProperties huifuPaymentProperties) { |
| | | this.jdbc = jdbc; |
| | | this.sensitiveDataCrypto = sensitiveDataCrypto; |
| | | this.realnameGateway = realnameGateway; |
| | | this.progressGateway = progressGateway; |
| | | this.progressService = progressService; |
| | | this.pictureUploadGateway = pictureUploadGateway; |
| | | this.huifuPaymentProperties = huifuPaymentProperties; |
| | | } |
| | | |
| | | /** |
| | |
| | | String legalIdNumber = decrypt(application.get("subjectIdNoEncrypted")); |
| | | String legalAddress = clean(application.get("legalCertificateAddress")); |
| | | boolean enterprise = "enterprise".equalsIgnoreCase(clean(application.get("subjectType"))); |
| | | Map<String,Object> store = enterprise ? Map.of() : store(application); |
| | | |
| | | List<String> missing = new ArrayList<>(); |
| | | if (legalName.isBlank()) missing.add("法人姓名"); |
| | | if (!legalMobile.matches("1\\d{10}")) missing.add("法人手机号(必须是法人本人的号码,不能用联系人手机号顶替)"); |
| | | if (legalIdNumber.isBlank()) missing.add("法人身份证号"); |
| | | if (enterprise && legalAddress.isBlank()) missing.add("法人证件居住地址"); |
| | | if (!enterprise) requireMicroStoreMaterial(application, store, missing); |
| | | if (!missing.isEmpty()) throw new BusinessException(ErrorCode.BAD_REQUEST, "发起实名还缺:" + String.join(";", missing)); |
| | | |
| | | return new HuifuMerchantRealnameCommand(legalName, legalMobile, legalIdNumber, legalAddress, legalIsBeneficiary(huifuId, legalName)); |
| | | return new HuifuMerchantRealnameCommand(legalName, legalMobile, legalIdNumber, legalAddress, |
| | | legalIsBeneficiary(huifuId, legalName), enterprise ? null : microStore(application, store)); |
| | | } |
| | | |
| | | /** |
| | | * 小微(个人)主体在实名路径上另收一组门店材料,缺项先报出来。 |
| | | * |
| | | * <p>官网把这一组都标成 N,但每一条都注明「小微商户填写」;而实名被驳回要换业务申请编号重来, |
| | | * 所以缺材料宁可在这里拦住,也不提交一份注定被驳回的申请。 |
| | | */ |
| | | private void requireMicroStoreMaterial(Map<String,Object> application, Map<String,Object> store, List<String> missing) { |
| | | if (clean(application.get("storeHeaderUrl")).isBlank()) missing.add("门店门头照"); |
| | | if (clean(application.get("storeIndoorUrl")).isBlank()) missing.add("店内环境照"); |
| | | if (clean(store.get("storeName")).isBlank()) missing.add("门店名称"); |
| | | if (clean(application.get("legalIdFrontUrl")).isBlank() || clean(application.get("legalIdBackUrl")).isBlank()) missing.add("身份证正反面照片"); |
| | | if (storeAddress(application, store).isBlank()) missing.add("门店地址"); |
| | | } |
| | | |
| | | /** |
| | | * 小微(个人)主体的门店材料,图片一律现传现取 fileId。 |
| | | * |
| | | * <p>{@code store_address_code} 用的是进件时选的经营市编码:官网示例 {@code 360400} 就是市编码, |
| | | * 但字段名写的是「门店省市编码」、长度 16,是否要「省+市」拼接尚无官方说明 |
| | | * (见交付记录里的待确认项),这里按示例的市编码提交。 |
| | | */ |
| | | private HuifuMerchantRealnameCommand.MicroStore microStore(Map<String,Object> application, Map<String,Object> store) { |
| | | return new HuifuMerchantRealnameCommand.MicroStore( |
| | | huifuPaymentProperties.getMerchantMicroBusinessType().trim(), |
| | | clean(store.get("storeName")), |
| | | pictureUploadGateway.uploadUrl(clean(application.get("storeHeaderUrl")), "F22"), |
| | | pictureUploadGateway.uploadUrl(clean(application.get("storeIndoorUrl")), "F24"), |
| | | clean(application.get("registeredCityCode")), |
| | | storeAddress(application, store), |
| | | pictureUploadGateway.uploadUrl(clean(application.get("legalIdFrontUrl")), "F40"), |
| | | pictureUploadGateway.uploadUrl(clean(application.get("legalIdBackUrl")), "F41")); |
| | | } |
| | | |
| | | /** |
| | | * 门店地址取商家档案上的门店地址;档案没填时退回进件时的地址。 |
| | | * |
| | | * <p>个人主体的 {@code registered_address} 存的是证件地址(进件表单没有单独收经营详细地址), |
| | | * 拿它当门店地址并不准确,但比留空更可能过审;商户在商家档案里补上门店地址后就会以档案为准。 |
| | | */ |
| | | private String storeAddress(Map<String,Object> application, Map<String,Object> store) { |
| | | String address = clean(store.get("address")); |
| | | return address.isBlank() ? clean(application.get("registeredAddress")) : address; |
| | | } |
| | | |
| | | private Map<String,Object> store(Map<String,Object> application) { |
| | | Object storeId = application.get("storeId"); |
| | | if (storeId == null) return Map.of(); |
| | | List<Map<String,Object>> records = rows("select store_name, address from merchant_store where id=? and deleted=0 limit 1", storeId); |
| | | return records.isEmpty() ? Map.of() : records.get(0); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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))) return onboarding(active.get(0)); |
| | | 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, legal_mobile, 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.legalMobile()), 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); |
| | |
| | | String huifuId = clean(application.get("providerHuifuId")); |
| | | if (huifuId.isBlank() || "rejected".equals(clean(application.get("status")))) return; |
| | | |
| | | 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")); |
| | | wechatStatus = "approved"; |
| | | } |
| | | 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")); |
| | | // 同理:通道侧已经开通微信业务时不再重新提交,否则会为同一商户开出第二份业务开通申请。 |
| | | if (needsProvisioningStep(businessStatus) && wechatBusinessEffective(huifuId)) { |
| | | // 通道侧已经开通微信业务时不再重新提交,否则会为同一商户开出第二份业务开通申请。 |
| | | // 同时把本地状态收口成 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 provisioningRequestNo(String applicationNo, String suffix) { String value = applicationNo + suffix; return value.length() <= 32 ? value : value.substring(0, 32); } |
| | | /** |
| | | * 通道请求流水号。 |
| | | * |
| | | * <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 void refreshRecoveryAfterCommit(Long id) { |
| | |
| | | default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "公司类型无效"); |
| | | }; |
| | | } |
| | | /** |
| | | * 进件主体所属行业的 MCC。 |
| | | * |
| | | * <p>个人(小微)主体在进件表单第一步单独选过「行业类型」(餐饮服务/酒店服务/景区服务…), |
| | | * 它跟着企业主体的公司类型一起落在 {@code company_type} 列上。原先个人进件的 MCC 只认商家档案上的 |
| | | * 经营类型,等于把商户自己在进件时选的行业**静默丢掉**:档案没配好就一律落 7399(其它), |
| | | * 通道侧的微信行业类目和费率跟着一起错。所以个人主体优先用进件时选的行业, |
| | | * 行业识别不出来(选了「其它」)再退回商家档案。 |
| | | * |
| | | * <p>{@code mcc} 是进件的必填项;业务开通只在没有 {@code fee_rule_id} 时才用它兜底。 |
| | | */ |
| | | private String mcc(Long merchantId, String subjectType, String industryType) { |
| | | if ("individual".equalsIgnoreCase(clean(subjectType))) { |
| | | String industry = mccForIndustry(industryType); |
| | | if (!industry.isBlank()) return industry; |
| | | } |
| | | return mcc(merchantId); |
| | | } |
| | | |
| | | /** |
| | | * 经营行业选中的 MCC。取值为静态形式,便于直接断言这条规则本身 |
| | | * (见 {@code MerchantOnboardingIndustryMccTests});返回空串表示这个行业识别不出来,交给商家档案兜底。 |
| | | */ |
| | | static String mccForIndustry(String industryType) { |
| | | String industry = MerchantTypeCatalog.canonicalCode(cleanStatus(industryType)); |
| | | return "other".equals(industry) ? "" : MCC_BY_MERCHANT_TYPE.getOrDefault(industry, "7399"); |
| | | } |
| | | |
| | | 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, "商家不存在"); |
| | |
| | | request.put("legal_cert_no", command.legalIdNumber()); |
| | | request.put("legal_cert_validity_type", command.legalValidityType()); |
| | | request.put("legal_cert_begin_date", command.legalBeginDate()); |
| | | putIfPresent(request, "legal_cert_end_date", command.legalEndDate()); |
| | | // 证件「长期有效」(validity_type=1)时不填截止日期——官方口径已确认:长期有效无需填写。 |
| | | if (!"1".equals(command.legalValidityType())) putIfPresent(request, "legal_cert_end_date", command.legalEndDate()); |
| | | request.put("legal_addr", command.legalAddress()); |
| | | request.put("legal_cert_front_pic", command.legalFrontPictureId()); |
| | | request.put("legal_cert_back_pic", command.legalBackPictureId()); |
| | |
| | | * |
| | | * <p>不设起结金额与留存金额:一笔 1 分钱也要能结出去。结算周期由渠道商开通的业务类型决定, |
| | | * 当前可用的是 D1(次自然日到账)。 |
| | | * |
| | | * <p>节假日结算手续费({@code fixed_ratio} 费率与 {@code constant_amt} 固定金额)在 D1 下是 |
| | | * 条件必填项。平台不向商户收这笔钱,两项都填 0.00;不填会被通道判缺参数。 |
| | | */ |
| | | static Map<String, Object> settleConfig() { |
| | | Map<String, Object> settle = new LinkedHashMap<>(); |
| | |
| | | settle.put("settle_pattern", "P0"); |
| | | settle.put("min_amt", "0.00"); |
| | | settle.put("remained_amt", "0.00"); |
| | | settle.put("fixed_ratio", "0.00"); |
| | | settle.put("constant_amt", "0.00"); |
| | | settle.put("out_settle_flag", "2"); |
| | | settle.put("fee_rate", "0.00"); |
| | | return settle; |
| | |
| | | String bankProvinceCode, |
| | | String bankCityCode, |
| | | String bankCode, |
| | | String settlementCardFrontPictureId |
| | | String settlementCardFrontPictureId, |
| | | // 店铺照片 fileId(F22):三种小微经营类型在进件时都必填,只是图片内容要求不同 |
| | | // (线上=店铺首页截图、流动经营=经营现场照、门店场所=实体门头照)。 |
| | | String storeHeaderPictureId |
| | | ) {} |
| | |
| | | card.put("cert_no", command.idCardNo()); |
| | | card.put("cert_validity_type", command.certificateValidityType()); |
| | | card.put("cert_begin_date", command.certificateBeginDate()); |
| | | if (!blank(command.certificateEndDate())) card.put("cert_end_date", command.certificateEndDate()); |
| | | // 长期有效(cert_validity_type=1)时截止日期留空——官方写明「当 cert_validity_type=1 时为空」。 |
| | | if (!"1".equals(command.certificateValidityType()) && !blank(command.certificateEndDate())) card.put("cert_end_date", command.certificateEndDate()); |
| | | card.put("mp", command.mobileNo()); |
| | | card.put("is_settle_default", "Y"); |
| | | |
| | |
| | | request.put("legal_cert_no", command.idCardNo()); |
| | | request.put("legal_cert_validity_type", command.certificateValidityType()); |
| | | request.put("legal_cert_begin_date", command.certificateBeginDate()); |
| | | putIfPresent(request, "legal_cert_end_date", command.certificateEndDate()); |
| | | // 证件「长期有效」(validity_type=1)时不填截止日期——官方口径已确认:长期有效无需填写。 |
| | | if (!"1".equals(command.certificateValidityType())) putIfPresent(request, "legal_cert_end_date", command.certificateEndDate()); |
| | | request.put("legal_addr", command.address()); |
| | | request.put("legal_cert_front_pic", command.legalFrontPictureId()); |
| | | request.put("legal_cert_back_pic", command.legalBackPictureId()); |
| | |
| | | request.put("login_name", loginName(command.applicationNo())); |
| | | request.put("card_info", JSON.toJSONString(card)); |
| | | request.put("settle_card_front_pic", command.settlementCardFrontPictureId()); |
| | | // 店铺照片(F22):个人(小微)商户进件的必填项,三种经营类型都要传,只是图片内容要求不同—— |
| | | // 线上商品/服务交易传店铺首页截图、流动经营/便民服务传经营现场照、门店场所传实体门头照。 |
| | | // 这里只负责把商户交的图带上,内容按经营类型由商户端提示。 |
| | | request.put("store_header_pic", command.storeHeaderPictureId()); |
| | | request.put("cash_config", JSON.toJSONString(List.of(cash))); |
| | | // 与商户主体一致:进件即打开自动结算,核销确认后的钱由斗拱按结算周期自动划到结算卡。 |
| | | request.put("settle_config", JSON.toJSONString(HuifuEnterpriseOnboardingGateway.settleConfig())); |
| | |
| | | |
| | | public HuifuMerchantBusinessOpenGateway(HuifuPaymentProperties properties) { this.properties = properties; } |
| | | |
| | | public Result open(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant) { |
| | | /** |
| | | * 提交商户业务开通申请。 |
| | | * |
| | | * @param individualMerchant 个人(小微)主体:决定 {@code micro_biz_type} 与门店材料 |
| | | * @param mccRequired 必须下发 {@code mcc}:个体工商户与小微商户(个人主体)都算。 |
| | | * 与 {@code individualMerchant} 分开,因为个体工商户走的是企业进件接口, |
| | | * 它不是"个人主体",但官方同样要求 mcc 必填 |
| | | */ |
| | | public Result open(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) { |
| | | ensureConfigured(); |
| | | Map<String, Object> request = buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant); |
| | | Map<String, Object> request = buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId); |
| | | try { |
| | | synchronized (SDK_LOCK) { |
| | | initializeSdk(); |
| | | Map<String, Object> response = BasePayClient.request(request, "v2.merchant.busi.open", false); |
| | | log.info("Huifu merchant business open response: requestNo={}, huifuId={}, response={}", requestNo, huifuId, JSON.toJSONString(response)); |
| | | // 连 wx_conf_list 一起记:通道对这个字段的报错只有一句「wx_conf_list格式错误」, |
| | | // 不打出实际报文就只能靠手工反推(2026-09-16 为此多烧了两轮真实请求)。 |
| | | // 只记这个字段——extended_material_list 里是图片标识,按高敏感数据不入日志。 |
| | | log.info("Huifu merchant business open response: requestNo={}, huifuId={}, wxConfList={}, response={}", |
| | | requestNo, huifuId, request.get("wx_conf_list"), JSON.toJSONString(response)); |
| | | return Result.from(response); |
| | | } |
| | | } catch (BusinessException exception) { |
| | |
| | | * 而该字段在文档中属于 wx_real_name_info 子表(父字段列 + 同名子表的写法约定即为嵌套对象)。 |
| | | */ |
| | | static Map<String, Object> buildRequestForTest(HuifuPaymentProperties properties, String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant) { |
| | | return new HuifuMerchantBusinessOpenGateway(properties).buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant); |
| | | return buildRequestForTest(properties, requestNo, huifuId, shortName, mcc, individualMerchant, individualMerchant, "", ""); |
| | | } |
| | | |
| | | static Map<String, Object> buildRequestForTest(HuifuPaymentProperties properties, String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) { |
| | | return new HuifuMerchantBusinessOpenGateway(properties).buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | private static final List<String> ONLINE_WECHAT_SCENES = List.of("4", "5", "12"); |
| | | |
| | | private Map<String, Object> buildRequest(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant) { |
| | | String feeRuleId = properties.getMerchantWechatFeeRuleId(); |
| | | private Map<String, Object> buildRequest(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) { |
| | | String feeRuleId = feeRuleId(individualMerchant, mccRequired); |
| | | List<Map<String, Object>> wechatList = new ArrayList<>(); |
| | | for (String scene : ONLINE_WECHAT_SCENES) { |
| | | Map<String, Object> wechat = new LinkedHashMap<>(); |
| | | wechat.put("pay_scene", scene); |
| | | // 企业微信业务使用汇付确认的 fee_rule_id;仅在未配置规则 ID 时兼容旧的 mcc 路径。 |
| | | // 不得把参考商户的规则 ID 猜测套用到其它行业。 |
| | | // 企业用汇付确认的 fee_rule_id 选费率规则。 |
| | | if (!feeRuleId.isBlank()) { |
| | | wechat.put("fee_rule_id", feeRuleId); |
| | | } else { |
| | | // mcc:文档注明「个体工商户、小微商户必填」——个人主体属于小微商户,所以必须带上。 |
| | | } |
| | | // mcc:官方写明「个体工商户、小微商户必填」——个人主体与个体工商户属于这两类, |
| | | // **必须带上**,不能因为配了 fee_rule_id 就省掉。官方请求示例里 mcc 与 fee_rule_id 也是同时出现的 |
| | | // (`"mcc":"111"` 与 `"fee_rule_id":"758"` 在同一个元素里)。 |
| | | // 原先这条判断写在 fee_rule_id 的 else 分支里,配了规则号就永远发不出 mcc—— |
| | | // 2026-09-16 个人商户业务开通被通道回 `wx_conf_list格式错误` 就是这个原因。 |
| | | // 企业主体没有这项要求(文档原文:企业使用 fee_rule_id 代替原有 mcc,mcc 可不填),不下发。 |
| | | if (mccRequired || feeRuleId.isBlank()) { |
| | | wechat.put("mcc", mcc); |
| | | } |
| | | wechat.put("fee_rate", properties.getMerchantWechatFeeRate().trim()); |
| | |
| | | if (!microBizType.isBlank()) { |
| | | Map<String, Object> realName = new LinkedHashMap<>(); |
| | | realName.put("micro_biz_type", microBizType); |
| | | wechat.put("wx_real_name_info", JSON.toJSONString(realName)); |
| | | // 嵌套 jsonObject,**不能**再 JSON.toJSONString 一次。官方字段目录里 |
| | | // `request.data.wx_conf_list[].wx_real_name_info` 的类型列是 `Object`、子字段按 |
| | | // `wx_real_name_info.micro_biz_type` 逐层展开(对照:`wx_conf_list[]` 本身才是 |
| | | // `String`,整个数组按 jsonArray字符串 发)。双重编码发出去是一段字符串, |
| | | // 通道解析时按对象取字段取不到,回的就是「wx_conf_list格式错误」—— |
| | | // 2026-09-16 个人商户业务开通实测。 |
| | | wechat.put("wx_real_name_info", realName); |
| | | } |
| | | } |
| | | wechatList.add(wechat); |
| | |
| | | request.put("upper_huifu_id", properties.getOnboardingUpperHuifuId().trim()); |
| | | request.put("short_name", shortName); |
| | | request.put("wx_conf_list", JSON.toJSONString(wechatList)); |
| | | // 个人(小微)商户填了 micro_biz_type,就同时欠这两张门店照片:官方在 micro_biz_type 的说明里 |
| | | // 写明「需要提供 F22:店铺门头照图片;F24:店铺内景图片,补充资料填写在 extended_material_list |
| | | // 对象中」。企业主体没有这项要求,不下发(避免给企业商户凭空加材料要求)。 |
| | | List<Map<String, Object>> extendedMaterials = new ArrayList<>(); |
| | | if (individualMerchant) { |
| | | putMaterial(extendedMaterials, "F22", storeHeaderFileId); |
| | | putMaterial(extendedMaterials, "F24", storeIndoorFileId); |
| | | } |
| | | if (!extendedMaterials.isEmpty()) request.put("extended_material_list", JSON.toJSONString(extendedMaterials)); |
| | | request.put("online_busi_type", properties.getMerchantOnlineBusinessType().trim()); |
| | | // 运营媒介只在开通快捷/网银/余额支付Pro/分账/大额转账/代扣时才需要,本网关只开线上微信, |
| | | // 因此默认不下发。要下发就把 merchant-online-media-name 配上;注意声明 |
| | |
| | | BasePay.initWithMerConfig(config); |
| | | } |
| | | |
| | | /** |
| | | * 微信费率规则号(CID)**按主体类型**取:官方《微信费率规则ID》对照表用的是 |
| | | * 「支付场景 × 业务开通类型 × 主体类型」三维定位,本平台只开线上公众号/小程序/线上反扫 |
| | | * (场景 4/5/12)、业务开通类型恒为 01,所以只剩主体类型这一个变量: |
| | | * 企业 756、个体工商户 761、个人(小微)775。 |
| | | * |
| | | * <p>用错号不是"费率算错"而是直接被拒——通道回 |
| | | * 「微信费率规则号填写不正确,请检查!」(2026-09-16 拿企业的 756 给小微商户实测)。 |
| | | * |
| | | * <p>个体工商户落的是企业进件接口({@code individualMerchant=false}),所以判据要用 |
| | | * {@code mccRequired}——它覆盖的正是「个体工商户与小微商户」这一组。 |
| | | */ |
| | | private String feeRuleId(boolean individualMerchant, boolean mccRequired) { |
| | | if (individualMerchant) return properties.getMerchantWechatFeeRuleIdIndividual(); |
| | | if (mccRequired) return properties.getMerchantWechatFeeRuleIdSoleProprietor(); |
| | | return properties.getMerchantWechatFeeRuleId(); |
| | | } |
| | | |
| | | private static void putIfPresent(Map<String, Object> request, String key, String value) { if (!blank(value)) request.put(key, value.trim()); } |
| | | private static void putMaterial(List<Map<String, Object>> materials, String fileType, String fileId) { |
| | | if (blank(fileId)) return; |
| | | Map<String, Object> material = new LinkedHashMap<>(); |
| | | material.put("file_id", fileId.trim()); |
| | | material.put("file_type", fileType); |
| | | materials.add(material); |
| | | } |
| | | private static boolean blank(String value) { return value == null || value.isBlank(); } |
| | | private boolean outcomeUnknown(Exception exception) { String message = exception.getMessage() == null ? "" : exception.getMessage().toLowerCase(); return message.contains("timeout") || message.contains("timed out") || message.contains("no response"); } |
| | | |
| | |
| | | * {@code subject_id_no_encrypted}(法人身份证号,加密存储)、{@code legal_mobile}(法人手机号, |
| | | * V145 新增)、{@code legal_certificate_address}(法人证件居住地址)。 |
| | | * |
| | | * <p>{@code microStore} 是**个人(小微)主体专有**的门店资料:官方在小微商户路径上另有门店名称、 |
| | | * 门头照、店内环境照、门店地址与省市编码、身份证正反面这一组字段,缺了实名会被驳回。 |
| | | * 企业主体走营业执照与法人资料,这一项为 {@code null}——网关就按「非 null 即小微主体」分支。 |
| | | * |
| | | * @param legalName 法人姓名 |
| | | * @param legalMobile 法人本人手机号,不是联系人手机号 |
| | | * @param legalIdNumber 法人身份证号 |
| | | * @param legalAddress 法人证件居住地址,企业必填 |
| | | * @param legalIsBeneficiary 法人是否最终受益人,对应 {@code owner};企业开通微信支付时必填 |
| | | * @param microStore 小微(个人)主体的门店资料;企业主体传 {@code null} |
| | | */ |
| | | public record HuifuMerchantRealnameCommand( |
| | | String legalName, |
| | | String legalMobile, |
| | | String legalIdNumber, |
| | | String legalAddress, |
| | | boolean legalIsBeneficiary |
| | | ) {} |
| | | boolean legalIsBeneficiary, |
| | | MicroStore microStore |
| | | ) { |
| | | /** |
| | | * 小微(个人)主体的门店资料,全部使用图片上传接口返回的 fileId。 |
| | | * |
| | | * @param microBizType 小微经营类型,{@code MICRO_TYPE_STORE}/{@code MICRO_TYPE_MOBILE}/ |
| | | * {@code MICRO_TYPE_ONLINE} |
| | | * @param storeName 门店名称 |
| | | * @param storeHeaderFileId 门店门头照片(F22);流动经营/便民服务场景下是经营现场照 |
| | | * @param storeIndoorFileId 店内环境照片(F24) |
| | | * @param storeAddressCode 门店省市编码 |
| | | * @param storeAddress 门店地址 |
| | | * @param idFrontFileId 身份证件人像面(F40) |
| | | * @param idBackFileId 身份证件国徽面(F41) |
| | | */ |
| | | public record MicroStore( |
| | | String microBizType, |
| | | String storeName, |
| | | String storeHeaderFileId, |
| | | String storeIndoorFileId, |
| | | String storeAddressCode, |
| | | String storeAddress, |
| | | String idFrontFileId, |
| | | String idBackFileId |
| | | ) {} |
| | | } |
| | |
| | | request.put("contact_type", "LEGAL"); |
| | | // 企业开通微信支付时必填:经营者/法人是否为最终受益人。 |
| | | request.put("owner", command.legalIsBeneficiary() ? "Y" : "N"); |
| | | putIfPresent(request, "identification_address", command.legalAddress()); |
| | | // 本项目主体是普通企业,不是金融机构。 |
| | | request.put("finance_institution_flag", "N"); |
| | | HuifuMerchantRealnameCommand.MicroStore microStore = command.microStore(); |
| | | if (microStore == null) { |
| | | // 法人证件居住地址:文档写明「主体类型为企业时,需要填写。其他主体类型,无需上传」。 |
| | | putIfPresent(request, "identification_address", command.legalAddress()); |
| | | return request; |
| | | } |
| | | // 小微(个人)主体:官方在这一路径上另有一组门店与证件字段,说明均为「小微商户填写」。 |
| | | // 不进 extended_material_list,也不复用业务开通的 store_*_pic——实名的字段名是 *_copy。 |
| | | putIfPresent(request, "micro_biz_type", microStore.microBizType()); |
| | | putIfPresent(request, "store_name", microStore.storeName()); |
| | | putIfPresent(request, "store_header_copy", microStore.storeHeaderFileId()); |
| | | putIfPresent(request, "store_indoor_copy", microStore.storeIndoorFileId()); |
| | | putIfPresent(request, "store_address_code", microStore.storeAddressCode()); |
| | | putIfPresent(request, "store_address", microStore.storeAddress()); |
| | | putIfPresent(request, "identification_front_copy", microStore.idFrontFileId()); |
| | | putIfPresent(request, "identification_back_copy", microStore.idBackFileId()); |
| | | return request; |
| | | } |
| | | |
| | |
| | | * 由汇付按商户实际行业确认;留空时兼容旧的 mcc 请求方式。 |
| | | */ |
| | | private String merchantWechatFeeRuleId = ""; |
| | | /** |
| | | * 个人(小微)主体微信业务开通使用的费率规则 ID。 |
| | | * |
| | | * <p>官方的《微信费率规则ID》对照表按「支付场景 × 业务开通类型 × **主体类型**」定 CID:同样是 |
| | | * 线上公众号/小程序(场景 4/5、类型 01),企业是 756、个体户 761、事业单位 773、社会组织 774、 |
| | | * **小微 775**。所以这个号不能全平台共用一个——给小微发企业的 756 会被通道回 |
| | | * 「微信费率规则号填写不正确,请检查!」(2026-09-16 个人商户实测)。 |
| | | */ |
| | | private String merchantWechatFeeRuleIdIndividual = "775"; |
| | | /** 个体工商户(走企业进件接口、对私结算)的费率规则 ID,同一张表里是 761。 */ |
| | | private String merchantWechatFeeRuleIdSoleProprietor = "761"; |
| | | private String merchantOnlineBusinessType = "H5311L"; |
| | | private String merchantOnlineMediaName = "东区人才卡"; |
| | | /** |
| | |
| | | public void setMerchantWechatFeeRate(String merchantWechatFeeRate) { this.merchantWechatFeeRate = merchantWechatFeeRate; } |
| | | public String getMerchantWechatFeeRuleId() { return merchantWechatFeeRuleId == null ? "" : merchantWechatFeeRuleId.trim(); } |
| | | public void setMerchantWechatFeeRuleId(String merchantWechatFeeRuleId) { this.merchantWechatFeeRuleId = merchantWechatFeeRuleId; } |
| | | public String getMerchantWechatFeeRuleIdIndividual() { return merchantWechatFeeRuleIdIndividual == null ? "" : merchantWechatFeeRuleIdIndividual.trim(); } |
| | | public void setMerchantWechatFeeRuleIdIndividual(String merchantWechatFeeRuleIdIndividual) { this.merchantWechatFeeRuleIdIndividual = merchantWechatFeeRuleIdIndividual; } |
| | | public String getMerchantWechatFeeRuleIdSoleProprietor() { return merchantWechatFeeRuleIdSoleProprietor == null ? "" : merchantWechatFeeRuleIdSoleProprietor.trim(); } |
| | | public void setMerchantWechatFeeRuleIdSoleProprietor(String merchantWechatFeeRuleIdSoleProprietor) { this.merchantWechatFeeRuleIdSoleProprietor = merchantWechatFeeRuleIdSoleProprietor; } |
| | | public String getMerchantOnlineBusinessType() { return merchantOnlineBusinessType; } |
| | | public void setMerchantOnlineBusinessType(String merchantOnlineBusinessType) { this.merchantOnlineBusinessType = merchantOnlineBusinessType; } |
| | | public String getMerchantOnlineMediaName() { return merchantOnlineMediaName; } |
| | |
| | | # 唯一的一条规则,细分行业含「就业信息平台/其他信息服务平台」,与本平台业务对应; |
| | | # 场景 12 在该表没有独立行,与线上公众号/小程序共用这一条。留空则退回 mcc 兼容路径。 |
| | | merchant-wechat-fee-rule-id: ${HUIFU_MERCHANT_WECHAT_FEE_RULE_ID:756} |
| | | # 同一张表按**主体类型**分行:线上(类型 01)下企业 756、个体工商户 761、小微 775。 |
| | | # 小微拿企业的 756 会被通道回「微信费率规则号填写不正确,请检查!」,所以必须分开配。 |
| | | merchant-wechat-fee-rule-id-individual: ${HUIFU_MERCHANT_WECHAT_FEE_RULE_ID_INDIVIDUAL:775} |
| | | merchant-wechat-fee-rule-id-sole-proprietor: ${HUIFU_MERCHANT_WECHAT_FEE_RULE_ID_SOLE_PROPRIETOR:761} |
| | | merchant-online-business-type: ${HUIFU_MERCHANT_ONLINE_BUSINESS_TYPE:H5311L} |
| | | # 运营媒介(online_media_info_list)。文档写明它只在开通快捷/网银/余额支付Pro/分账/ |
| | | # 大额转账/代扣时才需要,且是「基本信息入驻接口」的必填项,不是「商户业务开通」的必填项; |
| New file |
| | |
| | | -- 进件申请增加「门店照片」两列:门头照/经营现场照、店内环境照。 |
| | | -- |
| | | -- 个人(小微/无执照)商户拿到微信支付收款能力必须交这两张图,两个地方都点名要: |
| | | -- 1. 商户业务开通:`wx_conf_list[].wx_real_name_info.micro_biz_type` 的说明写明 |
| | | -- 「个人商户需要填写」且「需要提供 F22:店铺门头照图片;F24:店铺内景图片, |
| | | -- 补充资料填写在 extended_material_list 对象中」; |
| | | -- 2. 微信实名认证:小微商户另有 `store_header_copy`(门店门头照片)与 |
| | | -- `store_indoor_copy`(店内环境照片)两个专用字段。 |
| | | -- |
| | | -- 原先两条链路都没有位置存这两张图,个人商户的实名与开通只能缺材料提交。 |
| | | -- 企业主体用不到这两列(企业实名走营业执照与法人资料)。 |
| | | -- |
| | | -- 注意:不要写 `add column if not exists`——那是 MariaDB 的扩展,MySQL 8.0.36 会报 ERROR 1064 |
| | | -- (本仓 V143 的注释里记过这个坑)。迁移只执行一次,本来也不需要它。 |
| | | -- 开发库 spring.flyway.enabled=false,这些列由 MerchantOnboardingSchemaInitializer 补建。 |
| | | alter table merchant_onboarding_application |
| | | add column store_header_url varchar(512) default null, |
| | | add column store_indoor_url varchar(512) default null; |
| | |
| | | assertThat(result).containsEntry("status","pending"); |
| | | } |
| | | |
| | | /** |
| | | * 通道**不返回** {@code fee_min_amt} 时,微信支付阶段同样要算已生效。 |
| | | * |
| | | * <p>该字段是可选的,文档写明「大于 0」,不设最低收费时我们本来就不下发它,通道也就不返回它。 |
| | | * 判定原先要求它必须出现且为 0,于是真实商户的微信业务明明已经开好(三场景、费率 0.70、内扣、 |
| | | * 子商户号与默认标识都在),阶段却永远停在未确认——2026-09-16 拿通道回执逐项核对才发现。 |
| | | */ |
| | | @Test |
| | | void anAbsentMinimumFeeStillCountsAsWechatConfirmed() { |
| | | MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); |
| | | when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); |
| | | // 与真实回执同形:三个场景都不带 fee_min_amt。 |
| | | var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_charge_type","1","out_fee_flag","2", |
| | | "wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","922609119","pay_channel_id","00016904","is_default","1")))).toList(); |
| | | when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes)); |
| | | |
| | | Map<String,Object> result=service.refreshOnboarding(principal,false); |
| | | |
| | | assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","wechat").containsEntry("status","confirmed")); |
| | | } |
| | | |
| | | /** 但真设了最低收费(非 0)时仍不算生效——放宽的只是"字段缺失",不是"值不对"。 */ |
| | | @Test |
| | | void aNonZeroMinimumFeeKeepsTheWechatStageUnconfirmed() { |
| | | MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); |
| | | when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); |
| | | var scenes=java.util.List.of("4","5","12").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.70","fee_min_amt","1.00","fee_charge_type","1","out_fee_flag","2", |
| | | "wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","922609119","is_default","1")))).toList(); |
| | | when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N","qry_wx_conf_list",scenes)); |
| | | |
| | | Map<String,Object> result=service.refreshOnboarding(principal,false); |
| | | |
| | | assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","wechat").containsEntry("status","missing")); |
| | | } |
| | | |
| | | @Test |
| | | void confirmsD1OnlyFromEffectiveZeroThresholdBatchSettlement() { |
| | | MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal"); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 分阶段刷新要**先推进开通步骤,再回报结果**。渠道侧的微信小程序支付配置与商户业务开通原本 |
| | | * 只由进件审核回调触发,而分阶段申请的回调走的是进度刷新——不在这里补上推进,新商户的微信 |
| | | * 开通就永远不会被发起。 |
| | | */ |
| | | @Test |
| | | void refreshingAdvancesTheWechatProvisioningSteps() { |
| | | Map<String,Object> fixture=provisioningFixture(); |
| | | when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); |
| | | when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","delay_flag","Y","forced_delay_flag","N")); |
| | | when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000")); |
| | | when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(true,"00000000","成功")); |
| | | when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(true,"BO-APPLY","90000000","审核中")); |
| | | |
| | | service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false); |
| | | |
| | | assertThat(statusOf("wechat_config_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved"); |
| | | assertThat(statusOf("business_open_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("processing"); |
| | | verify(wechatConfigGateway,times(1)).configure(anyString(),eq("6666000241335656")); |
| | | verify(businessOpenGateway,times(1)).open(anyString(),eq("6666000241335656"),anyString(),anyString(),anyBoolean()); |
| | | } |
| | | |
| | | /** |
| | | * 通道侧已经生效的步骤不再重复提交。微信商户配置是**添加**接口(成功后不支持改删、每种参数 |
| | | * 最多 5 组),业务开通是申请型接口,重复提交会在通道留下重复配置或第二份申请。 |
| | | */ |
| | |
| | | assertThat(statusOf("wechat_config_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved"); |
| | | assertThat(statusOf("business_open_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved"); |
| | | verify(wechatConfigGateway,never()).configure(anyString(),anyString()); |
| | | verify(businessOpenGateway,never()).open(anyString(),anyString(),anyString(),anyString(),anyBoolean()); |
| | | verify(businessOpenGateway,never()).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString()); |
| | | } |
| | | |
| | | /** |
| | | * 顺序与闸门:先做业务开通;微信小程序配置要等**通道侧真的有微信子商户号**才发。 |
| | | * |
| | | * <p>微信子商户号由业务开通创建,但入驻是**异步**的——业务开通回 {@code 90000000}(审核中) |
| | | * 并不代表子商户号已经建好,此时发配置会被「根据渠道号或微信子商户号未查到入驻记录」驳回。 |
| | | * 所以闸门取通道证据({@code qry_wx_conf_list} 里出现线上场景),不取本地受理状态。 |
| | | * 2026-09-16 用本地状态当闸门的真实代价就是这个报错。 |
| | | */ |
| | | @Test |
| | | void theWechatConfigWaitsUntilTheChannelConfirmsTheWechatBusiness() { |
| | | Map<String,Object> fixture=provisioningFixture(); |
| | | String applicationNo=String.valueOf(fixture.get("applicationNo")); |
| | | MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal"); |
| | | when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); |
| | | when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N")); |
| | | when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(true,"BO-APPLY","90000000","审核中")); |
| | | |
| | | // 第一轮:通道侧还没有微信业务 → 只提交业务开通,配置这一步根本不碰。 |
| | | service.refreshOnboarding(principal,false); |
| | | assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("processing"); |
| | | verify(wechatConfigGateway,never()).configure(anyString(),anyString()); |
| | | // 保持 not_started,不写成 failed:这一步根本没试过。 |
| | | assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("not_started"); |
| | | |
| | | // 第二轮:通道侧已经开通线上场景 → 业务开通按已开通收口(不重复提交),配置才发出去。 |
| | | when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N", |
| | | "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4")))); |
| | | when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(true,"00000000","成功")); |
| | | |
| | | service.refreshOnboarding(principal,false); |
| | | |
| | | // 业务开通只提交过一次——通道已开通时不再重复提交,避免第二份申请。 |
| | | verify(businessOpenGateway,times(1)).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString()); |
| | | verify(wechatConfigGateway,times(1)).configure(anyString(),anyString()); |
| | | assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("approved"); |
| | | assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("approved"); |
| | | } |
| | | |
| | | /** 微信配置失败不影响业务开通的推进与收口:两步各自记状态、各自可重试。 */ |
| | | @Test |
| | | void aFailedWechatConfigDoesNotHoldBackTheBusinessOpen() { |
| | | Map<String,Object> fixture=provisioningFixture(); |
| | | when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); |
| | | when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N", |
| | | "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4")))); |
| | | when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(false,"00000001","参数非法")); |
| | | |
| | | service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false); |
| | | |
| | | String applicationNo=String.valueOf(fixture.get("applicationNo")); |
| | | // 业务开通照样收口为已开通(通道证据),不被另一步的失败牵连。 |
| | | assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("approved"); |
| | | assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("failed"); |
| | | assertThat(statusOf("reject_reason",applicationNo)).contains("参数非法"); |
| | | } |
| | | |
| | | /** |
| | | * 延迟入账失败时把通道给的描述一起落库。 |
| | | * |
| | | * <p>这一条失败回的是 {@code 99999999}(通道的「失败:<原因>」通用码),原先只存 code, |
| | | * 描述一丢就完全看不出为什么失败——2026-09-16 排查时正是卡在这里。 |
| | | */ |
| | | @Test |
| | | void aFailedDelayAttemptKeepsTheChannelMessage() { |
| | | Map<String,Object> fixture=fixture(); |
| | | when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); |
| | | when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","N","forced_delay_flag","N")); |
| | | when(gateway.configureDelay(anyString(),anyString(),anyString())).thenReturn(Map.of("resp_code","99999999","sub_resp_desc","复用交易信息,上级商户号为空")); |
| | | |
| | | service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false); |
| | | |
| | | String message=jdbc.queryForObject(""" |
| | | select t.response_message from merchant_onboarding_attempt t |
| | | join merchant_onboarding_application a on a.id=t.application_id |
| | | where a.application_no=? and t.step='delay' |
| | | """,String.class,String.valueOf(fixture.get("applicationNo"))); |
| | | assertThat(message).isEqualTo("复用交易信息,上级商户号为空"); |
| | | } |
| | | |
| | | /** |
| | | * 通道按流水号去重:上一步被通道**明确驳回**后重试必须换新号,沿用旧号会被当成重复请求。 |
| | | * 进件与实名走的是同一条规则,业务开通与微信配置原本是复用——2026-09-16 被驳回后要重发时才发现。 |
| | | */ |
| | | @Test |
| | | void aFailedBusinessOpenIsRetriedWithANewRequestNumber() { |
| | | Map<String,Object> fixture=provisioningFixture(); |
| | | String applicationNo=String.valueOf(fixture.get("applicationNo")); |
| | | when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); |
| | | when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N")); |
| | | when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(false,"","00000001","wx_conf_list格式错误")); |
| | | |
| | | MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal"); |
| | | service.refreshOnboarding(principal,false); |
| | | assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("failed"); |
| | | service.refreshOnboarding(principal,false); |
| | | |
| | | var requests=org.mockito.ArgumentCaptor.forClass(String.class); |
| | | verify(businessOpenGateway,times(2)).open(requests.capture(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString()); |
| | | assertThat(requests.getAllValues()).doesNotHaveDuplicates(); |
| | | assertThat(requests.getAllValues().get(0)).isEqualTo(applicationNo+"BO"); |
| | | assertThat(statusOf("business_open_request_no",applicationNo)).isEqualTo(requests.getAllValues().get(1)); |
| | | } |
| | | |
| | | @Test |
| | | void aFailedWechatConfigIsRetriedWithANewRequestNumber() { |
| | | Map<String,Object> fixture=provisioningFixture(); |
| | | String applicationNo=String.valueOf(fixture.get("applicationNo")); |
| | | when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S")); |
| | | // 通道侧已开通线上场景,配置这一步才有东西可挂(见 advanceWechatConfig 的闸门)。 |
| | | when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N", |
| | | "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4")))); |
| | | when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(false,"00000001","参数非法")); |
| | | |
| | | MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal"); |
| | | service.refreshOnboarding(principal,false); |
| | | assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("failed"); |
| | | service.refreshOnboarding(principal,false); |
| | | |
| | | var requests=org.mockito.ArgumentCaptor.forClass(String.class); |
| | | verify(wechatConfigGateway,times(2)).configure(requests.capture(),anyString()); |
| | | assertThat(requests.getAllValues()).doesNotHaveDuplicates(); |
| | | assertThat(requests.getAllValues().get(0)).isEqualTo(applicationNo+"WC"); |
| | | assertThat(statusOf("wechat_config_request_no",applicationNo)).isEqualTo(requests.getAllValues().get(1)); |
| | | } |
| | | |
| | | private String statusOf(String column,String applicationNo) { |
| | | return jdbc.queryForObject("select "+column+" from merchant_onboarding_application where application_no=?",String.class,applicationNo); |
| | | } |
| | | |
| | | /** |
| | | * 分阶段模型下重复提交不新建进件,也就不会在通道上多出一家商户。唯一的例外是补齐个人主体的 |
| | | * 门店照片:那是开通微信支付与发起微信实名的硬材料,而商户端没有别的补图入口——真实开发库里 |
| | | * 已经有拿到汇付商户号的个人进件停在这个形态上(有商户号、门店照片为空、业务开通没做过)。 |
| | | */ |
| | | @Test |
| | | void resubmittingSuppliesTheMissingStorePhotosWithoutCreatingANewApplication() { |
| | | Map<String,Object> fixture = individualProvisioningFixture(); |
| | | MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal"); |
| | | String applicationNo = String.valueOf(fixture.get("applicationNo")); |
| | | |
| | | service.submitOnboarding(principal, request("https://cdn.example.com/store-header.jpg","https://cdn.example.com/store-indoor.jpg"), "T02-store-"+System.nanoTime()); |
| | | |
| | | assertThat(statusOf("store_header_url",applicationNo)).isEqualTo("https://cdn.example.com/store-header.jpg"); |
| | | assertThat(statusOf("store_indoor_url",applicationNo)).isEqualTo("https://cdn.example.com/store-indoor.jpg"); |
| | | assertThat(jdbc.queryForObject("select count(*) from merchant_onboarding_application where application_no=?",Integer.class,applicationNo)).isEqualTo(1); |
| | | // 没有重新进件,原汇付商户号与银行卡关系都保持。 |
| | | assertThat(statusOf("provider_huifu_id",applicationNo)).isEqualTo("6666000241335656"); |
| | | assertThat(jdbc.queryForObject("select initial_bank_card_id from merchant_onboarding_application where application_no=?",Long.class,applicationNo)).isNotNull(); |
| | | } |
| | | |
| | | /** 已经有值的门店照片不会被重新提交覆盖。 */ |
| | | @Test |
| | | void resubmittingDoesNotOverwriteAnExistingStorePhoto() { |
| | | Map<String,Object> fixture = individualProvisioningFixture(); |
| | | MerchantPrincipal principal = (MerchantPrincipal) fixture.get("principal"); |
| | | String applicationNo = String.valueOf(fixture.get("applicationNo")); |
| | | jdbc.update("update merchant_onboarding_application set store_header_url='https://cdn.example.com/original.jpg' where application_no=?", applicationNo); |
| | | |
| | | service.submitOnboarding(principal, request("https://cdn.example.com/other.jpg","https://cdn.example.com/store-indoor.jpg"), "T02-store-"+System.nanoTime()); |
| | | |
| | | assertThat(statusOf("store_header_url",applicationNo)).isEqualTo("https://cdn.example.com/original.jpg"); |
| | | assertThat(statusOf("store_indoor_url",applicationNo)).isEqualTo("https://cdn.example.com/store-indoor.jpg"); |
| | | } |
| | | |
| | | private Map<String,Object> individualProvisioningFixture() { |
| | | String key="T02I-"+System.nanoTime(); |
| | | jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)",key); |
| | | Long merchant=jdbc.queryForObject("select id from merchant where merchant_name=?",Long.class,key); |
| | | jdbc.update("insert into merchant_store(merchant_id,store_name,status,deleted) values (?,?,1,0)",merchant,key); |
| | | Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant); |
| | | jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key); |
| | | Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key); |
| | | jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id,business_open_status) values (?, ?, ?, 'individual','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'failed')",merchant,store,key,key,card); |
| | | return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key); |
| | | } |
| | | |
| | | /** 补齐门店照片这一步只读这两项,其余字段留空即可。 */ |
| | | private static MerchantOnboardingRequest request(String storeHeaderUrl,String storeIndoorUrl) { |
| | | return new MerchantOnboardingRequest("individual","Owner",null,null,null,null,null,null,null,null, |
| | | null,null,"510100",null,null,null,null,null,null,null,null,null,null,null,null,null, |
| | | "Owner","13700000000",null,null,"510100",null,null,null,storeHeaderUrl,storeIndoorUrl); |
| | | } |
| | | |
| | | /** 建一张还停在 not_started 的进件,用来观察刷新会不会去推进开通步骤。 */ |
| | | private Map<String,Object> provisioningFixture() { |
| | | String key="T02P-"+System.nanoTime(); |
| New file |
| | |
| | | package com.talentcard.server.business.merchant; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | |
| | | import org.junit.jupiter.api.Test; |
| | | |
| | | /** |
| | | * 个人(小微)主体的 MCC 取的是**商户在进件表单里选的行业**。 |
| | | * |
| | | * <p>进件第一步让个人商户选「行业类型」(餐饮服务/酒店服务/景区服务/加油服务/停车服务/其它), |
| | | * 它跟着企业主体的公司类型一起落在 {@code company_type} 列上。原先上报汇付的 {@code mcc} |
| | | * 只认商家档案上的经营类型,等于把这一项**静默丢掉**:档案没配好就一律落 7399(其它), |
| | | * 通道侧的微信行业类目和费率跟着一起错,而商户页面上明明选过行业。 |
| | | * |
| | | * <p>「其它」与识别不出来的取值返回空串,交给商家档案兜底——不要在这里拍一个 7399, |
| | | * 那会把「档案里配了准确行业」的商户也降级成「其它」。 |
| | | */ |
| | | class MerchantOnboardingIndustryMccTests { |
| | | |
| | | @Test |
| | | void theIndustryPickedOnTheFormBecomesTheMcc() { |
| | | assertThat(MerchantSettlementService.mccForIndustry("餐饮服务")).isEqualTo("5812"); |
| | | assertThat(MerchantSettlementService.mccForIndustry("酒店服务")).isEqualTo("7011"); |
| | | assertThat(MerchantSettlementService.mccForIndustry("景区服务")).isEqualTo("7991"); |
| | | assertThat(MerchantSettlementService.mccForIndustry("加油服务")).isEqualTo("5541"); |
| | | assertThat(MerchantSettlementService.mccForIndustry("停车服务")).isEqualTo("7523"); |
| | | } |
| | | |
| | | /** 别名与规范化代码同样认,商户端换过文案不影响映射。 */ |
| | | @Test |
| | | void theIndustryIsResolvedThroughTheSharedCatalog() { |
| | | assertThat(MerchantSettlementService.mccForIndustry("餐饮")).isEqualTo("5812"); |
| | | assertThat(MerchantSettlementService.mccForIndustry("food")).isEqualTo("5812"); |
| | | assertThat(MerchantSettlementService.mccForIndustry("餐饮商家")).isEqualTo("5812"); |
| | | assertThat(MerchantSettlementService.mccForIndustry(" theatre ")).isEqualTo("7832"); |
| | | } |
| | | |
| | | @Test |
| | | void anUnrecognisedIndustryFallsBackToTheMerchantProfile() { |
| | | assertThat(MerchantSettlementService.mccForIndustry("其它")).isEmpty(); |
| | | assertThat(MerchantSettlementService.mccForIndustry("街边小店")).isEmpty(); |
| | | assertThat(MerchantSettlementService.mccForIndustry("")).isEmpty(); |
| | | assertThat(MerchantSettlementService.mccForIndustry(null)).isEmpty(); |
| | | } |
| | | } |
| | |
| | | import static org.mockito.ArgumentMatchers.*; |
| | | import static org.mockito.Mockito.*; |
| | | import java.util.Map; |
| | | import com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand; |
| | | import com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; |
| | | import com.talentcard.server.business.payment.huifu.HuifuPictureUploadGateway; |
| | | import com.talentcard.server.common.BusinessException; |
| | | import com.talentcard.server.common.SensitiveDataCrypto; |
| | | import org.junit.jupiter.api.Test; |
| | |
| | | @Autowired JdbcTemplate jdbc; |
| | | @Autowired MerchantRealnameService service; |
| | | @Autowired SensitiveDataCrypto crypto; |
| | | @Autowired HuifuPaymentProperties huifuProperties; |
| | | @MockitoBean HuifuMerchantRealnameGateway realnameGateway; |
| | | @MockitoBean HuifuOnboardingProgressGateway progressGateway; |
| | | @MockitoBean HuifuPictureUploadGateway pictureUploadGateway; |
| | | |
| | | @Test |
| | | void missingLegalMobileIsReportedBeforeAnyProviderCall() { |
| | |
| | | } |
| | | |
| | | /** |
| | | * 个人(小微)主体的实名要带齐门店材料:官方在实名的小微路径上另收门店名称、门头照、 |
| | | * 店内环境照、门店地址与省市编码、以及身份证正反面。原先这一组一个都没发, |
| | | * 而且这一条路径上缺材料是被通道驳回——驳回要换业务申请编号重来。 |
| | | */ |
| | | @Test |
| | | void anIndividualMerchantCarriesItsStoreMaterial() { |
| | | long applicationId = individualFixture(); |
| | | when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000")); |
| | | when(pictureUploadGateway.uploadUrl(anyString(),anyString())).thenReturn("file-id"); |
| | | when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功")); |
| | | |
| | | service.submit(applicationId, false); |
| | | |
| | | var command = org.mockito.ArgumentCaptor.forClass(com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand.class); |
| | | verify(realnameGateway).submit(anyString(),eq("6666000241335656"),command.capture()); |
| | | HuifuMerchantRealnameCommand.MicroStore store = command.getValue().microStore(); |
| | | assertThat(store).isNotNull(); |
| | | assertThat(store.storeName()).isEqualTo("张维的餐饮店"); |
| | | assertThat(store.storeAddress()).isEqualTo("成都市成华区东丽街138号"); |
| | | // 门店省市编码取进件时选的经营市编码。 |
| | | assertThat(store.storeAddressCode()).isEqualTo("510100"); |
| | | assertThat(store.microBizType()).isEqualTo(huifuProperties.getMerchantMicroBusinessType().trim()); |
| | | // 四张图各按官方文件类型上报:F22 门头照、F24 店内环境照、F40/F41 身份证正反面。 |
| | | verify(pictureUploadGateway).uploadUrl(anyString(),eq("F22")); |
| | | verify(pictureUploadGateway).uploadUrl(anyString(),eq("F24")); |
| | | verify(pictureUploadGateway).uploadUrl(anyString(),eq("F40")); |
| | | verify(pictureUploadGateway).uploadUrl(anyString(),eq("F41")); |
| | | assertThat(store.storeHeaderFileId()).isEqualTo("file-id"); |
| | | assertThat(store.idBackFileId()).isEqualTo("file-id"); |
| | | } |
| | | |
| | | /** 企业主体的实名不带小微门店材料,也不该为它去传门店照片。 */ |
| | | @Test |
| | | void anEnterpriseMerchantCarriesNoStoreMaterial() { |
| | | long applicationId = fixture("13900000000"); |
| | | when(progressGateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000")); |
| | | when(realnameGateway.submit(anyString(),anyString(),any())).thenReturn(new HuifuMerchantRealnameGateway.Result(true,"2000002327252449","00000000","成功")); |
| | | |
| | | service.submit(applicationId, false); |
| | | |
| | | var command = org.mockito.ArgumentCaptor.forClass(com.talentcard.server.business.payment.huifu.HuifuMerchantRealnameCommand.class); |
| | | verify(realnameGateway).submit(anyString(),anyString(),command.capture()); |
| | | assertThat(command.getValue().microStore()).isNull(); |
| | | verifyNoInteractions(pictureUploadGateway); |
| | | } |
| | | |
| | | /** |
| | | * 个人主体缺门店材料时一次说清,不提交注定被驳回的申请——实名被驳回要换业务申请编号重来, |
| | | * 而商户端下次还得从头把资料填一遍。 |
| | | */ |
| | | @Test |
| | | void missingStoreMaterialIsReportedBeforeAnyProviderCall() { |
| | | long applicationId = individualFixture(); |
| | | jdbc.update("update merchant_onboarding_application set store_indoor_url=null where id=?", applicationId); |
| | | |
| | | assertThatThrownBy(() -> service.submit(applicationId, false)) |
| | | .isInstanceOf(BusinessException.class) |
| | | .hasMessageContaining("店内环境照"); |
| | | verifyNoInteractions(realnameGateway); |
| | | verifyNoInteractions(pictureUploadGateway); |
| | | } |
| | | |
| | | /** |
| | | * 授权是否完成只看 {@code authorize_stat}:{@code PASSED} 单独不算完成,而 {@code FREEZED} |
| | | * (「该主体已完成过入驻」)也可能已经授权成功——真实样本就是 FREEZED + authorize_stat=1, |
| | | * 按申请单状态判会把它显示成待办理。 |
| | |
| | | when(progressGateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat",applymentStat,"authorize_stat",authorizeStat)); |
| | | Map<String,Object> view = progress.refresh(applicationId, false); |
| | | return stageStatus(view,"realname"); |
| | | } |
| | | |
| | | /** |
| | | * 授权二维码要能直接当图片用:通道回的是**裸 base64**,且是 JPEG 还是 PNG 并不固定 |
| | | * ——实测小微实名的码就是 JPEG({@code /9j/4AAQSkZJRg…})。原先这里写死了 PNG 的 magic |
| | | * 并统一标成 {@code image/png},于是 JPEG 的码被判非法、返回空串,两端页面都是空白, |
| | | * 而"本人扫码授权"是完成实名的唯一途径(2026-09-16 实测:申请单已受理、通道已回码,页面却空)。 |
| | | */ |
| | | @Test |
| | | void theAuthorizationQrCodeIsExposedAsADataUrlWhicheverFormatTheChannelReturns() { |
| | | assertThat(qrCodeOf("iVBORw0KGgoAAAANSUhEUg==")).startsWith("data:image/png;base64,"); |
| | | assertThat(qrCodeOf("/9j/4AAQSkZJRgABAQAAAQABAAD")).startsWith("data:image/jpeg;base64,"); |
| | | // 已经带前缀的也认,不重复叠加。 |
| | | assertThat(qrCodeOf("data:image/jpeg;base64,/9j/4AAQSkZJRgAB")).isEqualTo("data:image/jpeg;base64,/9j/4AAQSkZJRgAB"); |
| | | // 不是图片的 base64 一律不拼进 img src。 |
| | | assertThat(qrCodeOf("QUJDREVG")).isEmpty(); |
| | | assertThat(qrCodeOf("")).isEmpty(); |
| | | } |
| | | |
| | | /** 走一遍刷新,取 realname 阶段实际暴露给前端的 qrCode。 */ |
| | | @SuppressWarnings("unchecked") |
| | | private String qrCodeOf(String qrcodeData) { |
| | | long applicationId = fixture("13900000000"); |
| | | 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.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_WAITTING_FOR_CONFIRM_CONTACT","authorize_stat","0","qrcode_data",qrcodeData)); |
| | | Map<String,Object> view = progressBean.refresh(applicationId,false); |
| | | for (Object item : (java.util.List<Object>) view.get("stages")) { |
| | | Map<String,Object> stage = (Map<String,Object>) item; |
| | | if ("realname".equals(stage.get("key"))) return String.valueOf(stage.get("qrCode")); |
| | | } |
| | | throw new IllegalStateException("realname stage not found"); |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | |
| | | jdbc.update("insert into merchant_onboarding_progress(application_id,stages_json) values (?,?)", applicationId, stagesJson == null ? "{}" : stagesJson); |
| | | return applicationId; |
| | | } |
| | | |
| | | /** |
| | | * 建一张个人(小微)主体的进件申请:门店照片、身份证照片、经营市编码都已落库, |
| | | * 商家档案上也有门店名称与地址,进度里微信支付已确认(实名的前置)。 |
| | | */ |
| | | private long individualFixture() { |
| | | String key = "RNI-" + System.nanoTime(); |
| | | jdbc.update("insert into merchant(merchant_name,status,deleted) values (?,1,0)", key); |
| | | Long merchant = jdbc.queryForObject("select id from merchant where merchant_name=?", Long.class, key); |
| | | jdbc.update("insert into merchant_store(merchant_id,store_name,address,status,deleted) values (?,?,?,1,0)", merchant, "张维的餐饮店", "成都市成华区东丽街138号"); |
| | | Long store = jdbc.queryForObject("select id from merchant_store where merchant_id=?", Long.class, merchant); |
| | | jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)", merchant, store, key, key); |
| | | Long card = jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?", Long.class, key); |
| | | jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,subject_id_no_masked,subject_id_no_encrypted,registered_city_code,registered_address,legal_mobile,contact_name,contact_mobile,store_header_url,store_indoor_url,legal_id_front_url,legal_id_back_url,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?,?,?,'individual',?,?,?,?,?,?,?,?,?,?,?,?,?,'pending','6666000241335656','original-apply',?)", |
| | | merchant, store, key, "张维", "513********4667", crypto.encrypt("513021198109304667"), "510100", "成都市成华区东丽街138号9栋2单元16楼5号", |
| | | "13900000000", "张维", "13700000000", "/uploads/store-header.jpg", "/uploads/store-indoor.jpg", |
| | | "/uploads/id-front.jpg", "/uploads/id-back.jpg", key, card); |
| | | Long applicationId = jdbc.queryForObject("select id from merchant_onboarding_application where application_no=?", Long.class, key); |
| | | jdbc.update("insert into merchant_onboarding_progress(application_id,stages_json) values (?,?)", applicationId, WECHAT_CONFIRMED); |
| | | return applicationId; |
| | | } |
| | | } |
| | |
| | | // 个人主体属于小微商户,mcc 也是必填;三个线上场景都要带。 |
| | | assertThat(wechat).allSatisfy(entry -> assertThat(entry).containsKeys("mcc", "wx_real_name_info")); |
| | | |
| | | // 层级:micro_biz_type 在 wx_real_name_info 这个嵌套 jsonObject 里,不在元素顶层。 |
| | | // 层级与形态:micro_biz_type 在 wx_real_name_info 这个**嵌套 jsonObject** 里,不在元素顶层, |
| | | // 也不能是 JSON 字符串。官方字段目录里该字段类型是 `Object`(对照 wx_conf_list[] 本身才是 |
| | | // `String` 的 jsonArray字符串)——双重编码发出去通道会回「wx_conf_list格式错误」。 |
| | | assertThat(wechat.get(0)).doesNotContainKey("micro_biz_type"); |
| | | Map<String, Object> realName = JSON.parseObject(String.valueOf(wechat.get(0).get("wx_real_name_info")), Map.class); |
| | | assertThat(wechat.get(0).get("wx_real_name_info")).isInstanceOf(Map.class); |
| | | Map<String, Object> realName = JSON.parseObject(JSON.toJSONString(wechat.get(0).get("wx_real_name_info")), Map.class); |
| | | assertThat(realName).containsEntry("micro_biz_type", "MICRO_TYPE_ONLINE"); |
| | | } |
| | | |
| | |
| | | assertThat(wechat).allSatisfy(entry -> assertThat(entry).doesNotContainKey("wx_real_name_info")); |
| | | } |
| | | |
| | | /** |
| | | * 个人(小微)商户填了 {@code micro_biz_type},就欠这两张门店照片:官方在 micro_biz_type |
| | | * 的说明里写明「需要提供 F22:店铺门头照图片;F24:店铺内景图片,补充资料填写在 |
| | | * extended_material_list 对象中」。原先只有 micro_biz_type、没有材料,开通是缺材料提交。 |
| | | */ |
| | | @Test |
| | | void anIndividualMerchantCarriesTheStorePhotosAsExtendedMaterial() { |
| | | Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest( |
| | | properties(), "BO-TEST-MATERIAL", "6666000241390821", "阳野", "5812", true, true, "file-f22", "file-f24"); |
| | | |
| | | List<Map<String, Object>> materials = |
| | | JSON.parseObject(String.valueOf(request.get("extended_material_list")), List.class); |
| | | assertThat(materials).hasSize(2); |
| | | assertThat(materials.get(0)).containsEntry("file_type", "F22").containsEntry("file_id", "file-f22"); |
| | | assertThat(materials.get(1)).containsEntry("file_type", "F24").containsEntry("file_id", "file-f24"); |
| | | } |
| | | |
| | | /** 门店照片缺一张就只发一张:宁可让通道明确报缺哪一项,也不要塞占位图顶上。 */ |
| | | @Test |
| | | void aMissingStorePhotoIsNotReplacedByAPlaceholder() { |
| | | Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest( |
| | | properties(), "BO-TEST-MATERIAL-1", "6666000241390821", "阳野", "5812", true, true, "file-f22", ""); |
| | | |
| | | List<Map<String, Object>> materials = |
| | | JSON.parseObject(String.valueOf(request.get("extended_material_list")), List.class); |
| | | assertThat(materials).hasSize(1); |
| | | assertThat(materials.get(0)).containsEntry("file_type", "F22"); |
| | | } |
| | | |
| | | /** 企业主体没有这项材料要求,不要凭空给企业商户加附件。 */ |
| | | @Test |
| | | void anEnterpriseMerchantCarriesNoExtendedMaterial() { |
| | | Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest( |
| | | properties(), "BO-TEST-MATERIAL-2", "6666000241390821", "成都喜望科技", "5812", false, false, "file-f22", "file-f24"); |
| | | |
| | | assertThat(request).doesNotContainKey("extended_material_list"); |
| | | } |
| | | |
| | | /** |
| | | * 个体工商户走企业进件接口,所以不是「个人主体」,但官方把「个体工商户、小微商户」并列为 |
| | | * {@code mcc} 必填的一类。配了 {@code fee_rule_id} 也不能把 mcc 省掉——官方请求示例里 |
| | | * {@code mcc} 与 {@code fee_rule_id} 本来就出现在同一个元素里。 |
| | | * |
| | | * <p>原先这条判断写在 {@code fee_rule_id} 的 else 分支里,平台配了规则号 756 之后 |
| | | * 小微/个体工商户的 mcc 永远发不出去,业务开通被通道回 {@code wx_conf_list格式错误}。 |
| | | * |
| | | * <p>费率规则号也要换成该主体类型的那一个:同表「线上、类型 01」下个体工商户是 761。 |
| | | */ |
| | | @Test |
| | | void anIndividualBusinessCarriesTheMccAndItsOwnFeeRule() { |
| | | HuifuPaymentProperties properties = properties(); |
| | | properties.setMerchantWechatFeeRuleId("756"); |
| | | |
| | | List<Map<String, Object>> wechat = wechatConfig(HuifuMerchantBusinessOpenGateway.buildRequestForTest( |
| | | properties, "BO-TEST-INDV", "6666000241390821", "阳野餐饮店", "5812", false, true, "", "")); |
| | | |
| | | assertThat(wechat).hasSize(3); |
| | | assertThat(wechat).allSatisfy(entry -> { |
| | | assertThat(entry).containsEntry("fee_rule_id", "761"); |
| | | assertThat(entry).containsEntry("mcc", "5812"); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 个人(小微)主体的费率规则号必须换成小微那一行。官方的《微信费率规则ID》对照表按 |
| | | * 「支付场景 × 业务开通类型 × 主体类型」定位 CID:线上(场景 4/5、类型 01)下企业是 756、 |
| | | * 小微是 775。拿企业的 756 发给小微,通道回的是「微信费率规则号填写不正确,请检查!」—— |
| | | * 2026-09-16 真实个人商户实测(该次报文的格式已经正确,纯粹是号选错)。 |
| | | */ |
| | | @Test |
| | | void aMicroMerchantUsesTheMicroFeeRuleNotTheEnterpriseOne() { |
| | | HuifuPaymentProperties properties = properties(); |
| | | properties.setMerchantWechatFeeRuleId("756"); |
| | | |
| | | List<Map<String, Object>> wechat = wechatConfig(HuifuMerchantBusinessOpenGateway.buildRequestForTest( |
| | | properties, "BO-TEST-MICRO", "6666000241804122", "阳野", "5812", true)); |
| | | |
| | | assertThat(wechat).allSatisfy(entry -> |
| | | assertThat(entry).containsEntry("fee_rule_id", "775").containsEntry("mcc", "5812")); |
| | | } |
| | | |
| | | @Test |
| | | void anEnterpriseCanUseAProviderConfirmedFeeRuleInsteadOfMcc() { |
| | | HuifuPaymentProperties properties = properties(); |
| New file |
| | |
| | | package com.talentcard.server.business.payment.huifu; |
| | | |
| | | import static org.assertj.core.api.Assertions.assertThat; |
| | | |
| | | import java.util.Map; |
| | | |
| | | import com.alibaba.fastjson.JSON; |
| | | import org.junit.jupiter.api.Test; |
| | | |
| | | /** |
| | | * 个人(小微)商户进件请求里几条容易漏、且漏了就一定被通道驳回的字段。 |
| | | * |
| | | * <p>三条规则都是与业务确认过的口径,不是猜的: |
| | | * |
| | | * <ul> |
| | | * <li>{@code store_header_pic}(F22)是**必填**:三种小微经营类型都要传,只是图片内容要求不同—— |
| | | * 线上商品/服务交易传店铺首页截图、流动经营/便民服务传经营现场照、门店场所传实体门头照。 |
| | | * 不能因为"线上没有实体店"就整项不传。</li> |
| | | * <li>证件「长期有效」({@code *_validity_type=1})时**不填**截止日期,非长期必填。</li> |
| | | * <li>{@code settle_config} 的 {@code fixed_ratio} / {@code constant_amt} 在 D1 下是条件必填, |
| | | * 平台不收这笔钱,填 0.00;完全不传会被判缺参数。</li> |
| | | * </ul> |
| | | */ |
| | | class HuifuIndividualOnboardingRequiredFieldsTests { |
| | | |
| | | @Test |
| | | void theStorePhotoIsAlwaysCarriedEvenForOnlineOnlyMicroMerchants() { |
| | | Map<String, Object> request = request(command("0", "20120110", "20320110")); |
| | | |
| | | assertThat(request).containsEntry("store_header_pic", "F22-STORE"); |
| | | assertThat(request).containsEntry("settle_card_front_pic", "F13-CARD"); |
| | | } |
| | | |
| | | @Test |
| | | void aLongTermCertificateOmitsBothDeadlines() { |
| | | Map<String, Object> request = request(command("1", "20120110", "")); |
| | | |
| | | assertThat(request).containsEntry("legal_cert_validity_type", "1"); |
| | | assertThat(request).doesNotContainKey("legal_cert_end_date"); |
| | | assertThat(cardInfo(request)).doesNotContainKey("cert_end_date"); |
| | | } |
| | | |
| | | @Test |
| | | void aFixedTermCertificateCarriesBothDeadlines() { |
| | | Map<String, Object> request = request(command("0", "20120110", "20320110")); |
| | | |
| | | assertThat(request).containsEntry("legal_cert_end_date", "20320110"); |
| | | assertThat(cardInfo(request)).containsEntry("cert_validity_type", "0").containsEntry("cert_end_date", "20320110"); |
| | | } |
| | | |
| | | /** 结算周期是 D1,节假日结算手续费两项必须下发;平台不收,填 0.00。 */ |
| | | @Test |
| | | void theSettlementConfigCarriesTheD1HolidayFeeFields() { |
| | | Map<String, Object> settle = JSON.parseObject(String.valueOf(request(command("0", "20120110", "20320110")).get("settle_config")), Map.class); |
| | | |
| | | assertThat(settle) |
| | | .containsEntry("settle_cycle", "D1") |
| | | .containsEntry("fixed_ratio", "0.00") |
| | | .containsEntry("constant_amt", "0.00") |
| | | .containsEntry("min_amt", "0.00") |
| | | .containsEntry("remained_amt", "0.00"); |
| | | } |
| | | |
| | | /** 企业主体同一份 settle_config(个人进件复用它,见 HuifuIndividualOnboardingGateway)。 */ |
| | | @Test |
| | | void theEnterpriseSettlementConfigCarriesTheSameHolidayFeeFields() { |
| | | Map<String, Object> settle = HuifuEnterpriseOnboardingGateway.settleConfig(); |
| | | |
| | | assertThat(settle).containsEntry("fixed_ratio", "0.00").containsEntry("constant_amt", "0.00"); |
| | | } |
| | | |
| | | private static HuifuPaymentProperties properties() { |
| | | HuifuPaymentProperties properties = new HuifuPaymentProperties(); |
| | | properties.setEnabled(true); |
| | | properties.setSystemId("6666000220591054"); |
| | | properties.setProductId("KAZX"); |
| | | properties.setPrivateKey("x"); |
| | | properties.setPlatformPublicKey("y"); |
| | | properties.setMerchantWechatFeeRate("0.70"); |
| | | properties.setMerchantOnlineBusinessType("H5311L"); |
| | | properties.setMerchantAgreementUrl("https://example.com/agreement.htm"); |
| | | properties.setOnboardingUpperHuifuId("6666000220591054"); |
| | | return properties; |
| | | } |
| | | |
| | | private static Map<String, Object> request(HuifuIndividualOnboardingCommand command) { |
| | | return HuifuIndividualOnboardingGateway.buildRequestForTest(properties(), command); |
| | | } |
| | | |
| | | @SuppressWarnings("unchecked") |
| | | private static Map<String, Object> cardInfo(Map<String, Object> request) { |
| | | return JSON.parseObject(String.valueOf(request.get("card_info")), Map.class); |
| | | } |
| | | |
| | | private static HuifuIndividualOnboardingCommand command(String validityType, String beginDate, String endDate) { |
| | | return new HuifuIndividualOnboardingCommand( |
| | | "MO-TEST-1", "阳野", "511602199803310019", validityType, beginDate, endDate, "15708261096", |
| | | "四川省广安市广安区兴安中街227号3栋1单元6楼2号", "1347747024@qq.com", "5812", |
| | | "510000", "510100", "510104", "F40-ID", "F41-ID", |
| | | "6217003680002760924", "510000", "511600", "01050000", "F13-CARD", "F22-STORE"); |
| | | } |
| | | } |
| | |
| | | /** 法人姓名与身份证号按大写提交;手机号必须原样是法人本人的。 */ |
| | | @Test |
| | | void theLegalIdNumberIsNormalisedToUpperCase() { |
| | | Map<String,Object> request = request(new HuifuMerchantRealnameCommand("张维","13900000000","513021198109304667x","成都市",true)); |
| | | Map<String,Object> request = request(new HuifuMerchantRealnameCommand("张维","13900000000","513021198109304667x","成都市",true,null)); |
| | | assertThat(request).containsEntry("id_card_number","513021198109304667X"); |
| | | } |
| | | |
| | |
| | | assertThat(rejected.applymentId()).isEmpty(); |
| | | } |
| | | |
| | | /** |
| | | * 个人(小微)主体的实名另有一组门店字段,字段名与业务开通那套**不一样**: |
| | | * 实名用 {@code store_header_copy}/{@code store_indoor_copy}(不是业务开通的 |
| | | * {@code store_header_pic}/{@code store_indoor_pic}),身份证用的是 |
| | | * {@code identification_front_copy}/{@code identification_back_copy}。 |
| | | */ |
| | | @Test |
| | | void anIndividualRealnameApplicationCarriesTheStoreMaterial() { |
| | | Map<String,Object> request = request(individualCommand()); |
| | | |
| | | assertThat(request).containsEntry("micro_biz_type","MICRO_TYPE_STORE"); |
| | | assertThat(request).containsEntry("store_name","张维的餐饮店"); |
| | | assertThat(request).containsEntry("store_header_copy","file-f22"); |
| | | assertThat(request).containsEntry("store_indoor_copy","file-f24"); |
| | | assertThat(request).containsEntry("store_address_code","510100"); |
| | | assertThat(request).containsEntry("store_address","成都市成华区东丽街138号"); |
| | | assertThat(request).containsEntry("identification_front_copy","file-f40"); |
| | | assertThat(request).containsEntry("identification_back_copy","file-f41"); |
| | | // 文档写明 identification_address「主体类型为企业时,需要填写。其他主体类型,无需上传」。 |
| | | assertThat(request).doesNotContainKey("identification_address"); |
| | | // 企业才要 owner(是否为受益人);小微不填,避免把不在受益人名单上的经营者报成受益人。 |
| | | assertThat(request).containsEntry("owner","N"); |
| | | } |
| | | |
| | | /** 企业主体一个门店字段都不该出现——企业实名走营业执照与法人资料,不要塞小微材料。 */ |
| | | @Test |
| | | void anEnterpriseRealnameApplicationCarriesNoStoreMaterial() { |
| | | Map<String,Object> request = request(command("成都市成华区东丽街138号",true)); |
| | | |
| | | assertThat(request).doesNotContainKeys("micro_biz_type","store_name","store_header_copy", |
| | | "store_indoor_copy","store_address_code","store_address", |
| | | "identification_front_copy","identification_back_copy"); |
| | | } |
| | | |
| | | private static HuifuMerchantRealnameCommand individualCommand() { |
| | | return new HuifuMerchantRealnameCommand("张维","13900000000","513021198109304667","",false, |
| | | new HuifuMerchantRealnameCommand.MicroStore("MICRO_TYPE_STORE","张维的餐饮店","file-f22","file-f24", |
| | | "510100","成都市成华区东丽街138号","file-f40","file-f41")); |
| | | } |
| | | |
| | | private Map<String,Object> request(HuifuMerchantRealnameCommand command) { |
| | | HuifuPaymentProperties properties = new HuifuPaymentProperties(); |
| | | properties.setEnabled(true); |
| | |
| | | } |
| | | |
| | | private static HuifuMerchantRealnameCommand command(String legalAddress, boolean legalIsBeneficiary) { |
| | | return new HuifuMerchantRealnameCommand("张维","13900000000","513021198109304667",legalAddress,legalIsBeneficiary); |
| | | return new HuifuMerchantRealnameCommand("张维","13900000000","513021198109304667",legalAddress,legalIsBeneficiary,null); |
| | | } |
| | | } |
| | |
| | | "MO-TEST-1", "阳野", "511602199803310019", "0", "20120110", "20320110", "15708261096", |
| | | "四川省广安市广安区兴安中街227号3栋1单元6楼2号", "1347747024@qq.com", "5812", |
| | | "510000", "510100", "510104", "F40-ID", "F41-ID", |
| | | "6217003680002760924", "510000", "511600", "01050000", "F13-CARD"); |
| | | "6217003680002760924", "510000", "511600", "01050000", "F13-CARD", "F22-STORE"); |
| | | } |
| | | |
| | | private static HuifuEnterpriseOnboardingCommand enterpriseCommand() { |