Skip to content

数据接口 ​

适用端:后端 + Web | 类型:机制

定制功能

数据接口(菜单里叫「接口管理」)是本项目自研能力,上游 yudao 的 infra 只有数据源配置与代码生成,没有「把一段 SQL 变成可调用接口」这一层。

数据接口把一段 SQL 原文(或一份静态 JSON、一个 HTTP 转发配置)包装成可按编码调用的接口,供表单控件下拉、门户卡片、Agent 工具取数。它不是 ORM,也不是查询 DSL —— 配置里存的就是你要执行的那条语句。

前置阅读:后端架构与模块依赖、数据库约定与表总览。

它解决什么问题 ​

三件事必须分清,否则读代码会绕晕:

  1. 配置的是 SQL 原文,不是 DSL。type=1 时 config 列直接存 SQL 文本,占位符是 MyBatis 风格的 #{name};type=2 存 JSON 数据;type=3 存 POSTMAN 式 HTTP 配置。
  2. 入参有声明、出参没有。入参声明在 params 列(ParamDefinition 数组:name/label/type/required/defaultValue/remark),未声明的请求参数会被直接丢弃;出参形状由执行器决定,可以用 script(JS)后处理。
  3. sql_statement_type 是用户在界面显式选的,不是推断出来的(DataInterfaceDO.java:100-105)。界面能「探测」,但探测结果要用户确认。

代码在哪 ​

路径前缀 enlev-module-infra/src/main/java/cn/enlev/ainb/module/infra/:

职责文件关键方法
HTTP 端点controller/admin/datainterface/DataInterfaceController.javainvoke / preview / options
编排(CRUD+版本+日志)service/datainterface/DataInterfaceServiceImpl.javadoExecute:385、analyzeSqlIfNeeded:758
执行器路由framework/datainterface/core/executor/InterfaceExecutor.javagetExecutor:129
SQL 执行framework/datainterface/core/executor/SqlExecutor.javaexecute:77、isTenantTable:256
HTTP 转发framework/datainterface/core/executor/HttpExecutor.javaexecute:92、validateUrl:344
静态 JSONframework/datainterface/core/executor/StaticExecutor.javaexecute:30
参数绑定与校验framework/datainterface/core/param/{SqlParamBinder, ParamValidator, ParamDefinition}.javabind:130
语句分析 / 策略 / LIMIT / 租户 / COUNTframework/datainterface/core/sql/{SqlStatementAnalyzer, SqlPolicyValidator, SqlLimitEnforcer, SqlTenantRewriter, CountSqlRewriter}.java—
JS 后处理framework/datainterface/core/script/ScriptPostProcessor.javaprocess:85
跨模块 API(Agent 用)api/datainterface/{DataInterfaceApi, DataInterfaceApiImpl}.javaqueryData:74、invokeData:79
租户谓词依赖framework/datamodel/core/TableMetaReader.javagetColumnNames:79
枚举enums/datainterface/{DataInterfaceTypeEnum, SqlStatementTypeEnum}.java—
Agent 工具enlev-module-agent/.../tool/impl/datainterface/DataInterfaceQueryTool、DataInterfaceInvokeTool
前端src/{api,views}/infra/dataInterface/、src/components/FormCreate/src/utils/dataInterfaceSource.tsfetchOptions

核心机制 ​

一次 /invoke 的完整链路 ​

SqlExecutor.execute:77 里顺序是固定的,改动前想清楚为什么是这个顺序:

java
SqlParamBinder.BindResult bind = SqlParamBinder.bind(rawSql,
        context.getParamDefinitions(), finalParams);
SqlStatementAnalyzer.AnalyzeResult analyze = SqlStatementAnalyzer.analyze(bind.getSql());
if (!analyze.isSuccess()) {
    throw exception(DATA_INTERFACE_SQL_PARSE_FAIL, analyze.getErrorMsg());
}
SqlPolicyValidator.validate(analyze);
Long tenantId = TenantContextHolder.getTenantId();
SqlTenantRewriter rewriter = new SqlTenantRewriter(this::isTenantTable);
SqlTenantRewriter.RewriteResult rewrite = rewriter.rewrite(bind.getSql(), tenantId);

绑定必须在解析之前:#{name} 不是合法 SQL 词法,JSqlParser 解析不了。租户改写必须在策略校验之后:校验的是配置者写的原文,改写产物是框架自己生成的,不需要再过一遍黑名单。

参数是怎么进 SQL 的 —— PreparedStatement 占位符,值不拼字符串 ​

