跳转至

Mars Admin 源码阅读笔记

个人学习记录,主要梳理读源码过程中关注的点。Spring Boot 3 + Vue 3 的 RBAC 后台脚手架(RuoYi 同源思路,技术栈更新)。 主体是「功能点梳理」(重要的多写、带流程图,次要的简记),末尾是优点 / 缺点 / 总评。


项目总评

定位:功能完整度优先的"开箱即用脚手架",不是工程严谨度优先的生产系统。技术栈现代(Java17/SpringBoot3/Sa-Token/MyBatis-Plus/Vue3),功能面广(RBAC+加密+IM+代码生成+多种第三方集成),分层和设计模式运用规范——作为学习架构设计、设计模式、Spring生态用法的素材非常合适

短板:几乎所有"有状态的东西"都只考虑了单机(AES密钥/SaToken配置/定时任务/WebSocket会话),分布式部署会接连暴雷;安全细节多处将就(明文密钥、伪加密、弱口令后门、登出不吊销);部分功能"装好没入住"(字典、App推送、web-api)。这些缺陷本身就是最好的反面教材。

给自己的话

  • 最大价值不止"它怎么实现RBAC",更是"我发现了哪些问题、根因、正确做法"——缺点清单比功能实现更值钱
  • 对照阅读 RuoYi-Vue-Plus(更成熟),看同一问题两边怎么处理,长进更快。

AI 时代注脚:这类脚手架作为"省事的成品"在贬值——AI 能一键生成"差不多能用"的克隆(还会自信地把单机 AES 密钥、RAMJobStore 这些常见反模式一起复刻,因为它输出的是"平均值"不是"对的那个")。但它作为"凝固的架构决策 + 一致约定"反而在升值:好脚手架正是 AI 扩展代码的轨道,「好脚手架 + AI」远胜「AI 从零裸写」。结论——代码越来越廉价,能判断 AI 写得对不对、能给它定轨道的判断力越来越贵;这份笔记的缺点清单,练的正是这个,所以读它在 AI 时代不是贬值而是升值,前提是你像现在这样拆解和批判,而不是抄

功能点梳理

模块层级(分层 + 单向依赖,理解一切的骨架)

mars-common   公共基础:Result统一响应、BaseEntity、全局异常、RsaUtils   (谁都能依赖,不依赖业务)
mars-infra    基础设施层:对外依赖都在这。db/redis/oss/sms/pay/push/social/wechat/crypto/mail/websocket
     ↑          —— 每个外部能力独立成子模块,互相不依赖
mars-core     业务核心:system(RBAC主体)/auth(登录策略)/file/gen(代码生成)/message(IM)/biz(生成代码落脚)
mars-api      接口层:admin-api(后台,30+控制器) / app-api(小程序) / web-api(网页前台,基本空壳)
mars-starter  唯一可执行jar:聚合三套api、打fat-jar、放application.yml
  • 父 pom 用 <dependencyManagement> 统一锁版本,子模块引依赖不写版本号。
  • 关键约束:底层模块不能反向依赖上层 —— 后面"依赖处理手法"全是为绕开这条。
  • 规模:后端约 248 个 Java 文件;Mapper XML 只 3 个(绝大多数走 mybatis-plus的BaseMapper/LambdaQueryWrapper)。

Sa-Token 认证鉴权(重点)

三个核心抽象(框架轻量的根源)

抽象 角色 谁实现
StpUtil 静态门面,一行 StpUtil.getLoginId() 取当前用户 框架
StpInterface RBAC 数据源:「给 userId,返回权限/角色」 项目 StpInterfaceImpl
SaTokenDao token/session 存储 Redis(sa-token-redis-jackson)

鉴权请求流程

