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
import com.talentcard.server.business.payment.huifu.HuifuPaymentProperties;
import com.talentcard.server.business.payment.huifu.HuifuSettlementRecordGateway;
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.format.DateTimeFormatter;
import java.util.Properties;
 
/**
 * 只读排查探针:问通道「钱到底结了多少到这家商户的银行卡」,按日期区间直查结算记录。
 *
 * <p>出款改由斗拱按结算周期自动完成之后,平台侧不再有提现记录,「每天结了多少」只有通道知道。
 */
public class SettlementRecordProbe {
 
    /**
     * 通道对 {@code pageSize} 的硬上限,与 {@code HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE} 同一个值。
     *
     * <p>实测超过 50 时接口直接返回 {@code 00000003}
     * 「基础参数校验失败:【pageSize-分页条数】不能为空,且最小值为0,最大值为50」,<b>一条记录都拿不到</b>。
     * 后台此前默认传 100,于是「结算记录」页永远空白,而通道里其实有流水。本探针同样必须钉住这个上限,
     * 不是照原样透传调用方给的值。
     */
    private static final int PROVIDER_MAX_PAGE_SIZE = 50;
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
 
    /**
     * 回答什么问题:给定汇付号与日期区间,通道 {@code /v2/merchant/basicdata/settlement/query} 里这家商户
     * 结了多少笔、多少钱、结算到哪张卡(卡号一律不打印)。
     *
     * <p>什么时候用它:商户说「钱没到账」、或者后台「结算记录」页空白时——先确认通道侧到底有没有结算流水,
     * 再区分是「还没到结算周期」还是「通道里根本没有这笔钱的流水」(后者要去查支付与资金确认)。
     *
     * <p>它不改任何状态:只调用通道的结算记录查询接口,不发起支付、不关单、不退款、不交易确认、
     * 不下发业务开通、连本地数据库都不连。
     *
     * <p>参数格式(全部从命令行传,缺参数直接打用法退出,不用任何默认值瞎猜):
     * <pre>
     * --config=&lt;汇付配置文件路径&gt;        必填:通道查询要先有 system-id / product-id / 私钥 / 平台公钥
     * --huifu-id=&lt;斗拱商户号&gt;           必填
     * --begin-date=yyyyMMdd --end-date=yyyyMMdd   必填
     * [--page-size=&lt;1..50&gt;]            默认 50(通道上限);越界值会被收敛,不会被发到通道
     * [--huifu-environment=test|prod]
     * </pre>
     */
    public static void main(String[] args) {
        String configPath = required(args, "config");
        String huifuId = required(args, "huifu-id");
        String beginDate = required(args, "begin-date");
        String endDate = required(args, "end-date");
        int requested = intValue(arg(args, "page-size"), PROVIDER_MAX_PAGE_SIZE);
        int pageSize = Math.min(Math.max(requested, 1), PROVIDER_MAX_PAGE_SIZE);
        requireBasicDate("--begin-date", beginDate);
        requireBasicDate("--end-date", endDate);
 
        HuifuPaymentProperties properties = loadProperties(configPath, arg(args, "huifu-environment"));
        warnIfProduction(properties);
        System.out.println("只读探针:只调用通道的结算记录查询接口。不发起支付、不关单、不退款、不交易确认、"
                + "不下发业务开通、不连本地数据库、不改任何状态。");
        System.out.println();
        System.out.println("== 请求 ==");
        line("huifuId ", maskId(huifuId));
        line("区间     ", beginDate + " ~ " + endDate);
        line("pageSize", pageSize + (pageSize == requested ? "" : "(你要的 " + requested + " 已收敛到 1..50)"));
        System.out.println("  说明:通道对 pageSize 的硬上限就是 50,超了直接回 00000003 且一条都不返回,"
                + "所以这里不会原样透传。要看得更多只能缩小日期窗口,通道不返回总页数。");
 
        HuifuSettlementRecordGateway.SettlementRecords records;
        try {
            records = new HuifuSettlementRecordGateway(properties).query(huifuId, beginDate, endDate, pageSize);
        } catch (RuntimeException exception) {
            System.out.println();
            System.out.println("== 结果 ==");
            System.out.println("查询失败:" + exception.getClass().getSimpleName() + " " + text(exception.getMessage()));
            System.out.println("常见读法:00000003 是参数校验失败(先看参数,不一定是 pageSize);"
                    + "通道说商户不存在就核对 --huifu-id 与 app.payment.huifu.environment 是否配套"
                    + "(测试环境的产品号问不到生产商户)。");
            System.out.println("查不通不代表没有结算流水:钱的问题仍以支付与资金确认那两处为准。");
            return;
        }
 
        System.out.println();
        System.out.println("== 结果 ==");
        line("succeeded   ", String.valueOf(records.succeeded()));
        line("resp_code   ", text(records.code()));
        line("message     ", text(records.message()));
        line("result_count", String.valueOf(records.total()));
        if (!records.succeeded()) {
            System.out.println("通道没有给出成功回执。它可能什么都没查,也可能查了但整体被拒——"
                    + "不要在这种时候下「没有结算流水」的结论。");
        }
        System.out.println();
        System.out.println("结算记录(trans_log_result_list,已对商户号/卡号脱敏):");
        System.out.println(sanitizeRaw(records.rawRecords()));
        System.out.println();
        System.out.println("读法:");
        System.out.println("  * 通道里没有记录而商户说没到账 → 先看订单的 verify_status 与 funds_confirmation_status:"
                + "未核销的钱还在通道延迟户,不会进结算。");
        System.out.println("  * result_count 打满 pageSize 时,这个窗口里很可能还有下一页——缩小日期窗口再查一次。");
        System.out.println("  * 结算按商户(huifuId)维度、跨门店汇总;对不上时核对收款的子商户号"
                + "(benefit_order.collect_huifu_id / payment_transaction.provider_huifu_id)。");
    }
 