SqlParamBinder.bind:130-155 把 #{name} 逐个换成 ?,按序把值收进 args,最后交给 jdbcTemplate.queryForList(sql, args)。三道额外约束:

  • 占位符名字受 PLACEHOLDER_PATTERN 限制为 [A-Za-z_][A-Za-z0-9_]*(:52);
  • ParamValidator 只放行已声明的参数,未声明的请求参数被丢弃(ParamValidatorTest:128-130);
  • 内置变量 _userId / _tenantId / _deptId 由服务端上下文 putAll 覆盖,用户传同名无效(:135、:163-169)。

排序没有独立机制,ORDER BY 只能写死在 SQL 里。分页是约定式而非自动改写:开启后要求 SQL 自带 LIMIT #{pageSize} OFFSET #{offsetSize},缺失直接报错(SqlLimitEnforcer.java:45-50);offsetSize = (pageNo-1) * pageSize;未开分页的查询自动注入 LIMIT 10000(:53-56);预览在结果侧截断 100 条而不改 SQL(SqlExecutor:242-251)。

租户隔离靠改写 SQL,不靠 MyBatis 拦截器 ​

数据接口用的是裸 JdbcTemplate,压根不经过 MyBatis 执行器,所以 TenantDatabaseInterceptor 帮不上忙。SqlTenantRewriter 用 JSqlParser 遍历 AST,给每张「有 tenant_id 列」的表补上 tenant_id = <当前租户>。

「有没有 tenant_id 列」的判定在 SqlExecutor.isTenantTable:256:

java
private boolean isTenantTable(String tableName) {
    return tenantTableCache.computeIfAbsent(tableName.toLowerCase(), name -> {
        try {
            Set<String> columns = new TableMetaReader(jdbcTemplate).getColumnNames(name);
            return columns.stream()
                    .anyMatch(column -> SqlTenantRewriter.TENANT_COLUMN.equalsIgnoreCase(column));
        } catch (Exception ex) {
            // 读不到表结构(如跨库表、视图)时保守处理:不注入,
            // 但这类表通常也不含租户字段
            log.warn("[isTenantTable][读取表({})结构失败]", name, ex);
            return false;
        }
    });
}

四个后果要记住:

  1. 缓存 tenantTableCache 是 ConcurrentHashMap(:69),无 TTL、无失效,注释明写「表结构真变了重启即可」。给表补上 tenant_id 列后旧的 false 不会刷新。
  2. 表没有 tenant_id 列 → 谓词为 false → 不注入(SqlTenantRewriterTest:59-64)。
  3. 读元数据抛异常(跨库表、视图)→ catch 后也返回 false → 不注入(:262-267)。这是 fail-open,与 SqlTenantRewriter 自己的口径相反 —— 后者遇到 tenantId == null 或解析失败是整体拒绝(SqlTenantRewriter.java:122-133),fail-close。
  4. 注入值是字面量 LongValue 而不是占位符(:112-116 说明理由:值来自服务端上下文,且便于日志核对)。

tenant-id 请求头不可伪造 —— TenantSecurityWebFilter.java:76-83 会把它与登录用户的租户比对,不一致直接 403。

INSERT 有特殊处理:补列补值;用户自己写了 tenant_id 会被强制改成当前租户(:227-233);不写字段列表则拒绝(:200-202)。

安全模型:数据接口用策略黑名单,datamodel 用标识符白名单 ​

这是本项目里两套并存但完全不同的 SQL 安全机制,别混。

数据接口(datainterface)数据模型 / 动态 CRUD(datamodel)
SQL 来源配置者写的整段自由文本框架用表名/列名拼出来
能不能白名单不能能
机制JSqlParser 解析(CCJSqlParserUtil.parseStatements)+ 策略黑名单SqlSafeGuard 标识符白名单
拦什么禁 DDL、禁 OTHER(认不出的按最危险处理)、禁对 SystemTablePolicy.WRITE 前缀表写正则 ^[A-Za-z_][A-Za-z0-9_]{0,63}$ + MySQL 保留字 + 受管表登记 + 列真实存在
读一律放行受三关校验

框架表写保护的前缀清单在 SqlPolicyValidator.java:47-75:act_ / ai_ / bpm_ / flw_ / infra_ / qrtz_ / sys_ / system_。

SqlSafeGuard 在数据接口链路上是个死 import

SqlExecutor.java:12 有一行 import ...datamodel.core.SqlSafeGuard;,但全类没有任何调用。全仓 main 代码里 SqlSafeGuard. 的调用点全在 datamodel 侧(DataModelServiceImpl、DataModelDataServiceImpl、MysqlDdlBuilder、DynamicSqlExecutor、TableMetaReader.countRows:125、DataModelApiImpl:246)。看到这个 import 不要以为数据接口的表名过了那道白名单。

