精炼版 RuoYi-Vue-Plus 后端:从多模块骨架到数据权限与多租户

用 Java 21 + Spring Boot 3.5 + MyBatis-Plus + Sa-Token 从零搭一套多模块 RBAC 后端,讲清分层、统一响应、持久层基建、声明式鉴权、数据权限 AOP、多租户行级隔离,以及两个值得记下来的排错故事。

2026/6/3
0 分钟阅读

这套后端是精炼重构 RuoYi-Vue-Plus 的产物:技术栈全换成现代版本(Java 21、Spring Boot 3.5、Jakarta 命名空间、PostgreSQL、MyBatis-Plus spring-boot3、Sa-Token、Redis),但只实现最核心、最通用的主流程,砍掉那些应对极端企业场景的层层兜底,让每一层的本质都能一眼看清。这篇按"分层 → 响应 → 持久层 → 鉴权 → 数据权限 → 多租户 → 排错"的顺序走一遍。

项目地址

github:https://github.com/oinsist/oinsist-backend

gitee:https://gitee.com/o_insist/oinsist-backend

一、多模块分层

不单独拉一个 framework 一级模块,而是把可复用能力拆进 common-*

模块职责
oinsist-admin启动 + Controller 出口层(只做 HTTP 编排,不含业务逻辑)
oinsist-systemRBAC 核心业务(用户/角色/部门/菜单)的 Service/Mapper
common-core基础常量/枚举/响应模型/异常/纯工具——不依赖任何技术栈
common-web全局异常、参数校验、WebMvc、Jackson 配置
common-mybatisMyBatis-Plus、分页、数据权限、租户拦截器、字段填充
common-satokenSa-Token 登录认证、权限校验
common-redis缓存与分布式能力

依赖方向严格自上而下:admin → system → common-mybatis → common-coresystem 不得反向依赖 admincommon-core 不碰 Web/DB/Redis。这条线最容易被破坏的是 BaseEntity 该放哪——它带 MyBatis-Plus 注解,所以必须放 common-mybatis 而不是 common-core,否则核心层就被 ORM 污染了。

这种分层的价值是:同一套业务逻辑能被不同入口(REST、RPC、定时任务)复用。一个直接体现:Controller 全放 adminsystem 只有 Service/Mapper——业务层完全不感知 HTTP。

二、统一响应与异常收敛

所有接口返回统一的 R<T> = { code, msg, data }code=200 成功。请求的主流程是一条收敛的管道:

请求 → 参数绑定 → Jakarta Validation 校验
   ├─ 校验不过 → GlobalExceptionHandler → R.fail(400, 聚合的字段错误)
   └─ 通过 → Controller/Service
              ├─ 正常 → R.ok(data)
              ├─ 业务异常(ServiceException) → R.fail(业务码, 业务消息)
              └─ 未知异常 → R.fail(500, "操作失败")(堆栈只记日志,不暴露给前端)

关键约定:异常全部交给 @RestControllerAdviceGlobalExceptionHandler 统一拦截(参数校验、请求体解析失败、类型转换、方法不支持、业务异常、未知异常各有分支),严禁在 Service 层盲目 try-catch 把异常吞掉ServiceException 给业务主动抛,错误码集中在 ResultCode 枚举里维护。这样 Controller 写起来很干净,只管编排,不管错误怎么变成响应。

三、持久层:MyBatis-Plus 基建

common-mybatis 承载所有持久层基础能力,几个要点:

  • 雪花主键:全局 id-type: assign_id@TableId(type = IdType.ASSIGN_ID),不用自增——这是后面"前端精度坑"的根源(见第七节)。
  • 自动填充MetaObjectHandler 在新增/更新时自动填 createTime/updateTime/createBy/updateBy,业务代码不用手写。
  • 逻辑删除BaseEntitydeleted 字段标 @TableLogicdelete 被改写成 update deleted=1,普通查询自动追加 deleted=0
  • 分页:注册 PaginationInnerInterceptor 并指定 DbType.POSTGRE_SQL;注意 MyBatis-Plus 3.5.x 起分页依赖 mybatis-plus-jsqlparser 做 SQL 解析,漏引运行期分页会失败。入参 PageQuery{pageNum,pageSize}、出参精简成 PageResult<T>{rows,total},作为 R<PageResult<T>> 的 data。
  • SQL 日志:开发环境用 mapper 包的 debug 日志看 SQL,不用 StdOutImpl(生产噪声大)。

为什么这些放公共持久层而不是业务模块?因为它们是所有表都要的横切能力,集中一处配置,业务 Mapper 只管写自己的查询。

四、Sa-Token 鉴权

选 Sa-Token 而非 Spring Security,图的是主流程可读。鉴权通过注解声明式地挂在 Controller 上:

@SaCheckPermission("system:user:list")
@GetMapping("/list")
public R<PageResult<SysUserVo>> list(PageQuery pageQuery) { ... }

