This commit is contained in:
2026-03-12 20:08:58 +08:00
parent 8cef3227f3
commit e352488298
38 changed files with 12 additions and 4173 deletions

3
.gitignore vendored
View File

@@ -73,3 +73,6 @@ functions/mock
screenshot
.firebase
sessionStore
# local docs
/docs/

View File

@@ -1,118 +0,0 @@
# rdms-spring-boot-starter-biz-ip
## 模块定位
这是一个本地静态工具模块,用于:
1. IP -> 地区编码查询
2. 地区树查询与地区名称格式化
不包含远程调用,不包含自动配置,不包含智能识别能力。
## 它做什么
1. 基于 `ip2region.xdb` 做 IP 归属地编码查询(离线、本地内存查询)
2. 基于 `area.csv` 提供地区树、地区路径、父级区域定位等工具方法
主要入口类:
1. `com.njcn.rdms.framework.ip.core.utils.IPUtils`
2. `com.njcn.rdms.framework.ip.core.utils.AreaUtils`
## 它不做什么
1. 不访问外部 IP 服务
2. 不保证行政区数据实时更新(数据随资源文件版本)
3. 不负责业务策略(如风控、推荐、画像)
## 资源与代价
1. 内置资源文件:
2. `src/main/resources/ip2region.xdb`(约 4MB
3. `src/main/resources/area.csv`
4. 类加载时会预加载资源到内存,换取查询速度
## 前端地区树返回(重点)
本模块非常适合做省市区级联选择器的后端数据源。
典型接口:
1. 管理端:`GET /system/area/tree`
2. App 端:`GET /system/area/tree`
节点字段:
1. `id`:区域编码
2. `name`:区域名称
3. `children`:子节点列表
## 后端示例(获取“江苏区域树”)
### 1. 获取江苏节点
```java
import com.njcn.rdms.framework.ip.core.Area;
import com.njcn.rdms.framework.ip.core.utils.AreaUtils;
Area jiangsu = AreaUtils.parseArea("中国/江苏省");
if (jiangsu == null) {
return;
}
```
### 2. 直接返回江苏及其下级区域树
`Area` 本身就是树节点(包含 `children`),拿到江苏节点后即可把它作为一棵子树返回。
```java
import com.njcn.rdms.framework.ip.core.Area;
import com.njcn.rdms.framework.ip.core.utils.AreaUtils;
Area jiangsu = AreaUtils.parseArea("中国/江苏省");
return jiangsu; // children 中包含南京、苏州等下级节点
```
### 3. 转成前端常用结构id/name/children
```java
import com.njcn.rdms.framework.ip.core.Area;
import com.njcn.rdms.framework.ip.core.utils.AreaUtils;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
record AreaNode(Integer id, String name, List<AreaNode> children) {}
private static AreaNode toNode(Area a) {
List<AreaNode> children = a.getChildren() == null
? Collections.emptyList()
: a.getChildren().stream().map(child -> toNode(child)).collect(Collectors.toList());
return new AreaNode(a.getId(), a.getName(), children);
}
Area jiangsu = AreaUtils.parseArea("中国/江苏省");
AreaNode jiangsuTree = jiangsu == null ? null : toNode(jiangsu);
```
返回示例:
```json
{
"id": 320000,
"name": "江苏省",
"children": [
{ "id": 320100, "name": "南京市", "children": [] },
{ "id": 320500, "name": "苏州市", "children": [] }
]
}
```
## 适用场景
1. 后端需要快速把 IP 转成地区信息
2. 需要返回地区树给前端做省市区联动
## 不需要它的场景
1. 项目没有 IP 归属地需求
2. 项目不需要地区树能力

View File

@@ -1,24 +0,0 @@
这个模块的核心作用是“环境标签(tag)透传 + 按标签路由”。
它具体做了什么
1. 启动早期把 rdms.env.tag 映射到 Nacos 实例 metadata (spring.cloud.nacos.discovery.metadata.tag)。
文件: EnvEnvironmentPostProcessor.java
2. Web 请求进来时读取请求头 tag,放到线程上下文。
文件: EnvWebFilter.java
3. Feign 调用时把上下文里的 tag 继续写到下游请求头。
文件: EnvLoadBalancerClient.java
rdms.env.tag 在这个项目里是可选的,不配也能跑。
代码里明确是“有就生效、没有就跳过”:
- 读取 rdms.env.tag为空就直接返回不改路由标签
EnvEnvironmentPostProcessor.java
- application-local.yaml 里配了 rdms.env.tag: ${HOSTNAME},而 dev 没配
application-local.yaml
它的目的就是你说的这种场景之一:
多人共用一个 Nacos namespace/集群时,用 tag 做流量隔离(比如“我发起的请求尽量打到我这套实例”)。
再具体点,作用有三层:
1. 给本实例打 Nacos metadata tag。
2. 接口收到 tag 请求头后放到上下文。
3. Feign/LB 优先选同 tag 实例。
所以 dev 不配 tag通常代表该环境不需要“按人/按版本隔离流量”,走默认路由即可。

View File

@@ -1,89 +0,0 @@
# rdms-spring-boot-starter-excel
## 模块定位
这是一个面向业务的 Excel 能力模块,核心目标是:
1. 降低导入导出开发成本(统一工具类)
2. 让业务字段和 Excel 展示语义解耦(注解 + Converter
3. 把字典能力接入 Excel 场景(值与标签互转、校验、下拉选项)
## 设计思路
1. 统一入口:通过 `ExcelUtils` 封装读写Controller 只关心 VO 和数据。
2. 注解驱动:通过 `@DictFormat``@ExcelColumnSelect` 把“字段语义”放在 VO 上,而不是散落在业务代码里。
3. 转换器分层:`DictConvert``AreaConvert``JsonConvert``MoneyConvert` 分别处理不同类型转换。
4. 字典缓存:`DictFrameworkUtils` 基于缓存读取字典,减少重复远程调用。
5. 导出体验自动列宽、下拉选项、Long 防精度丢失等细节统一在模块内处理。
## 核心类
1. `com.njcn.rdms.framework.excel.core.util.ExcelUtils`
2. `com.njcn.rdms.framework.excel.core.convert.DictConvert`
3. `com.njcn.rdms.framework.excel.core.handler.SelectSheetWriteHandler`
4. `com.njcn.rdms.framework.dict.core.DictFrameworkUtils`
5. `com.njcn.rdms.framework.dict.validation.InDict`
## 示例:用户导入导出(含字典转换)
### 1. 定义 Excel VO
```java
import cn.idev.excel.annotation.ExcelProperty;
import com.njcn.rdms.framework.excel.core.annotations.DictFormat;
import com.njcn.rdms.framework.excel.core.convert.DictConvert;
import lombok.Data;
@Data
public class UserImportExcelVO {
@ExcelProperty("登录名称")
private String username;
@ExcelProperty(value = "用户性别", converter = DictConvert.class)
@DictFormat("USER_SEX")
private Integer sex;
@ExcelProperty(value = "账号状态", converter = DictConvert.class)
@DictFormat("COMMON_STATUS")
private Integer status;
}
```
### 2. 导出模板
```java
import com.njcn.rdms.framework.excel.core.util.ExcelUtils;
import jakarta.servlet.http.HttpServletResponse;
public void exportTemplate(HttpServletResponse response) throws Exception {
ExcelUtils.write(response, "用户导入模板.xls", "用户列表", UserImportExcelVO.class, List.of());
}
```
### 3. 导入解析
```java
import com.njcn.rdms.framework.excel.core.util.ExcelUtils;
import org.springframework.web.multipart.MultipartFile;
public List<UserImportExcelVO> importExcel(MultipartFile file) throws Exception {
return ExcelUtils.read(file, UserImportExcelVO.class);
}
```
说明:
1. 导入时 `DictConvert` 会把 Excel 标签值转回字典 value如“男”->`1`)。
2. 导出时 `DictConvert` 会把字典 value 转成可读标签(如 `1`->“男”)。
## 适用场景
1. 后台管理常规 Excel 导入导出
2. 字典字段较多、希望自动做值/标签转换
3. 需要生成带下拉选项的导入模板
## 不适用场景
1. 超大规模离线数据处理(建议走专用批处理链路)
2. 复杂流式处理或多工作簿复杂编排(建议单独实现)

View File

@@ -1,217 +0,0 @@
# MQ 改造方案(评审稿)
## 1. 背景与目标
你当前的业务策略是:
1. 单体部署优先使用 Redis 作为 MQ。
2. 微服务部署优先使用 RocketMQ 作为 MQ。
3. RabbitMQ、Kafka 暂时不作为主路径,仅保留包结构隔离,不参与核心链路。
本方案目标是:
1. 在不破坏现有系统的前提下,建立可切换的 MQ 入口。
2. 把“切换成本”收敛到配置层,而不是业务代码层。
3. 采用分阶段改造,先低风险,后统一抽象。
## 1.1 本期非目标(避免范围蔓延)
本期改造明确不包含:
1. 严格顺序语义保障(全链路有序消费)。
2. 统一死信队列DLQ治理体系。
3. 事务消息与最终一致性框架化封装。
4. 多机房容灾级别的 MQ 治理。
说明:
1. 本期先完成“可切换、可回滚、可观测”的基础能力。
2. 复杂语义能力后续在 RocketMQ 路线下单独立项。
## 2. 当前实现现状(简版)
当前 `rdms-spring-boot-starter-mq` 的真实情况:
1. Redis 能力最完整包含模板、监听器抽象、Stream 补偿与清理任务。
2. RabbitMQ 只有 `MessageConverter` 级别自动配置,未形成统一收发抽象。
当前 `rdms-system` 默认配置:
## 3. 改造原则
1. 先配置统一,再接口统一,最后再清理非主路径。
2. 任何阶段都必须可回滚,且回滚只改配置不改代码。
3. 保持 Rabbit/Kafka 包路径存在,避免一次性大删导致历史分支合并困难。
## 4. 目标架构(落地后)
统一引入配置:
1. `rdms.mq.type=redis|rocketmq`
统一行为:
1. 单体环境配 `redis`
2. 微服务环境配 `rocketmq`
建议方式:
## 4.1 配置矩阵(最小可运行)
### 单体Redis
```yaml
rdms:
mq:
type: redis
spring:
data:
redis:
host: 127.0.0.1
port: 6379
```
### 微服务RocketMQ
```yaml
rdms:
mq:
type: rocketmq
rocketmq:
name-server: 127.0.0.1:9876
producer:
group: rdms-producer-group
```
备注:
1. 微服务切 Rocket 前,必须先补齐 topic、consumer-group 等业务配置。
2. 建议在 dev/local 保留 Redis 配置,便于快速回滚。
## 4.2 命名规范Topic / Channel / StreamKey
建议统一命名规则,避免后续混乱:
1. Redis Channel / StreamKey`{env}:{domain}:{event}`
2. Rocket Topic`{env}_{domain}_{event}`
3. ConsumerGroup`{app}-{domain}-cg`
示例:
1. `dev:system:notify`
2. `prod_system_notify`
3. `rdms-system-notify-cg`
## 5. 分阶段实施计划
## 阶段 A低风险建议先做
目标:只做配置层统一,不动大规模代码。
实施项:
1. 新增配置项 `rdms.mq.type`,默认值 `redis`
3. 本阶段不删除 Rabbit/Kafka 代码,不变更包结构。
收益:
1. 切换 Redis/Rocket 只改配置。
2. 不触碰核心业务逻辑,风险最低。
回滚:
## 阶段 B中风险接口统一
目标:抽象统一 MQ 发送接口,减少业务对具体中间件的耦合。
实施项:
1. 新增统一接口,例如 `UnifiedMqSender`
2. 提供 `RedisMqSender``RocketMqSender` 两个实现。
3.`@ConditionalOnProperty` 根据 `rdms.mq.type` 注入唯一实现。
5. 统一消息契约,至少包含标准消息头:
`msgId``bizKey``timestamp``producer``traceId``version`
收益:
1. 业务层不再直接依赖 `RedisMQTemplate``RocketMQTemplate`
2. 后续切换中间件时改动面可控。
3. 消息协议可演进,减少多团队并行开发冲突。
回滚:
1. 切回原 sender Bean 注入方案,保留统一接口代码但不启用。
## 阶段 C可选结构收敛
目标:让非主路径代码“隔离可见但不激活”。
实施项:
1. Rabbit/Kafka 相关自动配置入口默认关闭。
2. 保留目录与类,增加注释标识 `reserved``deprecated`
3. 文档明确“当前生产主路径仅 Redis/Rocket”。
收益:
1. 新成员不会误判项目“全量支持四种 MQ”。
2. 后续若要恢复 Rabbit/Kafka可低成本重新开启。
回滚:
1. 打开对应配置开关即可恢复。
## 6. 影响面评估
主要影响模块:
1. `rdms-framework/rdms-spring-boot-starter-mq`
3. `rdms-system/rdms-system-boot` 配置文件
主要风险点:
1. Redis 与 Rocket 的消费语义不完全一致,幂等要在业务侧兜底。
2. 广播模型下,多实例重复消费属于预期,业务处理要避免副作用。
3. 配置切换后,缺失 Rocket 连接配置会导致启动失败。
4. 现有代码中 Kafka 消费类注解存在明显可疑项,改造时应单独复核。
5. 缺少统一幂等策略时Rocket 与 Redis 切换后可能放大重复消费副作用。
6. 配置缺失时若做了自动降级,可能导致“误以为切到 Rocket实际走本地/Redis”。
## 6.1 幂等策略(建议作为阶段 B 配套)
最小策略建议:
1. 每条消息必须带 `msgId``bizKey`
2.`bizKey` 作为业务幂等键(例如订单号、任务号)。
3. 消费前先做幂等检查,消费成功后写入幂等记录。
4. 幂等记录建议放 Redis设置合理 TTL如 3~7 天,按业务回放窗口)。
说明:
1. `msgId` 解决“传输级去重定位”,`bizKey` 解决“业务级幂等”。
2. 幂等是切换 MQ 前置条件,不应后补。
## 6.2 故障切换策略(明确 fail-fast
建议默认策略:
1. 目标 MQ 不可用时 `fail-fast`,启动或发送直接失败并告警。
2. 禁止隐式降级到 local避免行为不透明。
可选策略:
1. 在开发环境允许显式降级(需开关控制,并打印告警日志)。
## 7. 验收清单(每阶段都要过)
1. 单体环境 `rdms.mq.type=redis` 可启动、可发送、可消费。
2. 微服务环境 `rdms.mq.type=rocketmq` 可启动、可发送、可消费。
4. 关键链路日志可定位发送端与消费端。
5. 切换只改配置,不改代码。
6. 回滚路径已验证。
7. 幂等校验通过(重复消息不会造成业务副作用)。
8. 关键指标可观测并有告警(发送失败、消费失败、积压、重试)。
## 7.1 观测与告警建议
建议统一接入以下指标:
1. `mq_send_total` / `mq_send_fail_total`
2. `mq_consume_total` / `mq_consume_fail_total`
3. `mq_backlog_size`
4. `mq_retry_total`
建议阈值(示例):
1. 5 分钟发送失败率 > 1% 告警。
2. 连续 10 分钟消费失败率 > 0.5% 告警。
3. 积压超过基线 3 倍且持续 10 分钟告警。
## 8. 推荐实施顺序与工时
推荐顺序:
1. 先做阶段 A。
2. 阶段 A 稳定后再做阶段 B。
3. 阶段 C 按团队节奏处理。
粗略工时(含联调):
1. 阶段 A0.5 天。
2. 阶段 B1 天到 1.5 天。
3. 阶段 C0.5 天。
## 8.1 上线与回滚步骤(建议)
上线顺序:
1. 开发环境完成阶段 A验证双配置切换。
2. 预发环境灰度 1 个实例切换目标 MQ。
3. 小流量观察 1 天,确认无异常后全量。
回滚触发条件(任一满足即回滚):
1. 消费失败率持续超过阈值。
2. 积压持续增长且无法在观察窗口内回落。
3. 关键业务出现重复消费副作用。
回滚动作:
2. 不回滚代码,确保恢复路径最短。
## 9. 当前建议
你现在对项目结构还在熟悉期,建议先做阶段 A 的评审与验证,不直接进入阶段 B。
这样能先拿到“可切换能力”,同时把改造风险控制在最低范围。

View File