权限:只有功能权限串,完全不走行级权限 ​

那套自研的行级权限(sys_rule + sys_slot_mapping + FormRowFilter)只作用于 formdesign 路径。数据接口对它零引用 —— framework/service/controller/api 下的 datainterface 包里没有 FormRowFilter、FormRowScopeGuard、RuleDO、PermissionApi、sys_grant 的任何一处引用。

这意味着:一个数据接口只要能调,就能查到 SQL 写得出来的全部行。 租户隔离有(上一节),行级隔离没有。要按人过滤,只能靠内置变量自己在 SQL 里写 WHERE create_user_id = #{_userId}。

三个执行入口口径不同,这是刻意设计的:

入口权限为什么
POST /invokeinfra:data-interface:invoke管理面主动调用,可以执行写型接口
POST /options无 @PreAuthorize,仅登录态表单控件运行时取数,理由注释在 DataInterfaceController.java:156-167;靠 Service 层 invokeReadOnly → isReadOnly:363 强制只读
POST /previewinfra:data-interface:query挂只读的 query 权限;写型接口再由 validatePreviewWritePermission:298 补查 infra:data-interface:write-sql

保存含写 SQL 的接口需要 riskConfirmed=true + infra:data-interface:write-sql(:797-802、:857-868)。回滚路径单独补一次且判据更严 —— 用 !isReadOnly(snapshot) 覆盖三种类型(:660-663)。

Agent 侧:DataInterfaceQueryTool.permission = "agent:assistant:use";DataInterfaceInvokeTool.permission = "infra:data-interface:invoke" + risk = HIGH + requireConfirm = true。

日志必须在独立事务里提交 ​

saveLog:579 用 PROPAGATION_REQUIRES_NEW(:597-599)。原因很实在:invoke 和 preview 都标了 @Transactional(rollbackFor = Exception.class),如果日志跟着外层事务走,失败日志会随外层回滚一起消失 —— 而失败日志恰恰是最该留下的那份。

数据模型 ​

三张表均继承 BaseDO(creator/create_time/updater/update_time/deleted/tenant_id)。DDL 见 ainb/sql/mysql/ainb-init.sql:14082,与 docs/.generated/tables-lowcode.md(脚本生成物,不是站点页面)一致 —— 以脚本生成物为准,本表只列要点。

表关键字段索引
infra_data_interfacecode、type(1 SQL/2 JSON/3 HTTP)、status、config(text)、params(text)、script(text)、sql_statement_type、has_write_operation、has_page、count_sql、base_url、allow_internal_url、data_source_config_id、category、tags(逗号串,StringListTypeHandler)、version、log_enabledPRIMARY(id);idx_code(code) — 非唯一
infra_data_interface_versioninterface_id、version、snapshot(text,整份配置 JSON)、change_summaryPRIMARY(id);idx_interface_id
infra_data_interface_loginterface_id、interface_code、invoke_type(1 预览/2 调用)、request_params、execute_sql、success、error_msg(500)、error_detail(4000)、result_count、duration、user_id、user_ipPRIMARY(id);idx_interface_id;idx_create_time

三张表都有 tenant_id,故 Mapper 查询由 MyBatis-Plus 租户拦截器自动加条件(ignore-tables 里只有 agent_model_price,见 application.yaml:404-408)。

code 没有唯一索引

idx_code 是普通索引(ainb-init.sql:14111),唯一性只靠 validateCodeUnique:740 的应用层检查。并发创建可产生重复 code,之后 selectByCode(DataInterfaceMapper.selectByCode:18)走 selectOne 会抛 TooManyResultsException。

接口清单 ​

基路径 /infra/data-interface(完整清单由 docs/.generated/api-infra.md 脚本生成,此处只列与机制相关的):

方法路径权限串摘要
POST/createinfra:data-interface:create创建
PUT/updateinfra:data-interface:update更新(更新前自动存版本快照)
DELETE/deleteinfra:data-interface:delete删除
PUT/update-statusinfra:data-interface:update启用/停用
GET/getinfra:data-interface:query获得单个
GET/pageinfra:data-interface:query分页
POST/detect-sqlinfra:data-interface:query探测 SQL 语句类型(只解析不拦截,解析失败也返 200)
POST/previewquery + Service 层 write-sql在线测试,回传实际执行的 SQL
POST/invokeinfra:data-interface:invoke按 code 调用
GET/simple-listinfra:data-interface:query可用于运行时取数的接口列表(只下发签名)
POST/options无(仅登录态)运行时取数,只允许读取型接口
GET/version-page、/get-versioninfra:data-interface:query版本历史 / 指定版本快照
PUT/rollbackupdate + Service 层 write-sql回滚(自身也产生新版本)
GET/log-pageinfra:data-interface:query调用日志分页
GET/list-type、/list-statement-typeinfra:data-interface:query类型 / 动作类型选项

