import com.alibaba.fastjson.JSON; import com.talentcard.server.business.payment.huifu.HuifuMiniProgramPaymentGateway; import com.talentcard.server.business.payment.huifu.HuifuPayload; import com.talentcard.server.business.payment.huifu.HuifuPayload.TransStatus; import com.talentcard.server.business.payment.huifu.HuifuPaymentCloseQueryCommand; import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties; import com.talentcard.server.business.payment.huifu.HuifuPaymentQueryCommand; 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.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DriverManagerDataSource; /** * 只读排查探针:一笔支付在本地库里是什么结论,在通道侧又是什么结论,两者差在哪。 * *

通道侧的支付状态是权威事实,本地状态只是它的副本;两者不一致时,先看清差在哪里,再决定要不要让 * 对账去收敛它(后台订单列表的「立即对账」跑的是同一套真实逻辑)。 */ public class PaymentStateProbe { /** 公网 IP 只留主机段前两段:回调/查询地址是公开可达的,整串贴出来等于把入口交出去。 */ private static final Pattern AUTHORITY_IPV4 = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$"); private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); /** * 关单在通道侧是异步的,结论要另问一次关单查询(汇付文档:关单处理中应继续调用关单查询)。 * 只有这两个本地状态值得问:其余状态下本地已经有结论,问了也多不出信息。 */ private static final Set CLOSE_QUERY_STATUSES = Set.of("CLOSING", "CANCEL_UNCONFIRMED"); /** 通道「交易不存在」:它不认识这个请求流水号,也就不可能为它收过钱。 */ private static final String TRADE_MISSING = "23000001"; /** * 回答什么问题:给定订单号或支付流水号,本地 {@code payment_transaction} 是什么状态,通道侧这笔 * 交易现在是什么状态,两者差在哪(含金额、收款子商户、关单结论)。 * *

什么时候用它:用户说「钱扣了订单没变」「取消了还能付」「一直显示支付处理中」,而回调审计表 * ({@code payment_callback_event})看不出结论时——它把本地与通道摆在一起,差异一眼可见。 * *

它不改任何状态:只做 SELECT,通道侧只调查单与关单查询,不发起支付、不关单、不退款、不交易确认、 * 不下发业务开通、不写库。查询本身会落在通道的调用记录里,是唯一的「痕迹」。 * *

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

     * --db-url=jdbc:mysql://<host>:<port>/<db>?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false
     * --db-user=<用户> --db-password=<口令>
     * --order-no=<订单号>          与 --payment-no 二选一
     * --payment-no=<支付流水号>
     * [--config=<汇付配置文件路径>]  不给就只查本地、不问通道
     * [--huifu-environment=test|prod] 覆盖配置文件里的 environment
     * 
*/ 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 orderNo = arg(args, "order-no"); String paymentNo = arg(args, "payment-no"); if (blank(orderNo) == blank(paymentNo)) { System.out.println("必须且只能给一个:--order-no=<订单号> 或 --payment-no=<支付流水号>。"); usage(); } String configPath = arg(args, "config"); HuifuPaymentProperties properties = blank(configPath) ? null : loadProperties(configPath, arg(args, "huifu-environment")); warnIfProduction(properties, "app.payment.huifu.environment"); System.out.println("只读探针:只做 SELECT 与通道的查询类接口(查单 / 关单查询)。不发起支付、不关单、不退款、" + "不交易确认、不下发业务开通、不改任何数据库行。"); System.out.println("数据库目标:" + maskHost(dbUrl)); if (!dbUrl.toLowerCase(Locale.ROOT).contains("servertimezone")) { System.out.println("! 提示:--db-url 上没有 serverTimezone。org_req_date 由 created_at 推出来," + "时区解释不同会整体偏一天,通道那边只会回「交易不存在」——那会看起来像「钱丢了」。"); } JdbcTemplate jdbc = connect(dbUrl, dbUser, dbPassword); String key = !blank(orderNo) ? orderNo.trim() : paymentNo.trim(); List> rows = jdbc.queryForList(""" select id, payment_no, order_no, order_id, merchant_id, amount, status, funds_confirmation_status, idempotency_key, attempt_no, provider_transaction_no, provider_huifu_id, close_attempts, last_reconciled_at, provider_response_code, provider_response_message, created_at, paid_at, closed_at from payment_transaction where payment_no = ? or order_no = ? order by id asc """, key, key); if (rows.isEmpty()) { System.out.println(); System.out.println("本地没有匹配的支付流水:payment_no 或 order_no = " + key); System.out.println("如果确认订单号没错,先查订单本身(OrderFundBoundaryProbe),再看是不是根本没有支付尝试。"); return; } for (Map row : rows) { localState(row, key, jdbc); askProvider(row, properties); System.out.println(); } siblingAttempts(jdbc, rows); } /** 打印一行流水的本地口径。 */ private static void localState(Map row, String key, JdbcTemplate jdbc) { String paymentNo = text(row.get("payment_no")); section("本地状态 / payment_transaction#" + row.get("id") + "(" + (key.equals(paymentNo) ? "按支付流水号匹配" : "按订单号匹配") + ")"); line("payment_no ", paymentNo); line("order_no ", text(row.get("order_no"))); line("status ", text(row.get("status"))); line("funds_confirmation_status", nullable(row.get("funds_confirmation_status"))); line("amount ", text(row.get("amount")) + " 分"); line("idempotency_key ", text(row.get("idempotency_key"))); line("attempt_no ", text(row.get("attempt_no"))); line("provider_transaction_no", nullable(row.get("provider_transaction_no"))); line("provider_huifu_id ", maskId(row.get("provider_huifu_id"))); line("close_attempts ", text(row.get("close_attempts"))); line("last_reconciled_at ", nullable(row.get("last_reconciled_at"))); line("created_at ", nullable(row.get("created_at"))); line("paid_at / closed_at ", nullable(row.get("paid_at")) + " / " + nullable(row.get("closed_at"))); line("provider_response ", nullable(row.get("provider_response_code")) + " " + nullable(row.get("provider_response_message"))); // 没有 funds_revert_status 这一列:回退期间 funds_confirmation_status 用 'reverting' 占位(V147), // 回退的细节留在 funds_revert_* 上。所以「回退到哪一步了」要看上面那个状态 + 下面这些证据列。 System.out.println(); System.out.println(" 资金确认/回退的证据列(funds_confirmation_status = reverting 表示回退在途," + "没有独立的回退状态列):"); Map funds = fundsEvidence(jdbc, row); if (funds == null) { System.out.println(" 本库缺少 funds_* 证据列,跳过(开发库结构不由迁移维护,见 docs/agents/testing.md)。"); } else { for (Map.Entry entry : funds.entrySet()) { line(" " + entry.getKey(), nullable(entry.getValue())); } } if ("SUCCEEDED".equals(text(row.get("status"))) && row.get("funds_confirmation_status") == null) { System.out.println(" 说明:funds_confirmation_status 为 NULL 表示这笔支付没走延迟入账(支付即核销的到店消费)," + "钱不需要确认。"); } } /** * 资金确认与回退的现场证据,单独一条查询取。 * *

不并进主查询:这些列是 V139 / V147 加上去的,而开发库的结构不由迁移维护、缺列只能人工补, * 并进去会让「缺一列」变成「整条主结论都打不出来」。缺列时这里返回 null,主结论照常。 */ private static Map fundsEvidence(JdbcTemplate jdbc, Map row) { try { return new LinkedHashMap<>(jdbc.queryForMap(""" select funds_confirm_no, funds_confirm_request_date, funds_confirm_code, funds_confirm_message, funds_confirmed_amount, funds_confirmed_at, funds_revert_no, funds_revert_request_date, funds_revert_code, funds_revert_message, funds_reverted_amount, funds_reverted_at from payment_transaction where id = ? """, row.get("id"))); } catch (org.springframework.dao.DataAccessException exception) { return null; } } /** 问通道,并把差异逐条说出来。 */ private static void askProvider(Map row, HuifuPaymentProperties properties) { section("通道真实状态"); if (properties == null) { System.out.println("未给 --config,跳过通道查询。要问通道请附上配置文件(见 scripts/probes/README.md)。"); return; } String paymentNo = text(row.get("payment_no")); String providerTransactionNo = text(row.get("provider_transaction_no")); String requestDate = requestDate(row.get("created_at")); String huifuId = text(row.get("provider_huifu_id")); if (blank(huifuId)) { // V131 之前的支付由平台商户收款,本地没记收款子商户。这里按 HuifuRefundService.refundHuifuId // 的同一条回退取平台商户号去问,否则这些历史流水永远问不出结论。 huifuId = text(properties.getMerchantId()); System.out.println("本地 provider_huifu_id 为空(V131 之前的支付,由平台商户收款)," + "按 HuifuRefundService 的同一条回退用配置里的平台商户号去问。"); } if (blank(huifuId)) { System.out.println("连平台商户号也没有(配置里 merchant-id 为空),无法问通道:通道查询必须先说明问哪家商户。"); return; } line("发起请求 ", "paymentNo=" + paymentNo + " orgReqDate=" + requestDate + " orgHfSeqId=" + (blank(providerTransactionNo) ? "(无)" : providerTransactionNo) + " huifuId=" + maskId(huifuId)); HuifuMiniProgramPaymentGateway gateway = new HuifuMiniProgramPaymentGateway(properties); Map response; try { response = gateway.queryMiniProgramPayment( new HuifuPaymentQueryCommand(paymentNo, huifuId, providerTransactionNo, requestDate)); } catch (RuntimeException exception) { System.out.println("通道查询失败:" + exception.getClass().getSimpleName() + " " + text(exception.getMessage())); System.out.println("读法:通道没给出结论时不要往本地补结论——这是「不知道」,不是「没收钱」。"); return; } Map data = HuifuPayload.businessData(response); String code = HuifuPayload.code(data); String message = HuifuPayload.message(data); String transStatus = HuifuPayload.value(data, "trans_stat", "transStat"); // 支付查询回执里通道全局流水号是 org_hf_seq_id;hf_seq_id 只出现在异步通知里。 String providerNo = HuifuPayload.value(data, "org_hf_seq_id", "orgHfSeqId", "hf_seq_id", "hfSeqId"); Long providerAmount = HuifuPayload.amountInCent(HuifuPayload.value(data, "trans_amt", "transAmt")); String providerHuifuId = HuifuPayload.value(data, "huifu_id", "huifuId"); TransStatus status = HuifuPayload.status(transStatus); boolean paid = HuifuPayload.paid(transStatus, code); line("resp_code / desc", code + " " + message); line("trans_stat ", transStatus + "(" + status + ")"); line("org_hf_seq_id ", blank(providerNo) ? "(未给)" : providerNo); line("huifu_id ", maskId(providerHuifuId)); line("trans_amt ", providerAmount == null ? "(未给)" : providerAmount + " 分"); System.out.println("原始回执(已脱敏,openid / 私钥类字段一律不打印):" + sanitized(response)); section("差异"); String local = text(row.get("status")); long localAmount = row.get("amount") instanceof Number number ? number.longValue() : -1L; boolean found = false; if (paid && !"SUCCEEDED".equals(local)) { found = true; System.out.println("* 通道:已收款(" + code + ")。本地:" + local + "。钱在通道,本地没落成已支付——" + "回调丢了或没被处理。下一步:后台订单列表点「立即对账」,它跑的是真实对账逻辑;" + "同时看 payment_callback_event 里这笔的 process_status。"); } if (!found && "SUCCEEDED".equals(local) && !paid && status != TransStatus.UNKNOWN) { found = true; System.out.println("* 本地:已支付。通道:" + (status == TransStatus.FAILED ? "失败/已关闭" : "处理中") + "(" + code + ")。" + "本地认了钱、通道说没有,先怀疑查错了商户或查错了日期(huifuId 与 orgReqDate)。"); } if (providerAmount != null && localAmount >= 0 && providerAmount != localAmount) { found = true; System.out.println("* 金额不一致:本地 " + localAmount + " 分,通道 " + providerAmount + " 分。" + "本地结算按本地金额走,这种差异必须有结论(对账会把金额不符记成 PAYMENT_AMOUNT_MISMATCH)。"); } if (!blank(providerHuifuId) && !blank(text(row.get("provider_huifu_id"))) && !providerHuifuId.equals(text(row.get("provider_huifu_id")))) { found = true; System.out.println("* 收款子商户漂移:本地记 " + maskId(row.get("provider_huifu_id")) + ",通道说是 " + maskId(providerHuifuId) + "。退款要按实际收款的商户发起," + "这种单子必须先查清是哪个商户收的钱。"); } if (TRADE_MISSING.equals(code)) { found = true; System.out.println("* 通道不认识这个请求流水号(" + TRADE_MISSING + "):它不可能为这笔收过钱。" + "若本地还是 PREPAY_CREATED,这笔凭证在通道侧等于不存在,可以直接按未支付处理。"); } if (status == TransStatus.PENDING || status == TransStatus.UNKNOWN) { found = true; System.out.println("* 通道还没有结论(" + transStatus + "):只能等或让对账继续问," + "不要在这种状态下把本地改成已支付或已关闭。"); } if (!found) { System.out.println("* 本地与通道一致:" + local + " <-> trans_stat=" + transStatus + "。"); } if (CLOSE_QUERY_STATUSES.contains(local)) { askCloseQuery(gateway, row, huifuId, providerTransactionNo, requestDate); } } /** 本地是 CLOSING / CANCEL_UNCONFIRMED 时,关单结论只能问关单查询。 */ private static void askCloseQuery(HuifuMiniProgramPaymentGateway gateway, Map row, String huifuId, String providerTransactionNo, String requestDate) { section("关单查询(本地状态 " + text(row.get("status")) + ",说明关单还在等结论)"); Map response; try { response = gateway.closeQueryMiniProgramPayment(new HuifuPaymentCloseQueryCommand( "PQ" + System.currentTimeMillis(), text(row.get("payment_no")), huifuId, providerTransactionNo, requestDate)); } catch (RuntimeException exception) { System.out.println("关单查询失败:" + exception.getClass().getSimpleName() + " " + text(exception.getMessage())); return; } Map data = HuifuPayload.businessData(response); String code = HuifuPayload.code(data); String transStatus = HuifuPayload.value(data, "trans_stat", "transStat"); String originalStatus = HuifuPayload.value(data, "org_trans_stat", "orgTransStat"); line("resp_code / desc ", code + " " + HuifuPayload.message(data)); line("trans_stat ", transStatus); line("org_trans_stat ", originalStatus); if ("S".equalsIgnoreCase(transStatus) && HuifuPayload.successCode(code)) { System.out.println("读法:关单已成功,本地可以落成 CLOSED(对账下一轮会这么做)。"); } else if ("S".equalsIgnoreCase(originalStatus)) { System.out.println("读法:原交易已经成功——钱可能已经收下。此时不能当「未支付」处理," + "先按上面那次查单确认金额。"); } else if ("P".equalsIgnoreCase(transStatus) || "00000100".equals(code)) { System.out.println("读法:关单还在处理中。这是个可等待的中间态,不是终点。"); } else { System.out.println("读法:关单被拒。未满一分钟不允许关单、原交易不支持关单都会走到这里;" + "代码 23000000 / 10000016 表示原交易已终结(那张预支付单不可能再被支付)。"); } } /** 同一订单下的其它支付尝试:一个订单可能被重复发起支付,「钱收了两笔」这个问题只能这样看出来。 */ private static void siblingAttempts(JdbcTemplate jdbc, List> rows) { String orderNo = text(rows.get(0).get("order_no")); if (blank(orderNo)) return; List> siblings = jdbc.queryForList(""" select id, payment_no, status, amount, idempotency_key, attempt_no, created_at from payment_transaction where order_no = ? order by id asc """, orderNo); if (siblings.size() <= 1) return; section("同订单的全部支付尝试(order_no=" + orderNo + ",共 " + siblings.size() + " 条)"); System.out.println(" 幂等键不同就会各自在通道建一张预支付单——重复扣款要看的就是这里。"); for (Map sibling : siblings) { line(" " + sibling.get("id"), sibling.get("payment_no") + " | " + sibling.get("status") + " | " + sibling.get("amount") + " 分 | key=" + sibling.get("idempotency_key") + " | attempt_no=" + sibling.get("attempt_no") + " | " + sibling.get("created_at")); } } // ---------- 参数、配置与输出 ---------- /** 读汇付配置。不读 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(); // 网关的 ensureConfigured 要求 enabled=true,否则查询会被自己拦住。配置没写这个键时按 true 处理, // 免得「探针被自己的开关拦住」变成一个查不出原因的空输出。 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); apply(value(source, "merchant-business-notify-url"), properties::setMerchantBusinessNotifyUrl); apply(value(source, "merchant-onboarding-notify-url"), properties::setMerchantOnboardingNotifyUrl); apply(value(source, "merchant-wechat-config-notify-url"), properties::setMerchantWechatConfigNotifyUrl); apply(value(source, "merchant-business-open-notify-url"), properties::setMerchantBusinessOpenNotifyUrl); 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(HuifuPaymentProperties properties, String key) { if (properties == null || !"prod".equalsIgnoreCase(properties.getEnvironment())) return; System.out.println("!! ================================================================================= !!"); System.out.println("!! " + key + " = prod:目标是生产环境。 !!"); System.out.println("!! 本探针只读(只 SELECT + 通道查询类接口),不发起支付、不关单、不退款、不确认资金。 !!"); System.out.println("!! 但查询会留在通道的调用记录里,排查请按生产操作对待。 !!"); System.out.println("!! ================================================================================= !!"); } private static JdbcTemplate connect(String url, String user, String password) { // 不写死 driver-class-name:DriverManager 会按 SPI 找到 mysql-connector-j, // 写死类名只会在驱动换 artifactId 时变成一个假故障。 return new JdbcTemplate(new DriverManagerDataSource(url, user, password)); } /** * 商户号 / 汇付号只留前 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; } /** 回执里凡是商户号、openid、签名材料一律替换掉再打印。 */ private static String sanitized(Map payload) { try { return JSON.toJSONString(sanitize(payload)); } catch (RuntimeException exception) { return "(回执无法序列化:" + exception.getClass().getSimpleName() + ")"; } } private static Object sanitize(Object value) { if (value instanceof Map source) { Map result = new LinkedHashMap<>(); for (Map.Entry entry : source.entrySet()) { String key = String.valueOf(entry.getKey()); String normalized = key.toLowerCase(Locale.ROOT); if (normalized.contains("huifu_id") || normalized.contains("huifuid")) { result.put(key, maskId(entry.getValue())); } else if (normalized.contains("openid") || normalized.contains("sign") || normalized.contains("private") || normalized.contains("secret") || normalized.contains("card")) { result.put(key, "***"); } else { result.put(key, sanitize(entry.getValue())); } } return result; } if (value instanceof List source) return source.stream().map(PaymentStateProbe::sanitize).toList(); if (value instanceof String text && (text.startsWith("{") || text.startsWith("["))) { try { return sanitize(JSON.parse(text)); } catch (RuntimeException ignored) { return text; } } return value; } /** * 通道查询用 {@code org_req_date} 定位原交易,取值就是发起支付那天。 * *

这里必须同时认 {@code java.sql.Timestamp}:本探针用裸 JdbcTemplate 取行,datetime 列回来的是 * Timestamp,不是应用里 {@code DatabaseRows} 归一化后的 LocalDateTime。 */ private static String requestDate(Object value) { if (value instanceof java.sql.Timestamp timestamp) return REQUEST_DATE.format(timestamp.toLocalDateTime()); if (value instanceof LocalDateTime dateTime) return REQUEST_DATE.format(dateTime); if (value instanceof LocalDate date) return REQUEST_DATE.format(date); return REQUEST_DATE.format(LocalDateTime.now()); } 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("用法:PaymentStateProbe --db-url= --db-user=<用户> --db-password=<口令>" + " (--order-no=<订单号> | --payment-no=<支付流水号>) [--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(); } }