    // ---------- 参数、配置与输出 ----------
 
    /** 读汇付配置。<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, "private-key"), properties::setPrivateKey);
        apply(value(source, "platform-public-key"), properties::setPlatformPublicKey);
        apply(value(source, "notify-url"), properties::setNotifyUrl);
        System.out.println("配置状态(只印有无,私钥与公钥从不打印):system-id=" + configured(properties.getSystemId())
                + " product-id=" + configured(properties.getProductId())
                + " private-key=" + configured(properties.getPrivateKey())
                + " platform-public-key=" + configured(properties.getPlatformPublicKey())
                + " enabled=" + properties.isEnabled()
                + " environment=" + properties.getEnvironment());
        return properties;
    }
 
    private static String configured(String value) {
        return value == null || value.isBlank() ? "未配置" : "已配置";
    }
 
    /** 键名兼容 {@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(HuifuPaymentProperties properties) {
        if (!"prod".equalsIgnoreCase(properties.getEnvironment())) return;
        System.out.println("!! ================================================================================= !!");
        System.out.println("!! app.payment.huifu.environment = prod:目标是生产环境。                          !!");
        System.out.println("!! 本探针只读(只查结算记录),不发起任何资金操作。                                !!");
        System.out.println("!! 但查询会留在通道的调用记录里,排查请按生产操作对待。                            !!");
        System.out.println("!! ================================================================================= !!");
    }
 
    /** 日期必须是通道要的 {@code yyyyMMdd};写成 2026-09-17 通道会当参数错误,而且不容易看出是自己写错了。 */
    private static void requireBasicDate(String name, String value) {
        if (!value.matches("\\d{8}")) {
            System.out.println(name + " 必须是 yyyyMMdd(例如 20260917),收到:" + value);
            usage();
        }
        try {
            LocalDate.parse(value, REQUEST_DATE);
        } catch (RuntimeException exception) {
            System.out.println(name + " 不是一个真实日期:" + value);
            usage();
        }
    }
 
    /**
     * 只做两件事,都是为了「不把凭据印出来」:商户号留前 6 位;卡号类字段整个替换掉
     * (结算记录里带结算卡信息,卡号绝不该出现在排查输出里)。
     */
    private static String sanitizeRaw(String raw) {
        if (raw == null || raw.isBlank()) return "(通道没有返回记录列表)";
        String masked = raw.replaceAll("(?i)(huifu_?id[\"']?\\s*[:=]\\s*[\"']?)(\\d{6})\\d+", "$1$2***");
        return masked.replaceAll("(?i)((?:card_?no|cardno|settle_?card|bank_?card)[\"']?\\s*[:=]\\s*[\"']?)([^,\\]}\\s\"']+)", "$1***");
    }
 
    /**
     * 商户号只留前 6 位。通道号是 16 位数字,整串贴出来等于把可枚举的账号交出去;
     * 前 6 位足够看出几条记录是不是同一个商户。
     */
    private static String maskId(String value) {
        String text = text(value);
        if (text.isBlank()) return "(空)";
        return text.length() <= 6 ? "***" : text.substring(0, 6) + "***";
    }
 
    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 int intValue(String value, int fallback) {
        if (value == null || value.isBlank()) return fallback;
        try {
            return Integer.parseInt(value.trim());
        } catch (NumberFormatException exception) {
            System.out.println("--page-size 必须是整数,收到:" + value);
            usage();
            return fallback;
        }
    }
 
    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("用法:SettlementRecordProbe --config=<汇付配置文件> --huifu-id=<斗拱商户号>"
                + " --begin-date=yyyyMMdd --end-date=yyyyMMdd [--page-size=<1..50>] [--huifu-environment=test|prod]");
        System.exit(2);
    }
 
    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();
    }
}