Browse Source

feat(common-openapi): 新增知识库开放接口并优化token验证

- 新增OpenApiKbController提供知识库开放接口,包括页面表单树查询、文件清单获取和附件下载功能
- 新增多个DTO类用于知识库接口的数据传输,包括OpenApiKbFileNodeDto、OpenApiKbPageNodeDto等
- 实现OpenApiKbService接口及OpenApiKbServiceImpl服务类,提供完整的业务逻辑
- 优化OpenApiAuthInterceptor支持多种token请求头格式,优先取token后兼容accessToken
- 更新OpenApiAuthServiceImpl的token验证逻辑,支持Bearer前缀的兼容处理
- 引入StrUtil工具类进行字符串非空验证,增强代码健壮性
- 添加详细的接口文档说明,包括调用流程和参数说明
feature/2026-08/0812-ccc-dev
chenchuchuan 2 weeks ago
parent
commit
b679ad5c47
  1. 2
      common/common-openapi/pom.xml
  2. 90
      common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiKbController.java
  3. 25
      common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileFieldDto.java
  4. 25
      common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileItemDto.java
  5. 27
      common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileNodeDto.java
  6. 21
      common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileQueryDto.java
  7. 39
      common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFormNodeDto.java
  8. 39
      common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbPageNodeDto.java
  9. 21
      common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbTreeQueryDto.java
  10. 19
      common/common-openapi/src/main/java/apelet/common/openapi/interceptor/OpenApiAuthInterceptor.java
  11. 50
      common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiKbService.java
  12. 9
      common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAuthServiceImpl.java
  13. 565
      common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiKbServiceImpl.java

2
common/common-openapi/pom.xml

@ -31,7 +31,7 @@ @@ -31,7 +31,7 @@
<dependency>
<groupId>apelet</groupId>
<artifactId>common-online</artifactId>
<version>1.0.5</version>
<version>1.0.6</version>
</dependency>
<!-- 表单元数据:OnlFormHead / OnlFormField / IOnlFormHeadService,开放接口字段清单与关联字段属性换算依赖 -->
<dependency>

90
common/common-openapi/src/main/java/apelet/common/openapi/controller/OpenApiKbController.java

@ -0,0 +1,90 @@ @@ -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 知识库开放接口(固定路径,供知识库侧跨系统调用)。
* <p>三个接口都挂在 {urlPrefix}/v1/kb/** 下,由 OpenApiAuthInterceptor 统一做 token 校验与 TokenData 注入;
* 按已对齐决策不做应用级授权(不校验 xy_sys_open_app_api 关联),因此消费侧无需配置开放接口、无需授权。</p>
* <p>鉴权请求头为 {@code token: <accessToken>}(不带 Bearer 前缀)。</p>
* <p>调用顺序:pageFormTree 锁定表单与附件字段 → files 取清单做增量比对 → download 逐个下载。</p>
*
* @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<List<OpenApiKbPageNodeDto>> 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<List<OpenApiKbFileNodeDto>> 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);
}
}

25
common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileFieldDto.java

@ -0,0 +1,25 @@ @@ -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;
}

25
common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileItemDto.java

@ -0,0 +1,25 @@ @@ -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;
}

27
common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileNodeDto.java

@ -0,0 +1,27 @@ @@ -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<OpenApiKbFileItemDto> files;
}

21
common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFileQueryDto.java

@ -0,0 +1,21 @@ @@ -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;
}

39
common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbFormNodeDto.java

@ -0,0 +1,39 @@ @@ -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<OpenApiKbFileFieldDto> fileFields;
}

39
common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbPageNodeDto.java

@ -0,0 +1,39 @@ @@ -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<OpenApiKbFormNodeDto> children;
}

21
common/common-openapi/src/main/java/apelet/common/openapi/dto/OpenApiKbTreeQueryDto.java

@ -0,0 +1,21 @@ @@ -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;
}

19
common/common-openapi/src/main/java/apelet/common/openapi/interceptor/OpenApiAuthInterceptor.java

@ -5,6 +5,7 @@ import apelet.common.core.object.ResponseResult; @@ -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; @@ -19,6 +20,8 @@ import javax.servlet.http.HttpServletResponse;
* OpenAPI 对外开放路由({urlPrefix}/v1/**)的 token 校验拦截器。
* <p>在 SpringMVC 拦截器链中注册(见接入方 InterceptorConfig),只拦截 {urlPrefix}/v1/**;
* token 签发(/auth/token)与配置管理(/config/**、/app/**,走应用自身登录态)不经过本拦截器。</p>
* <p>支持的请求头(按顺序取第一个非空者):<code>token</code>(推荐,值为 accessToken 原值,不带 Bearer 前缀)、
* <code>accessToken</code>(带 Bearer 前缀)。见 OpenApiAuthServiceImpl#validateToken。</p>
*
* @author chenchuchuan
* @date 2026-09-02
@ -40,8 +43,7 @@ public class OpenApiAuthInterceptor implements HandlerInterceptor { @@ -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 { @@ -52,4 +54,17 @@ public class OpenApiAuthInterceptor implements HandlerInterceptor {
TokenData.addToRequest(tokenData);
return true;
}
/**
* 取请求头中的 token:优先 <code>token</code>(新增约定,值即 accessToken 原值),兼容 <code>accessToken</code>(带 Bearer 前缀)。
* <p>接入方的 CORS 配置均为 addAllowedHeader("*")(tenant-admin FilterConfig / gateway CorsConfig),
* 自定义头无需额外声明;若将来收窄为白名单,需把 token 头一并列入,否则浏览器预检就会被拦下。</p>
*/
private String resolveToken(HttpServletRequest request) {
String token = request.getHeader("token");
if (StrUtil.isNotBlank(token)) {
return token;
}
return request.getHeader("accessToken");
}
}