@@ -1,241 +0,0 @@
# rdms-spring-boot-starter-mq
## 模块定位
`rdms-spring-boot-starter-mq` 是项目里的消息队列基础封装,目标是:
1. 对 Redis 消息模型做统一抽象,降低业务接入复杂度
2. 通过自动配置按需启用消费者容器,不强制业务使用全部能力
3. 为后续多 MQ 方案Redis / RabbitMQ / RocketMQ / Kafka提供统一入口
当前实现里,**核心能力在 Redis**RabbitMQ 是轻量补充(消息转换器)。
## 设计思路
### 1. Redis 统一模型
Redis 相关能力分成两类:
1. `Pub/Sub` 广播模型
2. `Stream` 分组消费模型
两者的消息基类分别是:
1. `AbstractRedisChannelMessage`
2. `AbstractRedisStreamMessage`
共同父类是 `AbstractRedisMessage`,内置 `headers`,方便扩展链路信息。
### 2. 统一发送模板
发送端统一走 `RedisMQTemplate`:
1. `send(AbstractRedisChannelMessage)` -> Redis `convertAndSend`
2. `send(AbstractRedisStreamMessage)` -> Redis Stream `XADD`
这样业务代码不需要关心底层命令细节。
### 3. 监听器抽象 + 惰性自动装配
消费者由两个抽象监听器承接:
1. `AbstractRedisChannelMessageListener<T>`
2. `AbstractRedisStreamMessageListener<T>`
自动配置采用 `@ConditionalOnBean(...)`:
1. 只有你定义了对应监听器 Bean容器才会注册消费者
2. 没有监听器时不会额外启动 MQ 消费组件
### 4. Stream 运维补偿
针对 Redis Stream框架额外提供两个定时任务:
1. `RedisPendingMessageResendJob`
扫描 pending 超时消息并重投(默认超时 5 分钟)
2. `RedisStreamMessageCleanupJob`
定时 `XTRIM`,默认仅保留最近 10000 条,防止内存膨胀
两者都使用 Redisson 分布式锁,避免多实例重复执行。
### 5. RabbitMQ 轻封装
`RdmsRabbitMQAutoConfiguration` 只提供 `Jackson2JsonMessageConverter`,让 RabbitTemplate / @RabbitListener 默认按 JSON 处理消息。
### 6. 多 MQ 的真实边界
虽然模块描述写了支持 Redis / RocketMQ / RabbitMQ / Kafka
但本 starter 的自动配置主要是 Redis + Rabbit。
## 自动配置入口
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`:
1. `RdmsRedisMQProducerAutoConfiguration`
2. `RdmsRedisMQConsumerAutoConfiguration`
3. `RdmsRabbitMQAutoConfiguration`
## 如何使用
## 1. 引入依赖
一般由业务模块直接依赖:
```xml
<dependency>
<groupId>com.njcn</groupId>
<artifactId>rdms-spring-boot-starter-mq</artifactId>
</dependency>
```
## 1.1 项目现有 Redis 作为 MQ 的常规用法(推荐)
推荐按这个顺序接入:
1. 配好 `spring.data.redis.*`
2. 先按第 2 节使用 `Pub/Sub`(这是项目里最常见路径)。
3. 需要可恢复消费时,再按第 3 节接入 `Stream`
## 2. 使用 Redis Pub/Sub广播
适用场景: 通知广播、在线会话广播、对可靠性要求不高但强调实时性。
### 2.1 定义消息
```java
import com.njcn.rdms.framework.mq.redis.core.pubsub.AbstractRedisChannelMessage;
import lombok.Data;
@Data
public class DemoNotifyMessage extends AbstractRedisChannelMessage {
private Long userId;
private String content;
// 可选: 自定义 channel不重写时默认是类名
@Override
public String getChannel() {
return "demo:notify";
}
}
```
### 2.2 定义消费者
```java
import com.njcn.rdms.framework.mq.redis.core.pubsub.AbstractRedisChannelMessageListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class DemoNotifyListener extends AbstractRedisChannelMessageListener<DemoNotifyMessage> {
@Override
public void onMessage(DemoNotifyMessage message) {
log.info("收到广播消息 userId={}, content={}", message.getUserId(), message.getContent());
}
}
```
### 2.3 发送消息
```java
import com.njcn.rdms.framework.mq.redis.core.RedisMQTemplate;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class DemoNotifyProducer {
private final RedisMQTemplate redisMQTemplate;
public void send(Long userId, String content) {
redisMQTemplate.send(new DemoNotifyMessage()
.setUserId(userId)
.setContent(content));
}
}
```
## 3. 使用 Redis Stream分组消费 + ACK
适用场景: 异步任务、可恢复消费、希望具备重投和清理机制。
### 3.1 定义消息
```java
import com.njcn.rdms.framework.mq.redis.core.stream.AbstractRedisStreamMessage;
import lombok.Data;
@Data
public class DemoTaskMessage extends AbstractRedisStreamMessage {
private Long taskId;
private String bizType;
// 可选: 自定义 stream key不重写时默认是类名
@Override
public String getStreamKey() {
return "demo:task:stream";
}
}
```
### 3.2 定义消费者
```java
import com.njcn.rdms.framework.mq.redis.core.stream.AbstractRedisStreamMessageListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class DemoTaskListener extends AbstractRedisStreamMessageListener<DemoTaskMessage> {
@Override
public void onMessage(DemoTaskMessage message) {
log.info("消费任务 taskId={}, bizType={}", message.getTaskId(), message.getBizType());
// 这里抛异常会导致本次消费失败,不会执行 ACK
}
}
```
### 3.3 发送消息
```java
import com.njcn.rdms.framework.mq.redis.core.RedisMQTemplate;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.connection.stream.RecordId;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class DemoTaskProducer {
private final RedisMQTemplate redisMQTemplate;
public RecordId send(Long taskId, String bizType) {
return redisMQTemplate.send(new DemoTaskMessage()
.setTaskId(taskId)
.setBizType(bizType));
}
}
```
## 4. 消息拦截器扩展(可选)
可通过实现 `RedisMessageInterceptor`,对发送/消费前后做统一处理(比如租户透传、审计埋点)。
```java
import com.njcn.rdms.framework.mq.redis.core.interceptor.RedisMessageInterceptor;
import com.njcn.rdms.framework.mq.redis.core.message.AbstractRedisMessage;
import org.springframework.stereotype.Component;
@Component
public class DemoRedisMessageInterceptor implements RedisMessageInterceptor {
@Override
public void sendMessageBefore(AbstractRedisMessage message) {
message.addHeader("source", "rdms-system");
}
}
```
## 配置说明
本模块本身几乎没有 `rdms.mq.*` 配置项,主要依赖:
1. `spring.data.redis.*`Redis 连接)
2. `spring.application.name`Stream consumer group 默认值)
另外:
1. Redis Stream 要求 Redis 版本 >= 5.0
2. 只有存在 Stream 监听器时,重投/清理定时任务才会生效
## 当前已知注意点
1. `AbstractRedisStreamMessageListener` 有一个 `(streamKey, group)` 构造器,内部把 `messageType` 置空;而消费反序列化仍依赖 `messageType`
建议优先使用无参构造路径(即泛型推断消息类型的默认方式)。
2. Stream 的重投机制本质是“至少一次投递”语义,业务侧应自行保证幂等。
## 适用与不适用
适用:
1. 需要快速接入 Redis 广播/异步任务队列
2. 希望保留基础的失败补偿和消息清理能力
3. 希望通过抽象基类统一消息定义风格
不适用:
1. 对事务一致性、延迟、吞吐有强约束且需要完整 MQ 运维体系
2. 需要复杂顺序语义、死信重试拓扑、跨机房高可用治理

View File

@@ -1,349 +0,0 @@
# rdms-spring-boot-starter-mybatis
## 模块定位
`rdms-spring-boot-starter-mybatis` 不是一个“只把 MyBatis-Plus 引进来”的轻量 starter而是项目里的数据库访问基础设施模块。它把下面几类能力统一收口到一个地方
1. 数据源与事务管理
2. MyBatis-Plus 与 MyBatis-Plus-Join 的统一装配
3. 多数据库兼容策略
4. DO 基类、审计字段自动填充、逻辑删除
5. Mapper / Wrapper 的统一编码风格
6. 常用 TypeHandler
7. Easy-Trans 的 VO 翻译接入
从仓库里的实际用法看,这个模块倾向于让业务模块在 DAL 层遵循一套固定约定,而不是每个模块自己重复配置 MyBatis、分页、排序、主键策略和字段填充。
## 设计思路
## 1. 用一个 starter 统一数据库层约定
这个模块直接依赖了:
1. `druid-spring-boot-3-starter`
2. `mybatis-plus-spring-boot3-starter`
3. `dynamic-datasource-spring-boot3-starter`
4. `mybatis-plus-join-boot-starter`
5. `easy-trans-spring-boot-starter`
这说明模块不是“最小封装”路线,而是把项目默认采用的数据库栈整体打包,业务模块只需要依赖这一个 starter就能得到一致的运行时行为。
## 2. 优先解决“约定不一致”问题,而不是只提供工具类
模块里最核心的不是某一个工具类,而是整套约定:
1. `@MapperScan` 统一扫描 `${rdms.info.base-package}`
2. `BaseDO` 统一审计字段和逻辑删除字段
3. `BaseMapperX` 统一分页、批量操作、便捷查询
4. `LambdaQueryWrapperX` / `QueryWrapperX` 统一动态拼条件写法
5. `IdTypeEnvironmentPostProcessor` 统一不同数据库下的主键策略
这套设计可以让代码生成器、业务模块和框架模块都围绕同一套 DAL 模型工作。
## 3. 用“自动推断”减少多数据库切换成本
模块针对不同数据库运行场景做了几层兼容:
1. `pom.xml` 直接预留了 MySQL、Oracle、PostgreSQL、SQL Server、达梦、人大金仓、openGauss 等驱动
2. `IdTypeEnvironmentPostProcessor` 会根据主数据源 URL 自动判断 `DbType`
3. 如果 `mybatis-plus.global-config.db-config.id-type=NONE`,则自动改写为更适合当前数据库的 `AUTO``INPUT`
4. 同一个后处理器顺手补齐 Quartz 的 `driverDelegateClass`
5. `QueryWrapperX.limitN``MyBatisUtils.findInSet` 也在做跨数据库差异适配
也就是说,这个模块尽量不让业务代码关心“当前连接的是哪一种数据库”,而是把差异尽量前移到框架层。
## 4. 把“业务里高频重复的 Mapper 写法”沉到基类里
`BaseMapperX<T>` 是这个模块最重要的抽象之一。它在 `BaseMapper` 之上继续补了几类默认能力:
1. `selectPage(...)`:直接返回项目统一的 `PageResult`
2. `selectJoinPage(...)`:把 Join 查询也纳入同一分页模型
3. `selectOne` / `selectList` / `selectCount` 的便捷重载
4. `insertBatch` / `updateBatch` 等批量操作
5. 针对 SQL Server 的批量插入特殊处理
6. `PAGE_SIZE_NONE` 时的“不分页查询”约定
这背后的设计取向很明确:让 Mapper 接口本身承载一部分“轻业务语义”的默认实现,减少 XML 和 Service 层重复代码。
## 5. 动态查询要“少写 if”而不是把 if 搬到 Service 层
`LambdaQueryWrapperX``QueryWrapperX``MPJLambdaWrapperX` 都提供了 `xxxIfPresent` 系列方法,例如:
1. `likeIfPresent`
2. `eqIfPresent`
3. `inIfPresent`
4. `betweenIfPresent`
这样 Service 或 Mapper 默认方法可以直接链式拼接条件,空值自动跳过。这样可以把“查询条件存在才拼 SQL”这件事收口到 Wrapper而不是散落在业务代码里做大量 `if (param != null)`
## 6. 基础字段治理优先于业务字段治理
`BaseDO``DefaultDBFieldHandler` 体现了这个模块在数据治理上的几个固定要求:
1. 所有 DO 默认带 `createTime``updateTime``creator``updater``deleted`
2. 插入和更新时自动补时间
3. 已登录用户存在时自动补创建人和更新人
4. 逻辑删除统一使用 `deleted`,其未删除/已删除值由 yaml 中的 `logic-not-delete-value: 0``logic-delete-value: 1` 配置
5. `BaseDO.clean()` 用于清空前端可能误传回来的审计字段
这说明“审计字段一致性”被视为框架职责,而不是每个表、每个 Service 自己维护。
## 7. 常见字段存储形式做成透明 TypeHandler
模块内置了几类 `TypeHandler`
1. `EncryptTypeHandler`:字符串字段透明加解密
2. `StringListTypeHandler`
3. `LongListTypeHandler`
4. `LongSetTypeHandler`
5. `IntegerListTypeHandler`
6. `JacksonTypeHandler``ObjectMapper` 统一注入
这里的取向是数据库里允许保留“逗号分隔字符串”“JSON”“密文”等存储形式但业务对象层尽量继续使用自然的数据结构。
例如:
```java
@TableName(value = "infra_mail_account", autoResultMap = true)
public class MailAccountDO extends BaseDO {
@TableField(typeHandler = EncryptTypeHandler.class)
private String password;
@TableField(typeHandler = StringListTypeHandler.class)
private List<String> toMails;
}
```
如果数据库里的 `password` 存的是密文、`to_mails` 存的是 `a@xx.com,b@xx.com` 这样的逗号分隔字符串,
那么业务代码里仍然可以分别按普通 `String``List<String>` 来使用它们,字段转换交给 `TypeHandler` 处理。
## 8. 把“查询之后的展示翻译”也并入数据库 starter
`RdmsTranslateAutoConfiguration``TranslateUtils` 说明这个模块并不只关心“查出来”,还关心“查出来后如何转成面向前端的 VO”。
这是一种比较明显的项目式封装方式:把 DAL 和 VO 翻译放在同一个 starter方便后台管理类系统直接复用。
如果开发中需要这种能力,可以按下面的方式使用:
1. 在 VO 里同时定义“原始值字段”和“展示字段”,例如 `userId``userName`
2. 在原始值字段上增加 `@Trans`,声明要根据哪个对象、取哪个字段、回填到哪个展示字段
3. 查询完成后,先把 DO 转成 VO
4. 在返回前触发翻译。适合注解方式的接口可使用 `@TransMethodResult`,不适合注解方式的场景可手动调用 `TranslateUtils.translate(...)`
示例:
```java
public class OperateLogRespVO implements VO {
@Trans(type = TransType.SIMPLE, target = AdminUserDO.class, fields = "nickname", ref = "userName")
private Long userId;
private String userName;
}
```
```java
@TransMethodResult
public CommonResult<PageResult<OperateLogRespVO>> pageOperateLog(...) {
PageResult<OperateLogDO> pageResult = operateLogService.getOperateLogPage(pageReqVO);
return success(BeanUtils.toBean(pageResult, OperateLogRespVO.class));
}
```
上面的含义是:返回结果里先保留 `userId`,然后在翻译阶段根据 `userId` 查到对应用户的 `nickname`,再回填到 `userName`
如果不是常规接口返回场景,例如导出 Excel可以在转换为 VO 后手动调用:
```java
TranslateUtils.translate(BeanUtils.toBean(list, OperateLogRespVO.class));
```
## 自动装配链路
## 1. Spring Boot 自动配置入口
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
1. `com.njcn.rdms.framework.datasource.config.RdmsDataSourceAutoConfiguration`
2. `com.njcn.rdms.framework.mybatis.config.RdmsMybatisAutoConfiguration`
3. `com.njcn.rdms.framework.translate.config.RdmsTranslateAutoConfiguration`
## 2. EnvironmentPostProcessor 入口
`META-INF/spring.factories`
1. `com.njcn.rdms.framework.mybatis.config.IdTypeEnvironmentPostProcessor`
这意味着模块启动时会先做环境预处理,再进入正常的自动配置流程。
## 核心组件
| 组件 | 作用 |
| --- | --- |
| `RdmsDataSourceAutoConfiguration` | 开启事务管理,并在启用 Druid 监控页时注册过滤器去掉广告脚本 |
| `RdmsMybatisAutoConfiguration` | 提前于 MyBatis-Plus 完成装配,统一 `@MapperScan`、分页插件、字段填充、主键生成器、`JacksonTypeHandler` |
| `IdTypeEnvironmentPostProcessor` | 根据主数据源 URL 推断数据库类型,自动设置 `id-type` 和 Quartz Delegate |
| `BaseDO` | 统一 DO 基类,内置审计字段、逻辑删除和 Easy-Trans 兼容处理 |
| `DefaultDBFieldHandler` | 自动填充创建人、更新人、创建时间、更新时间 |
| `BaseMapperX` | 统一分页、Join 分页、批量操作、便捷查询 |
| `LambdaQueryWrapperX` / `QueryWrapperX` / `MPJLambdaWrapperX` | 提供 `IfPresent` 风格的条件拼装能力 |
| `JdbcUtils` / `MyBatisUtils` | 统一数据库类型探测、分页构造、排序拼装、跨库 SQL 片段 |
| `EncryptTypeHandler` 等 | 处理密文、列表、集合等特殊字段映射 |
| `TranslateUtils` | 在不能用注解自动翻译时,手动触发 Easy-Trans 翻译 |
## 模块特征
从实现细节和业务模块的用法看,这个 starter 有几个比较明确的特征:
1. 它承担“项目默认 DAL 规范”的角色,而不是一个可随意裁剪的通用组件。
2. 它偏向“约定优于配置”例如主键策略、Mapper 扫描、分页结果、审计字段都由框架先定好。
3. 它存在一定程度的强绑定例如绑定动态数据源、Druid、Easy-Trans、Security 上下文,以换取更少的样板代码。
4. 它在多数据库兼容上投入较多,适合数据库切换、国产库适配或私有化部署场景。
5. 它优先抽象后台管理系统里高频出现的分页、筛选、列表页、字典翻译、逻辑删除、审计字段等能力。
## 如何使用
## 1. 引入依赖
通常业务模块直接依赖:
```xml
<dependency>
<groupId>com.njcn</groupId>
<artifactId>rdms-spring-boot-starter-mybatis</artifactId>
</dependency>
```
如果项目需要自动填充 `creator``updater`,运行时还应提供安全模块里的登录上下文能力。
## 2. 基础配置
最小可工作的配置重点有四个:
1. `rdms.info.base-package`
2. `spring.datasource.dynamic.primary`
3. `spring.datasource.dynamic.datasource.<primary>.url`
4. `mybatis-plus.global-config.db-config.id-type`
示例:
```yaml
rdms:
info:
base-package: com.njcn.rdms.module.system
spring:
datasource:
dynamic:
primary: master
datasource:
master:
url: jdbc:mysql://127.0.0.1:13306/rdms?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
mybatis-plus:
global-config:
db-config:
id-type: NONE
logic-delete-value: 1
logic-not-delete-value: 0
type-aliases-package: ${rdms.info.base-package}.dal.dataobject
encryptor:
password: your-16-bytes-key
mybatis-plus-join:
banner: false
easy-trans:
is-enable-global: false
```
说明:
1. `id-type: NONE` 是这里推荐的“自动判断模式”
2. 如果没有配置主数据源 URL`IdTypeEnvironmentPostProcessor` 就无法自动判断数据库类型
3. `encryptor.password` 只在使用 `EncryptTypeHandler` 时需要
## 3. DO 写法
推荐让 DO 继承 `BaseDO`,并在需要时启用 `autoResultMap = true`
```java
@TableName(value = "infra_data_source_config", autoResultMap = true)
@KeySequence("infra_data_source_config_seq")
public class DataSourceConfigDO extends BaseDO {
private Long id;
private String name;
private String url;
private String username;
@TableField(typeHandler = EncryptTypeHandler.class)
private String password;
}
```
如果字段在库里按逗号分隔字符串保存,也可以直接挂列表型 `TypeHandler`
```java
@TableField(typeHandler = StringListTypeHandler.class)
private List<String> toMails;
```
## 4. Mapper 写法
推荐所有 Mapper 继承 `BaseMapperX<T>`,把分页和条件拼装直接写在默认方法里:
```java
@Mapper
public interface AdminUserMapper extends BaseMapperX<AdminUserDO> {
default PageResult<AdminUserDO> selectPage(UserPageReqVO reqVO,
Collection<Long> deptIds,
Collection<Long> userIds) {
return selectPage(reqVO, new LambdaQueryWrapperX<AdminUserDO>()
.likeIfPresent(AdminUserDO::getUsername, reqVO.getUsername())
.likeIfPresent(AdminUserDO::getMobile, reqVO.getMobile())
.eqIfPresent(AdminUserDO::getStatus, reqVO.getStatus())
.betweenIfPresent(AdminUserDO::getCreateTime, reqVO.getCreateTime())
.inIfPresent(AdminUserDO::getDeptId, deptIds)
.inIfPresent(AdminUserDO::getId, userIds)
.orderByDesc(AdminUserDO::getId));
}
}
```
如果需要跨库兼容的 `limit n`,当前实现建议使用 `QueryWrapperX`
```java
return selectList(new QueryWrapperX<NotifyMessageDO>()
.eq("user_id", userId)
.eq("user_type", userType)
.eq("read_status", false)
.orderByDesc("id")
.limitN(size));
```
## 5. 手动触发 VO 翻译
当场景不适合用注解自动翻译时,可以手动调用:
```java
List<OperateLogRespVO> result = TranslateUtils.translate(list);
```
## 典型开发流程
在这个 starter 的设计下,比较顺畅的开发流程基本是:
1. 配置好多数据源和 `rdms.info.base-package`
2. DO 继承 `BaseDO`
3. Mapper 继承 `BaseMapperX`
4. 查询条件优先使用 `WrapperX` 系列拼装
5. 分页统一返回 `PageResult`
6. 特殊字段优先通过 `TypeHandler` 做透明映射
7. VO 展示转换需要字典或名称翻译时再接 Easy-Trans
## 注意事项
1. `IdTypeEnvironmentPostProcessor` 依赖 `spring.datasource.dynamic.primary` 和对应数据源的 `url`,否则无法自动识别数据库类型。
2.`id-type` 被识别为 `INPUT` 时,自动配置还会注册对应数据库的 `IKeyGenerator`。这类场景下实体通常还需要正确声明 `@KeySequence`
3. `DefaultDBFieldHandler` 会调用安全上下文工具获取登录用户。如果运行时没有对应安全能力,创建人和更新人自动填充就不能按预期工作。
4. `EncryptTypeHandler` 依赖 `mybatis-plus.encryptor.password`。密钥一旦更换,历史密文可能无法解密,生产环境不应硬编码。
5. `easy-trans.is-enable-global` 在示例配置里默认关闭,说明默认没有把全局响应翻译作为高优先级能力。
6. `QueryWrapperX.limitN` 是跨数据库兼容封装,但它仍然是基于数据库方言分支处理,不适合无限扩展到所有数据库。
7. 这个模块的职责边界偏“大而全”适合本项目统一使用如果要抽成通用开源组件需要先拆掉对动态数据源、安全上下文、Easy-Trans 的强耦合。
## 总结
这个模块的核心价值,不是简单提供几个 MyBatis 工具类,而是把数据库层的共性问题一次性沉到框架层:
1. 启动时怎么自动装配
2. 多数据库怎么少改代码
3. 分页、排序、条件查询怎么统一写
4. 审计字段和逻辑删除怎么保持一致
5. 特殊字段怎么透明映射
6. 查询结果怎么更顺滑地进入 VO 展示层
如果把它当成一个“带强约束的项目级 DAL 基座”去理解,这个模块的设计就会非常清晰。

View File

@@ -1,373 +0,0 @@
# rdms-spring-boot-starter-protection
## 模块定位
`rdms-spring-boot-starter-protection` 用于提供一组服务保护能力,减少业务代码里重复编写“防重复提交、限流、分布式锁、接口签名校验”这类横切逻辑。
当前模块实际包含四块能力:
1. 幂等:防止同一请求在短时间内被重复执行,例如重复提交、重复点击
2. 分布式锁:在并发场景下保证同一业务动作同一时间只有一个线程或节点执行
3. 限流:限制单位时间内的访问次数,避免接口被刷爆或高频调用
4. HTTP API 签名:校验调用方身份、请求时效和随机数,防止伪造请求和重放攻击
它的目标不是提供复杂的治理平台,而是把常见保护能力做成可声明、可复用、可分布式生效的基础设施。
## 设计思路
## 1. 用注解声明保护规则,用 AOP 统一执行
模块里的幂等、限流、签名校验都不是要求业务手写 Redis 判断逻辑,而是通过注解声明规则,再由 AOP 统一拦截执行:
1. `@Idempotent`
2. `@RateLimiter`
3. `@ApiSignature`
这样业务代码只负责说明“这个方法需要什么保护”,具体的 Redis Key 生成、重复请求判断、限流判断、签名验证由框架层统一处理。
## 2. 把状态统一放到 Redis面向分布式部署
这个模块的保护能力不是单机内存级别,而是默认面向多实例部署场景:
1. 幂等使用 `StringRedisTemplate`
2. API 签名防重放使用 `StringRedisTemplate`
3. 限流使用 `Redisson``RRateLimiter`
4. 分布式锁接入 `lock4j-redisson-spring-boot-starter`
这意味着它的核心价值不是“本机防抖”,而是“多个节点共享保护状态”。
## 3. 单一能力单独建模,避免职责混杂
从实现上看,模块有一个比较明确的边界划分:
1. 幂等用于“同一请求短时间内只允许执行一次”
2. 分布式锁用于“并发竞争下只允许一个线程/节点进入”
3. 限流用于“单位时间窗口内限制请求次数”
4. API 签名用于“校验调用方身份、请求时效和防重放”
例如幂等组件没有扩展成“成功后立即删 Key”的锁语义而是把这类能力交给 Lock4j 处理。
区分这两类能力时,可以抓住一个核心点:
1. 幂等控制的是“短时间内不要重复执行同一类请求”
2. 分布式锁控制的是“同一时间谁有资格进入临界区执行”
例如:
1. 用户连续点击两次“提交订单”,更适合用幂等
2. 两个线程同时刷新同一份缓存,只允许一个线程进入执行,更适合用分布式锁
一个常见误区是把长耗时任务也交给幂等处理。假设幂等窗口配置为 3 秒,但某个任务实际执行了 20 秒:
1. 第一个请求在第 0 秒进入
2. 幂等 Key 在第 3 秒过期
3. 第二个请求在第 5 秒再次进入
4. 此时第一个任务还没执行完,但第二个请求已经有机会再次执行
所以幂等更偏“防重复请求”,而不是“在整个执行期间控制线程执行权”。如果真正关心的是执行过程中的互斥,应优先使用分布式锁。
## 4. 用 KeyResolver 抽象不同粒度的保护范围
幂等和限流都没有把 Key 生成规则写死,而是抽成了 `KeyResolver`
1. 默认级别:方法名 + 参数
2. 用户级别:方法名 + 参数 + 当前用户
3. IP 级别:方法名 + 参数 + 客户端 IP
4. 节点级别:方法名 + 参数 + 当前服务节点
5. 表达式级别:通过 SpEL 自定义 Key
这样模块能在“全局、按用户、按 IP、按节点、按业务字段”之间切换而不是只能支持一种固定粒度。
## 5. 自动装配按功能块拆分
自动配置入口见:
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
当前注册了四个自动配置类:
1. `RdmsIdempotentConfiguration`
2. `RdmsLock4jConfiguration`
3. `RdmsRateLimiterConfiguration`
4. `RdmsApiSignatureAutoConfiguration`
这说明模块是按能力块拆分装配的,而不是堆在一个总配置类里。
## 功能说明
## 1. 幂等
核心类:
1. `Idempotent`
2. `IdempotentAspect`
3. `IdempotentRedisDAO`
实现方式:
1. AOP 拦截带 `@Idempotent` 的方法
2. 通过 `IdempotentKeyResolver` 解析 Redis Key
3. 调用 `SETNX + EXPIRE` 语义的 `setIfAbsent`
4. 锁定失败时抛出“重复请求”异常
5. 业务异常时可按配置删除 Key允许后续重试
适合场景:
1. 防止用户双击按钮
2. 防止表单重复提交
3. 防止短时间内重复触发同一个业务动作
典型案例:
1. 创建工单时,前端连续点击两次“提交”,只允许创建一次
2. 导入任务提交后,用户刷新页面再次点击“开始导入”,短时间内不允许重复发起
3. 审批流提交节点时,浏览器因为网络抖动自动重发请求,后端只执行一次
边界说明:
1. 这里的幂等更偏“短时间窗口防重复请求”,不适合作为支付、退款这类长期业务幂等的唯一方案
2. 对于支付回调、订单状态推进这类场景,更可靠的做法仍然是基于业务唯一号和持久化状态做幂等判断,必要时再配合分布式锁
可选 KeyResolver
1. `DefaultIdempotentKeyResolver`
2. `UserIdempotentKeyResolver`
3. `ExpressionIdempotentKeyResolver`
示例:
```java
@Idempotent(timeout = 3, message = "请勿重复提交")
public Long createOrder(OrderCreateReqVO reqVO) {
return orderService.createOrder(reqVO);
}
```
如果需要按某个参数做幂等,可以使用表达式:
```java
@Idempotent(
keyResolver = ExpressionIdempotentKeyResolver.class,
keyArg = "#reqVO.no",
timeout = 10
)
public void submit(OrderReqVO reqVO) {
}
```
如果接口已经完成登录鉴权,也可以按“当前登录用户 + 接口参数”做幂等:
```java
@Idempotent(
keyResolver = UserIdempotentKeyResolver.class,
timeout = 3,
message = "请勿重复提交"
)
public CommonResult<Long> createOrder(OrderCreateReqVO reqVO) {
return success(orderService.createOrder(reqVO));
}
```
这种方式不要求把 `userId` 显式放在请求参数里,而是从当前登录上下文中获取用户信息,适合“同一个用户短时间内不能重复提交同一类请求”的场景。
## 2. 限流
核心类:
1. `RateLimiter`
2. `RateLimiterAspect`
3. `RateLimiterRedisDAO`
实现方式:
1. AOP 在方法执行前拦截
2. 通过 `RateLimiterKeyResolver` 解析限流 Key
3. 使用 Redisson 的 `RRateLimiter` 设置速率
4. 超过限制时抛出“请求过于频繁”异常
适合场景:
1. 接口防刷
2. 高频查询保护
3. 短时间内限制短信、验证码、导出等高成本操作
4. 对开放接口或公网接口做基础流量保护
典型案例:
1. 短信验证码接口限制“1 分钟内最多发送 1 次”
2. 导出 Excel 接口限制“10 分钟内最多导出 3 次”
3. 登录接口按 IP 限制访问频率,防止暴力尝试
4. 某个开放查询接口按调用方或用户维度限制 QPS避免被刷爆
可选 KeyResolver
1. `DefaultRateLimiterKeyResolver`
2. `UserRateLimiterKeyResolver`
3. `ClientIpRateLimiterKeyResolver`
4. `ServerNodeRateLimiterKeyResolver`
5. `ExpressionRateLimiterKeyResolver`
示例:
```java
@RateLimiter(count = 5, time = 1, timeUnit = TimeUnit.MINUTES, message = "请求过于频繁")
public CommonResult<Boolean> sendSmsCode(String mobile) {
return success(true);
}
```
如果希望按用户限流:
```java
@RateLimiter(
count = 10,
time = 1,
timeUnit = TimeUnit.MINUTES,
keyResolver = UserRateLimiterKeyResolver.class
)
public PageResult<OrderRespVO> pageMyOrders(OrderPageReqVO reqVO) {
return orderService.pageMyOrders(reqVO);
}
```
## 3. HTTP API 签名
核心类:
1. `ApiSignature`
2. `ApiSignatureAspect`
3. `ApiSignatureRedisDAO`
实现方式:
1. 从请求头中读取 `appId``timestamp``nonce``sign`
2. 校验时间戳是否过期
3. 校验 `nonce` 是否已使用,防止重放
4. 根据 `appId` 从 Redis 中获取 `appSecret`
5. 按“请求参数 + 请求体 + 请求头 + 密钥”构建签名字符串
6. 使用 `SHA-256` 计算服务端签名并比对
适合场景:
1. 开放接口
2. 系统间调用
3. 对请求来源和重放攻击有要求的 HTTP 接口
典型案例:
1. 第三方系统调用内部开放 API 时,使用 `appId + appSecret` 做签名校验
2. 支付平台、供应商平台、合作方系统调用回调接口时,先校验签名再进入业务处理
3. 对外提供的 B2B 接口要求请求在 60 秒内有效,并且 `nonce` 只能使用一次
示例:
```java
@ApiSignature(timeout = 60)
public CommonResult<String> callback() {
return success("ok");
}
```
当前切面是按 `@annotation(signature)` 拦截的,因此实操上应优先加在方法上。
## 4. 分布式锁
核心类:
1. `RdmsLock4jConfiguration`
2. `DefaultLockFailureStrategy`
这部分没有重复实现分布式锁,而是接入 Lock4j并补了一层默认失败策略
1. 如果类路径存在 `Lock4j`,则启用配置
2. 在获取锁失败时,统一抛出项目内的 `ServiceException`
3. 保持锁失败时的错误处理风格一致
适合场景:
1. 防止同一业务动作并发执行
2. 串行化关键资源操作
3. 抢占式处理任务
典型案例:
1. 同一个用户同时触发两次“刷新缓存”,只允许一个线程进入
2. 定时任务集群部署时,同一时间只允许一个节点执行清理任务
3. 库存扣减、状态迁移、批量结算这类关键动作,在并发场景下需要串行处理
示例:
```java
@Lock4j(keys = "#userId", expire = 30000, acquireTimeout = 1000)
public void refreshUserCache(Long userId) {
}
```
## 自动装配链路
## 1. Spring Boot 自动配置入口
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
1. `com.njcn.rdms.framework.idempotent.config.RdmsIdempotentConfiguration`
2. `com.njcn.rdms.framework.lock4j.config.RdmsLock4jConfiguration`
3. `com.njcn.rdms.framework.ratelimiter.config.RdmsRateLimiterConfiguration`
4. `com.njcn.rdms.framework.signature.config.RdmsApiSignatureAutoConfiguration`
## 2. 各配置类负责的事情
| 配置类 | 作用 |
| --- | --- |
| `RdmsIdempotentConfiguration` | 注册幂等切面、幂等 Redis DAO、幂等 KeyResolver |
| `RdmsLock4jConfiguration` | 在 Lock4j 存在时注册默认加锁失败策略 |
| `RdmsRateLimiterConfiguration` | 注册限流切面、限流 Redis DAO、限流 KeyResolver |
| `RdmsApiSignatureAutoConfiguration` | 注册 API 签名切面、签名 Redis DAO |
## 如何使用
## 1. 引入依赖
业务模块通常直接依赖:
```xml
<dependency>
<groupId>com.njcn</groupId>
<artifactId>rdms-spring-boot-starter-protection</artifactId>
</dependency>
```
## 2. 基础前提
这个模块依赖 Redis因此至少需要可用的 Redis 配置。
其中:
1. 幂等依赖 `StringRedisTemplate`
2. API 签名依赖 `StringRedisTemplate`
3. 限流依赖 `RedissonClient`
4. 分布式锁依赖 Lock4j + Redisson
## 3. 选择合适的保护手段
1. 防重复提交,优先使用 `@Idempotent`
2. 控制访问频率,优先使用 `@RateLimiter`
3. 控制并发进入,优先使用 Lock4j
4. 保护开放接口,优先使用 `@ApiSignature`
不要把它们混成一类能力:
1. 幂等不等于分布式锁
2. 限流不等于幂等
3. API 签名不等于权限校验
## 4. KeyResolver 的选择建议
1. 默认对全局请求做保护,使用默认 Resolver
2. 需要按登录用户隔离保护,使用 User Resolver
3. 需要按 IP 控制访问,使用 ClientIp Resolver
4. 需要按业务字段控制粒度,使用 Expression Resolver
## 注意事项
1. 幂等的 `timeout` 只是保护窗口,不是业务执行超时时间。如果方法执行时间长于这个窗口,后续请求仍可能进入。
2. 幂等在异常时默认删除 Key这是为了允许业务失败后重新提交如果追求的是“执行期间只允许一个线程进入”更适合使用分布式锁。
3. 限流底层使用 Redisson 的 `RRateLimiter`,运行环境需要有 `RedissonClient`
4. API 签名依赖 Redis 中预先存在 `appId -> appSecret` 的映射,否则签名校验无法通过。
5. API 签名当前实操上应加在方法上,避免把类级注解误认为一定会生效。
6. 这个模块的保护能力都是方法级横切逻辑,适合放在 Controller 或 Service 的明确边界上使用,不适合到处滥加。
## 总结
这个模块的核心价值,是把服务保护相关的高频横切问题统一沉到框架层:
1. 如何防止重复提交
2. 如何限制单位时间的访问频率
3. 如何在分布式环境下串行化关键操作
4. 如何校验开放接口的请求签名和防重放
如果把它理解成一个“请求保护与并发保护能力集合”的 starter这个模块的设计会比较清晰。

View File

@@ -1,389 +0,0 @@
# rdms-spring-boot-starter-redis
## 模块定位
`rdms-spring-boot-starter-redis` 不是单纯把 Redis 依赖引进来,而是把项目里常用的 Redis 使用约定统一收口。
当前模块主要承担三类职责:
1. 统一 `RedisTemplate` 的序列化策略
2. 统一 Spring Cache 基于 Redis 的实现方式
3. 引入 Redisson为分布式锁、限流等上层模块提供基础能力
它的重点不是封装大量 Redis 工具类而是先把“Redis 怎么接入、怎么序列化、缓存怎么配置”这些基础设施统一好。
## 设计思路
## 1. 先统一 RedisTemplate再让上层模块复用
模块通过 `RdmsRedisAutoConfiguration` 注册了统一的 `RedisTemplate<String, Object>`,核心约定是:
1. Key 使用字符串序列化
2. Hash Key 使用字符串序列化
3. Value 使用 JSON 序列化
4. Hash Value 使用 JSON 序列化
这样可以避免各模块各自定义一套 `RedisTemplate`,导致同一个 Redis 里出现不同的序列化格式。
## 2. 优先解决“可读性和兼容性”问题
这个模块没有采用 JDK 默认序列化,而是直接使用 JSON 序列化。这样做的直接收益是:
1. Redis 中的数据结构更容易观察和排查
2. 避免 JDK 序列化带来的可读性差和兼容性问题
3. 方便不同模块围绕同一种序列化格式工作
同时,它还额外处理了 `LocalDateTime` 的序列化问题,避免 Java 时间类型在 Redis 中读写异常。
## 3. 把 Cache 也纳入统一约定
模块不只提供 `RedisTemplate`,还把 Spring Cache 一起接入 Redis
1. 开启 `@EnableCaching`
2. 统一 `RedisCacheConfiguration`
3. 统一 `RedisCacheManager`
4. 扩展支持按缓存名声明过期时间
这说明模块的目标不是“给你 Redis 客户端自己玩”,而是先把项目里高频使用的缓存场景标准化。
## 4. 通过小扩展解决 Spring Cache 默认能力不够用的问题
模块里的 `TimeoutRedisCacheManager` 做了一个很实用的扩展:
1. 如果缓存名是普通格式,例如 `user`
2. 则按全局默认 TTL 工作
3. 如果缓存名写成 `user#10m`
4. 则这个缓存自动按 10 分钟过期
这相当于把“单个缓存项的过期时间”从配置文件和自定义代码里,收口到了缓存名约定里。
## 5. 给上层能力模块提供 Redis 和 Redisson 基座
这个模块本身不直接实现分布式锁、限流、幂等,但它提供了这些上层能力所依赖的基础能力:
1. `StringRedisTemplate`
2. `RedisTemplate<String, Object>`
3. `RedissonClient`
4. 基于 Redis 的 Spring Cache
因此像 `rdms-spring-boot-starter-protection``rdms-spring-boot-starter-mq` 这样的模块,都可以把 Redis 能力建立在这个 starter 之上。
## 自动装配链路
## 1. Spring Boot 自动配置入口
`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
1. `com.njcn.rdms.framework.redis.config.RdmsRedisAutoConfiguration`
2. `com.njcn.rdms.framework.redis.config.RdmsCacheAutoConfiguration`
## 2. 各配置类负责的事情
| 配置类 | 作用 |
| --- | --- |
| `RdmsRedisAutoConfiguration` | 注册统一序列化规则的 `RedisTemplate<String, Object>` |
| `RdmsCacheAutoConfiguration` | 开启 Spring Cache注册 `RedisCacheConfiguration` 和自定义 `RedisCacheManager` |
## 核心组件
| 组件 | 作用 |
| --- | --- |
| `RdmsRedisAutoConfiguration` | 提供统一 JSON 序列化的 RedisTemplate并处理 `LocalDateTime` 序列化 |
| `RdmsCacheAutoConfiguration` | 统一 Redis Cache 前缀、TTL、空值缓存策略和 CacheManager |
| `RdmsCacheProperties` | 提供 `rdms.cache.*` 配置项,目前包含 `redis-scan-batch-size` |
| `TimeoutRedisCacheManager` | 支持通过 `cacheName#ttl` 的形式为单个缓存声明过期时间 |
## 功能说明
## 1. 统一 RedisTemplate 序列化
核心类:
1. `RdmsRedisAutoConfiguration`
实现方式:
1. `RedisTemplate<String, Object>` 统一由框架注册
2. Key/Hash Key 使用字符串序列化
3. Value/Hash Value 使用 JSON 序列化
4. 通过 `JavaTimeModule` 兼容 `LocalDateTime`
适合场景:
1. 业务模块直接注入 `RedisTemplate<String, Object>` 使用
2. 需要缓存对象、列表、Map 等结构
3. 希望 Redis 中的数据具有一定可读性
示例:
```java
@Resource
private RedisTemplate<String, Object> redisTemplate;
public void saveUser(Long userId, UserRespVO user) {
redisTemplate.opsForValue().set("user:" + userId, user);
}
```
## 2. 统一 Spring Cache 基于 Redis 的实现
核心类:
1. `RdmsCacheAutoConfiguration`
2. `RdmsCacheProperties`
实现方式:
1. 开启 `@EnableCaching`
2. 创建统一的 `RedisCacheConfiguration`
3.`spring.cache.redis.*` 读取默认 TTL、前缀、空值缓存等配置
4. 使用 JSON 序列化缓存值
5. 使用自定义 `RedisCacheManager`
这个模块还额外做了一个约定:
1. Cache Key 前缀使用单个 `:`,而不是默认的 `::`
2. 这样 Redis 中的 Key 更紧凑,也更方便在可视化工具中查看
Spring Cache 在这个模块里的作用,可以理解为:
1. 让“方法查询结果缓存”变成注解式能力,而不是每次都手写 `RedisTemplate`
2. 让缓存的写入、读取、失效围绕业务方法本身声明
3. 让“查库 -> 放缓存 -> 下次命中缓存”这类标准流程交给框架处理
它更适合下面这类场景:
1. 根据主键查询角色、菜单、模板、配置
2. 根据某个唯一业务字段查询对象,例如 `clientId``code`
3. 根据某个参数查询相对稳定的数据,例如部门子节点列表、权限对应菜单列表
这类场景通常有几个共同点:
1. 输入参数比较明确
2. 返回结果可以直接缓存
3. 数据更新时可以找到明确的缓存失效点
典型流程是:
1. 在查询方法上加 `@Cacheable`
2. 第一次执行时,方法正常查数据库或远程接口
3. 返回结果自动写入 Redis
4. 后续相同参数调用时,直接命中缓存,不再进入方法体
5. 在更新、删除方法上通过 `@CacheEvict` 清理对应缓存
和直接使用 `RedisTemplate` 相比Spring Cache 的优势主要是:
1. 不需要手写 `get -> 判空 -> set -> expire` 这类模板代码
2. 缓存逻辑和业务方法绑定更紧,读代码时更容易看出哪里有缓存
3. 适合“查询结果缓存”这种标准模式
当前项目里,权限、角色、菜单、模板这类数据就大量采用这种方式;而像 Token、验证码这类更像“直接把业务对象存到 Redis”的场景则更适合单独写 RedisDAO。
示例:
```java
@Cacheable(cacheNames = "user", key = "#id")
public UserRespVO getUser(Long id) {
return userApi.getUser(id);
}
```
如果某个写操作会影响缓存,需要配套清理缓存:
```java
@CacheEvict(cacheNames = "user", key = "#id")
public void deleteUser(Long id) {
userMapper.deleteById(id);
}
```
在当前项目语境下,更贴近实际的使用案例包括:
1. 根据角色 ID 查询角色信息,并在角色更新或删除时清理缓存
2. 根据权限标识查询菜单 ID 列表,并在菜单新增、修改、删除时清理缓存
3. 根据模板编码查询通知模板、邮件模板,并在模板变更时清理缓存
## 3. 支持按缓存名自定义过期时间
核心类:
1. `TimeoutRedisCacheManager`
实现方式:
1. 解析 `@Cacheable(cacheNames = "...")` 中的缓存名
2. 如果命中 `cacheName#ttl` 格式,则为该缓存单独设置 TTL
3. 支持的单位有:
- `d`
- `h` 小时
- `m` 分钟
- `s`
4. 如果不带单位,默认按秒处理
示例:
```java
@Cacheable(cacheNames = "user#10m", key = "#id")
public UserRespVO getUser(Long id) {
return userApi.getUser(id);
}
```
说明:
1. `#10m` 只用于声明这个缓存的过期时间是 10 分钟
2. 它不会作为最终 Redis key 的一部分长期保留
3. 如果 `id = 1`
4.`spring.cache.redis.key-prefix = rdms`
5. 那么实际 Redis key 一般形如 `rdms:user:1`
6. 其中 `user` 是缓存名,`1``key = "#id"` 计算出的缓存 Key
```java
@Cacheable(cacheNames = "config#30s", key = "#key")
public ConfigRespVO getConfig(String key) {
return configService.getConfig(key);
}
```
说明:
1. 如果 `key = "sms"`
2.`spring.cache.redis.key-prefix = rdms`
3. 那么实际 Redis key 一般形如 `rdms:config:sms`
4. `#30s` 同样只影响 TTL不直接体现在最终 key 名上
这种方式适合“绝大多数缓存共用默认 TTL少数缓存按场景单独缩短或延长过期时间”的场景。
补充说明:
1. `cacheNames = "user#10m"` 中的 `user` 是缓存名,`10m` 是 TTL 描述
2. `key = "#id"``key = "#key"` 才决定具体缓存项的业务 Key
3. 如果配置了 `spring.cache.redis.key-prefix = rdms`,最终 Redis key 通常是 `rdms:user:1``rdms:config:sms` 这种形式
4. `#10m``#30s` 这类 TTL 后缀用于创建缓存时解析过期时间,不应理解成最终 key 名的一部分
## 4. 提供 Redisson 基础能力
从依赖上看,模块直接引入了 `redisson-spring-boot-starter`,因此除了 Spring Data Redis 以外,也会把 `RedissonClient` 这类能力带入项目。
这部分的直接收益是:
1. 分布式锁能力可被上层模块直接复用
2. 限流等基于 Redisson 的能力可直接建立在此模块之上
3. 项目不需要在多个模块里重复引入 Redisson
## 如何使用
## 1. 引入依赖
业务模块通常直接依赖:
```xml
<dependency>
<groupId>com.njcn</groupId>
<artifactId>rdms-spring-boot-starter-redis</artifactId>
</dependency>
```
## 2. 基础配置
最常见的配置重点包括:
1. `spring.data.redis.*`
2. `spring.cache.redis.*`
3. `rdms.cache.redis-scan-batch-size`
示例:
```yaml
spring:
data:
redis:
host: 127.0.0.1 # Redis 地址
port: 6379 # Redis 端口
database: 0 # 使用的 Redis 库编号
cache:
type: REDIS # Spring Cache 底层使用 Redis
redis:
time-to-live: 1h # 全局默认缓存过期时间
cache-null-values: false # 是否缓存空值,通常建议关闭
use-key-prefix: true # 是否启用缓存 Key 前缀
key-prefix: rdms # 缓存 Key 的统一前缀
rdms:
cache:
redis-scan-batch-size: 100 # Redis CacheWriter 使用 SCAN 批量处理时的单次扫描数量
```
说明:
1. `spring.cache.redis.time-to-live` 是全局默认缓存过期时间
2. `cacheName#ttl` 可以覆盖单个缓存的 TTL
3. `rdms.cache.redis-scan-batch-size` 用于控制 Redis CacheWriter 使用 `SCAN` 批量处理时的单次返回数量
## 3. 直接使用 RedisTemplate
如果场景不适合 Spring Cache例如
1. 需要操作复杂数据结构
2. 需要精细控制过期时间
3. 需要手动删除、递增、集合操作
可以直接注入 `RedisTemplate<String, Object>``StringRedisTemplate` 使用。
## 4. 优先使用 Spring Cache 的场景
如果场景是:
1. 查询结果缓存
2. 参数到结果的简单映射
3. 希望通过注解快速声明缓存
则优先使用 `@Cacheable``@CacheEvict``@CachePut`,并利用统一的 Redis Cache 约定。
这三个注解的典型使用场景可以这样区分:
1. `@Cacheable`
用于“先查缓存,缓存没有再执行方法,并把结果写入缓存”。
适合读操作,例如“根据 ID 查询角色”“根据编码查询模板”。
```java
@Cacheable(cacheNames = "role", key = "#id", unless = "#result == null")
public RoleDO getRoleFromCache(Long id) {
return roleMapper.selectById(id);
}
```
2. `@CacheEvict`
用于“执行方法后删除缓存”,避免更新或删除数据后缓存还是旧值。
适合写操作,例如“更新角色后清理角色缓存”“删除模板后清理模板缓存”。
```java
@CacheEvict(cacheNames = "role", key = "#updateReqVO.id")
public void updateRole(RoleSaveReqVO updateReqVO) {
roleMapper.updateById(BeanUtils.toBean(updateReqVO, RoleDO.class));
}
```
3. `@CachePut`
用于“执行方法后,把方法返回值直接写回缓存”。
适合更新后希望立即刷新缓存,而不是简单删除缓存的场景。
```java
@CachePut(cacheNames = "role", key = "#result.id")
public RoleDO updateRoleAndReturn(RoleSaveReqVO updateReqVO) {
RoleDO role = BeanUtils.toBean(updateReqVO, RoleDO.class);
roleMapper.updateById(role);
return role;
}
```
可以把它们简单记成:
1. `@Cacheable`:查的时候用,没有缓存才执行方法
2. `@CacheEvict`:改或删的时候用,执行后清缓存
3. `@CachePut`:改的时候用,执行后直接刷新缓存
## 注意事项
1. `RedisTemplate<String, Object>` 使用 JSON 序列化,适合对象缓存,但跨版本变更字段时仍应关注兼容性。
2. `LocalDateTime` 已做序列化兼容处理,但复杂对象结构仍应关注 Jackson 序列化结果。
3. `cacheName#ttl` 是这个模块扩展出来的约定,不是 Spring Cache 默认语法。
4. 通过 `cacheName#ttl` 设置过期时间时TTL 是针对这个缓存名生效的,不是针对单条 Key 动态计算。
5. `RdmsCacheAutoConfiguration` 将 Cache Key 前缀改成了单冒号 `:` 风格,如果已有依赖默认 `::` 的脚本或习惯,需要注意。
6. 这个模块本身主要解决“接入和约定”问题,不提供大量 Redis 业务工具方法;复杂 Redis 场景通常还是由业务模块或上层 starter 自己封装。
## 总结
这个模块的核心价值,不是简单引入 Redis 依赖,而是统一项目里的 Redis 使用方式:
1. 统一 `RedisTemplate` 的序列化规则
2. 统一 Spring Cache 的 Redis 落地方式
3. 提供按缓存名声明 TTL 的扩展能力
4. 为 Redisson 相关的上层能力提供基础环境
如果把它理解成一个“Redis 接入基座 + Cache 约定基座”的 starter这个模块的设计会比较清晰。

