yeg
2026-09-14 ad495196188cd3d80654edaa21b04c11a9f75865
fix(settlement): 修复结算记录页因分页条数越界而查不到任何流水

后台「结算记录」页一条流水都不显示,而通道里其实有记录。根因是分页条数越界:
斗拱结算记录查询接口的 pageSize 上限为 50,超过就返回 00000003
「基础参数校验失败:【pageSize-分页条数】不能为空,且最小值为0,最大值为50」;
后台原来默认传 100,于是每个商户的查询都被判失败,全部落进 failures。

实测(SettlementRecordPageSizeProbe,只读):
- pageSize=20 / 50 → resp_code=00000000,正常返回 2 条(2026-08-26 T1 0.02 元、2026-08-29 D1 0.01 元)
- pageSize=51 / 100 → resp_code=00000003,一条不返回

改动:
- HuifuSettlementRecordGateway:显式声明 PROVIDER_MAX_PAGE_SIZE=50,请求组装前收敛取值,
并把请求组装拆成可断言的 buildRequest,避免越界值再发到通道;
- MerchantSettlementAdminService / AdminManagementController:默认值与上限从 100 改为 50;
- 后台结算记录页同步把 size 从 100 改为 50。

新增 HuifuSettlementRecordGatewayTests 覆盖收敛边界与回执解析,共 4 项。
2个文件已添加
4个文件已修改
225 ■■■■■ 已修改文件
.scratch/automatic-withdrawal/tools/SettlementRecordPageSizeProbe.java 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantSettlementView.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuSettlementRecordGateway.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuSettlementRecordGatewayTests.java 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/automatic-withdrawal/tools/SettlementRecordPageSizeProbe.java
New file
@@ -0,0 +1,88 @@
import com.alibaba.fastjson.JSON;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import com.huifu.bspay.sdk.opps.client.BasePayClient;
import com.huifu.bspay.sdk.opps.core.BasePay;
import com.huifu.bspay.sdk.opps.core.config.MerConfig;
import com.huifu.bspay.sdk.opps.core.request.V2MerchantBasicdataSettlementQueryRequest;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
 * 只读核对斗拱「结算记录查询」接口对 pageSize 的取值上限。
 *
 * <p>后台「结算记录」页默认传 size=100,页面报「【pageSize-分页条数】不能为空,且最小值为0,最大值为50」。
 * 这个探针逐个取值调用同一个接口,确认失败到底由 pageSize 造成,以及可用上限是多少。
 * 不产生任何交易或费用。
 *
 * <p>用法:java -cp "target/classes;&lt;deps&gt;;&lt;tools&gt;" SettlementRecordPageSizeProbe [huifu_id]
 */
