Skip to content

数据模型(开发视角) ​

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

定制功能

数据模型(菜单「数据建模」)是本项目自研能力:把「建表 / 改表」变成一个受治理的界面操作。上游 yudao 只有代码生成器读表结构,没有任何写 DDL 的能力。

数据模型负责物理表的登记与结构变更。它不生成代码(那是代码生成器的事),也不管业务数据(那是表单运行时的事)。它只做一件事:让「改一张表的结构」这件事有校验、有日志、有防呆,而不是让人去客户端里敲 DDL。

前置阅读:数据库约定与表总览。

它解决什么问题 ​

两条贯穿全局的设计决定,先记住再往下看:

  1. 表结构的唯一事实来源是数据库本身。TableMetaReader 的类注释明写「刻意不缓存、不落元数据表」。infra_data_model 只是登记 + 业务语义 + 治理字段,不是结构的副本。所以差异比对永远拿「提交的目标列」去比「information_schema 实时列」,不比登记表。
  2. DDL 不进事务、不可回滚。这是 MySQL 的限制,不是设计选择。整套防呆机制的存在意义就是把「改坏了没法撤」的概率压到最低。

代码在哪 ​

后端路径前缀 enlev-module-infra/src/main/java/cn/enlev/ainb/module/infra/,前端前缀 ainb-ui-admin-vue3/src/:

职责文件关键方法
登记 DOdal/dataobject/datamodel/DataModelDO.java@TableName("infra_data_model")
DDL 日志 DOdal/dataobject/datamodel/DataModelLogDO.java—
唯一入口 Serviceservice/datamodel/DataModelServiceImpl.javacreateDataModel:88、updateDataModel:206、buildAlterSqlList:319、deleteDataModel:370、importTables:404、getDataModelStructure:463、previewDdl:498、getTableDataPage:539、saveLog:926
差异比对framework/datamodel/core/TableDiffer.javadiff:81、isChanged:116、diffIndexes:175
DDL 生成(纯拼串)framework/datamodel/core/MysqlDdlBuilder.javabuildCreateTable:78、buildAlterTable:123、buildModifyColumn:156、appendDefaultValue:359、isNonKeyable:414
DDL 执行framework/datamodel/core/DdlExecutor.javaexecute:73
读真实结构framework/datamodel/core/TableMetaReader.javaexistsTable:40、getColumns:62、getIndexes:95、countRows:122、reverseType:194
SQL 安全(白名单而非转义)framework/datamodel/core/SqlSafeGuard.javaIDENTIFIER_PATTERN:39、SNAKE_CASE_PATTERN:55、MYSQL_KEYWORDS:82、isFrameworkTable:183、checkTableManaged:309、wrap:356
系统列注入framework/datamodel/core/SystemColumnInjector.javaSYSTEM_COLUMNS:50、inject:91
结构指纹framework/datamodel/core/SchemaFingerprint.javacanonicalize:124
列业务语义framework/datamodel/core/ColumnMetaStore.javamerge、rebuild:161
值转换framework/datamodel/core/ValueConverter.javaconvert:86
类型枚举 / 系统列策略enums/datamodel/{DataModelColumnTypeEnum, SystemColumnProfileEnum}.java11 种受控类型;TENANT/GLOBAL/LEGACY
配置framework/datamodel/config/DataModelProperties.java前缀 enlev.data-model
Controllercontroller/admin/datamodel/{DataModelController, DataModelDataController}.java—
对外 APIapi/datamodel/{DataModelApi, DataModelApiImpl}.java11 个方法;DataModelApiImpl:40-51
前端 API / 设计器api/infra/dataModel/index.ts(15 个封装)、views/infra/dataModel/DataModelForm.vue、index.vue—
数据查看页views/infra/dataModel/data/{index.vue, DataForm.vue, RowDetailDialog.vue, columnMeta.ts}类型集在 columnMeta.ts 硬编码

核心机制 ​

新建:先执行 DDL,成功了才写登记行 ​

createDataModel:88-143 的顺序不能调:

checkTableName:92 → checkNotFrameworkTable:93 → validateColumnList:95
→ validateCreateConvention:97(前缀/snake_case/注释质量/索引名/配额)
→ SystemColumnInjector.inject:99 → validateColumnCount:102
→ 存在性判定:111-121 → validateTableCount:123
→ 🔴 先执行 DDL:126-128 → saveLog(CREATE):129
→ 失败抛 DATA_MODEL_DDL_EXECUTE_FAIL:131
→ 成功才 insert:141

登记行放在 DDL 之后写,是为了避免「表没建成、登记行却存在」这种悬挂数据。反过来的悬挂(表建了、登记没写)由 getDanglingRegistrationList 反查兜住。

