1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
package com.talentcard.server.business.payment.huifu;
 
import static org.assertj.core.api.Assertions.assertThat;
 
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
 
import com.alibaba.fastjson.JSON;
import org.junit.jupiter.api.Test;
 
/**
 * 商户业务开通请求里那两个"文档要求、我们原来没发"的字段。
 *
 * <p>以汇付《商户业务开通》(v2.merchant.busi.open) 的请求规则为准逐项核对后发现两处缺口:
 *
 * <ul>
 *   <li>{@code media_mer_common_flag}({@code online_media_info_list} 的元素字段):运营媒介主体与商户主体
 *       是否一致。平台用一个运营媒介给众多子商户开通,主体天然不一致,取 N。它属于媒介**元素**而不是顶层参数,
 *       放顶层会被拒:「【media_merCommon_flag-媒介主体与商户主体是否一致】不能为空,或格式非法」。</li>
 *   <li>{@code micro_biz_type}(wx_conf_list 元素内的 wx_real_name_info):个人商户开通微信业务必填,
 *       且三种取值都要求 F22 店铺门头照 + F24 店铺内景照。</li>
 * </ul>
 *
 * <p>层级依据:文档里 {@code wx_real_name_info} 既是父字段列、又有同名子表,按本接口的写法约定
 * 它是嵌套 jsonObject(同样的写法可见 {@code online_media_info_list}),所以 micro_biz_type
 * 必须放进 wx_conf_list 的元素里,不能平铺到顶层。
 */
class HuifuBusinessOpenRequiredFieldsTests {
 
    private static HuifuPaymentProperties properties() {
        HuifuPaymentProperties properties = new HuifuPaymentProperties();
        properties.setEnabled(true);
        properties.setSystemId("6666000226048687");
        properties.setProductId("KAZX");
        properties.setPrivateKey("x");
        properties.setPlatformPublicKey("y");
        properties.setMerchantWechatFeeRate("0.60");
        properties.setMerchantOnlineBusinessType("H5311L");
        properties.setMerchantOnlineMediaName("东区人才卡");
        properties.setMerchantAgreementUrl("https://example.com/agreement.htm");
        properties.setOnboardingUpperHuifuId("6666000220591054");
        return properties;
    }
 
    @Test
    void theMediaSubjectFlagIsInsideTheMediaElement() {
        Map<String, Object> request = request(false);
 
        // 平台媒介主体与商户主体不一致,取 N。它是 online_media_info_list 的**元素字段**,
        // 不是顶层参数——放顶层会被通道判「不能为空,或格式非法」。
        assertThat(request).doesNotContainKey("media_mer_common_flag");
 
        Map<String, Object> media = onlineMedia(request);
        assertThat(media).containsEntry("media_mer_common_flag", "N");
    }
 
    /**
     * 运营媒介只在开通快捷/网银/余额支付Pro/分账/大额转账/代扣时才需要,本平台只开线上微信,
     * 默认不下发;不下发媒介,通道也就不会再索要「授权或开发证明材料」。
     */
    @Test
    void aBlankMediaNameKeepsTheMediaDeclarationOutOfTheRequest() {
        HuifuPaymentProperties properties = properties();
        properties.setMerchantOnlineMediaName(" ");
 
        Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
                properties, "BO-TEST-1", "6666000241390821", "阳野", "5812", false);
 
