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 项。
| New file |
| | |
| | | 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;<deps>;<tools>" 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; |
| | | } |
| | | } |
| | |
| | | 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) |
| | |
| | | @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(); |
| | |
| | | * |
| | | * <p>按门店取该商户当前生效的斗拱子商户号,逐商户查询结算记录后合并成一条时间倒序的列表。 |
| | | * 通道查询失败只影响这一条商户,不会让整页失败——监控页最重要的是「看得到」。 |
| | | * |
| | | * <p>条数上限跟着通道走:这个接口的 {@code pageSize} 超过 50 会被判为基础参数校验失败, |
| | | * 传 100 时整页一条流水都查不出来。上限在网关里还有一道兜底,这里只是不要再主动越界。 |
| | | */ |
| | | public Map<String, Object> settlements(String beginDate, String endDate, int size) { |
| | | List<Map<String, Object>> merchants = rows(""" |
| | |
| | | """); |
| | | 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 { |
| | |
| | | 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; |
| | | |
| | |
| | | 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() { |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |