diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiKbController.java b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiKbController.java index 9fd7966..3e5afaa 100644 --- a/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiKbController.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiKbController.java @@ -2,7 +2,7 @@ package apelet.common.openapi.controller; import apelet.common.core.constant.ErrorCodeEnum; import apelet.common.core.object.ResponseResult; -import apelet.common.openapi.dto.OpenApiKbFileNodeDto; +import apelet.common.openapi.dto.OpenApiKbFilePageDto; import apelet.common.openapi.dto.OpenApiKbFileQueryDto; import apelet.common.openapi.dto.OpenApiKbPageNodeDto; import apelet.common.openapi.dto.OpenApiKbTreeQueryDto; @@ -56,17 +56,19 @@ public class OpenApiKbController { } /** - * 按表单(可选按附件字段)查询全部文件清单,供消费侧做增量比对后再逐个下载。清单不分页。 + * 按表单(可选按附件字段)分页查询文件清单,供消费侧做增量比对后再逐个下载。 + *

每次只返回一页,翻页由调用方用 pageNo 递增驱动,翻页只认 hasMore。返回的记录含全部主表记录, + * 没有附件的记录 files 为空数组(不是被丢弃),因此本页条数与 pageSize 一致(末页除外)。

* - * @param queryDto 查询入参(formId 必填,fieldName 可缺省)。 - * @return data 直接为记录节点数组(元素含 files 子数组)。 + * @param queryDto 查询入参(formId 必填,fieldName/pageNo/pageSize 可缺省)。 + * @return data 为分页对象(list/pageNo/pageSize/total/hasMore)。 */ @PostMapping("/files") - public ResponseResult> files(@RequestBody(required = false) OpenApiKbFileQueryDto queryDto) { + public ResponseResult files(@RequestBody(required = false) OpenApiKbFileQueryDto queryDto) { if (queryDto == null) { return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, "请求体不能为空!"); } - return openApiKbService.getFileList(queryDto.getFormId(), queryDto.getFieldName()); + return openApiKbService.getFileList(queryDto.getFormId(), queryDto.getFieldName(), queryDto.getPageNo(), queryDto.getPageSize()); } /** diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileQueryDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileQueryDto.java index 71218d7..b84cb56 100644 --- a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileQueryDto.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileQueryDto.java @@ -18,4 +18,12 @@ public class OpenApiKbFileQueryDto { * 附件字段的数据库列名,选填;不传时返回该表单全部文件字段的附件。 */ private String fieldName; + /** + * 页码,选填,从 1 开始;不传或非法按 1 处理。 + */ + private Integer pageNo; + /** + * 单页记录数,选填;不传或非法按 500 处理,上限 1000。 + */ + private Integer pageSize; } diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiKbService.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiKbService.java index 433f2b8..0c4d963 100644 --- a/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiKbService.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiKbService.java @@ -1,7 +1,7 @@ package apelet.common.openapi.service; import apelet.common.core.object.ResponseResult; -import apelet.common.openapi.dto.OpenApiKbFileNodeDto; +import apelet.common.openapi.dto.OpenApiKbFilePageDto; import apelet.common.openapi.dto.OpenApiKbPageNodeDto; import javax.servlet.http.HttpServletResponse; @@ -26,15 +26,17 @@ public interface OpenApiKbService { List getPageFormTree(String keyword, Boolean onlyReady); /** - * 按表单(可选按附件字段)查询全部文件清单,供消费侧做增量比对后再逐个下载。 - *

清单不分页:消费侧靠它做增量比对,只给一页会让其余记录的文档被误判成"已删除"。内部按 1000 条一页翻完, - * 记录数超过 {@code SCAN_LIMIT} 时截断并记 warn 日志。

+ * 按表单(可选按附件字段)分页查询文件清单,每次只返回一页,翻页由调用方驱动。 + *

只认返回的 hasMore 判断是否到底,不要用"本页条数不足 pageSize"或"本页为空"判断。返回的记录含全部主表记录, + * 没有附件的记录 files 为空数组,因此本页条数与 pageSize 一致(末页除外)。

* * @param formId 在线表单主键Id。 * @param fieldName 附件字段的数据库列名,为空时返回该表单全部文件字段的附件。 - * @return 记录节点列表(含 files 子数组),直接作为响应的 data。 + * @param pageNo 页码,从 1 开始;为空或非法按 1 处理。 + * @param pageSize 单页记录数;为空或非法按 500 处理,上限 1000。 + * @return 分页对象(list/pageNo/pageSize/total/hasMore)作为响应的 data。 */ - ResponseResult> getFileList(Long formId, String fieldName); + ResponseResult getFileList(Long formId, String fieldName, Integer pageNo, Integer pageSize); /** * 下载指定记录指定附件字段下的单个附件,直接写出二进制流。 diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiKbServiceImpl.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiKbServiceImpl.java index 3dba0f4..a3b631a 100644 --- a/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiKbServiceImpl.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiKbServiceImpl.java @@ -1,6 +1,7 @@ package apelet.common.openapi.service.impl; import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.object.MyOrderParam; import apelet.common.core.object.MyPageParam; import apelet.common.core.object.ObjectValue; import apelet.common.core.object.ResponseResult; @@ -47,13 +48,13 @@ public class OpenApiKbServiceImpl implements OpenApiKbService { */ private static final String UPDATE_TIME_COLUMN = "update_time"; /** - * 单次翻页大小,取在线列表引擎上限。 + * 缺省单页记录数,与接口文档示例一致。 */ - private static final int PAGE_SIZE = 1000; + private static final int DEFAULT_PAGE_SIZE = 500; /** - * 单次调用最多扫描的记录数,兜底防止一次拉爆内存;正常表单远达不到。 + * 单页记录数上限,取在线列表引擎上限。 */ - private static final int SCAN_LIMIT = 50000; + private static final int MAX_PAGE_SIZE = 1000; @Autowired private OnlinePageService onlinePageService; @@ -270,10 +271,12 @@ public class OpenApiKbServiceImpl implements OpenApiKbService { // ============ 接口2:文件清单 ============ @Override - public ResponseResult> getFileList(Long formId, String fieldName) { + public ResponseResult getFileList(Long formId, String fieldName, Integer pageNo, Integer pageSize) { if (formId == null) { return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, "formId 不能为空!"); } + int effectivePageNo = this.toIntParam(pageNo, 1, 1, Integer.MAX_VALUE); + int effectivePageSize = this.toIntParam(pageSize, DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE); OnlineForm form = onlineFormService.getOnlineFormFromCache(formId); if (form == null) { return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "在线表单不存在!"); @@ -311,60 +314,79 @@ public class OpenApiKbServiceImpl implements OpenApiKbService { if (datasourceResult.getData() == null || datasourceResult.getData().getMasterTable() == null) { return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "表单主表不存在!"); } - List> rawList = this.queryAllRows(form, datasourceResult, primaryKeyColumn); - if (rawList == null) { + GridData gridData = this.queryPage(form, datasourceResult, primaryKeyColumn, effectivePageNo, effectivePageSize); + // 查询失败必须报错,不能返回 hasMore=false + 空列表:消费侧会把"出错"当成"拉完了",静默少拉一批。 + if (gridData == null) { return ResponseResult.error(ErrorCodeEnum.DATA_ACCESS_FAILED, "列表查询失败!"); } + List> rawList = gridData.getDataList(); // 列表路径的 SELECT 列来自列表设计器配置,并不强制包含主键;而 fillFileColumn 是靠主键回 zz_online_file 捞附件的, // 主键缺失时附件会全部回填不出来,接口却照常返回 200。这里显式校验,把静默失败变成显式失败。 if (CollUtil.isNotEmpty(rawList) && !this.hasPrimaryKeyValue(rawList, primaryKeyColumn.getColumnName())) { return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "该表单的列表未配置主键列,无法回填附件,请在表单列表中加入主键字段后重试!"); } - return ResponseResult.success(this.assembleFileNodeList(rawList, primaryKeyColumn.getColumnName(), fileColumnList)); + return ResponseResult.success(this.buildPageData(rawList, primaryKeyColumn.getColumnName(), fileColumnList, + effectivePageNo, effectivePageSize, gridData.getTotalCount())); } finally { tokenData.setAppCode(savedAppCode); } } /** - * 一次翻页拉完全部记录。 - *

清单必须完整:消费侧靠它做增量比对(updateTime 变没变、记录还在不在),只取一页会导致其余记录的文档被判成"已删除"。 - * 每页 1000 条(引擎上限),循环翻到累计条数达到 total 为止。真正的安全阀是 scanLimit,单次调用最多扫这么多个记录节点。

+ * 只翻一页。页号由调用方给,内部不再循环,也不再有扫描行数上限——翻到 hasMore=false 即是全量。 * - * @return 全部记录行;查询失败返回 null。 + * @return 该页的查询结果;查询失败返回 null(由调用方转成报错,避免被消费侧当成"拉完了")。 */ - private List> queryAllRows(OnlineForm form, ResponseResult datasourceResult, OnlineColumn primaryKeyColumn) { + private GridData queryPage(OnlineForm form, ResponseResult datasourceResult, OnlineColumn primaryKeyColumn, + int pageNo, int pageSize) { OnlFormHead onlFormHead = onlFormHeadService.getHeadTableNameCache(datasourceResult.getData().getMasterTable().getTableName()); if (onlFormHead == null) { log.warn("OpenAPI 知识库清单:表单 [{}] 的表头不存在", form.getFormId()); return null; } - List> allRows = new ArrayList<>(); - int pageNum = 1; - while (true) { - MyPageParam pageParam = new MyPageParam(); - pageParam.setPageNum(pageNum); - pageParam.setPageSize(PAGE_SIZE); - GridData gridData = onlineOperationService.exeListPlugin(form.getFormId(), new ArrayList<>(), null, pageParam, null, onlFormHead, datasourceResult, null); - if (gridData == null) { - return null; - } - List> rows = gridData.getDataList(); - if (CollUtil.isEmpty(rows)) { - break; - } - allRows.addAll(rows); - // 本页不足一页,或已拉满 total,说明到底了 - if (rows.size() < PAGE_SIZE || allRows.size() >= gridData.getTotalCount()) { - break; - } - if (allRows.size() >= SCAN_LIMIT) { - log.warn("OpenAPI 知识库清单:表单 [{}] 记录数超过单次扫描上限 {},本次结果可能不完整,请指定 fieldName 缩小范围或分批调用", form.getFormId(), SCAN_LIMIT); - break; - } - pageNum++; + MyPageParam pageParam = new MyPageParam(); + pageParam.setPageNum(pageNo); + pageParam.setPageSize(pageSize); + return onlineOperationService.exeListPlugin(form.getFormId(), new ArrayList<>(), this.buildOrderParam(onlFormHead, primaryKeyColumn), + pageParam, null, onlFormHead, datasourceResult, null); + } + + /** + * 构造 KB 清单专用排序:主键降序。 + *

列表路径的默认排序兜底只有 create_time DESC(见 ListDataOrmService#loadGridData),同一秒创建的记录相对顺序不稳定, + * OFFSET 分页按"第 N 行"切片就会切错位置,导致某行在两页各出现一次、或两页都不出现。主键唯一,补上它即得到全序。 + * 该排序与列表 UI 的排序语义无关,KB 只需要一次完整稳定的遍历。

+ * + * @return 排序参数;EAS 异构表单返回 null,见方法内注释。 + */ + private MyOrderParam buildOrderParam(OnlFormHead onlFormHead, OnlineColumn primaryKeyColumn) { + // EAS 异构表单:ListDataEasService 会把 orderParam 的字段名映射到 onlFormField,映射不到直接空指针 + // (见 ListDataEasService#loadGridData),且它自带 number DESC 排序,因此这里不传排序。 + if (onlFormHead.getIsomerismMark() != null) { + return null; } - return allRows; + MyOrderParam orderParam = new MyOrderParam(); + MyOrderParam.OrderInfo orderInfo = new MyOrderParam.OrderInfo(); + // 取小写列名,与 ORM 选择列及 create_time 兜底的写法保持同一形态。 + orderInfo.setFieldName(primaryKeyColumn.getColumnName().toLowerCase()); + orderInfo.setAsc(false); + orderParam.add(orderInfo); + return orderParam; + } + + /** + * 组装分页应答:记录节点 + 回显分页参数 + hasMore。 + *

hasMore 按分页那一层(主表记录数)算,与本页返回的清单条数无关——即使本页记录全都没有附件,也不影响翻页。

+ */ + private OpenApiKbFilePageDto buildPageData(List> rawList, String primaryKeyColumnName, + List fileColumnList, int pageNo, int pageSize, int totalCount) { + OpenApiKbFilePageDto pageDto = new OpenApiKbFilePageDto(); + pageDto.setList(this.assembleFileNodeList(rawList, primaryKeyColumnName, fileColumnList)); + pageDto.setPageNo(pageNo); + pageDto.setPageSize(pageSize); + pageDto.setTotal((long) totalCount); + pageDto.setHasMore((long) pageNo * pageSize < totalCount); + return pageDto; } private boolean hasPrimaryKeyValue(List> rawList, String primaryKeyColumnName) { @@ -372,9 +394,9 @@ public class OpenApiKbServiceImpl implements OpenApiKbService { } /** - * 把查询结果裁剪成文件清单:每个记录节点只保留主键、记录更新时间与附件条目,并丢弃一个附件都没有的记录。 - *

丢弃无附件记录既省流量,也让消费侧的 changeKey diff 语义自洽:某条记录从清单里消失, - * 就代表它不再有附件,应当删除对应文档。

+ * 把查询结果裁剪成文件清单:每个记录节点只保留主键、记录更新时间与附件条目。 + *

记录一律保留,某个字段没有附件时 files 为空数组。这样页内条数与本次请求的 pageSize 一致 + * (末页除外),消费侧不需要靠"本页条数"做任何推断;翻页只认 hasMore。

*/ private List assembleFileNodeList(List> rawList, String primaryKeyColumnName, List fileColumnList) { List result = new ArrayList<>(); @@ -394,9 +416,6 @@ public class OpenApiKbServiceImpl implements OpenApiKbService { } files.addAll(this.parseFileItems(fileJson, column.getColumnName())); } - if (files.isEmpty()) { - continue; - } OpenApiKbFileNodeDto node = new OpenApiKbFileNodeDto(); node.setRecordId(this.toLongId(String.valueOf(recordId))); String updateTime = this.toPlainString(this.getRowValue(row, UPDATE_TIME_COLUMN)); @@ -532,7 +551,8 @@ public class OpenApiKbServiceImpl implements OpenApiKbService { /** * 安全地把入参解析为整数(兼容 1.0 等数值形态),非法/缺省回退默认值,并夹到 [min, max]。 */ - private int toIntParam(Object value, int defaultValue, int min, int max) { if (value == null || StrUtil.isBlank(String.valueOf(value))) { + private int toIntParam(Object value, int defaultValue, int min, int max) { + if (value == null || StrUtil.isBlank(String.valueOf(value))) { return defaultValue; } try {