        assertThat(request).doesNotContainKey("online_media_info_list");
    }
 
    @Test
    void anIndividualMerchantCarriesTheMicroBusinessTypeInsideTheWeChatConfig() {
        Map<String, Object> request = request(true);
 
        List<Map<String, Object>> wechat = wechatConfig(request);
        assertThat(wechat).hasSize(3);
        // 个人主体属于小微商户,mcc 也是必填;三个线上场景都要带。
        assertThat(wechat).allSatisfy(entry -> assertThat(entry).containsKeys("mcc", "wx_real_name_info"));
 
        // 层级与形态:micro_biz_type 在 wx_real_name_info 这个**嵌套 jsonObject** 里,不在元素顶层,
        // 也不能是 JSON 字符串。官方字段目录里该字段类型是 `Object`(对照 wx_conf_list[] 本身才是
        // `String` 的 jsonArray字符串)——双重编码发出去通道会回「wx_conf_list格式错误」。
        assertThat(wechat.get(0)).doesNotContainKey("micro_biz_type");
        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");
    }
 
    @Test
    void anEnterpriseMerchantDoesNotCarryTheMicroBusinessType() {
        // 文档写明「个人商户需要填写」,企业主体不需要,不要硬塞。
        Map<String, Object> request = request(false);
 
        List<Map<String, Object>> wechat = wechatConfig(request);
        assertThat(wechat).allSatisfy(entry -> assertThat(entry).doesNotContainKey("wx_real_name_info"));
    }
 
    /**
     * 个人(小微)商户填了 {@code micro_biz_type},就欠这两张门店照片:官方在 micro_biz_type
     * 的说明里写明「需要提供 F22:店铺门头照图片;F24:店铺内景图片,补充资料填写在
     * extended_material_list 对象中」。原先只有 micro_biz_type、没有材料,开通是缺材料提交。
     */
    @Test
    void anIndividualMerchantCarriesTheStorePhotosAsExtendedMaterial() {
        Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
                properties(), "BO-TEST-MATERIAL", "6666000241390821", "阳野", "5812", true, true, "file-f22", "file-f24");
 
        List<Map<String, Object>> materials =
                JSON.parseObject(String.valueOf(request.get("extended_material_list")), List.class);
        assertThat(materials).hasSize(2);
        assertThat(materials.get(0)).containsEntry("file_type", "F22").containsEntry("file_id", "file-f22");
        assertThat(materials.get(1)).containsEntry("file_type", "F24").containsEntry("file_id", "file-f24");
    }
 
    /** 门店照片缺一张就只发一张:宁可让通道明确报缺哪一项,也不要塞占位图顶上。 */
    @Test
    void aMissingStorePhotoIsNotReplacedByAPlaceholder() {
        Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
                properties(), "BO-TEST-MATERIAL-1", "6666000241390821", "阳野", "5812", true, true, "file-f22", "");
 
        List<Map<String, Object>> materials =
                JSON.parseObject(String.valueOf(request.get("extended_material_list")), List.class);
        assertThat(materials).hasSize(1);
        assertThat(materials.get(0)).containsEntry("file_type", "F22");
    }
 
    /** 企业主体没有这项材料要求,不要凭空给企业商户加附件。 */
    @Test
    void anEnterpriseMerchantCarriesNoExtendedMaterial() {
        Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
                properties(), "BO-TEST-MATERIAL-2", "6666000241390821", "成都喜望科技", "5812", false, false, "file-f22", "file-f24");
 
        assertThat(request).doesNotContainKey("extended_material_list");
    }
 
    /**
     * 个体工商户走企业进件接口,所以不是「个人主体」,但官方把「个体工商户、小微商户」并列为
     * {@code mcc} 必填的一类。配了 {@code fee_rule_id} 也不能把 mcc 省掉——官方请求示例里
     * {@code mcc} 与 {@code fee_rule_id} 本来就出现在同一个元素里。
     *
     * <p>原先这条判断写在 {@code fee_rule_id} 的 else 分支里,平台配了规则号 756 之后
     * 小微/个体工商户的 mcc 永远发不出去,业务开通被通道回 {@code wx_conf_list格式错误}。
     *
     * <p>费率规则号也要换成该主体类型的那一个:同表「线上、类型 01」下个体工商户是 761。
     */
    @Test
    void anIndividualBusinessCarriesTheMccAndItsOwnFeeRule() {
        HuifuPaymentProperties properties = properties();
        properties.setMerchantWechatFeeRuleId("756");
 
        List<Map<String, Object>> wechat = wechatConfig(HuifuMerchantBusinessOpenGateway.buildRequestForTest(
                properties, "BO-TEST-INDV", "6666000241390821", "阳野餐饮店", "5812", false, true, "", ""));
 
        assertThat(wechat).hasSize(3);
        assertThat(wechat).allSatisfy(entry -> {
            assertThat(entry).containsEntry("fee_rule_id", "761");
            assertThat(entry).containsEntry("mcc", "5812");
        });
    }
 
    /**
     * 个人(小微)主体的费率规则号必须换成小微那一行。官方的《微信费率规则ID》对照表按
     * 「支付场景 × 业务开通类型 × 主体类型」定位 CID:线上(场景 4/5、类型 01)下企业是 756、
     * 小微是 775。拿企业的 756 发给小微,通道回的是「微信费率规则号填写不正确,请检查!」——
     * 2026-09-16 真实个人商户实测(该次报文的格式已经正确,纯粹是号选错)。
     */
    @Test
    void aMicroMerchantUsesTheMicroFeeRuleNotTheEnterpriseOne() {
        HuifuPaymentProperties properties = properties();
        properties.setMerchantWechatFeeRuleId("756");
 
        List<Map<String, Object>> wechat = wechatConfig(HuifuMerchantBusinessOpenGateway.buildRequestForTest(
                properties, "BO-TEST-MICRO", "6666000241804122", "阳野", "5812", true));
 
        assertThat(wechat).allSatisfy(entry ->
                assertThat(entry).containsEntry("fee_rule_id", "775").containsEntry("mcc", "5812"));
    }
 
    @Test
    void anEnterpriseCanUseAProviderConfirmedFeeRuleInsteadOfMcc() {
        HuifuPaymentProperties properties = properties();
        properties.setMerchantWechatFeeRuleId("756");
 
        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).doesNotContainKey("mcc");
        });
    }
 
    /**
     * 线上公众号、小程序、反扫三个场景一起开。只开小程序会让另外两条支付路径在通道侧缺配置,
     * 而平台的小程序、H5 公众号与反扫共用同一个子商户号。
     */
    @Test
    void theOnlineWeChatScenesAreOpenedTogether() {
        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(wechat).allSatisfy(entry -> {
            assertThat(entry).containsEntry("fee_rate", "0.60");
            // 最低收取手续费可选、文档写明「大于 0」:留空让通道按不设最低收费处理,
            // 详情查询读回 fee_min_amt=0.00(参考商户线上三场景实测即 0.00),不要主动送 0。
            assertThat(entry).doesNotContainKey("fee_min_amt");
            // 不填子渠道号即入驻到渠道默认配置的微信渠道号下。
            assertThat(entry).doesNotContainKey("pay_channel_id");
        });
    }
 
    /**
     * 直接驱动组装逻辑。网关的 {@code open()} 会真的调用通道,所以这里只构建请求体:
     * 复用网关内部的组装顺序没有意义,改为断言"网关是否会包含这些字段"的等价物——
     * 用同一份 properties 构造,并让网关暴露一个纯组装的包级方法。
     */
    private Map<String, Object> request(boolean individualMerchant) {
        return HuifuMerchantBusinessOpenGateway.buildRequestForTest(
                properties(), "BO-TEST-1", "6666000241390821", "阳野", "5812", individualMerchant);
    }
 
    @SuppressWarnings("unchecked")
    private List<Map<String, Object>> wechatConfig(Map<String, Object> request) {
        return JSON.parseObject(String.valueOf(request.get("wx_conf_list")), List.class);
    }
 
    @SuppressWarnings("unchecked")
    private Map<String, Object> onlineMedia(Map<String, Object> request) {
        List<Map<String, Object>> media =
                JSON.parseObject(String.valueOf(request.get("online_media_info_list")), List.class);
        assertThat(media).hasSize(1);
        return media.get(0);
    }
}