View File

@@ -1,159 +0,0 @@
# rdms-spring-boot-starter-rpc
## 1. 模块现状
当前模块本体基本是空的。
从代码来看,这个模块目前只有:
- `pom.xml` 中的依赖声明
- `config``core` 两个包下的占位 `package-info.java`
当前没有实际落地的 RPC 代码。
## 2. 当前实际作用
这个模块目前的作用,主要是统一引入 RPC 相关基础依赖,包括:
- `spring-cloud-starter-openfeign`
- `spring-cloud-starter-loadbalancer`
- `feign-okhttp`
- `jakarta.validation-api`
所以更准确地说它现在是一个“RPC 依赖聚合模块”,而不是一个已经实现完整功能的 RPC starter。
## 3. 当前不包含的能力
这个模块当前没有提供以下能力:
- 没有自动配置类
- 没有 `@EnableFeignClients` 封装
- 没有请求拦截器
- 没有统一异常处理
- 没有统一重试策略
- 没有统一降级处理
- 没有自定义编码器、解码器
- 没有任何业务侧 RPC 工具类
也就是说,单看这个模块本身,当前还谈不上“功能实现”。
## 4. 如何理解这个模块
如果结合当前仓库结构来理解,这个模块更适合看作:
- 先把 RPC 相关依赖收进来
- 给后续扩展预留模块位置
- 暂时还没有继续往里面建设实际能力
因此,这个模块当前的关键词不是“功能”,而是“占位”和“依赖聚合”。
## 5. 这个模块后续适合承接的内容
如果后续要继续建设,这个模块比较适合承接“各业务模块都可能共用的 RPC 通用能力”。
### 5.1 Feign 统一配置
适合放在这里的内容包括:
- 统一开启 Feign 所需的基础配置
- 统一配置超时、连接池、日志级别
- 统一切换底层 HTTP 客户端
这类内容属于基础设施层,通常不应该散落在各业务模块中。
### 5.2 请求拦截
这部分正是比较典型、也比较适合放在本模块中的能力。
例如可以统一实现:
- 请求头透传
- Token 透传
- 用户信息透传
- TraceId / 请求链路标识透传
常见做法是基于 `RequestInterceptor`,在所有 Feign 请求发出前统一补充请求头。
### 5.3 返回结果统一处理
如果希望对所有 OpenFeign 调用结果做统一拦截和异常处理,也适合放在这个模块。
常见做法包括:
- 通过 `ErrorDecoder` 统一处理非 2xx 响应
- 通过自定义 `Decoder` 统一解析返回体
- 对返回结果做统一业务异常转换
- 对远程服务错误码做统一包装
例如:
- 对方服务返回 404、500 时,统一转成项目内部异常
- 对方服务返回统一响应结构时,统一判断是否成功
- 对远程调用超时、连接失败做统一异常封装
### 5.4 调用日志与问题排查
这类通用能力也适合放在这里:
- 统一打印 RPC 请求日志
- 统一打印 RPC 响应日志
- 记录调用耗时
- 记录失败原因
这样做的好处是,排查跨服务问题时不需要每个模块各写一套。
### 5.5 重试、降级与容错
如果项目后续需要增强 RPC 稳定性,也可以在这里统一建设:
- 重试策略
- 熔断策略
- 降级处理
- 超时兜底
不过这部分是否要做,需要结合项目实际复杂度决定;如果当前系统调用链不复杂,也可以先不引入。
### 5.6 统一 RPC 规范
这个模块也适合沉淀一些统一约定,例如:
- 统一返回结构
- 统一异常码映射
- 统一 Header 命名
- 统一调用方与被调用方的接口规范
这样可以避免不同模块各自定义一套 RPC 风格。
## 6. 是否适合把拦截器和异常处理放在这里
如果这些能力是“针对所有或大多数 OpenFeign 调用都生效”的公共能力,那么放在这个模块是合适的。
原因很直接:
- 它属于 RPC 基础设施,不属于某个单独业务模块
- 放在这里可以统一维护,避免重复实现
- 后续任何模块接入 Feign 时,都可以复用同一套规则
例如你提到的这类需求,就很适合落在这里:
- 所有 Feign 请求统一加请求头
- 所有 Feign 返回统一做异常判断
- 所有远程调用失败统一转换成项目内部异常
如果某个拦截逻辑只服务于单一业务模块,那更适合放在该业务模块自己的 RPC 配置中;如果是全局通用规则,则更适合放在本模块。
## 7. 结论
`rdms-spring-boot-starter-rpc` 当前没有真正的功能代码。
如果后续要把它做成一个真正的 RPC starter通常才会逐步补这些内容
- 自动装配
- Feign 统一启用
- 请求头透传
- 调用日志
- 异常处理
- 超时与重试配置
- 统一 RPC 规范
但以当前仓库代码为准,这些能力都还没有在本模块中实现。