两处细节容易看漏:columnMeta 用注入前的业务列(:139,系统列不该有业务语义),指纹取 DDL 之后的真实列(:140 + :164-168)。

DATA_MODEL_TABLE_EXISTS 与 DATA_MODEL_TABLE_UNREGISTERED 是两个不同的错误码:前者说「表在、登记也在」,后者说「表在、没登记」—— 后者的出路是「导入」而不是改名。

改表:五道闸全排在 DDL 前,越便宜越靠前 ​

updateDataModel:206-277,方法注释 :185-205 明写这个排序原则:

validateRecordVersion:212 → existsTable:217 → validateSchemaNotDrifted:223
→ TableDiffer.diff:224 → validateSystemColumnsNotDropped:226
→ validateDropColumnsConfirmed:228 → validateLossyChange:230
→ validatePrimaryKeyNotChanged:232 → validateIndexes:236 / diffIndexes:238
→ buildAlterSqlList:319(删索引 → 改字段 → 加索引 → 表注释)
→ 执行 DDL
→ if (dataModelMapper.updateById(updateObj) == 0) throw exception(DATA_MODEL_RECORD_VERSION_CONFLICT);

buildAlterSqlList 的内部顺序也是有理由的:先删索引再改字段,否则改一个被索引的列会被 MySQL 拒;先改字段再加索引,否则新索引可能建在还不存在的列上。

TableDiffer 是全量同步 —— 没提交的列会被 DROP ​

这是本模块最容易出事的一条。diff:81-108 比的是「提交的目标列」与「information_schema 实时列」:

  • 优先按 oldColumnName 配对 —— 改名走 CHANGE COLUMN(MysqlDdlBuilder.buildModifyColumn:156-166);
  • 未配对的存量列进 dropColumns;
  • isChanged:116-155 逐项比 name / type / length / scale(normalizeNumber:null ≡ 0)/ nullable / primaryKey / autoIncrement / onUpdateCurrentTimestamp / comment / defaultValue;
  • diffIndexes:175-196 同语义,同名但 signature() 不同 → 先删后加。

推论:前端必须先取 /structure 回填完整列表(含 oldColumnName),再提交。少传一列 = 删一列。

DDL 执行:顺序、异常即停、如实报告「改了一半」 ​

DdlExecutor.execute:73-90 逐条 jdbcTemplate.execute(sql),任一失败就 break,把已成功的记进 executedSqlList、失败的记进 failedSql:

java
public boolean isPartial() {
    return failedSql != null && !executedSqlList.isEmpty();
}

isPartial() 就是「改了一半」的诚实信号。没有自动补偿 —— 补救只能靠 infra_data_model_log 里存下的 execute_sql 人工回滚。

saveLog:926 上方的注释逐字:

java
/**
 * 记录 DDL 执行日志
 *
 * 无论成功失败都记录 —— 失败的 DDL 同样是排查线索。
 * 刻意不加 @Transactional:日志要独立于业务事务落库,否则业务回滚会把日志一起回滚掉。
 */

(与数据接口的日志用 PROPAGATION_REQUIRES_NEW 是同一个动机、不同实现。)

buildDropTable:266 刻意不加 IF EXISTS —— 表不存在时应该报错,而不是静默成功。

SQL 安全:白名单,不是转义 ​

SqlSafeGuard 的思路是「只放行认得的」,四关:

关卡位置内容
标识符正则IDENTIFIER_PATTERN:39^[A-Za-z_][A-Za-z0-9_]{0,63}$
snake_case 正则SNAKE_CASE_PATTERN:55新建约定校验用
MySQL 8.0 保留字MYSQL_KEYWORDS:82保留字必须反引号包裹
受管表登记checkTableManaged:309表必须在 infra_data_model 里登记过
框架表前缀isFrameworkTable:183SystemTablePolicy 的 24 个保留前缀

转义在这套模型里没用 —— 因为表名/列名不能用 ? 占位符,只能拼进 SQL。所以唯一可靠的办法是「拼之前确认它长得像标识符」。

数据接口那边因为 SQL 整段是自由文本、白名单做不到,改用了 JSqlParser 解析 + 策略黑名单,两套机制并存但不共用,详见数据接口 · 安全模型。

系统列由框架注入,profile 刻意不落库 ​

SystemColumnInjector.inject:91-152 按 SystemColumnProfileEnum 注入 creator/create_time/updater/update_time/deleted/tenant_id 等。三种剖面:TENANT / GLOBAL / LEGACY。

