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