From 3dff4451ffe12e87d16792cdd8f744e1e29aad9c Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 24 九月 2026 14:24:56 +0800
Subject: [PATCH] feat(huifu): 微信支付口径统一切到线下场景与 0.30% 费率
---
docs/huifu/02-进件与收款能力.md | 22
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java | 19
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue | 14
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java | 2
.scratch/payment-409-probe/ChannelQuery.java | 35 ++
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java | 20
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenRequiredFieldsTests.java | 56 ++
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameGateway.java | 9
.scratch/offline-wechat-scene-switch/issues/04-refund-from-basic-account-needs-manual-exit.md | 33 +
.scratch/payment-409-probe/PaymentProbe.java | 83 ++++
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingMerchantLevelTests.java | 3
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantWechatConfigGateway.java | 4
.scratch/payment-409-probe/Payment409Probe.java | 85 +++++
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java | 77 +++
.scratch/offline-wechat-scene-switch/spec.md | 34 ++
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingCommand.java | 6
docs/huifu/06-踩坑清单.md | 2
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java | 10
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java | 28
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java | 3
talent-card-server/src/main/resources/db/migration/V155__merchant_business_address.sql | 26 +
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingGateway.java | 6
docs/adr/0005-wechat-pay-scene-switch-to-offline.md | 45 ++
.scratch/offline-wechat-scene-switch/issues/01-bank-message-not-read.md | 43 ++
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingGateway.java | 3
.scratch/payment-409-probe/OrderExpiryProbe.java | 64 +++
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java | 30
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingRequiredFieldsTests.java | 17
CONTEXT.md | 30 +
docs/huifu/08-上线与环境.md | 41 ++
talent-card-admin/src/views/MerchantOnboardingDetailView.vue | 3
talent-card-server/src/main/resources/application.yml | 39 +
.scratch/offline-wechat-scene-switch/issues/02-wechat-capability-value-object.md | 30 +
.scratch/payment-409-probe/TestSchemaProbe.java | 38 ++
docs/agents/testing.md | 10
.scratch/offline-wechat-scene-switch/issues/03-enterprise-offline-store-materials-unknown.md | 32 +
docs/huifu/05-汇付对接注意事项.md | 6
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameTests.java | 4
38 files changed, 892 insertions(+), 120 deletions(-)
diff --git a/.scratch/offline-wechat-scene-switch/issues/01-bank-message-not-read.md b/.scratch/offline-wechat-scene-switch/issues/01-bank-message-not-read.md
new file mode 100644
index 0000000..f9ddfd6
--- /dev/null
+++ b/.scratch/offline-wechat-scene-switch/issues/01-bank-message-not-read.md
@@ -0,0 +1,43 @@
+# 微信侧拒绝支付的真因被丢弃:回执里的 `bank_message` 从不读
+
+Status: ready-for-agent
+
+## 现象
+
+商户的微信支付下单失败时,用户看到的、以及落到 `payment_transaction.provider_response_message` 里的,
+只有一句没有信息量的 **「业务执行失败[失败]」**。2026-09-24 排查一笔真实失败时,真因其实就在同一条回执里:
+
+```
+resp_code: 90000000
+resp_desc: 业务执行失败[失败]
+bank_message: sub_mch_id与sub_appid不匹配 ← 真正的原因,我们没读
+```
+
+运营与开发因此只能靠猜,或者反复发测试请求去试。
+
+## 根因
+
+`HuifuPaymentService.responseText()` 的取值优先级是 `resp_desc` → `message` → `sub_resp_desc`,
+**从不读 `bank_message`**。微信侧的拒绝原因由微信返回、汇付放在 `bank_message` 里,`resp_desc` 只剩
+「业务执行失败[<微信的短描述>]」,短描述还常常就是「失败」。
+
+同类字段还有 `org_trans_stat`(关单路径用到了)等通道补充字段;目前只有这一个被证明会承载唯一真因。
+
+## 证据
+
+- 2026-09-24 只读下单核验(`.scratch/payment-409-probe/`,未支付、自动过期):同一形状的请求,
+ 回执含 `bank_message=sub_mch_id与sub_appid不匹配`,而 `resp_desc` 只有「业务执行失败[失败]」。
+- 库里 2026-09-24 13:25 那两次的原始消息已被关单回执覆盖(另见下条),所以现场只能从 `bank_message` 拿。
+
+## 建议改法
+
+1. `responseText()` 在 `resp_desc` 之外**优先合并 `bank_message`**(形如 `业务执行失败[失败](sub_mch_id与sub_appid不匹配)`),
+ 或直接把 `bank_message` 写进 `provider_response_message`;改动点在 `HuifuPaymentService.responseText` 与
+ `HuifuMiniprogramPaymentGateway` 的日志(日志已经打了全量回执,只是没人看)。
+2. 同时核对退款/关单/查单三条路径的回执里是否也有同类「只在这里出现的真因字段」,一并纳入。
+3. 落一条测试:回执带 `bank_message` 时,抛出的业务异常文案里必须包含它。
+
+## 关联
+
+- 关单回执覆盖创建失败回执(`PaymentReconciliationService.markCancelledTransaction`),
+ 使事后无法从库里复盘真因——是这次排查的第二个障碍,可作为同一条 issue 的第二项一并处理。
diff --git a/.scratch/offline-wechat-scene-switch/issues/02-wechat-capability-value-object.md b/.scratch/offline-wechat-scene-switch/issues/02-wechat-capability-value-object.md
new file mode 100644
index 0000000..4888c78
--- /dev/null
+++ b/.scratch/offline-wechat-scene-switch/issues/02-wechat-capability-value-object.md
@@ -0,0 +1,30 @@
+# 微信口径的五个值散在 6 个文件里:收成一个值对象,顺手去掉重复的 AppID 扫描
+
+Status: ready-for-agent
+
+## 现象
+
+「微信支付口径」这一套值(支付场景、业务开通类型、费率、三个费率规则号、进件场景、小微经营类型)
+现在横跨 **6 个 Java 文件 + application.yml**:下发侧(业务开通网关、微信配置网关、实名网关、两个进件网关)
+与判定侧(阶段进度服务、`MerchantSettlementService` 的两个 `*Effective`)各自读 `HuifuPaymentProperties`
+的散字段。2026-09-24 由线上切线下时,正是**一处一处改**才完成;评审明确指出这是 Shotgun Surgery,
+且任何一个点漏改都会以「通道开通成功、本地阶段永远 missing → 收款闸门挡住商户收款」的形式暴露。
+
+另有一处重复:`atu_config_bo_list → AppID 绑定` 的扫描逻辑在
+`MerchantSettlementService.wechatConfigEffective` 与 `MerchantOnboardingProgressService` 各写了一遍
+(本轮两边又同时改成读 `fee_type`)。两处判定口径一旦漂移,会出现「配置闸门放行、阶段却 missing」。
+
+## 建议改法
+
+1. 在 `HuifuPaymentProperties` 之上引入一个 `WechatCapability` 值对象(或一个 `WechatSceneProfile`),
+ 内部持有 `scenes / feeType / feeRate / cid(企业,个体户,小微) / onboardingSceneType / microBizType`,
+ 并提供 `cidFor(subjectType, mccRequired)` 这类按主体类型取号的方法——**下发与判定都只依赖它**。
+2. AppID 绑定扫描抽成一个共用方法(例如 `WechatConfigProbe.appIdBound(config, feeType, appId)`),
+ 两处判定改为调用它。
+3. 加一条测试:用同一个 `WechatCapability` 同时构造「业务开通报文」与「阶段判定所需的通道回执」,
+ 断言二者取值一致(这是把「口径必须一起改」变成编译器/测试约束,而不是靠文档提醒)。
+
+## 为什么值得做
+
+这不是洁癖:口径横跨下发与判定两侧,而判定的下游是**收款闸门**。今天靠文档(`05` 第 19b 条、`02` 口径段)
+兜住的约束,应该由类型和测试来兜。
diff --git a/.scratch/offline-wechat-scene-switch/issues/03-enterprise-offline-store-materials-unknown.md b/.scratch/offline-wechat-scene-switch/issues/03-enterprise-offline-store-materials-unknown.md
new file mode 100644
index 0000000..3d6297f
--- /dev/null
+++ b/.scratch/offline-wechat-scene-switch/issues/03-enterprise-offline-store-materials-unknown.md
@@ -0,0 +1,32 @@
+# 线下报备下企业主体是否也要门头照/内景照(需汇付确认)
+
+Status: needs-info
+
+## 现象
+
+2026-09-24 把进件场景切成 `scene_type=OFFLINE` 之后,**个人(小微)主体**的门店材料要求是清楚的
+(F22 门头照 + F24 内景照,语义从「店铺首页截图」改为「实体门头照」),但**企业(含个体工商户)主体**
+要不要同样交这两张图,目前**没有任何依据**:
+
+- 线上口径下企业从来不需要(代码里 `individualMerchant` 为真才下发 `extended_material_list`,
+ 商户端也只有 `merchantType == 'individual'` 才要求上传),这一条是既有实现,不是这次新加的。
+- 线上官方口径只写了「企业使用 fee_rule_id 代替 mcc」,没有提经营场所材料;
+ 线下场景按「有实体经营场所」报备,**很可能会索要门店材料**,但仓库里没有官方原文可依。
+
+## 影响
+
+如果通道对线下企业主体索要 F22/F24,那么企业商户的进件会被驳,而我们的进件表单根本没采集这两张图 →
+商户必须重新提交一遍(这正是「提交时校验比通道驳回后再补」要避免的那种返工)。
+
+## 需要什么
+
+向汇付确认(书面最好):**线下场景(1/2/3)下企业主体进件需要哪些经营场所材料**?
+- 若需要 F22/F24:企业路径要补采集(后端校验 + 商户端表单 + 落库 + 进件请求下发),
+ 与个人路径对齐;`extended_material_list` 的下发条件从「仅个人」改为「个人 + 企业」。
+- 若不需要:在 `05` 里记一条「线下企业不需门店材料(依据:汇付 ×× 回复)」,免得下一个人再问一遍。
+
+## 关联
+
+- 代码位置:`HuifuMerchantBusinessOpenGateway`(`extended_material_list` 的下发条件)、
+ `MerchantSettlementService`(个人主体的两张图校验)、`merchant-onboarding.uvue`(表单)。
+- 置信度约定见 `docs/huifu/README.md`:拿到回复前,这条只能标【待确认】。
diff --git a/.scratch/offline-wechat-scene-switch/issues/04-refund-from-basic-account-needs-manual-exit.md b/.scratch/offline-wechat-scene-switch/issues/04-refund-from-basic-account-needs-manual-exit.md
new file mode 100644
index 0000000..f163ca4
--- /dev/null
+++ b/.scratch/offline-wechat-scene-switch/issues/04-refund-from-basic-account-needs-manual-exit.md
@@ -0,0 +1,33 @@
+# 走基本户的两条退款路径没有人工出口:失败只抛一句错,运营看不到
+
+Status: needs-triage
+
+## 现象
+
+退款失败时,我们只有两个动作:抛错给前端、把 `order_refund.refund_status` 置成 `3`(失败)。
+**没有台账、没有告警、没有「这单必须有人管」的出口。** 用户看到的是一句「退款失败」,运营手上什么都没有。
+
+## 什么时候会真失败(已论证的两类)
+
+1. **到店消费订单的退款**:这类支付即核销、不走延迟,钱直接进商户**基本户**。退款从基本户出;
+ 若商户已被 D1 自动结算划空,或基本户余额不足 → 通道明确拒绝(余额不足/已结算到银行卡)。
+2. **撤销核销**:本来就是从基本户把钱捞回延迟户,钱已结算到卡就必然失败(`PaymentFundConfirmationService`
+ 的 `REVERT_REJECTED` 已按此处理,退回可重试的 `confirmed`;但退款那条路径没有对应处理)。
+
+延迟入账下未核销订单的退款不受影响:钱在延迟户,通道会把已扣手续费同步退还、全额退得平
+(2026-09-24 汇付侧口径 + 我方实测)。
+
+## 建议改法
+
+1. 失败原因入 `payment_reconciliation_issue`(表和类型体系都已有,撤销核销那条路已经在用),
+ 让「退款失败」在后台有一个可筛的列表。
+2. 区分**确定性拒绝**(余额不足/已结算到银行卡 → 重试一万次也没用,必须转人工)与**在途/未定**
+ (`23000003`/`23000004` 已被刻意判为「语义未定」,保持现状)。确定性拒绝不该被无限重试。
+3. 文案对齐撤销核销那条路已有的分级(`PaymentFundConfirmationService.revertRefusalMessage()`):
+ 商户/运营需要知道是「联系商户充值/平台垫付」还是「稍后重试」。
+4. 顺带补上 `bank_message` 的可观测性(见 `01-bank-message-not-read.md`),否则这些失败的原因同样是不可读的。
+
+## 备注
+
+这条与「让商户充值」的关系:充值只应作为**这两条基本户路径的例外兜底**,不能变成常规退款的前置条件
+(退款由用户随时发起,不可能要求商户实时垫款)。
diff --git a/.scratch/offline-wechat-scene-switch/spec.md b/.scratch/offline-wechat-scene-switch/spec.md
new file mode 100644
index 0000000..24fb17f
--- /dev/null
+++ b/.scratch/offline-wechat-scene-switch/spec.md
@@ -0,0 +1,34 @@
+# 微信支付口径切到线下(2026-09-24)
+
+## 目标
+
+平台所有商户的微信支付统一按**线下**报备与开通,并把这一套口径做成可配置(保留一行回退)。
+决策与代价见 [ADR 0005](../../../docs/adr/0005-wechat-pay-scene-switch-to-offline.md)。
+
+## 落地范围
+
+- 下发的 **5 个值**:支付场景 `1/2/3`、业务开通类型 `02`、费率 `0.30%`、
+ 费率规则号 企业 `758`/个体户 `762`/小微 `770`、进件 `scene_type=OFFLINE`(另加小微经营类型 `MICRO_TYPE_STORE`)。
+- 下发侧:业务开通网关(场景配置化、不再下发 `online_busi_type`)、微信配置网关(`fee_type`)、
+ 实名网关与实名查询(`pay_scene`)、两个进件网关(`scene_type`)。
+- 判定侧:阶段进度(微信三场景 + 费率 + 业务开通类型,AppID 判定改为跟随配置)、
+ `MerchantSettlementService` 的两个 `*Effective` 闸门。三者与下发**取同一份配置**。
+- 进件新增:个人主体的**经营详细地址**(迁移 `V155` + 请求字段 + 落库 + 商户端表单 + 后台详情展示),
+ `detail_addr` 不再用证件地址顶替;F22 的含义与文案改为「实体门头照」。
+- 文档:`docs/huifu/02`、`05`、`06`、`08`(含 `08` §9 的存量迁移 runbook 与清单 SQL)。
+
+## 验收
+
+- 已跑:定向 62~66 项全过;全量 410 项只剩既有失败(3 项基线 + 3 项连 H2 的用例,见
+ `docs/agents/testing.md`)。迁移 `V155` 在测试库执行成功。
+- **未做(归用户)**:真实进件与真实支付/退款的通道验收。用户明确要求「不用你来走真实进件流程,
+ 你改好了到时候我自己来走」。
+- 上线时必做:存量商户按 `docs/huifu/08` §9 逐个在控台改口径,并确认人才端 AppID 绑到了那个商户
+ **实际收款**的微信子商户(换场景会重建子商户号)。
+
+## 遗留(见 `issues/`)
+
+- `01` 微信侧拒绝真因(`bank_message`)被丢弃 —— 可观测性缺陷,本次排查的第二个障碍。
+- `02` 口径的五个值散在 6 个文件里,收成一个值对象 + 去掉重复的 AppID 扫描。
+- `03` 线下报备下**企业**主体要不要门头照/内景照(需汇付确认)。
+- `04` 走基本户的两条退款路径没有人工出口(到店消费退款、撤销核销余额不足)。
diff --git a/.scratch/payment-409-probe/ChannelQuery.java b/.scratch/payment-409-probe/ChannelQuery.java
new file mode 100644
index 0000000..4be7414
--- /dev/null
+++ b/.scratch/payment-409-probe/ChannelQuery.java
@@ -0,0 +1,35 @@
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import com.alibaba.fastjson.JSON;
+import com.huifu.bspay.sdk.opps.client.BasePayClient;
+import com.huifu.bspay.sdk.opps.core.BasePay;
+import com.huifu.bspay.sdk.opps.core.config.MerConfig;
+import org.yaml.snakeyaml.Yaml;
+
+/** 只读查询探针:把通道回执原样打出来(库里的回执会被关单覆盖,原文只能这样拿)。 */
+public class ChannelQuery {
+ public static void main(String[] args) throws Exception {
+ Map root = new Yaml().load(Files.readString(Path.of("talent-card-server/src/main/resources/application.yml")));
+ Map config = find(root);
+ BasePay.debug = false;
+ BasePay.prodMode = BasePay.MODE_PROD;
+ MerConfig c = new MerConfig();
+ c.setSysId("" + config.get("system-id"));
+ c.setProductId("" + config.get("product-id"));
+ c.setRsaPrivateKey("" + config.get("private-key"));
+ c.setRsaPublicKey("" + config.get("platform-public-key"));
+ BasePay.initWithMerConfig(c);
+ if (!java.util.Set.of("v2.merchant.basicdata.query", "v2.merchant.busi.config.query",
+ "v2.merchant.busi.realname.query", "v2.merchant.basicdata.status.query").contains(args[0]))
+ throw new IllegalArgumentException("Read only query required");
+ Map<String, Object> request = JSON.parseObject(args[1], Map.class);
+ System.out.println(JSON.toJSONString(BasePayClient.request(request, args[0], false)));
+ }
+
+ static Map find(Map root) {
+ if (root.containsKey("system-id") && root.containsKey("private-key")) return root;
+ for (Object v : root.values()) if (v instanceof Map m) { Map r = find(m); if (r != null) return r; }
+ return null;
+ }
+}
diff --git a/.scratch/payment-409-probe/OrderExpiryProbe.java b/.scratch/payment-409-probe/OrderExpiryProbe.java
new file mode 100644
index 0000000..eb0f406
--- /dev/null
+++ b/.scratch/payment-409-probe/OrderExpiryProbe.java
@@ -0,0 +1,64 @@
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+/** 只读探针二:看订单的过期时间与商户微信阶段快照,判断 90000000 那笔是不是「过期时间快到了」。 */
+public class OrderExpiryProbe {
+
+ public static void main(String[] args) throws Exception {
+ Path out = Path.of(".scratch/payment-409-probe/out2.txt");
+ StringBuilder text = new StringBuilder();
+ String url = "jdbc:mysql://127.0.0.1:8134/talent_card2?useUnicode=true&characterEncoding=utf8"
+ + "&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false";
+ try (Connection connection = DriverManager.getConnection(url, "root", "123456")) {
+ dump(text, connection, "涉事订单",
+ "select order_no, merchant_id, store_id, order_type, pay_amount, order_status, verify_status, "
+ + "created_at, expires_at, collect_huifu_id from benefit_order "
+ + "where order_no in ('P202609241252066893','P202609241307109556')");
+ dump(text, connection, "这些支付尝试的时间线(含请求日期与创建时间)",
+ "select payment_no, order_no, amount, status, created_at, closed_at, provider_response_code, provider_response_message "
+ + "from payment_transaction where created_at >= '2026-09-24 12:00:00' order by id");
+ dump(text, connection, "商户与门店",
+ "select m.id, m.merchant_name, m.merchant_type, m.collect_huifu_id, s.store_name "
+ + "from merchant m left join merchant_store s on s.merchant_id = m.id "
+ + "where m.collect_huifu_id = '6666000241804122' or m.id in "
+ + "(select merchant_id from benefit_order where order_no in ('P202609241252066893','P202609241307109556'))");
+ dump(text, connection, "商户进件阶段快照(微信/延迟/结算)",
+ "select application_id, sync_owner, last_synced_at, sync_error from merchant_onboarding_progress");
+ }
+ System.out.println(text);
+ Files.writeString(out, text.toString(), StandardCharsets.UTF_8);
+ System.out.println("written to " + out.toAbsolutePath());
+ }
+
+ private static void dump(StringBuilder text, Connection connection, String title, String sql) {
+ text.append("\n--- ").append(title).append(" ---\n");
+ try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) {
+ ResultSetMetaData meta = rs.getMetaData();
+ int columns = meta.getColumnCount();
+ int rows = 0;
+ while (rs.next()) {
+ rows++;
+ for (int i = 1; i <= columns; i++) {
+ text.append(meta.getColumnLabel(i)).append('=').append(value(rs.getObject(i))).append(" ");
+ }
+ text.append('\n');
+ }
+ if (rows == 0) text.append("(空)\n");
+ } catch (SQLException exception) {
+ text.append("查询失败:").append(exception.getMessage()).append('\n');
+ }
+ }
+
+ private static String value(Object raw) {
+ if (raw == null) return "NULL";
+ String text = String.valueOf(raw).trim();
+ return text.isEmpty() ? "\"\"" : text;
+ }
+}
diff --git a/.scratch/payment-409-probe/Payment409Probe.java b/.scratch/payment-409-probe/Payment409Probe.java
new file mode 100644
index 0000000..b93c729
--- /dev/null
+++ b/.scratch/payment-409-probe/Payment409Probe.java
@@ -0,0 +1,85 @@
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+/**
+ * 一次性只读探针:定位 2026-09-24 那笔支付创建 409 的通道原始回执。
+ *
+ * <p>只跑 select,不写库。输出同时打到 stdout 与 UTF-8 文件(Git Bash 控制台编码不可靠)。
+ */
+public class Payment409Probe {
+
+ private static final String[] DATABASES = {
+ "talent_card2", "talent_card", "talent_card_verify1", "talent_card_dryrun"
+ };
+
+ public static void main(String[] args) throws Exception {
+ Path out = Path.of(args.length > 0 ? args[0] : ".scratch/payment-409-probe/out.txt");
+ Files.createDirectories(out.getParent());
+ StringBuilder text = new StringBuilder();
+ String base = "jdbc:mysql://127.0.0.1:8134/?useUnicode=true&characterEncoding=utf8"
+ + "&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false";
+ try (Connection connection = DriverManager.getConnection(base, "root", "123456")) {
+ for (String database : DATABASES) {
+ section(text, "===== " + database + " =====");
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("use `" + database + "`");
+ } catch (SQLException exception) {
+ text.append("数据库不可用:").append(exception.getMessage()).append('\n');
+ continue;
+ }
+ dump(text, connection, "最近的支付流水",
+ "select payment_no, order_no, amount, status, provider_huifu_id, "
+ + "provider_response_code, provider_response_message, created_at, paid_at "
+ + "from payment_transaction order by id desc limit 15");
+ dump(text, connection, "今天创建的支付流水(金额/状态/回执)",
+ "select payment_no, order_no, amount, status, provider_response_code, provider_response_message, created_at "
+ + "from payment_transaction where created_at >= '2026-09-24 00:00:00' order by id desc limit 20");
+ dump(text, connection, "最近的对账问题记录",
+ "select issue_type, business_no, status, expected_value, actual_value, amount, description, created_at "
+ + "from payment_reconciliation_issue order by id desc limit 15");
+ dump(text, connection, "商户微信阶段快照",
+ "select application_id, sync_owner, last_synced_at, sync_error, stages_json "
+ + "from merchant_onboarding_progress order by id desc limit 8");
+ }
+ }
+ System.out.println(text);
+ Files.writeString(out, text.toString(), StandardCharsets.UTF_8);
+ System.out.println("\nwritten to " + out.toAbsolutePath());
+ }
+
+ private static void section(StringBuilder text, String title) {
+ text.append('\n').append(title).append('\n');
+ }
+
+ private static void dump(StringBuilder text, Connection connection, String title, String sql) {
+ text.append("\n--- ").append(title).append(" ---\n");
+ try (Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) {
+ ResultSetMetaData meta = rs.getMetaData();
+ int columns = meta.getColumnCount();
+ int rows = 0;
+ while (rs.next()) {
+ rows++;
+ for (int i = 1; i <= columns; i++) {
+ text.append(meta.getColumnLabel(i)).append('=').append(value(rs.getObject(i))).append(" ");
+ }
+ text.append('\n');
+ }
+ if (rows == 0) text.append("(空)\n");
+ } catch (SQLException exception) {
+ text.append("查询失败:").append(exception.getMessage()).append('\n');
+ }
+ }
+
+ private static String value(Object raw) {
+ if (raw == null) return "NULL";
+ String text = String.valueOf(raw).trim();
+ return text.isEmpty() ? "\"\"" : text;
+ }
+}
diff --git a/.scratch/payment-409-probe/PaymentProbe.java b/.scratch/payment-409-probe/PaymentProbe.java
new file mode 100644
index 0000000..542ad6b
--- /dev/null
+++ b/.scratch/payment-409-probe/PaymentProbe.java
@@ -0,0 +1,83 @@
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import com.alibaba.fastjson.JSON;
+import com.huifu.bspay.sdk.opps.client.BasePayClient;
+import com.huifu.bspay.sdk.opps.core.BasePay;
+import com.huifu.bspay.sdk.opps.core.config.MerConfig;
+import org.yaml.snakeyaml.Yaml;
+
+/**
+ * 只读探针 + 一笔 ¥0.01 的测试下单(不会有人支付,15 分钟后自然过期,不动钱)。
+ *
+ * <p>目的:拿到某商户在**当前通道配置**下小程序支付的真实回执——库里的创建失败回执会被
+ * 关单覆盖,只有直接问通道才看得到原文。
+ *
+ * <p>用法:java ... PaymentProbe openid 取一个该商户用过的真实 openid
+ * java ... PaymentProbe jspay <huifuId> <openid> <reqSeqId>
+ */
+public class PaymentProbe {
+
+ private static final String DB = "jdbc:mysql://127.0.0.1:8134/talent_card2?useUnicode=true&characterEncoding=utf8"
+ + "&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false";
+
+ public static void main(String[] args) throws Exception {
+ if ("openid".equals(args[0])) {
+ Class.forName("com.mysql.cj.jdbc.Driver");
+ try (Connection c = DriverManager.getConnection(DB, "root", "123456");
+ Statement s = c.createStatement();
+ ResultSet rs = s.executeQuery("select t.id, t.openid, o.order_no, o.collect_huifu_id from benefit_order o "
+ + "join talent_user t on t.id = o.talent_id "
+ + "where o.collect_huifu_id = '" + args[1] + "' and t.openid is not null and t.openid <> '' "
+ + "order by o.id desc limit 3")) {
+ while (rs.next()) System.out.println(rs.getString(2) + " | talent=" + rs.getString(1) + " order=" + rs.getString(3));
+ }
+ return;
+ }
+ if (!"jspay".equals(args[0])) throw new IllegalArgumentException("mode?");
+ Map root = new Yaml().load(Files.readString(Path.of("talent-card-server/src/main/resources/application.yml")));
+ Map config = find(root);
+ BasePay.debug = false;
+ BasePay.prodMode = BasePay.MODE_PROD;
+ MerConfig mer = new MerConfig();
+ mer.setSysId("" + config.get("system-id"));
+ mer.setProductId("" + config.get("product-id"));
+ mer.setRsaPrivateKey("" + config.get("private-key"));
+ mer.setRsaPublicKey("" + config.get("platform-public-key"));
+ BasePay.initWithMerConfig(mer);
+
+ Map<String, Object> request = new LinkedHashMap<>();
+ request.put("req_date", DateTimeFormatter.ofPattern("yyyyMMdd").format(LocalDateTime.now(ZoneId.of("Asia/Shanghai"))));
+ request.put("req_seq_id", args[3]);
+ request.put("huifu_id", args[1]);
+ request.put("goods_desc", "probe-test-goods");
+ request.put("trans_amt", "0.01");
+ request.put("trade_type", "T_MINIAPP");
+ Map<String, Object> wxData = new LinkedHashMap<>();
+ wxData.put("sub_openid", args[2]);
+ wxData.put("sub_appid", ("" + config.get("mini-program-app-id")).trim());
+ Map<String, Object> extend = new LinkedHashMap<>();
+ // 官方《聚合正扫》T_MINIAPP 示例把 wx_data / notify_url 放在**顶层**(不在 extend_info 里)。
+ request.put("wx_data", JSON.toJSONString(wxData));
+ request.put("notify_url", ("" + config.get("notify-url")).trim());
+ extend.put("delay_acct_flag", "Y");
+ extend.put("time_expire", DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusMinutes(15)));
+ request.put("extend_info", extend);
+ System.out.println("REQUEST " + JSON.toJSONString(request));
+ System.out.println("RESPONSE " + JSON.toJSONString(BasePayClient.request(request, "v3.trade.payment.jspay", false)));
+ }
+
+ static Map find(Map root) {
+ if (root.containsKey("system-id") && root.containsKey("private-key")) return root;
+ for (Object v : root.values()) if (v instanceof Map m) { Map r = find(m); if (r != null) return r; }
+ return null;
+ }
+}
diff --git a/.scratch/payment-409-probe/TestSchemaProbe.java b/.scratch/payment-409-probe/TestSchemaProbe.java
new file mode 100644
index 0000000..760f191
--- /dev/null
+++ b/.scratch/payment-409-probe/TestSchemaProbe.java
@@ -0,0 +1,38 @@
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+
+/** 只读:看测试库的 merchant 表到底有没有那两个列,以及 Flyway 记到哪一版。 */
+public class TestSchemaProbe {
+ public static void main(String[] args) throws Exception {
+ String url = "jdbc:mysql://127.0.0.1:8134/talent_card_test?useUnicode=true&characterEncoding=utf8"
+ + "&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false";
+ Path out = Path.of(".scratch/payment-409-probe/out3.txt");
+ StringBuilder text = new StringBuilder();
+ Class.forName("com.mysql.cj.jdbc.Driver");
+ try (Connection c = DriverManager.getConnection(url, "root", "123456")) {
+ text.append("--- merchant 上的相关列 ---\n");
+ dump(text, c, "select column_name from information_schema.columns where table_schema=database() and table_name='merchant' and column_name in ('merchant_rights_config','merchant_type_code','business_address')");
+ text.append("--- flyway 版本(最近 8 条)---\n");
+ dump(text, c, "select version, description, success from flyway_schema_history order by installed_rank desc limit 8");
+ text.append("--- merchant_onboarding_application.business_address 是否存在 ---\n");
+ dump(text, c, "select column_name, column_type from information_schema.columns where table_schema=database() and table_name='merchant_onboarding_application' and column_name='business_address'");
+ }
+ System.out.println(text);
+ Files.writeString(out, text.toString(), StandardCharsets.UTF_8);
+ }
+
+ private static void dump(StringBuilder text, Connection c, String sql) {
+ try (Statement s = c.createStatement(); ResultSet rs = s.executeQuery(sql)) {
+ int n = 0;
+ while (rs.next()) { n++; StringBuilder row = new StringBuilder();
+ for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) row.append(rs.getString(i)).append(" ");
+ text.append(row).append('\n'); }
+ if (n == 0) text.append("(空)\n");
+ } catch (Exception e) { text.append("查询失败:").append(e.getMessage()).append('\n'); }
+ }
+}
diff --git a/CONTEXT.md b/CONTEXT.md
index e1e01f1..9bca3f8 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -74,9 +74,9 @@
**商户端**:商户办理进件、扫描人才码创建到店消费订单,以及扫描预购订单核销码完成核销的小程序;付款由人才用户在人才端完成。
**商户进件**:商户提交主体、经营和结算资料,申请成为汇付商户的过程。主体分企业(含个体工商户)
-与个人(小微)两种:企业交营业执照与法人资料,个人交身份证与**店铺照片**(线上经营传店铺首页截图、
-有实体门店传门头照、流动经营传经营现场照)、**店内环境照**——个人主体的微信业务开通与微信实名认证
-都要这两张图。基本资料审核通过后,微信支付仍可能处于开通中或开通失败。
+与个人(小微)两种:企业交营业执照与法人资料,个人交身份证、**经营详细地址**与**店铺照片**
+(线下报备口径下是实体门头照)、**店内环境照**——个人主体的微信业务开通与微信实名认证都要这两张图。
+基本资料审核通过后,微信支付仍可能处于开通中或开通失败。
**渠道商**:汇付里位于我们平台之上、**直属**管辖我们名下商户的那一层主体。平台的每一次通道调用都以它(或它的下级身份)
签名,因此「谁能查到哪个商户」由**签名身份本身的数据权限**决定,与商户在通道里挂在谁下面无关——
@@ -90,3 +90,27 @@
**收款能力**:通道侧对这家子商户当下是否可用的判定事实,即通道商户状态(`merchant_stat`)与取现、结算开关。它不由平台决定,会随汇付控台的操作随时改变——进件通过不代表它一直正常,商户被通道停用后它会变成异常。它**不等于**收款就绪:这里只描述通道侧的事实,不含我们要求的微信支付开通与实名等前提,也不等同于进件阶段。
**收款就绪**:商户可以接受微信付款的状态,是某一时点的判定结果,不是进件的终态。除基本进件通过、结算银行卡可用、延迟入账与自动结算已核实、**收款能力**正常外,还必须已开通微信支付业务并完成微信实名认证与授权;未完成实名前不允许收款。收款能力一旦异常,收款就绪随之回退,所以它的对外可信度以最近核实时间为准。
+
+## 微信支付口径
+
+三个都叫「场景/scene」的东西是**三件不同的事**,混起来会把支付配瘸,因此各给一个名字:
+
+**支付场景(pay_scene)**:这笔微信支付在通道侧按哪一档**支付能力**受理——线下(反扫、公众号、小程序)
+与线上(公众号、小程序、反扫)是两组独立的报备与费率,不是同一件事的两种叫法。平台统一走线下。
+_Avoid_: 场景、支付方式(会与下面两个混)
+
+**业务开通类型(fee_type)**:商户微信业务按线上标准费率还是线下这一档开通。它决定三件事:人才端
+AppID 绑在哪一组配置下、实名与实名查询用哪一档、通道回执里按哪一档匹配。与**支付场景**不是同一个字段。
+_Avoid_: 费率类型、pay_scene(同名字段、不同含义)
+
+**进件场景(scene_type)**:商户的**主体资料**按线上经营还是线下经营场所报备,决定通道索要哪些经营场所
+材料。它与支付场景相互独立:一家商户的进件场景与它开通的支付场景可以不同,也不要求相同。
+_Avoid_: 经营场景、业务场景(都会与上面两个混)
+
+**费率规则号(CID)**:通道按「支付场景 × 业务开通类型 × 主体类型」三维定位的结算规则编号,费率与它一体。
+选错号不是「费率算错」而是直接被拒,所以主体类型不同、场景不同就不能共用一个号。
+_Avoid_: 费率号、规则 ID(听起来像一个可以随便配的数)
+
+**经营详细地址**:商户实际经营场所的地址,线下报备下是必填项。它与**证件地址**(身份证/营业执照上的住址)
+是两回事,不能互相顶替——线上口径下曾经用证件地址顶替它,线下会被通道驳回或留下错误的报备信息。
+_Avoid_: 注册地址、门店地址(前者是执照住址,后者是门店这一维度的地址)
diff --git a/docs/adr/0005-wechat-pay-scene-switch-to-offline.md b/docs/adr/0005-wechat-pay-scene-switch-to-offline.md
new file mode 100644
index 0000000..bada15a
--- /dev/null
+++ b/docs/adr/0005-wechat-pay-scene-switch-to-offline.md
@@ -0,0 +1,45 @@
+# 微信支付口径统一走线下
+
+**状态**:accepted(2026-09-24)
+
+平台所有商户的微信支付统一按**线下**报备与开通:支付场景 `1/2/3`(线下反扫/线下公众号/线下小程序)、
+业务开通类型 `02`、费率规则号 企业 `758`/个体户 `762`/小微 `770`、费率 `0.30%`、进件 `scene_type=OFFLINE`、
+小微经营类型 `MICRO_TYPE_STORE`。线上那一套(`4/5/12` + `01` + `756/761/775` + `0.70` + `ONLINE`)不再使用,
+但**保留可配置回退**:这些值都在 `app.payment.huifu.*` 里,且下发(业务开通/微信配置/实名/进件)与判定
+(阶段进度、配置闸门)**取同一份配置**——只改一处会让通道侧开通成功而本地阶段永远 `missing`,而阶段下游的
+收款闸门会挡住商户收款。
+
+## 为什么
+
+- **费率**:服务商给的线下费率是 `0.30%`,线上是 `0.70%`,差一倍以上,按笔结算的钱差在这里。
+- **业务形态**:本平台的收款是人才在微信小程序里远程付款,但收款方都是有实体门店的本地商户,
+ 商务与通道侧都认为按线下报备更贴实际经营形态。
+- **通道侧实测**:控台把场景改成线下后,通道把我们的下单请求路由到**线下**子商户并真的打到了微信
+ (2026-09-24 只读回执:`atu_sub_mer_id`=线下那个子商户、`party_order_id`=微信侧订单号)。
+ 权限那一关也随之从 `22000004 暂未开通微信线上小程序支付权限` 变成通过。
+
+## 代价与后果
+
+- **口径是一整套**:场景、CID、费率、业务开通类型、进件场景必须一起改(回退亦然)。见
+ [05-汇付对接注意事项](../huifu/05-汇付对接注意事项.md) 第 18/19/19b 条。
+- **存量商户是在控台手工改的**,改完之前它们会被收款闸门挡住;清单 SQL 与迁移步骤见
+ [08-上线与环境](../huifu/08-上线与环境.md) §9。
+- **换场景会重建微信子商户号**,必须重新把人才端 AppID 绑到**新**子商户,否则微信回
+ `sub_mch_id与sub_appid不匹配`——而现象只剩一句没有信息量的「业务执行失败[失败]」,因为真因在回执的
+ `bank_message` 里、代码只读 `resp_desc`(见 `.scratch/offline-wechat-scene-switch/issues/01-*.md`)。
+- **场景错配的合规归因不在本 ADR 范围内**:线上远程交易按线下费率报备是否被微信/汇付事后处置,
+ 由用户与汇付侧确认口径;本 ADR 只记录平台自身的实现决定。
+
+## 考虑过但没选的
+
+- **保留线上(放弃 0.30%)**:费率差一倍以上,商业上不成立。
+- **双轨(按商户选场景)**:通道侧场景与微信子商户、AppID 绑定、费率规则号都是按商户固定的,
+ 双轨要把判定与闸门都改成按商户分支,成本与出错面翻倍;本轮不做。
+- **把手续费改成外扣**以规避「退款只退净额」的担心:实测口径是全额退款时通道会把已扣手续费同步退还
+ (汇付侧说明),退款本身不亏,没必要引入外扣与承担方账户余额问题。
+
+## 与既有 ADR 的关系
+
+不冲突。[ADR 0001](0001-merchant-onboarding-and-wechat-provisioning.md) 定的是进件进度的**触发方式**
+(人工刷新,不加定时轮询)——本轮存量商户迁移同样走「刷新进度」;[ADR 0004](0004-active-query-is-authoritative.md)
+要求「通道状态以主动查询为权威」,本轮把判定改为跟随配置、判定依据仍取自通道回执,符合那条。
diff --git a/docs/agents/testing.md b/docs/agents/testing.md
index 399df6c..681989f 100644
--- a/docs/agents/testing.md
+++ b/docs/agents/testing.md
@@ -96,16 +96,24 @@
## 已知基线失败(不是回归)
-整套测试有 3 项在 `HEAD` 基线上就失败,与本轮改动无关;用 HEAD worktree + 同一测试库复现过:
+整套测试里有 5 项在基线上就失败,与本轮改动无关:前 3 项用 HEAD worktree + 同一测试库复现过,
+后 2 行是连内存 H2 的用例(原因见表内)。
| 测试 | 现象 |
| --- | --- |
| `MerchantWorkServiceTests.filtersOrdersByCategoryStatusAndDateWithoutExposingFullMobile` | `stays.total()` 期望 1 实得 0 |
| `TalentAuthServiceTests.movesWechatBindingWhenSameWechatLogsInWithAnotherMobile` | `insert into talent_session` BadSqlGrammar |
| `MerchantSettlementStatisticsMapperTests.includesHotelFreeOfflineSubsidyWithoutIncreasingTransactionAmount` | 持久化断言失败 |
+| `ManualConsumptionServiceTests` 2 项(`childcareCurrentBenefitUsesTheScannedLevelsRightsDescription`、`cultureCurrentBenefitUsesOnlyTheScannedLevelsStoreConsumptionDescription`) | 连的是内存 **H2**,H2 里没建 `merchant` 表 → `Table "merchant" not found`。与测试库无关,与支付域改动也无关 |
+| `MerchantVerificationFreeStayTests.scanReturnsConfiguredHotelSubsidyAmount` | 同上:H2 里 `select merchant_type_code, merchant_rights_config from merchant` 报表不存在 |
新会话不要为了证明它们"是既有的"而重建 worktree:直接看这张表。修好其中任何一项时,请删掉对应行。
+**最后两行是 2026-09-24 补的**(跑全量时共 6 项失败:3 项基线 + 这 3 项):它们不在运行脚本打印的
+`known baseline failures` 清单里,但全量与单独跑那 2 个类都同样失败。原因是这几个用例自己连内存 H2
+(`jdbc:h2:mem:...`)而不是共享测试库,而 H2 里没有建 `merchant` 表——判断一次改动是否引入回归时,
+先把它们排除;要修就得给这几条 H2 路径补上建表(属权益/手工消费那一域,跨域改动请交给对应负责人)。
+
## 环境相关失败:已修(2026-09-17)
`PaymentFundConfirmationTests.aPendingConfirmationOfAVerifiedOrderIsRetriedWithoutAnotherVerification`
diff --git "a/docs/huifu/02-\350\277\233\344\273\266\344\270\216\346\224\266\346\254\276\350\203\275\345\212\233.md" "b/docs/huifu/02-\350\277\233\344\273\266\344\270\216\346\224\266\346\254\276\350\203\275\345\212\233.md"
index 36a3458..0749284 100644
--- "a/docs/huifu/02-\350\277\233\344\273\266\344\270\216\346\224\266\346\254\276\350\203\275\345\212\233.md"
+++ "b/docs/huifu/02-\350\277\233\344\273\266\344\270\216\346\224\266\346\254\276\350\203\275\345\212\233.md"
@@ -25,6 +25,16 @@
术语表与 `CONTEXT.md` 的表述一致,但它本身是**仓库术语表**、不是官方文档;其中「延迟户不可结算、不可取现」这类**官方定义**目前只是转述(仓库内没有对应官方页面原件,属【官方·外部】)。
**本文没有任何一条结论以汇付官方确认为依据**(2026-09-18 全仓核查:仓库里不存在汇付官方回复的留档):凡我方自测得到的结论一律标【实测】,并注明「自测结论,汇付侧未书面确认」。
+**场景口径(2026-09-24 起)**:微信支付统一走**线下**——支付场景 `1/2/3`、业务开通类型 `02`、
+费率规则号企业 `758`/个体户 `762`/小微 `770`、费率 `0.30%`、进件 `scene_type=OFFLINE`、
+小微经营类型 `MICRO_TYPE_STORE`。这些都取自 `application.yml` 的 `app.payment.huifu.*`,**下发与判定同源**:
+只改一处会让通道侧开通成功而本地阶段永远 `missing`(下游是收款闸门,会挡住商户收款)。成套口径与回退方式
+见 `05` 第 18/19/19b 条,存量商户的迁移步骤见 `08` §9。
+
+进件侧随之多两项要求:个人(小微)主体的**经营详细地址**成为必填(进件请求的 `detail_addr`,落
+`merchant_onboarding_application.business_address`,**与证件地址 `legal_addr` 分开**——以前两者共用同一个值,
+线下报备下会被通道驳回或留下错误信息,迁移 `V155`);F22 店铺照片的含义从「店铺首页截图」变为「实体门头照」。
+
## 1. 进件时序:企业/个体双路径 + 通道回调 + 分阶段开通
```mermaid
@@ -42,9 +52,9 @@
S->>H: v2.merchant.basicdata.indv(先传 F40/F41/F13/F22)
end
H-->>S: huifu_id + apply_no(同步响应即可推进下一步,不等 token_no)
- S->>H: v2.merchant.busi.open(wx_conf_list 场景 4/5/12、delay_flag=Y、forced_delay_flag=N)
+ S->>H: v2.merchant.busi.open(wx_conf_list 场景 1/2/3(线下)、delay_flag=Y、forced_delay_flag=N)
H-->>S: 90000000「审核中」=已受理(不是失败)
- Note over S: 配置的闸门取通道证据:qry_wx_conf_list 里真的出现线上场景才发下一条
+ Note over S: 配置的闸门取通道证据:qry_wx_conf_list 里真的出现配置的场景才发下一条
S->>H: v2.merchant.busi.config(绑定人才端小程序 AppID)
H-->>S: 00000000 或驳回
H->>S: 回调 merchant-onboarding-notify / merchant-business-open-notify / merchant-wechat-config-notify
@@ -92,8 +102,8 @@
| `key`(`LABELS`) | 展示名 | 「已确认」的通道证据 |
| --- | --- | --- |
| `basic` | 基础审核 | `apply_status=Y` 且 `reg_status=S` |
-| `wechat` | 微信支付 | `qry_wx_conf_list` 里 4/5/12 三场景齐全,且每场景 `fee_rate=0.70`、`fee_min_amt` **缺失或为 0**、`fee_charge_type=1`、`out_fee_flag=2`、`wx_mer_infos` 里有 `bank_mer_code` 且 `is_default=1` |
-| `appId` | 人才端绑定 | `busi.config.query` 的 `atu_config_bo_list` 中 `fee_type=01` 的 `wx_config_list` 里出现人才端 AppID;`control_flag=Y` 记 `action_required` |
+| `wechat` | 微信支付 | `qry_wx_conf_list` 里配置的三个支付场景(**线下默认 1/2/3**;线上 4/5/12)齐全,且每场景 `fee_rate=`配置值(**线下 0.30**、线上 0.70)、`fee_min_amt` **缺失或为 0**、`fee_charge_type=1`、`out_fee_flag=2`、`wx_mer_infos` 里有 `bank_mer_code` 且 `is_default=1`。场景与费率都取 `application.yml` 的配置,与下发同源 |
+| `appId` | 人才端绑定 | `busi.config.query` 的 `atu_config_bo_list` 中 `fee_type=`配置值(**线下 02**、线上 01)的 `wx_config_list` 里出现人才端 AppID;`control_flag=Y` 记 `action_required` |
| `realname` | 实名与授权 | **只看 `authorize_stat`**:`=1` → `confirmed`;`APPLYMENT_STATE_REJECTED` → `failed`;`NOAPPLY/CANCELED` → `missing`;`EDITTING/WAITTING_FOR_AUDIT` → `pending`;其余 → `action_required` |
| `delay` | 延迟入账 | 商户详情 `delay_flag=Y` 且 `forced_delay_flag=N`;不满足时由 `v2.merchant.busi.modify` 发起(落 `merchant_onboarding_attempt`,`step='delay'`) |
| `settlement` | 自动结算 | `qry_settle_config_list` 恰好 1 条,且 `settle_status=1`、`settle_cycle=D1`、`settle_pattern=P0`、`min_amt=0`、`remained_amt=0` |
@@ -170,7 +180,7 @@
### 坑 4(卡死流程)微信小程序配置排在业务开通之前,任何新商户都开不出微信收款能力
**现象**:配置被驳「根据渠道号或微信子商户号未查到入驻记录」,紧接着微信支付、人才端绑定、实名全部连坐;商户永远停在 `not_started`。
-**根因**:两层。①**顺序反了**:微信子商户号是业务开通创建的,配置必须先等它;配置一失败旧实现就 `return`,业务开通永远轮不到。②**闸门取错了依据**:业务开通被受理(`90000000`)不等于子商户号已可查——微信入驻是**异步**的,闸门必须取通道证据(`qry_wx_conf_list` 里真的出现线上场景),不能取本地状态。
+**根因**:两层。①**顺序反了**:微信子商户号是业务开通创建的,配置必须先等它;配置一失败旧实现就 `return`,业务开通永远轮不到。②**闸门取错了依据**:业务开通被受理(`90000000`)不等于子商户号已可查——微信入驻是**异步**的,闸门必须取通道证据(`qry_wx_conf_list` 里真的出现配置的场景),不能取本地状态。
**置信档**:【实测】(新商户真实报文:配置驳回原因逐字如此;改成通道证据后受理成功)
**为什么以前没暴露**:只验证过存量/参考商户,它们的微信业务早就开通了,配置有东西可挂。
@@ -199,7 +209,7 @@
| 查询一律 `20002 数据权限认证失败` | 签名身份有没有该商户的数据权限;与商户层级(head 字段)**无关** | 我方凭据/权限。【实测】 |
| 业务开通回 `90000000` | 这是「审核中=已受理」,**不是失败** | 我方判定(曾把它记成 `failed`)。【实测】 |
| `wx_conf_list格式错误` | 逐字段比对层级与类型:`wx_real_name_info` 是**嵌套对象**不是字符串;小微/个体工商户必须带 `mcc` | 我方报文。【实测】 |
-| `微信费率规则号填写不正确` | CID 按「场景 × 业务类型 × **主体类型**」三维定位(企业 756 / 个体户 761 / 小微 775) | 我方配置。【官方原件】(官方 Excel 全表在 `docs/huifu/reference/official/wx-fee-rule-id.xlsx`) |
+| `微信费率规则号填写不正确` | CID 按「场景 × 业务类型 × **主体类型**」三维定位(线下:企业 758 / 个体户 762 / 小微 770;线上:756 / 761 / 775) | 我方配置。【官方原件】(官方 Excel 全表在 `docs/huifu/reference/official/wx-fee-rule-id.xlsx`) |
| 阶段永远「未确认」而 `sync_error` 是空的 | 通道查询其实成功了,是**判定条件**没通过(典型:不设最低收费时通道**不返回** `fee_min_amt`,缺失必须按 0 处理) | 我方判定。【实测】(真实回执里该字段整个缺席) |
| 商户端没有授权二维码 | 通道回的是**裸 base64 的 JPEG**,别写死 PNG magic;官方示例却是 `data:image/png;base64,iVBOR…` | 我方解析.【实测】(通道回 `/9j/`)+官方示例属【官方原件】(`reference/official/huifu-api_shjj_wxsmrzztcx.md`)——**两者不一致,不要照抄官方示例** |
| `authorize_stat=1` 却显示待办理 | 授权**只看 `authorize_stat`**;`APPLYMENT_STATE_FREEZED`(主体已完成过入驻)也可能是已授权成功 | 我方判定。【实测】 |
diff --git "a/docs/huifu/05-\346\261\207\344\273\230\345\257\271\346\216\245\346\263\250\346\204\217\344\272\213\351\241\271.md" "b/docs/huifu/05-\346\261\207\344\273\230\345\257\271\346\216\245\346\263\250\346\204\217\344\272\213\351\241\271.md"
index 589b0e7..40ebf20 100644
--- "a/docs/huifu/05-\346\261\207\344\273\230\345\257\271\346\216\245\346\263\250\346\204\217\344\272\213\351\241\271.md"
+++ "b/docs/huifu/05-\346\261\207\344\273\230\345\257\271\346\216\245\346\263\250\346\204\217\344\272\213\351\241\271.md"
@@ -108,8 +108,10 @@
| 15 | `media_mer_common_flag` 是 `online_media_info_list` 的**元素字段**,不是顶层参数;声明「不一致(N)」会连带索要授权或开发证明材料 | 名字像顶层参数 | 放在列表元素里 | 【官方·外部】+【实测】 |
| 16 | `use_chains_flag=3`(复用上级微信商户号)要求**上级本身**有商户级微信配置——渠道商没有 | 上级渠道商天然能复用 | 不下发该标志,让子商户在渠道下拿自己的微信子商户号 | 【实测】 |
| 17 | **微信小程序配置必须排在业务开通之后**:微信子商户号是业务开通创建的 | 配置可以从容提前做 | 顺序反了必被驳「根据渠道号或微信子商户号未查到入驻记录」,而且配置一失败业务开通永远轮不到 | 【实测】 |
-| 18 | CID 按「支付场景 × 业务开通类型 × **主体类型**」**三维**定位:线上场景下企业 756/个体工商户 761/事业单位 773/社会组织 774/小微 775 | 全平台共用一个费率规则号 | 按主体类型分行配置。权威是官方 Excel:`reference/official/wx-fee-rule-id.xlsx`(Sheet1 共 139 行) | 【官方原件】+【实测】 |
-| 19 | **场景 12(线上反扫)在官方《微信费率规则ID》表里没有独立行**,与线上公众号/小程序(4/5)共用同一条线上规则 | 每个场景都该有独立行 | 共用线上的那一条 | 【官方原件】+【实测】 |
+| 18 | CID 按「支付场景 × 业务开通类型 × **主体类型**」**三维**定位:**线下(类型 02,2026-09-24 起默认)企业 758/个体工商户 762/小微 770**;线上(类型 01,回退用)企业 756/个体工商户 761/事业单位 773/社会组织 774/小微 775 | 全平台共用一个费率规则号 | 按主体类型分行配置。权威是官方 Excel:`reference/official/wx-fee-rule-id.xlsx`(Sheet1 共 139 行) | 【官方原件】+【实测】 |
+| 19 | **同一组场景共用一行 CID**:线上场景 12(线上反扫)在官方表里没有独立行,与线上公众号/小程序(4/5)共用线上规则;**线下三场景(1/2/3)同理,共用线下那一行** | 每个场景都该有独立行 | 共用该组场景的那一条 | 【官方原件】+【实测】 |
+| 19b | **场景口径是一整套,不是单个字段**:支付场景(1/2/3 ↔ 4/5/12)、费率规则号(758/770/762 ↔ 756/775/761)、费率(0.30 ↔ 0.70)、业务开通类型(02 ↔ 01)、进件 `scene_type`(OFFLINE ↔ ONLINE)必须一起改 | 只改场景号 | 只改一处会出现「业务开通成功但阶段判定永远 missing」或「AppID 判定永远 missing」,而判定下游的收款闸门会挡住商户收款 | 【实测】 |
+| 19c | 商户微信业务已开通,小程序支付却只报「业务执行失败[失败]」 | 微信要求 `sub_mch_id` 与 `sub_appid` 匹配:**人才端小程序 AppID 必须绑定到商户实际收款的那个微信子商户**。控台改场景后微信子商户号变成**新号**(2026-09-24 只读核验:线上 `…637173`/线下 `…82693`,与 09-15 记录的 `…628112` 不同),新子商户未重新绑 AppID 时微信回 `sub_mch_id与sub_appid不匹配` | 换场景/重建子商户后必须重新绑 AppID(`v2.merchant.busi.config`,`fee_type` 与场景口径一致)。⚠️ 这句真因在回执的 **`bank_message`** 里,而代码只读 `resp_desc`(`HuifuPaymentService.responseText`),所以现象只剩没有信息量的「失败」——排查时先看 `bank_message` | 【实测】报文与只读回执;「新子商户是控台改场景导致的」为【推断】(归因未经汇付确认) |
| 20 | 结算记录查询 `pageSize` 上限 **50**,超过直接 `00000003` 且**一条都不返回**(不是截断到 50) | 超限应该截断或报错 | `HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE = 50`;后台曾默认传 100,导致结算记录页**永远空白** | 【实测】 |
| 21 | 支付**查询**回执里通道流水号字段是 **`org_hf_seq_id`**;`hf_seq_id` 只出现在**异步通知**里 | 两者是同一个字段 | 取错会**静默写成 NULL**。⚠️ 官方支付查询响应表写的是 `hf_seq_id`——见 §4 | 【实测】·需复核 |
| 22 | **退款查询回执不带退款流水号**——它只在**发起退款的那次回执**里有 | 查单能拿到全部字段 | 不能无条件覆盖,否则把唯一凭证擦成空串。⚠️ 官方退款查询响应表列有 `org_hf_seq_id`——见 §4 | 【实测】·需复核 |
diff --git "a/docs/huifu/06-\350\270\251\345\235\221\346\270\205\345\215\225.md" "b/docs/huifu/06-\350\270\251\345\235\221\346\270\205\345\215\225.md"
index 5579931..09fe078 100644
--- "a/docs/huifu/06-\350\270\251\345\235\221\346\270\205\345\215\225.md"
+++ "b/docs/huifu/06-\350\270\251\345\235\221\346\270\205\345\215\225.md"
@@ -52,7 +52,7 @@
| 10 | **任何新商户**都开不出微信收款能力:微信支付、人才端绑定、实名全部连坐 | 「微信小程序配置」排在「商户业务开通」**之前**,而微信子商户号是业务开通创建的,配置必被驳;配置一失败就 `return`,业务开通永远轮不到 | 【实测】 |
| 11 | 业务开通报 `wx_conf_list格式错误`(第一层) | 小微的 `mcc` 写在 `fee_rule_id` 的 `else` 分支里,平台配了默认规则号之后小微的 mcc **一次都没发过** | 【实测】 |
| 12 | 换号后**仍是**同一句 `wx_conf_list格式错误`(第二层) | `wx_real_name_info` **双重编码**:官方字段目录类型列是 Object,代码又 `JSON.toJSONString` 编了一层;网关 javadoc 与实现不一致 | 【实测】 |
-| 13 | 业务开通报「微信费率规则号填写不正确,请检查!」 | CID 按「场景 × 业务开通类型 × **主体类型**」三维定位,不能全平台共用一个号 | 【实测】 |
+| 13 | 业务开通报「微信费率规则号填写不正确,请检查!」 | CID 按「场景 × 业务开通类型 × **主体类型**」三维定位,不能全平台共用一个号(线下2026-09-24起:企业 758/个体户 762/小微 770) | 【实测】 |
| 14 | 六项阶段里 5 项绿,「微信支付」永远未确认,而 `sync_error` 是空的 | 判定要求 `fee_min_amt` 必须出现且为 0,而该字段可选、通道在不设最低收费时不返回;缺失被判成未生效 | 【推断】 |
| 15 | 商户端没有授权二维码,而「本人扫码授权」是完成实名的唯一途径 | `qrCode()` 写死 PNG 的 base64 magic(`iVBORw0KGgo`),通道回的是 **JPEG**(`/9j/`),被判非法返回空串 | 【实测】 |
| 16 | 已授权成功的商户(`authorize_stat=1`)被显示成「待办理」,二维码还在让人扫 | 授权判定要求 `APPLYMENT_STATE_PASSED`,而真实样本是 `APPLYMENT_STATE_FREEZED` + `authorize_stat=1`;授权完成**只能看 `authorize_stat`** | 【实测】 |
diff --git "a/docs/huifu/08-\344\270\212\347\272\277\344\270\216\347\216\257\345\242\203.md" "b/docs/huifu/08-\344\270\212\347\272\277\344\270\216\347\216\257\345\242\203.md"
index a08d394..be865a7 100644
--- "a/docs/huifu/08-\344\270\212\347\272\277\344\270\216\347\216\257\345\242\203.md"
+++ "b/docs/huifu/08-\344\270\212\347\272\277\344\270\216\347\216\257\345\242\203.md"
@@ -67,8 +67,12 @@
| `app.payment.stranded-refund.enabled` | `true` | **悬账**自动退款(退回付款人);通道拒绝则升级告警转人工 |
| `app.payment.stranded-refund.fixed-delay-ms` | `300000` | 5 分钟一轮 |
| `...huifu.merchant-use-chains-flag` | 空(不下发) | **不要下发 `3`**:那要求上级本身有商户级微信配置,我们的上级是渠道商,会被拒「失败:复用交易信息,上级商户号为空」【实测】 |
-| `...huifu.merchant-wechat-fee-rate` | `0.70` | 微信费率(Java 字段默认值是 `0.60`,与 yml 不一致) |
-| `...huifu.merchant-wechat-fee-rule-id` / `-individual` / `-sole-proprietor` | `756` / `775` / `761` | CID 按「支付场景 × 业务开通类型 × **主体类型**」三维定位,不能全平台共用一个号(Java 里企业那个默认值为空) |
+| `...huifu.merchant-wechat-scenes` | `1,2,3` | 微信支付场景:线下反扫/线下公众号/线下小程序。**2026-09-24 起统一走线下**;回退线上配 `4,5,12`(场景、CID、费率、fee-type、进件场景是一套口径,要一起换) |
+| `...huifu.merchant-wechat-fee-type` | `02` | 业务开通类型:线下 `02`/线上 `01`。它同时决定 AppID 绑定的 `fee_type`、实名与实名查询的 `pay_scene`、以及判定时按 `fee_type` 匹配配置项的口径——四处必须同一个值 |
+| `...huifu.merchant-wechat-fee-rate` | `0.30` | 微信费率(线下)。Java 字段默认值已与 yml 统一为 `0.30`(线上是 `0.70`)。实际生效值以商户详情 `qry_wx_conf_list[].fee_rate` **读回为准** |
+| `...huifu.merchant-wechat-fee-rule-id` / `-individual` / `-sole-proprietor` | `758` / `770` / `762` | CID 按「支付场景 × 业务开通类型 × **主体类型**」三维定位:**线下(类型 02)企业 758/小微 770/个体户 762**;线上(回退用)是 756/775/761。不能全平台共用一个号 |
+| `...huifu.merchant-onboarding-scene-type` | `OFFLINE` | 进件的 `scene_type`:线下报备口径(要实体经营场所材料)。回退线上配 `ONLINE` |
+| `...huifu.merchant-micro-business-type` | `MICRO_TYPE_STORE` | 小微经营类型(个人主体开通微信业务必填):线下门店场所取 `MICRO_TYPE_STORE`,线上商品服务交易才是 `MICRO_TYPE_ONLINE` |
| `app.order.pending-expiry.fixed-delay-ms` | **yml 里没有这一行** | 订单到期清理,靠注解默认 `60000` 生效;它**没有** `enabled` 开关 |
两处过期的注释要当心:`enabled: true` 上方仍写着 `Keep disabled until the Huifu test/production product, merchant and keys are provisioned.`,
@@ -203,11 +207,11 @@
## 5. 迁移编号规约
- **编号全仓共享**。新增迁移前**先看** `talent-card-server/src/main/resources/db/migration/` 当前的**最大版本号**
- (截至本提交是 `V154`)。本仓库由多人按业务域分工,两人从同一祖先各自独立占同一段编号时 **git 不报冲突,
+ (截至本提交是 `V155`)。本仓库由多人按业务域分工,两人从同一祖先各自独立占同一段编号时 **git 不报冲突,
Flyway 会直接报「同一版本两个迁移」**。
-- **最大版本号 ≠ 迁移条数**。当前是 **148 个**:147 个 `.sql` + **1 个 Java 版迁移**
+- **最大版本号 ≠ 迁移条数**。当前是 **149 个**:148 个 `.sql` + **1 个 Java 版迁移**
(`src/main/java/db/migration/V125__parking_lot_registration_snapshot.java`,包名 `db.migration`)。
- `1..154` 里真正的空档只有 **6 个**:97 / 105 / 109 / 110 / 111 / 126。
+ `1..155` 里真正的空档只有 **6 个**:97 / 105 / 109 / 110 / 111 / 126。
**数迁移时不要只数 `resources/db/migration/*.sql`**——那样会漏掉 Java 版迁移,把 `V125` 误判成空档
(这个错 2026-09-22 真的发生过)。要核条数就看 Flyway 启动日志里的
`Successfully validated N migrations`。经过见 §8.2。
@@ -380,3 +384,30 @@
唯一要守住的是 **`app.payment.huifu.reconciliation.enabled` 保持默认 `true`**:它是支付与退款唯一的后台兜底,
关掉就只剩「用户点了取消 / 重申支付」这一个入口(见 §1.2)。
+
+## 9. 存量商户迁移到线下口径(2026-09-24)
+
+微信支付口径整体从线上切到线下(见 §1 的环境变量表:场景 `1,2,3`、类型 `02`、费率 `0.30`、
+CID `758/770/762`、进件 `scene_type=OFFLINE`)。**存量商户是在控台手工改的口径**,所以发布后要逐个迁移:
+
+1. 部署新代码,在商户详情点一次「刷新进度」。
+2. 跑下面这条 SQL(开发库,需 MySQL 5.7+)。列出来的就是「通道侧仍是线上 4/5/12、按线下判定会变
+ `missing`」的商户 —— 也就是**收款闸门会挡住收款**的那批:
+
+```sql
+select a.provider_huifu_id as huifu_id,
+ a.merchant_name,
+ json_extract(p.stages_json, '$.wechat') as wechat_stage,
+ p.last_synced_at
+from merchant_onboarding_application a
+join merchant_onboarding_progress p on p.application_id = a.id
+where a.provider_huifu_id is not null and a.provider_huifu_id <> ''
+ and json_extract(p.stages_json, '$.wechat') <> 'confirmed';
+```
+
+3. 在汇付控台把每条商户的微信场景改成线下(`1/2/3`),并**确认人才端小程序 AppID 绑到了该商户实际收款的
+ 那个微信子商户**(改场景会重建微信子商户号,见 `05` 第 19c 条)—— 不绑就会出现「业务执行失败[失败]」。
+4. 回商户详情再点「刷新进度」,`wechat` 回到 `confirmed` 即恢复收款。
+
+**注意**:判定完全跟随配置,所以回退线上要连场景、CID、费率、`fee-type`、进件 `scene_type` **一起**改回去
+(`05` 第 19b 条),只改一处会出现「通道已开通、阶段永远 missing」。
diff --git a/rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue b/rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
index 584248f..a1a47db 100644
--- a/rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
+++ b/rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
@@ -24,7 +24,8 @@
<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="input-shell" :class="invalidField == 'businessAddress' ? 'field-invalid' : ''"><input class="text-input" v-model="businessAddress" maxlength="255" placeholder="请输入" placeholder-class="input-placeholder" @input="clearFieldError('businessAddress')" /></view><text class="field-hint">与门头照上的地址一致(线下报备需要)</text><text v-if="invalidField == 'businessAddress'" 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>
@@ -83,7 +84,7 @@
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 storeHeaderUrl = ref(''); const storeIndoorUrl = 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 businessAddress = 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 = ['固定期限', '长期']
@@ -125,9 +126,11 @@
} else {
if (industryType.value.length == 0) return 'industryType|请选择行业类型'
if (registeredRegion.value.length == 0) return 'registeredRegion|请选择经营地区'
+ // 经营详细地址:线下报备(entity 有实体经营场所)下它是通道必填项,是进件的 detail_addr。
+ if (businessAddress.value.trim().length == 0) return 'businessAddress|请输入经营详细地址'
// 店铺照片是个人(小微)商户进件的必填项,微信业务开通与微信实名认证都要用它。
// 在这里拦住,好过进件提交上去以后在开通或实名那一步被驳回——那时得重走整份进件。
- if (storeHeaderUrl.value.length == 0) return 'storeHeaderUrl|请上传店铺照片(线上为店铺首页截图,门店为门头照)'
+ if (storeHeaderUrl.value.length == 0) return 'storeHeaderUrl|请上传店铺照片(线下报备为实体门头照)'
if (storeIndoorUrl.value.length == 0) return 'storeIndoorUrl|请上传店内环境照'
}
}
@@ -181,7 +184,7 @@
}
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, storeHeaderUrl: storeHeaderUrl.value, storeIndoorUrl: storeIndoorUrl.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, businessAddress: businessAddress.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 = () => {
@@ -212,6 +215,7 @@
if (draft.registeredCityCode != null) registeredCityCode.value = draft.registeredCityCode as string
if (draft.registeredDistrictCode != null) registeredDistrictCode.value = draft.registeredDistrictCode as string
if (draft.registeredAddress != null) registeredAddress.value = draft.registeredAddress as string
+ if (draft.businessAddress != null) businessAddress.value = draft.businessAddress as string
if (draft.registeredCapital != null) registeredCapital.value = draft.registeredCapital as string
if (draft.subjectName != null) subjectName.value = draft.subjectName as string
if (draft.legalIdFrontUrl != null) legalIdFrontUrl.value = draft.legalIdFrontUrl as string
@@ -277,7 +281,7 @@
}
}
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 : '', 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' }) })
+ 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 : '', businessAddress: merchantType.value == 'individual' ? businessAddress.value.trim() : '' }, '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(() => {
diff --git a/talent-card-admin/src/views/MerchantOnboardingDetailView.vue b/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
index 131bc4e..66f553f 100644
--- a/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
+++ b/talent-card-admin/src/views/MerchantOnboardingDetailView.vue
@@ -193,6 +193,9 @@
它同时决定上报汇付的 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>
+ <!-- 经营详细地址:线下进件上报给通道的 detail_addr(业务开通与实名都可能被问到),
+ 运营要能在这里核对,别只看证件地址。 -->
+ <div class="detail-field detail-field--auto-height"><span>经营详细地址</span><strong>{{ text(detail.businessAddress) }}</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>
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
index 5ad8734..132b063 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingProgressService.java
@@ -3,6 +3,7 @@
import com.alibaba.fastjson.JSON;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
+import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import java.util.ArrayList;
@@ -24,7 +25,8 @@
}
private final JdbcTemplate jdbc;
private final HuifuOnboardingProgressGateway gateway;
- public MerchantOnboardingProgressService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway gateway) { this.jdbc=jdbc; this.gateway=gateway; }
+ private final HuifuPaymentProperties huifuPaymentProperties;
+ public MerchantOnboardingProgressService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway gateway, HuifuPaymentProperties huifuPaymentProperties) { this.jdbc=jdbc; this.gateway=gateway; this.huifuPaymentProperties=huifuPaymentProperties; }
/**
* 分阶段进度适用于「已关联结算银行卡、且未被整体拒绝」的申请。
@@ -80,8 +82,13 @@
}
if (capabilities.containsKey("qry_wx_conf_list")) {
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")
+ // 口径必须与业务开通的下发取同一份配置(场景、费率、业务开通类型都来自 properties):
+ // 判定跟着下发走,否则会出现「通道已开通、阶段永远 missing」,而它下游的收款闸门
+ // 会把商户挡在下单之外。2026-09-24 前这里是硬编码的线上 4/5/12 + 0.70。
+ List<String> expectedScenes = huifuPaymentProperties.merchantWechatSceneList();
+ String expectedFeeRate = huifuPaymentProperties.getMerchantWechatFeeRate().trim();
+ boolean valid = !expectedScenes.isEmpty() && expectedScenes.stream().allMatch(scene -> wechat.stream().anyMatch(item ->
+ scene.equals(text(item.get("pay_scene"))) && decimal(item.get("fee_rate"), expectedFeeRate)
// fee_min_amt 是可选字段,且文档写明「大于 0」——不设最低收费时我们**不下发**它,
// 通道也就不返回它。缺失必须按 0 处理,否则这条判定永远不成立:商户微信业务
// 明明已经开好(三场景、费率、内扣、子商户号都对),阶段却永远停在未确认
@@ -114,10 +121,13 @@
Map<String,Object> appConfig = gateway.queryWechatConfig(text(application.get("providerHuifuId")));
if (appConfig != null && "00000000".equals(text(appConfig.get("resp_code")))) {
boolean bound = false, controlled = false;
+ // 业务开通类型也取配置:绑定写在哪个 fee_type 下就得在哪个下面找,两边必须同一个值。
+ String expectedFeeType = huifuPaymentProperties.getMerchantWechatFeeType();
+ String expectedAppId = huifuPaymentProperties.getMiniProgramAppId().trim();
for (Map<String,Object> config : configurations(appConfig.getOrDefault("atu_config_bo_list", List.of()))) {
- if (!"01".equals(text(config.get("fee_type")))) continue;
+ if (!expectedFeeType.equals(text(config.get("fee_type")))) continue;
for (Map<String,Object> entry : configurations(config.getOrDefault("wx_config_list", List.of()))) {
- if ("1".equals(text(entry.get("config_type"))) && "wxcfee477acb54d179".equals(text(entry.get("config_value")))) {
+ if ("1".equals(text(entry.get("config_type"))) && expectedAppId.equals(text(entry.get("config_value")))) {
bound = true;
controlled |= "Y".equals(text(entry.get("control_flag")));
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java
index 9d5fdb5..af513bc 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java
@@ -42,8 +42,12 @@
@NotBlank @Pattern(regexp = "\\d{12}") String bankBranchCode,
@Size(max = 512) String settlementCardFrontUrl,
@Size(max = 512) String registrationAccountUrl,
- // 门店照片:个人(小微)商户拿到微信收款能力必须交的两张图。门头照在「流动经营/便民服务」
- // 场景下是经营现场照。企业主体不填这两项。
+ // 门店照片:个人(小微)商户拿到微信收款能力必须交的两张图。线下门店场所口径下是实体门头照;
+ // 线上商品服务是店铺首页截图。企业主体不填这两项。
@Size(max = 512) String storeHeaderUrl,
- @Size(max = 512) String storeIndoorUrl
+ @Size(max = 512) String storeIndoorUrl,
+ // 经营详细地址(进件的 detail_addr):线下口径下个人(小微)商户必填。它**不是**证件地址——
+ // registeredAddress 走通道的 legal_addr,两个字段在通道侧语义不同,不要互相顶替。
+ // 放在记录末尾是为了不动既有构造点的参数位置(见 MerchantExistingOnboardingTests 的工厂方法)。
+ @Size(max = 255) String businessAddress
) {}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
index 6aad387..cd6db81 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
@@ -128,6 +128,8 @@
// 企业主体用不到。见 V146 的说明。
columns.put("store_header_url", "varchar(512) default null");
columns.put("store_indoor_url", "varchar(512) default null");
+ // 经营详细地址:线下进件的 detail_addr 是通道必填项,与证件地址分开存。见 V155 的说明。
+ columns.put("business_address", "varchar(255) 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());
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
index 767243b..0b6cc4d 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -165,6 +165,9 @@
// 放在提交时校验:这两张图是拿到微信收款能力的硬条件,缺了也能把进件提上去,
// 但后面微信开通和实名一定被通道驳回,那时商户得把整份进件重走一遍。
if (blank(request.storeHeaderUrl()) || blank(request.storeIndoorUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传店铺照片与店内环境照,微信支付开通与实名认证都需要");
+ // 经营详细地址(进件的 detail_addr)。线下按「有实体经营场所」口径报备后它是通道必填项,
+ // 而证件地址(legal_addr)语义不同、不能顶替——两者原先共用同一个值,这里必须分开采集。
+ if (blank(request.businessAddress())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请填写经营详细地址");
}
boolean privateSettlement = isPrivateSettlement(request.subjectType(), request.companyType());
if (privateSettlement && blank(request.settlementCardFrontUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传结算银行卡卡号面");
@@ -184,7 +187,7 @@
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, settlement_card_front_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(settlementProofUrl), clean(request.legalCertificateAddress()), clean(request.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), clean(request.storeHeaderUrl()), clean(request.storeIndoorUrl()), 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, settlement_card_front_url, legal_certificate_address, legal_mobile, administrator_email, contact_name, contact_mobile, store_header_url, store_indoor_url, business_address, 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(settlementProofUrl), clean(request.legalCertificateAddress()), clean(request.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), clean(request.storeHeaderUrl()), clean(request.storeIndoorUrl()), clean(request.businessAddress()), key.trim());
String cardNumber = request.bankCardNumber().trim();
String cardKey = key.trim() + ":initial";
if (cardKey.length() > 128) cardKey = cardKey.substring(0, 128);
@@ -281,7 +284,7 @@
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(), "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));
+ request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), settlementCardPictureId, storeHeaderPictureId, clean(request.businessAddress())));
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);
@@ -804,7 +807,8 @@
}
/**
- * 微信小程序支付配置是否已经在通道生效:人才端 AppID 是否绑定在 {@code fee_type=01} 的配置项下。
+ * 微信小程序支付配置是否已经在通道生效:人才端 AppID 是否绑定在配置的业务开通类型
+ * (线下 {@code 02}、线上 {@code 01},取 {@code merchant-wechat-fee-type})下的配置项里。
* 与分阶段进度用的是同一份证据。查询失败按「未生效」处理——宁可多提一次也不要漏配。
*/
private boolean wechatConfigEffective(String huifuId) {
@@ -812,8 +816,9 @@
Map<String, Object> config = onboardingQueryGateway.queryWechatConfig(huifuId);
if (config == null || !"00000000".equals(clean(config.get("resp_code")))) return false;
String appId = huifuPaymentProperties.getMiniProgramAppId().trim();
+ String feeType = huifuPaymentProperties.getMerchantWechatFeeType();
for (Map<String, Object> entry : providerList(config.get("atu_config_bo_list"))) {
- if (!"01".equals(clean(entry.get("fee_type")))) continue;
+ if (!feeType.equals(clean(entry.get("fee_type")))) continue;
for (Map<String, Object> item : providerList(entry.get("wx_config_list"))) {
if ("1".equals(clean(item.get("config_type"))) && appId.equals(clean(item.get("config_value")))) return true;
}
@@ -826,15 +831,17 @@
}
/**
- * 微信业务是否已经在通道开通:商户详情的 {@code qry_wx_conf_list} 里出现线上三场景之一即视为已开通。
+ * 微信业务是否已经在通道开通:商户详情的 {@code qry_wx_conf_list} 里出现配置的支付场景之一
+ * (线下 {@code 1/2/3}、线上 {@code 4/5/12},取 {@code merchant-wechat-scenes})即视为已开通。
* 业务开通是申请型接口,重复提交会给同一商户开出第二份申请,所以提交前必须先查。
*/
private boolean wechatBusinessEffective(String huifuId) {
try {
Map<String, Object> detail = onboardingQueryGateway.queryCapabilities(huifuId);
if (detail == null || !"00000000".equals(clean(detail.get("resp_code")))) return false;
+ List<String> scenes = huifuPaymentProperties.merchantWechatSceneList();
return providerList(detail.get("qry_wx_conf_list")).stream()
- .anyMatch(item -> List.of("4", "5", "12").contains(clean(item.get("pay_scene"))));
+ .anyMatch(item -> scenes.contains(clean(item.get("pay_scene"))));
} catch (RuntimeException exception) {
log.warn("Huifu merchant business open probe failed: huifuId={}", huifuId, exception);
return false;
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingGateway.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingGateway.java
index 0b11a5a..01f4134 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingGateway.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingGateway.java
@@ -84,7 +84,8 @@
request.put("ent_type", command.entType());
request.put("mcc", command.mcc());
request.put("busi_type", "1");
- request.put("scene_type", "ONLINE");
+ // 进件场景:线下 OFFLINE(线上 ONLINE)。它决定通道索要哪些经营场所材料,统一走线下后由配置提供。
+ request.put("scene_type", properties.getMerchantOnboardingSceneType());
request.put("license_pic", command.licensePictureId());
request.put("license_code", command.creditCode());
request.put("license_validity_type", command.licenseValidityType());
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingCommand.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingCommand.java
index 127ae69..d23342b 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingCommand.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingCommand.java
@@ -22,6 +22,8 @@
String bankCode,
String settlementCardFrontPictureId,
// 店铺照片 fileId(F22):三种小微经营类型在进件时都必填,只是图片内容要求不同
- // (线上=店铺首页截图、流动经营=经营现场照、门店场所=实体门头照)。
- String storeHeaderPictureId
+ // (线下门店场所=实体门头照、流动经营=经营现场照、线上商品服务=店铺首页截图)。
+ String storeHeaderPictureId,
+ // 经营详细地址(请求里的 detail_addr)。线下口径下是通道必填项,与证件地址(legal_addr)分开。
+ String businessAddress
) {}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingGateway.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingGateway.java
index 4d6910a..c95a1c3 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingGateway.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingGateway.java
@@ -78,11 +78,13 @@
request.put("reg_name", command.name());
request.put("short_name", command.name());
request.put("mcc", command.mcc());
- request.put("scene_type", "ONLINE");
+ // 进件场景:线下 OFFLINE(线上 ONLINE)。线下属「有实体场所」口径,经营详细地址随之变成必填,
+ // 所以 detail_addr 取商户填的经营地址,不再用证件地址顶替(见 legal_addr 与 detail_addr 的分离)。
+ request.put("scene_type", properties.getMerchantOnboardingSceneType());
request.put("prov_id", command.provinceCode());
request.put("area_id", command.cityCode());
request.put("district_id", command.districtCode());
- request.put("detail_addr", command.address());
+ request.put("detail_addr", command.businessAddress());
request.put("legal_cert_no", command.idCardNo());
request.put("legal_cert_validity_type", command.certificateValidityType());
request.put("legal_cert_begin_date", command.certificateBeginDate());
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java
index 93f9469..5d2c8da 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java
@@ -70,19 +70,12 @@
return new HuifuMerchantBusinessOpenGateway(properties).buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId);
}
- /**
- * 线上微信业务场景:4=线上公众号、5=线上小程序、12=线上反扫。
- *
- * <p>三个场景一起开:平台的小程序、H5 公众号与反扫共用同一个子商户号,只开小程序会让另外两条
- * 支付路径在通道侧没有配置。汇付《微信费率规则ID》表里场景 12 没有独立行,它与 4/5 共用同一条
- * 线上规则——参考商户 {@code 6666000226048687} 线上三场景费率同为 0.70% 且真实生效。
- */
- 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, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) {
String feeRuleId = feeRuleId(individualMerchant, mccRequired);
List<Map<String, Object>> wechatList = new ArrayList<>();
- for (String scene : ONLINE_WECHAT_SCENES) {
+ // 支付场景来自配置(默认线下 1/2/3,见 application.yml 的 merchant-wechat-scenes)。
+ // 场景决定费率规则号、微信侧的经营场所材料与费率:改场景必须同时改 CID 与费率(这是一套口径)。
+ for (String scene : properties.merchantWechatSceneList()) {
Map<String, Object> wechat = new LinkedHashMap<>();
wechat.put("pay_scene", scene);
// 企业用汇付确认的 fee_rule_id 选费率规则。
@@ -141,8 +134,10 @@
putMaterial(extendedMaterials, "F24", storeIndoorFileId);
}
if (!extendedMaterials.isEmpty()) request.put("extended_material_list", JSON.toJSONString(extendedMaterials));
- request.put("online_busi_type", properties.getMerchantOnlineBusinessType().trim());
- // 运营媒介只在开通快捷/网银/余额支付Pro/分账/大额转账/代扣时才需要,本网关只开线上微信,
+ // online_busi_type 不下发:官方口径是「基本信息入驻接口 scene_type=ONLINE/ALL 时必填」,它是线上
+ // 业务类型编码,属于**进件**请求的字段(我们从来只在业务开通里发过它,是个历史错位)。统一走线下
+ // 后不再发;将来要回退线上,正确做法是把它加到进件的两个网关里,而不是加回这里。
+ // 运营媒介只在开通快捷/网银/余额支付Pro/分账/大额转账/代扣时才需要,本网关只开微信,
// 因此默认不下发。要下发就把 merchant-online-media-name 配上;注意声明
// media_mer_common_flag=N(媒介主体与商户主体不一致)时,通道会一并索要授权或开发证明材料。
String mediaName = properties.getMerchantOnlineMediaName().trim();
@@ -168,7 +163,8 @@
private void ensureConfigured() {
if (!properties.isEnabled()) throw new BusinessException(ErrorCode.CONFLICT, "Huifu payment is disabled");
- if (blank(properties.getSystemId()) || blank(properties.getProductId()) || blank(properties.getOnboardingUpperHuifuId()) || blank(properties.getMerchantWechatFeeRate()) || blank(properties.getMerchantOnlineBusinessType()) || blank(properties.getMerchantAgreementUrl()) || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant business opening configuration is incomplete");
+ // 场景列表为空就不能开通:没有 pay_scene 的 wx_conf_list 通道会直接拒,所以单独挡一道。
+ if (blank(properties.getSystemId()) || blank(properties.getProductId()) || blank(properties.getOnboardingUpperHuifuId()) || blank(properties.getMerchantWechatFeeRate()) || blank(properties.getMerchantAgreementUrl()) || properties.merchantWechatSceneList().isEmpty() || blank(properties.getMerchantWechatFeeType()) || blank(properties.getPrivateKey()) || blank(properties.getPlatformPublicKey())) throw new BusinessException(ErrorCode.CONFLICT, "Huifu merchant business opening configuration is incomplete");
}
private void initializeSdk() throws Exception {
@@ -184,9 +180,9 @@
/**
* 微信费率规则号(CID)**按主体类型**取:官方《微信费率规则ID》对照表用的是
- * 「支付场景 × 业务开通类型 × 主体类型」三维定位,本平台只开线上公众号/小程序/线上反扫
- * (场景 4/5/12)、业务开通类型恒为 01,所以只剩主体类型这一个变量:
- * 企业 756、个体工商户 761、个人(小微)775。
+ * 「支付场景 × 业务开通类型 × 主体类型」三维定位。本平台统一走线下三场景(1/2/3)、
+ * 业务开通类型恒为 02,所以只剩主体类型这一个变量:线下口径下企业 758、个体工商户 762、
+ * 个人(小微)770(线上口径是 756 / 761 / 775)。
*
* <p>用错号不是"费率算错"而是直接被拒——通道回
* 「微信费率规则号填写不正确,请检查!」(2026-09-16 拿企业的 756 给小微商户实测)。
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameGateway.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameGateway.java
index 1f790a1..b1cf2a8 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameGateway.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameGateway.java
@@ -30,8 +30,11 @@
public class HuifuMerchantRealnameGateway {
private static final Object SDK_LOCK = new Object();
private static final Logger log = LoggerFactory.getLogger(HuifuMerchantRealnameGateway.class);
- /** 微信业务开通类型:线上标准费率。实名查询的 pay_scene 引用的是它,不是业务开通的支付场景 4/5/12。 */
- static final String PAY_SCENE = "01";
+ /**
+ * 微信业务开通类型({@code pay_scene} 入参):线下口径是 02、线上是 01,两者取同一个配置项
+ * {@code merchant-wechat-fee-type}。它引用的是「业务开通类型」,不是业务开通的支付场景
+ * (线下 1/2/3、线上 4/5/12)——同名不同义,别混。
+ */
private final HuifuPaymentProperties properties;
public HuifuMerchantRealnameGateway(HuifuPaymentProperties properties) { this.properties = properties; }
@@ -64,7 +67,7 @@
request.put("req_seq_id", requestNo);
request.put("req_date", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE));
request.put("huifu_id", huifuId);
- request.put("pay_scene", PAY_SCENE);
+ request.put("pay_scene", properties.getMerchantWechatFeeType());
// 法人本人。文档要求联系人类型为 LEGAL 时 name/mobile/id_card_number 都必须是法人本人的,
// 法人手机号与联系人手机号是两个不同的人,调用方必须传法人自己的号码。
request.put("name", command.legalName().trim());
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantWechatConfigGateway.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantWechatConfigGateway.java
index ef5e549..f93f4d0 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantWechatConfigGateway.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantWechatConfigGateway.java
@@ -28,7 +28,9 @@
request.put("req_seq_id", requestNo);
request.put("req_date", LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE));
request.put("huifu_id", huifuId);
- request.put("fee_type", "01");
+ // 业务开通类型:线下 02(线上 01)。AppID 必须绑在该类型下,否则阶段判定读的是另一组配置项、
+ // 永远看不到绑定结果——2026-09-24 之前线上口径写死 01,改成线下后这里不跟着改就会出现这个问题。
+ request.put("fee_type", properties.getMerchantWechatFeeType());
// 文档《微信商户配置》的请求参数表里 product_id 是必填(示例值 JCTEST)。
// 它与公共参数里的 product_id(SDK 自动带)不是同一处:这一处在 data 里,必须自己传。
request.put("product_id", properties.getProductId().trim());
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java
index 37d0f8c..6e86fe3 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingProgressGateway.java
@@ -36,7 +36,8 @@
public Map<String,Object> queryRealname(String huifuId) {
Map<String,Object> request = queryRequest(huifuId);
- request.put("pay_scene", "01");
+ // 这里的 pay_scene 是「微信业务开通类型」(线下 02 / 线上 01),不是支付场景(1/2/3 或 4/5/12)。
+ request.put("pay_scene", properties.getMerchantWechatFeeType());
return send("v2.merchant.busi.realname.query", request);
}
diff --git a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java
index 3682b7b..ffccc05 100644
--- a/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java
+++ b/talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuPaymentProperties.java
@@ -43,23 +43,55 @@
* 「失败:复用交易信息,上级商户号为空」。
*/
private String merchantUseChainsFlag = "";
- private String merchantWechatFeeRate = "0.60";
/**
- * 企业微信业务开通使用的汇付费率规则 ID。企业场景应优先填写该值,
- * 由汇付按商户实际行业确认;留空时兼容旧的 mcc 请求方式。
+ * 微信支付费率(%)。2026-09-24 起平台统一按**线下**场景报备,费率随之从线上的 0.70 改为线下的 0.30。
+ * 通道侧真实生效值以商户详情 {@code qry_wx_conf_list[].fee_rate} 读回为准,不要以本配置为准。
*/
- private String merchantWechatFeeRuleId = "";
+ private String merchantWechatFeeRate = "0.30";
+ /**
+ * 微信业务开通的支付场景({@code wx_conf_list[].pay_scene}),逗号分隔。
+ *
+ * <p>汇付的场景枚举:1=线下反扫、2=线下公众号、3=线下小程序、4=线上公众号、5=线上小程序、12=线上反扫。
+ * 统一走线下三场景:它们共用同一个微信子商户号,只开小程序会让另外两条支付路径在通道侧没有配置
+ * (线上时同理,见「场景 12 与 4/5 共用同一条线上规则」的实测)。
+ *
+ * <p>要回退线上,把它配成 {@code 4,5,12},并把下面三个费率规则号、费率、{@code merchant-wechat-fee-type}
+ * 与进件场景一起换回线上——这四处是一套口径,只改一处会出现「业务开通成功但判定永远 missing」。
+ */
+ private String merchantWechatScenes = "1,2,3";
+ /**
+ * 微信业务开通类型({@code fee_type}):01=线上标准费率,02=线下。
+ *
+ * <p>它同时决定三件事,四处必须一致:微信 AppID 绑定({@code v2.merchant.busi.config} 的 {@code fee_type})、
+ * 实名认证与实名查询的 {@code pay_scene}、以及通道回执({@code qry_wx_conf_list} /
+ * {@code atu_config_bo_list})里按 {@code fee_type} 匹配配置项的口径。
+ */
+ private String merchantWechatFeeType = "02";
+ /**
+ * 企业微信业务开通使用的汇付费率规则 ID(CID)。企业场景应优先填写该值,
+ * 由汇付按商户实际行业确认;留空时兼容旧的 mcc 请求方式。
+ *
+ * <p>官方《微信费率规则ID》表按「支付场景 × 业务开通类型 × 主体类型」定 CID。线下三场景(类型 02)
+ * 下企业是 **758**;线上(类型 01)是 756。
+ */
+ private String merchantWechatFeeRuleId = "758";
/**
* 个人(小微)主体微信业务开通使用的费率规则 ID。
*
- * <p>官方的《微信费率规则ID》对照表按「支付场景 × 业务开通类型 × **主体类型**」定 CID:同样是
- * 线上公众号/小程序(场景 4/5、类型 01),企业是 756、个体户 761、事业单位 773、社会组织 774、
- * **小微 775**。所以这个号不能全平台共用一个——给小微发企业的 756 会被通道回
- * 「微信费率规则号填写不正确,请检查!」(2026-09-16 个人商户实测)。
+ * <p>同一张表按**主体类型**分行:线下三场景(类型 02)下企业 758、个体户 762、**小微 770**;
+ * 线上(类型 01)下企业 756、个体户 761、小微 775。所以这个号不能全平台共用一个——给小微发企业的
+ * 号码会被通道回「微信费率规则号填写不正确,请检查!」(2026-09-16 个人商户实测)。
*/
- private String merchantWechatFeeRuleIdIndividual = "775";
- /** 个体工商户(走企业进件接口、对私结算)的费率规则 ID,同一张表里是 761。 */
- private String merchantWechatFeeRuleIdSoleProprietor = "761";
+ private String merchantWechatFeeRuleIdIndividual = "770";
+ /** 个体工商户(走企业进件接口、对私结算)的费率规则 ID,同一张表线下口径是 762(线上是 761)。 */
+ private String merchantWechatFeeRuleIdSoleProprietor = "762";
+ /**
+ * 线上业务类型编码({@code online_busi_type})。
+ *
+ * <p>官方口径:它是「基本信息入驻接口」在 {@code scene_type=ONLINE/ALL} 时的必填项。本平台此前把它
+ * 发在**业务开通**请求里(错位),统一走线下后已停止下发,这个字段现在**没有任何调用点**。
+ * 要回退线上,应把它加进两个进件网关(那里才是官方口径的位置),而不是加回业务开通。
+ */
private String merchantOnlineBusinessType = "H5311L";
private String merchantOnlineMediaName = "东区人才卡";
/**
@@ -77,7 +109,13 @@
* 文档注明「个人商户需要填写」,且**三种取值都需要 F22 店铺门头照与 F24 店铺内景照**,
* 补充资料挂在 {@code extended_material_list} 里。
*/
- private String merchantMicroBusinessType = "MICRO_TYPE_ONLINE";
+ private String merchantMicroBusinessType = "MICRO_TYPE_STORE";
+ /**
+ * 商户进件的 {@code scene_type}:ONLINE/OFFLINE/ALL。
+ *
+ * <p>统一走**线下**报备(实体经营场所口径),通道据此决定索要哪些经营场所材料。回退线上时配 {@code ONLINE}。
+ */
+ private String merchantOnboardingSceneType = "OFFLINE";
private String merchantAgreementUrl = "https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/PaymentServiceAgreement.htm";
private Boolean merchantCapabilityRequired;
@@ -128,6 +166,21 @@
public void setMerchantUseChainsFlag(String merchantUseChainsFlag) { this.merchantUseChainsFlag = merchantUseChainsFlag; }
public String getMerchantWechatFeeRate() { return merchantWechatFeeRate; }
public void setMerchantWechatFeeRate(String merchantWechatFeeRate) { this.merchantWechatFeeRate = merchantWechatFeeRate; }
+ public String getMerchantWechatScenes() { return merchantWechatScenes == null ? "" : merchantWechatScenes.trim(); }
+ public void setMerchantWechatScenes(String merchantWechatScenes) { this.merchantWechatScenes = merchantWechatScenes; }
+ /**
+ * 支付场景列表(忽略空项)。刻意不叫 {@code getMerchantWechatScenes}:配置项是逗号分隔的字符串,
+ * 让同一个属性的 getter/setter 一个是 String、一个是 List 会让 Spring 的绑定行为变得难以预料。
+ */
+ public java.util.List<String> merchantWechatSceneList() {
+ java.util.List<String> scenes = new java.util.ArrayList<>();
+ for (String scene : getMerchantWechatScenes().split(",")) if (!scene.isBlank()) scenes.add(scene.trim());
+ return scenes;
+ }
+ public String getMerchantWechatFeeType() { return merchantWechatFeeType == null ? "" : merchantWechatFeeType.trim(); }
+ public void setMerchantWechatFeeType(String merchantWechatFeeType) { this.merchantWechatFeeType = merchantWechatFeeType; }
+ public String getMerchantOnboardingSceneType() { return merchantOnboardingSceneType == null ? "" : merchantOnboardingSceneType.trim(); }
+ public void setMerchantOnboardingSceneType(String merchantOnboardingSceneType) { this.merchantOnboardingSceneType = merchantOnboardingSceneType; }
public String getMerchantWechatFeeRuleId() { return merchantWechatFeeRuleId == null ? "" : merchantWechatFeeRuleId.trim(); }
public void setMerchantWechatFeeRuleId(String merchantWechatFeeRuleId) { this.merchantWechatFeeRuleId = merchantWechatFeeRuleId; }
public String getMerchantWechatFeeRuleIdIndividual() { return merchantWechatFeeRuleIdIndividual == null ? "" : merchantWechatFeeRuleIdIndividual.trim(); }
diff --git a/talent-card-server/src/main/resources/application.yml b/talent-card-server/src/main/resources/application.yml
index 49fd414..583dbde 100644
--- a/talent-card-server/src/main/resources/application.yml
+++ b/talent-card-server/src/main/resources/application.yml
@@ -117,25 +117,42 @@
# 「失败:复用交易信息,上级商户号为空」。不下发时,子商户在渠道 00016904 下拿到自己的微信
# 子商户号(实测 sub_mer_id=W15737921333057628112,sub_mer_name 即商户注册名)。
merchant-use-chains-flag: ${HUIFU_MERCHANT_USE_CHAINS_FLAG:}
- merchant-wechat-fee-rate: ${HUIFU_MERCHANT_WECHAT_FEE_RATE:0.70}
- # 企业微信业务的费率规则号(cid)。756 是汇付《微信费率规则ID》表里「企业 + fee_type=01(线上)」
- # 唯一的一条规则,细分行业含「就业信息平台/其他信息服务平台」,与本平台业务对应;
- # 场景 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}
+ # 微信支付场景(wx_conf_list[].pay_scene)。汇付枚举:1=线下反扫 2=线下公众号 3=线下小程序
+ # 4=线上公众号 5=线上小程序 12=线上反扫。2026-09-24 起统一走**线下三场景**(它们共用同一个
+ # 微信子商户号,只开小程序会让另外两条支付路径在通道侧没有配置)。
+ # 线上口径的实测留档:参考商户 6666000226048687 线上三场景费率同为 0.70% 且真实生效。
+ merchant-wechat-scenes: "${HUIFU_MERCHANT_WECHAT_SCENES:1,2,3}"
+ # 微信业务开通类型(fee_type):01=线上标准费率、02=线下。它同时决定 AppID 绑定的 fee_type、
+ # 实名/实名查询的 pay_scene、以及回执里按 fee_type 匹配配置项的口径——四处必须一致。
+ merchant-wechat-fee-type: ${HUIFU_MERCHANT_WECHAT_FEE_TYPE:02}
+ # 微信支付费率(%):线下 0.30(线上是 0.70)。以商户详情 qry_wx_conf_list[].fee_rate 读回为准,
+ # 不要以本配置为准——通道可能按合同拒绝或不生效。
+ merchant-wechat-fee-rate: ${HUIFU_MERCHANT_WECHAT_FEE_RATE:0.30}
+ # 企业微信业务的费率规则号(cid)。官方《微信费率规则ID》表按「支付场景 × 业务开通类型 ×
+ # 主体类型」定 CID:线下三场景(类型 02)**企业 758**、个体户 762、小微 770;
+ # 线上(类型 01)企业 756、个体户 761、小微 775。留空则退回 mcc 兼容路径。
+ merchant-wechat-fee-rule-id: ${HUIFU_MERCHANT_WECHAT_FEE_RULE_ID:758}
+ # 小微与个体工商户同样按线下口径取号(770 / 762)。小微拿企业的号码会被通道回
+ # 「微信费率规则号填写不正确,请检查!」,所以这三个号必须分开配。
+ merchant-wechat-fee-rule-id-individual: ${HUIFU_MERCHANT_WECHAT_FEE_RULE_ID_INDIVIDUAL:770}
+ merchant-wechat-fee-rule-id-sole-proprietor: ${HUIFU_MERCHANT_WECHAT_FEE_RULE_ID_SOLE_PROPRIETOR:762}
+ # 线上业务类型编码(online_busi_type):官方口径是「基本信息入驻接口 scene_type=ONLINE/ALL 时必填」。
+ # 我们以前错把它发在业务开通里,统一走线下后已停发;这个键现在**没有调用点**。
+ # 回退线上时应把它加进进件网关(那里才是它的位置),而不是加回业务开通。
merchant-online-business-type: ${HUIFU_MERCHANT_ONLINE_BUSINESS_TYPE:H5311L}
# 运营媒介(online_media_info_list)。文档写明它只在开通快捷/网银/余额支付Pro/分账/
# 大额转账/代扣时才需要,且是「基本信息入驻接口」的必填项,不是「商户业务开通」的必填项;
- # 本平台只开线上微信,所以默认留空即不下发。要下发时再配媒介名称。
+ # 本平台只开微信,所以默认留空即不下发。要下发时再配媒介名称。
merchant-online-media-name: ${HUIFU_MERCHANT_ONLINE_MEDIA_NAME:}
# 媒介主体与商户主体是否一致;它是 online_media_info_list 的**元素字段**,不是顶层参数。
# 声明 N(不一致)时通道会一并索要授权或开发证明材料,因此不下发媒介时它也用不上。
merchant-media-subject-same: ${HUIFU_MERCHANT_MEDIA_SUBJECT_SAME:N}
# 小微经营类型,个人商户开通微信业务时必填;三种取值都要求 F22 门头照 + F24 内景照。
- merchant-micro-business-type: ${HUIFU_MERCHANT_MICRO_BUSINESS_TYPE:MICRO_TYPE_ONLINE}
+ # 线下报备取 MICRO_TYPE_STORE(门店场所),线上商品服务交易才是 MICRO_TYPE_ONLINE。
+ merchant-micro-business-type: ${HUIFU_MERCHANT_MICRO_BUSINESS_TYPE:MICRO_TYPE_STORE}
+ # 商户进件的 scene_type:ONLINE/OFFLINE/ALL。统一走线下报备(实体经营场所口径),
+ # 回退线上时配 ONLINE。它决定通道索要哪些经营场所材料。
+ merchant-onboarding-scene-type: ${HUIFU_MERCHANT_ONBOARDING_SCENE_TYPE:OFFLINE}
merchant-agreement-url: ${HUIFU_MERCHANT_AGREEMENT_URL:https://cloudpnrcdn.oss-cn-shanghai.aliyuncs.com/opps/api/prod/download_file/PaymentServiceAgreement.htm}
# 收款能力只要求「斗拱子商户正常 + 取现可用」:未核销的钱由延迟户兜住,
# 自动结算开着不再影响收款。设 false 仅用于刻意跳过通道能力确认的部署。
diff --git a/talent-card-server/src/main/resources/db/migration/V155__merchant_business_address.sql b/talent-card-server/src/main/resources/db/migration/V155__merchant_business_address.sql
new file mode 100644
index 0000000..1401189
--- /dev/null
+++ b/talent-card-server/src/main/resources/db/migration/V155__merchant_business_address.sql
@@ -0,0 +1,26 @@
+-- 个人(小微)主体的**经营详细地址**(进件的 detail_addr)。
+--
+-- 背景:进件的 scene_type 统一改成 OFFLINE 之后,通道按「有实体经营场所」口径索要资料:个人进件的
+-- detail_addr 从选填变成必填。在这之前它一直拿**证件地址**(registered_address)顶替——语义不对
+-- (一个是身份证住址、一个是经营场所),线下口径下会被通道驳回或留下错误报备信息。因此单独存一列,
+-- 进件时取商户填的经营地址,legal_addr 仍取证件地址。
+--
+-- 老数据不回填:存量商户的场景是在控台单独改成线下的,历史进件记录保持原样即可,本地这一列为空
+-- 不影响任何读路径(只有「提交新进件」会写它)。
+--
+-- 用存储过程按 exists 判断追加,使脚本可以重复执行(本地开发的库常常手工加过列)。
+DROP PROCEDURE IF EXISTS add_business_address_column;
+DELIMITER //
+CREATE PROCEDURE add_business_address_column()
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema = DATABASE() AND table_name = 'merchant_onboarding_application'
+ AND column_name = 'business_address') THEN
+ ALTER TABLE merchant_onboarding_application
+ ADD COLUMN business_address VARCHAR(255) DEFAULT NULL
+ COMMENT '经营详细地址(线下进件个人主体的 detail_addr,与证件地址 legal_addr 分开)';
+ END IF;
+END //
+DELIMITER ;
+CALL add_business_address_column();
+DROP PROCEDURE add_business_address_column;
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
index a96ae23..59a8235 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
@@ -51,12 +51,12 @@
MerchantPrincipal principal=(MerchantPrincipal)fixture().get("principal");
jdbc.update("update merchant_bank_card set card_hash=? where merchant_id=?",sha256("622200001234"),principal.merchantId());
when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S","bind_card_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","0.00","fee_charge_type","1","out_fee_flag","2",
+ var scenes=java.util.List.of("1","2","3").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.30","fee_min_amt","0.00","fee_charge_type","1","out_fee_flag","2",
"wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","wx-merchant","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,
"qry_settle_config_list",java.util.List.of(Map.of("settle_status","1","settle_cycle","D1","settle_pattern","P0","min_amt","0","remained_amt","0","token_no","original-card-token")),
"qry_cash_card_info_list",java.util.List.of(Map.of("card_no","622200001234","token_no","original-card-token"))));
- when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","01","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179"))))));
+ when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","02","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179"))))));
when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","1"));
assertThat(service.refreshOnboarding(principal,false)).containsEntry("status","approved");
assertThat(service.onboardingStatus(principal)).containsEntry("status","approved");
@@ -148,7 +148,7 @@
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","delay_flag","Y","forced_delay_flag","N"));
- when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","01","wx_applet_app_id","wxcfee477acb54d179","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179","control_flag","Y"))))));
+ when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",java.util.List.of(Map.of("fee_type","02","wx_applet_app_id","wxcfee477acb54d179","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179","control_flag","Y"))))));
when(gateway.queryRealname(anyString())).thenReturn(Map.of("resp_code","00000000","applyment_stat","APPLYMENT_STATE_PASSED","authorize_stat","0"));
Map<String,Object> result=service.refreshOnboarding(principal,false);
assertThat((java.util.List<Map<String,Object>>)result.get("stages")).anySatisfy(stage -> assertThat(stage).containsEntry("key","appId").containsEntry("status","action_required"));
@@ -182,7 +182,7 @@
void configuredWechatScenesStillRequireTalentAppAndAuthorization() {
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","0.00","fee_charge_type","1","out_fee_flag","2",
+ var scenes=java.util.List.of("1","2","3").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.30","fee_min_amt","0.00","fee_charge_type","1","out_fee_flag","2",
"wx_mer_infos",java.util.List.of(Map.of("bank_mer_code","wx-merchant","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);
@@ -202,7 +202,7 @@
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",
+ var scenes=java.util.List.of("1","2","3").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.30","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));
@@ -216,7 +216,7 @@
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",
+ var scenes=java.util.List.of("1","2","3").stream().map(scene -> Map.of("pay_scene",scene,"fee_rate","0.30","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));
@@ -299,9 +299,9 @@
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",
- "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","4","fee_rate","0.70"))));
+ "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","1","fee_rate","0.30"))));
when(gateway.queryWechatConfig(anyString())).thenReturn(Map.of("resp_code","00000000","atu_config_bo_list",
- java.util.List.of(Map.of("fee_type","01","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179"))))));
+ java.util.List.of(Map.of("fee_type","02","wx_config_list",java.util.List.of(Map.of("config_type","1","config_value","wxcfee477acb54d179"))))));
service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false);
@@ -337,7 +337,7 @@
// 第二轮:通道侧已经开通线上场景 → 业务开通按已开通收口(不重复提交),配置才发出去。
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"))));
+ "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","1"))));
when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(true,"00000000","成功"));
service.refreshOnboarding(principal,false);
@@ -355,7 +355,7 @@
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"))));
+ "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","1"))));
when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(false,"00000001","参数非法"));
service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false);
@@ -421,7 +421,7 @@
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"))));
+ "qry_wx_conf_list",java.util.List.of(Map.of("pay_scene","1"))));
when(wechatConfigGateway.configure(anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantWechatConfigGateway.Result(false,"00000001","参数非法"));
MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal");
@@ -564,7 +564,7 @@
private static MerchantOnboardingRequest request(String storeHeaderUrl,String storeIndoorUrl,String settlementCardFrontUrl) {
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,settlementCardFrontUrl,null,storeHeaderUrl,storeIndoorUrl);
+ "Owner","13700000000",null,null,"510100",null,settlementCardFrontUrl,null,storeHeaderUrl,storeIndoorUrl,"四川省成都市武侯区某路1号");
}
/**
@@ -663,14 +663,14 @@
"https://cdn.example.com/legal-front.jpg","https://cdn.example.com/legal-back.jpg","四川省成都市武侯区某路1号",null,"owner@example.com",
"测试联系人","13700000001","6222000012345678","510000","510100",clearingCode,
null,"https://cdn.example.com/registration-account.jpg",
- null,null);
+ null,null,null);
}
/** 对公主体重提时要补的是开户许可证那一张(请求里走 registrationAccountUrl)。 */
private static MerchantOnboardingRequest registrationAccountRequest(String registrationAccountUrl) {
return new MerchantOnboardingRequest("enterprise","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,registrationAccountUrl,null,null);
+ "Owner","13700000000",null,null,"510100",null,null,registrationAccountUrl,null,null,null);
}
/** 个人进件的一次完整提交(除门店照片与结算卡卡号面外的必填项也都给上)。 */
@@ -682,7 +682,7 @@
"https://cdn.example.com/id-front.jpg","https://cdn.example.com/id-back.jpg","四川省邻水县丰禾镇9组108号","13700000000","owner@example.com",
"测试负责人","13700000000","6222000012345678","510000","510100",clearingCode,
"https://cdn.example.com/card-front.jpg",null,
- "https://cdn.example.com/store-header.jpg","https://cdn.example.com/store-indoor.jpg");
+ "https://cdn.example.com/store-header.jpg","https://cdn.example.com/store-indoor.jpg","四川省邻水县丰禾镇9组108号");
}
/** 建一张还停在 not_started 的进件,用来观察刷新会不会去推进开通步骤。 */
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenRequiredFieldsTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenRequiredFieldsTests.java
index b63ef79..46419b8 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenRequiredFieldsTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenRequiredFieldsTests.java
@@ -85,7 +85,8 @@
assertThat(wechat.get(0)).doesNotContainKey("micro_biz_type");
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");
+ // 小微经营类型默认是线下门店场所口径(MICRO_TYPE_STORE);线上商品服务交易才是 MICRO_TYPE_ONLINE。
+ assertThat(realName).containsEntry("micro_biz_type", "MICRO_TYPE_STORE");
}
@Test
@@ -143,66 +144,66 @@
* <p>原先这条判断写在 {@code fee_rule_id} 的 else 分支里,平台配了规则号 756 之后
* 小微/个体工商户的 mcc 永远发不出去,业务开通被通道回 {@code wx_conf_list格式错误}。
*
- * <p>费率规则号也要换成该主体类型的那一个:同表「线上、类型 01」下个体工商户是 761。
+ * <p>费率规则号也要换成该主体类型的那一个:同表「线下、类型 02」下个体工商户是 762。
*/
@Test
void anIndividualBusinessCarriesTheMccAndItsOwnFeeRule() {
HuifuPaymentProperties properties = properties();
- properties.setMerchantWechatFeeRuleId("756");
+ properties.setMerchantWechatFeeRuleId("758");
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("fee_rule_id", "762");
assertThat(entry).containsEntry("mcc", "5812");
});
}
/**
* 个人(小微)主体的费率规则号必须换成小微那一行。官方的《微信费率规则ID》对照表按
- * 「支付场景 × 业务开通类型 × 主体类型」定位 CID:线上(场景 4/5、类型 01)下企业是 756、
- * 小微是 775。拿企业的 756 发给小微,通道回的是「微信费率规则号填写不正确,请检查!」——
- * 2026-09-16 真实个人商户实测(该次报文的格式已经正确,纯粹是号选错)。
+ * 「支付场景 × 业务开通类型 × 主体类型」定位 CID:线下(场景 1/2/3、类型 02)下企业是 758、
+ * 小微是 770(线上是 756 / 775)。拿企业的号发给小微,通道回的是「微信费率规则号填写不正确,
+ * 请检查!」——2026-09-16 真实个人商户实测(该次报文的格式已经正确,纯粹是号选错)。
*/
@Test
void aMicroMerchantUsesTheMicroFeeRuleNotTheEnterpriseOne() {
HuifuPaymentProperties properties = properties();
- properties.setMerchantWechatFeeRuleId("756");
+ properties.setMerchantWechatFeeRuleId("758");
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"));
+ assertThat(entry).containsEntry("fee_rule_id", "770").containsEntry("mcc", "5812"));
}
@Test
void anEnterpriseCanUseAProviderConfirmedFeeRuleInsteadOfMcc() {
HuifuPaymentProperties properties = properties();
- properties.setMerchantWechatFeeRuleId("756");
+ properties.setMerchantWechatFeeRuleId("758");
List<Map<String, Object>> wechat = wechatConfig(HuifuMerchantBusinessOpenGateway.buildRequestForTest(
properties, "BO-TEST-RULE", "6666000241335656", "成都喜望科技", "7372", false));
assertThat(wechat).allSatisfy(entry -> {
- assertThat(entry).containsEntry("fee_rule_id", "756");
+ assertThat(entry).containsEntry("fee_rule_id", "758");
assertThat(entry).doesNotContainKey("mcc");
});
}
/**
- * 线上公众号、小程序、反扫三个场景一起开。只开小程序会让另外两条支付路径在通道侧缺配置,
- * 而平台的小程序、H5 公众号与反扫共用同一个子商户号。
+ * 配置的三个支付场景一起开(默认线下:线下反扫 1、线下公众号 2、线下小程序 3)。
+ * 只开小程序会让另外两条支付路径在通道侧缺配置,而平台的小程序、H5 公众号与反扫共用同一个子商户号。
*/
@Test
- void theOnlineWeChatScenesAreOpenedTogether() {
+ void theConfiguredWeChatScenesAreOpenedTogether() {
List<Map<String, Object>> wechat = wechatConfig(request(false));
List<String> scenes = new ArrayList<>();
for (Map<String, Object> entry : wechat) scenes.add(String.valueOf(entry.get("pay_scene")));
- assertThat(scenes).containsExactly("4", "5", "12");
+ assertThat(scenes).containsExactly("1", "2", "3");
assertThat(wechat).allSatisfy(entry -> {
assertThat(entry).containsEntry("fee_rate", "0.60");
@@ -215,6 +216,31 @@
}
/**
+ * 默认口径就是**线下**:场景 1/2/3、费率 0.30、业务开通类型 02、CID 企业 758/个体户 762/小微 770、
+ * 进件场景 OFFLINE、小微经营类型门店场所。
+ *
+ * <p>这几条是「口径切换」的回归门:只改一处(例如只改场景号、忘了换 CID 或业务开通类型)会让下发与
+ * 判定脱节——通道明明开通成功,阶段却永远 missing,而它下游的收款闸门会挡住商户收款。
+ */
+ @Test
+ void theDefaultsAreTheOfflineWeChatCapability() {
+ HuifuPaymentProperties defaults = new HuifuPaymentProperties();
+
+ assertThat(defaults.merchantWechatSceneList()).containsExactly("1", "2", "3");
+ assertThat(defaults.getMerchantWechatFeeRate()).isEqualTo("0.30");
+ assertThat(defaults.getMerchantWechatFeeType()).isEqualTo("02");
+ assertThat(defaults.getMerchantWechatFeeRuleId()).isEqualTo("758");
+ assertThat(defaults.getMerchantWechatFeeRuleIdSoleProprietor()).isEqualTo("762");
+ assertThat(defaults.getMerchantWechatFeeRuleIdIndividual()).isEqualTo("770");
+ assertThat(defaults.getMerchantOnboardingSceneType()).isEqualTo("OFFLINE");
+ assertThat(defaults.getMerchantMicroBusinessType()).isEqualTo("MICRO_TYPE_STORE");
+
+ // 线上业务类型编码不再下发:它是「基本信息入驻接口」在 scene_type=ONLINE/ALL 时的字段,
+ // 不属于业务开通(以前错发在这里)。
+ assertThat(request(false)).doesNotContainKey("online_busi_type");
+ }
+
+ /**
* 直接驱动组装逻辑。网关的 {@code open()} 会真的调用通道,所以这里只构建请求体:
* 复用网关内部的组装顺序没有意义,改为断言"网关是否会包含这些字段"的等价物——
* 用同一份 properties 构造,并让网关暴露一个纯组装的包级方法。
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingRequiredFieldsTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingRequiredFieldsTests.java
index 2b51b5f..22370d7 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingRequiredFieldsTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingRequiredFieldsTests.java
@@ -87,6 +87,19 @@
return HuifuIndividualOnboardingGateway.buildRequestForTest(properties(), command);
}
+ /**
+ * 线下口径的两条硬要求:进件的 {@code scene_type=OFFLINE},且 {@code detail_addr} 取**经营地址**、
+ * {@code legal_addr} 取**证件地址**——这两个字段以前共用同一个值,线下报备下会被通道驳回或留下错误信息。
+ */
+ @Test
+ void theOfflineSceneTypeCarriesTheBusinessAddressSeparatelyFromTheCertificateAddress() {
+ Map<String, Object> request = request(command("0", "20120110", "20320110"));
+
+ assertThat(request).containsEntry("scene_type", "OFFLINE");
+ assertThat(request).containsEntry("detail_addr", "四川省广安市广安区兴安中街228号1栋101");
+ assertThat(request).containsEntry("legal_addr", "四川省广安市广安区兴安中街227号3栋1单元6楼2号");
+ }
+
@SuppressWarnings("unchecked")
private static Map<String, Object> cardInfo(Map<String, Object> request) {
return JSON.parseObject(String.valueOf(request.get("card_info")), Map.class);
@@ -97,6 +110,8 @@
"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");
+ "6217003680002760924", "510000", "511600", "01050000", "F13-CARD", "F22-STORE",
+ // 经营详细地址刻意与证件地址(第 8 个参数)用不同字符串:取错成证件地址时断言能发现。
+ "四川省广安市广安区兴安中街228号1栋101");
}
}
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameTests.java
index 6d35370..a8c5d6b 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuMerchantRealnameTests.java
@@ -20,8 +20,8 @@
Map<String,Object> request = request(command("成都市成华区东丽街138号9栋2单元16楼5号",true));
assertThat(request).containsEntry("contact_type","LEGAL");
- // pay_scene 引用的是「微信业务开通类型」,线上是 01,不是业务开通的支付场景 4/5/12。
- assertThat(request).containsEntry("pay_scene","01");
+ // pay_scene 引用的是「微信业务开通类型」,线下是 02、线上是 01,不是业务开通的支付场景 1/2/3。
+ assertThat(request).containsEntry("pay_scene","02");
assertThat(request).containsEntry("name","张维");
assertThat(request).containsEntry("mobile","13900000000");
assertThat(request).containsEntry("id_card_number","513021198109304667");
diff --git a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingMerchantLevelTests.java b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingMerchantLevelTests.java
index 91ed6c5..a860ea7 100644
--- a/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingMerchantLevelTests.java
+++ b/talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingMerchantLevelTests.java
@@ -92,7 +92,8 @@
"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", "F22-STORE");
+ "6217003680002760924", "510000", "511600", "01050000", "F13-CARD", "F22-STORE",
+ "四川省广安市广安区兴安中街227号3栋1单元6楼2号");
}
private static HuifuEnterpriseOnboardingCommand enterpriseCommand() {
--
Gitblit v1.7.1