profile 不落库,以「物理表是否存在 tenant_id 列」为事实(profileOf 兜底 TENANT)。这是上一条设计决定的直接推论 —— 既然数据库是唯一事实来源,就别在登记表里再存一份可能漂移的副本。

validateSystemColumnsNotDropped 排在两个确认位之前:删系统列是任何确认位都放行不了的。

结构指纹:null 与「有值不匹配」是两件事 ​

schema_fingerprint 是 sha256/64。null = 尚未记录基线(比如导入的存量表首次登记),有值但不匹配 = 结构被人绕过平台改了(validateSchemaNotDrifted:223 拦这个)。把 null 当成不匹配会导致所有导入表都改不动。

指纹算法在 SchemaFingerprint.canonicalize:124:长度前缀 + 按列名小写排序,保证「同一组列、不同提交顺序」得到同一指纹。

infra_form_design.schema_fingerprint 恒为 NULL 是另一回事

表单设计表也有同名列,但那边恒为 NULL 是刻意的(FormDesignDO:299-315 给了 4 条理由)。别把两边的口径混为一谈,也别去「修好」表单那边。详见表单设计后端。

数据模型 ​

infra_data_model ​

建表语句 ainb/sql/mysql/ainb-init.sql:16758-16781。索引只有 PRIMARY KEY (id) + INDEX idx_table_name —— 没有唯一索引,唯一性由 Service 层查询 + 物理表存在性双重保证。

列说明
id / data_source_config_id / table_name / table_comment / model_name / remark基本登记信息
list_config / form_config / sub_table_configJSON,列表与表单的展示配置
column_metaJSON,列名 → {dictType, refTable, refColumn}。DO 注释明写「整体读写、不按字段查询」
sourceMANUAL / AI / IMPORT,服务端按入口盖章,不接受调用方传入
manual_edited_flag同上
schema_fingerprintsha256/64,null = 尚未记录基线
versionMyBatis-Plus @Version,INITIAL_VERSION = 0
tenant_id继承 BaseDO,受租户拦截器过滤(application.yaml:397-410 的 ignore-tables 只有 agent_model_price)

infra_data_model_log ​

operate_type、execute_sql、success、error_msg、duration。

关于「版本」这个词 ​

数据模型没有版本表、没有快照、没有版本递增语义。version 列只是 MyBatis-Plus 的乐观锁整数,全库只有 infra_data_model 与 infra_form_design 两张表用 @Version(EnlevMybatisAutoConfiguration.java:48-77 注册 OptimisticLockerInnerInterceptor,注释里点明了这一点)。旧结构不做保留。

带版本快照的是表单设计(infra_form_design_version,唯一索引 uk_design_version)和数据接口(infra_data_interface_version),跟数据模型无关。

乐观锁的三个盲区 ​

EnlevMybatisAutoConfiguration.java:48-77 的注释明列:

  1. 冲突时命中 0 行而不抛异常 —— 每条写路径都要自己查返回值(updateDataModel:274-276 就是这么做的);
  2. version 为 null 时静默跳过版本条件;
  3. deleteById / update(null, wrapper) 走不到拦截器。

deleteDataModel:343-369 有一段长注释解释两处 deleteById 为什么刻意不带版本条件。

接口清单 ​

DataModelController.java,@RequestMapping("/infra/data-model"),15 个端点(权限串逐字取自 @PreAuthorize):

方法路径权限摘要
POST/createinfra:data-model:create创建数据模型(真实执行 CREATE TABLE)
PUT/updateinfra:data-model:update更新(真实执行 ALTER TABLE,且无法回滚)
DELETE/deleteinfra:data-model:delete删除(dropTable,riskConfirmed 默认 false)
POST/importinfra:data-model:create导入已有表(仅登记,不执行 DDL)
GET/getinfra:data-model:query获得数据模型
GET/pageinfra:data-model:query分页
GET/list-columnsinfra:data-model:query字段列表(实时读真实表结构)
GET/structureinfra:data-model:query完整结构(字段+索引+指纹+version)
GET/list-system-columninfra:data-model:query系统内置字段列表(可带 profile)
GET/list-system-table-prefixinfra:data-model:query系统表前缀(前端据此禁用删除按钮)
POST/preview-ddlinfra:data-model:query预览待执行的 DDL(不实际执行)
GET/list-database-tableinfra:data-model:query数据库表列表(用于导入)
GET/table-data-pageinfra:data-model:query预览表数据
GET/log-pageinfra:data-model:queryDDL 执行日志分页
GET/list-column-typeinfra:data-model:query支持的字段类型列表

