Database Starter
受众:开发者 摘要:MyBatis-Plus + Liquibase 关系型数据库底座。BaseEntity / BaseService / 结构化查询 / 树形组装 / 多数据库迁移 / 乐观锁 / 逻辑删除。
何时使用
| 场景 | 建议 |
|---|---|
| 关系型业务数据持久化 | BaseEntity + BaseServiceImpl + Mapper |
| 分页 / 条件搜索 / 排序 | CrudQuery + CrudQuerySpec 白名单 |
| 字段名解析(白名单、排序、导出) | LambdaField |
| 内存组树(菜单 / 部门 / 分类) | TreeSpec |
| 数据库版本管理 | Liquibase XML 迁移文件 |
| 跨实例迁移并发安全 | 内置 liquibase-sessionlock |
| 乐观锁 | @Version + optimistic_lock_version 列 |
| 逻辑删除 | @TableLogic + deleted_at 列 |
| 缓存 / 登录态 / 队列等 Key-Value | 用 redis.md |
快速开始
<dependency>
<groupId>com.springopen</groupId>
<artifactId>spring-open-starter-database</artifactId>
</dependency>数据源(MySQL):
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=spring_open
DB_USERNAME=spring_open
DB_PASSWORD=spring_open实体:
@TableName("example_item")
public class ExampleItem extends BaseEntity {
@TableField("item_code")
private String itemCode;
}Service:
@Service
public class ExampleItemServiceImpl
extends BaseServiceImpl<ExampleItemMapper, ExampleItem>
implements ExampleItemService {
}Mapper:
public interface ExampleItemMapper extends BaseMapper<ExampleItem> { }Mapper 扫描由 spring-open-starter-database 统一提供,业务模块只需要声明继承 BaseMapper<T> 的 Mapper 接口,不需要再写局部 @MapperScan。
支持的数据库
| 数据库 | 等级 |
|---|---|
| MySQL 5.7+ | 默认 |
| PostgreSQL 15+ | 一等支持 |
| H2 2.x | lite 兼容 / 隔离诊断模式,不作普通开发或生产 |
迁移文件按 MySQL 5.7+ / PostgreSQL 15+ / H2 2.x 兼容方向编写,不依赖 MySQL 8 专属语法。
配置项
spring:
datasource:
type: ${DB_TYPE:com.zaxxer.hikari.HikariDataSource}
url: ${DB_URL:jdbc:mysql://${DB_HOST:127.0.0.1}:${DB_PORT:3306}/${DB_DATABASE:spring_open}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai}
username: ${DB_USERNAME:spring_open}
password: ${DB_PASSWORD:spring_open}
driver-class-name: ${DB_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver}
mybatis-plus:
global-config:
banner: false
spring:
open:
data:
mybatis-plus:
enabled: true
db-type: auto
pagination-enabled: true
pagination-max-limit: 500
pagination-overflow: false
optimistic-locker-enabled: true
block-attack-enabled: true
auto-fill-enabled: true
model-events-enabled: true| 配置 | 默认值 | 说明 |
|---|---|---|
enabled | true | 启用开关 |
db-type | auto | 分页插件 DB 类型,按 URL 推断 |
pagination-enabled | true | 分页插件 |
pagination-max-limit | 空(应用装配 500) | 单页最大条数,空表示不额外限制 |
pagination-overflow | false | 超出总页数是否回到第一页 |
optimistic-locker-enabled | true | 乐观锁插件 |
block-attack-enabled | true | 全表 update/delete 防护 |
auto-fill-enabled | true | 审计时间自动填充 |
model-events-enabled | true | 模型生命周期事件(ModelObserver + ModelEvent) |
切换 PostgreSQL:
DB_URL=jdbc:postgresql://127.0.0.1:5432/spring_open
DB_DRIVER_CLASS_NAME=org.postgresql.Driverlite 兼容模式 H2 文件:
${APP_STORAGE_DIR:${user.dir}/storage}/database/${DB_DATABASE:spring_open}.db./mvnw -Plite -pl spring-open-application spring-boot:runstorage/ 是私有持久化目录;H2 大小写参数(DATABASE_TO_UPPER)是数据库创建期语义,不在新旧 URL 间来回切换。
源码机制骨架
| 机制 | 源码 | 说明 |
|---|---|---|
| 自动配置 | DataMybatisPlusAutoConfiguration | 在 MyBatis-Plus 自动配置之前注册插件链、审计填充器和 BaseMapper 扫描规则 |
| 配置属性 | DataMybatisPlusProperties | 承接 spring.open.data.mybatis-plus.* 开关和分页数据库类型推断 |
| 基础实体 | BaseEntity | 统一主键、审计时间、审计用户、乐观锁和逻辑删除字段 |
| 审计填充 | DataMybatisPlusMetaObjectHandler | 插入时填充 createdAt / updatedAt,更新时刷新 updatedAt |
| 基础 Service | BaseService / BaseServiceImpl | 提供分页、必取、创建、更新、删除、批量删除和结构化查询闭环 |
| 结构化查询 | CrudQuery / CrudQuerySpec / SearchCondition / SortCondition | 用 Service 侧白名单把外部字段和操作符转换为安全的 MyBatis-Plus Wrapper |
| 字段解析 | LambdaField / FieldName | 从实体 getter 解析 Java 属性名和数据库列名,优先尊重 MyBatis-Plus 表元信息 |
| 树形组装 | TreeSpec | 在内存中按 id / parentId 组装树,查询和权限过滤由调用方负责 |
扩展边界
- Database starter 只提供 MyBatis-Plus 装配、Mapper 扫描、基础 Entity / Service、结构化查询、字段解析和树形组装。
- Database starter 不提供 Provider 切换;数据库类型由
spring.datasource.*和连接池配置决定,分页插件数据库类型可通过db-type显式指定。 - 业务表结构和 Liquibase 迁移归各业务模块维护;starter 不反向拥有业务 schema。
BaseServiceImpl生命周期钩子只用于领域前后置处理,不承接权限、数据范围、余额扣减、支付补偿或跨模块事务编排。CrudQuerySpec是查询白名单边界。Controller 和前端不能直接透传数据库列名、排序列或 SQL 片段。- 租户、组织、状态机、所有权等字段不进入
BaseEntity,由业务实体按自身上下文声明。 - 多对多关联重分配不要依赖逻辑删除旧关系后再插入同一唯一键组合,详见“关联表唯一键”。
Facade / 基类 API
BaseEntity
| 字段 | 类型 | 说明 |
|---|---|---|
id | Long | 主键,IdType.AUTO |
createdAt / updatedAt | LocalDateTime | 自动填充 |
createdBy / updatedBy | Long | 业务 Service 填充 |
optimisticLockVersion | Integer | 乐观锁版本号 |
deletedAt | LocalDateTime | 逻辑删除 |
不暴露 ID 泛型;主线 Long 主键,Service ID 用 Serializable。
租户字段不进 BaseEntity。确实多租户的实体自己声明:
public class Order extends BaseEntity {
@TableField("tenant_id")
private Long tenantId;
}BaseService
| 方法 | 说明 |
|---|---|
page(PageQuery query) | 分页查询 |
page(PageQuery query, Wrapper<T> wrapper) | 按条件分页 |
page(CrudQuery query, CrudQuerySpec<T> spec) | 按结构化查询分页 |
list(CrudQuery query, CrudQuerySpec<T> spec) | 按结构化查询列表 |
getRequired(Serializable id) | 不存在抛异常 |
create(T entity) | 创建 |
update(Serializable id, T entity) | 修改 |
deleteRequired(Serializable id) | 删除 |
deleteBatchRequired(Collection<?> ids) | 批量删除 |
BaseServiceImpl 加写事务并提供生命周期钩子:
protected T beforeCreate(T entity);
protected T afterCreate(T entity);
protected T beforeUpdate(T entity);
protected T afterUpdate(T entity);
protected void beforeDelete(Serializable id, T entity);
protected void afterDelete(Serializable id, T entity);
protected void beforeBatchDelete(Collection<?> ids);
protected void afterBatchDelete(Collection<?> ids);通用基类保持轻量。不塞导入导出、树结构、文件等可选能力。
上面的 beforeX / afterX 钩子是单个 Service 自身的前后置处理。跨模块、跨 Service 的横切(审计、缓存失效、Outbox)请用下面的模型事件,别在业务 Service 里手写。
模型生命周期事件
BaseService 的 create / update / deleteRequired / deleteBatchRequired 写入闭环会触发模型生命周期事件(对标 Laravel Eloquent 的 creating/created/updating/updated/deleting/deleted),把审计、缓存失效、Outbox 投递等横切从业务 Service 解耦出来。两种消费方式:
1. 类型化观察者(对标 Eloquent Observer)——声明为 Spring Bean、泛型指定实体类型即自动生效,无需业务 Service 挂钩:
@Component
public class OrderObserver implements ModelObserver<Order> {
@Override public void created(Order order) { /* 发通知 / 建索引 */ }
@Override public void updated(Order order) { /* 失效缓存 */ }
@Override public void deleting(Order order, Object id) { /* 前置校验,抛异常可阻断并回滚 */ }
}以父实体类型声明可覆盖其所有子类;*ing 前置钩子与写入同事务,抛异常回滚本次写入。
2. Spring 应用事件——同一次写入也会发布 ModelEvent<T>,可用 @EventListener / @TransactionalEventListener 消费(后者拿提交后语义);ModelEvent<T> 携带泛型,可用 ModelEvent<Order> 精确订阅:
@TransactionalEventListener(phase = AFTER_COMMIT)
public void onOrder(ModelEvent<Order> event) {
if (event.lifecycle() == ModelLifecycle.CREATED) { /* 提交后异步 */ }
}事件在写入前(*ING,主键可能未回填)与写入成功后(*ED,仍在事务内)触发。批量删除按每个主键各发一次 DELETING / DELETED(实体为 null、仅带主键)。仅覆盖走 BaseService 的写入;Mapper 直连的裸 SQL 不触发(与 Laravel 模型事件不覆盖 DB::table() 一致)。关闭:spring.open.data.mybatis-plus.model-events-enabled=false。
Provider
Database 本身不提供 Provider 切换;数据库类型通过 spring.datasource.url 推断。扩展能力:
- 自定义
MetaObjectHandler改自动填充 - 自定义
ISqlInjector替换 MyBatis-Plus 默认注入器 - 通过 Spring Boot / MyBatis-Plus 原生配置项进一步定制
- 数据源连接池可通过
spring.datasource.type切换(Hikari / Druid 等)
结构化查询
Service-first 能力,不提供万能通用 Controller。
核心类型:
| 类型 | 说明 |
|---|---|
CrudQuery | 分页 + 搜索条件 + 排序 |
SearchCondition | 单字段或多字段条件 |
SortCondition | 排序条件 |
SearchOperator | EQ / LIKE / IN / BETWEEN / IS_NULL 等 |
CrudQuerySpec<T> | 字段白名单 + operator 白名单 + 排序白名单 + 默认排序 |
private static final CrudQuerySpec<ExampleItem> QUERY_SPEC = CrudQuerySpec.<ExampleItem>builder()
.sortableField(ExampleItem::getCode, SearchOperator.EQ, SearchOperator.LIKE)
.sortableField(ExampleItem::getCreatedAt, SearchOperator.GE, SearchOperator.LE)
.field(ExampleItem::getDeletedAt, SearchOperator.IS_NULL, SearchOperator.IS_NOT_NULL)
.defaultSort(ExampleItem::getUpdatedAt, SortCondition.Direction.DESC)
.build();lambda 同时解析 Java 属性名和数据库列名。createdAt 和 created_at 都归到同一白名单字段。对外 API 推荐用 Java 属性名;列名是兼容别名。
虚拟字段、关联字段、聚合字段或 DTO 别名用字符串 API:
CrudQuerySpec.<Order>builder()
.sortableField("ownerName", "u.owner_name", SearchOperator.LIKE)
.build();只允许排序、不允许查询的字段用 sortable(例如可按时间排序但不开放过滤的列):
CrudQuerySpec.<Order>builder()
.sortable(Order::getCreatedAt)
.build();所有字段和操作符必须进白名单。未声明字段、未允许 operator、非法排序字段抛统一参数异常。 SearchCondition 提供 like / likeAny / in / notIn / between / isNull / isNotNull 工厂方法;更复杂的条件仍应先在 Service 层映射到白名单字段。
LambdaField
字段解析工具,适合白名单、排序、导出、审计、动态查询和表格列复用。
String propertyName = LambdaField.propertyName(ExampleItem::getDeletedAt); // deletedAt
String columnName = LambdaField.columnName(ExampleItem::getDeletedAt); // deleted_at
FieldName fieldName = LambdaField.of(ExampleItem::getDeletedAt);尊重 MyBatis-Plus 注解:
@TableId("entity_id") private Long id;
@TableField("created_on") private LocalDateTime createdAt;
LambdaField.columnName(Entity::getId); // entity_id
LambdaField.columnName(Entity::getCreatedAt); // created_onLambdaField 只解析字段名,不代表开放查询权限。对外查询仍走 CrudQuerySpec 白名单。
TreeSpec
轻量树形组装规则,菜单 / 部门 / 配置分类等已查列表按 id / parentId 组装树:
TreeSpec<MenuVO> spec = TreeSpec.<MenuVO>builder()
.id(MenuVO::getId)
.parentId(MenuVO::getParentId)
.children(MenuVO::setChildren)
.sort(Comparator.comparing(MenuVO::getSortOrder))
.build();
List<MenuVO> tree = spec.build(list);默认根节点:parentId == null / 0 / "0" / 父节点不在当前列表。
特殊根节点:
TreeSpec.<MenuVO>builder()
.rootParentId(-1L)
...
.build();树形接口保留显式 Controller 和显式 Service 方法(如 /api/v1/admin/example-categories/tree)。不因有 TreeSpec 就引入万能 Tree Controller。
Mapper
优先继承 MyBatis-Plus BaseMapper<T>:
public interface ExampleItemMapper extends BaseMapper<ExampleItem> { }普通 CRUD 用 MyBatis-Plus API,不手写 SQL。
手写 SQL 仅限:
- 复杂聚合统计
- 多表关联且 wrapper 难表达
- 数据库或第三方组件表的只读运维查询
要求:
- 保持 MySQL / PostgreSQL / H2 兼容
- SQL 集中在 Mapper 或 XML,不散落 Service 字符串拼接
- 表名、列名不来自用户请求
- 数据库差异化写法在文档或测试说明
Liquibase 迁移
主入口:
spring-open-application/src/main/resources/db/changelog/db.changelog-master.xml模块迁移文件:
spring-open-core/spring-open-core-iam/src/main/resources/db/changelog/migrations/
0000_00_00_010000_core_iam_create_tables.xml
0000_00_00_010100_core_iam_seed_data.xml
spring-open-core/spring-open-core-config/src/main/resources/db/changelog/migrations/
0000_00_00_011000_core_config_create_tables.xml
0000_00_00_011100_core_config_seed_data.xmlchangeSet ID 命名
迁移文件采用 Laravel 风格可排序前缀。初始化基线推荐格式:
0000_00_00_<6位序号>_<owner>_<action>.xml后续增量迁移推荐使用真实日期时间:
<yyyy_MM_dd_HHmmss>_<owner>_<action>.xmlchangeSet id 推荐格式:
<排序前缀>.<3位序号>.<owner-hyphen>-<action>示例:
<!-- 文件:0000_00_00_401000_module_cms_create_tables.xml -->
<changeSet id="0000_00_00_401000.001.module-cms-create-cms-content" author="system">
<!-- ... -->
</changeSet>
<!-- 文件:0000_00_00_401100_module_cms_seed_data.xml -->
<changeSet id="0000_00_00_401100.001.module-cms-iam-permission" author="system">
<!-- ... -->
</changeSet>约定:
- 排序前缀表示全仓主迁移文件顺序,必须全仓唯一,并按实际执行顺序递增,用来稳定
includeAll的跨文件执行顺序 owner从物理模块路径推导,使用starter_*/core_*/module_*,多词模块用下划线连接;changeSet id中的 owner 使用短横线形式,例如module-cms- 模块 schema 与 seed 分文件维护:建表、字段、索引、约束放模块主迁移文件;初始化数据、权限、菜单、角色绑定和当前基线数据修正放对应模块目录下的
*_seed_data.xml或语义明确的 seed 文件,例如0000_00_00_010000_core_iam_create_tables.xml+0000_00_00_010100_core_iam_seed_data.xml - 只要模块存在数据库实体,就必须同时存在模块 schema
.xml与同模块 seed.xml;seed 文件允许使用insert/loadData/loadUpdateData/update/delete等数据类 changeSet,但不得包含createTable/addColumn/createIndex/renameTable/dropTable等 schema changeSet,也不得保留旧库兼容式preConditions、checksum 或可变执行标记 - 第一版初始化基线使用
0000_00_00_<6位序号>,按模块依赖拓扑分区:starter000000-009999、core010000-399999、module400000-799999、install / application800000-899999、test / reserved900000-999999;每个 owner 默认预留 1000 序号,owner 内 schema 用块起点,seed 用+100,补充基线按+200/+300递增 - 当前开发期保护已迁移文件:只要某迁移文件可能已经在本地库、共享库或协作环境执行过,就不得为了补洞、压实、改 author、重排或折叠而回改该文件;新增表、字段、索引、约束、菜单或基线数据修正使用真实日期时间前缀新增增量迁移
- 全新模块尚未执行的迁移草稿,可以在首次执行前受控整理、折叠或调整依赖顺序,并同步更新文档、测试和所有旧文件名引用
- V1.0 发布前会单独做一次基线迁移整理 / freeze,把开发期已验证的增量按维护者确认重新整理为发布基线;该工作不作为日常开发变更处理
3位序号表示当前迁移文件内的 changeSet 执行顺序,例如001/002;必须和文件内实际出现顺序一致action使用英文动作短语,例如create-tables/add-column/create-index/iam-permission- 新增或尚未执行的
author统一使用中性值system,不使用项目名、组织名或个人名;已发布或已执行的 changeSet 不为统一 author 而改动,author作为 Liquibase 身份必须保持稳定 - 已发布或已执行的 changeSet 不为改名而改名;需要修订时新增 changeSet,不通过清库重跑来掩盖迁移身份或顺序问题
author 是 Liquibase changeSet 身份的一部分,和 filename、id 一起决定迁移是否已经执行。开发期如果发现本地库已经执行过旧 author 的 changeSet,优先保持主迁移里的旧身份稳定;只有明确要让旧开发库跟随统一 author 时,才先备份并同步更新 database_changelog.author 元数据。不要通过删除 changelog 表、只刷新 checksum,或给建表迁移追加跳过式 precondition 来掩盖身份不一致。具体处理步骤见 升级指南。
提交前建议执行:
./.ai/scripts/check migrations优先 Liquibase 标准标签
<createTable tableName="example_item">
<column name="id" type="bigint" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="varchar(100)">
<constraints nullable="false"/>
</column>
<column name="created_at" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="datetime" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="optimistic_lock_version" type="int" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="deleted_at" type="datetime"/>
</createTable>种子数据:
<insert tableName="iam_permission">
<column name="code" value="example:item:view"/>
<column name="name" value="查看示例"/>
<column name="created_at" valueComputed="CURRENT_TIMESTAMP"/>
<column name="updated_at" valueComputed="CURRENT_TIMESTAMP"/>
</insert>引用其他表 ID:
<column name="role_id" valueComputed="(SELECT id FROM iam_role WHERE code = 'super-admin')"/>避免固定数据库 SQL
不默认写死 MySQL SQL(ON DUPLICATE KEY UPDATE 等)。优先 <createTable> / <addColumn> / <createIndex> / <addUniqueConstraint> / <insert> / <preConditions> / valueComputed。
只有标准标签无法表达且明确兼容风险时才用 <sql>。
内置会话锁
Database starter 内置 Liquibase 运行时和 liquibase-sessionlock。应用执行迁移用数据库会话级锁;进程中断后连接断开锁自动释放,减少 database_changelog_lock 残留导致的启动阻塞。
主要覆盖 MySQL / MariaDB / PostgreSQL / Oracle。lite H2 模式仍避免迁移过程强制终止应用。
字段约定
数据库 snake_case,Java camelCase:
created_at → createdAt
updated_at → updatedAt
created_by → createdBy
updated_by → updatedBy
optimistic_lock_version → optimisticLockVersion
deleted_at → deletedAt逻辑删除
@TableLogic(value = "null", delval = "now()")
@TableField(value = "deleted_at", fill = FieldFill.UPDATE)
private LocalDateTime deletedAt;后台查询显式过滤:
.isNull(Entity::getDeletedAt)不把逻辑删除表当物理删除使用。审计、支付、通知、队列等运维数据按业务做受控清理。
关联表唯一键
多对多关联表如果同时具备组合唯一键和逻辑删除字段,不能用“逻辑删除旧关系后重新插入同一关系”实现重分配,因为软删除行仍会占用唯一键。例如 user_id + role_id、role_id + menu_id、role_id + permission_id、user_id + post_id 这类关系表,重新分配一组关系前应物理清理当前主体的旧关联,或恢复 / 更新既有行后继续使用。
典型场景:
- 用户重新分配角色。
- 角色重新分配菜单。
- 角色重新分配权限。
- 用户重新分配岗位。
这类表通常只表达“当前关系集合”,审计应通过操作日志或专门审计表记录,不依赖软删除行保留历史关系。
乐观锁
@Version
@TableField("optimistic_lock_version")
private Integer optimisticLockVersion;适合用户、配置、角色、菜单、支付订单等并发修改资源。
审计字段
createdAt / updatedAt 由 DataMybatisPlusMetaObjectHandler 自动填充;createdBy / updatedBy 业务 Service 填:
Long operatorId = Auth.id(Long.class);
entity.setUpdatedBy(operatorId);新增数据库资源规范
新增与数据库表交互的实体类时同步补:
- Liquibase 迁移
- Entity / Mapper / DTO / VO / Service / Controller
- RBAC 权限码
- 必要测试
- 正式文档或模块文档更新
标准后台接口:
GET /resources
GET /resources/{id}
POST /resources
PUT /resources/{id}
DELETE /resources/{id}
DELETE /resources # 批量删除,ID 放请求体DTO / VO / Entity
- Controller 入参 DTO + Validation
- Controller 出参 VO
- Entity 不直接作接口响应
entity包只放数据库表映射实体;状态、类型、分类、消息 key、字段 key、转换器、校验器等辅助类放support或语义更明确的包- Service 接收 command/query 对象或明确参数
- Controller 不写业务逻辑
目录:
example
├── controller
├── dto
├── entity
├── mapper
├── service
├── support
└── vo分页
入参用 PageQuery 或继承它的 DTO;返回 PageResult<T>:
return ResponseEntity.ok(Result.success(page));多数据库兼容建议
- 表名 / 字段名小写 snake_case
- 不用数据库保留字
- 字段类型优先使用 Liquibase 通用类型:
bigint/int/boolean/datetime/date/decimal(p,s)/varchar(n)/text - 时间字段用
datetime,只有明确日期语义才用date - 大文本用
text,不为 MySQL 写死longtext,也不使用兼容性较差的clob - 不使用方言化类型承载普通业务字段,例如
jsonb、double、timestamp(6)、uuid、tinyint - 二进制或扩展类型只允许在组件边界显式例外(db-scheduler
task_dataMySQLblob/ PostgreSQLbytea,可选 pgvectorvector(256)) - 业务 payload / 队列 payload / 通知内容等可读数据优先
text,便于导出迁移 - 普通 CRUD 用 MyBatis-Plus,不业务代码拼接 SQL
开发约定
- 实体继承
BaseEntity;多租户字段实体自声明 - Service 继承
BaseServiceImpl,钩子里做业务校验 - Controller 用
BaseController+ 显式 DTO / VO - 结构化查询用
CrudQuerySpec白名单,禁止前端字段直接拼 SQL - 迁移文件每个
<changeSet>唯一 ID + 唯一 author - 同一迁移不事后修改,需要修订时新增 changeSet
- 涉及生产数据库的 DDL 在 staging 先验证
验证命令
./mvnw -pl spring-open-starters/spring-open-starter-database -am test -DskipITs
./mvnw -pl spring-open-application -am test -DskipITs -DskipFrontend
git diff --check涉及真实 MySQL / PostgreSQL 链路时按模块测试说明开启 Testcontainers。
相关
- Key-Value / 缓存:redis.md / cache.md
- 配置中心(数据库覆盖):config.md
- 部署与初始化:deployment.md
- Provider 扩展模型:provider.md