public class SettlementRecordPageSizeProbe {
    public static void main(String[] args) throws Exception {
        String huifuId = args.length > 0 ? args[0] : "6666000226048687";
        String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
        String begin = LocalDate.now().minusDays(30).format(DateTimeFormatter.BASIC_ISO_DATE);
        JsonNode huifu = new YAMLMapper().readTree(Files.newInputStream(
                Path.of("src", "main", "resources", "application.yml")))
                .path("app").path("payment").path("huifu");
        BasePay.debug = false;
        BasePay.prodMode = "prod".equalsIgnoreCase(text(huifu, "environment", "prod"))
                ? BasePay.MODE_PROD : BasePay.MODE_TEST;
        MerConfig config = new MerConfig();
        config.setSysId(text(huifu, "system-id", "").trim());
        config.setProductId(text(huifu, "product-id", "").trim());
        config.setRsaPrivateKey(text(huifu, "private-key", "").trim());
        config.setRsaPublicKey(text(huifu, "platform-public-key", "").trim());
        BasePay.initWithMerConfig(config);
        System.out.println("== 目标 " + huifuId + ",区间 " + begin + " ~ " + today + " ==");
        for (String pageSize : List.of("20", "50", "51", "100")) {
            V2MerchantBasicdataSettlementQueryRequest request = new V2MerchantBasicdataSettlementQueryRequest();
            request.setReqDate(today);
            request.setReqSeqId("PS" + System.currentTimeMillis() + pageSize);
            request.setHuifuId(huifuId);
            request.setBeginDate(begin);
            request.setEndDate(today);
            request.setPageSize(pageSize);
            System.out.println("---- pageSize=" + pageSize + " ----");
            try {
                Object response = BasePayClient.request(request, false);
                System.out.println(compact(response));
            } catch (Exception exception) {
                System.out.println("!! 调用异常: " + exception.getClass().getSimpleName() + " : " + exception.getMessage());
            }
        }
    }
    /** 只打印判断需要的字段,避免整段回执刷屏。 */
    @SuppressWarnings("unchecked")
    private static String compact(Object response) {
        if (!(response instanceof java.util.Map)) return String.valueOf(response);
        java.util.Map<String, Object> map = (java.util.Map<String, Object>) response;
        Object data = map.get("data");
        if (!(data instanceof java.util.Map)) return JSON.toJSONString(map);
        java.util.Map<String, Object> body = (java.util.Map<String, Object>) data;
        return "resp_code=" + body.get("resp_code")
                + " resp_desc=" + body.get("resp_desc")
                + " result_count=" + body.get("result_count")
                + " trans_log_result_list=" + body.get("trans_log_result_list");
    }
    private static String text(JsonNode node, String field, String fallback) {
        String value = node.path(field).asText("");
        if (value.isBlank()) return fallback;
        if (value.startsWith("${") && value.endsWith("}")) {
            String inner = value.substring(2, value.length() - 1);
            int separator = inner.indexOf(':');
            value = separator >= 0 ? inner.substring(separator + 1) : "";
        }
        return value.isBlank() ? fallback : value;
    }
}
talent-card-admin/src/views/MerchantSettlementView.vue
@@ -86,10 +86,11 @@
  try {
    if (kind.value === 'settlements') {
      // 结算记录来自斗拱,按日期区间查询,没有分页与审核状态筛选。
      // 条数上限 50 是通道的硬限制:传更大的值通道会判基础参数校验失败,整页查不出流水。
      const result = await getMerchantSettlements({
        beginDate: normalizeDate(filters.dateRange[0]),
        endDate: normalizeDate(filters.dateRange[1]),
        size: 100,
        size: 50,
      })
      rows.value = result.records || []
      total.value = Number(result.total || 0)
talent-card-server/src/main/java/com/talentcard/server/business/admin/AdminManagementController.java
@@ -36,9 +36,9 @@
  @GetMapping("/merchant-settlement/bank-cards") public ApiResponse<com.talentcard.server.business.PageResult<Map<String,Object>>> bankCards(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String merchantName, @RequestParam(required = false) String contactMobile, @RequestParam(required = false) String merchantType, @RequestParam(required = false) String status, @RequestParam(required = false) String startDate, @RequestParam(required = false) String endDate) { return ApiResponse.success(settlementService.bankCards(page, size, merchantName, contactMobile, merchantType, status, startDate, endDate)); }
  /**
   * 结算记录:向斗拱查询指定区间的真实结算流水,用于监控每天自动结算了多少到银行卡。
   * 日期格式 yyyyMMdd,默认最近 30 天。
   * 日期格式 yyyyMMdd,默认最近 30 天;通道单页上限 50,默认值必须落在上限内。
   */
  @GetMapping("/merchant-settlement/settlements") public ApiResponse<Map<String,Object>> settlements(@RequestParam(required = false) String beginDate, @RequestParam(required = false) String endDate, @RequestParam(defaultValue = "100") int size) {
  @GetMapping("/merchant-settlement/settlements") public ApiResponse<Map<String,Object>> settlements(@RequestParam(required = false) String beginDate, @RequestParam(required = false) String endDate, @RequestParam(defaultValue = "50") int size) {
    java.time.LocalDate today = java.time.LocalDate.now();
    String end = (endDate == null || endDate.isBlank()) ? today.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE) : endDate.trim();
    String begin = (beginDate == null || beginDate.isBlank()) ? today.minusDays(30).format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE) : beginDate.trim();
talent-card-server/src/main/java/com/talentcard/server/business/admin/MerchantSettlementAdminService.java
@@ -37,6 +37,9 @@
     *
     * <p>按门店取该商户当前生效的斗拱子商户号,逐商户查询结算记录后合并成一条时间倒序的列表。
     * 通道查询失败只影响这一条商户,不会让整页失败——监控页最重要的是「看得到」。
     *
     * <p>条数上限跟着通道走:这个接口的 {@code pageSize} 超过 50 会被判为基础参数校验失败,
     * 传 100 时整页一条流水都查不出来。上限在网关里还有一道兜底,这里只是不要再主动越界。
     */
    public Map<String, Object> settlements(String beginDate, String endDate, int size) {
        List<Map<String, Object>> merchants = rows("""
@@ -51,7 +54,7 @@
                """);
        List<Map<String, Object>> records = new ArrayList<>();
        List<String> failures = new ArrayList<>();
        int limit = Math.min(Math.max(size, 1), 100);
        int limit = Math.min(Math.max(size, 1), HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE);
        for (Map<String, Object> merchant : merchants) {
            String huifuId = String.valueOf(merchant.get("providerHuifuId"));
            try {
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuSettlementRecordGateway.java
@@ -26,8 +26,15 @@
    private static final DateTimeFormatter REQUEST_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
    private static final Logger log = LoggerFactory.getLogger(HuifuSettlementRecordGateway.class);
    /** 单页上限:通道按页返回,后台一页最多展示这么多条。 */
    private static final int MAX_PAGE_SIZE = 100;
    /**
     * 通道对 {@code pageSize} 的硬上限。
     *
     * <p>实测超过 50 时接口直接返回 {@code 00000003}
     * 「基础参数校验失败:【pageSize-分页条数】不能为空,且最小值为0,最大值为50」,
     * 一条记录都拿不到。后台此前默认传 100,于是「结算记录」页永远空白,而通道里其实有流水。
     * 所以这里必须收敛而不是原样透传。
     */
    public static final int PROVIDER_MAX_PAGE_SIZE = 50;
    private final HuifuPaymentProperties properties;
@@ -47,20 +54,34 @@
            throw new BusinessException(ErrorCode.BAD_REQUEST, "缺少斗拱子商户号,无法查询结算记录");
        }
        initializeSdk();
        try {
            return SettlementRecords.from(BasePayClient.request(buildRequest(huifuId, beginDate, endDate, pageSize), false));
        } catch (Exception exception) {
            log.warn("Huifu settlement record query failed: type={}, message={}",
                    exception.getClass().getSimpleName(), safe(exception.getMessage()));
            throw new HuifuPaymentGatewayException("汇付结算记录查询失败", isOutcomeUnknown(exception), exception);
        }
    }
    /** 组装通道请求;{@code pageSize} 在这里就已收敛,越界值不会发到通道。 */
    static V2MerchantBasicdataSettlementQueryRequest buildRequest(String huifuId, String beginDate, String endDate, int pageSize) {
        V2MerchantBasicdataSettlementQueryRequest request = new V2MerchantBasicdataSettlementQueryRequest();
        request.setReqDate(LocalDate.now().format(REQUEST_DATE));
        request.setReqSeqId("SQ" + System.currentTimeMillis());
        request.setHuifuId(huifuId.trim());
        request.setBeginDate(beginDate);
        request.setEndDate(endDate);
        request.setPageSize(String.valueOf(Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE)));
        try {
            return SettlementRecords.from(BasePayClient.request(request, false));
        } catch (Exception exception) {
            log.warn("Huifu settlement record query failed: type={}, message={}",
                    exception.getClass().getSimpleName(), safe(exception.getMessage()));
            throw new HuifuPaymentGatewayException("汇付结算记录查询失败", isOutcomeUnknown(exception), exception);
        }
        request.setPageSize(String.valueOf(pageSizeWithinProviderLimit(pageSize)));
        return request;
    }
    /**
     * 把调用方想要的条数收敛到通道允许的区间 {@code [1, 50]}。
     *
     * <p>独立出来是为了能直接断言:越界值曾经让整个「结算记录」页查不出任何流水。
     */
    static int pageSizeWithinProviderLimit(int pageSize) {
        return Math.min(Math.max(pageSize, 1), PROVIDER_MAX_PAGE_SIZE);
    }
    private void ensureConfigured() {
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuSettlementRecordGatewayTests.java
New file
@@ -0,0 +1,84 @@
package com.talentcard.server.business.payment.huifu;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
/**
 * 结算记录查询的两个真实约束。
 *
 * <p>一是通道对 {@code pageSize} 的上限:实测传 51 或 100 会返回 {@code 00000003}
 * 「基础参数校验失败:【pageSize-分页条数】不能为空,且最小值为0,最大值为50」,
 * 一条流水都拿不到。后台「结算记录」页原来默认传 100,于是页面永远空白——通道里其实有流水
 * (商户 6666000226048687 在 2026-08-26 / 08-29 各结过一次)。所以越界值必须在网关内收敛。
 *
 * <p>二是回执解析:结算流水以 JSON 字符串放在 {@code trans_log_result_list} 里,
 * 翻页总数在 {@code result_count}。这两处解析错了,后台同样看不到流水。
 */
class HuifuSettlementRecordGatewayTests {
    @Test
    void aPageSizeAboveTheProviderLimitIsClampedBeforeItReachesTheProvider() {
        // 100 与 51 都是通道明确拒绝的取值,必须被压到 50。
        assertThat(HuifuSettlementRecordGateway.pageSizeWithinProviderLimit(100))
                .isEqualTo(HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE);
        assertThat(HuifuSettlementRecordGateway.pageSizeWithinProviderLimit(51))
                .isEqualTo(HuifuSettlementRecordGateway.PROVIDER_MAX_PAGE_SIZE);
        // 上限内保持原样,0 与负数兜到 1。
        assertThat(HuifuSettlementRecordGateway.pageSizeWithinProviderLimit(50)).isEqualTo(50);
        assertThat(HuifuSettlementRecordGateway.pageSizeWithinProviderLimit(20)).isEqualTo(20);
        assertThat(HuifuSettlementRecordGateway.pageSizeWithinProviderLimit(1)).isEqualTo(1);
        assertThat(HuifuSettlementRecordGateway.pageSizeWithinProviderLimit(0)).isEqualTo(1);
        assertThat(HuifuSettlementRecordGateway.pageSizeWithinProviderLimit(-5)).isEqualTo(1);
    }
    @Test
    void theBuiltRequestCarriesTheClampedPageSizeAndTheQueryWindow() {
        var request = HuifuSettlementRecordGateway.buildRequest("6666000226048687", "20260815", "20260914", 100);
        assertThat(request.getPageSize()).isEqualTo("50");
        assertThat(request.getHuifuId()).isEqualTo("6666000226048687");
        assertThat(request.getBeginDate()).isEqualTo("20260815");
        assertThat(request.getEndDate()).isEqualTo("20260914");
    }
    @Test
    void theSettlementRecordsReplyIsReadFromTheProviderPayload() {
        String records = "[{\"trans_date\":\"20260829\",\"trans_amt\":\"0.01\",\"settle_cycle\":\"D1\",\"trans_stat\":\"S\"}]";
        Map<String, Object> data = new HashMap<>();
        data.put("resp_code", "00000000");
        data.put("resp_desc", "成功");
        data.put("result_count", "2");
        data.put("trans_log_result_list", records);
        Map<String, Object> payload = new HashMap<>();
        payload.put("data", data);
        HuifuSettlementRecordGateway.SettlementRecords result = HuifuSettlementRecordGateway.SettlementRecords.from(payload);
        assertThat(result.succeeded()).isTrue();
        assertThat(result.code()).isEqualTo("00000000");
        assertThat(result.total()).isEqualTo(2);
        assertThat(result.rawRecords()).isEqualTo(records);
    }
    @Test
    void aProviderParameterFailureIsReportedAsAnUnsuccessfulQuery() {
        Map<String, Object> data = new HashMap<>();
        data.put("resp_code", "00000003");
        data.put("resp_desc", "基础参数校验失败:【pageSize-分页条数】不能为空,且最小值为0,最大值为50");
        data.put("result_count", "0");
        Map<String, Object> payload = new HashMap<>();
        payload.put("data", data);
        HuifuSettlementRecordGateway.SettlementRecords result = HuifuSettlementRecordGateway.SettlementRecords.from(payload);
        // 解析层如实报失败,让调用方把它展示成 failures,而不是静默当成"没有流水"。
        assertThat(result.succeeded()).isFalse();
        assertThat(result.total()).isZero();
        assertThat(result.rawRecords()).isEmpty();
    }
}