动态数据引擎
动态数据引擎
业务用途
动态数据引擎是低代码平台的心脏。它由 SchemaService 实现,职责是:不写任何 Entity / Mapper / Service / Controller,仅凭 lc_table_meta + lc_column_meta 元数据,用 JdbcTemplate 动态拼装参数化 SQL,完成对任意业务表的增删改查。
所有需要操作业务表数据的地方——表设计器的数据管理、表单提交、列表查询、图表数据获取、报表数据查询、开放平台 API、运行时引擎——最终都汇聚到 SchemaService 的这几个方法:
| 方法 | 作用 |
|---|---|
insertData(tableId, data) | 动态 INSERT,自动注入 tenant_id,必填校验 |
updateData(tableId, id, data) | 动态 UPDATE,蛇形↔驼峰转换,租户隔离 |
deleteData(tableId, id) | 逻辑删除(SET deleted=1),无 deleted 列时物理删除 |
getDataById(tableId, id) | 单条查询,自动追加 deleted=0 + tenant_id 过滤 |
queryDataList(tableId, params, pageNum, pageSize) | 分页查询,动态 WHERE 条件,多种 queryType |
isValidColumnName(name) | 列名防注入校验(只允许字母/数字/下划线) |
涉及文件
| 层 | 文件路径 | 说明 |
|---|---|---|
| Service | backend/src/main/java/com/lowcode/service/SchemaService.java | 核心引擎(957 行) |
| Controller | backend/src/main/java/com/lowcode/controller/DatabaseController.java | 暴露 /api/database/table/{id}/data 系列端点 |
| 配置 | backend/src/main/java/com/lowcode/config/TenantContext.java | 租户上下文(ThreadLocal) |
| Entity | backend/src/main/java/com/lowcode/entity/LcTableMeta.java | 表元数据 |
| Entity | backend/src/main/java/com/lowcode/entity/LcColumnMeta.java | 字段元数据 |
数据库表
动态数据引擎本身不创建新表,它操作的是通过表设计器创建的业务表(如 erp_supplier、fnd_donation 等)。这些表都遵循统一结构:
-- 所有业务表的标准结构(由 generateCreateTableSql 自动生成)
CREATE TABLE `xxx` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
`tenant_id` BIGINT DEFAULT 1, -- 租户隔离
-- ... 用户定义的业务字段 ...
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`create_by` VARCHAR(50),
`update_by` VARCHAR(50),
`deleted` TINYINT DEFAULT 0, -- 软删除标记
INDEX idx_tenant_id (`tenant_id`)
);引擎读取元数据表 lc_table_meta + lc_column_meta 来知道业务表有哪些字段、哪些必填、哪些可查。
后端实现
端点列表(DatabaseController 数据管理部分)
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/database/table/{id}/data/list?pageNum=1&pageSize=10 | 分页查询数据 |
| GET | /api/database/table/{id}/data/{dataId} | 获取单条数据 |
| POST | /api/database/table/{id}/data | 新增数据 |
| PUT | /api/database/table/{id}/data/{dataId} | 更新数据 |
| DELETE | /api/database/table/{id}/data/{dataId} | 删除数据(逻辑删除) |
| GET | /api/database/table/{id}/data/export | 导出 CSV |
| POST | /api/database/table/{id}/data/import | 导入 CSV |
1. 插入数据 -- insertData()
@Transactional(rollbackFor = Exception.class)
public Long insertData(Long tableId, Map<String, Object> data) {
LcTableMeta tableMeta = getTableDetail(tableId);
if (tableMeta == null) throw new RuntimeException("表不存在");
List<LcColumnMeta> columns = tableMeta.getColumns();
List<String> columnNames = new ArrayList<>();
List<Object> values = new ArrayList<>();
List<String> missingRequiredFields = new ArrayList<>();
for (LcColumnMeta column : columns) {
String fieldName = column.getJavaField(); // 驼峰字段名
if (data.containsKey(fieldName) && data.get(fieldName) != null) {
columnNames.add("`" + column.getColumnName() + "`");
values.add(data.get(fieldName));
} else if (StrUtil.isNotBlank(column.getDefaultValue())) {
// 使用字段默认值
columnNames.add("`" + column.getColumnName() + "`");
values.add(column.getDefaultValue());
} else if ("Y".equals(column.getIsRequired())
&& !"Y".equals(column.getIsPk())
&& !"Y".equals(column.getIsIncrement())) {
// 必填校验
String label = StrUtil.isNotBlank(column.getColumnComment())
? column.getColumnComment() : column.getColumnName();
missingRequiredFields.add("「" + label + "」");
}
}
if (!missingRequiredFields.isEmpty()) {
throw new IllegalArgumentException(
"请先填写必填项 " + String.join("、", missingRequiredFields) + " 后再提交");
}
// 自动注入租户 ID
if (hasColumn(tableMeta, "tenant_id")) {
columnNames.add("`tenant_id`");
values.add(currentTenantId());
}
// 参数化 SQL:VALUES 用 ? 占位,防止 SQL 注入
String sql = "INSERT INTO `" + tableMeta.getTableName() + "` ("
+ String.join(", ", columnNames)
+ ") VALUES ("
+ String.join(", ", Collections.nCopies(columnNames.size(), "?"))
+ ")";
jdbcTemplate.update(sql, values.toArray());
return jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
}关键设计点:
- 蛇形↔驼峰:前端传来的 data 的 key 是驼峰(如
orderAmount),引擎通过column.getJavaField()匹配,拼 SQL 时用column.getColumnName()(蛇形order_amount)。 - 必填校验:
isRequired = "Y"的字段如果 data 中没有值且没有 defaultValue,抛出包含字段注释的异常。 - 租户注入:如果表有
tenant_id列,自动追加并注入TenantContext.getTenantId()。 - 参数化查询:VALUES 部分用
?占位符,值通过jdbcTemplate.update(sql, values.toArray())传入,杜绝 SQL 注入。
2. 更新数据 -- updateData()
@Transactional(rollbackFor = Exception.class)
public boolean updateData(Long tableId, Long id, Map<String, Object> data) {
LcTableMeta tableMeta = getTableDetail(tableId);
List<LcColumnMeta> columns = tableMeta.getColumns();
List<String> setClauses = new ArrayList<>();
List<Object> values = new ArrayList<>();
for (LcColumnMeta column : columns) {
String fieldName = column.getJavaField();
if (data.containsKey(fieldName)) {
String columnName = column.getColumnName();
// 防注入:验证列名安全性
if (!isValidColumnName(columnName)) {
throw new IllegalArgumentException("Invalid column name: " + columnName);
}
setClauses.add("`" + columnName + "` = ?");
values.add(data.get(fieldName));
}
}
values.add(id); // WHERE id = ?
// 租户隔离:自动追加 AND tenant_id = ?
String tenantCondition = "";
if (hasColumn(tableMeta, "tenant_id")) {
tenantCondition = " AND tenant_id = ?";
values.add(currentTenantId());
}
String sql = "UPDATE `" + tableMeta.getTableName() + "` SET "
+ String.join(", ", setClauses)
+ " WHERE id = ?" + tenantCondition;
jdbcTemplate.update(sql, values.toArray());
return true;
}3. 删除数据 -- deleteData()(逻辑删除)
@Transactional(rollbackFor = Exception.class)
public boolean deleteData(Long tableId, Long id) {
LcTableMeta tableMeta = getTableDetail(tableId);
// 检查是否有 deleted 字段
boolean hasDeletedColumn = tableMeta.getColumns() != null &&
tableMeta.getColumns().stream().anyMatch(col -> "deleted".equals(col.getColumnName()));
boolean hasTenantColumn = hasColumn(tableMeta, "tenant_id");
String tenantCondition = hasTenantColumn ? " AND tenant_id = ?" : "";
Object[] args = hasTenantColumn ? new Object[]{id, currentTenantId()} : new Object[]{id};
String sql;
if (hasDeletedColumn) {
// 逻辑删除
sql = "UPDATE `" + tableMeta.getTableName()
+ "` SET deleted = 1 WHERE id = ? AND deleted = 0" + tenantCondition;
} else {
// 物理删除(无 deleted 列时)
sql = "DELETE FROM `" + tableMeta.getTableName() + "` WHERE id = ?" + tenantCondition;
}
int rows = jdbcTemplate.update(sql, args);
if (rows == 0) throw new RuntimeException("数据不存在或已删除");
return true;
}4. 分页查询 -- queryDataList()
这是引擎中最复杂的方法,支持多种查询类型、自动租户过滤、软删除过滤、分页。
public Map<String, Object> queryDataList(Long tableId, Map<String, Object> params,
Integer pageNum, Integer pageSize) {
LcTableMeta tableMeta = getTableDetail(tableId);
boolean hasDeletedColumn = hasColumn(tableMeta, "deleted");
boolean hasTenantColumn = hasColumn(tableMeta, "tenant_id");
// 前端可通过 _queryTypes 指定每个字段的查询方式
Map<String, String> queryTypes = null;
if (params != null && params.containsKey("_queryTypes")) {
queryTypes = new HashMap<>((Map<?,?>) params.get("_queryTypes"));
params.remove("_queryTypes"); // 不传递给 SQL
}
StringBuilder sql = new StringBuilder("SELECT * FROM `" + tableMeta.getTableName() + "`");
List<Object> paramsList = new ArrayList<>();
boolean firstCondition = true;
// 自动追加 deleted = 0
if (hasDeletedColumn) {
sql.append(" WHERE deleted = 0");
firstCondition = false;
}
// 自动追加 tenant_id 过滤
if (hasTenantColumn) {
sql.append(firstCondition ? " WHERE tenant_id = ?" : " AND tenant_id = ?");
paramsList.add(currentTenantId());
firstCondition = false;
}
// 动态构建查询条件
if (params != null && !params.isEmpty()) {
for (LcColumnMeta column : tableMeta.getColumns()) {
String fieldName = column.getJavaField();
if (params.containsKey(fieldName) && params.get(fieldName) != null) {
// 查询类型优先级:前端 _queryTypes > 列元数据 queryType > 类型推断
String queryType;
if (queryTypes != null && queryTypes.containsKey(fieldName)) {
queryType = queryTypes.get(fieldName);
} else if (column.getQueryType() != null && !column.getQueryType().isEmpty()) {
queryType = column.getQueryType();
} else {
// 文本类型默认 LIKE,其他默认 EQ
String colType = column.getColumnType().toUpperCase();
queryType = (colType.contains("VARCHAR") || colType.contains("TEXT"))
? "LIKE" : "EQ";
}
Object value = params.get(fieldName);
String prefix = firstCondition ? " WHERE " : " AND ";
firstCondition = false;
switch (queryType) {
case "LIKE":
sql.append(prefix).append("`").append(column.getColumnName())
.append("` LIKE ?");
paramsList.add("%" + value + "%");
break;
case "GT": sql.append(prefix).append("`").append(column.getColumnName()).append("` > ?"); paramsList.add(value); break;
case "LT": sql.append(prefix).append("`").append(column.getColumnName()).append("` < ?"); paramsList.add(value); break;
case "GTE": sql.append(prefix).append("`").append(column.getColumnName()).append("` >= ?"); paramsList.add(value); break;
case "LTE": sql.append(prefix).append("`").append(column.getColumnName()).append("` <= ?"); paramsList.add(value); break;
default: sql.append(prefix).append("`").append(column.getColumnName()).append("` = ?"); paramsList.add(value);
}
}
}
}
// 查询总数(把 SELECT * 替换为 SELECT COUNT(*))
String countSql = sql.toString().replace("SELECT *", "SELECT COUNT(*)");
Long total = jdbcTemplate.queryForObject(countSql, Long.class, paramsList.toArray());
// 分页查询
sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?");
paramsList.add(pageSize);
paramsList.add((pageNum - 1) * pageSize);
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql.toString(), paramsList.toArray());
// 蛇形转驼峰
List<Map<String, Object>> resultList = new ArrayList<>();
for (Map<String, Object> row : list) {
resultList.add(convertUnderscoreToCamelCase(row));
}
Map<String, Object> result = new HashMap<>();
result.put("total", total);
result.put("list", resultList);
result.put("pageNum", pageNum);
result.put("pageSize", pageSize);
return result;
}5. 防注入 -- isValidColumnName()
private boolean isValidColumnName(String columnName) {
if (columnName == null || columnName.isEmpty()) return false;
// 必须以字母或下划线开头
if (!Character.isLetter(columnName.charAt(0)) && columnName.charAt(0) != '_') return false;
// 只能包含字母、数字和下划线
for (int i = 0; i < columnName.length(); i++) {
char c = columnName.charAt(i);
if (!Character.isLetterOrDigit(c) && c != '_') return false;
}
return true;
}
private String safeColumnName(String columnName) {
if (!isValidColumnName(columnName)) {
throw new IllegalArgumentException("Invalid column name: " + columnName);
}
return "`" + columnName + "`";
}为什么需要列名校验
动态 SQL 中,列名不能用 ? 参数化(JDBC 不支持参数化列名/表名),必须拼接到 SQL 字符串中。如果不校验,攻击者可通过列名注入 SQL。isValidColumnName 确保列名只含 [a-zA-Z0-9_],从源头杜绝注入。
6. 蛇形转驼峰 -- convertUnderscoreToCamelCase()
private Map<String, Object> convertUnderscoreToCamelCase(Map<String, Object> map) {
Map<String, Object> result = new HashMap<>();
for (Map.Entry<String, Object> entry : map.entrySet()) {
String camelKey = StrUtil.toCamelCase(entry.getKey()); // order_amount -> orderAmount
result.put(camelKey, entry.getValue());
}
return result;
}数据库返回的列名是蛇形(order_amount),转成驼峰后返回给前端,前端用驼峰 key 操作数据。
7. 字典 SQL 执行 -- executeDictSql() + validateDictSql()
字段配置了 dictSql 时,引擎执行动态 SQL 获取下拉选项。validateDictSql 做严格安全校验:
private void validateDictSql(String sql) {
String upperSql = sql.trim().toUpperCase();
if (!upperSql.startsWith("SELECT")) throw new RuntimeException("仅支持SELECT查询");
String[] forbidden = {";", "--", "/*", "*/", " DELETE ", " UPDATE ", " INSERT ",
" DROP ", " TRUNCATE ", " ALTER ", " CREATE ", " REPLACE ", " GRANT ", " REVOKE "};
for (String keyword : forbidden) {
if (upperSql.contains(keyword)) throw new RuntimeException("SQL语句包含非法内容");
}
if (upperSql.contains("TENANT_ID") && !sql.contains(":tenantId")) {
throw new RuntimeException("租户字段必须使用 :tenantId 参数");
}
}执行时使用 NamedParameterJdbcTemplate(命名参数),自动包装子查询并加 LIMIT 500。
前端实现
动态数据引擎没有独立的前端组件,它的前端调用分散在各个运行时组件中:
// 新增数据(表单提交)
export function insertData(tableId: number, data: any) {
return request.post(`/database/table/${tableId}/data`, data).then((res: any) => res.data)
}
// 更新数据
export function updateData(tableId: number, dataId: number, data: any) {
return request.put(`/database/table/${tableId}/data/${dataId}`, data).then((res: any) => res.data)
}
// 删除数据
export function deleteData(tableId: number, dataId: number) {
return request.delete(`/database/table/${tableId}/data/${dataId}`).then((res: any) => res.data)
}
// 分页查询
export function queryDataList(tableId: number, params?: any, pageNum = 1, pageSize = 10) {
return request.post(`/database/table/${tableId}/data/list?pageNum=${pageNum}&pageSize=${pageSize}`, params)
.then((res: any) => res.data)
}在运行时组件 RuntimeForm.vue 和 RuntimeList.vue 中直接使用这些 API:
// RuntimeForm.vue 中的数据提交
const res = await request.post('/database/table/' + props.config.tableId + '/data', { ...formData })
// RuntimeList.vue 中的数据查询
const res = await request.post(
`/database/table/${props.config.tableId}/data/list?pageNum=${page}&pageSize=${size}`,
queryParams
)操作步骤
- 在表设计器中建表(参见 ),引擎自动创建物理表 + 元数据
- 前端通过
POST /api/database/table/{tableId}/data提交数据,引擎自动校验必填、注入租户 - 前端通过
POST /api/database/table/{tableId}/data/list分页查询,引擎自动追加deleted=0+tenant_id过滤 - 删除数据时引擎执行
SET deleted=1逻辑删除,不物理删除

常见问题
必填校验报错「请先填写必填项」
insertData() 遍历 lc_column_meta 中 is_required = 'Y' 的字段,如果 data(按驼峰 key)中没有对应值且没有 defaultValue,就会报错。解决方法:在表单设计器中添加对应输入项,或在表设计器中给字段设置默认值。
查询条件不生效
queryDataList() 只对 lc_column_meta 中定义的字段做条件匹配。如果传入的参数 key(驼峰)不在任何字段的 javaField 中,会被忽略。请检查元数据是否完整。
多租户数据串了
租户隔离依赖 TenantContext.getTenantId()。如果请求没有经过 JWT 认证过滤器(如开放平台 API),需要确保 ApiKeyAuthFilter 或 OpenContentController 正确设置了租户上下文。OpenContentController 使用 runInFoundationTenant() 显式锁定租户。
返回数据字段是蛇形不是驼峰
queryDataList() 和 getDataById() 内部调用 convertUnderscoreToCamelCase() 做了蛇形转驼峰。如果你直接调 jdbcTemplate.queryForList() 而不走这两个方法,需要自行处理。