请求 /api/sys/user (Authorization头带token)
   ▼ SaInterceptor (SaTokenConfig注册) ── 匹配/api/**,白名单notMatch放行,否则 checkLogin()
   ▼ @SaCheckPermission("sys:user:add")  ── 方法级注解鉴权
   │     │
   │     ▼ 触发 StpInterfaceImpl.getPermissionList(userId)
   │           │
   │           ▼ 先查 SaSession 缓存(懒加载) ── session.get(key, ()->查库)
   │           │    命中→直接用;未命中→ userService.getPermissions() → SQL: user→role→menu
   │           ▼ 比对 "sys:user:add" 是否在列表 → 通过 / 抛 NotPermissionException / 可以用通配符user:*
               ▼ 如果觉得注解麻烦,可以在添加SaInterceptor的时候,指定SaRouter.match("/user/**", r -> StpUtil.checkPermission("user"));
Controller 执行

登录 / 登出 / Token 生命周期

登录 StpUtil.login(userId):
   生成token(uuid风格) → 写Redis三组键值对:
     token  → userId           (反查"谁登录的", TTL=timeout)
     userId → Account-Session  (该账号所有在线token清单 + 权限缓存)
     token  → last-active时间戳 (配合 activeTimeout 无操作过期)
   → getTokenValue() 返回token给前端,存localStorage

登出 StpUtil.logout() (无参):
   ThreadLocal(RequestContextHolder)拿当前请求 → 从Authorization头抠token
   → Redis反查userId → 删除上述键 → 旧token立即失效

  • tokenName(默认配成Authorization)一职两用:既是请求头名,也是 Redis key 前缀。
  • 权限缓存放 Account-Session(按用户、多端共享),一次清理对所有端生效。
  • 缓存失效:改用户角色 / 改角色权限时调 StpInterfaceImpl.clearPermissionCache(userId) 主动删权限缓存 → 保证实时(RBAC 最易漏的一步)。
  • Token 配置(tokenName/timeout/isConcurrent/isShare/tokenStyle)由 SaTokenConfigLoader(ApplicationRunner) 启动时从数据库 security 配置加载,覆盖 yml。
  • 两套权限分清:功能权限(菜单按钮)走 StpInterface + SaSession 缓存;数据权限(行级)走 DataPermissionInterceptor 每次查库现算、无缓存。
  • 超管判定是反模式isAdmin 全靠硬编码 "admin".equals(code),散在 DataPermissionInterceptor/SysMenuServiceImpl/GenTableServiceImpl 至少 3 处——魔法串、逻辑分散、还耦合"可被改名的 role code"。最佳实践:超管 = 拥有 Sa-Token 的 * 通配权限getPermissionList 对超管返回 ["*"])+ 数据范围"全部",于是所有 if(isAdmin) 特判都能删掉、由通用 RBAC 自动放行——"最好的超管实现是没有超管特判"。退一步至少收口成一个 SecurityUtils.isSuperAdmin(),并绑不可变角色 ID 而非 code。
  • 【我觉得超管账号列表可以放到配置中心】

演示模式拦截器

DemoModeInterceptor + 配置项 mars.demo-mode,开启后拦截所有增删改PUT/DELETE/POST。线上 demo 站 marsadmin.cn 用 admin/admin123 登录后"部分操作受限"的实现。

Spring 切面 / 拦截机制管线(重点)

核心认知:这几种不是可互换的同类,而是卡在请求管线不同位置、能拿到不同数据的工具。选哪个取决于「要在什么时机操作什么形态的数据」。

HTTP请求(字节流)
  ▼ ① Filter ─────────────────── 最外层,只有原始byte[],看不到对象
  ▼ ② HandlerInterceptor.preHandle ── 进Controller前;有request/response,body未解析、返回值不存在
  │      项目: DemoModeInterceptor(演示模式拦写操作)、ApiAccessCollectInterceptor(采集访问)
  ▼ ③ RequestBodyAdvice.beforeBodyRead ── @RequestBody反序列化"前";能改请求体
  │      项目: DecryptRequestBodyAdvice(解密请求)
  │      〔HttpMessageConverter: JSON字节 → 入参对象〕
  ▼ ④ AOP @Around/@Before/@AfterReturning ── 包住Controller方法;拿得到入参对象+返回对象+注解
  │      项目: LogAspect(操作日志)、RepeatSubmitAspect(防重提交)
  │        Controller执行 → return Result<T>
  │ ▼ ⚡【全局异常处理拦截点】⚡ 
  │      Spring 捕获异常,匹配 `@ExceptionHandler` 方法。
  │      把你的异常(如 `BizException`)转换/包装为正常的返回值(如 `Result.fail("错误信息")`)。
  |     【之后抛出的异常,就捕获不到了】
  ▼ ⑤ ResponseBodyAdvice.beforeBodyWrite ── 返回对象序列化成JSON"前";能改它 ★加密在这★
  │      项目: EncryptResponseBodyAdvice(加密响应)
  │      〔HttpMessageConverter: 返回对象 → JSON字节〕
  ▼ ② HandlerInterceptor.postHandle / afterCompletion
  ▼ ① Filter 出
HTTP响应(字节流)

另: MyBatis InnerInterceptor 在 DAO 层改写SQL(数据权限、分页),不在HTTP管线上。
项目里 4 个实例对号入座

机制 项目里的类 拿到的数据 干的事
② HandlerInterceptor DemoModeInterceptor request 的 method/uri 演示模式下拦截写操作(放行/拦截)
③ RequestBodyAdvice DecryptRequestBodyAdvice 原始请求体字符串 反序列化前解密请求体
④ AOP @Aspect LogAspectRepeatSubmitAspect 方法入参对象、返回对象、注解 记操作日志 / 防重复提交
⑤ ResponseBodyAdvice EncryptResponseBodyAdvice 返回对象(Result<?>) 序列化前加密响应体
(DAO 层) MyBatis InnerInterceptor DataPermissionInterceptor、分页 SQL 语句 改写 SQL

选型决策表

要做的事 该用 因为
鉴权/限流/放行/改header HandlerInterceptor 进Controller前介入,不需要body
解密/改写请求体 RequestBodyAdvice 卡在请求反序列化前
加密/包装响应体 ResponseBodyAdvice 卡在响应序列化前,能拿类型化返回对象+注解+路径
记日志/防重/方法增强(入参/返回值/耗时) AOP @Aspect 包住方法,拿得到方法签名和参数
改写SQL MyBatis InnerInterceptor DAO层,操作SQL不是HTTP
原始字节流/全局编码/CORS Filter 最外层Servlet容器级
  • 口诀:先问「要操作的数据是什么形态」(HTTP原语/请求体/方法参数/返回对象/SQL),再问「在哪个时机」→ 定位唯一合适的机制。

为何加密非 ResponseBodyAdvice 不可(对照另外三种):它要把"序列化前的 Result.data"换成密文,天然拿到三样上下文——body(类型化返回对象→getData()加密) + returnType(读@EncryptResponse) + request(读路径白名单)。换别的全别扭:

  • HandlerInterceptor:postHandle 对 @RestController 拿不到返回对象(ModelAndView=null);改body只能套 ResponseWrapper 缓存整个输出再回写——笨重易错。它定位是放行/改header,不碰body内容。
  • ⚠️ AOP:唯一勉强可行的替代——能拿返回对象,但内容类型/路径/是否序列化等上下文要自己从 RequestContextHolder 抠,且语义不对(方法增强≠响应体加工)。能凑,不专业。
  • Filter:最外层只有字节流,看不到 Result 对象。

请求/响应是对称的一对:DecryptRequestBodyAdvice(③) 与 EncryptResponseBodyAdvice(⑤) 正好卡在 HttpMessageConverter 两侧——进:JSON字节→对象"前"把密文换明文(Controller收到正常对象);出:对象→JSON字节"前"把明文换密文。Spring 给这对扩展点,就是为"请求/响应体的透明编解码"而生。

一句话总结 这 4 种机制不是"风格选择",而是请求管线上不同位置、操作不同数据的专用工具。加密用 ResponseBodyAdvice,是因为它要操作的是"序列化前的返回对象",并且需要方法注解、内容类型、请求路径这些上下文——只有这个钩子能优雅、完整地提供;换 Interceptor 拿不到返回对象(得套 ResponseWrapper 硬抠),换 AOP 能凑合但语义不对、上下文要自己捞。换 Filter 只有字节流。同理,演示模式用 Interceptor(只需放行/拦截)、日志防重用 AOP(要方法入参)、数据权限用 MyBatis 拦截器(要改 SQL)——各按"要操作的数据在哪一层"选工具,这恰恰是这个项目切面用得专业的地方,而不是混乱。


防重放

RepeatSubmitAspect 的 key 设计(④ AOP 的具体玩法):repeat_submit:{userId|未登录则sessionId}:{URI}:{参数MD5} 做 Redis setIfAbsent(key,"1",interval)——占成功放行、占失败即判重复。精确到"同接口+同参数"才算重复,比按 URL 一刀切更细;过滤掉 MultipartFile/Request/Response 等不可序列化入参再取 MD5。


全局异常处理

  • @RestControllerAdviceGlobalExceptionHandler)把各类异常逐层映射成统一 Result + 正确 HTTP 语义NotLoginException→401NotPermission/NotRole→403MethodArgumentNotValid/BindExceptionBindingResult 抠首个字段错误→400、BusinessException 带业务码,最后 Exception 兜底"系统繁忙"。
  • 它补全切面管线里"出错那条线":正常返回走 ⑤ ResponseBodyAdvice,异常则在此统一收口,前端永远拿到同构 Result

全局 JSON 序列化(Jackson)

  • JacksonConfig 没有 new ObjectMapper,而是注册一个 Jackson2ObjectMapperBuilderCustomizer增量定制:只往 Spring 那个 ObjectMapper 上加 LocalDateTime/LocalDate 格式,不动其它默认配置。
  • 生效机制:Spring Boot 的 JacksonAutoConfigurationJackson2ObjectMapperBuilder 构建那个唯一的 ObjectMapper Bean,构建时会回调容器里所有 Jackson2ObjectMapperBuilderCustomizer(按 @Order 排序依次 apply)。所以你做的是"挂钩子"而非"换对象"——到处 @Autowired 注入的 ObjectMapper,正是这个被定制过的同一个实例。
  • 对比坏做法 @Bean ObjectMapper 自己 new:会整个顶掉 Spring Boot 的默认配置。
  • ⚠️ 项目自己破了这条规矩DemoModeInterceptorNoticeRequestSshWebSocketHandler 三处直接 new ObjectMapper(),绕过全局 Bean → 没有统一时间格式、还重建重量级对象。正确做法:注入那个全局 Bean。

日志体系(6套入库 + 1套文件)

日志 实现 维度/受众 写法 必要性
操作日志 LogAspect sys_oper_log 业务审计:谁增删改了什么(标题/参数/返回值/耗时) @Log注解AOP,只记写操作,异步 ✅ 必要
登录日志 SysLoginLogService sys_login_log 安全审计:登录成败/IP/地点 同步 ✅ 必要
API访问日志 ApiAccessCollectInterceptor sys_api_access_log 性能监控:全量接口耗时/状态码/QPS Redis缓冲+30s批量落库 ⚠️ 最该砍
任务日志 SysJobLogService sys_job_log 业务留痕:定时任务执行结果 同步 ✅ 必要
短信日志 SmsLogService (短信日志表) 业务留痕:短信发送记录 同步 ✅ 必要
通知日志 SysNoticeSendLogService (通知日志表) 业务留痕:通知投递记录 同步 ✅ 必要
应用日志 SLF4J/logback (文件,不入库) 开发排障 文件 ✅ 必要
  • 三类分工:审计(操作/登录,给人/合规) / 监控(API访问,给运维) / 投递留痕(任务/短信/通知,排障"为什么没发出去")。维度受众不同,大部分不重复。
  • 唯一重叠:操作日志 vs API访问日志都记 path/method/IP/耗时——但操作日志只记写操作+带业务语义(title/参数),API日志全量轻量无业务语义,不能互替。
  • 该砍的是 API访问日志(见缺点#20):用 MySQL 干 APM 的活(QPS/耗时聚合是OLAP)、全量入库(每个GET都记)、且无清理任务=只进不出的炸弹,数据量扛不住。正确做法:Actuator 的 http.server.requests 指标(内存聚合) + Prometheus,或 Nginx access log。其余5套是审计+排障刚需,保留。
  • ⚠️ 操作日志会记明文密码@Log 默认 isSaveRequestData=true,注册/改密接口入参带明文 password,LogAspect 只滤 MultipartFile 不脱敏 → 明文落 sys_oper_log.oper_param(见缺点#13)。

数据访问(MyBatis-Plus 体系)

  • MybatisPlusConfig 一身二职:① @Bean MybatisPlusInterceptor 装拦截器链(数据权限先包SQL → 分页最后加LIMIT,顺序敏感);② implements MetaObjectHandler 写库前自动填充。
  • BaseEntity:公共字段自动填充如 updateTime/updateByFieldFill.INSERT_UPDATE(插入+更新都填)。deleted不该在 insertFill 填,最佳实践是建表时默认值0。
  • 公共字段为何应用层填而非数据库默认值:要填 createBy/updateBy(当前登录人),数据库拿不到"谁",只能从 StpUtil 取;时间顺势一起填。代价:只对走ORM的写生效,绕过ORM不填充。
  • 数据权限 DataPermissionInterceptorInnerInterceptor.beforeQuery 里,按方法 @DataScope 注解 + 角色 dataScope(1全部/2本角色部门/3本部门/4本部门及子级/5仅本人),把原SQL包成 SELECT * FROM (原SQL) WHERE 部门条件,反射改写 BoundSql.sql。"本部门及子级"用 FIND_IN_SET(deptId, ancestors)
  • ⚠️ 实现坏味道(很怪、很容易出错)【数据权限不用看了】:① 字符串拼 SQL(注入温床);② 反射调 Mapper(丢类型安全、改名运行时才炸);③ 反射改私有字段 BoundSql.sql(框架升级即崩);④ 外层 SELECT * FROM (原SQL) WHERE 包裹 → 派生表物化、过滤用不上索引,且强耦合"原 SQL 必须 select 出 dept_id 列"否则报未知列;⑤ 多角色 OR 逻辑中途 return "" 提前退出,边界绕;⑥ 超管魔法串 "admin"

接口加密(混合加密)【缺陷重重,可只关注对称密钥加密的切面实现】

  • 请求:RSA 公钥加密(前端jsencrypt)→ 后端私钥解密,只保护 password 等字段或整体 encryptedData。
  • 响应:AES-256-GCM 对称加密(响应量大RSA加不了),格式 Base64(IV).Base64(密文),前端 Web Crypto API 解密。
  • 密钥分发/crypto/config 下发 RSA公钥 + AES密钥(明文Base64),前端存内存不落盘。两种模式:全局加密(白名单豁免登录/文件) / 部分加密(只加 @EncryptResponse 的方法)。
  • ⚠️ 变量名 encryptedAesKey 骗人——实际没加密就是明文Base64,安全全靠HTTPS;密钥全局唯一全员共享,AES每实例随机生成(分布式会崩,见缺点#1)。
  • ⚠️ 更根本:对称密钥就不该"分发"。AES 是对称密钥(加解密同一把),密码学铁律是永不明文传输——传输一旦被截获即全盘解密。这里走接口明文下发,安全完全寄生于 HTTPS。而这恰恰自相矛盾:既然信 HTTPS 能安全送密钥,就该信它能安全送业务数据,应用层再 AES 加密一遍纯属多余
  • 正确做法(若真要应用层加密):前端自己生成 AES 密钥 → 用后端 RSA公钥加密后上传 → 后端私钥解开 → 密钥永不明文露面(这就是 TLS 握手的密钥交换)。
  • 讽刺:项目明明已有 RSA 公私钥(请求方向就在用公钥加密密码),却没拿来包裹 AES,反而明文下发——RSA 方向用对了(公钥加密/私钥解、不传密钥),AES 方向用错了

认证与跨域安全(CORS / CSRF)

  • SaTokenConfigallowedOriginPatterns("*") + allowCredentials(true) 是安全漏洞,简直就是大门洞开,CSRF的福地,该项目也未用cookie鉴权;
  • Spring两项配置,allowCredentials(true)允许跨站请求带cookie,这对多个关联网站例如a1.domain.com和a2.domain.com认证很有效,但一定要显式设置域名allowedOrigins,决不允许*
  • 非法的跨域请求会被spring拦截返回403;
  • 但不带Origin就会直接放行,如GET和老浏览器
  • 可以靠SameSite cookie来防止请求带cookie,但老式浏览器也可能不支持;
  • 所以CORS是无法完全防住CSRF的,后者必须有专门的措施。
  • 现代更主流的做法是放弃了Cookie鉴权,使用Header token根治。前端把token存入 localStorage,每次请求时,前端手动将其放入请求头:Authorization: Bearer <token>

小程序 / APP 不参与 CORS - CORS 只对浏览器生效。wx.request、原生 HTTP 客户端不校验同源、通常不发 Origin。 - 收紧后端 CORS 白名单不影响小程序/APP(小程序受微信「request 合法域名」+ HTTPS 管,APP 无同源限制),只影响浏览器端(后台 Vue、uni-app H5)。


登录策略工厂(最佳样板)

LoginRequest(loginType+clientType+各方式字段)
   ▼ LoginStrategyFactory ── 启动时 List<LoginStrategy>自动注入 → @PostConstruct收进EnumMap
   ▼ getStrategy(loginType, clientType) ── 双维度: loginType选策略, clientType校验客户端
   ▼ 具体策略.login(): Password/SmsCode/MiniProgram/Social ── 各自认证(变化的部分)
   ▼ LoginHelper.doLogin(user) ── 公共: 单点踢人/签token/写SaToken-Session/记日志(不变的部分)
   ▼ LoginResult(token...)返给前端
  • 新增登录方式:只写一个 @Service implements LoginStrategy,工厂自动识别,零改动(开闭原则)。
  • 模板方法味:策略管「认证」,LoginHelper 管「建立登录态」,切分干净。

策略工厂全家(6个,分两类)

工厂 选策略依据 类型
Login / Social / Pay 请求参数(loginType/platform/payType) 请求驱动:多实现并存,找不到报错
Sms / Push / FileStorage 配置 provider 字段 配置驱动:全局单一生效,找不到降级console
  • 收集方式:无状态实现(login/sms/social/pay)用 List<T> 集合注入;有状态实现(oss/push)用 switch+new+init
  • 短信无状态(每次现读ak/sk+new轻量client)→ 能做单例Bean;OSS因为密钥可动态变 + 持MinioClient连接池必须复用=无法单例Bean()→ 脱离Spring,用new对象+refresh

配置中心(系统配置)

  • sys_config_group 表:一分组一行,值是JSON(14分组:system/login/password/email/sms/storage/push/payment/security/wechat...)。SystemConfigHelper.getConfig() 读库→Jackson解析→取字段(⚠️每次读库无缓存)。
  • 好处:免重启生效、运营可改、策略工厂靠 provider 切供应商、配置可被前端消费(/config-group/public)、集中管理带变更日志。边界:连库前要用的(datasource/redis/port)必须留 yml。

代码生成器(mars-gen)

INFORMATION_SCHEMA读表结构 → 智能推断(类型映射/命名转换/控件类型/字典)落gen_table(可编辑)
   → Velocity模板渲染 → 8文件(entity/mapper/service/serviceImpl/controller + api.ts/index.vue + menu.sql)
   → 预览Map / 打ZIP / 直写源码 + 自动建菜单&授权admin
  • table_schema=(SELECT DATABASE()) 当前库;INFORMATION_SCHEMA 对所有账号开放、按对象权限过滤行,应用账号能CRUD这些表就必然看得到结构,无需额外授权。
  • generateToProjectSystem.getProperty("user.dir") 定位源码树 → 只能开发环境用,jar部署失效。

💡 重要提示(AI 时代视角):这类"表结构 + 模板引擎生成 CRUD"的代码生成器,价值已大幅下降——如今把表结构/DDL 直接丢给 AI 就能生成 entity/mapper/service/controller/前端页,而且比固定模板灵活得多:能处理非标准需求、直接带业务逻辑(不止 CRUD 骨架)、适配任意代码风格、不受模板束缚。模板生成器只剩"批量、强一致、零 token 成本"的边角优势。今天学它,价值在于理解"元数据驱动 + 模板渲染"这个思路本身,而非把它当实用工具。


依赖处理手法(6种,按优雅度)

手法 实例 解决 评价
构造器注入(final+@RequiredArgsConstructor) 全项目 正向同层 基线,首选
DIP接口下沉 CryptoConfigProvider 跨层反向依赖 ⭐ 下层定义接口、上层实现,类型安全
集合注入 List<T> 6个策略工厂 一对多可插拔
@Lazy 延迟注入 SysMenuServiceImpl 循环依赖 止血非治本;有Lombok坑
静态上下文 SpringUtils mars-job 非Bean环境取Bean 逃生舱
getBean(name)+反射 DataPermissionInterceptor / JobInvokeUtil 跨层/动态调用 逃生舱,丢类型安全
  • 同一问题两种解法对照CryptoConfigProvider 用 DIP(优雅),DataPermissionInterceptor 用 getBean+反射(将就)。经验:跨层反向依赖优先 DIP 接口下沉
  • @Lazy 原理:注入的不是null,是个类型兼容的代理(替身)(接口走JDK代理、类走CGLIB)。代理只揣 BeanFactory 引用,每次方法调用才解析真身并反射转发——把「需要对方」从构造期推到调用期破环。口诀:构造图必须无环,调用图随便有环
  • 这里Lazy+RequiredArgsConstructor的坑是,构造器上默认并没有带@Lazy参数,所以这里Lazy了个寂寞,好在实际并没有循环依赖,所以没有报错;

依赖实战:把 DataPermissionInterceptor 的反射重构成 DIP 接口下沉

对应依赖处理表 #6(getBean+反射)的优化。现状糙在用字符串 beanName + 反射调 Mapper,丢了类型安全。

关键认知:接口不能、也不该镜像 Mapper。想在 mars-db 里定义 selectById(...)→SysUser?编译就过不了——实体 SysUser 在上层,下层引用不到。这不是障碍,正是 DIP 在逼你把接口写成问题域语义("拦截器要什么"),只用下层有的语言(基本类型 + 层中立 DTO)。

扒现有代码,拦截器真正只需三个问题 → 三个方法,零实体泄露:

// mars-db 定义接口 + record DTO(下沉的契约)
public interface DataScopeProvider {
    boolean isAdmin(Long userId);                   // 原来只用了 role.getCode() 比对
    List<RoleDataScope> getRoleDataScopes(Long u);  // 拼SQL要的 dataScope+roleId
    Long getUserDeptId(Long userId);                // 原 selectById 整个user其实只取了deptId
}
public record RoleDataScope(Long roleId, Integer dataScope) {}   // 层中立,不依赖任何实体

// mars-system 实现:Mapper/实体/BaseMapper 随便用 —— "怎么查"留在这
@Component @RequiredArgsConstructor
class DataScopeProviderImpl implements DataScopeProvider {
    private final SysRoleMapper roleMapper;
    private final SysUserMapper userMapper;
    public Long getUserDeptId(Long id){
        return userMapper.selectById(id).getDeptId(); 
    }
    // ...
}

"要什么"下沉成接口、"怎么查"(selectById 等 DAO 细节)留上层实现 —— 这就是翻译层的意义。和 CryptoConfigProvider 完全同构,依赖方向不变(mars-system 本就依赖 mars-db)。

改造前(getBean+反射) 改造后(DIP)
调方法 getMethod("...").invoke() provider.getRoleDataScopes(id) 直接调
重命名 mapper 方法 静默坏,运行时才炸 编译报错,IDE 重构自动同步
单测 几乎没法 mock mock 一个 DataScopeProvider 即可

另:拦截器里还有一处反射(改写 BoundSql 私有字段 sql)是独立的 SQL 改写问题,本次不动;彻底做法是换 MyBatis-Plus 官方 DataPermissionHandler(基于 JSqlParser)。【这里我并未继续了解】

依赖实战②:Bean 循环依赖 & ObjectProvider(破环原理)

上面重构若直接构造注入 Mapper 会启动炸 BeanCurrentlyInCreationException——这才是作者注释"动态获取 Mapper 解决循环依赖"的真实含义。

环不是两点直连,是绕经 SqlSessionFactory 的四节点环

DataPermissionInterceptor ─(若构造注入)→ Mapper
        ▲                                   │ Mapper 是 MapperFactoryBean 基于↓生成的代理
   插件链(持有它) ←(装配所有插件)── SqlSessionFactory
造 Mapper 要先有 SqlSessionFactory → 要先有拦截器 →(若构造注入)又要先有 Mapper,死锁。根因是 MyBatis 插件链上的 Bean 在构造期碰了 Mapper(结构性属性,换任何插件都一样)。

破环通用原理:构造图必须无环,调用图随便有环。Spring 要拓扑排序算创建顺序,构造期有环就排不出来;但运行期 A调B、B调A 无所谓(都是成品了)。所以把"解析依赖"从构造期推到调用期,构造图那条边就消失了。三种手段是同一招,差在类型安全:

手段 延迟解析 类型安全 暗坑
getBean("名")(作者用的) ❌ 字符串+反射 重命名静默坏
@Lazy(菜单服务用的) ✓(代理内部) Lombok不复制注解、代理身份
ObjectProvider(推荐) 几乎没有

ObjectProvider 本职是类型安全的按需依赖查找(把"容器塞给我"反转成"我要时找容器拿"),四种用法:getObject() 延迟解析(破环/原型注入) | getIfAvailable(默认) 可选依赖 | getIfUnique() 唯一才取 | stream() 遍历所有候选。

彩蛋:MyBatis-Plus 自动配置收集插件用的就是 ObjectProvider<Interceptor[]>——框架作者在同一片雷区选了同一个工具,放心用。


类命名后缀词汇表(角色 stereotype,看后缀预判职责)

命名后缀是社区共享词汇,看到它就能预判这个类"扮演什么角色"。分两类。

A 类 · GoF 模式命名(有结构约束)

后缀 角色信号 本项目/Spring 实例
Adapter A接口转成B接口 DecryptRequestBodyAdvice extends RequestBodyAdviceAdapter
Facade 复杂子系统包成简单接口 SystemConfigHelper(读JSON→getString/getInt)
Strategy 可互换的算法/策略 LoginStrategy + 4实现
Factory 创建对象/选实现 6个策略工厂
Builder 分步构建复杂对象 Lombok @Builder、StringBuilder
Decorator/Wrapper 包一层增强、保持同接口 DecryptedHttpInputMessage、BufferedReader
Template 固定流程骨架留钩子 RedisTemplate、JdbcTemplate
Proxy 代理增强 Spring AOP 动态代理

B 类 · 职责命名(无结构、只表角色)—— 和 Helper 同级

后缀 角色信号 实例
Helper 复用的、非主流程辅助逻辑(最泛) LoginHelper
Manager 管资源生命周期/协调(有决策权) EntityManager、TransactionManager
Handler 处理某类事件/输入 MetaObjectHandlerGlobalExceptionHandler
Provider 按需提供数据/资源(常解耦/SPI) CryptoConfigProviderMailConfigProvider
Resolver 从输入解析出结论 ViewResolver、ArgumentResolver
Processor 对输入做加工 BeanPostProcessor
Holder 持有并提供访问(常配ThreadLocal) RequestContextHolder
Registry 注册表,管一组已注册对象 BeanDefinitionRegistry、MeterRegistry
Context 携带一组相关状态/环境 ApplicationContext、SecurityContext
Support 给子类通用支撑的抽象基类 WebMvcConfigurationSupport
Aware 接口:"我想被注入某能力" DataPermissionInterceptor implements ApplicationContextAware

最易混的几个:Util(静态/无依赖,工具) | Helper(Bean/有依赖,帮手·打下手) | Manager(掌控资源·有决策,管家) | Handler(被触发处理,接线员) | Service(业务主流程,主理人)。

  • 选后缀口诀:创建→Factory/Builder|转换适配→Adapter|处理输入→Handler|提供数据→Provider|解析→Resolver|管资源→Manager|持有状态→Holder|说不清的复用辅助→Helper。
  • 原则:能用更具体的后缀就别用 Helper(最泛、最易沦为垃圾桶)。如 SystemConfigHelper 实质是 Provider+Facade,叫 Helper 反而模糊了职责。

其它杂项(简记)

  • 三套API:admin(后台全功能) / app(小程序IM真功能) / web(空壳)。同进程同业务层,按客户端的权限模型/认证/加密差异分门面。web 可砍。
  • 登出 token 怎么传:axios 请求拦截器对所有请求统一加 Authorization 头;token 走 localStorage 不走 Cookie。
  • 缓存监控:纯透传 Redis 自省命令——INFO(内存/命中率/QPS)、DBSIZEKEYS/TYPE/TTLDEL。用 RedisCallback 降到原生连接执行模板没封装的服务器命令。
  • 定时任务(mars-job):Quartz 内存模式(RAMJobStore)sys_job 存任务,invokeTarget="beanName.method" 字符串反射调用普通Bean;AbstractQuartzJob 模板方法记日志。对标 RuoYi,不是 XXL-Job
  • 推送(mars-push):README 说极光/友盟/个推,实际只有钉钉/飞书/企业微信群机器人webhook,无App终端推送(接口预留 pushToDevice 但无实现)。
  • 字典管理:设施建好但没人用——手写页面全硬编码(性别写死1男2女),唯一消费者是生成器产出的student页;缺 useDict 消费设施。

优点(值得学)

  1. 清晰分层 + 单向依赖:common→infra→core→api→starter,父pom统一锁版本。
  2. DIP 接口下沉破跨层依赖:CryptoConfigProvider 范式,类型安全。
  3. 策略工厂 + 集合注入:6处复用同一套路,新增实现零改动(开闭原则)。
  4. 配置驱动 + 数据库动态配置中心:换供应商/改策略=改一个字段,免重启、运营可改。
  5. Sa-Token RBAC 最佳实践:StpInterface + SaSession 懒加载缓存权限 + 变更主动失效,避免每请求查库又保证实时。
  6. 切面用得专业:按"要操作的数据在哪一层"选对工具(加密用ResponseBodyAdvice、日志用AOP、演示模式用Interceptor、数据权限用MyBatis拦截器)。
  7. 公共字段自动填充 + 逻辑删除:MetaObjectHandler,业务无感。
  8. 代码生成器闭环:一张表到可用功能模块(含前端页面+菜单权限)几乎零样板。
  9. 模板方法收口公共逻辑:LoginHelper(登录态)、AbstractQuartzJob(任务执行+日志)。

缺点 / 反模式清单(症状 → 根因 → 正确做法)

A. 分布式隐患("只考虑单机"是主线问题)

# 症状 根因 正确做法
1 AES密钥多节点不一致,前端解密失败/抖动 各实例 KeyGenerator 内存随机生成,不共享 密钥放Redis/DB共享,或改per-session协商
2 SaToken配置改了只生效一台 applyConfig() 只刷本机 SaManager 内存 Redis pub/sub广播 或 Nacos推送
3 FileStorageFactory.refresh() 死代码无人调用 改ak/sk连单机都不生效 保存配置后调用 + 广播
4 定时任务多节点重复执行 Quartz RAMJobStore 内存模式 换 JdbcJobStore 集群模式 或 XXL-Job
5 IM消息跨节点发不出 ONLINE_SESSIONS 是单机内存 Map Redis发布订阅做跨节点投递

B. 安全问题

# 症状 根因 正确做法
6 登出不吊销凭证,旧token存活至TTL 前端先清token再调接口→logout请求裸奔被401 先带token调接口再清本地(已修)
7 接口加密实为伪安全 AES密钥明文Base64下发,全局唯一全员共享 至少per-session;安全靠HTTPS兜
8 支付私钥/AK/SK 明文进库 sys_config_group 存明文,真实凭证进了开源SQL 密钥加密存储/KMS;清空初始凭证
9 小程序默认密码123456后门 + 社交空密码 无"是否允许密码登录"标志,靠 userType/非法hash 侥幸拦 显式禁止标志 + 哨兵密码
10 缓存监控 KEYS 阻塞Redis keys(*) 单线程全扫,默认pattern=* 改用 SCAN 增量遍历
11 SSH终端服务器密码明文存库 + 浏览器直连shell 高危能力无防护 凭证加密 + 严格授权审计
12 CORS 反射任意源 + 带凭证=零跨域防护、且是定时炸弹 allowedOriginPatterns("*")+allowCredentials(true):反射任意 Origin 当白名单 → Spring 一个都不拦;而项目用 header token 根本不靠 cookie,credentials(true) 纯属多余——既危险又没必要 显式 origin 白名单 + allowCredentials(false);详见「认证与跨域安全」小节
13 明文密码写进操作日志 @Log 默认 isSaveRequestData=true,注册/改密接口入参带明文 password,LogAspect 只滤 MultipartFile 不脱敏 → 明文落 sys_oper_log.oper_param 敏感接口 @Log(isSaveRequestData=false),或字段 @JsonIgnore/脱敏白名单

C. 数据模型 / 设计

# 症状 根因 正确做法
14 三方登录跨平台openId碰撞串号、无法一人多绑、无unionId SysUser.openId 单字段,getByOpenId不带platform 独立 sys_user_social 表(platform,open_id)唯一索引,一对多
15 LoginRequest 大杂烩DTO 4种登录字段全塞一个类 按方式拆子对象
16 岗位(Post)是空架子 不参与任何权限计算 无审批流可砍

D. 工程瑕疵

# 症状 根因
17 @Lazy 静默失效(双重死代码) 字段上@Lazy但走Lombok构造器、未配 lombok.config 复制注解;且本无环
18 字典管理建好没人用 手写页面全硬编码,唯一消费者是生成器产出页;缺useDict
19 字典初始数据有3个重复性别 + 111/222测试垃圾 随手测试残留进了初始化SQL
20 API访问日志撑爆MySQL 全量入库做APM的活(OLTP干OLAP),且无清理任务
21 SystemConfigHelper 每次读库无缓存 高频路径隐性DB压力
22 构建产物/垃圾入库.idea/uploads/用户上传、mars-uniapp/unpackage/dist小程序编译产物132个 gitignore只对未跟踪文件生效 → git rm -r --cached 这些目录
23 死代码CustomerService/Customer 全工程零引用、TestTask 只打一行log 装好没入住的残留,直接删除