View File

@@ -1,646 +0,0 @@
# rdms-spring-boot-starter-security
## 1. 模块定位
`rdms-spring-boot-starter-security` 是项目中的安全基础模块,当前实际包含两块能力:
- 安全认证与权限校验
- 操作日志记录
这个模块不是单纯引入 Spring Security 依赖,而是已经在项目里落了完整的自动装配链路,包括:
- 基于 Token 的无状态认证
- 登录用户上下文维护
- URL 与方法级权限控制
- 跨服务 `LoginUser` 透传
- 401 / 403 统一返回
- 操作日志采集与异步上报
## 2. 设计思路
### 2.1 认证与权限分层
这个模块把安全相关能力拆成了几层:
1. 配置层
通过 `SecurityProperties` 统一管理 token 请求头、token 参数名、白名单、mock 登录、密码加密强度等配置。
2. 过滤器层
通过 `TokenAuthenticationFilter` 解析请求中的登录信息,并把 `LoginUser` 放入 Spring Security 上下文。
3. Spring Security 配置层
通过 `SecurityFilterChain` 统一配置无状态认证、放行规则、异常处理和过滤器顺序。
4. 权限服务层
通过 `SecurityFrameworkService` 对外提供权限、角色、scope 判断能力,供 `@PreAuthorize` 等表达式直接使用。
5. RPC 透传层
通过 Feign `RequestInterceptor` 把当前 `LoginUser` 继续透传给下游服务。
### 2.2 当前认证链路
当前项目的认证链路可以理解为:
1. 请求进入服务
2. 优先尝试从 `login-user` 请求头恢复登录用户
适用于网关或上游服务已经完成认证并透传用户信息的场景
3. 如果没有 `login-user`,再尝试从 `Authorization` 或请求参数中获取 token
4. 通过 `OAuth2TokenCommonApi` 校验 token
5. 构造 `LoginUser`
6. 放入 Spring Security 上下文,供后续权限判断、业务代码、日志记录使用
这意味着当前项目跨服务调用时,主要透传的是 `LoginUser`,而不是原始 `Authorization`
### 2.3 权限判断思路
权限判断没有把权限数据直接塞进本地配置,而是通过远程接口获取:
- 权限校验走 `PermissionCommonApi`
- token 校验走 `OAuth2TokenCommonApi`
也就是说,这个模块负责“接入和执行安全规则”,权限与 token 数据本身仍然由系统服务提供。
### 2.4 操作日志思路
操作日志能力不是自己从零实现注解解析,而是集成 `bizlog-sdk`
- 业务代码通过 `@LogRecord` 声明日志
- 本模块提供 `ILogRecordService` 实现
- 最终通过 `OperateLogCommonApi` 异步上报操作日志
这样做的特点是:
- 业务代码侧书写简单
- 日志记录逻辑统一
- 日志存储仍集中在系统服务
## 3. 自动装配入口
当前自动装配入口在:
- `RdmsSecurityRpcAutoConfiguration`
- `RdmsSecurityAutoConfiguration`
- `RdmsWebSecurityConfigurerAdapter`
- `RdmsOperateLogConfiguration`
- `RdmsOperateLogRpcAutoConfiguration`
说明这个 starter 当前已经不是占位模块,而是有完整自动配置入口的基础模块。
## 4. 核心功能点
### 4.1 基于 Token 的无状态认证
通过 `SecurityFilterChain` 配置为无状态模式:
- 禁用 Session
- 禁用表单登录
- 禁用 httpBasic
- 通过自定义 `TokenAuthenticationFilter` 完成登录态识别
这意味着服务本身不维护 Session认证主要依赖 token 或上游透传的 `login-user`
### 4.2 登录用户上下文维护
模块内定义了 `LoginUser`,当前主要包含这些信息:
- `id`
- `userType`
- `info`
- `scopes`
- `expiresTime`
认证成功后,`LoginUser` 会进入 Spring Security 上下文,后续可以通过 `SecurityFrameworkUtils` 获取,例如:
- 当前用户 ID
- 当前用户昵称
- 当前用户部门 ID
### 4.3 支持两种登录信息来源
`TokenAuthenticationFilter` 当前支持两种方式恢复登录用户:
1.`login-user` 请求头恢复
适合网关转发、服务间调用、Feign 透传场景
2. 从 token 恢复
适合直接请求服务、没有上游透传 `login-user` 的场景
这也是为什么当前服务间调用时,即使不继续透传原始 token下游仍然可以识别当前登录用户。
### 4.4 跨服务 LoginUser 透传
模块内提供了 `LoginUserRequestInterceptor`
作用是:
- 发起 Feign 请求时,读取当前线程中的 `LoginUser`
- 将其序列化后放到 `login-user` 请求头
- 下游服务再通过 `TokenAuthenticationFilter` 还原为登录用户上下文
当前项目跨服务透传的重点是“登录用户信息透传”,不是“原始 token 透传”。
### 4.5 URL 级访问控制
`RdmsWebSecurityConfigurerAdapter` 里统一配置了请求访问规则,当前支持:
- 静态资源默认放行
- `@PermitAll` 标注的方法或类自动放行
- `rdms.security.permit-all-urls` 配置的 URL 放行
- 各业务模块通过 `AuthorizeRequestsCustomizer` 继续追加自定义规则
- 其余请求默认需要认证
其中 `AuthorizeRequestsCustomizer` 是一个扩展点,适合各模块自己补充 URL 安全规则。
例如 WebSocket 模块就是通过继承这个类,把自己的连接地址加入放行规则。
### 4.6 方法级权限校验
模块开启了 `@EnableMethodSecurity`,并注册了名为 `ss` 的权限服务 Bean。
因此业务代码可以直接写:
```java
@PreAuthorize("@ss.hasPermission('system:user:create')")
public Long createUser(UserSaveReqVO reqVO) {
...
}
```
当前支持的能力包括:
- `hasPermission`
- `hasAnyPermissions`
- `hasRole`
- `hasAnyRoles`
- `hasScope`
- `hasAnyScopes`
### 4.7 权限与角色远程校验
权限、角色判断不是本地硬编码,而是通过 `PermissionCommonApi` 远程校验。
为了减少频繁 RPC 调用,当前还做了本地 Guava 缓存:
- 权限判断缓存 1 分钟
- 角色判断缓存 1 分钟
`scope` 判断则直接基于当前 `LoginUser.scopes` 完成。
### 4.8 401 / 403 统一处理
模块内提供了统一异常处理器:
- `AuthenticationEntryPointImpl`:未登录访问需要认证的资源时返回 401
- `AccessDeniedHandlerImpl`:已登录但权限不足时返回 403
这样前端收到的是统一 JSON 结构,而不是默认的 Spring Security 页面或跳转行为。
### 4.9 SecurityContext 跨线程传递
模块把 `SecurityContextHolder` 的策略切换成了 `TransmittableThreadLocalSecurityContextHolderStrategy`
作用是:
-`@Async` 等异步线程场景下,尽量保留当前安全上下文
- 减少使用普通 `ThreadLocal` 时上下文丢失的问题
### 4.10 支持开发期 mock 登录
当配置开启后,可以使用 mock token 构造登录用户,便于本地调试。
这块能力由 `SecurityProperties` 中的以下配置控制:
- `mock-enable`
- `mock-secret`
这类能力只适合开发调试环境,不适合生产环境开启。
### 4.11 操作日志能力
这个模块还同时集成了操作日志能力。
当前实现方式是:
1. 通过 `@EnableLogRecord` 开启日志能力
2. 业务方法上使用 `@LogRecord`
3. 本模块的 `LogRecordServiceImpl` 收集日志内容
4. 自动补充用户信息、请求信息、链路追踪信息
5. 通过 `OperateLogCommonApi` 异步上报
日志中会补充的典型信息包括:
- 用户 ID
- 用户类型
- 请求方法
- 请求 URL
- 请求 IP
- User-Agent
- TraceId
## 5. 配置项
当前可见的核心配置在 `rdms.security` 下:
```yaml
rdms:
security:
token-header: Authorization
token-parameter: token
mock-enable: false
mock-secret: test
permit-all-urls:
- /admin-api/system/auth/login
password-encoder-length: 4
```
说明:
- `token-header`:请求头里 token 的字段名,默认是 `Authorization`
- `token-parameter`:请求参数里 token 的字段名,主要兼容不能方便传 header 的场景
- `mock-enable` / `mock-secret`:开发期 mock 登录控制
- `permit-all-urls`:额外白名单 URL
- `password-encoder-length`BCrypt 加密强度
## 6. 快速上手
如果从“一个开发者接手后该怎么用”来理解,这个模块最常见的上手路径如下。
### 6.1 引入依赖后,会自动得到什么
业务模块引入这个 starter 后,默认会自动得到这些能力:
- Spring Security 过滤链
- Token 认证过滤器
- `@EnableMethodSecurity`
- `@ss` 权限表达式 Bean
- 401 / 403 统一返回
- Feign 的 `login-user` 透传
- `@LogRecord` 操作日志能力
也就是说,通常不需要你再手写一套 Security 配置类,除非业务模块有额外的特殊规则。
### 6.2 最小配置怎么写
最常见的基础配置如下:
```yaml
rdms:
security:
token-header: Authorization
token-parameter: token
mock-enable: false
permit-all-urls:
- /admin-api/system/auth/login
- /admin-api/system/auth/refresh-token
```
如果没有特殊需求,通常只需要确认:
- 哪些接口要放行
- token 从哪个 header 取
### 6.3 一个新接口,默认是什么状态
默认情况下:
- 没有显式放行的接口,都会要求登录
- 已登录后,如果方法上写了 `@PreAuthorize`,还会继续做权限校验
也就是说,一个普通接口最常见的开发方式是:
1. 默认需要登录
2. 再按需要加权限注解
例如:
```java
@PreAuthorize("@ss.hasPermission('system:user:query')")
@GetMapping("/page")
public CommonResult<PageResult<UserRespVO>> getUserPage(UserPageReqVO reqVO) {
...
}
```
### 6.4 哪些接口不需要登录,怎么做
有两种常用方式。
方式一:在接口或类上标 `@PermitAll`
```java
@PermitAll
@GetMapping("/public-info")
public CommonResult<String> getPublicInfo() {
...
}
```
方式二:通过配置白名单放行
```yaml
rdms:
security:
permit-all-urls:
- /admin-api/system/auth/login
- /admin-api/system/auth/refresh-token
```
实践建议:
- 固定的公共接口、登录接口、刷新 token 接口,更适合放配置白名单
- 个别无需登录的业务接口,更适合直接写 `@PermitAll`
### 6.5 需要权限控制,怎么做
最常用的是在 Controller 或 Service 方法上写 `@PreAuthorize`
```java
@PreAuthorize("@ss.hasPermission('system:user:create')")
@PostMapping("/create")
public CommonResult<Long> createUser(@Valid @RequestBody UserSaveReqVO reqVO) {
...
}
```
当前可直接使用的表达式包括:
- `@ss.hasPermission('xxx')`
- `@ss.hasAnyPermissions('a', 'b')`
- `@ss.hasRole('admin')`
- `@ss.hasAnyRoles('admin', 'manager')`
- `@ss.hasScope('user.read')`
- `@ss.hasAnyScopes('a', 'b')`
最常见的是 `hasPermission`
### 6.5.1 接口可以按三类理解
从实际开发角度,可以把接口简单分成三类:
1. 匿名可访问
这类接口不要求登录。
常见做法:
- 使用 `@PermitAll`
- 或加入 `rdms.security.permit-all-urls` 白名单
2. 登录即可访问
这类接口要求登录,但不校验具体 permission。
常见做法:
- 不加 `@PermitAll`
- 不加白名单
- 也不加 `@PreAuthorize`
在当前配置下,这类接口会被默认的 `authenticated()` 规则保护:
- 未登录无法访问
- 已登录可以访问
3. 登录后还要校验具体权限
这类接口除了要求登录,还要求具备具体权限点。
常见做法:
- 使用 `@PreAuthorize("@ss.hasPermission('xxx')")`
一个比较实用的开发策略是:
- 大部分普通接口只要求登录
- 删除、导出、审批、分配等敏感动作再加具体权限校验
### 6.6 权限校验是怎么触发的
很多时候容易疑惑:`@PreAuthorize("@ss.hasPermission('system:dept:update')")` 这行代码本身没有手动调用 `PermissionCommonApi`,那远程权限校验是什么时候发生的?
当前链路可以直接理解成下面这几步:
```text
请求进入服务
-> TokenAuthenticationFilter 完成登录态恢复
-> 请求到达 Controller / Service 方法前
-> Spring Security 拦截 @PreAuthorize
-> 解析表达式 @ss.hasPermission('system:dept:update')
-> 调用 Spring 容器里的 ss Bean
-> 进入 SecurityFrameworkServiceImpl.hasPermission(...)
-> 转到 hasAnyPermissions(...)
-> 先查本地 1 分钟缓存
-> 缓存未命中时,调用 PermissionCommonApi 远程判断权限
-> 返回 true允许进入方法
-> 返回 false拒绝访问返回 403
```
可以把它理解为:
- `@PreAuthorize` 负责“触发权限判断”
- `@ss` 负责“找到权限判断 Bean”
- `SecurityFrameworkServiceImpl` 负责“执行权限判断逻辑”
- `PermissionCommonApi` 负责“向权限服务查询结果”
所以真正触发 `PermissionCommonApi` 的,不是 Controller 代码手动调用,而是 Spring Security 在执行 `@PreAuthorize` 表达式时自动触发。
### 6.7 业务代码里怎么拿当前登录用户
最常用的是通过 `SecurityFrameworkUtils` 获取:
```java
Long userId = SecurityFrameworkUtils.getLoginUserId();
String nickname = SecurityFrameworkUtils.getLoginUserNickname();
Long deptId = SecurityFrameworkUtils.getLoginUserDeptId();
```
如果需要更完整的信息:
```java
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
```
适用场景:
- 新增、修改数据时记录操作人
- 查询当前用户自己的数据
- 记录审计字段
### 6.8 为什么跨服务还能拿到当前用户
当前项目的链路是这样的:
1. 前端把 token 传给网关
2. 网关校验 token 后,把用户信息转成 `login-user` 请求头
3. 下游服务读取 `login-user`,恢复 `LoginUser`
4. 如果下游服务再通过 Feign 调别的服务,`LoginUserRequestInterceptor` 会继续透传 `login-user`
所以开发时可以这样理解:
- Web 入口主要靠 token 建立登录态
- 服务间调用主要靠 `login-user` 继续传递登录态
### 6.9 什么时候需要自己补 URL 规则
如果某个模块有特殊放行需求,可以继承 `AuthorizeRequestsCustomizer`
例如:
```java
public class XxxAuthorizeRequestsCustomizer extends AuthorizeRequestsCustomizer {
@Override
public void customize(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
registry.requestMatchers("/xxx/**").permitAll();
}
}
```
常见场景:
- WebSocket 握手地址
- 第三方回调接口
- 某些模块自己的公共入口
### 6.10 需要记录操作日志,怎么做
在业务方法上直接加 `@LogRecord`
```java
@LogRecord(type = "SYSTEM_USER", subType = "CREATE", bizNo = "{{#user.id}}",
success = "创建用户成功")
public Long createUser(UserSaveReqVO reqVO) {
...
}
```
这个注解更适合加在“有明确业务动作”的方法上,例如:
- 新增用户
- 修改角色
- 删除字典
- 分配权限
### 6.11 开发时最容易混淆的几个点
1. 这个模块负责“认证接入和权限执行”,不负责 token 签发
token 的签发和存储仍在系统服务等其他模块。
2. 当前跨服务透传的是 `login-user`,不是原始 token
所以下游服务能识别当前用户,不等于它一直拿着原始 `Authorization`
3. 单体部署(不经过网关)建议在入口层剥离 `login-user` 请求头,避免外部伪造登录态
- Nginx 示例:
```nginx
# 去除客户端伪造的 login-user 头
proxy_set_header login-user "";
```
3. 不加 `@PreAuthorize` 不代表匿名可访问
默认仍然需要登录,只是不会进一步做权限点校验。
4. `@PermitAll` 和 `permit-all-urls` 都是放行登录校验
放行后通常也不会再走权限控制。
## 7. 常见使用方式
### 7.1 方法权限控制
```java
@PreAuthorize("@ss.hasPermission('system:user:query')")
@GetMapping("/page")
public CommonResult<PageResult<UserRespVO>> getUserPage(UserPageReqVO reqVO) {
...
}
```
适用场景:
- 控制某个接口必须具备某个权限点
- 细粒度控制新增、修改、删除、导出等操作
### 7.2 放行无需登录的接口
方式一:直接标注 `@PermitAll`
```java
@PermitAll
@GetMapping("/public-info")
public CommonResult<String> getPublicInfo() {
...
}
```
方式二:通过配置加入白名单
```yaml
rdms:
security:
permit-all-urls:
- /admin-api/system/auth/login
- /admin-api/system/auth/refresh-token
```
### 7.3 为某个模块补充 URL 安全规则
如果某个模块有自己的特殊放行需求,可以继承 `AuthorizeRequestsCustomizer`
```java
public class XxxAuthorizeRequestsCustomizer extends AuthorizeRequestsCustomizer {
@Override
public void customize(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
registry.requestMatchers("/xxx/**").permitAll();
}
}
```
适用场景:
- WebSocket 握手地址放行
- 回调接口放行
- 某些特殊模块追加自己的 URL 规则
### 7.4 记录操作日志
```java
@LogRecord(type = "SYSTEM_USER", subType = "CREATE", bizNo = "{{#user.id}}",
success = "创建用户成功")
public Long createUser(UserSaveReqVO reqVO) {
...
}
```
适用场景:
- 新增、修改、删除等需要审计的核心业务动作
- 希望把操作人、请求来源、业务对象编号一起记录下来
## 8. 模块边界
阅读这个模块时,需要把“它负责什么”和“它不负责什么”分开看。
当前它负责:
- 接入 Spring Security
- 解析 token 或 `login-user`
- 构建登录用户上下文
- 提供权限判断能力
- 统一未登录 / 未授权响应
- 透传 `LoginUser`
- 集成操作日志记录
当前它不负责:
- token 的签发
- 权限数据本身的维护
- 用户、角色、菜单、权限模型的存储
- 原始 `Authorization` 的 Feign 透传
这些数据来源和业务模型,当前仍由系统服务等其他模块提供。
## 9. 总结
`rdms-spring-boot-starter-security` 当前已经是一个实际落地的基础模块,不是占位模块。
从当前代码看,它的核心价值在于:
- 统一接入认证与权限体系
- 把登录用户上下文在 Web、Spring Security、Feign 调用之间串起来
- 给业务层提供简单直接的权限表达式入口
- 把操作日志能力收进统一基础设施
如果后续继续扩展,这个模块仍然适合承接更多安全通用能力,例如:
- 原始 token 透传
- 更细的权限表达式
- 更完整的审计日志字段