几个关键点:token-name: Authorizationtoken-prefix 没启用(学习阶段简化调试,所以前端传裸 token、不带 Bearer )。登录时把用户的角色标识、权限标识写进 Session 的 LoginUser,后续 @SaCheckPermission 直接从 Session 取,不每次查库。前端那套"菜单级 + 按钮级"权限,数据源头就是这里发出去的角色/权限集和菜单树。

五、数据权限:注解 + AOP + SQL 拼接

"菜单级权限"管能不能调某个接口;"数据权限"管同一接口里能看到哪几行——比如同样查用户列表,部门经理只能看本部门。

实现是 MyBatis-Plus 的 DataPermissionHandler + 一个注解:

@DataPermission(deptIdColumn = "dept_id", userIdColumn = "create_by")
Page<SysUser> selectUserPage(Page<SysUser> page, @Param("ew") Wrapper<SysUser> ew);

拦截器在 SQL 执行前回调 handler,按当前用户角色的数据范围(DataScopeEnum:ALL / DEPT / DEPT_AND_CHILD / CUSTOM / SELF)动态拼 WHERE:

  • 有 ALL → 直接放行,不加条件;
  • 有可访问部门 → dept_id IN (...)
  • SELF → create_by = 当前用户ID
  • 二者并存 → (dept_id IN (...) OR create_by = ?)
  • 拿不到有效用户上下文 → 返回恒假 1=0

最后这条 fail-closed 很关键:宁可什么都查不到,也不能在身份异常时泄露全量数据。多角色时取并集(不是交集,避免缩小权限)。Provider 用 ObjectProvider 延迟获取,绕开"拦截器依赖 SqlSessionFactory、Provider 又依赖 Mapper"的循环依赖。

六、多租户:行级隔离

多租户 = 一套库表给多家公司用,彼此看不见。这里走 MyBatis-Plus 的租户行级隔离:隔离表每条数据带 tenant_idTenantLineHandler 在所有 SQL 上自动追加 tenant_id = ?,业务代码无感。sys_menusys_role_menu 这类全局共享表则不加。

难点在"租户上下文从哪来"。正常请求从 Sa-Token Session 的 LoginUser.tenantId 取;但登录这一刻 Session 还没建立,查用户时拿不到租户。解法是一个 TenantContextHolder(ThreadLocal):登录流程先用请求里的租户 ID 显式 set,查完用户、建 Session 后再清掉;之后 SaTokenTenantProvider 优先读 ThreadLocal、再退回 Session。需要说明:这是"行级隔离的内核",租户管理后台、登录选租户等运营功能还没做——属于"机制实现了、功能没补全"。

七、两个值得记下来的排错故事

1. 雪花主键的精度问题

前端 JavaScript 的 Number 安全整数只到 2^53-1,19 位雪花 Long 一传过去就静默丢精度,回传命中错误记录。解法是写一个 BigNumberSerializer只对超出 JS 安全范围的 Long 转字符串,范围内的小整数(status、count)仍按数字输出,把对前端的影响降到最小:

long v = value.longValue();
if (v > MAX_SAFE_INTEGER || v < MIN_SAFE_INTEGER) gen.writeString(value.toString());
else gen.writeNumber(v);

2. 一个列表接口 500,根因在 Jackson 模块注册

注册上面那个序列化器时,我在 JacksonConfig 写了 builder.modules(sensitiveModule, bigNumberModule)。当时构建、单测都过,认证、菜单接口也正常。直到做用户列表,GET /system/user/list 稳定 500。

根因相当隐蔽:Jackson2ObjectMapperBuilder.modules(...) 一旦被显式调用,就会覆盖 Spring Boot 自动注册的那批"知名模块"——包括处理 Java 8 时间的 JavaTimeModule。之前的响应都没有 LocalDateTime 所以没暴露;用户列表的 VO 带了 createTime,序列化时找不到 JavaTimeModule 直接抛异常 → 全局处理器兜成 500。修复是把它一并显式装回去:

builder
  .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
  .modules(new JavaTimeModule(), sensitiveModule, bigNumberModule);

教训:builder.modules() 是"全量替换"语义,不是"追加"。一旦自己接管模块列表,Spring Boot 默认帮你装的东西就得自己负责装回来。

相关的小坑:Java 21 起 javac 默认不再隐式扫描 classpath 上的注解处理器,而 spring-boot-starter-parent 没把 Lombok 放进 annotationProcessorPaths。结果 IDE(自带 Lombok 插件)能编译,纯命令行 mvn compile 却报 @Slf4jlog 找不到符号。解法是在根 pommaven-compiler-plugin 里显式登记 Lombok 处理器路径。

小结

这套后端最终提供了一个干净的 RBAC 底座:多模块分层、统一响应与全局异常、MyBatis-Plus 的雪花主键/逻辑删除/自动填充/分页、Sa-Token 声明式鉴权、数据权限 AOP、多租户行级隔离。

回头看,最有价值的不是"又配好一个 starter",而是那两个排错故事——它们都指向同一类问题:

当你接管框架的某个默认行为时(自定义 Jackson 模块、自定义注解处理器路径),就等于把框架原本默默替你做好的事一起接管了,漏一样就出问题。

把这些隐式约定显式化、并在真实数据上验证,比堆功能重要得多。