DataModelDataController.java(/infra/data-model-data)只有 3 个只读接口存活:POST /page:46、GET /get:54、GET /list-columns:64,权限 infra:data-model-data:query。create / update / delete / delete-list 整段被注释掉(:72-113,已核实),连各自的 @PreAuthorize 一起注释,但对应权限串仍留在 ainb-init.sql 里。

「数据查看」只读是两个仓库协调过的决定,不是漏改:前端「新增」按钮同样被 HTML 注释包起来(views/infra/dataModel/data/index.vue:114-121,v-hasPermi="['infra:data-model-data:create']" 还在注释里)。三处遗留值得知道:

  • Service 层完好:DataModelDataServiceImpl 的 createData:127 / updateData:136 / deleteData:148 / deleteDataList:158 全部实现且带 @Transactional,updateData 还处理了「影响 0 行 = 不存在或不属于本租户」的歧义。要放开写入口只需解注释,不用重写业务逻辑。
  • 前端 api 函数仍然导出:api/infra/dataModel/data.ts:42-58 的 createData / updateData / deleteData / deleteDataList 还在,指向已被注释掉的端点 —— 调了就是 404。目前是死代码,没有任何 .vue 引用。
  • 权限树上有三个授得出、用不上的按钮:菜单语料里「数据查看」下仍登记着 数据创建 / 数据更新 / 数据删除 三个按钮权限串。管理员在角色授权页会看到这三个勾,勾上不会造成越权(端点不存在,fail-closed),但会让人以为这页能写。

怎么扩展 ​

新增一种字段类型(例如金额、级联选择) ​

11 种受控类型在 DataModelColumnTypeEnum:STRING / TEXT / LONG_TEXT / JSON / TINYINT / INT / LONG / DECIMAL / BOOLEAN / DATE / DATETIME。加一种要按顺序改:

  1. enums/datamodel/DataModelColumnTypeEnum.java — 加枚举项 (type, mysqlType, javaType, needLength, needScale, description)。⚠️ description 会逐字打进 AI 规划提示词(WorkflowDslPlannerPrompt),所以它不是给人看的注释,是给模型看的规格说明。
  2. framework/datamodel/core/MysqlDdlBuilder.java — buildTypeDefinition、appendDefaultValue:359、isNonKeyable:414。注意 isNonKeyable 用 == 而非 Set.contains,因为 typeOf 会返回 null。
  3. 🔴 framework/datamodel/core/TableMetaReader.java:194 reverseType — 漏改会导致指纹与差异判定永久假阳性,表现为「这张表永远改不动、每次都报有差异」。
  4. framework/datamodel/core/ValueConverter.java:86 convert + isStringType(JSON 被刻意排除在字符串类型外)。
  5. service/datamodel/DataModelServiceImpl.java validateColumnList — 若需要长度/精度约束。
  6. agent 侧不用改:DataModelCreateValidator 与 WorkflowDslPlannerPrompt 都通过 getColumnTypeList() 消费枚举,没有重复清单。
  7. 前端 views/infra/dataModel/data/columnMeta.ts(isNumberType / isDateType / isLongContent 等类型集硬编码)与 data/DataForm.vue(DECIMAL 精度、DATE/DATETIME picker、BOOLEAN 默认值)。DataModelForm.vue 的类型下拉由 /list-column-type 驱动,不用改。

第 7 步的硬编码是真实的重复清单 —— 加类型时最容易漏的就是它。

配置项 ​

enlev.data-model.*(DataModelProperties):tablePrefixEnforced=true、tablePrefix="biz_"、snakeCaseEnforced=true、indexPrefixEnforced=true、maxColumnCount=100、maxTableCount=0(0 = 不限)。

约定校验 validateCreateConvention 只作用于新建,导入存量表不受约束 —— 否则历史表一个都导不进来。

DataModelApi 的消费者全在 agent 模块 ​

DataModelApi 的真实调用方全部在 enlev-module-agent/:tool/impl/datamodel/ 下 8 个工具(datamodel_create / datamodel_update / datamodel_import / datamodel_preview_ddl / datamodel_list / datamodel_structure / datamodel_column_types / datamodel_physical_tables)+ DataModelParamResolver;tool/impl/form/FormSaveDraftTool.java:288、FormFieldFactory.java:562,568;framework/agent/core/validate/{DataModelCreateValidator, DataModelUpdateValidator, WorkflowDslValidator};core/plan/WorkflowDslPlannerPrompt;core/manifest/ChangeManifestDryRunner;controller/admin/diagnostic/AgentApiDiagnosticController。

