From b679ad5c47b9d9d82df76fc9ec90ed17dd1feec9 Mon Sep 17 00:00:00 2001 From: chenchuchuan <13554600537@163.com> Date: Mon, 28 Sep 2026 17:41:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(common-openapi):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=9F=A5=E8=AF=86=E5=BA=93=E5=BC=80=E6=94=BE=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96token=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增OpenApiKbController提供知识库开放接口,包括页面表单树查询、文件清单获取和附件下载功能 - 新增多个DTO类用于知识库接口的数据传输,包括OpenApiKbFileNodeDto、OpenApiKbPageNodeDto等 - 实现OpenApiKbService接口及OpenApiKbServiceImpl服务类,提供完整的业务逻辑 - 优化OpenApiAuthInterceptor支持多种token请求头格式,优先取token后兼容accessToken - 更新OpenApiAuthServiceImpl的token验证逻辑,支持Bearer前缀的兼容处理 - 引入StrUtil工具类进行字符串非空验证,增强代码健壮性 - 添加详细的接口文档说明,包括调用流程和参数说明 --- common/common-openapi/pom.xml | 2 +- .../openapi/controller/OpenApiKbController.java | 90 ++++ .../common/openapi/dto/OpenApiKbFileFieldDto.java | 25 + .../common/openapi/dto/OpenApiKbFileItemDto.java | 25 + .../common/openapi/dto/OpenApiKbFileNodeDto.java | 27 + .../common/openapi/dto/OpenApiKbFileQueryDto.java | 21 + .../common/openapi/dto/OpenApiKbFormNodeDto.java | 39 ++ .../common/openapi/dto/OpenApiKbPageNodeDto.java | 39 ++ .../common/openapi/dto/OpenApiKbTreeQueryDto.java | 21 + .../interceptor/OpenApiAuthInterceptor.java | 19 +- .../common/openapi/service/OpenApiKbService.java | 50 ++ .../service/impl/OpenApiAuthServiceImpl.java | 9 +- .../openapi/service/impl/OpenApiKbServiceImpl.java | 565 +++++++++++++++++++++ 13 files changed, 928 insertions(+), 4 deletions(-) create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiKbController.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileFieldDto.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileItemDto.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileNodeDto.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileQueryDto.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFormNodeDto.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbPageNodeDto.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbTreeQueryDto.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiKbService.java create mode 100644 common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiKbServiceImpl.java diff --git a/common/common-openapi/pom.xml b/common/common-openapi/pom.xml index 75b435e..c02b425 100644 --- a/common/common-openapi/pom.xml +++ b/common/common-openapi/pom.xml @@ -31,7 +31,7 @@ apelet common-online - 1.0.5 + 1.0.6 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 new file mode 100644 index 0000000..9fd7966 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiKbController.java @@ -0,0 +1,90 @@ +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.OpenApiKbFileQueryDto; +import apelet.common.openapi.dto.OpenApiKbPageNodeDto; +import apelet.common.openapi.dto.OpenApiKbTreeQueryDto; +import apelet.common.openapi.service.OpenApiKbService; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; + +/** + * OpenAPI 知识库开放接口(固定路径,供知识库侧跨系统调用)。 + *

三个接口都挂在 {urlPrefix}/v1/kb/** 下,由 OpenApiAuthInterceptor 统一做 token 校验与 TokenData 注入; + * 按已对齐决策不做应用级授权(不校验 xy_sys_open_app_api 关联),因此消费侧无需配置开放接口、无需授权。

+ *

鉴权请求头为 {@code token: }(不带 Bearer 前缀)。

+ *

调用顺序:pageFormTree 锁定表单与附件字段 → files 取清单做增量比对 → download 逐个下载。

+ * + * @author chenchuchuan + */ +@Tag(name = "OpenAPI知识库开放接口") +@Slf4j +@RestController +@RequestMapping("${common-openapi.urlPrefix:/openapi}/v1/kb") +@ConditionalOnProperty(name = "common-openapi.operationEnabled", havingValue = "true") +public class OpenApiKbController { + + @Autowired + private OpenApiKbService openApiKbService; + + /** + * 查询页面 → 表单两层树,每个表单节点带文件字段清单与可用性判定。 + * + * @param queryDto 查询入参(keyword/onlyReady 均可缺省)。 + * @return 页面节点列表。 + */ + @PostMapping("/pageFormTree") + public ResponseResult> pageFormTree(@RequestBody(required = false) OpenApiKbTreeQueryDto queryDto) { + if (queryDto == null) { + queryDto = new OpenApiKbTreeQueryDto(); + } + return ResponseResult.success(openApiKbService.getPageFormTree(queryDto.getKeyword(), queryDto.getOnlyReady())); + } + + /** + * 按表单(可选按附件字段)查询全部文件清单,供消费侧做增量比对后再逐个下载。清单不分页。 + * + * @param queryDto 查询入参(formId 必填,fieldName 可缺省)。 + * @return data 直接为记录节点数组(元素含 files 子数组)。 + */ + @PostMapping("/files") + 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()); + } + + /** + * 下载指定记录、指定附件字段下的单个附件,响应为二进制流(不套统一响应体)。 + * + * @param formId 在线表单主键Id。 + * @param dataId 附件所在记录的主键值(取清单返回的 recordId)。 + * @param fieldName 附件字段的数据库列名(取清单返回的 fieldName)。 + * @param filename 物理文件名(取清单返回的 filename,形如 uuid@真实名),原样回传。 + * @param response Http 应答对象。 + * @throws IOException 写出应答时发生错误。 + */ + @GetMapping("/download") + public void download(@RequestParam(required = false) Long formId, + @RequestParam(required = false) String dataId, + @RequestParam(required = false) String fieldName, + @RequestParam(required = false) String filename, + HttpServletResponse response) throws IOException { + openApiKbService.download(formId, dataId, fieldName, filename, response); + } +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileFieldDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileFieldDto.java new file mode 100644 index 0000000..111114a --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileFieldDto.java @@ -0,0 +1,25 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +/** + * 知识库开放接口:表单的文件字段(附件上传/图片上传)。 + * + * @author chenchuchuan + */ +@Data +public class OpenApiKbFileFieldDto { + + /** + * 数据库列名(下载接口 fieldName 入参的原值)。 + */ + private String columnName; + /** + * 字段类别:1=附件上传(FieldKind.UPLOAD),2=图片上传(FieldKind.UPLOAD_IMAGE)。 + */ + private Integer fieldKind; + /** + * 字段中文名(取列注释,无注释时回退列名)。 + */ + private String label; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileItemDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileItemDto.java new file mode 100644 index 0000000..03149b1 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileItemDto.java @@ -0,0 +1,25 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +/** + * 知识库开放接口:文件清单中的单个附件条目。 + * + * @author chenchuchuan + */ +@Data +public class OpenApiKbFileItemDto { + + /** + * 附件所属字段的数据库列名(下载接口 fieldName 入参)。 + */ + private String fieldName; + /** + * 物理文件名(形如 uuid@真实名),下载接口 filename 入参**原样回传**此值。 + */ + private String filename; + /** + * 真实文件名(从 filename 的 @ 之后截取,无 @ 时与 filename 相同),仅供展示与落盘命名。 + */ + private String fileRealName; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileNodeDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileNodeDto.java new file mode 100644 index 0000000..00f099c --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileNodeDto.java @@ -0,0 +1,27 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 知识库开放接口:文件清单中的记录节点,一个节点对应在线表单的一条记录。 + * + * @author chenchuchuan + */ +@Data +public class OpenApiKbFileNodeDto { + + /** + * 记录主键值(下载接口 dataId 入参)。 + */ + private Long recordId; + /** + * 记录更新时间,作为增量比对的 changeKey;该表无 update_time 列时为空。 + */ + private String updateTime; + /** + * 该记录在目标字段下的附件列表。 + */ + private List files; +} 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 new file mode 100644 index 0000000..71218d7 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileQueryDto.java @@ -0,0 +1,21 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +/** + * 知识库开放接口:文件清单入参。 + * + * @author chenchuchuan + */ +@Data +public class OpenApiKbFileQueryDto { + + /** + * 在线表单主键Id,必填。 + */ + private Long formId; + /** + * 附件字段的数据库列名,选填;不传时返回该表单全部文件字段的附件。 + */ + private String fieldName; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFormNodeDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFormNodeDto.java new file mode 100644 index 0000000..2e2a804 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFormNodeDto.java @@ -0,0 +1,39 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 知识库开放接口:页面表单树中的表单节点。 + * + * @author chenchuchuan + */ +@Data +public class OpenApiKbFormNodeDto { + + /** + * 在线表单主键Id(查询/下载接口的 formId 入参)。 + */ + private Long formId; + /** + * 在线表单编码。 + */ + private String formCode; + /** + * 在线表单名称。 + */ + private String formName; + /** + * 是否可用于知识库拉取附件:主表无文件字段、或未配置列表时为 false。 + */ + private Boolean kbReady; + /** + * 不可用原因或预警提示,无问题时为 null。 + */ + private String reason; + /** + * 该表单主表的文件字段(附件+图片)。 + */ + private List fileFields; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbPageNodeDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbPageNodeDto.java new file mode 100644 index 0000000..afc54ce --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbPageNodeDto.java @@ -0,0 +1,39 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 知识库开放接口:页面表单树中的页面节点(父节点)。 + * + * @author chenchuchuan + */ +@Data +public class OpenApiKbPageNodeDto { + + /** + * 在线页面主键Id。 + */ + private Long pageId; + /** + * 页面编码。 + */ + private String pageCode; + /** + * 页面名称。 + */ + private String pageName; + /** + * 页面状态。 + */ + private Integer status; + /** + * 是否已发布。 + */ + private Boolean published; + /** + * 该页面下的表单节点。 + */ + private List children; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbTreeQueryDto.java b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbTreeQueryDto.java new file mode 100644 index 0000000..67c5e16 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbTreeQueryDto.java @@ -0,0 +1,21 @@ +package apelet.common.openapi.dto; + +import lombok.Data; + +/** + * 知识库开放接口:页面表单树入参。 + * + * @author chenchuchuan + */ +@Data +public class OpenApiKbTreeQueryDto { + + /** + * 页面名称模糊搜索关键字,可为空。 + */ + private String keyword; + /** + * 是否只返回可用表单,缺省 true。 + */ + private Boolean onlyReady; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/interceptor/OpenApiAuthInterceptor.java b/common/common-openapi/src/main/java/apelet/common/openapi/interceptor/OpenApiAuthInterceptor.java index 2bd33bb..c68dbd5 100644 --- a/common/common-openapi/src/main/java/apelet/common/openapi/interceptor/OpenApiAuthInterceptor.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/interceptor/OpenApiAuthInterceptor.java @@ -5,6 +5,7 @@ import apelet.common.core.object.ResponseResult; import apelet.common.core.object.TokenData; import apelet.common.openapi.config.OpenApiProperties; import apelet.common.openapi.service.OpenApiAuthService; +import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -19,6 +20,8 @@ import javax.servlet.http.HttpServletResponse; * OpenAPI 对外开放路由({urlPrefix}/v1/**)的 token 校验拦截器。 *

在 SpringMVC 拦截器链中注册(见接入方 InterceptorConfig),只拦截 {urlPrefix}/v1/**; * token 签发(/auth/token)与配置管理(/config/**、/app/**,走应用自身登录态)不经过本拦截器。

+ *

支持的请求头(按顺序取第一个非空者):token(推荐,值为 accessToken 原值,不带 Bearer 前缀)、 + * accessToken(带 Bearer 前缀)。见 OpenApiAuthServiceImpl#validateToken。

* * @author chenchuchuan * @date 2026-09-02 @@ -40,8 +43,7 @@ public class OpenApiAuthInterceptor implements HandlerInterceptor { if (!request.getRequestURI().startsWith(openApiProperties.getUrlPrefix() + "/v1/")) { return true; } - String token = request.getHeader("Authorization"); - TokenData tokenData = openApiAuthService.validateToken(token); + TokenData tokenData = openApiAuthService.validateToken(this.resolveToken(request)); if (tokenData == null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); @@ -52,4 +54,17 @@ public class OpenApiAuthInterceptor implements HandlerInterceptor { TokenData.addToRequest(tokenData); return true; } + + /** + * 取请求头中的 token:优先 token(新增约定,值即 accessToken 原值),兼容 accessToken(带 Bearer 前缀)。 + *

接入方的 CORS 配置均为 addAllowedHeader("*")(tenant-admin FilterConfig / gateway CorsConfig), + * 自定义头无需额外声明;若将来收窄为白名单,需把 token 头一并列入,否则浏览器预检就会被拦下。

+ */ + private String resolveToken(HttpServletRequest request) { + String token = request.getHeader("token"); + if (StrUtil.isNotBlank(token)) { + return token; + } + return request.getHeader("accessToken"); + } } 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 new file mode 100644 index 0000000..433f2b8 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiKbService.java @@ -0,0 +1,50 @@ +package apelet.common.openapi.service; + +import apelet.common.core.object.ResponseResult; +import apelet.common.openapi.dto.OpenApiKbFileNodeDto; +import apelet.common.openapi.dto.OpenApiKbPageNodeDto; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; + +/** + * 知识库对外开放接口服务(固定路径 /v1/kb/**,供知识库侧跨系统调用)。 + *

两步走:先取页面表单树锁定目标表单与附件字段,再取文件清单做增量比对,最后逐个下载文件。

+ * + * @author chenchuchuan + */ +public interface OpenApiKbService { + + /** + * 查询页面 → 表单两层树,树上带表单的文件字段与可用性判定。 + * + * @param keyword 页面名称模糊关键字,可为空。 + * @param onlyReady 是否只返回可用表单,为空按 true 处理。 + * @return 页面节点列表,父节点为在线页面,children 为表单节点。 + */ + List getPageFormTree(String keyword, Boolean onlyReady); + + /** + * 按表单(可选按附件字段)查询全部文件清单,供消费侧做增量比对后再逐个下载。 + *

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

+ * + * @param formId 在线表单主键Id。 + * @param fieldName 附件字段的数据库列名,为空时返回该表单全部文件字段的附件。 + * @return 记录节点列表(含 files 子数组),直接作为响应的 data。 + */ + ResponseResult> getFileList(Long formId, String fieldName); + + /** + * 下载指定记录指定附件字段下的单个附件,直接写出二进制流。 + * + * @param formId 在线表单主键Id。 + * @param dataId 附件所在记录的主键值(取清单返回的 recordId)。 + * @param fieldName 附件字段的数据库列名(取清单返回的 fieldName)。 + * @param filename 物理文件名(取清单返回的 filename,形如 uuid@真实名),原样回传。 + * @param response Http 应答对象。 + * @throws IOException 写出应答时发生错误。 + */ + void download(Long formId, String dataId, String fieldName, String filename, HttpServletResponse response) throws IOException; +} diff --git a/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAuthServiceImpl.java b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAuthServiceImpl.java index d1f66fd..5d2c79e 100644 --- a/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAuthServiceImpl.java +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAuthServiceImpl.java @@ -103,7 +103,14 @@ public class OpenApiAuthServiceImpl implements OpenApiAuthService { @Override public TokenData validateToken(String token) { - Claims c = JwtUtil.parseToken(token, openApiProperties.getTokenSigningKey()); + if (StrUtil.isBlank(token)) { + return null; + } + // token 原样传入(不带前缀),兼容历史调用方误加的 "Bearer " 前缀。 + if (token.startsWith(BEARER_PREFIX)) { + token = token.substring(BEARER_PREFIX.length()); + } + Claims c = JwtUtil.parseToken(BEARER_PREFIX + token, openApiProperties.getTokenSigningKey()); if (JwtUtil.isNullOrExpired(c)) { return null; } 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 new file mode 100644 index 0000000..3dba0f4 --- /dev/null +++ b/common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiKbServiceImpl.java @@ -0,0 +1,565 @@ +package apelet.common.openapi.service.impl; + +import apelet.common.core.constant.ErrorCodeEnum; +import apelet.common.core.object.MyPageParam; +import apelet.common.core.object.ObjectValue; +import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; +import apelet.common.generator.model.OnlFormHead; +import apelet.common.generator.service.IOnlFormHeadService; +import apelet.common.online.abstractplugin.model.GridData; +import apelet.common.online.model.*; +import apelet.common.online.model.constant.EventEnum; +import apelet.common.online.model.constant.FieldKind; +import apelet.common.online.service.*; +import apelet.common.online.util.OnlineOperationHelper; +import apelet.common.openapi.dto.*; +import apelet.common.openapi.service.OpenApiKbService; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 知识库对外开放接口服务实现。 + *

三个固定路径接口都挂在 {urlPrefix}/v1/kb/** 下,由 OpenApiAuthInterceptor 做 token 校验;按已对齐决策不做应用级授权。

+ * + * @author chenchuchuan + */ +@Slf4j +@Service("openApiKbService") +public class OpenApiKbServiceImpl implements OpenApiKbService { + + /** + * 知识库约定的记录更新时间列名,作为增量比对的 changeKey。 + */ + private static final String UPDATE_TIME_COLUMN = "update_time"; + /** + * 单次翻页大小,取在线列表引擎上限。 + */ + private static final int PAGE_SIZE = 1000; + /** + * 单次调用最多扫描的记录数,兜底防止一次拉爆内存;正常表单远达不到。 + */ + private static final int SCAN_LIMIT = 50000; + + @Autowired + private OnlinePageService onlinePageService; + @Autowired + private OnlineFormService onlineFormService; + @Autowired + private OnlineTableService onlineTableService; + @Autowired + private OnlineDatasourceService onlineDatasourceService; + @Autowired + private OnlineOperationService onlineOperationService; + @Autowired + private OnlineOperationHelper onlineOperationHelper; + @Autowired + private OnlineFileService onlineFileService; + @Autowired + private IOnlFormHeadService onlFormHeadService; + + // ============ 接口1:页面表单树 ============ + + @Override + public List getPageFormTree(String keyword, Boolean onlyReady) { + // 默认只返回可用表单,避免消费侧选到"没有附件字段"或"查不出数据"的表单,同步空跑一趟才发现。 + boolean readyOnly = onlyReady == null || onlyReady; + List pageList = this.getPublishedPageList(keyword); + if (CollUtil.isEmpty(pageList)) { + return new ArrayList<>(); + } + List result = new ArrayList<>(); + Map> formMap = this.getFormMapByPageIds(pageList.stream().map(OnlinePage::getPageId).collect(Collectors.toSet())); + for (OnlinePage page : pageList) { + List children = new ArrayList<>(); + for (OnlineForm form : formMap.getOrDefault(page.getPageId(), new ArrayList<>())) { + OpenApiKbFormNodeDto formNode = this.buildFormNode(form); + if (readyOnly && !Boolean.TRUE.equals(formNode.getKbReady())) { + continue; + } + children.add(formNode); + } + // 只返回可用表单时,页面下没有可用表单就整页不返回,避免给出空目录。 + if (readyOnly && children.isEmpty()) { + continue; + } + OpenApiKbPageNodeDto pageNode = new OpenApiKbPageNodeDto(); + pageNode.setPageId(page.getPageId()); + pageNode.setPageCode(page.getPageCode()); + pageNode.setPageName(page.getPageName()); + pageNode.setStatus(page.getStatus()); + pageNode.setPublished(page.getPublished()); + pageNode.setChildren(children); + result.add(pageNode); + } + return result; + } + + /** + * 取已发布页面。租户/应用隔离:令牌的 appCode 是开放应用编码,与在线资源的归属编码不是一回事(参见 + * OpenApiExecServiceImpl#execute 的归属切换说明),这里只按租户隔离,不按 appCode 过滤,否则树会查不出来。 + */ + private List getPublishedPageList(String keyword) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(OnlinePage::getPublished, true); + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData != null && tokenData.getTenantId() != null) { + wrapper.eq(OnlinePage::getTenantId, tokenData.getTenantId()); + } + if (StrUtil.isNotBlank(keyword)) { + wrapper.like(OnlinePage::getPageName, keyword); + } + wrapper.orderByAsc(OnlinePage::getPageName); + List pageList = onlinePageService.list(wrapper); + return pageList == null ? new ArrayList<>() : pageList; + } + + /** + * 一次查出这批页面下的全部表单,避免在循环里逐页查库。 + */ + private Map> getFormMapByPageIds(Set pageIdSet) { + if (CollUtil.isEmpty(pageIdSet)) { + return new HashMap<>(1); + } + List formList = onlineFormService.list(new LambdaQueryWrapper().in(OnlineForm::getPageId, pageIdSet)); + if (CollUtil.isEmpty(formList)) { + return new HashMap<>(1); + } + return formList.stream().collect(Collectors.groupingBy(OnlineForm::getPageId)); + } + + /** + * 组装表单节点:文件字段 + 可用性判定。硬门槛置 kbReady=false,其余只给 reason 预警。 + */ + private OpenApiKbFormNodeDto buildFormNode(OnlineForm form) { + OpenApiKbFormNodeDto node = new OpenApiKbFormNodeDto(); + node.setFormId(form.getFormId()); + node.setFormCode(form.getFormCode()); + node.setFormName(form.getFormName()); + List fileFields = this.getFileFields(form.getMasterTableId()); + node.setFileFields(fileFields); + if (fileFields.isEmpty()) { + node.setKbReady(false); + node.setReason("该表单主表没有附件/图片字段,无法拉取附件"); + return node; + } + JSONObject widgetJson = this.parseWidgetJson(form); + if (!this.hasListTableWidget(widgetJson)) { + node.setKbReady(false); + node.setReason("该表单未配置列表(pc.tableWidget),无法查询数据"); + return node; + } + node.setKbReady(true); + node.setReason(this.buildWarningReason(widgetJson, fileFields)); + return node; + } + + /** + * 取表单主表的文件字段(附件+图片),可再按指定列名过滤。 + *

注意:columnMap 是 @TableField(exist=false) 的临时字段,只有 getOnlineTableFromCache/queryByTableName 会填充, + * getById 不填。用错会导致 fieldKind 全部取不到,进而把所有表单误判为"无附件字段"、清单静默变空。

+ */ + private List getFileColumns(OnlineTable masterTable, String fieldName) { + if (masterTable == null || masterTable.getColumnMap() == null) { + return new ArrayList<>(); + } + return masterTable.getColumnMap().values().stream() + .filter(c -> FieldKind.isFileKind(c.getFieldKind())) + .filter(c -> StrUtil.isBlank(fieldName) || fieldName.equalsIgnoreCase(c.getColumnName())) + .collect(Collectors.toList()); + } + + private List getFileFields(Long masterTableId) { + List result = new ArrayList<>(); + if (masterTableId == null) { + return result; + } + for (OnlineColumn column : this.getFileColumns(onlineTableService.getOnlineTableFromCache(masterTableId), null)) { + OpenApiKbFileFieldDto dto = new OpenApiKbFileFieldDto(); + dto.setColumnName(column.getColumnName()); + dto.setFieldKind(column.getFieldKind()); + dto.setLabel(StrUtil.blankToDefault(column.getColumnComment(), column.getColumnName())); + result.add(dto); + } + return result; + } + + /** + * 组装软预警(不影响 kbReady)。只对"判得准"的情况给提示,避免误拦本来可用的表单。 + */ + private String buildWarningReason(JSONObject widgetJson, List fileFields) { + List warnings = new ArrayList<>(); + if (this.hasLoadListDataPlugin(widgetJson)) { + warnings.add("该表单绑定了列表事件插件,附件可能无法回填,建议先小批量探测"); + } + boolean hasUploadField = fileFields.stream().anyMatch(f -> f.getFieldKind() != null && f.getFieldKind() == FieldKind.UPLOAD); + if (!hasUploadField) { + warnings.add("该表单仅含图片字段,图片文档按现有解析逻辑会落到解析失败,仅作存档"); + } + return warnings.isEmpty() ? null : StrUtil.join(";", warnings); + } + + /** + * 判断表单是否绑定了会响应 loadListData 的插件。 + *

插件配置里没有事件绑定信息(PluginInfo 只有 pluginType/pluginName/pluginMemo/order),运行时是靠反射调用同名方法决定的 + * (见 OnlineFormServiceImpl#executePlugins),因此这里也按方法签名探测。插件类加载不到时按"未绑定"处理: + * 那种情况下 exeListPlugin 本身就会抛异常,属于另一个更早暴露的问题。

+ */ + private boolean hasLoadListDataPlugin(JSONObject widgetJson) { + if (widgetJson == null) { + return false; + } + String listEventCode = EventEnum.LOADLISTDATA.getCode(); + for (String device : new String[]{"pc", "mobile"}) { + JSONObject deviceObj = widgetJson.getJSONObject(device); + JSONArray pluginList = deviceObj == null ? null : deviceObj.getJSONArray("pluginList"); + if (pluginList == null) { + continue; + } + for (int i = 0; i < pluginList.size(); i++) { + JSONObject plugin = pluginList.getJSONObject(i); + String pluginType = plugin == null ? null : plugin.getString("pluginType"); + if (StrUtil.isBlank(pluginType)) { + continue; + } + try { + Class.forName(pluginType).getMethod(listEventCode, String.class, ObjectValue.class); + return true; + } catch (Exception e) { + log.debug("OpenAPI 知识库树:插件类 [{}] 未实现 {},不计入预警", pluginType, listEventCode); + } + } + } + return false; + } + + private JSONObject parseWidgetJson(OnlineForm form) { + if (form == null || StrUtil.isBlank(form.getWidgetJson())) { + return null; + } + try { + return JSON.parseObject(form.getWidgetJson()); + } catch (Exception e) { + log.warn("OpenAPI 知识库树:解析表单 [{}] 的 widgetJson 失败", form.getFormId(), e); + return null; + } + } + + /** + * 判断表单是否配置了列表控件(列表查询路径硬依赖它,缺失会空指针)。 + */ + private boolean hasListTableWidget(JSONObject widgetJson) { + JSONObject pc = widgetJson == null ? null : widgetJson.getJSONObject("pc"); + return pc != null && pc.containsKey("tableWidget"); + } + + // ============ 接口2:文件清单 ============ + + @Override + public ResponseResult> getFileList(Long formId, String fieldName) { + if (formId == null) { + return ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, "formId 不能为空!"); + } + OnlineForm form = onlineFormService.getOnlineFormFromCache(formId); + if (form == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "在线表单不存在!"); + } + OnlineTable masterTable = form.getMasterTableId() == null ? null : onlineTableService.getOnlineTableFromCache(form.getMasterTableId()); + if (masterTable == null || masterTable.getColumnMap() == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "在线表单主表不存在!"); + } + List fileColumnList = this.getFileColumns(masterTable, fieldName); + if (fileColumnList.isEmpty()) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, + StrUtil.isBlank(fieldName) ? "该表单主表没有附件/图片字段,无法拉取附件!" : "字段 [" + fieldName + "] 不是该表单的附件/图片字段!"); + } + OnlineColumn primaryKeyColumn = masterTable.getPrimaryKeyColumn(); + if (primaryKeyColumn == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_VALIDATED_FAILED, "该表单主表没有主键列,无法拉取附件!"); + } + OnlineDatasource datasource = onlineDatasourceService.getOnlineDatasourceByMasterTableId(form.getMasterTableId()); + if (datasource == null) { + return ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "表单数据源不存在!"); + } + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData == null) { + return ResponseResult.error(ErrorCodeEnum.UNAUTHORIZED_LOGIN, "OpenAPI token 无效或已过期!"); + } + // 在线引擎按 TokenData.appCode 校验数据源归属,令牌的 appCode 是开放应用编码,与在线资源归属编码不是一回事, + // 执行期间先切到该表单数据源真正归属的编码,结束后还原。 + String savedAppCode = tokenData.getAppCode(); + try { + tokenData.setAppCode(datasource.getAppCode()); + ResponseResult datasourceResult = onlineOperationHelper.verifyAndGetDatasource(datasource.getDatasourceId()); + if (!datasourceResult.isSuccess()) { + return ResponseResult.errorFrom(datasourceResult); + } + 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) { + return ResponseResult.error(ErrorCodeEnum.DATA_ACCESS_FAILED, "列表查询失败!"); + } + // 列表路径的 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)); + } finally { + tokenData.setAppCode(savedAppCode); + } + } + + /** + * 一次翻页拉完全部记录。 + *

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

+ * + * @return 全部记录行;查询失败返回 null。 + */ + private List> queryAllRows(OnlineForm form, ResponseResult datasourceResult, OnlineColumn primaryKeyColumn) { + 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++; + } + return allRows; + } + + private boolean hasPrimaryKeyValue(List> rawList, String primaryKeyColumnName) { + return rawList.stream().anyMatch(row -> StrUtil.isNotBlank(this.toPlainString(this.getRowValue(row, primaryKeyColumnName)))); + } + + /** + * 把查询结果裁剪成文件清单:每个记录节点只保留主键、记录更新时间与附件条目,并丢弃一个附件都没有的记录。 + *

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

+ */ + private List assembleFileNodeList(List> rawList, String primaryKeyColumnName, List fileColumnList) { + List result = new ArrayList<>(); + if (CollUtil.isEmpty(rawList)) { + return result; + } + for (Map row : rawList) { + Object recordId = this.getRowValue(row, primaryKeyColumnName); + if (ObjectUtil.isEmpty(recordId)) { + continue; + } + List files = new ArrayList<>(); + for (OnlineColumn column : fileColumnList) { + String fileJson = this.toPlainString(this.getRowValue(row, column.getColumnName())); + if (StrUtil.isBlank(fileJson)) { + continue; + } + 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)); + node.setUpdateTime(StrUtil.isBlank(updateTime) ? null : updateTime); + node.setFiles(files); + result.add(node); + } + return result; + } + + /** + * 解析一个文件字段的 JSON 数组,转成附件条目。解析失败只记日志并跳过,避免一条脏数据让整页清单失败。 + */ + private List parseFileItems(String fileJson, String columnName) { + List result = new ArrayList<>(); + JSONArray fileArray; + try { + fileArray = JSON.parseArray(fileJson); + } catch (Exception e) { + log.warn("OpenAPI 知识库清单:字段 [{}] 的附件 JSON 解析失败,已跳过", columnName); + return result; + } + if (fileArray == null) { + return result; + } + for (int i = 0; i < fileArray.size(); i++) { + JSONObject file = fileArray.getJSONObject(i); + String filename = file == null ? null : file.getString("filename"); + if (StrUtil.isBlank(filename)) { + continue; + } + OpenApiKbFileItemDto item = new OpenApiKbFileItemDto(); + item.setFieldName(columnName); + item.setFilename(filename); + item.setFileRealName(this.toRealFileName(filename)); + result.add(item); + } + return result; + } + + /** + * 取真实文件名。本地存储的 filename 形如 uuid@真实名(见 LocalUpDownloader),取**第一个** @ 之后的部分, + * 与平台侧 filename.split("@")[1] 的语义一致;没有 @ 时回退用 filename 本身。 + */ + private String toRealFileName(String filename) { + int index = filename.indexOf('@'); + return index < 0 || index == filename.length() - 1 ? filename : filename.substring(index + 1); + } + + // ============ 接口3:附件下载 ============ + + @Override + public void download(Long formId, String dataId, String fieldName, String filename, HttpServletResponse response) throws IOException { + if (formId == null || StrUtil.isBlank(dataId) || StrUtil.isBlank(fieldName) || StrUtil.isBlank(filename)) { + ResponseResult.output(HttpServletResponse.SC_BAD_REQUEST, + ResponseResult.error(ErrorCodeEnum.ARGUMENT_NULL_EXIST, "formId、dataId、fieldName、filename 均不能为空!")); + return; + } + OnlineForm form = onlineFormService.getOnlineFormFromCache(formId); + OnlineTable masterTable = form == null || form.getMasterTableId() == null ? null : onlineTableService.getOnlineTableFromCache(form.getMasterTableId()); + if (masterTable == null || masterTable.getColumnMap() == null) { + ResponseResult.output(HttpServletResponse.SC_NOT_FOUND, ResponseResult.error(ErrorCodeEnum.DATA_NOT_EXIST, "在线表单主表不存在!")); + return; + } + OnlineColumn fileColumn = this.getFileColumns(masterTable, fieldName).stream().findFirst().orElse(null); + if (fileColumn == null) { + ResponseResult.output(HttpServletResponse.SC_FORBIDDEN, + ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION, "指定的字段不是该表单的附件/图片字段!")); + return; + } + Long businessId = this.toLongId(dataId); + if (businessId == null) { + ResponseResult.output(HttpServletResponse.SC_BAD_REQUEST, + ResponseResult.error(ErrorCodeEnum.INVALID_ARGUMENT_FORMAT, "dataId 不是合法的记录主键!")); + return; + } + // 归属校验:OnlineOperationHelper#doDownload 中"记录是否存在、是否真的包含该 filename"的校验已被整段注释掉, + // 而本接口按决策不做应用级授权,这条校验是唯一的越权防线。不补的话,知道一个 filename 就能拖走该记录的附件。 + List fileList = onlineFileService.getOnlineFileList(masterTable.getTableId(), fileColumn.getColumnName(), businessId); + boolean owned = fileList != null && fileList.stream().anyMatch(f -> filename.equals(f.getFileName())); + if (!owned) { + ResponseResult.output(HttpServletResponse.SC_FORBIDDEN, + ResponseResult.error(ErrorCodeEnum.NO_OPERATION_PERMISSION, "该附件不属于指定的记录!")); + return; + } + // asImage 不接受外部入参:传错会让 doVerifyUpDownloadFileColumn 直接 403,按列的 fieldKind 推导最稳。 + boolean asImage = fileColumn.getFieldKind() != null && fileColumn.getFieldKind() == FieldKind.UPLOAD_IMAGE; + // 传规范化后的列名:doVerifyUpDownloadFileColumn 是大小写敏感的精确匹配。 + onlineOperationHelper.doDownload(masterTable, dataId, fileColumn.getColumnName(), filename, asImage, response); + } + + // ============ 公共辅助 ============ + + /** + * 按列名从结果行取值。ORM 选择列取的是小写列名,而 fillFileColumn 回填时用的是原始列名, + * 因此这里逐级回退,最后再走一次忽略大小写的匹配。 + */ + private Object getRowValue(Map row, String columnName) { + if (row == null || StrUtil.isBlank(columnName)) { + return null; + } + Object value = row.get(columnName); + if (value == null) { + value = row.get(columnName.toLowerCase()); + } + if (value == null) { + value = row.get(StrUtil.toUnderlineCase(columnName)); + } + if (value != null) { + return value; + } + for (Map.Entry entry : row.entrySet()) { + if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(columnName)) { + return entry.getValue(); + } + } + return null; + } + + /** + * 转成字符串:附件字段回填的是 JSON 字符串;collectionToJson 会把 null 统一写成 "",这里一并按空处理。 + */ + private String toPlainString(Object value) { + if (value == null) { + return null; + } + if (value instanceof String) { + return (String) value; + } + return JSON.toJSONString(value); + } + + /** + * 安全地把入参解析为整数(兼容 1.0 等数值形态),非法/缺省回退默认值,并夹到 [min, max]。 + */ + private int toIntParam(Object value, int defaultValue, int min, int max) { if (value == null || StrUtil.isBlank(String.valueOf(value))) { + return defaultValue; + } + try { + long longValue = new BigDecimal(String.valueOf(value).trim()).longValue(); + if (longValue < min) { + return min; + } + if (longValue > max) { + return max; + } + return (int) longValue; + } catch (Exception e) { + return defaultValue; + } + } + + /** + * 把主键值解析为 Long(zz_online_file.business_id 为 Long)。解析失败回退 null。 + */ + private Long toLongId(String id) { + if (StrUtil.isBlank(id)) { + return null; + } + try { + return new BigDecimal(id.trim()).longValue(); + } catch (Exception e) { + return null; + } + } +}