yeg
2026-09-18 db704fbed062d5700a37b7858231bf7d0958e38d
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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityGateway;
import com.talentcard.server.business.payment.huifu.HuifuMerchantCapabilityQueryCommand;
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.support.TransactionTemplate;
 
/**
 * 只读排查探针:这家商户「能不能收款」,本地能力快照说行不行,通道侧又是什么值。
 *
 * <p>口径与下单闸门同源:最终结论取 {@link MerchantPaymentReadinessService#readiness},本探针只负责把
 * 它的输入摆出来(本地快照 <-> 通道真值),省得为了看懂一句「商户收款能力待确认」再去翻库。
 */
public class MerchantCapabilityProbe {
 
    /** 公网 IP 只留主机段前两段:地址本身(端口+路径)就是攻击面。 */
    private static final Pattern AUTHORITY_IPV4 = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$");
 
    /**
     * 回答什么问题:给定商户 ID / 进件 ID / 汇付号,本地 {@code merchant_onboarding_application} 上的
     * 能力快照({@code merchant_stat} / {@code auto_settle_status} / {@code cash_switch_status} /
     * {@code cash_type} / {@code capability_verified_at})与通道 {@code v2/merchant/basicdata/query} 的
     * 真值差在哪,以及按 {@code MerchantPaymentReadinessService} 的规则算出来现在能不能收款。
     *
     * <p>什么时候用它:下单被拒「商户收款能力待确认 / 取现能力不可用 / 子商户状态异常」,或者后台商户页
     * 的收款按钮灰着——先看是本地快照过期,还是通道那边真的被关停了。
     *
     * <p>它不改任何状态:只 SELECT 与通道查询。特别地,<b>它绝不调用</b>
     * {@code refresh} / {@code capability} / {@code refreshQuietly} / {@code merchantVerify}
     * ——那四个都会把通道结果写回快照({@code update merchant_onboarding_application}),属于写操作;
     * 要刷新快照请用后台的「立即核实」,那条路径有冷却与并发保护,探针不走它。
     *
     * <p>参数格式(全部从命令行传,缺参数直接打用法退出,不用任何默认值瞎猜):
     * <pre>
     * --db-url=&lt;JDBC URL&gt; --db-user=&lt;用户&gt; --db-password=&lt;口令&gt;
     * 三者之一:--application-id=&lt;进件ID&gt; | --huifu-id=&lt;斗拱商户号&gt; | --merchant-id=&lt;本地商户ID&gt;
     * [--store-id=&lt;门店ID&gt;]            判定「能不能收款」时按这个门店问;默认用命中进件自己的 store_id
     * [--config=&lt;汇付配置文件路径&gt;]     不给就只查本地,且判定会退化成「跳过通道确认」那一档
     * [--huifu-environment=test|prod]
     * </pre>
     */
    public static void main(String[] args) {
        String dbUrl = required(args, "db-url");
        String dbUser = required(args, "db-user");
        String dbPassword = required(args, "db-password");
        String applicationId = arg(args, "application-id");
        String huifuId = arg(args, "huifu-id");
        String merchantId = arg(args, "merchant-id");
        if (blank(applicationId) && blank(huifuId) && blank(merchantId)) {
            System.out.println("必须给一个定位方式:--application-id=<进件ID>、--huifu-id=<斗拱商户号> 或 --merchant-id=<本地商户ID>。");
            usage();
        }
        String configPath = arg(args, "config");
        HuifuPaymentProperties properties = blank(configPath)
                ? new HuifuPaymentProperties() : loadProperties(configPath, arg(args, "huifu-environment"));
        warnIfProduction(blank(configPath), properties);
 
        System.out.println("只读探针:只做 SELECT 与通道的商户信息查询。不发起支付、不关单、不退款、不交易确认、"
                + "不下发业务开通、不刷新能力快照、不改任何数据库行。");
        System.out.println("数据库目标:" + maskHost(dbUrl));
 
        DriverManagerDataSource dataSource = new DriverManagerDataSource(dbUrl, dbUser, dbPassword);
        JdbcTemplate jdbc = new JdbcTemplate(dataSource);
        List<Map<String, Object>> rows = jdbc.queryForList("""
                select id, merchant_id, store_id, application_no, subject_type, subject_name, status,
                       provider_huifu_id, provider_apply_no, initial_bank_card_id,
                       merchant_stat, auto_settle_status, cash_switch_status, cash_type,
                       capability_verified_at, capability_sync_until, capability_response_code,
                       capability_response_message, business_open_status, wechat_config_status, updated_at
                from merchant_onboarding_application
                where (? is not null and id = ?)
                   or (? is not null and provider_huifu_id = ?)
                   or (? is not null and merchant_id = ?)
                order by id desc
                """, number(applicationId), number(applicationId),
                blank(huifuId) ? null : huifuId.trim(), blank(huifuId) ? null : huifuId.trim(),
                number(merchantId), number(merchantId));
        if (rows.isEmpty()) {
            System.out.println();
            System.out.println("本地没有匹配的进件。注意 --application-id / --merchant-id 要传数字,"
                    + "--huifu-id 是通道号(本地列是 provider_huifu_id)。");
            return;
        }
 
        // readiness() 只在本地按规则判定、不问通道;但它构造时要求传入 gateway 与 properties,
        // 因此这里传真实实例,同时绝不调用会写库的 refresh / capability / merchantVerify。
        MerchantPaymentReadinessService readinessService = new MerchantPaymentReadinessService(
                jdbc, new HuifuMerchantCapabilityGateway(properties), properties,
                new TransactionTemplate(new DataSourceTransactionManager(dataSource)));
        for (Map<String, Object> row : rows) {
            localSnapshot(row, jdbc);
            askProvider(row, properties);
            verdict(row, readinessService, arg(args, "store-id"));
            System.out.println();
        }
    }
 