View File

@@ -1,373 +0,0 @@
# rdms-spring-boot-starter-test
## 1. 模块定位
`rdms-spring-boot-starter-test` 是项目中的测试基础模块,作用不是提供业务测试用例,而是为测试提供统一的基础设施和基类。
当前模块主要解决的是:
- 纯 Mockito 单元测试怎么写
- 依赖数据库的单元测试怎么快速启动
- 依赖 Redis 的单元测试怎么快速启动
- 同时依赖 DB 和 Redis 的测试怎么快速启动
- 测试里怎么更方便地造随机数据和做断言
所以这个模块更适合理解为“测试脚手架模块”。
## 2. 设计思路
当前模块的设计思路比较明确:把常见测试场景抽成几种固定模板,让开发人员通过继承基类快速开始写测试,而不是每个模块都自己从零搭测试环境。
整体上分成三层:
1. 依赖层
统一引入测试常用依赖,例如 Mockito、Spring Boot Test、H2、内存 Redis、随机对象生成工具。
2. 配置层
提供针对测试场景的补充配置,例如:
- 内存 Redis 启动配置
- SQL 初始化配置
3. 基类层
把测试场景收口成几类基类,开发时按场景继承即可。
这种设计的重点不是“测试能力多复杂”,而是“让测试环境标准化”。
## 3. 当前提供的能力
### 3.1 纯 Mockito 单元测试基类
基类:
- `BaseMockitoUnitTest`
作用:
- 适合不需要 Spring 容器、不需要 DB、不需要 Redis 的纯单元测试
- 基于 `MockitoExtension`
适用场景:
- 测试工具类
- 测试纯业务逻辑类
- 依赖全部可以通过 Mock 替代的 Service
### 3.2 内存 DB 单元测试基类
基类:
- `BaseDbUnitTest`
作用:
- 启动 Spring 测试上下文
- 引入数据源、事务、MyBatis 相关配置
- 使用 H2 作为内存数据库
- 支持 SQL 初始化
- 每个测试方法结束后自动执行 `/sql/clean.sql` 清理数据库
适用场景:
- Mapper 测试
- 依赖本模块数据库访问的 Service 测试
- 需要真实执行 MyBatis SQL 的测试
### 3.3 内存 Redis 单元测试基类
基类:
- `BaseRedisUnitTest`
作用:
- 启动 Spring 测试上下文
- 启动内存 Redis
- 引入 Redis 与 Redisson 相关配置
适用场景:
- Redis DAO 测试
- 缓存逻辑测试
- 分布式锁、限流、缓存等依赖 Redis 的逻辑测试
### 3.4 内存 DB + Redis 组合测试基类
基类:
- `BaseDbAndRedisUnitTest`
作用:
- 同时启动内存数据库和内存 Redis
- 一次性引入 DB、MyBatis、Redis、Redisson 所需配置
- 每个测试方法结束后自动清理数据库
适用场景:
- 同时依赖数据库和缓存的 Service 测试
- 需要验证“写库 + 删缓存 / 刷缓存”这类联动逻辑的测试
### 3.5 内存 Redis 启动配置
配置类:
- `RedisTestConfiguration`
作用:
- 基于 `jedis-mock` 启动一个内存 Redis Server
- 供 Redis 相关测试基类复用
这个配置的重点是:让测试不依赖外部真实 Redis 服务。
### 3.6 SQL 初始化配置
配置类:
- `SqlInitializationTestConfiguration`
作用:
- 在测试场景下补充 DataSource SQL 初始化能力
- 解决延迟加载场景下默认 SQL 初始化配置不生效的问题
这个配置的核心价值是:保证 H2 测试数据库能按测试配置正常初始化 schema 和 data。
### 3.7 随机测试数据工具
工具类:
- `RandomUtils`
作用:
- 随机生成字符串、数字、时间、邮箱、手机号
- 随机生成 POJO
- 随机生成对象列表、对象集合
它内部基于 `podam` 做随机对象填充,并对部分字段做了定制处理,例如:
- `status` 字段优先生成常见状态值
- `deleted` 字段默认生成 `false`
- `LocalDateTime` 的纳秒位归零,避免 MySQL / H2 时间精度差异
### 3.8 测试断言工具
工具类:
- `AssertUtils`
作用:
- 对比两个 POJO 的字段是否一致
- 断言是否抛出指定 `ServiceException`
适用场景:
- 校验 DTO / DO / VO 转换结果
- 校验业务异常是否符合预期
## 4. 开发人员怎么上手
这个模块最重要的不是“看懂代码”,而是先判断当前测试属于哪一类,然后继承对应基类。
### 4.1 如果只是纯逻辑测试
直接继承:
```java
class XxxServiceTest extends BaseMockitoUnitTest {
}
```
适合:
- 不需要 Spring 容器
- 不访问数据库
- 不访问 Redis
- 依赖都可以 Mock
这是成本最低、执行最快的一类测试。
### 4.2 如果要测 Mapper 或真实 SQL
直接继承:
```java
class XxxMapperTest extends BaseDbUnitTest {
}
```
适合:
- Mapper 层测试
- 依赖 H2 跑 SQL
- Service 里要真实查库、写库
开发时通常要准备:
- `application-unit-test.yaml`
- 初始化 SQL
- `clean.sql`
### 4.3 如果要测 Redis 逻辑
直接继承:
```java
class XxxRedisDaoTest extends BaseRedisUnitTest {
}
```
适合:
- Redis Key 操作测试
- 缓存逻辑测试
- 基于 Redis 的基础设施测试
这样就不需要自己额外准备 Redis 环境。
### 4.4 如果业务同时依赖 DB 和 Redis
直接继承:
```java
class XxxServiceTest extends BaseDbAndRedisUnitTest {
}
```
适合:
- 先写库再删缓存
- 先查库再写缓存
- 依赖数据库和 Redis 共同完成的业务逻辑
### 4.5 如果想快速造测试对象
可以直接使用 `RandomUtils`
```java
UserSaveReqVO reqVO = RandomUtils.randomPojo(UserSaveReqVO.class);
List<UserSaveReqVO> list = RandomUtils.randomPojoList(UserSaveReqVO.class, 3);
String email = RandomUtils.randomEmail();
String mobile = RandomUtils.randomMobile();
```
适合:
- 减少手写测试数据
- 快速生成大量测试输入
- 配合局部字段覆盖提高测试效率
### 4.6 如果要断言业务异常
可以直接使用 `AssertUtils.assertServiceException(...)`
```java
AssertUtils.assertServiceException(
() -> userService.createUser(reqVO),
USER_USERNAME_EXISTS
);
```
适合:
- 校验业务校验逻辑
- 校验异常码和异常信息
### 4.7 如果要断言对象字段一致
可以使用:
```java
AssertUtils.assertPojoEquals(expected, actual, "createTime", "updateTime");
```
适合:
- 校验转换逻辑
- 校验查询结果
- 忽略少数字段差异后做整体对比
## 5. 常见使用建议
### 5.1 优先选最轻的测试基类
建议顺序是:
1. 能用 `BaseMockitoUnitTest` 就不要上 Spring 容器
2. 需要真实 DB 再用 `BaseDbUnitTest`
3. 需要真实 Redis 再用 `BaseRedisUnitTest`
4. 两者都需要时再用 `BaseDbAndRedisUnitTest`
原因很简单:
- 测试越轻,执行越快
- 环境越少,问题越少
### 5.2 本模块更偏“单元测试基础设施”
虽然这里用到了 Spring Boot Test、H2、内存 Redis但当前命名和设计仍然偏“单元测试 / 轻量集成测试”。
也就是说,它更适合:
- 在模块内部验证逻辑正确性
- 快速验证 Mapper / Redis / Service 行为
而不是:
- 完整端到端测试
- 完整微服务联调测试
- 依赖真实外部中间件的系统测试
### 5.3 自己模块真实依赖走内存实现,外部模块依赖走 Mock
`BaseDbUnitTest` 的注释就能看出当前设计思路:
- 自己模块的 Mapper 走 H2
- 别的模块的 Service 走 Mock
这是一种比较务实的测试策略:
- 保留自己模块的真实数据访问能力
- 避免跨模块依赖把测试拖重
## 6. 模块边界
当前模块负责的是:
- 提供测试基类
- 提供测试配置
- 提供随机数据和断言工具
当前模块不负责的是:
- 自动生成业务测试用例
- 自动替你写 Mock 行为
- 真实中间件联调
- 完整集成测试平台
所以它的职责很明确:让测试环境更容易搭,不是替业务写测试。
## 7. 总结
`rdms-spring-boot-starter-test` 当前已经把项目里最常见的测试场景做成了统一模板。
对于开发人员来说,真正重要的上手方式只有两步:
1. 先判断当前测试依赖什么环境
2. 再继承对应的基类
如果只是想快速理解这个模块,可以直接记住下面这张映射:
- 纯逻辑测试:`BaseMockitoUnitTest`
- 测 DB`BaseDbUnitTest`
- 测 Redis`BaseRedisUnitTest`
- DB + Redis 一起测:`BaseDbAndRedisUnitTest`
再配合:
- `RandomUtils` 造数据
- `AssertUtils` 做断言
基本就能覆盖大部分日常测试开发场景。

