数据接口
适用端:后端 + Web | 类型:机制
定制功能
数据接口(菜单里叫「接口管理」)是本项目自研能力,上游 yudao 的 infra 只有数据源配置与代码生成,没有「把一段 SQL 变成可调用接口」这一层。
数据接口把一段 SQL 原文(或一份静态 JSON、一个 HTTP 转发配置)包装成可按编码调用的接口,供表单控件下拉、门户卡片、Agent 工具取数。它不是 ORM,也不是查询 DSL —— 配置里存的就是你要执行的那条语句。
它解决什么问题
三件事必须分清,否则读代码会绕晕:
- 配置的是 SQL 原文,不是 DSL。
type=1时config列直接存 SQL 文本,占位符是 MyBatis 风格的#{name};type=2存 JSON 数据;type=3存 POSTMAN 式 HTTP 配置。 - 入参有声明、出参没有。入参声明在
params列(ParamDefinition数组:name/label/type/required/defaultValue/remark),未声明的请求参数会被直接丢弃;出参形状由执行器决定,可以用script(JS)后处理。 sql_statement_type是用户在界面显式选的,不是推断出来的(DataInterfaceDO.java:100-105)。界面能「探测」,但探测结果要用户确认。
代码在哪
路径前缀 enlev-module-infra/src/main/java/cn/enlev/ainb/module/infra/:
| 职责 | 文件 | 关键方法 |
|---|---|---|
| HTTP 端点 | controller/admin/datainterface/DataInterfaceController.java | invoke / preview / options |
| 编排(CRUD+版本+日志) | service/datainterface/DataInterfaceServiceImpl.java | doExecute:385、analyzeSqlIfNeeded:758 |
| 执行器路由 | framework/datainterface/core/executor/InterfaceExecutor.java | getExecutor:129 |
| SQL 执行 | framework/datainterface/core/executor/SqlExecutor.java | execute:77、isTenantTable:256 |
| HTTP 转发 | framework/datainterface/core/executor/HttpExecutor.java | execute:92、validateUrl:344 |
| 静态 JSON | framework/datainterface/core/executor/StaticExecutor.java | execute:30 |
| 参数绑定与校验 | framework/datainterface/core/param/{SqlParamBinder, ParamValidator, ParamDefinition}.java | bind:130 |
| 语句分析 / 策略 / LIMIT / 租户 / COUNT | framework/datainterface/core/sql/{SqlStatementAnalyzer, SqlPolicyValidator, SqlLimitEnforcer, SqlTenantRewriter, CountSqlRewriter}.java | — |
| JS 后处理 | framework/datainterface/core/script/ScriptPostProcessor.java | process:85 |
| 跨模块 API(Agent 用) | api/datainterface/{DataInterfaceApi, DataInterfaceApiImpl}.java | queryData:74、invokeData:79 |
| 租户谓词依赖 | framework/datamodel/core/TableMetaReader.java | getColumnNames: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.ts | fetchOptions |
核心机制
一次 /invoke 的完整链路
SqlExecutor.execute:77 里顺序是固定的,改动前想清楚为什么是这个顺序:
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:
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;
}
});
}四个后果要记住:
- 缓存
tenantTableCache是ConcurrentHashMap(:69),无 TTL、无失效,注释明写「表结构真变了重启即可」。给表补上tenant_id列后旧的false不会刷新。 - 表没有
tenant_id列 → 谓词为 false → 不注入(SqlTenantRewriterTest:59-64)。 - 读元数据抛异常(跨库表、视图)→ catch 后也返回 false → 不注入(
:262-267)。这是 fail-open,与SqlTenantRewriter自己的口径相反 —— 后者遇到tenantId == null或解析失败是整体拒绝(SqlTenantRewriter.java:122-133),fail-close。 - 注入值是字面量
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 /invoke | infra:data-interface:invoke | 管理面主动调用,可以执行写型接口 |
POST /options | 无 @PreAuthorize,仅登录态 | 表单控件运行时取数,理由注释在 DataInterfaceController.java:156-167;靠 Service 层 invokeReadOnly → isReadOnly:363 强制只读 |
POST /preview | infra: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_interface | code、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_enabled | PRIMARY(id);idx_code(code) — 非唯一 |
infra_data_interface_version | interface_id、version、snapshot(text,整份配置 JSON)、change_summary | PRIMARY(id);idx_interface_id |
infra_data_interface_log | interface_id、interface_code、invoke_type(1 预览/2 调用)、request_params、execute_sql、success、error_msg(500)、error_detail(4000)、result_count、duration、user_id、user_ip | PRIMARY(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 | /create | infra:data-interface:create | 创建 |
| PUT | /update | infra:data-interface:update | 更新(更新前自动存版本快照) |
| DELETE | /delete | infra:data-interface:delete | 删除 |
| PUT | /update-status | infra:data-interface:update | 启用/停用 |
| GET | /get | infra:data-interface:query | 获得单个 |
| GET | /page | infra:data-interface:query | 分页 |
| POST | /detect-sql | infra:data-interface:query | 探测 SQL 语句类型(只解析不拦截,解析失败也返 200) |
| POST | /preview | query + Service 层 write-sql | 在线测试,回传实际执行的 SQL |
| POST | /invoke | infra:data-interface:invoke | 按 code 调用 |
| GET | /simple-list | infra:data-interface:query | 可用于运行时取数的接口列表(只下发签名) |
| POST | /options | 无(仅登录态) | 运行时取数,只允许读取型接口 |
| GET | /version-page、/get-version | infra:data-interface:query | 版本历史 / 指定版本快照 |
| PUT | /rollback | update + Service 层 write-sql | 回滚(自身也产生新版本) |
| GET | /log-page | infra:data-interface:query | 调用日志分页 |
| GET | /list-type、/list-statement-type | infra:data-interface:query | 类型 / 动作类型选项 |
怎么扩展
新增一种接口类型(例如存储过程)
按顺序改,跳步会在运行时才发现:
DataInterfaceTypeEnum加枚举值;- 新建
XxxExecutor implements InterfaceExecutor并标@Component——getExecutor:129由 Spring 注入List<InterfaceExecutor>自动建索引,编排层不用改; - 🔴 必须在
isReadOnly:363加分支。漏了会默认返回false,于是/options与 Agent 的 query 工具全部拒绝调用 —— 表现为「管理页能测通、表单里取不到数」; doExecute:410-413决定要不要wrapStandardResponse;analyzeSqlIfNeeded:758加保存期校验(HTTP 类型的validateHttpConfigIfNeeded:820是现成范式);rollbackVersion:668-689是逐字段回滚清单,新字段要补进去,否则回滚后该字段保持新值;- 前端
DataInterfaceForm.vue+components/XxxConfig.vue。
/list-type 会自动带出新类型,不用另外登记。
新增参数类型
ParamDefinition.type 是自由字符串,真正的类型转换在 ValueConverter.convert:73-97 的 switch 里,类型集由 DataModelColumnTypeEnum 决定 —— 两处必须同改。
新增内置变量
改 SqlParamBinder 的常量定义 + resolveBuiltinVariables:163。内置变量的关键性质是「用户传同名无效」,新增时保持这个语义,否则会变成可伪造的上下文。
坑与注意
真会咬人的
- 🔴
countSql完全不过策略校验。保存期analyzeSqlIfNeeded只看getConfig();执行期SqlExecutor:279-291只做参数绑定 + 租户改写,没有SqlStatementAnalyzer/SqlPolicyValidator/SqlLimitEnforcer。也就是说count_sql这一列绕过了全部三道关。且validateParamsDeclared:930也只扫 config —— 只在 countSql 里用的参数不会在保存时被要求声明,运行时才抛PARAM_NOT_DEFINED。另外getStatementSqlList().get(0)只取第一条,多写的语句被静默丢弃。 - 🔴 COUNT 失败被静默吞掉:
executePaginatedQuerycatch 全部异常后total = 0L(:309-312)。表现为「列表有数据、分页器显示共 0 条」。 - 🔴
CountSqlRewriter是死代码:main 侧零引用(已核实,只有它自己的类声明),只有CountSqlRewriterTest在测。实际生效的是SqlExecutor.generateCountSql:327,用replaceAll("(?i)\\s+LIMIT\\s+[^;]*", "")剥 LIMIT ——[^;]*会吃掉 LIMIT 之后到分号的全部内容,子查询里的 LIMIT 也会被误剥。 pageSize无上限:requestArgsDefaultHandler:524-528只parseInt不钳制,SqlLimitEnforcer开了分页就只检查 LIMIT 是否存在、不管值(:45-50)。任何登录用户经/options可请求超大页。非数字入参会抛裸NumberFormatException(500),不是ServiceException。- 分页默认值算了两遍:
ServiceImpl:432-532(按ParamDefinition.defaultValue)与SqlExecutor:141-157(按requestArgs),口径不同,改一处漏一处会导致 offset 不一致。 - 🔴 HTTP 相对路径会跳过 SSRF 内网校验:无 scheme 的 URL 被拼成
http://localhost:<port>,然后validateUrl(url, allowInternal || isRelativePath)(HttpExecutor:111-115),并透传当前用户的Authorization/Cookie/tenant-id(:420-428)。等于配置者可让服务端带当前用户身份打本机任意 admin-api。allowInternalUrl=1可显式放开全部内网。 - HTTP 的 URL/body 是字符串替换而非参数化,
encode:332-334只替换&#?三个字符。 tenantTableCache无失效(见「租户隔离」一节),key 只有小写表名、不含 schema。- 无
tenant_id列 / 元数据读取失败时 fail-open(:262-267注释「保守处理:不注入」)。视图、跨库表、无租户列的表不受隔离。 - 改写器的覆盖面是枚举式的:
collectFromItem:283-300只处理Table与ParenthesedSelect,其它 FromItem 静默跳过;handleSubSelectInExpression:308-356同理只覆盖列出的表达式类型(注释自承「粗筛不命中时不递归」)。 SqlLimitEnforcer.injectLimit解析失败时字符串拼接兜底sql + " LIMIT " + limit(:104-106),注释自承「有风险但至少加了限制」。
刻意的取舍,不要「修好」
dataSourceConfigId是装饰性字段:DO/VO/DDL 注释都写「SQL 类型使用」,但执行路径零引用,SqlExecutor用的是注入的主JdbcTemplate(:60)。DataInterfaceServiceImpl:286的注释还拿它当「预览回滚管不到」的理由。配套错误码DATA_INTERFACE_DATA_SOURCE_NOT_EXISTS零引用。- 预览回传改写后的完整 SQL(
Controller:139、ServiceImpl:258-262)是刻意的(便于核对租户条件是否注入正确),代价是把表结构与字段名暴露给持query权限者。同理infra_data_interface_log明文留存execute_sql与request_params(截断 4000),业务参数含敏感值会长期留存。 SqlStatementAnalyzer支持多语句是业务需要(主子表一次写),事务由外层@Transactional兜住。- 菜单叫「接口管理」而代码/权限串叫「数据接口」(
Controller:40-43);前端按钮叫「测试」而路径叫preview(:137-138);category字段界面叫「来源」(DataInterfaceDO:44-54)—— 三处都是刻意分歧。 - 死错误码:
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/:
| 测试类 | 覆盖重点 |
|---|---|
SqlTenantRewriterTest | 30+ 例:JOIN / 子查询 / EXISTS / UNION / UPDATE / DELETE / INSERT / 多语句 / 伪造 tenant_id |
SqlPolicyValidatorTest | DDL 拒绝、框架表写拒绝、反引号与库名限定变形 |
SqlStatementAnalyzerTest | 语句类型识别 |
SqlLimitEnforcerTest | 分页 LIMIT 检查与自动注入 |
ParamValidatorTest | 未声明参数被丢弃 |
ScriptPostProcessorTest | 沙箱禁 Java 类 / 禁文件 / 禁线程 / 死循环超时 |
HttpExecutorTest | SSRF:内网、非 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+ 条接口」是否属实,未核实。
相关页面
- 表单设计后端 —— 表单运行时怎么用数据接口取下拉
- 门户 ——
/runtime/data强制走invokeReadOnly - 取号管理 —— 另一套「配置驱动」的自研能力
- 使用手册 02 基础设施 · 接口管理 —— 界面上长什么样
