精炼版 RuoYi-Vue-Plus 后端:从多模块骨架到数据权限与多租户
用 Java 21 + Spring Boot 3.5 + MyBatis-Plus + Sa-Token 从零搭一套多模块 RBAC 后端,讲清分层、统一响应、持久层基建、声明式鉴权、数据权限 AOP、多租户行级隔离,以及两个值得记下来的排错故事。
这套后端是精炼重构 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-system | RBAC 核心业务(用户/角色/部门/菜单)的 Service/Mapper |
common-core | 基础常量/枚举/响应模型/异常/纯工具——不依赖任何技术栈 |
common-web | 全局异常、参数校验、WebMvc、Jackson 配置 |
common-mybatis | MyBatis-Plus、分页、数据权限、租户拦截器、字段填充 |
common-satoken | Sa-Token 登录认证、权限校验 |
common-redis | 缓存与分布式能力 |
依赖方向严格自上而下:admin → system → common-mybatis → common-core,system 不得反向依赖 admin,common-core 不碰 Web/DB/Redis。这条线最容易被破坏的是 BaseEntity 该放哪——它带 MyBatis-Plus 注解,所以必须放 common-mybatis 而不是 common-core,否则核心层就被 ORM 污染了。
这种分层的价值是:同一套业务逻辑能被不同入口(REST、RPC、定时任务)复用。一个直接体现:Controller 全放 admin,system 只有 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, "操作失败")(堆栈只记日志,不暴露给前端)
关键约定:异常全部交给 @RestControllerAdvice 的 GlobalExceptionHandler 统一拦截(参数校验、请求体解析失败、类型转换、方法不支持、业务异常、未知异常各有分支),严禁在 Service 层盲目 try-catch 把异常吞掉。ServiceException 给业务主动抛,错误码集中在 ResultCode 枚举里维护。这样 Controller 写起来很干净,只管编排,不管错误怎么变成响应。
三、持久层:MyBatis-Plus 基建
common-mybatis 承载所有持久层基础能力,几个要点:
- 雪花主键:全局
id-type: assign_id,@TableId(type = IdType.ASSIGN_ID),不用自增——这是后面"前端精度坑"的根源(见第七节)。 - 自动填充:
MetaObjectHandler在新增/更新时自动填createTime/updateTime/createBy/updateBy,业务代码不用手写。 - 逻辑删除:
BaseEntity的deleted字段标@TableLogic,delete被改写成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: Authorization,token-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_id,TenantLineHandler 在所有 SQL 上自动追加 tenant_id = ?,业务代码无感。sys_menu、sys_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却报@Slf4j的log找不到符号。解法是在根pom的maven-compiler-plugin里显式登记 Lombok 处理器路径。
小结
这套后端最终提供了一个干净的 RBAC 底座:多模块分层、统一响应与全局异常、MyBatis-Plus 的雪花主键/逻辑删除/自动填充/分页、Sa-Token 声明式鉴权、数据权限 AOP、多租户行级隔离。
回头看,最有价值的不是"又配好一个 starter",而是那两个排错故事——它们都指向同一类问题:
当你接管框架的某个默认行为时(自定义 Jackson 模块、自定义注解处理器路径),就等于把框架原本默默替你做好的事一起接管了,漏一样就出问题。
把这些隐式约定显式化、并在真实数据上验证,比堆功能重要得多。