    private static void localSnapshot(Map<String, Object> row, JdbcTemplate jdbc) {
        section("本地能力快照 / merchant_onboarding_application#" + row.get("id"));
        line("application_no        ", text(row.get("application_no")));
        line("merchant_id / store_id", row.get("merchant_id") + " / " + row.get("store_id"));
        line("subject_type / name   ", text(row.get("subject_type")) + " / " + text(row.get("subject_name")));
        line("status                ", text(row.get("status")));
        line("provider_huifu_id     ", maskId(row.get("provider_huifu_id")));
        line("provider_apply_no     ", maskId(row.get("provider_apply_no")));
        line("initial_bank_card_id  ", nullable(row.get("initial_bank_card_id")));
        line("merchant_stat         ", nullable(row.get("merchant_stat")));
        line("auto_settle_status    ", nullable(row.get("auto_settle_status")));
        line("cash_switch_status    ", nullable(row.get("cash_switch_status")));
        line("cash_type             ", nullable(row.get("cash_type")));
        line("capability_verified_at", nullable(row.get("capability_verified_at")));
        line("capability_sync_until ", nullable(row.get("capability_sync_until")));
        line("capability_response   ", nullable(row.get("capability_response_code")) + " " + nullable(row.get("capability_response_message")));
        line("business_open_status  ", nullable(row.get("business_open_status")));
        line("wechat_config_status  ", nullable(row.get("wechat_config_status")));
        line("updated_at            ", nullable(row.get("updated_at")));
        System.out.println("  快照为空(NULL)的含义:这一行**没有**核实过「通道侧现在是什么状态」,"
                + "不等于通道那边有问题,也不等于没问题。");
 
        Long id = longValue(row.get("id"));
        List<Map<String, Object>> cards = jdbc.queryForList("""
                select id, store_id, status, card_tail, provider_token_no is not null as has_token
                from merchant_bank_card where merchant_id = ? order by id desc limit 5
                """, row.get("merchant_id"));
        section("结算银行卡(本地 merchant_bank_card,只印后 4 位;token 只印有无)");
        if (cards.isEmpty()) {
            System.out.println("  这家商户没有任何结算银行卡记录——下单闸门会先拒「结算银行卡尚未绑定成功」。");
        }
        for (Map<String, Object> card : cards) {
            line("  #" + card.get("id"), "store_id=" + card.get("store_id") + " | " + card.get("status")
                    + " | 尾号=" + card.get("card_tail") + " | 有通道 token=" + card.get("has_token"));
        }
 
        List<Map<String, Object>> progress = jdbc.queryForList(
                "select stages_json, last_synced_at, sync_error from merchant_onboarding_progress where application_id = ?", id);
        section("分阶段进度(微信收款权限的证据在 stages_json 的 wechat / realname 两项)");
        if (progress.isEmpty()) {
            System.out.println("  没有进度记录:闸门会把「微信支付业务开通」和「微信实名与授权」都算缺"
                    + "(查不到证据就不能认为能收款)。");
        }
        for (Map<String, Object> stage : progress) {
            line("  stages_json  ", text(stage.get("stages_json")));
            line("  last_synced_at", nullable(stage.get("last_synced_at")));
            line("  sync_error   ", nullable(stage.get("sync_error")));
        }
    }
 
