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; /** * 只读排查探针:这家商户「能不能收款」,本地能力快照说行不行,通道侧又是什么值。 * *

口径与下单闸门同源:最终结论取 {@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} 的规则算出来现在能不能收款。 * *

什么时候用它:下单被拒「商户收款能力待确认 / 取现能力不可用 / 子商户状态异常」,或者后台商户页 * 的收款按钮灰着——先看是本地快照过期,还是通道那边真的被关停了。 * *

它不改任何状态:只 SELECT 与通道查询。特别地,它绝不调用 * {@code refresh} / {@code capability} / {@code refreshQuietly} / {@code merchantVerify} * ——那四个都会把通道结果写回快照({@code update merchant_onboarding_application}),属于写操作; * 要刷新快照请用后台的「立即核实」,那条路径有冷却与并发保护,探针不走它。 * *

参数格式(全部从命令行传,缺参数直接打用法退出,不用任何默认值瞎猜): *

     * --db-url=<JDBC URL> --db-user=<用户> --db-password=<口令>
     * 三者之一:--application-id=<进件ID> | --huifu-id=<斗拱商户号> | --merchant-id=<本地商户ID>
     * [--store-id=<门店ID>]            判定「能不能收款」时按这个门店问;默认用命中进件自己的 store_id
     * [--config=<汇付配置文件路径>]     不给就只查本地,且判定会退化成「跳过通道确认」那一档
     * [--huifu-environment=test|prod]
     * 
*/ 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> 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 row : rows) { localSnapshot(row, jdbc); askProvider(row, properties); verdict(row, readinessService, arg(args, "store-id")); System.out.println(); } } private static void localSnapshot(Map 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> 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 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> 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 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 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 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。"); } } // ---------- 参数、配置与输出 ---------- /** 读汇付配置。不读 application.yml:私钥与密钥只在本探针内部使用,从不打印。 */ 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 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= --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(); } }