外观
持久层(nebula-framework-mybatis)
2026-04-01
nebula-framework-mybatis在 MyBatis-Plus 基础上封装了统一的实体基类、Mapper 基类和 Service 基类,让业务代码专注于业务逻辑,公共字段填充、逻辑删除、多租户隔离由框架自动处理。
目录
- 一、BaseEntity 实体基类
- 二、BaseMapper Mapper 基类
- 三、BaseService / BaseServiceImpl
- 四、MetaObjectHandler 自动填充
- 五、多租户与逻辑删除拦截规则
- 六、MapperExt 动态查询规范
一、BaseEntity 实体基类
所有业务实体类必须继承 BaseEntity,它包含 11 个公共字段,无需在子类中重复声明:
public abstract class BaseEntity implements Serializable {
@TableId(type = IdType.ASSIGN_ID) // 雪花算法,无需手动赋值
private Long id;
private Long tenantId; // 租户ID(INSERT 时自动填充)
private Long createUserId; // 创建人ID
private String creator; // 创建人用户名
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime; // 创建时间
private Long modifyUserId; // 最后修改人ID
private String updater; // 最后修改人用户名
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime modifyTime; // 最后修改时间
@TableLogic // 逻辑删除,查询自动追加 AND delete_flag=0
private Integer deleteFlag; // 0:未删除 1:已删除
private String remark; // 备注
private String customFields; // 自定义扩展字段(JSON)
}子类定义方式:
@Data
@EqualsAndHashCode(callSuper = true) // 必须加,否则 equals/hashCode 不包含父类字段
@TableName("t_demo")
@Schema(description = "示例实体")
public class DemoDO extends BaseEntity {
@Schema(description = "示例名称")
private String demoName;
@Schema(description = "状态(0:正常 1:禁用)")
private Integer status;
}高频错误: 子类中重复声明
id、createTime等父类字段,会导致 MyBatis-Plus 映射冲突。
另:DDL 建表时必须包含所有 11 个字段,特别是remark和custom_fields容易遗漏,详见 数据库设计规范。
二、BaseMapper Mapper 基类
@Mapper
public interface DemoMapper extends BaseMapper<DemoDO> {
// BaseMapper 继承自 MyBatis-Plus BaseMapper,已提供:
// insert / deleteById / updateById / selectById
// selectList / selectPage / selectCount 等
}BaseMapper<T> 直接继承 MyBatis-Plus 的 BaseMapper<T>,无额外扩展方法,但配合框架的多租户拦截器和逻辑删除插件,所有通过 BaseMapper 执行的 SQL 会自动追加 tenant_id 和 delete_flag=0 过滤条件。
注意:
@Select注解或 XML 自定义 SQL 不受多租户拦截器控制,需手动追加过滤条件,详见 第五节。
三、BaseService / BaseServiceImpl
3.1 框架扩展方法(在 BaseService / BaseServiceImpl 中)
除 MyBatis-Plus IService 的全量方法外,Nebula 额外提供:
// 分页查询(使用 PageParam,自动处理 pageNo/pageSize/sorts)
PageResult<T> page(PageParam pageParam);
PageResult<T> page(PageParam pageParam, LambdaQueryWrapper<T> wrapper);
// 根据 ID 查询,不存在返回 Optional.empty()(不抛异常)
Optional<T> findById(Long id);
// 根据 ID 查询,不存在直接抛 BusinessException(DATA_NOT_FOUND)
T getByIdOrThrow(Long id);
T getByIdOrThrow(Long id, String errorMessage);
// 根据某字段值查询列表/单个
List<T> listByField(SFunction<T, ?> column, Object value);
Optional<T> findByField(SFunction<T, ?> column, Object value);3.2 使用示例
@Slf4j
@Service
@RequiredArgsConstructor
public class DemoServiceImpl extends BaseServiceImpl<DemoMapper, DemoDO>
implements IDemoService {
private final DemoMapperExt demoMapperExt;
@Override
public DemoDetailVO getDemoById(Long id) {
// 不存在自动抛 BusinessException(DATA_NOT_FOUND),不需要 if(demo == null)
DemoDO demoDO = getByIdOrThrow(id);
return DemoConvert.INSTANCE.doToDetailVO(demoDO);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void enableDemo(Long id) {
getByIdOrThrow(id); // 先确认存在
DemoDO update = new DemoDO();
update.setId(id);
update.setStatus(0);
updateById(update); // 最小更新,只改 status,不污染其他字段
log.info("启用示例成功,id={}", id);
}
@Override
public PageResult<DemoRespVO> pageDemo(DemoPageParamVO query) {
PageResult<DemoDO> pageResult = demoMapperExt.pageQuery(query);
return pageResult.convert(DemoConvert.INSTANCE::doToRespVO);
}
}3.3 继承关系总览
IService<T>(MyBatis-Plus)
└── BaseService<T>(Nebula 扩展:getByIdOrThrow / page / findById 等)
ServiceImpl<M, T>(MyBatis-Plus)
└── BaseServiceImpl<M, T>(Nebula:实现 BaseService 扩展方法)
└── DemoServiceImpl(业务实现类)四、MetaObjectHandler 自动填充
MetaObjectHandlerImpl 实现了 MyBatis-Plus 的自动填充接口,业务代码无需手动赋值以下字段:
INSERT 时自动填充
| 字段 | 填充值 | 说明 |
|---|---|---|
tenantId | 当前租户 ID | 无上下文时填入哨兵值 -1L |
createUserId | 当前登录用户 ID | 有用户上下文才填 |
creator | 当前登录用户名 | 有用户上下文才填 |
createTime | 当前时间 | 始终填充 |
modifyUserId | 当前登录用户 ID | 有用户上下文才填 |
updater | 当前登录用户名 | 有用户上下文才填 |
modifyTime | 当前时间 | 始终填充 |
UPDATE 时自动填充
| 字段 | 填充值 |
|---|---|
modifyUserId | 当前登录用户 ID |
updater | 当前登录用户名 |
modifyTime | 当前时间 |
示例:
// 新增时,只需设置业务字段,公共字段框架自动填充
DemoDO demoDO = DemoConvert.INSTANCE.saveParamToDo(saveParam);
save(demoDO);
// 执行后 demoDO.getId() 有值(雪花),demoDO.getCreateTime() 有值,demoDO.getTenantId() 有值
// 更新时,只设置需要修改的字段,createTime 等不受影响
DemoDO update = new DemoDO();
update.setId(id);
update.setStatus(0);
updateById(update); // modifyTime 和 updater 自动更新五、多租户与逻辑删除拦截规则
这是使用 MyBatis-Plus 时最容易踩坑的地方,必须理解两者的作用范围:
5.1 多租户 SQL 拦截(TenantLineInnerInterceptor)
框架通过 MyBatis-Plus 的拦截器在 SQL 执行前自动追加 AND tenant_id = {currentTenantId}。
| SQL 来源 | 是否自动追加 tenant_id |
|---|---|
BaseMapper 方法(selectById、selectList 等) | ✅ 自动 |
LambdaQueryWrapper + selectXxx | ✅ 自动 |
@Select 注解 SQL | ❌ 需手动追加 |
| XML Mapper 中的 SQL | ❌ 需手动追加 |
5.2 逻辑删除(TableLogic)
@TableLogic 注解使查询自动追加 AND delete_flag = 0,规则与多租户一致:
| SQL 来源 | 是否自动过滤已删除数据 |
|---|---|
BaseMapper / LambdaQueryWrapper | ✅ 自动 |
@Select / XML | ❌ 需手动加 AND delete_flag = 0 |
5.3 正确使用方式
// ✅ 正确:MapperExt 中使用 LambdaQueryWrapper,自动追加租户过滤
public DemoDO findByCode(String code) {
return demoMapper.selectOne(
new LambdaQueryWrapper<DemoDO>()
.eq(DemoDO::getDemoCode, code)
.last("LIMIT 1")
);
}
// ❌ 错误:@Select 注解绕过拦截器,跨租户数据泄露风险
@Select("SELECT * FROM t_demo WHERE demo_code = #{code} LIMIT 1")
DemoDO selectByCode(@Param("code") String code);
// ✅ 如果必须用 @Select,手动追加所有过滤条件
@Select("SELECT * FROM t_demo WHERE demo_code = #{code} AND delete_flag = 0 AND tenant_id = #{tenantId} LIMIT 1")
DemoDO selectByCode(@Param("code") String code, @Param("tenantId") Long tenantId);结论: 凡是需要多租户隔离的查询,必须使用
LambdaQueryWrapper(放在MapperExt中),不要用@Select注解 SQL。
六、MapperExt 动态查询规范
MapperExt 是 Nebula 特有的动态查询扩展类,专门放置 LambdaQueryWrapper 条件构建逻辑。
6.1 分页两段式查询
@Repository
@RequiredArgsConstructor
public class DemoMapperExt {
private final DemoMapper demoMapper;
public PageResult<DemoDO> pageQuery(DemoPageParamVO query) {
LambdaQueryWrapper<DemoDO> wrapper = new LambdaQueryWrapper<DemoDO>()
.like(StringUtils.hasText(query.getDemoName()), DemoDO::getDemoName, query.getDemoName())
.eq(query.getStatus() != null, DemoDO::getStatus, query.getStatus())
.orderByDesc(DemoDO::getCreateTime);
// 第一段:先 count,为 0 时短路(避免无效的 LIMIT 查询)
Long count = demoMapper.selectCount(wrapper);
if (count == null || count == 0) {
return PageResult.empty(query);
}
// 第二段:fetch,false 表示不让 MP 再 count 一次
Page<DemoDO> mpPage = new Page<>(query.getPageNo(), query.getPageSize(), false);
Page<DemoDO> result = demoMapper.selectPage(mpPage, wrapper);
return PageResult.of(result.getRecords(), count, query);
}
}6.2 条件判空规则
| 字段类型 | 判空方式 | 原因 |
|---|---|---|
String | StringUtils.hasText(value) | 同时排除 null 和空字符串 |
Integer / Long / 其他对象 | value != null | — |
6.3 唯一性检查模板
// excludeId:修改时传入自身 id 排除自身;新增时传 null
public boolean existsByCode(String demoCode, Long excludeId) {
return demoMapper.selectCount(
new LambdaQueryWrapper<DemoDO>()
.eq(DemoDO::getDemoCode, demoCode)
.ne(excludeId != null, DemoDO::getId, excludeId)
) > 0;
}