    private static void askProvider(Map<String, Object> row, HuifuPaymentProperties properties) {
        section("通道真值 / v2/merchant/basicdata/query");
        String huifuId = text(row.get("provider_huifu_id"));
        if (huifuId.isBlank()) {
            System.out.println("本地没有 provider_huifu_id,无法问通道:通道查询必须先说明问哪家商户。");
            return;
        }
        if (blank(properties.getSystemId())) {
            System.out.println("未给 --config(或配置里没有 system-id),跳过通道查询。"
                    + "要问通道请附上配置文件(见 scripts/probes/README.md)。");
            return;
        }
        line("huifuId", maskId(huifuId));
        HuifuMerchantCapabilityGateway.CapabilityResult result;
        try {
            result = new HuifuMerchantCapabilityGateway(properties)
                    .query(new HuifuMerchantCapabilityQueryCommand("MC" + System.currentTimeMillis(), huifuId));
        } catch (RuntimeException exception) {
            System.out.println("通道查询失败:" + exception.getClass().getSimpleName() + " " + text(exception.getMessage()));
            System.out.println("读法:通道没给出结论时不要往本地补结论。");
            return;
        }
        line("succeeded             ", String.valueOf(result.succeeded()));
        line("resp_code / desc      ", text(result.code()) + " " + text(result.message()));
        line("mer_stat(local列名 merchant_stat)", text(result.merchantStatus()));
        line("settle_status         ", text(result.settleStatus()));
        line("settle_cycle          ", text(result.settleCycle()));
        line("cash switch_state     ", text(result.cashSwitchStatus()));
        line("cash_type             ", text(result.cashType()));
 
        section("差异");
        boolean found = false;
        found |= diff("merchant_stat", row.get("merchant_stat"), result.merchantStatus(),
                "通道返回值决定「子商户状态异常」这条拦截。NORMAL 之外都要当成被关停处理。");
        found |= diff("auto_settle_status", row.get("auto_settle_status"), result.settleStatus(),
                "这一项**不再是**收款闸门:未核销的钱由延迟户兜住,自动结算开着也不会提前结走。只作参考。");
        found |= diff("cash_switch_status", row.get("cash_switch_status"), result.cashSwitchStatus(),
                "闸门要求取现可用(1 或 Y);这一项为 1/Y 之外会直接拒「商户取现能力不可用」。");
        found |= diff("cash_type", row.get("cash_type"), result.cashType(), "取现类型,随取现状态一起看。");
        if (blank(text(row.get("capability_verified_at")))) {
            found = true;
            System.out.println("* capability_verified_at 为空:本地快照从没核实过。"
                    + "在要求通道确认的部署上,下单闸门会直接拒「商户收款能力待确认」,"
                    + "与通道那边实际是否正常无关——这就是「明明通道正常却收不了款」的常见原因。");
        }
        if (!found) {
            System.out.println("* 本地快照与通道真值一致。");
        }
    }
 
    /** 逐列比本地快照与通道真值;两边都空时不算差异(没核实 ≠ 不一致)。 */
    private static boolean diff(String column, Object localValue, String providerValue, String note) {
        String local = text(localValue);
        String provider = text(providerValue);
        if (provider.isBlank() && local.isBlank()) return false;
        if (local.equalsIgnoreCase(provider)) return false;
        System.out.println("* " + column + ":本地 " + (local.isBlank() ? "(NULL)" : local)
                + " vs 通道 " + (provider.isBlank() ? "(未给)" : provider) + "。 " + note);
        return true;
    }
 
