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管线上。
| 机制 | 项目里的类 | 拿到的数据 | 干的事 |
|---|---|---|---|
| ② HandlerInterceptor | DemoModeInterceptor |
request 的 method/uri | 演示模式下拦截写操作(放行/拦截) |
| ③ RequestBodyAdvice | DecryptRequestBodyAdvice |
原始请求体字符串 | 反序列化前解密请求体 |
| ④ AOP @Aspect | LogAspect、RepeatSubmitAspect |
方法入参对象、返回对象、注解 | 记操作日志 / 防重复提交 |
| ⑤ 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。
全局异常处理¶
@RestControllerAdvice(GlobalExceptionHandler)把各类异常逐层映射成统一Result+ 正确 HTTP 语义:NotLoginException→401、NotPermission/NotRole→403、MethodArgumentNotValid/BindException从BindingResult抠首个字段错误→400、BusinessException带业务码,最后Exception兜底"系统繁忙"。- 它补全切面管线里"出错那条线":正常返回走 ⑤ ResponseBodyAdvice,异常则在此统一收口,前端永远拿到同构
Result。
全局 JSON 序列化(Jackson)¶
JacksonConfig没有 new ObjectMapper,而是注册一个Jackson2ObjectMapperBuilderCustomizer做增量定制:只往 Spring 那个 ObjectMapper 上加 LocalDateTime/LocalDate 格式,不动其它默认配置。- 生效机制:Spring Boot 的
JacksonAutoConfiguration用Jackson2ObjectMapperBuilder构建那个唯一的ObjectMapperBean,构建时会回调容器里所有Jackson2ObjectMapperBuilderCustomizer(按@Order排序依次 apply)。所以你做的是"挂钩子"而非"换对象"——到处@Autowired注入的ObjectMapper,正是这个被定制过的同一个实例。 - 对比坏做法
@Bean ObjectMapper自己 new:会整个顶掉 Spring Boot 的默认配置。 - ⚠️ 项目自己破了这条规矩:
DemoModeInterceptor、NoticeRequest、SshWebSocketHandler三处直接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/updateBy是FieldFill.INSERT_UPDATE(插入+更新都填)。deleted不该在 insertFill 填,最佳实践是建表时默认值0。 - 公共字段为何应用层填而非数据库默认值:要填 createBy/updateBy(当前登录人),数据库拿不到"谁",只能从
StpUtil取;时间顺势一起填。代价:只对走ORM的写生效,绕过ORM不填充。 - 数据权限 DataPermissionInterceptor:
InnerInterceptor.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)¶
SaTokenConfig里allowedOriginPatterns("*") + 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这些表就必然看得到结构,无需额外授权。 generateToProject靠System.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
破环通用原理:构造图必须无环,调用图随便有环。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 | 处理某类事件/输入 | MetaObjectHandler、GlobalExceptionHandler |
| Provider | 按需提供数据/资源(常解耦/SPI) | CryptoConfigProvider、MailConfigProvider |
| 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)、DBSIZE、KEYS/TYPE/TTL、DEL。用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 消费设施。
优点(值得学)¶
- 清晰分层 + 单向依赖:common→infra→core→api→starter,父pom统一锁版本。
- DIP 接口下沉破跨层依赖:CryptoConfigProvider 范式,类型安全。
- 策略工厂 + 集合注入:6处复用同一套路,新增实现零改动(开闭原则)。
- 配置驱动 + 数据库动态配置中心:换供应商/改策略=改一个字段,免重启、运营可改。
- Sa-Token RBAC 最佳实践:StpInterface + SaSession 懒加载缓存权限 + 变更主动失效,避免每请求查库又保证实时。
- 切面用得专业:按"要操作的数据在哪一层"选对工具(加密用ResponseBodyAdvice、日志用AOP、演示模式用Interceptor、数据权限用MyBatis拦截器)。
- 公共字段自动填充 + 逻辑删除:MetaObjectHandler,业务无感。
- 代码生成器闭环:一张表到可用功能模块(含前端页面+菜单权限)几乎零样板。
- 模板方法收口公共逻辑: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 |
装好没入住的残留,直接删除 |