View File

@@ -1,606 +0,0 @@
# rdms-spring-boot-starter-web
## 1. 模块定位
`rdms-spring-boot-starter-web` 是 Web 层基础设施模块,用来统一处理 HTTP 接口开发中的通用问题,而不是承载具体业务逻辑。
模块当前聚合了以下能力:
- Web 基础自动配置
- 全局异常处理
- 接口返回结果暂存
- 管理端 / 应用端接口前缀约定
- Swagger / Knife4j 文档
- API 访问日志
- XSS 防护
- API 加解密
- 返回字段脱敏
- Jackson JSON 定制
- Banner 输出
## 2. 设计思路
这个模块的设计重点,是把 Web 层横切能力收敛到 starter 中,业务模块只关注 Controller、参数对象和业务逻辑本身。
核心思路如下:
- 通过自动装配统一注册 `Filter``ControllerAdvice``RestTemplate`、Swagger 等基础组件。
- 通过包路径约定,自动给 Controller 增加统一前缀,减少每个模块重复写公共路径。
- 通过全局异常处理,把常见异常统一翻译成 `CommonResult`
- 通过过滤器和拦截器处理访问日志、XSS、防重复读取请求体、演示环境保护等横切逻辑。
- 通过注解方式扩展局部能力,例如 API 访问日志增强、接口加解密、字段脱敏。
自动装配入口如下:
- `com.njcn.rdms.framework.apilog.config.RdmsApiLogAutoConfiguration`
- `com.njcn.rdms.framework.jackson.config.RdmsJacksonAutoConfiguration`
- `com.njcn.rdms.framework.swagger.config.RdmsSwaggerAutoConfiguration`
- `com.njcn.rdms.framework.web.config.RdmsWebAutoConfiguration`
- `com.njcn.rdms.framework.apilog.config.RdmsApiLogRpcAutoConfiguration`
- `com.njcn.rdms.framework.xss.config.RdmsXssAutoConfiguration`
- `com.njcn.rdms.framework.banner.config.RdmsBannerAutoConfiguration`
- `com.njcn.rdms.framework.encrypt.config.RdmsApiEncryptAutoConfiguration`
## 3. 功能模块
### 3.1 Web 基础自动配置
`RdmsWebAutoConfiguration` 是本模块的核心入口,主要提供以下能力:
- 注册全局异常处理器 `GlobalExceptionHandler`
- 注册返回结果处理器 `GlobalResponseBodyHandler`
- 注册 `WebFrameworkUtils`
- 注册跨域过滤器 `CorsFilter`
- 注册请求体缓存过滤器 `CacheRequestBodyFilter`
- 提供普通 `RestTemplate`
- 提供带 `@LoadBalanced``RestTemplate`
其中有两个基础能力需要重点关注:
#### 3.1.1 按包路径自动增加接口前缀
模块会根据 Controller 所在包路径,自动追加统一前缀:
- `**.controller.admin.**` 默认追加 `/admin-api`
- `**.controller.app.**` 默认追加 `/app-api`
默认配置来自 `rdms.web`
```yaml
rdms:
web:
admin-api:
prefix: /admin-api
controller: "**.controller.admin.**"
app-api:
prefix: /app-api
controller: "**.controller.app.**"
admin-ui:
url: http://127.0.0.1:80
# 说明:
# - admin-api/app-api 已有默认值(如上所示),不改可省略
# - admin-ui.url 无默认值,需要显式配置
```
例如:
```java
package com.njcn.rdms.module.system.controller.admin.user;
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/get")
public CommonResult<String> get() {
return CommonResult.success("ok");
}
}
```
实际访问路径是:
```text
/admin-api/user/get
```
如果 Controller 放在:
```text
com.xxx.xxx.controller.app.xxx
```
则会自动挂到 `/app-api` 下。
#### 3.1.2 `CommonResult` 不是自动包装
这个模块不会把任意 Controller 返回值自动包成 `CommonResult`
`GlobalResponseBodyHandler` 的作用,是在返回前记录已经构造好的 `CommonResult`,供访问日志等能力读取,而不是替开发者自动改写返回结构。
因此 Controller 仍然需要显式返回:
```java
return CommonResult.success(data);
```
而不是依赖框架自动包装。
### 3.2 全局异常处理
`GlobalExceptionHandler` 会把常见异常统一转换成 `CommonResult`,包括:
- 参数缺失
- 参数类型错误
- `@Validated` / `@Valid` 校验失败
- 请求方式不匹配
- Content-Type 不匹配
- 无权限访问
- 业务异常 `ServiceException`
- 系统异常
对于系统异常,除了统一返回错误结果外,还会通过 `ApiErrorLogCommonApi` 异步记录异常日志。
这意味着业务代码中通常只需要:
- 正常场景返回 `CommonResult.success(...)`
- 业务错误抛出 `ServiceException`
其余异常由框架统一兜底。
### 3.3 接口返回结果暂存
这里的“接口返回结果记录”,更准确地说是“接口返回结果暂存”。
对应实现是 `GlobalResponseBodyHandler`,它只会在 Controller 返回值为 `CommonResult` 时,把该结果放到当前请求上下文中,供后续组件读取。
它本身不负责:
- 打印日志
- 持久化日志
- 改写返回结构
它主要服务于访问日志能力。因为 `ApiAccessLogFilter` 执行时,需要拿到本次请求最终返回的 `CommonResult`,才能把返回码、返回消息、响应体等内容写入访问日志。
可以这样理解两者关系:
- 接口返回结果暂存:把本次返回结果放到 request 上,供后续读取
- API 访问日志:读取请求信息和返回结果,组装后异步上报日志
因此,这两个能力不是并列重复关系,而是前者为后者提供支撑。
### 3.4 请求体缓存
`CacheRequestBodyFilter` 会把请求体包装成可重复读取的形式。
这个能力主要解决以下问题:
- 过滤器里读过一次请求体后,后续代码还能继续读取
- 访问日志、XSS、防护类过滤器可以提前读取请求内容
- 异常日志记录时可以再次拿到请求参数
这类能力对 `application/json` 请求尤其重要。
### 3.5 跨域处理
模块默认注册了全局 `CorsFilter`,允许:
- 任意来源
- 任意请求头
- 任意请求方法
- 携带凭证
适合前后端分离场景的统一跨域处理。
如果项目已经有更严格的网关级跨域策略,需要注意是否与这里的配置重复。
### 3.6 RestTemplate 支持
模块提供两个 `RestTemplate` Bean
- 普通 `RestTemplate`
- 支持服务名负载均衡的 `loadBalancedRestTemplate`
使用示例:
```java
@Resource
private RestTemplate restTemplate;
@Resource(name = "loadBalancedRestTemplate")
private RestTemplate loadBalancedRestTemplate;
```
适用场景:
- `restTemplate`:直接调用固定地址
- `loadBalancedRestTemplate`:通过服务名访问注册中心中的其他服务
### 3.7 Swagger / Knife4j 文档
模块会自动装配 OpenAPI并按管理端、应用端拆分分组
- `/admin-api/**`
- `/app-api/**`
同时会在文档中预置常见请求头,例如认证头,便于在 Swagger 页面直接调试接口。
Swagger 配置示例:
```yaml
rdms:
swagger:
title: RDMS API # 文档标题
description: RDMS 接口文档 # 文档描述
author: RDMS # 作者/团队
version: 1.0.0 # 版本号
url: https://example.com # 项目或团队主页
email: dev@example.com # 联系邮箱
license: Apache 2.0 # 协议名称
license-url: https://www.apache.org/licenses/LICENSE-2.0.html # 协议地址
```
开发时通常只需要:
- 引入本模块
- 补齐 `rdms.swagger` 配置
- 在 Controller 上使用 `@Tag`
- 在接口上使用 `@Operation`
这样访问日志模块还能直接复用这些注解信息,自动推断操作模块、操作名称。
### 3.8 API 访问日志
模块通过 `ApiAccessLogFilter + ApiAccessLogInterceptor` 组合处理接口访问日志。
这里的访问日志,才是真正意义上的“日志记录能力”。
处理逻辑分成两层:
- 拦截器层:把 `HandlerMethod` 放到 request 中,供过滤器层读取
- 过滤器层:在请求结束后读取请求信息、异常信息、返回结果,并异步上报访问日志
访问日志内容包括:
- 用户信息
- 请求路径、方法、IP、User-Agent
- 请求参数
- 返回码、返回信息
- 执行耗时
- 操作模块、操作名称、操作类型
其中“返回码、返回信息、响应体”这部分数据,正是从前面的 `GlobalResponseBodyHandler` 暂存结果中读取出来的。
默认会记录访问日志;如需关闭,可配置:
```yaml
rdms:
access-log:
enable: false
```
如果需要对某个接口单独调整日志行为,可使用 `@ApiAccessLog`
常见用法示例:
```java
@Tag(name = "用户管理")
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/page")
@Operation(summary = "查询用户分页")
@ApiAccessLog(responseEnable = true, sanitizeKeys = {"mobile"})
public CommonResult<PageResult<UserRespVO>> page(UserPageReqVO reqVO) {
return CommonResult.success(userService.page(reqVO));
}
}
```
说明:
- `responseEnable = true`:额外记录响应体
- `sanitizeKeys`:从日志中移除敏感字段
- 如果没有显式指定操作名称,默认优先从 `@Operation` 中获取
访问日志的异步落库依赖 `ApiAccessLogCommonApi`,因此它本质上是“记录请求轨迹”,不是直接把日志写在本模块内部。
### 3.9 XSS 防护
模块内提供了 XSS 过滤能力,核心组成包括:
- `XssFilter`
- `XssRequestWrapper`
- `XssStringJsonDeserializer`
- `JsoupXssCleaner`
设计方式是:
- 对进入系统的请求内容做统一清洗
- 对 JSON 中的字符串字段做清洗
- 底层使用 `jsoup` 处理潜在的恶意脚本片段
适用场景:
- 富文本之外的大多数普通文本输入
- 表单提交
- JSON 请求体中的字符串字段
如果某些接口需要保留原始 HTML 内容,通常需要结合该模块的 XSS 配置做排除,而不是在业务层手工规避。
URL 白名单说明:
- 支持通过 `rdms.xss.exclude-urls` 配置 URL 白名单,命中的 URL 会跳过 XSS 过滤(包括 Filter 与 JSON 字符串反序列化)。
- 适合富文本、HTML 片段等需要保留原始内容的接口。
示例:
```yaml
rdms:
xss:
enable: true
exclude-urls:
- /admin-api/rich-text/**
- /app-api/article/save
```
### 3.10 API 加解密
模块提供了接口级加解密能力,核心组成包括:
- `@ApiEncrypt`
- `ApiEncryptFilter`
- `ApiDecryptRequestWrapper`
- `ApiEncryptResponseWrapper`
适合理解为:
- 请求进入时先解密
- Controller / Service 内部仍然处理明文
- 响应返回前再加密
开发使用上,一般是在需要加解密的接口上增加 `@ApiEncrypt`,其余请求不受影响。
示例:
```java
@RestController
@RequestMapping("/secure/demo")
public class SecureDemoController {
@PostMapping("/submit")
@ApiEncrypt
public CommonResult<String> submit(@RequestBody DemoReqVO reqVO) {
return CommonResult.success(reqVO.getContent());
}
}
```
这类能力适合:
- 对外开放接口
- 对传输内容有额外保护要求的场景
不适合把它当成通用权限控制手段。它解决的是“传输内容保护”,不是“访问授权”。
### 3.11 返回字段脱敏
模块内置了一组字段脱敏注解和序列化器,常见注解包括:
- `@MobileDesensitize`
- `@EmailDesensitize`
- `@IdCardDesensitize`
- `@BankCardDesensitize`
- `@ChineseNameDesensitize`
- `@PasswordDesensitize`
适用方式是:在返回对象字段上直接加注解,序列化为 JSON 时自动脱敏。
示例:
```java
@Data
public class UserRespVO {
private Long id;
@MobileDesensitize
private String mobile;
@EmailDesensitize
private String email;
@ChineseNameDesensitize
private String nickname;
}
```
这样接口内部仍可使用原始值,真正对外输出时才变成脱敏后的内容。
### 3.12 Jackson JSON 定制
模块包含 `RdmsJacksonAutoConfiguration`,用于统一注册 JSON 序列化 / 反序列化相关定制能力。
从模块结构上看,它主要承担两类职责:
- 承接本模块的 XSS、脱敏等 JSON 处理能力
- 把 JSON 相关行为统一收口到 starter 中,避免各业务模块重复配置 `ObjectMapper`
因此业务模块通常不需要自己再手动声明一套新的全局 Jackson 配置,除非有明确的覆盖需求。
### 3.13 Banner
模块还包含 `RdmsBannerAutoConfiguration``BannerApplicationRunner`,用于在应用启动时输出框架 Banner 信息。
这部分属于展示型能力,不影响具体业务逻辑。
## 4. 开发人员上手
### 4.1 引入依赖
```xml
<dependency>
<groupId>com.njcn</groupId>
<artifactId>rdms-spring-boot-starter-web</artifactId>
</dependency>
```
本模块已经聚合:
- `spring-boot-starter-web`
- `spring-boot-starter-validation`
- `knife4j-openapi3-jakarta-spring-boot-starter`
- `springdoc-openapi-starter-webmvc-ui`
如果项目已经单独引入了这些依赖,需要留意是否存在重复配置。
### 4.2 准备基础配置
建议至少配置以下内容:
```yaml
spring:
application:
name: rdms-system-server
rdms:
web:
admin-api:
prefix: /admin-api
controller: "**.controller.admin.**"
app-api:
prefix: /app-api
controller: "**.controller.app.**"
admin-ui:
url: http://127.0.0.1:80
swagger:
title: RDMS API # 文档标题
description: RDMS 接口文档 # 文档描述
author: RDMS # 作者/团队
version: 1.0.0 # 版本号
url: https://example.com # 项目或团队主页
email: dev@example.com # 联系邮箱
license: Apache 2.0 # 协议名称
license-url: https://www.apache.org/licenses/LICENSE-2.0.html # 协议地址
access-log:
enable: true
```
### 4.3 按约定放置 Controller
如果希望自动带上前缀,需要把 Controller 放到约定包下:
- 管理端:`xx.controller.admin.xx`
- 应用端:`xx.controller.app.xx`
不符合这个包路径规则的 Controller不会自动追加 `/admin-api``/app-api`
### 4.4 统一返回 `CommonResult`
Controller 写法建议保持统一:
```java
@Tag(name = "部门管理")
@RestController
@RequestMapping("/dept")
public class DeptController {
@GetMapping("/get")
@Operation(summary = "获得部门详情")
public CommonResult<DeptRespVO> get(@RequestParam("id") Long id) {
return CommonResult.success(deptService.get(id));
}
}
```
建议遵循:
- 成功场景返回 `CommonResult.success(...)`
- 业务异常抛出 `ServiceException`
- 参数对象使用 `@Validated` / `@Valid`
### 4.5 使用访问日志
多数情况下,不需要额外写代码,访问日志会自动生效。
如果需要补充操作名、操作类型、响应体记录等信息,可以加:
```java
@ApiAccessLog(responseEnable = true)
```
建议同时补齐:
- `@Tag`
- `@Operation`
这样日志中的操作模块和操作名称会更完整;建议将 `@Tag` / `@Operation` 作为接口开发的必填规范。
### 4.6 使用加解密
只在需要的接口上加:
```java
@ApiEncrypt
```
然后让调用方按约定传输密文即可。接口内部仍按明文对象开发,不需要在 Controller 内手工做解密逻辑。
### 4.7 使用字段脱敏
在返回对象字段上直接加注解即可:
```java
@MobileDesensitize
private String mobile;
```
适合用户信息、联系方式、证件信息等对外展示场景。
### 4.8 使用 RestTemplate
固定地址调用:
```java
@Resource
private RestTemplate restTemplate;
```
服务名调用:
```java
@Resource(name = "loadBalancedRestTemplate")
private RestTemplate loadBalancedRestTemplate;
```
### 4.9 开发时需要注意的几个点
- 这个模块不会自动把任意返回值包装成 `CommonResult`,需要显式返回。
- 接口前缀是按包路径匹配出来的,不是看 Controller 名称。
- 访问日志默认开启,但是否真正异步落库,还取决于日志相关 RPC 接口是否可用。
- XSS、防脱敏、加解密都属于横切能力原则上应该通过统一配置或注解使用不建议在业务代码里重复造轮子。
- 如果项目已经在网关层处理跨域、日志、安全头等逻辑,需要评估是否与本模块职责重叠。
## 5. 适合承载什么,不适合承载什么
适合放在这个模块里的能力:
- Web 层公共配置
- HTTP 请求/响应横切处理
- 接口文档
- 访问日志
- 安全性输入输出处理
- Web 基础工具注册
不适合放在这个模块里的能力:
- 具体业务规则
- 菜单、权限、数据权限等业务权限判断
- 某个业务模块专属的 Controller 逻辑
- 与单一业务强耦合的转换规则
从职责上看这个模块更接近“Web 基础设施层”,而不是“业务功能层”。

