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=<JDBC URL> --db-user=<用户> --db-password=<口令>
|
* 三者之一:--application-id=<进件ID> | --huifu-id=<斗拱商户号> | --merchant-id=<本地商户ID>
|
* [--store-id=<门店ID>] 判定「能不能收款」时按这个门店问;默认用命中进件自己的 store_id
|
* [--config=<汇付配置文件路径>] 不给就只查本地,且判定会退化成「跳过通道确认」那一档
|
* [--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();
|
}
|
}
|