怎么扩展 ​

新增一种接口类型(例如存储过程) ​

按顺序改,跳步会在运行时才发现:

  1. DataInterfaceTypeEnum 加枚举值;
  2. 新建 XxxExecutor implements InterfaceExecutor 并标 @Component —— getExecutor:129 由 Spring 注入 List<InterfaceExecutor> 自动建索引,编排层不用改;
  3. 🔴 必须在 isReadOnly:363 加分支。漏了会默认返回 false,于是 /options 与 Agent 的 query 工具全部拒绝调用 —— 表现为「管理页能测通、表单里取不到数」;
  4. doExecute:410-413 决定要不要 wrapStandardResponse;
  5. analyzeSqlIfNeeded:758 加保存期校验(HTTP 类型的 validateHttpConfigIfNeeded:820 是现成范式);
  6. rollbackVersion:668-689 是逐字段回滚清单,新字段要补进去,否则回滚后该字段保持新值;
  7. 前端 DataInterfaceForm.vue + components/XxxConfig.vue。

/list-type 会自动带出新类型,不用另外登记。

新增参数类型 ​

ParamDefinition.type 是自由字符串,真正的类型转换在 ValueConverter.convert:73-97 的 switch 里,类型集由 DataModelColumnTypeEnum 决定 —— 两处必须同改。

新增内置变量 ​

改 SqlParamBinder 的常量定义 + resolveBuiltinVariables:163。内置变量的关键性质是「用户传同名无效」,新增时保持这个语义,否则会变成可伪造的上下文。

坑与注意 ​

真会咬人的 ​

  1. 🔴 countSql 完全不过策略校验。保存期 analyzeSqlIfNeeded 只看 getConfig();执行期 SqlExecutor:279-291 只做参数绑定 + 租户改写,没有 SqlStatementAnalyzer / SqlPolicyValidator / SqlLimitEnforcer。也就是说 count_sql 这一列绕过了全部三道关。且 validateParamsDeclared:930 也只扫 config —— 只在 countSql 里用的参数不会在保存时被要求声明,运行时才抛 PARAM_NOT_DEFINED。另外 getStatementSqlList().get(0) 只取第一条,多写的语句被静默丢弃。
  2. 🔴 COUNT 失败被静默吞掉:executePaginatedQuery catch 全部异常后 total = 0L(:309-312)。表现为「列表有数据、分页器显示共 0 条」。
  3. 🔴 CountSqlRewriter 是死代码:main 侧零引用(已核实,只有它自己的类声明),只有 CountSqlRewriterTest 在测。实际生效的是 SqlExecutor.generateCountSql:327,用 replaceAll("(?i)\\s+LIMIT\\s+[^;]*", "") 剥 LIMIT —— [^;]* 会吃掉 LIMIT 之后到分号的全部内容,子查询里的 LIMIT 也会被误剥。
  4. pageSize 无上限:requestArgsDefaultHandler:524-528 只 parseInt 不钳制,SqlLimitEnforcer 开了分页就只检查 LIMIT 是否存在、不管值(:45-50)。任何登录用户经 /options 可请求超大页。非数字入参会抛裸 NumberFormatException(500),不是 ServiceException。
  5. 分页默认值算了两遍:ServiceImpl:432-532(按 ParamDefinition.defaultValue)与 SqlExecutor:141-157(按 requestArgs),口径不同,改一处漏一处会导致 offset 不一致。
  6. 🔴 HTTP 相对路径会跳过 SSRF 内网校验:无 scheme 的 URL 被拼成 http://localhost:<port>,然后 validateUrl(url, allowInternal || isRelativePath)(HttpExecutor:111-115),并透传当前用户的 Authorization / Cookie / tenant-id(:420-428)。等于配置者可让服务端带当前用户身份打本机任意 admin-api。allowInternalUrl=1 可显式放开全部内网。
  7. HTTP 的 URL/body 是字符串替换而非参数化,encode:332-334 只替换 & # ? 三个字符。
  8. tenantTableCache 无失效(见「租户隔离」一节),key 只有小写表名、不含 schema。
  9. 无 tenant_id 列 / 元数据读取失败时 fail-open(:262-267 注释「保守处理:不注入」)。视图、跨库表、无租户列的表不受隔离。
  10. 改写器的覆盖面是枚举式的:collectFromItem:283-300 只处理 Table 与 ParenthesedSelect,其它 FromItem 静默跳过;handleSubSelectInExpression:308-356 同理只覆盖列出的表达式类型(注释自承「粗筛不命中时不递归」)。
  11. SqlLimitEnforcer.injectLimit 解析失败时字符串拼接兜底 sql + " LIMIT " + limit(:104-106),注释自承「有风险但至少加了限制」。

