数据库约定与表总览
适用端:后端 | 类型:参考
MyBatis-Plus + dynamic-datasource,MySQL 为主、九种方言可选。本页讲清公共字段、逻辑删除、多租户、主键策略与类型处理器这五件事,以及全库 229 张表的分布。
前置阅读:接口约定与 CRUD 骨架。
它解决什么问题
每张业务表都有 6 个「不是业务字段但必须存在」的列,每个查询都要自动带上租户与逻辑删除条件。这些都不该由业务代码手写:
代码在哪
| 关注点 | 类 / 文件 |
|---|---|
| 公共字段基类 | enlev-spring-boot-starter-mybatis/.../mybatis/core/dataobject/BaseDO.java |
| 多租户基类 | enlev-spring-boot-starter-biz-tenant/.../tenant/core/db/TenantBaseDO.java |
| 字段自动填充 | enlev-spring-boot-starter-mybatis/.../mybatis/core/handler/DefaultDBFieldHandler.java |
| MyBatis 自动配置 | .../mybatis/config/EnlevMybatisAutoConfiguration.java(@MapperScan("${enlev.info.base-package}", annotationClass = Mapper.class)) |
| 租户拦截器 | .../tenant/config/EnlevTenantAutoConfiguration.java(new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties))) |
| 主键策略适配 | .../mybatis/core/IdTypeEnvironmentPostProcessor.java |
| Mapper 基类 | .../mybatis/core/mapper/BaseMapperX.java、.../core/query/LambdaQueryWrapperX.java |
| 类型处理器 | .../mybatis/core/type/{LongListTypeHandler, IntegerListTypeHandler, LongSetTypeHandler, StringListTypeHandler, EncryptTypeHandler}.java |
| 全局配置 | enlev-server/src/main/resources/application.yaml 的 mybatis-plus: 与 mybatis-plus-join: 段 |
核心机制
六个公共字段
// BaseDO(abstract,实现 Serializable 与 Easy-Trans 的 TransPojo)
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT, jdbcType = JdbcType.VARCHAR)
private String creator;
@TableField(fill = FieldFill.INSERT_UPDATE, jdbcType = JdbcType.VARCHAR)
private String updater;
@TableLogic
private Boolean deleted;TenantBaseDO extends BaseDO 只多一个字段:private Long tenantId;。
creator / updater 存的是用户 id 的字符串形式,由 DefaultDBFieldHandler 在填充时取 SecurityFrameworkUtils.getLoginUserId()。所以:
- 无登录上下文的写入(定时任务、匿名 webhook)里这两列是 null;
- 列表页显示「创建人昵称」需要翻译,前端用
useUserLabel,后端用 Easy-Trans(starter-mybatis的EnlevTranslateAutoConfiguration)。
逻辑删除
@TableLogic + yaml 里的值定义:
mybatis-plus:
global-config:
db-config:
logic-delete-value: 1
logic-not-delete-value: 0
type-aliases-package: ${enlev.info.base-package}.module.*.dal.dataobjectdeleteById 实际执行 UPDATE ... SET deleted = 1,所有查询自动追加 deleted = 0。要查已删数据只能写原生 SQL 或用 @InterceptorIgnore,LambdaQueryWrapperX 绕不过去。
唯一性校验要考虑逻辑删除
deleted 只是查询条件,不是唯一索引的一部分。validatePostNameUnique 这类「同名校验」查不到已删行,于是「删掉再新建同名」会通过校验,但如果表上有 UNIQUE(name),插入会被数据库拒绝并报 500。要么唯一索引带上 deleted,要么校验时显式查全量。
多租户
enlev.tenant.enable: true,拦截器给除白名单外的所有表自动拼 tenant_id。白名单在 yaml 里分三类:
enlev:
tenant:
enable: true
ignore-urls:
- /jmreport/* # 积木报表,无法携带租户编号
ignore-visit-urls:
- /admin-api/system/user/profile/**
- /admin-api/system/auth/**
ignore-tables:
# 模型单价是平台与厂商之间的事实,与租户无关 —— 同一个模型不会因为换个租户就换个进价。
# 不登记在这里的话,租户拦截器会给它拼 tenant_id,而该表没有这一列,
# 表现是「Unknown column 'tenant_id'」并且【把整条计量记录一起丢掉】
- agent_model_price
ignore-caches:
- user_role_ids
- permission_menu_ids
- oauth_client
- notify_template
# … mail_account / mail_template / sms_template / iot:device / iot:thing_model_list实测全库 229 张表中 112 张有 tenant_id 列。判断一张新表要不要租户化:
| 情况 | 做法 |
|---|---|
| 租户私有业务数据 | 建表带 tenant_id bigint NOT NULL DEFAULT '0'。DO 继承 BaseDO 就够,只有代码里需要读写 tenantId 时才继承 TenantBaseDO |
| 平台级事实(单价、字典模板、内置定义) | 建表不带 tenant_id,并把表名加进 ignore-tables |
| 平台级缓存 | 加进 ignore-caches,否则缓存 key 会带租户导致命中率异常 |
是不是租户表由「有没有这一列」决定,不由 DO 基类决定
TenantLineInnerInterceptor 工作在 SQL 层:它按表名判断是否拼 AND tenant_id = ?,根本不看实体有没有 tenantId 字段。实测全仓 97 个 DO 继承 BaseDO、只有 30 个继承 TenantBaseDO,而带 tenant_id 列的表有 112 张——差额就是这么来的。
一个现成例子:system_post 有 tenant_id 列,但 PostDO extends BaseDO。岗位照常按租户隔离,代码里只是从来不需要读这个值。
忘了登记 ignore-tables 的失败方式很安静
如上注释所说:拼了不存在的列 → SQL 报错 → 在计量这类「写日志」路径上,异常被吞掉的结果是整条记录丢失,不是接口报错。新增平台级表时,ignore-tables 与建表语句要同一次提交。
代码里临时忽略租户用 @TenantIgnore(starter-biz-tenant 的 tenant/core/aop/,支持 SpEL 的 enable())。
主键策略:id-type: NONE 的智能模式
mybatis-plus:
global-config:
db-config:
id-type: NONE # “智能”模式,基于 IdTypeEnvironmentPostProcessor + 数据源的类型,自动适配成 AUTO、INPUT 模式。IdTypeEnvironmentPostProcessor 在启动时按数据源方言决定:MySQL / 达梦 → IdType.AUTO;Oracle / PostgreSQL / Kingbase / DB2 / H2 → IdType.INPUT,并注册对应的 keyGenerator bean(PostgreKeyGenerator / OracleKeyGenerator 等),配合 DO 上的 @KeySequence("xxx_seq") 取号。
所以 DO 上那句看着多余的注解是有用的:
@TableName("system_post")
@KeySequence("system_post_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。代码生成器会自动带上它,手写 DO 时照抄即可;换成 ASSIGN_ID(雪花)则必须把 @KeySequence 去掉(yaml 注释已说明)。
单元测试跑在 H2 上,走的正是 INPUT 分支——这也是为什么 DO 缺 @KeySequence 时 MySQL 下正常、单测里却插入失败。
查询与分页
BaseMapperX 在 MP 的 BaseMapper 之上补了 selectPage(PageParam, Wrapper)、selectOne/selectList 的多参重载、deleteByIds 等;LambdaQueryWrapperX 补了 eqIfPresent / likeIfPresent / inIfPresent / betweenIfPresent 系列(参数为空则不加条件)。分页拦截器 PaginationInnerInterceptor 与乐观锁 OptimisticLockerInnerInterceptor 在 EnlevMybatisAutoConfiguration 里注册(乐观锁只对带 @Version 的字段生效)。
连表查询用 mybatis-plus-join,其全局配置也值得知道:
mybatis-plus-join:
sub-table-logic: true # 全局启用副表逻辑删除
table-alias: t # 表别名(默认 t)
logic-del-type: on # 副表逻辑删除条件放在 ON 而不是 WHERE类型处理器
| 处理器 | 用途 | Java 类型 ↔ 列 |
|---|---|---|
JacksonTypeHandler(MP 自带) | JSON 对象 | Object / 自定义类 ↔ json |
LongListTypeHandler | 逗号分隔的 id 串 | List<Long> ↔ varchar |
IntegerListTypeHandler / LongSetTypeHandler / StringListTypeHandler | 同上 | List<Integer> / Set<Long> / List<String> |
EncryptTypeHandler | 字段级 AES 加密 | String ↔ varchar(密文) |
用法(真实例子):
// AdminUserDO.java:85 —— JSON 字段(岗位编号数组)
@TableField(typeHandler = JacksonTypeHandler.class)
private Set<Long> postIds;
// MailLogDO.java:56 —— 字符串列表(接收邮箱地址)
@TableField(typeHandler = StringListTypeHandler.class)
private List<String> toMails;EncryptTypeHandler 的密钥在 yaml:mybatis-plus.encryptor.password。改了密钥等于已加密的数据全部解不开,且不会有明显报错(表现为字段乱码或空)。
数据模型
全库 212 张表(实测 information_schema,库 ainb-professional-init,2026-10-25),按前缀分布:
| 前缀 | 张数 | 归属 |
|---|---|---|
act_ | 39 | Flowable 工作流引擎(bpm 域) |
test_ | 39 | 代码生成器的示例表与联调残留 |
system_ | 34 | 上游系统域(用户/角色/菜单/租户/OAuth2/短信/邮件)+ ★ system_nav_node(分端导航树) |
infra_ | 24 | 上游基础设施 + ★ 低代码定制 13 张(infra_data_model{,_log}、infra_form_design{,_data_log,_menu,_version}、infra_portal{,_version}、infra_data_interface{,_log,_version}、infra_bill_template{,_log}) |
agent_ | 16 | ★ 自研 Agent 域 |
ai_ | 13 | AI 域 |
bpm_ | 12 | 工作流业务表 |
qrtz_ | 11 | Quartz |
biz_ | 9 | 低代码建出来的业务表(前缀由 enlev.data-model.table-prefix-enforced 强制) |
sys_ | 7 | ★ 定制权限体系(sys_resource / sys_grant / sys_rule / sys_subject / sys_subject_member / sys_slot_mapping / sys_record_share) |
flw_ | 6 | Flowable 事件注册 |
graph_ | 2 | spring-ai-alibaba graph |
本页上一版写的是「229 张表,含 im_ 17 张」,那两个数都已经不成立
im_ 前缀的表在 ainb-professional-init 与 ruoyi-vue-pro 里都是 0 张(enlev-module-im 已随 12347efcd1 连目录一起删除,表也跟着清掉了)。229 → 212 的差额主要就是这 17 张。旧库 ruoyi-vue-pro 现在是 211 张:与 212 的唯一差别就是少一张 system_nav_node,它已停在 nav-node 特性之前,不要再拿它当事实来源。
表结构不要手抄
定制域 36 张表(权限 7 + 低代码 13 + agent 16)的逐字段结构由 tools/gen-tables.mjs 从 information_schema 生成到文档工程的 docs/.generated/tables-{index,permission,lowcode,agent}.md,含字段类型、约束、键、注释与索引清单。写文档、对字段一律引用生成物。
各方言建表脚本在 ainb/sql/:mysql / oracle / postgresql / dm / kingbase / sqlserver / highgo / opengauss / db2,另有 sql/tools/convertor.py(MySQL → 其它库转换)与 sql/tools/docker-compose.yaml(各方言容器)。
怎么扩展
新建一张业务表的最小清单:
- 建表:表名
<模块前缀>_<业务名>(小写下划线);必备列id bigint NOT NULL AUTO_INCREMENT、creator varchar(64)、create_time datetime NOT NULL、updater varchar(64)、update_time datetime NOT NULL、deleted bit(1) NOT NULL DEFAULT 0;租户化再加tenant_id bigint NOT NULL DEFAULT '0'(实测 94 张带tenant_id列的表里只有 26 张给它建了索引,system_post就只有PRIMARY——要不要加索引按查询量自己判断,不是硬约定);每列都要写COMMENT(代码生成器与低代码建模都读注释); - DO:
@TableName+@KeySequence+ 继承BaseDO或TenantBaseDO; - Mapper:
extends BaseMapperX<XxxDO>+default selectPage; - 若走低代码建模(
infra_data_model),建表由平台的 DDL 通道执行,受SqlSafeGuard与enlev.data-model.*约束(保留字、标识符白名单、框架表黑名单是硬编码不可关的),别手工绕过; - 若是平台级表,同步在
enlev.tenant.ignore-tables登记。
坑与注意
sql/mysql/下的两份全量快照都不能单独用:ainb-professional-init.sql(39 MB,211 张表)平台表齐全但没有system_nav_node;ainb-init.sql(13 MB,95 张表)有system_nav_node却缺 64 张代码声明的表。正确路径是「导专业版快照 → 按依赖顺序跑sql/mysql/update/那 6 个增量脚本 → 重启后端」,详见环境准备与启动。没有 Flyway / Liquibase,增量脚本要人工跑。上游基线在ruoyi-vue-pro.sql(48 张建表语句),只作对照。deleted是bit(1),Java 侧是Boolean。手写 SQL 时用deleted = 0/= 1,别写= false(部分方言不认)。- H2 单测需要
src/test/resources/sql/{create_tables.sql, clean.sql},目前只有bpm / im / infra / system四个模块有。在ai/agent模块加 DB 测试要先补这两份文件和application-unit-test.yaml。 update_time是NOT NULL,但updater允许 null;反过来create_time也 NOT NULL。手工插数据(脚本、导入)时漏掉这两列会直接失败。creator/updater存 id 不存名字,改名不影响历史数据;但直接查库看到的是数字,排障时容易误判成「同一个人」。mybatis-plus.configuration.map-underscore-to-camel-case: true是显式写的(虽然默认就是 true)。DO 字段名与列名不一致时用@TableField("列名")显式指定,别依赖驼峰转换去猜。- 数据源是 dynamic-datasource,
primary: master,slave配了lazy: true(同库同账号)。用@DS("slave")切库前要确认 slave 真的指向你想读的地方——local profile 下它就是同一个库,读写分离问题在本地复现不出来。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
相关页面
- 接口约定与 CRUD 骨架:DO 之上那四层怎么写;
- 仓库结构与改名约定:
sql/目录与各方言脚本; - 测试规范:H2 单测基类与
clean.sql。