    /** 最终结论直接调用闸门自己的服务,不在这里重写一遍规则。 */
    private static void verdict(Map<String, Object> row, MerchantPaymentReadinessService readinessService, String storeIdOverride) {
        section("按 MerchantPaymentReadinessService 的规则算出来能不能收款");
        long merchantId = longValue(row.get("merchant_id"));
        Long storeId = blank(storeIdOverride) ? longValue(row.get("store_id")) : number(storeIdOverride);
        if (storeId == null || storeId == 0L) {
            storeId = null;
            System.out.println("  该进件没有 store_id(历史数据允许为空),按「该商户最新一条已通过的进件」判定。");
        }
        try {
            MerchantPaymentReadinessService.Readiness readiness = readinessService.readiness(merchantId, storeId);
            System.out.println("  ready  : " + readiness.ready());
            if (readiness.ready()) {
                MerchantPaymentReadinessService.CollectingMerchant merchant = readiness.merchant();
                System.out.println("  收款进件: #" + merchant.applicationId() + " store_id=" + merchant.storeId()
                        + " huifuId=" + maskId(merchant.huifuId()));
                System.out.println("  订单会把这组值固定下来(collect_onboarding_id / collect_store_id / collect_huifu_id)。");
            } else {
                System.out.println("  原因   : " + readiness.reason());
                System.out.println("  这句文案与用户下单时被拒的文案同源:看到它就等于看到用户看到的那句。");
            }
            System.out.println("  通道侧该进件是否正常(channelCapabilityNormal): "
                    + readinessService.channelCapabilityNormal(longValue(row.get("id"))));
            System.out.println("  两者读法不同:channelCapabilityNormal 只回答「通道那边开通正常吗」,"
                    + "ready 才回答「现在能不能收款」(它还要微信/实名与绑卡前置)。");
        } catch (RuntimeException exception) {
            System.out.println("  判定失败:" + exception.getClass().getSimpleName() + " " + text(exception.getMessage()));
            System.out.println("  排查读法:本地库缺列(开发库结构不由迁移维护)会让这里的查询直接报错,"
                    + "见 docs/agents/testing.md。");
        }
    }
 
    // ---------- 参数、配置与输出 ----------
 
    /** 读汇付配置。<b>不读 application.yml</b>:私钥与密钥只在本探针内部使用,从不打印。 */
    private static HuifuPaymentProperties loadProperties(String path, String environmentOverride) {
        Properties source = new Properties();
        try (Reader reader = Files.newBufferedReader(Path.of(path), StandardCharsets.UTF_8)) {
            source.load(reader);
        } catch (IOException exception) {
            System.out.println("读不了配置文件 " + path + ":" + exception.getMessage());
            usage();
        }
        HuifuPaymentProperties properties = new HuifuPaymentProperties();
        String enabled = value(source, "enabled");
        properties.setEnabled(enabled.isBlank() || Boolean.parseBoolean(enabled));
        properties.setEnvironment(firstNonBlank(environmentOverride, firstNonBlank(value(source, "environment"), "test")));
        apply(value(source, "system-id"), properties::setSystemId);
        apply(value(source, "product-id"), properties::setProductId);
        apply(value(source, "merchant-id"), properties::setMerchantId);
        apply(value(source, "mini-program-app-id"), properties::setMiniProgramAppId);
        apply(value(source, "private-key"), properties::setPrivateKey);
        apply(value(source, "platform-public-key"), properties::setPlatformPublicKey);
        apply(value(source, "notify-url"), properties::setNotifyUrl);
        // 这一项决定闸门要不要「快照为空就拒」。配置文件里没写时它会回落到 enabled,
        // 所以只要 enabled=true 就与线上默认(true)一致。
        String capabilityRequired = value(source, "merchant-capability-required");
        if (!capabilityRequired.isBlank()) properties.setMerchantCapabilityRequired(Boolean.parseBoolean(capabilityRequired));
        return properties;
    }
 
    /** 键名兼容 {@code system-id} 与 {@code app.payment.huifu.system-id} 两种写法(后者照抄部署配置)。 */
    private static String value(Properties source, String key) {
        return firstNonBlank(source.getProperty("app.payment.huifu." + key), source.getProperty(key));
    }
 
    private static String firstNonBlank(String first, String second) {
        if (first != null && !first.isBlank()) return first.trim();
        return second == null ? "" : second.trim();
    }
 
    private static void apply(String value, java.util.function.Consumer<String> setter) {
        if (value != null && !value.isBlank()) setter.accept(value.trim());
    }
 