刻意的取舍,不要「修好」 ​

  1. dataSourceConfigId 是装饰性字段:DO/VO/DDL 注释都写「SQL 类型使用」,但执行路径零引用,SqlExecutor 用的是注入的主 JdbcTemplate(:60)。DataInterfaceServiceImpl:286 的注释还拿它当「预览回滚管不到」的理由。配套错误码 DATA_INTERFACE_DATA_SOURCE_NOT_EXISTS 零引用。
  2. 预览回传改写后的完整 SQL(Controller:139、ServiceImpl:258-262)是刻意的(便于核对租户条件是否注入正确),代价是把表结构与字段名暴露给持 query 权限者。同理 infra_data_interface_log 明文留存 execute_sql 与 request_params(截断 4000),业务参数含敏感值会长期留存。
  3. SqlStatementAnalyzer 支持多语句是业务需要(主子表一次写),事务由外层 @Transactional 兜住。
  4. 菜单叫「接口管理」而代码/权限串叫「数据接口」(Controller:40-43);前端按钮叫「测试」而路径叫 preview(:137-138);category 字段界面叫「来源」(DataInterfaceDO:44-54)—— 三处都是刻意分歧。
  5. 死错误码:DATA_INTERFACE_SQL_MULTIPLE_STATEMENTS、DATA_INTERFACE_PAGE_ONLY_FOR_SELECT、DATA_INTERFACE_HTTP_REQUEST_FAIL、DATA_INTERFACE_HTTP_RESPONSE_TOO_LARGE、DATA_INTERFACE_DATA_SOURCE_NOT_EXISTS 均只声明未引用(HTTP 那两条实际抛 DATA_INTERFACE_EXECUTE_FAIL,见 HttpExecutor:457/482)。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

11 个测试类在 enlev-module-infra/src/test/java/.../{framework,service,api}/datainterface/:

测试类覆盖重点
SqlTenantRewriterTest30+ 例:JOIN / 子查询 / EXISTS / UNION / UPDATE / DELETE / INSERT / 多语句 / 伪造 tenant_id
SqlPolicyValidatorTestDDL 拒绝、框架表写拒绝、反引号与库名限定变形
SqlStatementAnalyzerTest语句类型识别
SqlLimitEnforcerTest分页 LIMIT 检查与自动注入
ParamValidatorTest未声明参数被丢弃
ScriptPostProcessorTest沙箱禁 Java 类 / 禁文件 / 禁线程 / 死循环超时
HttpExecutorTestSSRF:内网、非 http(s)、无主机、解析失败、大写 scheme
DataInterfaceServiceImplTest、DataInterfaceApiImplTest编排与跨模块契约
CountSqlRewriterTest测的是死代码
SqlExecutorIntegrationTest需真 MySQL(127.0.0.1:3306/ainb-professional-init,与 application-local.yaml 的 master.url 同一个库),Assumptions.assumeTrue(canConnect()) 连不上即跳过(:46)。它自建并自删 zz_di_order / zz_di_order_item,不碰业务表。🔴 「连不上即跳过」意味着库名写错时它是静默变绿、不是变红 —— 曾长期写死旧库 ruoyi-vue-pro,新机器上只建了 ainb-professional-init 的话这 9 个用例一个都不会跑。改库名后要用 mvn test -pl enlev-module-infra -Dtest=SqlExecutorIntegrationTest 确认报告里 Skipped: 0

Agent 侧另有 4 个测试类。

未证实 ​

  • JSqlParser 具体版本未确认:由 mybatis-plus-jsqlparser(enlev-dependencies/pom.xml:206,mybatis-plus 3.5.16)传递引入,本地 m2 未找到该 jar。因此 WITH ... AS(CTE)、LATERAL、表值函数在 SqlTenantRewriter 里的实际行为未验证 —— 从代码看 CTE 内部的表不会经过 handleSelectBody,但没有跑过用例。
  • sys_resource / system_menu 中 infra:data-interface:write-sql 的登记行未查证(ServiceImpl:91-94 注释称它同时是菜单按钮与权限资源的锚点,三处需逐字一致)。
  • DataInterfaceApi 注释所称「现网 380+ 条接口」是否属实,未核实。

相关页面 ​