50
common/common-openapi/src/main/java/apelet/common/openapi/service/OpenApiKbService.java

@ -0,0 +1,50 @@ @@ -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/**,供知识库侧跨系统调用)。
* <p>两步走:先取页面表单树锁定目标表单与附件字段,再取文件清单做增量比对,最后逐个下载文件。</p>
*
* @author chenchuchuan
*/
public interface OpenApiKbService {
/**
* 查询页面 → 表单两层树,树上带表单的文件字段与可用性判定。
*
* @param keyword 页面名称模糊关键字,可为空。
* @param onlyReady 是否只返回可用表单,为空按 true 处理。
* @return 页面节点列表,父节点为在线页面,children 为表单节点。
*/
List<OpenApiKbPageNodeDto> getPageFormTree(String keyword, Boolean onlyReady);
/**
* 按表单(可选按附件字段)查询全部文件清单,供消费侧做增量比对后再逐个下载。
* <p>清单不分页:消费侧靠它做增量比对,只给一页会让其余记录的文档被误判成"已删除"。内部按 1000 条一页翻完,
* 记录数超过 {@code SCAN_LIMIT} 时截断并记 warn 日志。</p>
*
* @param formId 在线表单主键Id。
* @param fieldName 附件字段的数据库列名,为空时返回该表单全部文件字段的附件。
* @return 记录节点列表(含 files 子数组),直接作为响应的 data。
*/
ResponseResult<List<OpenApiKbFileNodeDto>> 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;
}

9
common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiAuthServiceImpl.java

@ -103,7 +103,14 @@ public class OpenApiAuthServiceImpl implements OpenApiAuthService { @@ -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;
}

565
common/common-openapi/src/main/java/apelet/common/openapi/service/impl/OpenApiKbServiceImpl.java