DataModelApiImpl 是「纯委托」纪律的一个有意例外,类注释 :40-51 解释了原因:两个对账方法是方向相反的双读 —— getPhysicalTableList 遍历物理表反标 registered,getDanglingRegistrationList 遍历登记行反查物理表;任一单读方向的 Service 方法都表达不了这个语义。注释还记了一次真实事故(台账 #93 / id=324 的悬挂登记两天无人发现)。替代纪律是共享 registeredTableNames() 做归一化,并在注释里声明与 Controller 同名接口的 OR vs AND 语义分歧。

坑与注意 ​

  1. 🔴 全量同步:提交列表即目标全量,漏传的列/索引会被 DROP(TableDiffer.diff:81-108、diffIndexes:175-196)。
  2. 🔴 DDL 不进事务:失败即停可能「改了一半」,只能靠 infra_data_model_log + Result.isPartial() 追查。
  3. 两个确认位不可互换:riskConfirmed = 有数据的有损变更,confirmDropColumns = 删列的结构确认。删系统列任何确认位都放行不了(:193-198、:226-230)。
  4. 指纹基线取 DDL 之后的真实结构,不取请求(:164-168);getDataModelStructure:463-484 的指纹在 ColumnMetaStore.merge 之前计算,口径才与写入一致。
  5. source / manualEditedFlag 不接受调用方传入(DataModelSaveReqVO 无此二字段);:148-153 解释了为何不能让 BeanUtils.toBean 拷 version / schemaFingerprint。
  6. STRING 未填长度被静默改写为 255(MysqlDdlBuilder.STRING_DEFAULT_LENGTH)。agent 侧为此专设 validateSilentRewrites(DataModelCreateValidator:210,256)。
  7. TEXT / LONG_TEXT / JSON 的默认值被静默跳过(appendDefaultValue:359-390);JSON 反向读回带 _utf8mb4'{}' 前缀(reverseType:194-223);tinyint(1) 反读为 BOOLEAN。
  8. 🔴 getTableDataPage 是字符串拼接:"SELECT * FROM " + SqlSafeGuard.wrap(tableName) + " LIMIT ?, ?"(:539-558,已核实)。安全完全依赖 checkTableManaged:309 白名单;LIMIT 的值走占位符不拼接。业务表数据的租户隔离靠 DynamicSqlExecutor.appendTenantCondition:349-363,注释自称「这是防跨租户数据泄漏的唯一防线」,表无 tenant_id 列则跳过。
  9. list-database-table 的两个 LIKE 是 AND(TableMetaReader.getTableList:132-148),而 DataModelApiImpl.getPhysicalTableList 是 OR,两者结果不同(Impl 已声明该分歧)。
  10. existsTable:40-46 刻意不用 SHOW TABLES LIKE(_ 在 LIKE 里是通配符);countRows:122-127 用精确 COUNT(*) 而非 TABLE_ROWS(后者是估算值)。
  11. 前端两处代码/注释不一致:views/infra/dataModel/index.vue 的 handleDelete catch 读 error?.msg,而 DataModelForm.vue:1151-1155 注明 axios reject 的是 new Error(msg)、.msg 恒为 undefined;DataModelForm.vue:777-782 称「本模块的受控类型集没有 tinyint」,但 DataModelColumnTypeEnum:43 确实定义了 TINYINT。
  12. maxTableCount 默认 0(不限)是刻意选择;DataModelSaveReqDTO.dataSourceConfigId 恒为 0L,多数据源实际未启用。

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

测试 ​

enlev-module-infra/src/test/java/.../datamodel/:TableDifferTest、MysqlDdlBuilderTest、ColumnMetaStoreTest、DataModelGovernanceGuardTest、DataModelConventionGuardTest、DataModelSystemColumnGuardTest、DataModelServiceImplTest、DataModelApiImplTest、DataModelMysqlIntegrationTest。守卫测试 CommentLineRefBanTest 位于 agent 模块。

未证实 ​

  • DynamicSqlExecutor.java(462 行)未通读,仅 grep + 定点读;ValueConverter.java L120 之后的分支未读。
  • SystemTablePolicy 的 isAllowed / prefixes 方法体未读;「24 个保留前缀」是直接数 SYSTEM_CORE + RESERVED_MODULE 常量集合得出,分级判定逻辑未验证。
  • 坑 11 中「.msg 恒为 undefined」的结论来自 DataModelForm.vue:1151-1155 的注释,未亲自读 src/config/axios/service.ts 确认拦截器行为。
  • DataModelApi 的 javadoc 引用了 docs/数据建模规范.md,该文件不存在:后端与 Web 仓都没有 docs/ 目录;移动端仓有一个 docs/,但那是 unibest 原版模板站点,里面同样没有这份规范。
  • 测试文件已确认存在,但本页写作时未执行,其断言与实际行为是否一致未验证。

相关页面 ​