View File

@@ -1,142 +0,0 @@
# rdms-spring-boot-starter-websocket
## 1. 模块定位
`rdms-spring-boot-starter-websocket` 是 WebSocket 基础设施模块,用于统一处理连接、会话管理、消息分发与消息发送。
模块聚合的核心能力:
- WebSocket 自动装配与路径注册
- 登录用户绑定与会话管理
- JSON 消息协议与监听器分发
- 消息发送
## 2. 设计思路
- 统一 JSON 消息协议(`type` + `content`),通过 `type` 分发到对应监听器,降低业务耦合。
- 通过 `WebSocketSessionManager` 统一管理会话,支持按用户类型/用户编号/会话 ID 进行推送。
- 通过本地发送器支持单机消息推送。
- 与安全体系结合,在握手阶段写入 `LoginUser`,便于后续鉴权与定向发送。
## 3. 功能模块
### 3.1 自动装配
`RdmsWebSocketAutoConfiguration` 会完成:
- 注册 WebSocket 路径与处理器
- 注册握手拦截器(默认 `LoginUserHandshakeInterceptor`
- 注册 `WebSocketSessionManager`
- 放行 WebSocket 路径的安全校验
- 注册消息发送器
### 3.2 JSON 消息协议
内置统一消息结构:
```json
{
"type": "notice",
"content": "{...}"
}
```
处理流程:
- `JsonWebSocketMessageHandler` 解析 JSON
- 根据 `type` 选择对应 `WebSocketMessageListener`
-`content` 反序列化为监听器泛型类型并交给业务处理
内置 `ping/pong`:收到 `ping` 会直接返回 `pong`
### 3.3 会话管理
`WebSocketSessionManager` 支持:
-`sessionId` 获取会话
-`userType` 获取会话列表
-`userType + userId` 获取会话列表
用于定向推送和广播推送。
### 3.4 消息发送
统一使用 `WebSocketMessageSender` 发送消息:
- 按用户推送
- 按用户类型广播
- 按会话 ID 推送
消息会被封装为统一的 `JsonWebSocketMessage` 后发送。
### 3.5 登录用户绑定
握手阶段会读取当前登录用户并写入 WebSocket Session
- 需要前端通过 `?token={token}` 形式携带令牌
- `LoginUserHandshakeInterceptor` 会将 `LoginUser` 写入 Session
- `WebSocketFrameworkUtils` 可获取 `userId/userType`
## 4. 开发人员上手
### 4.1 引入依赖
```xml
<dependency>
<groupId>com.njcn</groupId>
<artifactId>rdms-spring-boot-starter-websocket</artifactId>
</dependency>
```
### 4.2 基础配置
```yaml
rdms:
websocket:
enable: true
path: /ws
```
说明:
- `path` 默认 `/ws`
- `enable` 默认 `true`
### 4.3 连接方式
前端连接示例:
```
ws://{host}:{port}/ws?token={token}
```
### 4.4 编写消息监听器
```java
@Component
public class NoticeMessageListener implements WebSocketMessageListener<NoticeDTO> {
@Override
public void onMessage(WebSocketSession session, NoticeDTO message) {
// 处理消息
}
@Override
public String getType() {
return "notice";
}
}
```
### 4.5 服务端推送
```java
@Resource
private WebSocketMessageSender webSocketMessageSender;
public void push(Long userId, NoticeDTO notice) {
webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, "notice", notice);
}
```

View File

@@ -1,143 +0,0 @@
# rdms-gateway
## 1. 模块定位
`rdms-gateway` 是 API 服务网关模块,基于 Spring Cloud GatewayWebFlux实现负责统一入口的路由转发、鉴权透传、跨域处理、访问日志、灰度/标签负载与全局异常处理。
## 2. 设计思路
- 通过网关统一处理横切能力,业务服务只关注业务逻辑。
- 鉴权只做“解析 + 透传”,是否需要登录交给后端服务判定。
- 灰度/标签负载通过自定义 `grayLb://` 方案实现。
- 访问日志默认打印到日志,后续可扩展为落库。
## 3. 功能模块
### 3.1 路由与文档聚合
- 路由配置集中在 `application.yaml``spring.cloud.gateway.server.webflux.routes`
- Knife4j Gateway 负责聚合各服务的 OpenAPI 文档。
### 3.2 Token 鉴权透传
`TokenAuthenticationFilter` 负责:
-`Authorization` 解析 Token
- 远程校验 Token 有效性
- 校验通过后将 `login-user` 头透传给后端
- 不阻断请求,是否需要登录由后端服务决定
### 3.3 跨域处理
`CorsFilter` 统一放行跨域请求,并处理 OPTIONS 预检请求。
### 3.4 访问日志
`AccessLogFilter` 记录:
- 请求路径、方法、参数、Body、Headers
- 响应体、响应码
- 耗时、用户信息
当前实现是打印到日志(控制台/文件),可扩展为落库。
### 3.5 灰度/标签负载
灰度发布Gray/Canary是指新旧版本并行运行只让一小部分请求先进入新版本验证稳定后再逐步扩大流量最终全量切换。
在本项目中,灰度不是按“百分比随机分流”,而是**按请求头定向路由**
- 请求头 `version` 用于匹配实例 `metadata.version`
- 请求头 `tag` 用于匹配实例 `metadata.tag`
- 如果匹配不到目标实例,会回退到全量实例
- 最终按 Nacos 权重随机选择实例
`grayLb://` + `GrayLoadBalancer` 规则:
- header `version` 匹配实例 metadata `version`
- header `tag` 匹配实例 metadata `tag`
- 无匹配时回落到全量实例
注意:
- 不带 `tag` 的请求会优先过滤掉带 `tag` 的实例,避免测试/灰度实例被默认流量命中
- 灰度发布依赖请求头控制路由范围,需要由网关或内部调用方注入 `version/tag`,避免外部随意绕路
### 3.6 全局异常
`GlobalExceptionHandler` 将异常统一翻译为 `CommonResult` 返回。
### 3.7 Jackson 一致性
`GatewayJacksonAutoConfiguration` 统一 JSON 序列化策略时间戳、Long -> Number 等)。
## 4. 开发人员上手
### 4.1 路由配置示例
```yaml
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: system-admin-api
uri: grayLb://rdms-system-server
predicates:
- Path=/admin-api/system/**
filters:
- RewritePath=/admin-api/system/v3/api-docs, /v3/api-docs
```
### 4.2 鉴权透传
前端携带:
```
Authorization: Bearer <token>
```
网关校验成功后,会透传 `login-user` 给后端服务。
### 4.3 灰度/标签路由
请求头示例:
```
version: 1.0.0
tag: dev
```
服务实例需要在注册中心 metadata 中配置对应 `version/tag`
### 4.4 WebSocket 路由
```yaml
- id: system-websocket
uri: grayLb://rdms-system-server
predicates:
- Path=/system/ws/**
```
### 4.5 文档聚合示例
```yaml
knife4j:
gateway:
enabled: true
routes:
- name: system-server
service-name: rdms-system-server
url: /admin-api/system/v3/api-docs
```
## 5. 关键类索引
- 入口:`src/main/java/com/njcn/rdms/gateway/GatewayServerApplication.java`
- 路由配置:`src/main/resources/application.yaml`
- 鉴权:`src/main/java/com/njcn/rdms/gateway/filter/security/TokenAuthenticationFilter.java`
- 灰度:`src/main/java/com/njcn/rdms/gateway/filter/grey/GrayLoadBalancer.java`
- 访问日志:`src/main/java/com/njcn/rdms/gateway/filter/logging/AccessLogFilter.java`
- 跨域:`src/main/java/com/njcn/rdms/gateway/filter/cors/CorsFilter.java`
- 全局异常:`src/main/java/com/njcn/rdms/gateway/handler/GlobalExceptionHandler.java`