@ -0,0 +1,565 @@ @@ -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;
/**
* 知识库对外开放接口服务实现。
* <p>三个固定路径接口都挂在 {urlPrefix}/v1/kb/** 下,由 OpenApiAuthInterceptor 做 token 校验;按已对齐决策不做应用级授权。</p>
*
* @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<OpenApiKbPageNodeDto> getPageFormTree(String keyword, Boolean onlyReady) {
// 默认只返回可用表单,避免消费侧选到"没有附件字段"或"查不出数据"的表单,同步空跑一趟才发现。
boolean readyOnly = onlyReady == null || onlyReady;
List<OnlinePage> pageList = this.getPublishedPageList(keyword);
if (CollUtil.isEmpty(pageList)) {
return new ArrayList<>();
}
List<OpenApiKbPageNodeDto> result = new ArrayList<>();
Map<Long, List<OnlineForm>> formMap = this.getFormMapByPageIds(pageList.stream().map(OnlinePage::getPageId).collect(Collectors.toSet()));
for (OnlinePage page : pageList) {
List<OpenApiKbFormNodeDto> 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<OnlinePage> getPublishedPageList(String keyword) {
LambdaQueryWrapper<OnlinePage> 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<OnlinePage> pageList = onlinePageService.list(wrapper);
return pageList == null ? new ArrayList<>() : pageList;
}
/**
* 一次查出这批页面下的全部表单,避免在循环里逐页查库。
*/
private Map<Long, List<OnlineForm>> getFormMapByPageIds(Set<Long> pageIdSet) {
if (CollUtil.isEmpty(pageIdSet)) {
return new HashMap<>(1);
}
List<OnlineForm> formList = onlineFormService.list(new LambdaQueryWrapper<OnlineForm>().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<OpenApiKbFileFieldDto> 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;
}
/**
* 取表单主表的文件字段(附件+图片),可再按指定列名过滤。
* <p>注意:columnMap 是 @TableField(exist=false) 的临时字段,只有 getOnlineTableFromCache/queryByTableName 会填充,
* getById 不填。用错会导致 fieldKind 全部取不到,进而把所有表单误判为"无附件字段"、清单静默变空。</p>
*/
private List<OnlineColumn> 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<OpenApiKbFileFieldDto> getFileFields(Long masterTableId) {
List<OpenApiKbFileFieldDto> 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<OpenApiKbFileFieldDto> fileFields) {
List<String> 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 的插件。
* <p>插件配置里没有事件绑定信息(PluginInfo 只有 pluginType/pluginName/pluginMemo/order),运行时是靠反射调用同名方法决定的
* (见 OnlineFormServiceImpl#executePlugins),因此这里也按方法签名探测。插件类加载不到时按"未绑定"处理:
* 那种情况下 exeListPlugin 本身就会抛异常,属于另一个更早暴露的问题。</p>
*/
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<List<OpenApiKbFileNodeDto>> 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<OnlineColumn> 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<OnlineDatasource> 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<Map<String, Object>> 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);
}
}
/**
* 一次翻页拉完全部记录。
* <p>清单必须完整:消费侧靠它做增量比对(updateTime 变没变、记录还在不在),只取一页会导致其余记录的文档被判成"已删除"。
* 每页 1000 条(引擎上限),循环翻到累计条数达到 total 为止。真正的安全阀是 scanLimit,单次调用最多扫这么多个记录节点。</p>
*
* @return 全部记录行;查询失败返回 null。
*/
private List<Map<String, Object>> queryAllRows(OnlineForm form, ResponseResult<OnlineDatasource> datasourceResult, OnlineColumn primaryKeyColumn) {
OnlFormHead onlFormHead = onlFormHeadService.getHeadTableNameCache(datasourceResult.getData().getMasterTable().getTableName());
if (onlFormHead == null) {
log.warn("OpenAPI 知识库清单:表单 [{}] 的表头不存在", form.getFormId());
return null;
}
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> rawList, String primaryKeyColumnName) {
return rawList.stream().anyMatch(row -> StrUtil.isNotBlank(this.toPlainString(this.getRowValue(row, primaryKeyColumnName))));
}
/**
* 把查询结果裁剪成文件清单:每个记录节点只保留主键、记录更新时间与附件条目,并丢弃一个附件都没有的记录。
* <p>丢弃无附件记录既省流量,也让消费侧的 changeKey diff 语义自洽:某条记录从清单里消失,
* 就代表它不再有附件,应当删除对应文档。</p>
*/
private List<OpenApiKbFileNodeDto> assembleFileNodeList(List<Map<String, Object>> rawList, String primaryKeyColumnName, List<OnlineColumn> fileColumnList) {
List<OpenApiKbFileNodeDto> result = new ArrayList<>();
if (CollUtil.isEmpty(rawList)) {
return result;
}
for (Map<String, Object> row : rawList) {
Object recordId = this.getRowValue(row, primaryKeyColumnName);
if (ObjectUtil.isEmpty(recordId)) {
continue;
}
List<OpenApiKbFileItemDto> 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<OpenApiKbFileItemDto> parseFileItems(String fileJson, String columnName) {
List<OpenApiKbFileItemDto> 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<OnlineFile> 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<String, Object> 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<String, Object> 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;
}
}
}
Loading…
Cancel
Save