    private static void warnIfProduction(boolean withoutConfig, HuifuPaymentProperties properties) {
        if (withoutConfig) {
            System.out.println("! 未提供 --config:本地快照照常打印,但通道真值只能是空白,且「能不能收款」的判定会"
                    + "退化成「跳过通道确认」那一档(merchant_capability_required=false),与线上默认**不一致**。"
                    + "要线上口径的答案请附上配置文件。");
            return;
        }
        if (!"prod".equalsIgnoreCase(properties.getEnvironment())) return;
        System.out.println("!! ================================================================================= !!");
        System.out.println("!! app.payment.huifu.environment = prod:目标是生产环境。                          !!");
        System.out.println("!! 本探针只读(只 SELECT + 通道查询),不刷新快照、不下发业务开通、不改库。            !!");
        System.out.println("!! 但查询会留在通道的调用记录里,排查请按生产操作对待。                            !!");
        System.out.println("!! ================================================================================= !!");
    }
 
    /**
     * 商户号 / 汇付号只留前 6 位。通道号是 16 位数字,整串贴出来等于把可枚举的账号交出去;
     * 前 6 位足够看出几条记录是不是同一个商户。
     */
    private static String maskId(Object value) {
        String text = text(value);
        if (text.isBlank()) return "(空)";
        return text.length() <= 6 ? "***" : text.substring(0, 6) + "***";
    }
 
    /** 只留主机与路径形状:IPv4 留前两段 + x.x,域名原样(它不是 IP),端口与路径照常。 */
    private static String maskHost(String value) {
        if (value == null || value.isBlank()) return "(空)";
        int marker = value.indexOf("//");
        if (marker < 0) return value.trim();
        String prefix = value.substring(0, marker + 2);
        String rest = value.substring(marker + 2);
        int end = rest.length();
        for (int index = 0; index < rest.length(); index++) {
            char current = rest.charAt(index);
            if (current == '/' || current == '?' || current == ';') { end = index; break; }
        }
        String authority = rest.substring(0, end);
        String tail = rest.substring(end);
        Matcher ipv4 = AUTHORITY_IPV4.matcher(authority);
        if (ipv4.matches()) {
            return prefix + ipv4.group(1) + "." + ipv4.group(2) + ".x.x" + (ipv4.group(3) == null ? "" : ipv4.group(3)) + tail;
        }
        return prefix + authority + tail;
    }
 
    /** 命令行上给的 ID 必须是数字:拿它去比 bigint 列,传错类型只会得到「查不到」,很难看出是自己传错了。 */
    private static Long number(String value) {
        if (value == null || value.isBlank()) return null;
        try {
            return Long.valueOf(value.trim());
        } catch (NumberFormatException exception) {
            System.out.println("参数 --...=" + value + " 不是数字 ID。");
            usage();
            return null;
        }
    }
 
    private static Long longValue(Object value) {
        return value instanceof Number number ? number.longValue() : null;
    }
 
    private static String arg(String[] args, String name) {
        for (int index = 0; index < args.length; index++) {
            String current = args[index];
            if (current.equals("--" + name)) return index + 1 < args.length ? args[index + 1] : null;
            if (current.startsWith("--" + name + "=")) return current.substring(name.length() + 3);
        }
        return null;
    }
 
    private static String required(String[] args, String name) {
        String value = arg(args, name);
        if (value == null || value.isBlank()) {
            System.out.println("缺少必填参数 --" + name);
            usage();
        }
        return value.trim();
    }
 
    private static void usage() {
        System.out.println("用法:MerchantCapabilityProbe --db-url=<JDBC URL> --db-user=<用户> --db-password=<口令>"
                + " (--application-id=<进件ID> | --huifu-id=<斗拱商户号> | --merchant-id=<本地商户ID>)"
                + " [--store-id=<门店ID>] [--config=<汇付配置文件>] [--huifu-environment=test|prod]");
        System.exit(2);
    }
 
    private static void section(String title) {
        System.out.println();
        System.out.println("== " + title + " ==");
    }
 
    private static void line(String key, String value) {
        System.out.println("  " + key + " : " + value);
    }
 
    private static String text(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }
 
    private static String nullable(Object value) {
        String text = text(value);
        return text.isBlank() ? "(NULL)" : text;
    }
 
    private static boolean blank(String value) {
        return value == null || value.isBlank();
    }
}