View File

@@ -1,57 +0,0 @@
# rdms-system-api
**概述**
本模块定义了 System系统模块对其他模块开放的公共 RPC API 面。它是一个纯 API 的 jar 包:包含 Feign 客户端接口、请求/响应 DTO以及枚举/常量。不包含 controller 或 service 的实现。
**这里包含什么**
- 位于 `com.njcn.rdms.module.system.api.*` 下的 Feign 客户端接口
- 位于 `com.njcn.rdms.module.system.api.*.dto` 下的 DTO
- 位于 `com.njcn.rdms.module.system.enums.*` 下的系统枚举和常量
**API 分组**
- `config``ConfigApi`,用于根据 key 读取配置值
- `dept``DeptApi``PostApi`,用于部门与岗位数据及校验
- `dict``DictDataApi`,用于字典数据校验(继承 `DictDataCommonApi`
- `file``FileApi`,用于文件创建与预签名 URL
- `logger``LoginLogApi``OperateLogApi`(继承 `OperateLogCommonApi`
- `notify``NotifyMessageSendApi`,用于站内消息发送
- `permission``PermissionApi`(继承 `PermissionCommonApi`)以及 `RoleApi`
- `user``AdminUserApi`,用于管理员用户查询与校验
- `websocket``WebSocketSenderApi`,用于推送 WebSocket 消息
**关键常量**
- `ApiConstants.NAME = "system-server"`(必须与 system 服务的 `spring.application.name` 保持一致)
- `ApiConstants.PREFIX = "/rpc-api/system"`
- `ApiConstants.VERSION = "1.0.0"`
**用法**
1. 在调用方模块中添加依赖。
```xml
<dependency>
<groupId>com.njcn</groupId>
<artifactId>rdms-system-api</artifactId>
</dependency>
```
2. 在调用方服务中启用 Feign 客户端扫描。
```java
@EnableFeignClients(basePackages = "com.njcn.rdms.module.system.api")
```
3. 注入并调用 API 接口。
```java
@Resource
private AdminUserApi adminUserApi;
public AdminUserRespDTO loadUser(Long id) {
return adminUserApi.getUser(id).getCheckedData();
}
```
**备注**
- 所有方法都返回 `CommonResult<T>`。按需使用 `getCheckedData()``checkError()`
- 有些 API 提供了默认的辅助方法(例如 `FileApi.createFile(...)``WebSocketSenderApi.send(...)`)。
- `DictDataApi``OperateLogApi``PermissionApi` 继承了来自 `rdms-framework` 的通用 RPC 接口。

View File

@@ -1,45 +0,0 @@
### 请求 /login 接口 => 成功
POST {{baseUrl}}/system/auth/login
Content-Type: application/json
tag: Yunai.local
{
"username": "admin",
"password": "admin123",
"uuid": "3acd87a09a4f48fb9118333780e94883",
"code": "1024"
}
### 请求 /login 接口【加密 AES】 => 成功
POST {{baseUrl}}/system/auth/login
Content-Type: application/json
tag: Yunai.local
X-API-ENCRYPT: true
WvSX9MOrenyGfBhEM0g1/hHgq8ocktMZ9OwAJ6MOG5FUrzYF/rG5JF1eMptQM1wT73VgDS05l/37WeRtad+JrqChAul/sR/SdOsUKqjBhvvQx1JVhzxr6s8uUP67aKTSZ6Psv7O32ELxXrzSaQvG5CInzz3w6sLtbNNLd1kXe6Q=
### 请求 /login 接口【加密 RSA】 => 成功
POST {{baseUrl}}/system/auth/login
Content-Type: application/json
tag: Yunai.local
X-API-ENCRYPT: true
e7QZTork9ZV5CmgZvSd+cHZk3xdUxKtowLM02kOha+gxHK2H/daU8nVBYS3+bwuDRy5abf+Pz1QJJGVAEd27wwrXBmupOOA/bhpuzzDwcRuJRD+z+YgiNoEXFDRHERxPYlPqAe9zAHtihD0ceub1AjybQsEsROew4C3Q602XYW0=
### 请求 /login 接口 => 成功(无验证码)
POST {{baseUrl}}/system/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "admin123"
}
### 请求 /get-permission-info 接口 => 成功
GET {{baseUrl}}/system/auth/get-permission-info
Authorization: Bearer {{token}}
### 请求 /list-menus 接口 => 成功
GET {{baseUrl}}/system/list-menus
Authorization: Bearer {{token}}
#Authorization: Bearer a6aa7714a2e44c95aaa8a2c5adc2a67a

View File

@@ -19,7 +19,7 @@ public class AuthRegisterReqVO extends CaptchaVerificationReqVO {
@Size(min = 4, max = 30, message = "用户账号长度为 4-30 个字符")
private String username;
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿")
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "awen")
@NotBlank(message = "用户昵称不能为空")
@Size(max = 30, message = "用户昵称长度不能超过 30 个字符")
private String nickname;

View File

@@ -1,3 +0,0 @@
### 请求 /menu/list 接口 => 成功
GET {{baseUrl}}/system/dict-data/list-all-simple
Authorization: Bearer {{token}}

View File

@@ -1,42 +0,0 @@
### 请求 /system/file-config/create 接口 => 成功
POST {{baseUrl}}/system/file-config/create
Content-Type: application/json
Authorization: Bearer {{token}}
{
"name": "S3 - 七牛云",
"remark": "",
"storage": 20,
"config": {
"accessKey": "b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8",
"accessSecret": "kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP",
"bucket": "ruoyi-vue-pro",
"endpoint": "s3-cn-south-1.qiniucs.com",
"domain": "http://test.rdms.iocoder.cn",
"region": "oss-cn-beijing"
}
}
### 请求 /system/file-config/update 接口 => 成功
PUT {{baseUrl}}/system/file-config/update
Content-Type: application/json
Authorization: Bearer {{token}}
{
"id": 2,
"name": "S3 - 七牛云",
"remark": "",
"config": {
"accessKey": "b7yvuhBSAGjmtPhMFcn9iMOxUOY_I06cA_p0ZUx8",
"accessSecret": "kXM1l5ia1RvSX3QaOEcwI3RLz3Y2rmNszWonKZtP",
"bucket": "ruoyi-vue-pro",
"endpoint": "s3-cn-south-1.qiniucs.com",
"domain": "http://test.rdms.iocoder.cn",
"region": "oss-cn-beijing"
}
}
### 请求 /system/file-config/test 接口 => 成功
GET {{baseUrl}}/system/file-config/test?id=2
Content-Type: application/json
Authorization: Bearer {{token}}

View File

@@ -1,3 +0,0 @@
### 获得地区树
GET {{baseUrl}}/system/area/tree
Authorization: Bearer {{token}}

View File

@@ -1,3 +0,0 @@
### 请求 /system/operate-log/page 接口 => 成功
GET {{baseUrl}}/system/operate-log/page
Authorization: Bearer {{token}}

View File

@@ -29,7 +29,7 @@ public class OperateLogRespVO implements VO {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@Trans(type = TransType.SIMPLE, target = AdminUserDO.class, fields = "nickname", ref = "userName")
private Long userId;
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿")
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "awen")
@ExcelProperty("操作人")
private String userName;

View File

@@ -25,7 +25,7 @@ public class NotifyMessageRespVO {
@Schema(description = "模板编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "test_01")
private String templateCode;
@Schema(description = "模版发送人名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿")
@Schema(description = "模版发送人名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "awen")
private String templateNickname;
@Schema(description = "模版内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "测试内容")

View File

@@ -1,22 +0,0 @@
### 请求 /login 接口 => 成功
POST {{baseUrl}}/system/oauth2-client/create
Content-Type: application/json
Authorization: Bearer {{token}}
{
"id": "1",
"secret": "admin123",
"name": "灿能源码",
"logo": "https://www.iocoder.cn/images/favicon.ico",
"description": "我是描述",
"status": 0,
"accessTokenValiditySeconds": 180,
"refreshTokenValiditySeconds": 8640,
"redirectUris": ["https://www.iocoder.cn"],
"autoApprove": true,
"authorizedGrantTypes": ["password"],
"scopes": ["user_info"],
"authorities": ["system:user:query"],
"resource_ids": ["1024"],
"additionalInformation": "{}"
}

View File

@@ -1,53 +0,0 @@
### 请求 /system/oauth2/authorize 接口 => 成功
GET {{baseUrl}}/system/oauth2/authorize?clientId=default
Authorization: Bearer {{token}}
### 请求 /system/oauth2/authorize + token 接口 => 成功
POST {{baseUrl}}/system/oauth2/authorize
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer {{token}}
response_type=token&client_id=default&scope={"user.read": true}&redirect_uri=https://www.iocoder.cn&auto_approve=true
### 请求 /system/oauth2/authorize + code 接口 => 成功
POST {{baseUrl}}/system/oauth2/authorize
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer {{token}}
response_type=code&client_id=default&scope={"user.read": true}&redirect_uri=https://www.iocoder.cn&auto_approve=false
### 请求 /system/oauth2/token + code 接口 => 成功
POST {{baseUrl}}/system/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
grant_type=authorization_code&redirect_uri=https://www.iocoder.cn&code=189956c07a174588a97157eabef2f93a
### 请求 /system/oauth2/token + password 接口 => 成功
POST {{baseUrl}}/system/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
grant_type=password&username=admin&password=admin123&scope=user.read
### 请求 /system/oauth2/token + client_credentials 接口 => 成功
POST {{baseUrl}}/system/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
grant_type=client_credentials&scope=user.read
### 请求 /system/oauth2/token + refresh_token 接口 => 成功
POST {{baseUrl}}/system/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
grant_type=refresh_token&refresh_token=00895465d6994f72a9d926ceeed0f588
### 请求 /system/oauth2/token + DELETE 接口 => 成功
DELETE {{baseUrl}}/system/oauth2/token?token=ca8a188f464441d6949c51493a2b7596
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==
### 请求 /system/oauth2/check-token 接口 => 成功
POST {{baseUrl}}/system/oauth2/check-token?token=620d307c5b4148df8a98dd6c6c547106
Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==

View File

@@ -1,12 +0,0 @@
### 请求 /system/oauth2/user/get 接口 => 成功
GET {{baseUrl}}/system/oauth2/user/get
Authorization: Bearer 47f9c74ec11041f193b777ebb95c3b0d
### 请求 /system/oauth2/user/update 接口 => 成功
PUT {{baseUrl}}/system/oauth2/user/update
Content-Type: application/json
Authorization: Bearer 47f9c74ec11041f193b777ebb95c3b0d
{
"nickname": "灿能源码"
}

View File

@@ -16,7 +16,7 @@ public class OAuth2UserInfoRespVO {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long id;
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿")
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "awen")
private String username;
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "灿能")

View File

@@ -15,7 +15,7 @@ import jakarta.validation.constraints.Size;
@AllArgsConstructor
public class OAuth2UserUpdateReqVO {
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿")
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "awen")
@Size(max = 30, message = "用户昵称长度不能超过 30 个字符")
private String nickname;

View File

@@ -1,3 +0,0 @@
### 请求 /menu/list 接口 => 成功
GET {{baseUrl}}/system/menu/list
Authorization: Bearer {{token}}

View File

@@ -1,37 +0,0 @@
### /role/create 成功
POST {{baseUrl}}/system/role/create
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "测试角色",
"code": "test",
"sort": 0
}
### /role/update 成功
POST {{baseUrl}}/system/role/update
Authorization: Bearer {{token}}
Content-Type: application/json
{
"id": 100,
"name": "测试角色",
"code": "test",
"sort": 10
}
### /resource/delete 成功
POST {{baseUrl}}/system/role/delete
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer {{token}}
roleId=14
### /role/get 成功
GET {{baseUrl}}/system/role/get?id=100
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer {{token}}
### /role/page 成功
GET {{baseUrl}}/system/role/page?pageNo=1&pageSize=10
Authorization: Bearer {{token}}

View File

@@ -1,4 +0,0 @@
### 请求 /system/redis/get-monitor-info 接口 => 成功
GET {{baseUrl}}/system/redis/get-monitor-info
Authorization: Bearer {{token}}

View File

@@ -1,8 +0,0 @@
### 请求 /system/user/page 接口 => 没有权限
GET {{baseUrl}}/system/user/page?pageNo=1&pageSize=10
Authorization: Bearer {{token}}
#Authorization: Bearer test100
### 请求 /system/user/page 接口(测试访问别的租户)
GET {{baseUrl}}/system/user/page?pageNo=1&pageSize=10
Authorization: Bearer {{token}}

View File

@@ -1,3 +0,0 @@
### 请求 /system/user/profile/get 接口 => 没有权限
GET {{baseUrl}}/system/user/profile/get
Authorization: Bearer {{token}}

View File

@@ -19,7 +19,7 @@ public class UserProfileRespVO {
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "rdms")
private String username;
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿")
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "awen")
private String nickname;
@Schema(description = "用户邮箱", example = "rdms@iocoder.cn")

View File

@@ -13,7 +13,7 @@ import org.hibernate.validator.constraints.URL;
@Data
public class UserProfileUpdateReqVO {
@Schema(description = "用户昵称", example = "芋艿")
@Schema(description = "用户昵称", example = "awen")
@Size(max = 30, message = "用户昵称长度不能超过 30 个字符")
private String nickname;

View File

@@ -24,7 +24,7 @@ public class UserRespVO{
@ExcelProperty("用户名称")
private String username;
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿")
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "awen")
@ExcelProperty("用户昵称")
private String nickname;

View File

@@ -28,7 +28,7 @@ public class UserSaveReqVO {
@DiffLogField(name = "用户账号")
private String username;
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿")
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "awen")
@Size(max = 30, message = "用户昵称长度不能超过30个字符")
@DiffLogField(name = "用户昵称")
private String nickname;