数据模型(开发视角)
适用端:后端 + Web | 类型:机制
定制功能
数据模型(菜单「数据建模」)是本项目自研能力:把「建表 / 改表」变成一个受治理的界面操作。上游 yudao 只有代码生成器读表结构,没有任何写 DDL 的能力。
数据模型负责物理表的登记与结构变更。它不生成代码(那是代码生成器的事),也不管业务数据(那是表单运行时的事)。它只做一件事:让「改一张表的结构」这件事有校验、有日志、有防呆,而不是让人去客户端里敲 DDL。
前置阅读:数据库约定与表总览。
它解决什么问题
两条贯穿全局的设计决定,先记住再往下看:
- 表结构的唯一事实来源是数据库本身。
TableMetaReader的类注释明写「刻意不缓存、不落元数据表」。infra_data_model只是登记 + 业务语义 + 治理字段,不是结构的副本。所以差异比对永远拿「提交的目标列」去比「information_schema实时列」,不比登记表。 - DDL 不进事务、不可回滚。这是 MySQL 的限制,不是设计选择。整套防呆机制的存在意义就是把「改坏了没法撤」的概率压到最低。
代码在哪
后端路径前缀 enlev-module-infra/src/main/java/cn/enlev/ainb/module/infra/,前端前缀 ainb-ui-admin-vue3/src/:
| 职责 | 文件 | 关键方法 |
|---|---|---|
| 登记 DO | dal/dataobject/datamodel/DataModelDO.java | @TableName("infra_data_model") |
| DDL 日志 DO | dal/dataobject/datamodel/DataModelLogDO.java | — |
| 唯一入口 Service | service/datamodel/DataModelServiceImpl.java | createDataModel:88、updateDataModel:206、buildAlterSqlList:319、deleteDataModel:370、importTables:404、getDataModelStructure:463、previewDdl:498、getTableDataPage:539、saveLog:926 |
| 差异比对 | framework/datamodel/core/TableDiffer.java | diff:81、isChanged:116、diffIndexes:175 |
| DDL 生成(纯拼串) | framework/datamodel/core/MysqlDdlBuilder.java | buildCreateTable:78、buildAlterTable:123、buildModifyColumn:156、appendDefaultValue:359、isNonKeyable:414 |
| DDL 执行 | framework/datamodel/core/DdlExecutor.java | execute:73 |
| 读真实结构 | framework/datamodel/core/TableMetaReader.java | existsTable:40、getColumns:62、getIndexes:95、countRows:122、reverseType:194 |
| SQL 安全(白名单而非转义) | framework/datamodel/core/SqlSafeGuard.java | IDENTIFIER_PATTERN:39、SNAKE_CASE_PATTERN:55、MYSQL_KEYWORDS:82、isFrameworkTable:183、checkTableManaged:309、wrap:356 |
| 系统列注入 | framework/datamodel/core/SystemColumnInjector.java | SYSTEM_COLUMNS:50、inject:91 |
| 结构指纹 | framework/datamodel/core/SchemaFingerprint.java | canonicalize:124 |
| 列业务语义 | framework/datamodel/core/ColumnMetaStore.java | merge、rebuild:161 |
| 值转换 | framework/datamodel/core/ValueConverter.java | convert:86 |
| 类型枚举 / 系统列策略 | enums/datamodel/{DataModelColumnTypeEnum, SystemColumnProfileEnum}.java | 11 种受控类型;TENANT/GLOBAL/LEGACY |
| 配置 | framework/datamodel/config/DataModelProperties.java | 前缀 enlev.data-model |
| Controller | controller/admin/datamodel/{DataModelController, DataModelDataController}.java | — |
| 对外 API | api/datamodel/{DataModelApi, DataModelApiImpl}.java | 11 个方法;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:
public boolean isPartial() {
return failedSql != null && !executedSqlList.isEmpty();
}isPartial() 就是「改了一半」的诚实信号。没有自动补偿 —— 补救只能靠 infra_data_model_log 里存下的 execute_sql 人工回滚。
saveLog:926 上方的注释逐字:
/**
* 记录 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:183 | SystemTablePolicy 的 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_config | JSON,列表与表单的展示配置 |
column_meta | JSON,列名 → {dictType, refTable, refColumn}。DO 注释明写「整体读写、不按字段查询」 |
source | MANUAL / AI / IMPORT,服务端按入口盖章,不接受调用方传入 |
manual_edited_flag | 同上 |
schema_fingerprint | sha256/64,null = 尚未记录基线 |
version | MyBatis-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 的注释明列:
- 冲突时命中 0 行而不抛异常 —— 每条写路径都要自己查返回值(
updateDataModel:274-276就是这么做的); version为null时静默跳过版本条件;deleteById/update(null, wrapper)走不到拦截器。
deleteDataModel:343-369 有一段长注释解释两处 deleteById 为什么刻意不带版本条件。
接口清单
DataModelController.java,@RequestMapping("/infra/data-model"),15 个端点(权限串逐字取自 @PreAuthorize):
| 方法 | 路径 | 权限 | 摘要 |
|---|---|---|---|
| POST | /create | infra:data-model:create | 创建数据模型(真实执行 CREATE TABLE) |
| PUT | /update | infra:data-model:update | 更新(真实执行 ALTER TABLE,且无法回滚) |
| DELETE | /delete | infra:data-model:delete | 删除(dropTable,riskConfirmed 默认 false) |
| POST | /import | infra:data-model:create | 导入已有表(仅登记,不执行 DDL) |
| GET | /get | infra:data-model:query | 获得数据模型 |
| GET | /page | infra:data-model:query | 分页 |
| GET | /list-columns | infra:data-model:query | 字段列表(实时读真实表结构) |
| GET | /structure | infra:data-model:query | 完整结构(字段+索引+指纹+version) |
| GET | /list-system-column | infra:data-model:query | 系统内置字段列表(可带 profile) |
| GET | /list-system-table-prefix | infra:data-model:query | 系统表前缀(前端据此禁用删除按钮) |
| POST | /preview-ddl | infra:data-model:query | 预览待执行的 DDL(不实际执行) |
| GET | /list-database-table | infra:data-model:query | 数据库表列表(用于导入) |
| GET | /table-data-page | infra:data-model:query | 预览表数据 |
| GET | /log-page | infra:data-model:query | DDL 执行日志分页 |
| GET | /list-column-type | infra: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。加一种要按顺序改:
enums/datamodel/DataModelColumnTypeEnum.java— 加枚举项(type, mysqlType, javaType, needLength, needScale, description)。⚠️description会逐字打进 AI 规划提示词(WorkflowDslPlannerPrompt),所以它不是给人看的注释,是给模型看的规格说明。framework/datamodel/core/MysqlDdlBuilder.java—buildTypeDefinition、appendDefaultValue:359、isNonKeyable:414。注意isNonKeyable用==而非Set.contains,因为typeOf会返回null。- 🔴
framework/datamodel/core/TableMetaReader.java:194 reverseType— 漏改会导致指纹与差异判定永久假阳性,表现为「这张表永远改不动、每次都报有差异」。 framework/datamodel/core/ValueConverter.java:86 convert+isStringType(JSON 被刻意排除在字符串类型外)。service/datamodel/DataModelServiceImpl.java validateColumnList— 若需要长度/精度约束。- agent 侧不用改:
DataModelCreateValidator与WorkflowDslPlannerPrompt都通过getColumnTypeList()消费枚举,没有重复清单。 - 前端
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 语义分歧。
坑与注意
- 🔴 全量同步:提交列表即目标全量,漏传的列/索引会被 DROP(
TableDiffer.diff:81-108、diffIndexes:175-196)。 - 🔴 DDL 不进事务:失败即停可能「改了一半」,只能靠
infra_data_model_log+Result.isPartial()追查。 - 两个确认位不可互换:
riskConfirmed= 有数据的有损变更,confirmDropColumns= 删列的结构确认。删系统列任何确认位都放行不了(:193-198、:226-230)。 - 指纹基线取 DDL 之后的真实结构,不取请求(
:164-168);getDataModelStructure:463-484的指纹在ColumnMetaStore.merge之前计算,口径才与写入一致。 source/manualEditedFlag不接受调用方传入(DataModelSaveReqVO无此二字段);:148-153解释了为何不能让BeanUtils.toBean拷version/schemaFingerprint。- STRING 未填长度被静默改写为 255(
MysqlDdlBuilder.STRING_DEFAULT_LENGTH)。agent 侧为此专设validateSilentRewrites(DataModelCreateValidator:210,256)。 - TEXT / LONG_TEXT / JSON 的默认值被静默跳过(
appendDefaultValue:359-390);JSON 反向读回带_utf8mb4'{}'前缀(reverseType:194-223);tinyint(1)反读为 BOOLEAN。 - 🔴
getTableDataPage是字符串拼接:"SELECT * FROM " + SqlSafeGuard.wrap(tableName) + " LIMIT ?, ?"(:539-558,已核实)。安全完全依赖checkTableManaged:309白名单;LIMIT 的值走占位符不拼接。业务表数据的租户隔离靠DynamicSqlExecutor.appendTenantCondition:349-363,注释自称「这是防跨租户数据泄漏的唯一防线」,表无tenant_id列则跳过。 list-database-table的两个 LIKE 是 AND(TableMetaReader.getTableList:132-148),而DataModelApiImpl.getPhysicalTableList是 OR,两者结果不同(Impl 已声明该分歧)。existsTable:40-46刻意不用SHOW TABLES LIKE(_在 LIKE 里是通配符);countRows:122-127用精确COUNT(*)而非TABLE_ROWS(后者是估算值)。- 前端两处代码/注释不一致:
views/infra/dataModel/index.vue的handleDeletecatch 读error?.msg,而DataModelForm.vue:1151-1155注明 axios reject 的是new Error(msg)、.msg恒为undefined;DataModelForm.vue:777-782称「本模块的受控类型集没有 tinyint」,但DataModelColumnTypeEnum:43确实定义了 TINYINT。 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.javaL120 之后的分支未读。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 原版模板站点,里面同样没有这份规范。- 测试文件已确认存在,但本页写作时未执行,其断言与实际行为是否一致未验证。
相关页面
- 数据接口 —— 另一套 SQL 安全机制(策略黑名单),与本页的白名单并存
- 表单设计后端 —— 表单绑定物理表,发布时会来核实这里的元数据
- 取号管理 —— 单据编号列的类型与唯一索引约束
- 使用手册 02 基础设施 · 数据建模 —— 界面上长什么样
