Browse Source
- 新增OpenApiKbController提供知识库开放接口,包括页面表单树查询、文件清单获取和附件下载功能 - 新增多个DTO类用于知识库接口的数据传输,包括OpenApiKbFileNodeDto、OpenApiKbPageNodeDto等 - 实现OpenApiKbService接口及OpenApiKbServiceImpl服务类,提供完整的业务逻辑 - 优化OpenApiAuthInterceptor支持多种token请求头格式,优先取token后兼容accessToken - 更新OpenApiAuthServiceImpl的token验证逻辑,支持Bearer前缀的兼容处理 - 引入StrUtil工具类进行字符串非空验证,增强代码健壮性 - 添加详细的接口文档说明,包括调用流程和参数说明feature/2026-08/0812-ccc-dev
13 changed files with 928 additions and 4 deletions
@ -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); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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…
Reference in new issue