feat: first commit
This commit is contained in:
83
.gitea/demo.yaml
Normal file
83
.gitea/demo.yaml
Normal file
@@ -0,0 +1,83 @@
|
||||
name: CodeChecker 变更检测
|
||||
run-name: ${{ gitea.actor }}的CodeChecker变更检测
|
||||
|
||||
on:
|
||||
push:
|
||||
# branches:
|
||||
# - check-code-dev-test
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# CodeChecker 私库坐标:com.codechecker:code-checker:1.0.0
|
||||
CODE_CHECKER_VERSION: "1.0.0"
|
||||
CODE_CHECKER_REPO_URL: "http://192.168.3.25:18081/nexus/repository/maven-releases"
|
||||
|
||||
jobs:
|
||||
code-check:
|
||||
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
||||
runs-on: jdk11
|
||||
steps:
|
||||
# 浅克隆:CodeChecker 仅需 HEAD 与 HEAD~1,避免全量拉取
|
||||
# 指定 --branch 确保非默认分支推送时也能正确检出 gitea.sha
|
||||
- name: 检出代码
|
||||
run: |
|
||||
git config --global http.sslVerify false
|
||||
git clone --depth 2 --single-branch --branch "${{ gitea.ref_name }}" \
|
||||
"https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
|
||||
# 将 commit 绑定回本地分支,避免 detached HEAD 导致分支识别为「未知」
|
||||
git checkout -B "${{ gitea.ref_name }}" "${{ gitea.sha }}"
|
||||
# git config --global http.sslVerify false
|
||||
# git clone --depth 2 --single-branch --branch "${{ gitea.ref_name }}" \
|
||||
# "https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
|
||||
# git checkout ${{ gitea.sha }}
|
||||
|
||||
- name: 检查配置文件
|
||||
run: |
|
||||
if [ ! -f .gitea/config/code-check-config.yaml ]; then
|
||||
echo "错误: 缺少 .gitea/config/code-check-config.yaml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 从 Nexus 私库下载 CodeChecker
|
||||
run: |
|
||||
GROUP_PATH="com/codechecker/code-checker"
|
||||
JAR_NAME="code-checker-${CODE_CHECKER_VERSION}.jar"
|
||||
JAR_URL="${CODE_CHECKER_REPO_URL}/${GROUP_PATH}/${CODE_CHECKER_VERSION}/${JAR_NAME}"
|
||||
JAR_PATH="/tmp/${JAR_NAME}"
|
||||
|
||||
echo "下载: ${JAR_URL}"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL -o "${JAR_PATH}" "${JAR_URL}"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O "${JAR_PATH}" "${JAR_URL}"
|
||||
else
|
||||
echo "错误: Runner 缺少 curl 或 wget,无法从 Nexus 下载 jar"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -s "${JAR_PATH}" ]; then
|
||||
echo "错误: 下载失败或文件为空: ${JAR_PATH}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ls -lh "${JAR_PATH}"
|
||||
|
||||
- name: 验证 JDK
|
||||
run: |
|
||||
echo "Java: $(java -version 2>&1 | head -1)"
|
||||
|
||||
- name: 执行 CodeChecker 变更检测
|
||||
run: |
|
||||
OLD_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "")
|
||||
if [ -z "$OLD_SHA" ]; then
|
||||
echo "首次提交,跳过变更检测"
|
||||
exit 0
|
||||
fi
|
||||
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
||||
java -jar "/tmp/code-checker-${CODE_CHECKER_VERSION}.jar" \
|
||||
--config .gitea/config/code-check-config.yaml \
|
||||
--repo-root . \
|
||||
--old-sha "$OLD_SHA" \
|
||||
--new-sha "$(git rev-parse HEAD)" \
|
||||
--modifier "${{ gitea.actor }}" \
|
||||
--modify-time "$COMMIT_TIME"
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
*.class
|
||||
*.log
|
||||
.DS_Store
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
13
.idea/compiler.xml
generated
Normal file
13
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<annotationProcessing>
|
||||
<profile name="Maven default annotation processors profile" enabled="true">
|
||||
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||
<outputRelativeToContentRoot value="true" />
|
||||
<module name="redis-schema-checker" />
|
||||
</profile>
|
||||
</annotationProcessing>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/encodings.xml
generated
Normal file
9
.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding">
|
||||
<file url="file://$PROJECT_DIR$/redis-schema-checker/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/redis-schema-checker/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||
</component>
|
||||
</project>
|
||||
30
.idea/jarRepositories.xml
generated
Normal file
30
.idea/jarRepositories.xml
generated
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RemoteRepositoriesConfiguration">
|
||||
<remote-repository>
|
||||
<option name="id" value="fantaibao-fantaibao-maven-repository" />
|
||||
<option name="name" value="ftb private nexus" />
|
||||
<option name="url" value="http://192.168.3.25:18081/nexus/repository/maven/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="central" />
|
||||
<option name="name" value="Maven Central repository" />
|
||||
<option name="url" value="https://repo1.maven.org/maven2" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="jboss.community" />
|
||||
<option name="name" value="JBoss Community repository" />
|
||||
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="getui-nexus" />
|
||||
<option name="name" value="getui releases" />
|
||||
<option name="url" value="http://mvn.gt.igexin.com/nexus/content/repositories/releases/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="central" />
|
||||
<option name="name" value="Central Repository" />
|
||||
<option name="url" value="https://maven.aliyun.com/repository/public" />
|
||||
</remote-repository>
|
||||
</component>
|
||||
</project>
|
||||
14
.idea/misc.xml
generated
Normal file
14
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="MavenProjectsManager">
|
||||
<option name="originalFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/pom.xml" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/redisCheck.iml
generated
Normal file
9
.idea/redisCheck.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
204
docs/CI集成说明.md
Normal file
204
docs/CI集成说明.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Redis 序列化结构检测 — CI 集成说明
|
||||
|
||||
---
|
||||
|
||||
## 1. 集成概览
|
||||
|
||||
```text
|
||||
开发者 push 代码
|
||||
↓
|
||||
Gitea Actions 触发
|
||||
↓
|
||||
浅克隆业务仓库(depth=2)
|
||||
↓
|
||||
从 Nexus 下载 redis-schema-checker.jar
|
||||
↓
|
||||
java -jar 执行(对比 HEAD~1 与 HEAD)
|
||||
↓
|
||||
有 P0/P1 变更 → 企微通知
|
||||
↓
|
||||
mode=block 且含 P0/P1/P2 任一变更 → exit 1(流水线失败)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 前置条件
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| Gitea Runner | 标签 `jdk11`,已安装 Java 11 |
|
||||
| Nexus 私库 | 可访问 `http://192.168.3.25:18081/nexus/repository/maven-releases` |
|
||||
| 工具 JAR | `com.codechecker:redis-schema-checker:1.0.0` 已发布 |
|
||||
| 仓库 Secret | `WECOM_ROBOT_WEBHOOK` 已配置 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 业务仓库文件清单
|
||||
|
||||
在 `jnpf-java-cloud` 中新增:
|
||||
|
||||
```text
|
||||
jnpf-java-cloud/
|
||||
├── .gitea/
|
||||
│ ├── workflows/
|
||||
│ │ └── redis-schema-check.yaml # 流水线
|
||||
│ └── config/
|
||||
│ └── redis-schema-check-config.yaml # 检测配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 流水线模板
|
||||
|
||||
```yaml
|
||||
name: Redis序列化结构检查
|
||||
run-name: ${{ gitea.actor }}的Redis结构检查
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REDIS_SCHEMA_CHECKER_VERSION: "1.0.0"
|
||||
REDIS_SCHEMA_CHECKER_REPO_URL: "http://192.168.3.25:18081/nexus/repository/maven-releases"
|
||||
|
||||
jobs:
|
||||
redis-schema-check:
|
||||
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
||||
runs-on: jdk11
|
||||
steps:
|
||||
- name: 检出代码
|
||||
run: |
|
||||
git config --global http.sslVerify false
|
||||
git clone --depth 2 --single-branch --branch "${{ gitea.ref_name }}" \
|
||||
"https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
|
||||
git checkout -B "${{ gitea.ref_name }}" "${{ gitea.sha }}"
|
||||
|
||||
- name: 检查配置文件
|
||||
run: |
|
||||
if [ ! -f .gitea/config/redis-schema-check-config.yaml ]; then
|
||||
echo "错误: 缺少 .gitea/config/redis-schema-check-config.yaml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 从 Nexus 下载检测工具
|
||||
run: |
|
||||
GROUP_PATH="com/codechecker/redis-schema-checker"
|
||||
JAR_NAME="redis-schema-checker-${REDIS_SCHEMA_CHECKER_VERSION}.jar"
|
||||
JAR_URL="${REDIS_SCHEMA_CHECKER_REPO_URL}/${GROUP_PATH}/${REDIS_SCHEMA_CHECKER_VERSION}/${JAR_NAME}"
|
||||
JAR_PATH="/tmp/${JAR_NAME}"
|
||||
|
||||
echo "下载: ${JAR_URL}"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL -o "${JAR_PATH}" "${JAR_URL}"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O "${JAR_PATH}" "${JAR_URL}"
|
||||
else
|
||||
echo "错误: Runner 缺少 curl 或 wget"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -s "${JAR_PATH}" ]; then
|
||||
echo "错误: 下载失败或文件为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ls -lh "${JAR_PATH}"
|
||||
|
||||
- name: 验证 JDK
|
||||
run: java -version
|
||||
|
||||
- name: 执行 Redis 结构检测
|
||||
env:
|
||||
WECOM_ROBOT_WEBHOOK: ${{ secrets.WECOM_ROBOT_WEBHOOK }}
|
||||
run: |
|
||||
OLD_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "")
|
||||
if [ -z "$OLD_SHA" ]; then
|
||||
echo "首次提交,跳过检测"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
||||
|
||||
java -jar "/tmp/redis-schema-checker-${REDIS_SCHEMA_CHECKER_VERSION}.jar" \
|
||||
--config .gitea/config/redis-schema-check-config.yaml \
|
||||
--repo-root . \
|
||||
--old-sha "$OLD_SHA" \
|
||||
--new-sha "$(git rev-parse HEAD)" \
|
||||
--branch "${{ gitea.ref_name }}" \
|
||||
--modifier "${{ gitea.actor }}" \
|
||||
--modify-time "$COMMIT_TIME"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 与现有流水线的关系
|
||||
|
||||
| 流水线 | 作用 | 关系 |
|
||||
|--------|------|------|
|
||||
| `demo.yaml` (AI代码质量分析) | AI Code Review | 并行,互不影响 |
|
||||
| `code-check` (CodeChecker) | 通用变更检测 | **同模式**,可并列执行 |
|
||||
| `redis-schema-check` | Redis 结构检测 | 新增 |
|
||||
|
||||
建议:三个 job 独立并行,各自 exit code 独立。
|
||||
|
||||
---
|
||||
|
||||
## 6. 工具发布流程(redisCheck 仓库)
|
||||
|
||||
```bash
|
||||
# 在 redisCheck 仓库
|
||||
mvn clean package -DskipTests
|
||||
|
||||
# 发布到 Nexus(需配置 settings.xml)
|
||||
mvn deploy -DskipTests
|
||||
```
|
||||
|
||||
发布产物:
|
||||
|
||||
```text
|
||||
com/codechecker/redis-schema-checker/1.0.0/
|
||||
├── redis-schema-checker-1.0.0.jar # 可执行 fat-jar
|
||||
└── redis-schema-checker-1.0.0.pom
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 退出码约定
|
||||
|
||||
| 退出码 | 含义 | 流水线表现 |
|
||||
|--------|------|------------|
|
||||
| 0 | 通过(含 notify 模式下的告警) | 绿色 |
|
||||
| 1 | 阻断(block 模式 + P0/P1/P2 任一变更) | 红色 |
|
||||
| 2 | 执行错误(配置缺失、jar 异常等) | 红色 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 故障排查
|
||||
|
||||
| 现象 | 可能原因 | 处理 |
|
||||
|------|----------|------|
|
||||
| 首次提交跳过 | 无 HEAD~1 | 正常行为 |
|
||||
| 下载 jar 失败 | Nexus 地址/版本错误 | 检查 env 变量 |
|
||||
| 未收到企微 | Secret 未配 / notify.enabled=false | 检查配置 |
|
||||
| 大量误报 | 锁/计数器未过滤 | 补充 ignore.key_patterns |
|
||||
| 漏报 | 写入模式未覆盖 | 启用 W04/W05 或补充 manual_mappings |
|
||||
| 类型展开不完整 | 类型在依赖 jar 中 | 补充 manual_mappings.value_type |
|
||||
|
||||
---
|
||||
|
||||
## 9. 本地调试
|
||||
|
||||
```bash
|
||||
# 在 jnpf-java-cloud 根目录
|
||||
java -jar /path/to/redis-schema-checker-1.0.0.jar \
|
||||
--config .gitea/config/redis-schema-check-config.yaml \
|
||||
--repo-root . \
|
||||
--old-sha HEAD~1 \
|
||||
--new-sha HEAD \
|
||||
--branch $(git branch --show-current) \
|
||||
--modifier "$(git log -1 --format=%an)" \
|
||||
--modify-time "$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')"
|
||||
```
|
||||
|
||||
可加 `--dry-run`(Phase 2 实现)仅输出报告不发企微。
|
||||
665
docs/实施方案.md
Normal file
665
docs/实施方案.md
Normal file
@@ -0,0 +1,665 @@
|
||||
# Redis 序列化结构变更检测 — 实施方案
|
||||
|
||||
> 版本:v0.1
|
||||
> 日期:2026-07-13
|
||||
> 技术栈:Java 11 + Maven + JavaParser
|
||||
> 目标仓库:`redisCheck`(工具) / `jnpf-java-cloud`(被检测业务仓库)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
业务仓库 `jnpf-java-cloud` 是多模块 Java 微服务项目,广泛使用 Redis 缓存业务对象。当开发者修改 VO/DTO 字段、调整序列化包装结构、或变更 Redis 写入逻辑时,线上 Redis 中可能仍存在旧结构数据,导致:
|
||||
|
||||
- 反序列化失败
|
||||
- 字段读取为空
|
||||
- 新旧结构并存引发隐蔽 Bug
|
||||
|
||||
典型变更示例(租户库信息缓存):
|
||||
|
||||
**变更前(逻辑上等价于直接缓存 `TenantVO`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"dbName": "",
|
||||
"linkList": [{ "id": "", "serviceName": "", "...": "..." }]
|
||||
}
|
||||
```
|
||||
|
||||
**变更后(`TenantDbContentCacheHelper` 包装为 `CacheEnvelope`):**
|
||||
|
||||
```json
|
||||
{
|
||||
"vo": {
|
||||
"dbName": "",
|
||||
"linkList": [{ "id": "", "serviceName": "", "...": "..." }]
|
||||
},
|
||||
"expiresAtMs": 0
|
||||
}
|
||||
```
|
||||
|
||||
该变更在业务代码中已有真实对应:
|
||||
|
||||
- Key:`tenant:db:content:{encode}`
|
||||
- 写入类:`jnpf.util.TenantDbContentCacheHelper#cacheSuccess`
|
||||
- Value 类型:`CacheEnvelope { TenantVO vo; Long expiresAtMs; }`
|
||||
|
||||
### 1.2 目标
|
||||
|
||||
在 **push 时** 自动执行检测:
|
||||
|
||||
1. 对比两次提交(`old-sha` vs `new-sha`)之间的代码差异
|
||||
2. 识别 Redis value 序列化结构是否发生变更
|
||||
3. 生成结构化变更报告
|
||||
4. 通过企微机器人发送通知
|
||||
5. 通过开关控制 **仅通知** 或 **阻断流水线**
|
||||
|
||||
### 1.3 非目标(第一版不做)
|
||||
|
||||
- 不连接真实 Redis 实例做运行时校验
|
||||
- 不扫描 Maven 依赖 jar 中的类(仅分析业务仓库源码)
|
||||
- 不做全量历史扫描(仅 diff 触发)
|
||||
- 不替代 CodeChecker / AI Code Review 等现有能力
|
||||
|
||||
---
|
||||
|
||||
## 2. 业务仓库调研结论
|
||||
|
||||
基于对 `jnpf-java-cloud` 的静态检索,得出以下判断(作为方案输入,不再向业务方重复确认):
|
||||
|
||||
### 2.1 Redis 序列化方式
|
||||
|
||||
| 类型 | 出现频率 | 第一版策略 |
|
||||
|------|----------|------------|
|
||||
| `JSON.toJSONString(obj)` / `JSONObject.toJSONString(obj)` | 高 | **重点支持** |
|
||||
| `JsonUtil.getObjectToString(obj)` | 高 | **重点支持**(按 Fastjson/Jackson 默认字段规则推断) |
|
||||
| `RedisTemplate.opsForValue().set(key, obj)` 直接写对象 | 中 | 第二阶段支持 |
|
||||
| `RedisTemplate.opsForHash().put(key, field, obj)` | 中 | 第二阶段支持 |
|
||||
| `StringRedisTemplate` 写 JSON 字符串 | 高 | **重点支持** |
|
||||
| 锁 / 计数器 / token 简单值 | 高 | **默认忽略** |
|
||||
|
||||
### 2.2 Key 与实体映射
|
||||
|
||||
- 不存在统一的「Key → 类型」注册中心
|
||||
- 存在大量 `static final String`、`String.format(...)`、`buildCacheKey(...)` 等模式
|
||||
- 第一版采用:**写入点静态推断 + 可选 YAML 人工补充映射**
|
||||
|
||||
### 2.3 多模块特征
|
||||
|
||||
- 根 `pom.xml` 下约 30+ 顶层模块、200+ 子模块
|
||||
- Java 版本混用(8/9/10/11),工具统一使用 **JDK 11** 编译运行
|
||||
- 公共工具类 `RedisUtil`、`JsonUtil` 来自外部依赖,不在业务仓源码内 — 仅分析调用方,不深入依赖实现
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
### 3.1 交付形态
|
||||
|
||||
沿用现有 `code-checker` 模式(见 `redisCheck/.gitea/demo.yaml`):
|
||||
|
||||
```text
|
||||
redisCheck 仓库
|
||||
├── 开发 Java 分析工具
|
||||
├── mvn package 打 fat-jar
|
||||
├── 发布到 Nexus:com.codechecker:redis-schema-checker:{version}
|
||||
└── 提供默认配置模板
|
||||
|
||||
jnpf-java-cloud 仓库
|
||||
├── .gitea/workflows/redis-schema-check.yaml
|
||||
├── .gitea/config/redis-schema-check-config.yaml
|
||||
└── push 时下载 jar 并执行检测
|
||||
```
|
||||
|
||||
### 3.2 架构图
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Gitea["Gitea Push Pipeline"]
|
||||
A[push 事件] --> B[浅克隆 old/new 提交]
|
||||
B --> C[下载 redis-schema-checker.jar]
|
||||
C --> D[java -jar 执行检测]
|
||||
end
|
||||
|
||||
subgraph Checker["redis-schema-checker (JDK 11)"]
|
||||
D --> E[GitDiffScanner]
|
||||
E --> F[RedisWritePointDetector]
|
||||
F --> G[JavaSchemaExtractor]
|
||||
G --> H[SchemaDiffer]
|
||||
H --> I{有结构变更?}
|
||||
I -->|否| J[exit 0]
|
||||
I -->|是| K[ReportBuilder]
|
||||
K --> L[WeComNotifier]
|
||||
L --> M{mode=block 且含变更?}
|
||||
M -->|是| N[exit 1]
|
||||
M -->|否| J
|
||||
end
|
||||
```
|
||||
|
||||
### 3.3 核心设计原则
|
||||
|
||||
1. **纯静态分析**:基于 Java 源码 AST + 符号解析,不启动 Spring 容器
|
||||
2. **Diff 驱动**:只分析本次 push 变更涉及的文件及其关联类型
|
||||
3. **本仓限定**:类型解析仅在业务仓库 `src/main/java` 范围内
|
||||
4. **可配置**:忽略规则、严重级别、通知开关、阻断开关均可 YAML 配置
|
||||
5. **可演进**:第一版聚焦 JSON 字符串写入,后续扩展 Template 直写对象
|
||||
|
||||
---
|
||||
|
||||
## 4. 技术选型
|
||||
|
||||
| 组件 | 选型 | 版本建议 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| 语言 | Java | 11 | 与 CI Runner `jdk11` 对齐 |
|
||||
| 构建 | Maven | 3.8+ | 与现有私库发布流程一致 |
|
||||
| AST 解析 | JavaParser | 3.25.x | 完整 Java 语法树 |
|
||||
| 符号解析 | javaparser-symbol-solver-core | 3.25.x | 跨文件类型推断 |
|
||||
| 配置 | SnakeYAML | 2.x | 读取检测配置 |
|
||||
| HTTP 通知 | JDK HttpClient / OkHttp | 11 内置 / 4.x | 企微 Webhook |
|
||||
| 报告 | Jackson | 2.15.x | JSON/Markdown 报告序列化 |
|
||||
| 测试 | JUnit 5 | 5.10.x | 单元测试 + 夹具样本 |
|
||||
|
||||
**不采用** Spoon / Eclipse JDT 的原因:JavaParser 足够覆盖第一版需求,依赖更轻,CLI 启动更快。
|
||||
|
||||
**Lombok 处理策略**:第一版基于源码字段 + `@Data` 等注解推断序列化字段;对 `@Builder`、`@SuperBuilder` 等复杂场景标记为「低置信度」并降级为 P2 提示。后续可选集成 `lombok.ast` 或 delombok 预处理。
|
||||
|
||||
---
|
||||
|
||||
## 5. 工程结构(redisCheck 仓库)
|
||||
|
||||
```text
|
||||
redisCheck/
|
||||
├── pom.xml
|
||||
├── docs/
|
||||
│ ├── 实施方案.md # 本文档
|
||||
│ ├── 配置说明.md # YAML 配置项详解
|
||||
│ └── CI集成说明.md # 业务仓库接入步骤
|
||||
├── redis-schema-checker/
|
||||
│ ├── pom.xml
|
||||
│ └── src/
|
||||
│ ├── main/
|
||||
│ │ ├── resources/
|
||||
│ │ │ └── default-config.yaml # 内置默认配置(随 jar 发布)
|
||||
│ │ └── java/com/codechecker/redis/
|
||||
│ │ ├── cli/ # 命令行入口
|
||||
│ │ │ └── RedisSchemaCheckerMain.java
|
||||
│ │ ├── config/ # 配置模型
|
||||
│ │ │ ├── CheckerConfig.java
|
||||
│ │ │ └── ConfigLoader.java
|
||||
│ │ ├── git/ # Git 操作
|
||||
│ │ │ ├── GitDiffScanner.java
|
||||
│ │ │ └── GitException.java
|
||||
│ │ ├── analyze/ # 编排与工作树扫描
|
||||
│ │ │ ├── SchemaCheckAnalyzer.java
|
||||
│ │ │ ├── FileScanner.java
|
||||
│ │ │ └── GlobMatcher.java
|
||||
│ │ ├── detector/ # Redis 写入点检测
|
||||
│ │ │ ├── RedisWritePointDetector.java
|
||||
│ │ │ └── WritePoint.java
|
||||
│ │ ├── schema/ # Schema 提取
|
||||
│ │ │ ├── JavaSchemaExtractor.java
|
||||
│ │ │ ├── SourceIndex.java
|
||||
│ │ │ ├── TypeSchema.java
|
||||
│ │ │ ├── FieldSchema.java
|
||||
│ │ │ ├── JsonType.java
|
||||
│ │ │ └── AnnotationSupport.java
|
||||
│ │ ├── diff/ # 结构对比
|
||||
│ │ │ ├── SchemaDiffer.java
|
||||
│ │ │ ├── SchemaChange.java
|
||||
│ │ │ ├── ChangeType.java
|
||||
│ │ │ └── Severity.java
|
||||
│ │ ├── key/ # Key 推断
|
||||
│ │ │ └── RedisKeyResolver.java
|
||||
│ │ ├── report/ # 报告
|
||||
│ │ │ ├── ReportBuilder.java
|
||||
│ │ │ └── CheckReport.java
|
||||
│ │ └── notify/ # 企微通知
|
||||
│ │ └── WeComNotifier.java
|
||||
│ └── test/
|
||||
│ ├── resources/fixtures/tenant/ # 夹具:TenantVO/Helper 新旧版本
|
||||
│ └── java/... # 各模块单测
|
||||
└── .gitea/
|
||||
└── demo.yaml # 工具自身 CI(可选)
|
||||
```
|
||||
|
||||
### 5.1 Maven 坐标
|
||||
|
||||
```xml
|
||||
<groupId>com.codechecker</groupId>
|
||||
<artifactId>redis-schema-checker</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
```
|
||||
|
||||
打包为 **shaded/fat jar**,主类:`com.codechecker.redis.cli.RedisSchemaCheckerMain`
|
||||
|
||||
---
|
||||
|
||||
## 6. 执行流程详解
|
||||
|
||||
### 6.1 CLI 参数
|
||||
|
||||
```bash
|
||||
java -jar redis-schema-checker-1.0.0.jar \
|
||||
--config .gitea/config/redis-schema-check-config.yaml \
|
||||
--repo-root /path/to/jnpf-java-cloud \
|
||||
--old-sha abc123 \
|
||||
--new-sha def456 \
|
||||
--branch feature/xxx \
|
||||
--modifier zhangsan \
|
||||
--modify-time "2026-07-13 14:00:00"
|
||||
```
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--config` | 是 | 检测配置文件路径 |
|
||||
| `--repo-root` | 是 | 业务仓库根目录 |
|
||||
| `--old-sha` | 是 | 对比基准提交 |
|
||||
| `--new-sha` | 是 | 当前提交 |
|
||||
| `--branch` | 否 | 分支名,用于报告展示 |
|
||||
| `--modifier` | 否 | 提交人(Gitea actor) |
|
||||
| `--modify-time` | 否 | 提交时间 |
|
||||
|
||||
### 6.2 对比基准(old-sha)获取策略
|
||||
|
||||
与 `demo.yaml` 保持一致,优先级:
|
||||
|
||||
1. 流水线显式传入 `--old-sha`(通常为 `HEAD~1`)
|
||||
2. 若 `HEAD~1` 不存在(首次提交)→ 跳过检测,`exit 0`
|
||||
3. 浅克隆 `--depth 2` 确保 `HEAD~1` 可用
|
||||
|
||||
> 不支持一次 push 多个 commit 时逐个分析;第一版仅对比 `HEAD~1..HEAD`。后续可扩展为 `before..after` 范围分析。
|
||||
|
||||
### 6.3 处理步骤
|
||||
|
||||
#### Step 1:加载配置
|
||||
|
||||
读取 `redis-schema-check-config.yaml`,合并默认值(见 `docs/配置说明.md`)。
|
||||
|
||||
#### Step 2:Git Diff 扫描
|
||||
|
||||
```bash
|
||||
git diff --name-only {old-sha} {new-sha} -- '*.java'
|
||||
```
|
||||
|
||||
输出变更 Java 文件列表。同时记录 diff hunks,用于判断「是否仅注释/格式变更」。
|
||||
|
||||
#### Step 3:构建双版本源码索引
|
||||
|
||||
对 `old-sha` 和 `new-sha` 分别:
|
||||
|
||||
1. `git show {sha}:path/to/File.java` 提取文件内容(无需完整 checkout 两个 worktree)
|
||||
2. 解析为 `CompilationUnit`
|
||||
3. 建立 `类全名 → CompilationUnit` 索引(仅本仓 `src/main/java`)
|
||||
|
||||
#### Step 4:Redis 写入点检测
|
||||
|
||||
在 **变更文件** 中扫描以下 AST 模式:
|
||||
|
||||
| 模式 ID | 匹配表达式 | 提取信息 |
|
||||
|---------|------------|----------|
|
||||
| W01 | `redisUtil.insert(key, JSON.toJSONString(expr), ttl)` | key 表达式、value 表达式 |
|
||||
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(expr), ...)` | 同上 |
|
||||
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(expr), ...)` | 同上 |
|
||||
| W04 | `redisTemplate.opsForValue().set(key, expr, ...)` 且 expr 非字面量 | 第二阶段 |
|
||||
| W05 | `redisTemplate.opsForHash().put(key, field, expr)` | 第二阶段 |
|
||||
| W06 | `JSON.parseObject(cacheValue, Xxx.class)` | 辅助反向确认读取类型 |
|
||||
|
||||
**忽略规则**(自动):
|
||||
|
||||
- value 为字符串字面量、数字、`UUID`、`"1"` 等
|
||||
- 方法名含 `setIfAbsent`、`increment`、`delete`、`remove`、`expire`
|
||||
- key 匹配 `ignore_key_patterns` 配置
|
||||
|
||||
#### Step 5:类型推断
|
||||
|
||||
对每个写入点的 `expr`,使用 JavaParser Symbol Solver 推断类型:
|
||||
|
||||
```java
|
||||
// 示例:TenantDbContentCacheHelper.cacheSuccess
|
||||
CacheEnvelope envelope = new CacheEnvelope();
|
||||
envelope.setVo(vo);
|
||||
envelope.setExpiresAtMs(expiresAtMs);
|
||||
redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), ttl);
|
||||
```
|
||||
|
||||
推断链:
|
||||
|
||||
1. `JSON.toJSONString(envelope)` → 实参类型 `CacheEnvelope`
|
||||
2. 定位 `CacheEnvelope` 类(内部类需支持 `Outer$Inner`)
|
||||
3. 读取字段 `vo: TenantVO`、`expiresAtMs: Long`
|
||||
4. 递归展开 `TenantVO` → `dbName: String`、`linkList: List<TenantLinkModel>`
|
||||
5. 继续展开 `TenantLinkModel` 全部字段
|
||||
|
||||
**注解处理**(第一版):
|
||||
|
||||
| 注解 | 行为 |
|
||||
|------|------|
|
||||
| `@JSONField(serialize = false)` | 排除字段 |
|
||||
| `@JSONField(name = "xxx")` | 字段名映射 |
|
||||
| `@JsonIgnore` | 排除字段 |
|
||||
| `@JsonProperty("xxx")` | 字段名映射 |
|
||||
| `@Schema` | 忽略(不影响序列化) |
|
||||
|
||||
#### Step 6:生成 JSON Schema
|
||||
|
||||
将 Java 类型转为统一的 `TypeSchema` 树:
|
||||
|
||||
```json
|
||||
{
|
||||
"typeName": "jnpf.util.TenantDbContentCacheHelper.CacheEnvelope",
|
||||
"fields": [
|
||||
{
|
||||
"path": "vo",
|
||||
"javaType": "jnpf.model.TenantVO",
|
||||
"jsonType": "object",
|
||||
"children": [
|
||||
{ "path": "vo.dbName", "jsonType": "string" },
|
||||
{ "path": "vo.linkList", "jsonType": "array", "itemType": "jnpf.model.TenantLinkModel" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "expiresAtMs",
|
||||
"javaType": "java.lang.Long",
|
||||
"jsonType": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 7:Schema Diff
|
||||
|
||||
对比同一写入点在 old/new 两个版本的 `TypeSchema`,输出 `SchemaChange` 列表。
|
||||
|
||||
**变更类型与严重级别**:
|
||||
|
||||
| 变更类型 | 示例 | 默认级别 |
|
||||
|----------|------|----------|
|
||||
| `FIELD_REMOVED` | 删除 `dbName` | P0 |
|
||||
| `TYPE_CHANGED` | `linkList` 从数组变对象 | P0 |
|
||||
| `WRAPPER_ADDED` | 顶层增加 `vo` 包装 | P0 |
|
||||
| `FIELD_PATH_MOVED` | `dbName` → `vo.dbName` | P0 |
|
||||
| `FIELD_ADDED` | 新增 `expiresAtMs` | P1 |
|
||||
| `KEY_PATTERN_CHANGED` | key 常量变更 | P1 |
|
||||
| `WRITE_POINT_REMOVED` | 删除缓存写入 | P1 |
|
||||
| `WRITE_POINT_ADDED` | 新增缓存写入 | P2 |
|
||||
| `LOW_CONFIDENCE` | 类型推断失败 | P2 |
|
||||
|
||||
#### Step 8:报告与通知
|
||||
|
||||
生成 `CheckReport`,包含:
|
||||
|
||||
- 仓库名、分支、old/new sha、提交人、时间
|
||||
- 变更列表(按严重级别排序)
|
||||
- 每项:Key 模式、写入位置(类#方法:行号)、旧结构、新结构、变更摘要
|
||||
|
||||
调用企微 Webhook 发送 Markdown 消息。
|
||||
|
||||
#### Step 9:退出码
|
||||
|
||||
| 条件 | 退出码 |
|
||||
|------|--------|
|
||||
| 无变更 / 仅 P2 | 0 |
|
||||
| `mode=notify` 且存在 P0/P1 | 0(仍通知) |
|
||||
| `mode=block` 且存在 P0/P1/P2 | 1 |
|
||||
| 配置错误 / 执行异常 | 2 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Redis Key 推断策略
|
||||
|
||||
### 7.1 自动推断
|
||||
|
||||
| 优先级 | 模式 | 示例 | 结果 |
|
||||
|--------|------|------|------|
|
||||
| 1 | 字符串字面量 | `"tenant:db:content:" + encode` | `tenant:db:content:*` |
|
||||
| 2 | 常量引用 | `CACHE_KEY_PREFIX + encode` | 追溯常量值 |
|
||||
| 3 | `String.format(CONST, args)` | `String.format(ATTENDANCE_BASE_SETTING_CACHE_KEY, tenantId)` | `fbt:attendance:base_setting:cache:*` |
|
||||
| 4 | 方法调用 | `buildCacheKey(encode)` | 读取方法内 return 表达式 |
|
||||
| 5 | 变量 | `redisKey` | `unknown-key` |
|
||||
|
||||
### 7.2 人工补充(配置)
|
||||
|
||||
```yaml
|
||||
manual_mappings:
|
||||
- id: tenant-db-content
|
||||
writer_method: "jnpf.util.TenantDbContentCacheHelper#cacheSuccess"
|
||||
key_pattern: "tenant:db:content:*"
|
||||
value_type: "jnpf.util.TenantDbContentCacheHelper.CacheEnvelope"
|
||||
```
|
||||
|
||||
当自动推断置信度低时,以 `manual_mappings` 为准。
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置与开关设计
|
||||
|
||||
采用 **双层配置合并** 策略:
|
||||
|
||||
| 层级 | 位置 | 职责 |
|
||||
|------|------|------|
|
||||
| 默认配置 | 工具 jar 内 `default-config.yaml` | 检测模式、忽略规则、严重级别默认值 |
|
||||
| 业务覆盖 | `jnpf-java-cloud/.gitea/config/redis-schema-check-config.yaml` | mode、notify、include_modules、manual_mappings |
|
||||
|
||||
合并规则:**业务配置覆盖默认配置**,未声明的项沿用默认值。
|
||||
|
||||
CLI 调用:
|
||||
|
||||
```bash
|
||||
java -jar redis-schema-checker.jar \
|
||||
--config .gitea/config/redis-schema-check-config.yaml \
|
||||
...
|
||||
```
|
||||
|
||||
工具启动时自动加载 jar 内 `default-config.yaml`,再与 `--config` 指定的业务配置深度合并。
|
||||
|
||||
详见 `docs/配置说明.md`。核心开关:
|
||||
|
||||
```yaml
|
||||
# 运行模式:notify(仅通知)| block(P0/P1/P2 全部阻断流水线)
|
||||
mode: notify
|
||||
|
||||
# block 模式下触发 exit 1 的严重级别(全部阻断)
|
||||
block_severities:
|
||||
- P0
|
||||
- P1
|
||||
- P2
|
||||
|
||||
# 是否发送企微通知
|
||||
notify:
|
||||
enabled: true
|
||||
webhook_env: WECOM_ROBOT_WEBHOOK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. CI 集成方案
|
||||
|
||||
详见 `docs/CI集成说明.md`。核心流程:
|
||||
|
||||
```yaml
|
||||
# jnpf-java-cloud/.gitea/workflows/redis-schema-check.yaml
|
||||
name: Redis序列化结构检查
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
redis-schema-check:
|
||||
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
||||
runs-on: jdk11
|
||||
steps:
|
||||
- name: 检出代码
|
||||
run: |
|
||||
git clone --depth 2 --single-branch --branch "${{ gitea.ref_name }}" \
|
||||
"https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
|
||||
git checkout -B "${{ gitea.ref_name }}" "${{ gitea.sha }}"
|
||||
|
||||
- name: 下载检测工具
|
||||
run: |
|
||||
# 从 Nexus 下载 redis-schema-checker jar
|
||||
...
|
||||
|
||||
- name: 执行检测
|
||||
env:
|
||||
WECOM_ROBOT_WEBHOOK: ${{ secrets.WECOM_ROBOT_WEBHOOK }}
|
||||
run: |
|
||||
OLD_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "")
|
||||
[ -z "$OLD_SHA" ] && exit 0
|
||||
java -jar /tmp/redis-schema-checker-1.0.0.jar \
|
||||
--config .gitea/config/redis-schema-check-config.yaml \
|
||||
--repo-root . \
|
||||
--old-sha "$OLD_SHA" \
|
||||
--new-sha "$(git rev-parse HEAD)" \
|
||||
--branch "${{ gitea.ref_name }}" \
|
||||
--modifier "${{ gitea.actor }}" \
|
||||
--modify-time "$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 分阶段交付计划
|
||||
|
||||
### Phase 1 — MVP(约 1.5 周)
|
||||
|
||||
**目标**:跑通端到端链路,覆盖租户缓存典型场景。
|
||||
|
||||
| 任务 | 产出 |
|
||||
|------|------|
|
||||
| Maven 工程骨架 + CLI | 可执行 fat-jar |
|
||||
| Git diff 扫描 | 变更文件列表 |
|
||||
| W01~W03 写入点检测 | 覆盖 JSON 字符串写入 |
|
||||
| 基础 Schema 提取 | 支持普通类、内部类、List、嵌套 |
|
||||
| Schema Diff P0/P1 | 字段增删、包装、路径迁移 |
|
||||
| 企微通知 | Markdown 消息 |
|
||||
| notify/block 开关 | 配置驱动 |
|
||||
| 夹具测试 | TenantVO/CacheEnvelope 样本 |
|
||||
|
||||
**验收标准**:
|
||||
|
||||
- 对 `TenantDbContentCacheHelper` 的结构变更能输出 P0 报告
|
||||
- 流水线 push 后能收到企微通知
|
||||
- `mode=block` 时任意 P0/P1/P2 变更导致 exit 1
|
||||
|
||||
### Phase 2 — 增强(约 1 周)
|
||||
|
||||
| 任务 | 说明 |
|
||||
|------|------|
|
||||
| W04/W05 模式 | RedisTemplate 直写对象、Hash 写入 |
|
||||
| 注解完整支持 | Fastjson/Jackson 注解 |
|
||||
| Key 推断增强 | `String.format`、常量追溯 |
|
||||
| 忽略规则完善 | 锁/计数器/token 自动过滤 |
|
||||
| 多模块性能优化 | 并行解析、缓存索引 |
|
||||
|
||||
### Phase 3 — 运营(约 0.5 周)
|
||||
|
||||
| 任务 | 说明 |
|
||||
|------|------|
|
||||
| 报告落盘 | 可选输出 JSON 报告文件 |
|
||||
| 误报反馈 | `suppressions` 配置支持按写入点忽略 |
|
||||
| 更多业务场景覆盖 | 考勤、文件下载进度等 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 测试策略
|
||||
|
||||
### 11.1 单元测试
|
||||
|
||||
- `SchemaDifferTest`:纯字段路径对比逻辑
|
||||
- `JavaSchemaExtractorTest`:类字段展开、注解、内部类
|
||||
- `RedisWritePointDetectorTest`:各种写入 AST 模式匹配
|
||||
- `RedisKeyResolverTest`:常量、format、拼接推断
|
||||
|
||||
### 11.2 夹具集成测试
|
||||
|
||||
在 `src/test/resources/fixtures/` 放置真实业务代码片段(从 `jnpf-java-cloud` 提取并脱敏),模拟 old/new 两个版本:
|
||||
|
||||
| 夹具 | 验证点 |
|
||||
|------|--------|
|
||||
| `tenant-cache/` | 包装结构变更 P0 |
|
||||
| `attendance-base-setting/` | Map 结构缓存 |
|
||||
| `evaluate-config/` | VO 字段新增 P1 |
|
||||
| `lock-only/` | 应被忽略 |
|
||||
|
||||
### 11.3 端到端测试
|
||||
|
||||
在 `jnpf-java-cloud` 开测试分支,故意提交一个 VO 字段变更,验证流水线 + 企微通知。
|
||||
|
||||
---
|
||||
|
||||
## 12. 风险与限制
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 类型推断失败 | 漏报 | 标记 `LOW_CONFIDENCE`,配置 `manual_mappings` |
|
||||
| Lombok 复杂注解 | 字段遗漏 | 基于源码字段 + 注解;后续 delombok |
|
||||
| 同一 key 多分支写不同类型 | 误报 | 报告注明置信度;人工 suppression |
|
||||
| 浅克隆 parent 不可用 | 跳过检测 | `--depth 2`;文档明确要求 |
|
||||
| 一次 push 多 commit | 仅检最后一个 | 文档说明;后续扩展 range |
|
||||
| 依赖 jar 中的类型 | 字段展开不完整 | 配置 `manual_mappings` 补充 |
|
||||
| JsonUtil 实现不可见 | 序列化规则猜测 | 默认按字段名序列化;与 Fastjson 对齐 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 已确认决策(Grill 共识)
|
||||
|
||||
| # | 决策项 | 结论 |
|
||||
|---|--------|------|
|
||||
| 1 | 阻断范围 | `block` 模式下 **P0/P1/P2 全部阻断**(exit 1) |
|
||||
| 2 | 发布坐标 | 独立产物 `com.codechecker:redis-schema-checker:1.0.0` |
|
||||
| 3 | 配置归属 | **双层配置**:jar 内 `default-config.yaml` + 业务仓覆盖合并 |
|
||||
| 4 | 上线策略 | 先 `notify` 观察 **1 周**,稳定后手动切 `block` |
|
||||
| 5 | 检测范围 | **仅 `src/main/java`**,不扫描测试代码 |
|
||||
|
||||
以上决策已纳入实施方案,可进入开发阶段。
|
||||
|
||||
---
|
||||
|
||||
## 14. 附录:关键类设计草图
|
||||
|
||||
### WritePoint
|
||||
|
||||
```java
|
||||
public class WritePoint {
|
||||
String filePath;
|
||||
int lineNumber;
|
||||
String enclosingClass;
|
||||
String enclosingMethod;
|
||||
String keyExpression; // 原始 AST 表达式
|
||||
String resolvedKeyPattern; // 推断结果,如 tenant:db:content:*
|
||||
String valueExpression;
|
||||
String resolvedValueType; // 全限定类名
|
||||
double confidence; // 0.0 ~ 1.0
|
||||
}
|
||||
```
|
||||
|
||||
### SchemaChange
|
||||
|
||||
```java
|
||||
public class SchemaChange {
|
||||
Severity severity; // P0, P1, P2
|
||||
ChangeType changeType; // FIELD_REMOVED, WRAPPER_ADDED, ...
|
||||
String keyPattern;
|
||||
String writeLocation; // class#method:line
|
||||
String fieldPath; // 如 vo.dbName
|
||||
String oldValue;
|
||||
String newValue;
|
||||
String message; // 人类可读描述
|
||||
}
|
||||
```
|
||||
|
||||
### CheckReport
|
||||
|
||||
```java
|
||||
public class CheckReport {
|
||||
String repository;
|
||||
String branch;
|
||||
String oldSha;
|
||||
String newSha;
|
||||
String modifier;
|
||||
String modifyTime;
|
||||
String mode;
|
||||
List<SchemaChange> changes;
|
||||
boolean blocked;
|
||||
int exitCode;
|
||||
}
|
||||
```
|
||||
295
docs/配置说明.md
Normal file
295
docs/配置说明.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# Redis 序列化结构检测 — 配置说明
|
||||
|
||||
> **双层配置**:工具 jar 内置 `default-config.yaml`(默认) + 业务仓库 `.gitea/config/redis-schema-check-config.yaml`(覆盖)
|
||||
|
||||
---
|
||||
|
||||
## 1. 配置合并机制
|
||||
|
||||
```text
|
||||
jar 内 default-config.yaml(工具仓维护)
|
||||
↓ 深度合并
|
||||
业务仓 redis-schema-check-config.yaml(业务仓维护)
|
||||
↓
|
||||
最终生效配置
|
||||
```
|
||||
|
||||
- 业务配置**仅需写差异项**,不必复制全部默认规则
|
||||
- 升级 jar 时,默认忽略规则/检测模式自动跟随工具版本演进
|
||||
- 业务仓必须存在 `--config` 指定的配置文件(可为仅含 `mode` 的最小文件)
|
||||
|
||||
### 1.1 业务仓最小配置示例
|
||||
|
||||
```yaml
|
||||
# jnpf-java-cloud/.gitea/config/redis-schema-check-config.yaml
|
||||
mode: notify
|
||||
|
||||
notify:
|
||||
enabled: true
|
||||
webhook_env: WECOM_ROBOT_WEBHOOK
|
||||
|
||||
include_modules:
|
||||
- jnpf-tenant
|
||||
```
|
||||
|
||||
### 1.2 工具内置默认配置(jar 内 default-config.yaml)
|
||||
|
||||
由 `redisCheck` 仓库维护,随 jar 发布,包含:
|
||||
|
||||
- `detection.patterns`(W01~W03)
|
||||
- `ignore.key_patterns`(锁/计数器/token)
|
||||
- `block_severities`(P0/P1/P2)
|
||||
- `detection.min_confidence`、`max_field_depth` 等
|
||||
|
||||
---
|
||||
|
||||
## 2. 业务仓完整配置示例
|
||||
|
||||
```yaml
|
||||
# 运行模式
|
||||
# notify - 仅通知,不阻断流水线
|
||||
# block - 按 block_severities 阻断流水线(exit 1)
|
||||
mode: notify
|
||||
|
||||
# block 模式下触发 exit 1 的严重级别(全部阻断:P0/P1/P2)
|
||||
block_severities:
|
||||
- P0
|
||||
- P1
|
||||
- P2
|
||||
|
||||
# 是否扫描测试代码(已确认:不扫描)
|
||||
scan_test_sources: false
|
||||
|
||||
# 源码扫描根目录(仅 main,不含 test)
|
||||
source_roots:
|
||||
- "src/main/java"
|
||||
|
||||
# 通知配置
|
||||
notify:
|
||||
enabled: true
|
||||
# 从环境变量读取 Webhook URL
|
||||
webhook_env: WECOM_ROBOT_WEBHOOK
|
||||
# 无变更时是否也发通知(一般 false)
|
||||
notify_on_clean: false
|
||||
# 消息标题前缀
|
||||
title_prefix: "[Redis结构变更]"
|
||||
|
||||
# 忽略规则
|
||||
ignore:
|
||||
# 忽略的 key 模式(glob)
|
||||
key_patterns:
|
||||
- "*:lock"
|
||||
- "*:lock:*"
|
||||
- "loginCount:*"
|
||||
- "Authorization:*"
|
||||
- "Authorization:login:session:*"
|
||||
|
||||
# 忽略的文件路径模式
|
||||
file_patterns:
|
||||
- "**/test/**"
|
||||
|
||||
# 忽略的写入方法(类全名#方法名)
|
||||
writer_methods: []
|
||||
|
||||
# 检测规则
|
||||
detection:
|
||||
# 启用的写入模式
|
||||
patterns:
|
||||
- W01 # redisUtil.insert + JSON.toJSONString
|
||||
- W02 # redisTemplate.opsForValue().set + JSON.toJSONString
|
||||
- W03 # stringRedisTemplate + JsonUtil.getObjectToString
|
||||
# - W04 # redisTemplate 直写对象(Phase 2)
|
||||
# - W05 # opsForHash().put(Phase 2)
|
||||
|
||||
# 类型推断最低置信度,低于此值仅输出 P2 提示
|
||||
min_confidence: 0.6
|
||||
|
||||
# 字段展开最大深度(防止循环引用死循环)
|
||||
max_field_depth: 8
|
||||
|
||||
# 严重级别覆盖(可选)
|
||||
severity_overrides:
|
||||
FIELD_ADDED: P1
|
||||
WRITE_POINT_ADDED: P2
|
||||
|
||||
# 人工补充映射(自动推断失败或需精确指定时使用)
|
||||
manual_mappings:
|
||||
- id: tenant-db-content
|
||||
writer_method: "jnpf.util.TenantDbContentCacheHelper#cacheSuccess"
|
||||
key_pattern: "tenant:db:content:*"
|
||||
value_type: "jnpf.util.TenantDbContentCacheHelper.CacheEnvelope"
|
||||
description: "租户库信息缓存"
|
||||
|
||||
- id: attendance-base-setting
|
||||
writer_method: "jnpf.attendance.service.impl.AttendanceBaseSettingServiceImpl#getStringAttendanceBaseSettingMap"
|
||||
key_pattern: "fbt:attendance:base_setting:cache:*"
|
||||
value_type: "java.util.Map"
|
||||
description: "考勤基础设置缓存(Map<String, AttendanceBaseSetting>)"
|
||||
|
||||
# 抑制规则(已知误报)
|
||||
suppressions:
|
||||
- id: ignore-export-progress
|
||||
key_pattern: "file:download:user:progress:*"
|
||||
reason: "导出进度缓存,结构变更不影响业务读取"
|
||||
|
||||
# 模块过滤(可选,默认扫描全部模块)
|
||||
include_modules:
|
||||
- jnpf-tenant
|
||||
- jnpf-ftb
|
||||
- jnpf-file
|
||||
- fantaibao-data-analysis
|
||||
|
||||
# exclude_modules: []
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 配置项说明
|
||||
|
||||
### 3.1 mode
|
||||
|
||||
| 值 | 行为 |
|
||||
|----|------|
|
||||
| `notify` | 检测到变更 → 发企微 → `exit 0` |
|
||||
| `block` | 检测到 `block_severities` 中的级别 → 发企微 → `exit 1` |
|
||||
|
||||
### 3.2 block_severities
|
||||
|
||||
默认 `["P0", "P1", "P2"]`,`block` 模式下任意级别变更均 `exit 1`。
|
||||
|
||||
建议上线初期仍使用 `mode: notify` 观察误报情况,确认稳定后再切换:
|
||||
|
||||
```yaml
|
||||
mode: block
|
||||
block_severities:
|
||||
- P0
|
||||
- P1
|
||||
- P2
|
||||
```
|
||||
|
||||
### 3.3 notify
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `enabled` | boolean | true | 是否发企微 |
|
||||
| `webhook_env` | string | WECOM_ROBOT_WEBHOOK | 环境变量名 |
|
||||
| `notify_on_clean` | boolean | false | 无变更时是否通知 |
|
||||
| `title_prefix` | string | [Redis结构变更] | 消息标题前缀 |
|
||||
|
||||
### 3.4 ignore.key_patterns
|
||||
|
||||
支持 glob:
|
||||
|
||||
- `*` 匹配单层
|
||||
- `**` 匹配多层
|
||||
|
||||
常见内置忽略(代码层也有硬编码兜底):
|
||||
|
||||
- 分布式锁 key
|
||||
- 登录计数
|
||||
- session/token
|
||||
|
||||
### 3.5 detection.patterns
|
||||
|
||||
| 模式 | 说明 | 阶段 |
|
||||
|------|------|------|
|
||||
| W01 | `redisUtil.insert(key, JSON.toJSONString(x), ttl)` | Phase 1 |
|
||||
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)` | Phase 1 |
|
||||
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)` | Phase 1 |
|
||||
| W04 | `redisTemplate.opsForValue().set(key, obj, ...)` | Phase 2 |
|
||||
| W05 | `redisTemplate.opsForHash().put(key, field, obj)` | Phase 2 |
|
||||
|
||||
### 3.6 manual_mappings
|
||||
|
||||
当自动推断不准确时使用。匹配优先级 **高于** 自动推断。
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | 是 | 唯一标识 |
|
||||
| `writer_method` | 否 | `类全名#方法名`,精确匹配写入点 |
|
||||
| `key_pattern` | 否 | 精确 key 模式 |
|
||||
| `value_type` | 否 | 强制指定 value 类型 |
|
||||
| `description` | 否 | 备注 |
|
||||
|
||||
### 3.7 suppressions
|
||||
|
||||
用于屏蔽已知可接受的变更:
|
||||
|
||||
```yaml
|
||||
suppressions:
|
||||
- id: my-suppression
|
||||
writer_method: "com.example.FooService#cacheBar"
|
||||
change_types:
|
||||
- FIELD_ADDED
|
||||
reason: "新增字段向后兼容"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 环境变量
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `WECOM_ROBOT_WEBHOOK` | notify.enabled=true 时必填 | 企微机器人 Webhook 完整 URL |
|
||||
|
||||
在 Gitea 仓库 Settings → Secrets 中配置。
|
||||
|
||||
---
|
||||
|
||||
## 5. 企微消息格式示例
|
||||
|
||||
```markdown
|
||||
## [Redis结构变更] jnpf-java-cloud
|
||||
|
||||
> 分支: feature/tenant-cache
|
||||
> 提交: a1b2c3d → e4f5g6h
|
||||
> 提交人: zhangsan
|
||||
> 时间: 2026-07-13 14:00:00
|
||||
> 模式: notify
|
||||
|
||||
### P0 - 顶层结构包装变更
|
||||
- **Key**: `tenant:db:content:*`
|
||||
- **位置**: `TenantDbContentCacheHelper#cacheSuccess:92`
|
||||
- **变更**:
|
||||
- `dbName` → `vo.dbName`(字段路径迁移)
|
||||
- `linkList` → `vo.linkList`(字段路径迁移)
|
||||
- 新增顶层字段 `expiresAtMs`
|
||||
- **影响**: 旧缓存反序列化可能失败,需评估缓存刷新策略
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 推荐上线配置
|
||||
|
||||
### 6.1 观察期(第 1 周,已确认策略)
|
||||
|
||||
业务仓默认配置:
|
||||
|
||||
```yaml
|
||||
mode: notify
|
||||
|
||||
notify:
|
||||
enabled: true
|
||||
webhook_env: WECOM_ROBOT_WEBHOOK
|
||||
|
||||
include_modules:
|
||||
- jnpf-tenant
|
||||
```
|
||||
|
||||
观察满 1 周、确认误报可接受后,手动切换:
|
||||
|
||||
```yaml
|
||||
mode: block
|
||||
block_severities: [P0, P1, P2]
|
||||
include_modules: [] # 扩至全仓
|
||||
```
|
||||
|
||||
### 6.2 全量启用(观察期结束后)
|
||||
|
||||
```yaml
|
||||
mode: block
|
||||
block_severities: [P0, P1, P2]
|
||||
include_modules: [] # 空表示全部模块
|
||||
detection:
|
||||
patterns: [W01, W02, W03, W04, W05]
|
||||
```
|
||||
31
pom.xml
Normal file
31
pom.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.codechecker</groupId>
|
||||
<artifactId>redis-schema-checker-parent</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>redis-schema-checker-parent</name>
|
||||
<description>Redis 序列化结构变更检测工具(父工程)</description>
|
||||
|
||||
<modules>
|
||||
<module>redis-schema-checker</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>11</maven.compiler.source>
|
||||
<maven.compiler.target>11</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
<javaparser.version>3.25.10</javaparser.version>
|
||||
<snakeyaml.version>2.2</snakeyaml.version>
|
||||
<jackson.version>2.15.4</jackson.version>
|
||||
<picocli.version>4.7.5</picocli.version>
|
||||
<junit.version>5.10.2</junit.version>
|
||||
<maven.shade.version>3.5.1</maven.shade.version>
|
||||
</properties>
|
||||
</project>
|
||||
97
redis-schema-checker/pom.xml
Normal file
97
redis-schema-checker/pom.xml
Normal file
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.codechecker</groupId>
|
||||
<artifactId>redis-schema-checker-parent</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>redis-schema-checker</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>redis-schema-checker</name>
|
||||
<description>基于 JavaParser 的 Redis value 序列化结构变更检测器</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.javaparser</groupId>
|
||||
<artifactId>javaparser-symbol-solver-core</artifactId>
|
||||
<version>${javaparser.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
<version>${snakeyaml.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>info.picocli</groupId>
|
||||
<artifactId>picocli</artifactId>
|
||||
<version>${picocli.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>redis-schema-checker-${project.version}</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>${maven.shade.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<transformers>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<mainClass>com.codechecker.redis.cli.RedisSchemaCheckerMain</mainClass>
|
||||
</transformer>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
<exclude>module-info.class</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.codechecker.redis.analyze;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 扫描工作树中受包含/排除模块限制的 {@code src/main/java} 下的所有 Java 源文件。
|
||||
*/
|
||||
public class FileScanner {
|
||||
|
||||
private final Path repoRoot;
|
||||
private final List<String> includeModules;
|
||||
private final List<String> excludeModules;
|
||||
|
||||
public FileScanner(Path repoRoot, List<String> includeModules, List<String> excludeModules) {
|
||||
this.repoRoot = repoRoot;
|
||||
this.includeModules = includeModules;
|
||||
this.excludeModules = excludeModules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 相对仓库根(/ 分隔)-> 文件内容
|
||||
*/
|
||||
public Map<String, String> scan() {
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
try (Stream<Path> stream = Files.walk(repoRoot)) {
|
||||
stream.filter(Files::isRegularFile)
|
||||
.filter(p -> p.toString().endsWith(".java"))
|
||||
.forEach(p -> {
|
||||
String rel = repoRoot.relativize(p).toString().replace('\\', '/');
|
||||
if (!rel.contains("/src/main/java/")) {
|
||||
return;
|
||||
}
|
||||
if (!moduleAllowed(rel)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
result.put(rel, new String(Files.readAllBytes(p), StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
boolean moduleAllowed(String relPath) {
|
||||
String topModule = relPath.contains("/") ? relPath.substring(0, relPath.indexOf('/')) : relPath;
|
||||
if (excludeModules != null && excludeModules.contains(topModule)) {
|
||||
return false;
|
||||
}
|
||||
if (includeModules == null || includeModules.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return includeModules.contains(topModule);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.codechecker.redis.analyze;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 极简 glob 匹配:{@code *} 与 {@code **} 均匹配任意字符(含空),其余按字面匹配。整串锚定。
|
||||
*/
|
||||
public final class GlobMatcher {
|
||||
|
||||
private GlobMatcher() {
|
||||
}
|
||||
|
||||
public static boolean matches(String glob, String input) {
|
||||
if (glob == null || input == null) {
|
||||
return false;
|
||||
}
|
||||
StringBuilder regex = new StringBuilder("^");
|
||||
int i = 0;
|
||||
while (i < glob.length()) {
|
||||
char ch = glob.charAt(i);
|
||||
if (ch == '*') {
|
||||
while (i < glob.length() && glob.charAt(i) == '*') {
|
||||
i++;
|
||||
}
|
||||
regex.append(".*");
|
||||
} else {
|
||||
regex.append(Pattern.quote(String.valueOf(ch)));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
regex.append('$');
|
||||
return Pattern.compile(regex.toString()).matcher(input).matches();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.codechecker.redis.analyze;
|
||||
|
||||
import com.codechecker.redis.config.CheckerConfig;
|
||||
import com.codechecker.redis.detector.RedisWritePointDetector;
|
||||
import com.codechecker.redis.detector.WritePoint;
|
||||
import com.codechecker.redis.diff.ChangeType;
|
||||
import com.codechecker.redis.diff.SchemaChange;
|
||||
import com.codechecker.redis.diff.SchemaDiffer;
|
||||
import com.codechecker.redis.diff.Severity;
|
||||
import com.codechecker.redis.git.GitDiffScanner;
|
||||
import com.codechecker.redis.git.GitException;
|
||||
import com.codechecker.redis.report.CheckReport;
|
||||
import com.codechecker.redis.schema.JavaSchemaExtractor;
|
||||
import com.codechecker.redis.schema.SourceIndex;
|
||||
import com.codechecker.redis.schema.TypeSchema;
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 端到端分析编排:git diff → 定位写入点 → 双版本 Schema 提取 → 结构对比 → 生成报告。
|
||||
*/
|
||||
public class SchemaCheckAnalyzer {
|
||||
|
||||
private final CheckerConfig config;
|
||||
private final Path repoRoot;
|
||||
|
||||
public SchemaCheckAnalyzer(CheckerConfig config, Path repoRoot) {
|
||||
this.config = config;
|
||||
this.repoRoot = repoRoot;
|
||||
}
|
||||
|
||||
public CheckReport analyze(String oldSha, String newSha) throws GitException {
|
||||
GitDiffScanner scanner = new GitDiffScanner(repoRoot);
|
||||
|
||||
List<String> changedRaw = scanner.changedJavaFiles(oldSha, newSha);
|
||||
Set<String> changedFiles = new LinkedHashSet<>();
|
||||
FileScanner fileScanner = new FileScanner(repoRoot, config.getIncludeModules(), config.getExcludeModules());
|
||||
for (String path : changedRaw) {
|
||||
if (path.contains("/src/main/java/") && fileScanner.moduleAllowed(path) && !isFileIgnored(path)) {
|
||||
changedFiles.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
// 当前工作树(= newSha 检出)内容
|
||||
Map<String, String> newContents = fileScanner.scan();
|
||||
|
||||
// 旧版本内容:以工作树为基线,用 old 版本覆盖变更文件
|
||||
Map<String, String> oldContents = new LinkedHashMap<>(newContents);
|
||||
for (String path : changedFiles) {
|
||||
String oldContent = scanner.fileContentAt(oldSha, path);
|
||||
if (oldContent == null) {
|
||||
oldContents.remove(path); // 新增文件在旧版本不存在
|
||||
} else {
|
||||
oldContents.put(path, oldContent);
|
||||
}
|
||||
}
|
||||
|
||||
SourceIndex newIndex = buildIndex(newContents.values());
|
||||
SourceIndex oldIndex = buildIndex(oldContents.values());
|
||||
|
||||
// 变更涉及的类型简单名(用于扩展候选写入点文件)
|
||||
Set<String> changedTypeNames = new HashSet<>();
|
||||
for (String path : changedFiles) {
|
||||
collectTypeNames(newContents.get(path), changedTypeNames);
|
||||
collectTypeNames(oldContents.get(path), changedTypeNames);
|
||||
}
|
||||
|
||||
Set<String> candidates = new LinkedHashSet<>(changedFiles);
|
||||
Pattern typePattern = buildTypePattern(changedTypeNames);
|
||||
if (typePattern != null) {
|
||||
for (Map.Entry<String, String> e : newContents.entrySet()) {
|
||||
if (!candidates.contains(e.getKey()) && typePattern.matcher(e.getValue()).find()) {
|
||||
candidates.add(e.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> patterns = new HashSet<>(config.getDetection().getPatterns());
|
||||
RedisWritePointDetector detectorNew = new RedisWritePointDetector(newIndex, patterns);
|
||||
RedisWritePointDetector detectorOld = new RedisWritePointDetector(oldIndex, patterns);
|
||||
JavaSchemaExtractor extractorNew = new JavaSchemaExtractor(newIndex, config.getDetection().getMaxFieldDepth());
|
||||
JavaSchemaExtractor extractorOld = new JavaSchemaExtractor(oldIndex, config.getDetection().getMaxFieldDepth());
|
||||
SchemaDiffer differ = new SchemaDiffer();
|
||||
|
||||
List<SchemaChange> allChanges = new ArrayList<>();
|
||||
|
||||
for (String path : candidates) {
|
||||
String newContent = newContents.get(path);
|
||||
if (newContent == null) {
|
||||
continue;
|
||||
}
|
||||
boolean fileChanged = changedFiles.contains(path);
|
||||
String oldContent = fileChanged ? oldContents.get(path) : newContent;
|
||||
|
||||
List<WritePoint> newWps = detectorNew.detect(path, newContent);
|
||||
List<WritePoint> oldWps = oldContent == null
|
||||
? new ArrayList<>() : detectorOld.detect(path, oldContent);
|
||||
|
||||
Map<String, WritePoint> oldBySig = new LinkedHashMap<>();
|
||||
for (WritePoint wp : oldWps) {
|
||||
oldBySig.put(wp.signature(), wp);
|
||||
}
|
||||
Set<String> newSigs = new HashSet<>();
|
||||
|
||||
for (WritePoint nw : newWps) {
|
||||
newSigs.add(nw.signature());
|
||||
if (isKeyIgnored(nw.getResolvedKeyPattern()) || isWriterIgnored(nw)) {
|
||||
continue;
|
||||
}
|
||||
WritePoint ow = oldBySig.get(nw.signature());
|
||||
if (ow != null) {
|
||||
TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray());
|
||||
TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray());
|
||||
List<SchemaChange> changes = differ.diff(oldSchema, newSchema);
|
||||
double confidence = min(nw.getConfidence(), ow.getConfidence(),
|
||||
oldSchema.getConfidence(), newSchema.getConfidence());
|
||||
enrich(changes, nw, confidence);
|
||||
allChanges.addAll(changes);
|
||||
} else if (fileChanged) {
|
||||
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
|
||||
c.setKeyPattern(nw.getResolvedKeyPattern());
|
||||
c.setWriteLocation(nw.location());
|
||||
c.setMessage("新增 Redis 写入点,value 类型: " + shortType(nw.getResolvedValueType()));
|
||||
allChanges.add(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileChanged) {
|
||||
for (WritePoint ow : oldWps) {
|
||||
if (!newSigs.contains(ow.signature())
|
||||
&& !isKeyIgnored(ow.getResolvedKeyPattern()) && !isWriterIgnored(ow)) {
|
||||
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_REMOVED);
|
||||
c.setKeyPattern(ow.getResolvedKeyPattern());
|
||||
c.setWriteLocation(ow.location());
|
||||
c.setMessage("删除 Redis 写入点,原 value 类型: " + shortType(ow.getResolvedValueType()));
|
||||
allChanges.add(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<SchemaChange> finalChanges = postProcess(allChanges);
|
||||
return buildReport(oldSha, newSha, finalChanges);
|
||||
}
|
||||
|
||||
private void enrich(List<SchemaChange> changes, WritePoint wp, double confidence) {
|
||||
boolean lowConfidence = confidence < config.getDetection().getMinConfidence();
|
||||
for (SchemaChange c : changes) {
|
||||
c.setKeyPattern(wp.getResolvedKeyPattern());
|
||||
c.setWriteLocation(wp.location());
|
||||
if (lowConfidence) {
|
||||
c.setSeverity(Severity.P2);
|
||||
c.setMessage(c.getMessage() + "(低置信度,建议人工确认)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<SchemaChange> postProcess(List<SchemaChange> changes) {
|
||||
List<SchemaChange> result = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (SchemaChange c : changes) {
|
||||
applySeverityOverride(c);
|
||||
if (isSuppressed(c)) {
|
||||
continue;
|
||||
}
|
||||
String dedupKey = c.getChangeType() + "|" + c.getKeyPattern() + "|"
|
||||
+ c.getWriteLocation() + "|" + c.getFieldPath();
|
||||
if (seen.add(dedupKey)) {
|
||||
result.add(c);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void applySeverityOverride(SchemaChange c) {
|
||||
String override = config.getSeverityOverrides().get(c.getChangeType().name());
|
||||
if (override != null) {
|
||||
try {
|
||||
c.setSeverity(Severity.valueOf(override.trim().toUpperCase()));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// 无效级别忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSuppressed(SchemaChange c) {
|
||||
for (CheckerConfig.Suppression s : config.getSuppressions()) {
|
||||
boolean keyMatch = s.getKeyPattern() == null
|
||||
|| s.getKeyPattern().equals(c.getKeyPattern());
|
||||
boolean typeMatch = s.getChangeTypes() == null || s.getChangeTypes().isEmpty()
|
||||
|| s.getChangeTypes().contains(c.getChangeType().name());
|
||||
if (keyMatch && typeMatch && (s.getKeyPattern() != null
|
||||
|| (s.getChangeTypes() != null && !s.getChangeTypes().isEmpty()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private CheckReport buildReport(String oldSha, String newSha, List<SchemaChange> changes) {
|
||||
CheckReport report = new CheckReport();
|
||||
report.setOldSha(oldSha);
|
||||
report.setNewSha(newSha);
|
||||
report.setMode(config.getMode());
|
||||
report.getChanges().addAll(changes);
|
||||
|
||||
boolean blocked = false;
|
||||
if (config.isBlockMode()) {
|
||||
Set<String> blockSev = new HashSet<>(config.getBlockSeverities());
|
||||
for (SchemaChange c : changes) {
|
||||
if (blockSev.contains(c.getSeverity().name())) {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
report.setBlocked(blocked);
|
||||
report.setExitCode(blocked ? 1 : 0);
|
||||
return report;
|
||||
}
|
||||
|
||||
private SourceIndex buildIndex(Iterable<String> contents) {
|
||||
SourceIndex index = new SourceIndex();
|
||||
for (String content : contents) {
|
||||
index.addSource(content);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
private void collectTypeNames(String content, Set<String> out) {
|
||||
if (content == null || content.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
CompilationUnit cu = StaticJavaParser.parse(content);
|
||||
for (TypeDeclaration<?> type : cu.findAll(TypeDeclaration.class)) {
|
||||
out.add(type.getNameAsString());
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
// 解析失败跳过
|
||||
}
|
||||
}
|
||||
|
||||
private Pattern buildTypePattern(Set<String> typeNames) {
|
||||
List<String> valid = new ArrayList<>();
|
||||
for (String name : typeNames) {
|
||||
if (name != null && name.matches("[A-Za-z_][A-Za-z0-9_]*")) {
|
||||
valid.add(Pattern.quote(name));
|
||||
}
|
||||
}
|
||||
if (valid.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return Pattern.compile("\\b(" + String.join("|", valid) + ")\\b");
|
||||
}
|
||||
|
||||
private boolean isFileIgnored(String path) {
|
||||
for (String glob : config.getIgnore().getFilePatterns()) {
|
||||
if (GlobMatcher.matches(glob, path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isKeyIgnored(String keyPattern) {
|
||||
if (keyPattern == null) {
|
||||
return false;
|
||||
}
|
||||
for (String glob : config.getIgnore().getKeyPatterns()) {
|
||||
if (GlobMatcher.matches(glob, keyPattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isWriterIgnored(WritePoint wp) {
|
||||
String sig = wp.getEnclosingClass() + "#" + wp.getEnclosingMethod();
|
||||
return config.getIgnore().getWriterMethods().contains(sig);
|
||||
}
|
||||
|
||||
private String shortType(String fqn) {
|
||||
if (fqn == null) {
|
||||
return "<未解析>";
|
||||
}
|
||||
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
|
||||
}
|
||||
|
||||
private double min(double... values) {
|
||||
double m = 1.0;
|
||||
for (double v : values) {
|
||||
m = Math.min(m, v);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.codechecker.redis.cli;
|
||||
|
||||
import com.codechecker.redis.analyze.SchemaCheckAnalyzer;
|
||||
import com.codechecker.redis.config.CheckerConfig;
|
||||
import com.codechecker.redis.config.ConfigLoader;
|
||||
import com.codechecker.redis.notify.WeComNotifier;
|
||||
import com.codechecker.redis.report.CheckReport;
|
||||
import com.codechecker.redis.report.ReportBuilder;
|
||||
import picocli.CommandLine;
|
||||
import picocli.CommandLine.Command;
|
||||
import picocli.CommandLine.Option;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
/**
|
||||
* 命令行入口。退出码:0 通过 / 1 阻断 / 2 执行错误。
|
||||
*/
|
||||
@Command(name = "redis-schema-checker",
|
||||
mixinStandardHelpOptions = true,
|
||||
version = "redis-schema-checker 1.0.0",
|
||||
description = "检测两次提交间 Redis value 序列化结构变更并通过企微机器人通知。")
|
||||
public class RedisSchemaCheckerMain implements Callable<Integer> {
|
||||
|
||||
@Option(names = "--config", required = true, description = "业务仓库检测配置文件路径")
|
||||
private Path configPath;
|
||||
|
||||
@Option(names = "--repo-root", required = true, description = "被检测仓库根目录")
|
||||
private Path repoRoot;
|
||||
|
||||
@Option(names = "--old-sha", required = true, description = "对比基准提交")
|
||||
private String oldSha;
|
||||
|
||||
@Option(names = "--new-sha", required = true, description = "当前提交")
|
||||
private String newSha;
|
||||
|
||||
@Option(names = "--branch", description = "分支名(用于报告展示)")
|
||||
private String branch;
|
||||
|
||||
@Option(names = "--modifier", description = "提交人")
|
||||
private String modifier;
|
||||
|
||||
@Option(names = "--modify-time", description = "提交时间")
|
||||
private String modifyTime;
|
||||
|
||||
@Option(names = "--repository", description = "仓库名(默认取仓库目录名)")
|
||||
private String repository;
|
||||
|
||||
@Option(names = "--dry-run", description = "只输出报告,不发送企微通知")
|
||||
private boolean dryRun;
|
||||
|
||||
@Override
|
||||
public Integer call() {
|
||||
try {
|
||||
CheckerConfig config = ConfigLoader.load(configPath);
|
||||
|
||||
if (oldSha == null || oldSha.trim().isEmpty()) {
|
||||
System.out.println("[redis-schema-checker] 无对比基准提交,跳过检测。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Path root = repoRoot.toAbsolutePath().normalize();
|
||||
SchemaCheckAnalyzer analyzer = new SchemaCheckAnalyzer(config, root);
|
||||
CheckReport report = analyzer.analyze(oldSha, newSha);
|
||||
|
||||
report.setBranch(branch);
|
||||
report.setModifier(modifier);
|
||||
report.setModifyTime(modifyTime);
|
||||
report.setRepository(repository != null ? repository : root.getFileName().toString());
|
||||
|
||||
ReportBuilder builder = new ReportBuilder(config.getNotify().getTitlePrefix());
|
||||
System.out.println(builder.toConsole(report));
|
||||
|
||||
boolean shouldNotify = config.getNotify().isEnabled()
|
||||
&& (report.hasChanges() || config.getNotify().isNotifyOnClean());
|
||||
if (shouldNotify && !dryRun) {
|
||||
String webhook = System.getenv(config.getNotify().getWebhookEnv());
|
||||
boolean ok = new WeComNotifier().sendMarkdown(webhook, builder.toMarkdown(report));
|
||||
System.out.println("[redis-schema-checker] 企微通知发送: " + (ok ? "成功" : "失败/跳过"));
|
||||
}
|
||||
|
||||
if (report.isBlocked()) {
|
||||
System.out.println("[redis-schema-checker] block 模式命中,流水线将被阻断(exit 1)。");
|
||||
}
|
||||
return report.getExitCode();
|
||||
} catch (Exception e) {
|
||||
System.err.println("[redis-schema-checker] 执行错误: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
int exitCode = new CommandLine(new RedisSchemaCheckerMain()).execute(args);
|
||||
System.exit(exitCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package com.codechecker.redis.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 检测器运行配置。由 {@link ConfigLoader} 加载 jar 内默认配置并与业务配置深度合并后构建。
|
||||
*/
|
||||
public class CheckerConfig {
|
||||
|
||||
/** notify | block */
|
||||
private String mode = "notify";
|
||||
|
||||
private List<String> blockSeverities = new ArrayList<>();
|
||||
|
||||
private boolean scanTestSources = false;
|
||||
|
||||
private List<String> sourceRoots = new ArrayList<>();
|
||||
|
||||
private Notify notify = new Notify();
|
||||
|
||||
private Ignore ignore = new Ignore();
|
||||
|
||||
private Detection detection = new Detection();
|
||||
|
||||
private Map<String, String> severityOverrides = new LinkedHashMap<>();
|
||||
|
||||
private List<ManualMapping> manualMappings = new ArrayList<>();
|
||||
|
||||
private List<Suppression> suppressions = new ArrayList<>();
|
||||
|
||||
private List<String> includeModules = new ArrayList<>();
|
||||
|
||||
private List<String> excludeModules = new ArrayList<>();
|
||||
|
||||
public static class Notify {
|
||||
private boolean enabled = true;
|
||||
private String webhookEnv = "WECOM_ROBOT_WEBHOOK";
|
||||
private boolean notifyOnClean = false;
|
||||
private String titlePrefix = "[Redis结构变更]";
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getWebhookEnv() {
|
||||
return webhookEnv;
|
||||
}
|
||||
|
||||
public void setWebhookEnv(String webhookEnv) {
|
||||
this.webhookEnv = webhookEnv;
|
||||
}
|
||||
|
||||
public boolean isNotifyOnClean() {
|
||||
return notifyOnClean;
|
||||
}
|
||||
|
||||
public void setNotifyOnClean(boolean notifyOnClean) {
|
||||
this.notifyOnClean = notifyOnClean;
|
||||
}
|
||||
|
||||
public String getTitlePrefix() {
|
||||
return titlePrefix;
|
||||
}
|
||||
|
||||
public void setTitlePrefix(String titlePrefix) {
|
||||
this.titlePrefix = titlePrefix;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Ignore {
|
||||
private List<String> keyPatterns = new ArrayList<>();
|
||||
private List<String> filePatterns = new ArrayList<>();
|
||||
private List<String> writerMethods = new ArrayList<>();
|
||||
|
||||
public List<String> getKeyPatterns() {
|
||||
return keyPatterns;
|
||||
}
|
||||
|
||||
public void setKeyPatterns(List<String> keyPatterns) {
|
||||
this.keyPatterns = keyPatterns;
|
||||
}
|
||||
|
||||
public List<String> getFilePatterns() {
|
||||
return filePatterns;
|
||||
}
|
||||
|
||||
public void setFilePatterns(List<String> filePatterns) {
|
||||
this.filePatterns = filePatterns;
|
||||
}
|
||||
|
||||
public List<String> getWriterMethods() {
|
||||
return writerMethods;
|
||||
}
|
||||
|
||||
public void setWriterMethods(List<String> writerMethods) {
|
||||
this.writerMethods = writerMethods;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Detection {
|
||||
private List<String> patterns = new ArrayList<>();
|
||||
private double minConfidence = 0.6;
|
||||
private int maxFieldDepth = 8;
|
||||
|
||||
public List<String> getPatterns() {
|
||||
return patterns;
|
||||
}
|
||||
|
||||
public void setPatterns(List<String> patterns) {
|
||||
this.patterns = patterns;
|
||||
}
|
||||
|
||||
public double getMinConfidence() {
|
||||
return minConfidence;
|
||||
}
|
||||
|
||||
public void setMinConfidence(double minConfidence) {
|
||||
this.minConfidence = minConfidence;
|
||||
}
|
||||
|
||||
public int getMaxFieldDepth() {
|
||||
return maxFieldDepth;
|
||||
}
|
||||
|
||||
public void setMaxFieldDepth(int maxFieldDepth) {
|
||||
this.maxFieldDepth = maxFieldDepth;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ManualMapping {
|
||||
private String id;
|
||||
private String writerMethod;
|
||||
private String keyPattern;
|
||||
private String valueType;
|
||||
private String description;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getWriterMethod() {
|
||||
return writerMethod;
|
||||
}
|
||||
|
||||
public void setWriterMethod(String writerMethod) {
|
||||
this.writerMethod = writerMethod;
|
||||
}
|
||||
|
||||
public String getKeyPattern() {
|
||||
return keyPattern;
|
||||
}
|
||||
|
||||
public void setKeyPattern(String keyPattern) {
|
||||
this.keyPattern = keyPattern;
|
||||
}
|
||||
|
||||
public String getValueType() {
|
||||
return valueType;
|
||||
}
|
||||
|
||||
public void setValueType(String valueType) {
|
||||
this.valueType = valueType;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Suppression {
|
||||
private String id;
|
||||
private String writerMethod;
|
||||
private String keyPattern;
|
||||
private List<String> changeTypes = new ArrayList<>();
|
||||
private String reason;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getWriterMethod() {
|
||||
return writerMethod;
|
||||
}
|
||||
|
||||
public void setWriterMethod(String writerMethod) {
|
||||
this.writerMethod = writerMethod;
|
||||
}
|
||||
|
||||
public String getKeyPattern() {
|
||||
return keyPattern;
|
||||
}
|
||||
|
||||
public void setKeyPattern(String keyPattern) {
|
||||
this.keyPattern = keyPattern;
|
||||
}
|
||||
|
||||
public List<String> getChangeTypes() {
|
||||
return changeTypes;
|
||||
}
|
||||
|
||||
public void setChangeTypes(List<String> changeTypes) {
|
||||
this.changeTypes = changeTypes;
|
||||
}
|
||||
|
||||
public String getReason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
public void setReason(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
public String getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(String mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public List<String> getBlockSeverities() {
|
||||
return blockSeverities;
|
||||
}
|
||||
|
||||
public void setBlockSeverities(List<String> blockSeverities) {
|
||||
this.blockSeverities = blockSeverities;
|
||||
}
|
||||
|
||||
public boolean isScanTestSources() {
|
||||
return scanTestSources;
|
||||
}
|
||||
|
||||
public void setScanTestSources(boolean scanTestSources) {
|
||||
this.scanTestSources = scanTestSources;
|
||||
}
|
||||
|
||||
public List<String> getSourceRoots() {
|
||||
return sourceRoots;
|
||||
}
|
||||
|
||||
public void setSourceRoots(List<String> sourceRoots) {
|
||||
this.sourceRoots = sourceRoots;
|
||||
}
|
||||
|
||||
public Notify getNotify() {
|
||||
return notify;
|
||||
}
|
||||
|
||||
public void setNotify(Notify notify) {
|
||||
this.notify = notify;
|
||||
}
|
||||
|
||||
public Ignore getIgnore() {
|
||||
return ignore;
|
||||
}
|
||||
|
||||
public void setIgnore(Ignore ignore) {
|
||||
this.ignore = ignore;
|
||||
}
|
||||
|
||||
public Detection getDetection() {
|
||||
return detection;
|
||||
}
|
||||
|
||||
public void setDetection(Detection detection) {
|
||||
this.detection = detection;
|
||||
}
|
||||
|
||||
public Map<String, String> getSeverityOverrides() {
|
||||
return severityOverrides;
|
||||
}
|
||||
|
||||
public void setSeverityOverrides(Map<String, String> severityOverrides) {
|
||||
this.severityOverrides = severityOverrides;
|
||||
}
|
||||
|
||||
public List<ManualMapping> getManualMappings() {
|
||||
return manualMappings;
|
||||
}
|
||||
|
||||
public void setManualMappings(List<ManualMapping> manualMappings) {
|
||||
this.manualMappings = manualMappings;
|
||||
}
|
||||
|
||||
public List<Suppression> getSuppressions() {
|
||||
return suppressions;
|
||||
}
|
||||
|
||||
public void setSuppressions(List<Suppression> suppressions) {
|
||||
this.suppressions = suppressions;
|
||||
}
|
||||
|
||||
public List<String> getIncludeModules() {
|
||||
return includeModules;
|
||||
}
|
||||
|
||||
public void setIncludeModules(List<String> includeModules) {
|
||||
this.includeModules = includeModules;
|
||||
}
|
||||
|
||||
public List<String> getExcludeModules() {
|
||||
return excludeModules;
|
||||
}
|
||||
|
||||
public void setExcludeModules(List<String> excludeModules) {
|
||||
this.excludeModules = excludeModules;
|
||||
}
|
||||
|
||||
public boolean isBlockMode() {
|
||||
return "block".equalsIgnoreCase(mode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.codechecker.redis.config;
|
||||
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 双层配置加载器:先读取 jar 内 {@code default-config.yaml},再与业务仓库配置深度合并(业务配置优先)。
|
||||
*/
|
||||
public final class ConfigLoader {
|
||||
|
||||
private static final String DEFAULT_CONFIG_RESOURCE = "default-config.yaml";
|
||||
|
||||
private ConfigLoader() {
|
||||
}
|
||||
|
||||
public static CheckerConfig load(Path businessConfigPath) {
|
||||
Map<String, Object> merged = loadDefault();
|
||||
if (businessConfigPath != null && Files.exists(businessConfigPath)) {
|
||||
Map<String, Object> business = loadYaml(businessConfigPath);
|
||||
merged = deepMerge(merged, business);
|
||||
}
|
||||
return bind(merged);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> loadDefault() {
|
||||
try (InputStream in = ConfigLoader.class.getClassLoader().getResourceAsStream(DEFAULT_CONFIG_RESOURCE)) {
|
||||
if (in == null) {
|
||||
throw new IllegalStateException("jar 内缺少 default-config.yaml");
|
||||
}
|
||||
Object obj = new Yaml().load(in);
|
||||
return obj == null ? new LinkedHashMap<>() : (Map<String, Object>) obj;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("读取默认配置失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> loadYaml(Path path) {
|
||||
try (InputStream in = Files.newInputStream(path)) {
|
||||
Object obj = new Yaml().load(in);
|
||||
return obj == null ? new LinkedHashMap<>() : (Map<String, Object>) obj;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("读取业务配置失败: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> deepMerge(Map<String, Object> base, Map<String, Object> override) {
|
||||
Map<String, Object> result = new LinkedHashMap<>(base);
|
||||
for (Map.Entry<String, Object> entry : override.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object overrideValue = entry.getValue();
|
||||
Object baseValue = result.get(key);
|
||||
if (baseValue instanceof Map && overrideValue instanceof Map) {
|
||||
result.put(key, deepMerge((Map<String, Object>) baseValue, (Map<String, Object>) overrideValue));
|
||||
} else {
|
||||
// 标量、列表:业务配置直接覆盖
|
||||
result.put(key, overrideValue);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static CheckerConfig bind(Map<String, Object> map) {
|
||||
CheckerConfig config = new CheckerConfig();
|
||||
|
||||
config.setMode(str(map, "mode", "notify"));
|
||||
config.setBlockSeverities(strList(map.get("block_severities")));
|
||||
config.setScanTestSources(bool(map, "scan_test_sources", false));
|
||||
config.setSourceRoots(strList(map.get("source_roots")));
|
||||
config.setIncludeModules(strList(map.get("include_modules")));
|
||||
config.setExcludeModules(strList(map.get("exclude_modules")));
|
||||
|
||||
Map<String, Object> notify = asMap(map.get("notify"));
|
||||
CheckerConfig.Notify n = config.getNotify();
|
||||
n.setEnabled(bool(notify, "enabled", true));
|
||||
n.setWebhookEnv(str(notify, "webhook_env", "WECOM_ROBOT_WEBHOOK"));
|
||||
n.setNotifyOnClean(bool(notify, "notify_on_clean", false));
|
||||
n.setTitlePrefix(str(notify, "title_prefix", "[Redis结构变更]"));
|
||||
|
||||
Map<String, Object> ignore = asMap(map.get("ignore"));
|
||||
CheckerConfig.Ignore ig = config.getIgnore();
|
||||
ig.setKeyPatterns(strList(ignore.get("key_patterns")));
|
||||
ig.setFilePatterns(strList(ignore.get("file_patterns")));
|
||||
ig.setWriterMethods(strList(ignore.get("writer_methods")));
|
||||
|
||||
Map<String, Object> detection = asMap(map.get("detection"));
|
||||
CheckerConfig.Detection d = config.getDetection();
|
||||
d.setPatterns(strList(detection.get("patterns")));
|
||||
d.setMinConfidence(dbl(detection, "min_confidence", 0.6));
|
||||
d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8));
|
||||
|
||||
Map<String, Object> severityOverrides = asMap(map.get("severity_overrides"));
|
||||
Map<String, String> so = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : severityOverrides.entrySet()) {
|
||||
so.put(e.getKey(), String.valueOf(e.getValue()));
|
||||
}
|
||||
config.setSeverityOverrides(so);
|
||||
|
||||
List<CheckerConfig.ManualMapping> mappings = new ArrayList<>();
|
||||
for (Object item : asList(map.get("manual_mappings"))) {
|
||||
Map<String, Object> m = asMap(item);
|
||||
CheckerConfig.ManualMapping mm = new CheckerConfig.ManualMapping();
|
||||
mm.setId(str(m, "id", null));
|
||||
mm.setWriterMethod(str(m, "writer_method", null));
|
||||
mm.setKeyPattern(str(m, "key_pattern", null));
|
||||
mm.setValueType(str(m, "value_type", null));
|
||||
mm.setDescription(str(m, "description", null));
|
||||
mappings.add(mm);
|
||||
}
|
||||
config.setManualMappings(mappings);
|
||||
|
||||
List<CheckerConfig.Suppression> suppressions = new ArrayList<>();
|
||||
for (Object item : asList(map.get("suppressions"))) {
|
||||
Map<String, Object> m = asMap(item);
|
||||
CheckerConfig.Suppression sp = new CheckerConfig.Suppression();
|
||||
sp.setId(str(m, "id", null));
|
||||
sp.setWriterMethod(str(m, "writer_method", null));
|
||||
sp.setKeyPattern(str(m, "key_pattern", null));
|
||||
sp.setChangeTypes(strList(m.get("change_types")));
|
||||
sp.setReason(str(m, "reason", null));
|
||||
suppressions.add(sp);
|
||||
}
|
||||
config.setSuppressions(suppressions);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> asMap(Object obj) {
|
||||
if (obj instanceof Map) {
|
||||
return (Map<String, Object>) obj;
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
private static List<Object> asList(Object obj) {
|
||||
if (obj instanceof List) {
|
||||
return (List<Object>) obj;
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
private static List<String> strList(Object obj) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (obj instanceof List) {
|
||||
for (Object o : (List<?>) obj) {
|
||||
if (o != null) {
|
||||
result.add(String.valueOf(o));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String str(Map<String, Object> map, String key, String def) {
|
||||
Object v = map.get(key);
|
||||
return v == null ? def : String.valueOf(v);
|
||||
}
|
||||
|
||||
private static boolean bool(Map<String, Object> map, String key, boolean def) {
|
||||
Object v = map.get(key);
|
||||
if (v instanceof Boolean) {
|
||||
return (Boolean) v;
|
||||
}
|
||||
return v == null ? def : Boolean.parseBoolean(String.valueOf(v));
|
||||
}
|
||||
|
||||
private static double dbl(Map<String, Object> map, String key, double def) {
|
||||
Object v = map.get(key);
|
||||
if (v instanceof Number) {
|
||||
return ((Number) v).doubleValue();
|
||||
}
|
||||
return v == null ? def : Double.parseDouble(String.valueOf(v));
|
||||
}
|
||||
|
||||
private static long lng(Map<String, Object> map, String key, long def) {
|
||||
Object v = map.get(key);
|
||||
if (v instanceof Number) {
|
||||
return ((Number) v).longValue();
|
||||
}
|
||||
return v == null ? def : Long.parseLong(String.valueOf(v));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.codechecker.redis.detector;
|
||||
|
||||
import com.codechecker.redis.key.RedisKeyResolver;
|
||||
import com.codechecker.redis.schema.SourceIndex;
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.body.CallableDeclaration;
|
||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||
import com.github.javaparser.ast.body.Parameter;
|
||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
import com.github.javaparser.ast.body.VariableDeclarator;
|
||||
import com.github.javaparser.ast.expr.Expression;
|
||||
import com.github.javaparser.ast.expr.MethodCallExpr;
|
||||
import com.github.javaparser.ast.expr.NameExpr;
|
||||
import com.github.javaparser.ast.expr.ObjectCreationExpr;
|
||||
import com.github.javaparser.ast.type.ClassOrInterfaceType;
|
||||
import com.github.javaparser.ast.type.Type;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 从单个 Java 源文件中检测 Redis value 写入点(W01~W03:JSON 字符串写入)。
|
||||
*/
|
||||
public class RedisWritePointDetector {
|
||||
|
||||
private static final Set<String> SERIALIZE_METHODS = new HashSet<>(Arrays.asList(
|
||||
"toJSONString", "toJsonString", "getObjectToString", "toJsonStr", "writeValueAsString"));
|
||||
private static final Set<String> WRITE_METHODS = new HashSet<>(Arrays.asList("set", "insert"));
|
||||
private static final Set<String> COLLECTION_SIMPLE = new HashSet<>(Arrays.asList(
|
||||
"List", "ArrayList", "LinkedList", "Set", "HashSet", "Collection"));
|
||||
|
||||
private final SourceIndex index;
|
||||
private final Set<String> enabledPatterns;
|
||||
private final RedisKeyResolver keyResolver;
|
||||
|
||||
public RedisWritePointDetector(SourceIndex index, Set<String> enabledPatterns) {
|
||||
this.index = index;
|
||||
this.enabledPatterns = enabledPatterns;
|
||||
this.keyResolver = new RedisKeyResolver(index);
|
||||
}
|
||||
|
||||
public List<WritePoint> detect(String filePath, String content) {
|
||||
List<WritePoint> result = new ArrayList<>();
|
||||
if (content == null || content.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
CompilationUnit cu;
|
||||
try {
|
||||
cu = StaticJavaParser.parse(content);
|
||||
} catch (RuntimeException e) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (MethodCallExpr mce : cu.findAll(MethodCallExpr.class)) {
|
||||
String method = mce.getNameAsString();
|
||||
if (!WRITE_METHODS.contains(method)) {
|
||||
continue;
|
||||
}
|
||||
String scope = mce.getScope().map(Expression::toString).orElse("");
|
||||
if (!isRedisScope(scope)) {
|
||||
continue;
|
||||
}
|
||||
if (mce.getArguments().size() < 2) {
|
||||
continue;
|
||||
}
|
||||
Expression valueArg = mce.getArgument(1);
|
||||
Expression serialized = unwrapSerializer(valueArg);
|
||||
if (serialized == null) {
|
||||
continue; // 非 JSON 字符串写入(W04/W05 归后续阶段)
|
||||
}
|
||||
String pattern = classify(method, valueArg);
|
||||
if (!enabledPatterns.contains(pattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
WritePoint wp = new WritePoint();
|
||||
wp.setFilePath(filePath);
|
||||
wp.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0));
|
||||
wp.setPattern(pattern);
|
||||
wp.setKeyExpression(mce.getArgument(0).toString());
|
||||
wp.setValueExpression(valueArg.toString());
|
||||
|
||||
fillEnclosing(mce, wp);
|
||||
|
||||
SourceIndex.IndexedType context = index.get(wp.getEnclosingClass());
|
||||
ClassOrInterfaceDeclaration enclosingDecl = mce
|
||||
.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null);
|
||||
wp.setResolvedKeyPattern(keyResolver.resolve(mce.getArgument(0), enclosingDecl, context));
|
||||
InferredType inferred = inferType(serialized, mce, context);
|
||||
if (inferred != null) {
|
||||
wp.setResolvedValueType(inferred.fqn);
|
||||
wp.setRootArray(inferred.isArray);
|
||||
wp.setConfidence(inferred.fqn == null ? 0.4 : 1.0);
|
||||
} else {
|
||||
wp.setConfidence(0.4);
|
||||
}
|
||||
result.add(wp);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isRedisScope(String scope) {
|
||||
String lower = scope.toLowerCase();
|
||||
return lower.contains("redis") || lower.contains("opsforvalue") || lower.contains("boundvalueops");
|
||||
}
|
||||
|
||||
private Expression unwrapSerializer(Expression valueArg) {
|
||||
if (valueArg instanceof MethodCallExpr) {
|
||||
MethodCallExpr call = (MethodCallExpr) valueArg;
|
||||
if (SERIALIZE_METHODS.contains(call.getNameAsString()) && !call.getArguments().isEmpty()) {
|
||||
return call.getArgument(0);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String classify(String method, Expression valueArg) {
|
||||
String serializer = valueArg instanceof MethodCallExpr
|
||||
? ((MethodCallExpr) valueArg).getNameAsString() : "";
|
||||
if ("insert".equals(method)) {
|
||||
return "W01";
|
||||
}
|
||||
if ("getObjectToString".equals(serializer)) {
|
||||
return "W03";
|
||||
}
|
||||
return "W02";
|
||||
}
|
||||
|
||||
private void fillEnclosing(MethodCallExpr mce, WritePoint wp) {
|
||||
Optional<ClassOrInterfaceDeclaration> clazz = mce.findAncestor(ClassOrInterfaceDeclaration.class);
|
||||
wp.setEnclosingClass(clazz.map(this::fqnOf).orElse("<unknown>"));
|
||||
Optional<CallableDeclaration> method = mce.findAncestor(CallableDeclaration.class);
|
||||
wp.setEnclosingMethod(method.map(NodeName::of).orElse("<unknown>"));
|
||||
}
|
||||
|
||||
private String fqnOf(ClassOrInterfaceDeclaration decl) {
|
||||
return decl.getFullyQualifiedName().orElse(decl.getNameAsString());
|
||||
}
|
||||
|
||||
private InferredType inferType(Expression expr, MethodCallExpr contextCall, SourceIndex.IndexedType context) {
|
||||
if (expr instanceof ObjectCreationExpr) {
|
||||
ClassOrInterfaceType t = ((ObjectCreationExpr) expr).getType();
|
||||
return resolveTypeNode(t, context);
|
||||
}
|
||||
if (expr instanceof NameExpr) {
|
||||
String name = ((NameExpr) expr).getNameAsString();
|
||||
Type declared = findVariableType(name, contextCall);
|
||||
if (declared != null) {
|
||||
return resolveTypeNode(declared, context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Type findVariableType(String name, MethodCallExpr contextCall) {
|
||||
Optional<CallableDeclaration> callable = contextCall.findAncestor(CallableDeclaration.class);
|
||||
if (callable.isPresent()) {
|
||||
CallableDeclaration<?> decl = callable.get();
|
||||
// 局部变量
|
||||
for (VariableDeclarator var : decl.findAll(VariableDeclarator.class)) {
|
||||
if (var.getNameAsString().equals(name)) {
|
||||
return var.getType();
|
||||
}
|
||||
}
|
||||
// 方法参数
|
||||
for (Parameter p : decl.getParameters()) {
|
||||
if (p.getNameAsString().equals(name)) {
|
||||
return p.getType();
|
||||
}
|
||||
}
|
||||
}
|
||||
// 类字段
|
||||
Optional<ClassOrInterfaceDeclaration> clazz = contextCall.findAncestor(ClassOrInterfaceDeclaration.class);
|
||||
if (clazz.isPresent()) {
|
||||
for (FieldDeclaration field : clazz.get().getFields()) {
|
||||
for (VariableDeclarator var : field.getVariables()) {
|
||||
if (var.getNameAsString().equals(name)) {
|
||||
return var.getType();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private InferredType resolveTypeNode(Type type, SourceIndex.IndexedType context) {
|
||||
if (!(type instanceof ClassOrInterfaceType)) {
|
||||
return new InferredType(null, false);
|
||||
}
|
||||
ClassOrInterfaceType cit = (ClassOrInterfaceType) type;
|
||||
String simple = cit.getNameAsString();
|
||||
if (COLLECTION_SIMPLE.contains(simple)) {
|
||||
Optional<Type> arg = cit.getTypeArguments().filter(a -> !a.isEmpty()).map(a -> a.get(0));
|
||||
if (arg.isPresent() && arg.get() instanceof ClassOrInterfaceType) {
|
||||
String elementFqn = resolveFqn((ClassOrInterfaceType) arg.get(), context);
|
||||
return new InferredType(elementFqn, true);
|
||||
}
|
||||
return new InferredType(null, true);
|
||||
}
|
||||
return new InferredType(resolveFqn(cit, context), false);
|
||||
}
|
||||
|
||||
private String resolveFqn(ClassOrInterfaceType cit, SourceIndex.IndexedType context) {
|
||||
String fqn = index.resolveFqn(cit.getNameWithScope(), context);
|
||||
if (fqn == null) {
|
||||
fqn = index.resolveFqn(cit.getNameAsString(), context);
|
||||
}
|
||||
return fqn;
|
||||
}
|
||||
|
||||
private static final class InferredType {
|
||||
final String fqn;
|
||||
final boolean isArray;
|
||||
|
||||
InferredType(String fqn, boolean isArray) {
|
||||
this.fqn = fqn;
|
||||
this.isArray = isArray;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NodeName {
|
||||
static String of(CallableDeclaration<?> decl) {
|
||||
return decl.getNameAsString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.codechecker.redis.detector;
|
||||
|
||||
/**
|
||||
* 一个 Redis value 写入点的静态描述。
|
||||
*/
|
||||
public class WritePoint {
|
||||
|
||||
private String filePath;
|
||||
private int lineNumber;
|
||||
private String enclosingClass;
|
||||
private String enclosingMethod;
|
||||
private String pattern;
|
||||
|
||||
private String keyExpression;
|
||||
private String resolvedKeyPattern;
|
||||
|
||||
private String valueExpression;
|
||||
private String resolvedValueType;
|
||||
private boolean rootArray;
|
||||
|
||||
private double confidence = 1.0;
|
||||
|
||||
/** 稳定标识:用于在 old/new 两个版本间配对同一写入点 */
|
||||
public String signature() {
|
||||
return enclosingClass + "#" + enclosingMethod + "|" + normalizeKey();
|
||||
}
|
||||
|
||||
private String normalizeKey() {
|
||||
return keyExpression == null ? "" : keyExpression.replaceAll("\\s+", "");
|
||||
}
|
||||
|
||||
public String getFilePath() {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
public void setFilePath(String filePath) {
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
public int getLineNumber() {
|
||||
return lineNumber;
|
||||
}
|
||||
|
||||
public void setLineNumber(int lineNumber) {
|
||||
this.lineNumber = lineNumber;
|
||||
}
|
||||
|
||||
public String getEnclosingClass() {
|
||||
return enclosingClass;
|
||||
}
|
||||
|
||||
public void setEnclosingClass(String enclosingClass) {
|
||||
this.enclosingClass = enclosingClass;
|
||||
}
|
||||
|
||||
public String getEnclosingMethod() {
|
||||
return enclosingMethod;
|
||||
}
|
||||
|
||||
public void setEnclosingMethod(String enclosingMethod) {
|
||||
this.enclosingMethod = enclosingMethod;
|
||||
}
|
||||
|
||||
public String getPattern() {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
public void setPattern(String pattern) {
|
||||
this.pattern = pattern;
|
||||
}
|
||||
|
||||
public String getKeyExpression() {
|
||||
return keyExpression;
|
||||
}
|
||||
|
||||
public void setKeyExpression(String keyExpression) {
|
||||
this.keyExpression = keyExpression;
|
||||
}
|
||||
|
||||
public String getResolvedKeyPattern() {
|
||||
return resolvedKeyPattern;
|
||||
}
|
||||
|
||||
public void setResolvedKeyPattern(String resolvedKeyPattern) {
|
||||
this.resolvedKeyPattern = resolvedKeyPattern;
|
||||
}
|
||||
|
||||
public String getValueExpression() {
|
||||
return valueExpression;
|
||||
}
|
||||
|
||||
public void setValueExpression(String valueExpression) {
|
||||
this.valueExpression = valueExpression;
|
||||
}
|
||||
|
||||
public String getResolvedValueType() {
|
||||
return resolvedValueType;
|
||||
}
|
||||
|
||||
public void setResolvedValueType(String resolvedValueType) {
|
||||
this.resolvedValueType = resolvedValueType;
|
||||
}
|
||||
|
||||
public boolean isRootArray() {
|
||||
return rootArray;
|
||||
}
|
||||
|
||||
public void setRootArray(boolean rootArray) {
|
||||
this.rootArray = rootArray;
|
||||
}
|
||||
|
||||
public double getConfidence() {
|
||||
return confidence;
|
||||
}
|
||||
|
||||
public void setConfidence(double confidence) {
|
||||
this.confidence = confidence;
|
||||
}
|
||||
|
||||
public String location() {
|
||||
String simpleClass = enclosingClass;
|
||||
if (simpleClass != null && simpleClass.contains(".")) {
|
||||
simpleClass = simpleClass.substring(simpleClass.lastIndexOf('.') + 1);
|
||||
}
|
||||
return simpleClass + "#" + enclosingMethod + ":" + lineNumber;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.codechecker.redis.diff;
|
||||
|
||||
/**
|
||||
* 结构变更类型及其默认严重级别。
|
||||
*/
|
||||
public enum ChangeType {
|
||||
FIELD_REMOVED(Severity.P0, "字段删除"),
|
||||
TYPE_CHANGED(Severity.P0, "字段类型变更"),
|
||||
WRAPPER_ADDED(Severity.P0, "新增包装层"),
|
||||
FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"),
|
||||
FIELD_ADDED(Severity.P1, "新增字段"),
|
||||
KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"),
|
||||
WRITE_POINT_REMOVED(Severity.P1, "删除写入点"),
|
||||
WRITE_POINT_ADDED(Severity.P2, "新增写入点"),
|
||||
LOW_CONFIDENCE(Severity.P2, "低置信度结构变更");
|
||||
|
||||
private final Severity defaultSeverity;
|
||||
private final String label;
|
||||
|
||||
ChangeType(Severity defaultSeverity, String label) {
|
||||
this.defaultSeverity = defaultSeverity;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public Severity getDefaultSeverity() {
|
||||
return defaultSeverity;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.codechecker.redis.diff;
|
||||
|
||||
/**
|
||||
* 一条结构变更记录。
|
||||
*/
|
||||
public class SchemaChange {
|
||||
|
||||
private Severity severity;
|
||||
private ChangeType changeType;
|
||||
private String keyPattern;
|
||||
private String writeLocation;
|
||||
private String fieldPath;
|
||||
private String oldValue;
|
||||
private String newValue;
|
||||
private String message;
|
||||
|
||||
public SchemaChange(ChangeType changeType) {
|
||||
this.changeType = changeType;
|
||||
this.severity = changeType.getDefaultSeverity();
|
||||
}
|
||||
|
||||
public Severity getSeverity() {
|
||||
return severity;
|
||||
}
|
||||
|
||||
public void setSeverity(Severity severity) {
|
||||
this.severity = severity;
|
||||
}
|
||||
|
||||
public ChangeType getChangeType() {
|
||||
return changeType;
|
||||
}
|
||||
|
||||
public void setChangeType(ChangeType changeType) {
|
||||
this.changeType = changeType;
|
||||
}
|
||||
|
||||
public String getKeyPattern() {
|
||||
return keyPattern;
|
||||
}
|
||||
|
||||
public void setKeyPattern(String keyPattern) {
|
||||
this.keyPattern = keyPattern;
|
||||
}
|
||||
|
||||
public String getWriteLocation() {
|
||||
return writeLocation;
|
||||
}
|
||||
|
||||
public void setWriteLocation(String writeLocation) {
|
||||
this.writeLocation = writeLocation;
|
||||
}
|
||||
|
||||
public String getFieldPath() {
|
||||
return fieldPath;
|
||||
}
|
||||
|
||||
public void setFieldPath(String fieldPath) {
|
||||
this.fieldPath = fieldPath;
|
||||
}
|
||||
|
||||
public String getOldValue() {
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
public void setOldValue(String oldValue) {
|
||||
this.oldValue = oldValue;
|
||||
}
|
||||
|
||||
public String getNewValue() {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
public void setNewValue(String newValue) {
|
||||
this.newValue = newValue;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.codechecker.redis.diff;
|
||||
|
||||
import com.codechecker.redis.schema.FieldSchema;
|
||||
import com.codechecker.redis.schema.JsonType;
|
||||
import com.codechecker.redis.schema.TypeSchema;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 对比两个版本的 {@link TypeSchema},输出结构变更列表。基于叶子字段路径集合进行差异分析。
|
||||
*/
|
||||
public class SchemaDiffer {
|
||||
|
||||
/**
|
||||
* @return 结构变更列表(不含 keyPattern/location 上下文,由调用方补充)
|
||||
*/
|
||||
public List<SchemaChange> diff(TypeSchema oldSchema, TypeSchema newSchema) {
|
||||
List<SchemaChange> changes = new ArrayList<>();
|
||||
|
||||
Map<String, JsonType> oldLeaves = leaves(oldSchema);
|
||||
Map<String, JsonType> newLeaves = leaves(newSchema);
|
||||
|
||||
Set<String> removed = new LinkedHashSet<>(oldLeaves.keySet());
|
||||
removed.removeAll(newLeaves.keySet());
|
||||
Set<String> added = new LinkedHashSet<>(newLeaves.keySet());
|
||||
added.removeAll(oldLeaves.keySet());
|
||||
|
||||
// 类型变更(同路径)
|
||||
for (String path : oldLeaves.keySet()) {
|
||||
if (newLeaves.containsKey(path)) {
|
||||
JsonType oldType = oldLeaves.get(path);
|
||||
JsonType newType = newLeaves.get(path);
|
||||
if (oldType != newType) {
|
||||
SchemaChange c = new SchemaChange(ChangeType.TYPE_CHANGED);
|
||||
c.setFieldPath(path);
|
||||
c.setOldValue(oldType.name());
|
||||
c.setNewValue(newType.name());
|
||||
c.setMessage("字段 " + path + " 类型由 " + oldType + " 变为 " + newType);
|
||||
changes.add(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 路径迁移检测(removed 的路径是某 added 路径的后缀)
|
||||
List<String[]> moves = new ArrayList<>();
|
||||
Set<String> matchedRemoved = new LinkedHashSet<>();
|
||||
Set<String> matchedAdded = new LinkedHashSet<>();
|
||||
for (String r : removed) {
|
||||
for (String a : added) {
|
||||
if (matchedAdded.contains(a)) {
|
||||
continue;
|
||||
}
|
||||
if (isSuffix(a, r)) {
|
||||
moves.add(new String[]{r, a});
|
||||
matchedRemoved.add(r);
|
||||
matchedAdded.add(a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 包装层检测:多个迁移共享同一新前缀
|
||||
Map<String, Integer> prefixCount = new LinkedHashMap<>();
|
||||
for (String[] move : moves) {
|
||||
String a = move[1];
|
||||
if (a.contains(".")) {
|
||||
String prefix = a.substring(0, a.indexOf('.'));
|
||||
prefixCount.merge(prefix, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, Integer> e : prefixCount.entrySet()) {
|
||||
if (e.getValue() >= 2) {
|
||||
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED);
|
||||
c.setFieldPath(e.getKey());
|
||||
c.setNewValue(e.getKey());
|
||||
c.setMessage("新增包装层 " + e.getKey() + ",原顶层字段被下移至该层(影响 " + e.getValue() + " 个字段)");
|
||||
changes.add(c);
|
||||
}
|
||||
}
|
||||
|
||||
for (String[] move : moves) {
|
||||
SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||
c.setFieldPath(move[1]);
|
||||
c.setOldValue(move[0]);
|
||||
c.setNewValue(move[1]);
|
||||
c.setMessage("字段路径迁移:" + move[0] + " → " + move[1]);
|
||||
changes.add(c);
|
||||
}
|
||||
|
||||
// 剩余删除
|
||||
for (String r : removed) {
|
||||
if (matchedRemoved.contains(r)) {
|
||||
continue;
|
||||
}
|
||||
SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED);
|
||||
c.setFieldPath(r);
|
||||
c.setOldValue(oldLeaves.get(r).name());
|
||||
c.setMessage("删除字段 " + r);
|
||||
changes.add(c);
|
||||
}
|
||||
|
||||
// 剩余新增
|
||||
for (String a : added) {
|
||||
if (matchedAdded.contains(a)) {
|
||||
continue;
|
||||
}
|
||||
SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED);
|
||||
c.setFieldPath(a);
|
||||
c.setNewValue(newLeaves.get(a).name());
|
||||
c.setMessage("新增字段 " + a);
|
||||
changes.add(c);
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
private Map<String, JsonType> leaves(TypeSchema schema) {
|
||||
Map<String, JsonType> result = new LinkedHashMap<>();
|
||||
for (FieldSchema f : schema.getFields().values()) {
|
||||
if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) {
|
||||
result.put(f.getPath(), f.getJsonType());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 full 的按段后缀是否等于 suffix(如 vo.dbName 的后缀是 dbName)。
|
||||
*/
|
||||
private boolean isSuffix(String full, String suffix) {
|
||||
if (full.equals(suffix)) {
|
||||
return false;
|
||||
}
|
||||
String[] fullSeg = full.split("\\.");
|
||||
String[] sufSeg = suffix.split("\\.");
|
||||
if (sufSeg.length >= fullSeg.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 1; i <= sufSeg.length; i++) {
|
||||
if (!fullSeg[fullSeg.length - i].equals(sufSeg[sufSeg.length - i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.codechecker.redis.diff;
|
||||
|
||||
/**
|
||||
* 变更严重级别。
|
||||
*/
|
||||
public enum Severity {
|
||||
P0,
|
||||
P1,
|
||||
P2
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.codechecker.redis.git;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 基于 git 命令的差异扫描与双版本文件内容读取。
|
||||
*
|
||||
* <p>通过 {@code git diff --name-only} 获取变更的 .java 文件,通过 {@code git show sha:path}
|
||||
* 读取指定提交下的文件内容,避免检出两个完整 worktree。</p>
|
||||
*/
|
||||
public class GitDiffScanner {
|
||||
|
||||
private final Path repoRoot;
|
||||
|
||||
public GitDiffScanner(Path repoRoot) {
|
||||
this.repoRoot = repoRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 old..new 之间变更的 Java 文件路径(相对仓库根,使用 / 分隔)。
|
||||
*/
|
||||
public List<String> changedJavaFiles(String oldSha, String newSha) throws GitException {
|
||||
List<String> lines = runLines(
|
||||
"git", "diff", "--name-only", "--diff-filter=ACMR", oldSha, newSha, "--", "*.java");
|
||||
List<String> result = new ArrayList<>();
|
||||
for (String line : lines) {
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.isEmpty() && trimmed.endsWith(".java")) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取某提交下指定文件的内容;文件在该提交不存在时返回 null。
|
||||
*/
|
||||
public String fileContentAt(String sha, String path) throws GitException {
|
||||
try {
|
||||
ProcessResult pr = run("git", "show", sha + ":" + path);
|
||||
if (pr.exitCode != 0) {
|
||||
return null;
|
||||
}
|
||||
return pr.stdout;
|
||||
} catch (GitException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> runLines(String... command) throws GitException {
|
||||
ProcessResult pr = run(command);
|
||||
if (pr.exitCode != 0) {
|
||||
throw new GitException("git 命令执行失败(exit=" + pr.exitCode + "): " + String.join(" ", command)
|
||||
+ "\n" + pr.stderr);
|
||||
}
|
||||
List<String> lines = new ArrayList<>();
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(new java.io.ByteArrayInputStream(
|
||||
pr.stdout.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
lines.add(line);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new GitException("读取 git 输出失败", e);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private ProcessResult run(String... command) throws GitException {
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(repoRoot.toFile());
|
||||
pb.redirectErrorStream(false);
|
||||
try {
|
||||
Process process = pb.start();
|
||||
String stdout = readStream(process.getInputStream());
|
||||
String stderr = readStream(process.getErrorStream());
|
||||
int exit = process.waitFor();
|
||||
return new ProcessResult(exit, stdout, stderr);
|
||||
} catch (IOException e) {
|
||||
throw new GitException("无法启动 git 进程: " + String.join(" ", command), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new GitException("git 进程被中断", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readStream(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[8192];
|
||||
int read;
|
||||
while ((read = in.read(chunk)) != -1) {
|
||||
buffer.write(chunk, 0, read);
|
||||
}
|
||||
return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static final class ProcessResult {
|
||||
final int exitCode;
|
||||
final String stdout;
|
||||
final String stderr;
|
||||
|
||||
ProcessResult(int exitCode, String stdout, String stderr) {
|
||||
this.exitCode = exitCode;
|
||||
this.stdout = stdout;
|
||||
this.stderr = stderr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.codechecker.redis.git;
|
||||
|
||||
/**
|
||||
* Git 操作异常。
|
||||
*/
|
||||
public class GitException extends Exception {
|
||||
|
||||
public GitException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public GitException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.codechecker.redis.key;
|
||||
|
||||
import com.codechecker.redis.schema.SourceIndex;
|
||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||
import com.github.javaparser.ast.body.VariableDeclarator;
|
||||
import com.github.javaparser.ast.expr.BinaryExpr;
|
||||
import com.github.javaparser.ast.expr.Expression;
|
||||
import com.github.javaparser.ast.expr.FieldAccessExpr;
|
||||
import com.github.javaparser.ast.expr.MethodCallExpr;
|
||||
import com.github.javaparser.ast.expr.NameExpr;
|
||||
import com.github.javaparser.ast.expr.StringLiteralExpr;
|
||||
import com.github.javaparser.ast.stmt.ReturnStmt;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。
|
||||
*/
|
||||
public class RedisKeyResolver {
|
||||
|
||||
private static final int MAX_DEPTH = 6;
|
||||
|
||||
private final SourceIndex index;
|
||||
|
||||
public RedisKeyResolver(SourceIndex index) {
|
||||
this.index = index;
|
||||
}
|
||||
|
||||
public String resolve(Expression keyExpr, ClassOrInterfaceDeclaration enclosingClass,
|
||||
SourceIndex.IndexedType context) {
|
||||
String raw = resolveExpr(keyExpr, enclosingClass, context, 0);
|
||||
return normalize(raw);
|
||||
}
|
||||
|
||||
private String resolveExpr(Expression expr, ClassOrInterfaceDeclaration enclosingClass,
|
||||
SourceIndex.IndexedType context, int depth) {
|
||||
if (expr == null || depth > MAX_DEPTH) {
|
||||
return "*";
|
||||
}
|
||||
if (expr instanceof StringLiteralExpr) {
|
||||
return ((StringLiteralExpr) expr).asString();
|
||||
}
|
||||
if (expr instanceof BinaryExpr) {
|
||||
BinaryExpr be = (BinaryExpr) expr;
|
||||
if (be.getOperator() == BinaryExpr.Operator.PLUS) {
|
||||
return resolveExpr(be.getLeft(), enclosingClass, context, depth + 1)
|
||||
+ resolveExpr(be.getRight(), enclosingClass, context, depth + 1);
|
||||
}
|
||||
return "*";
|
||||
}
|
||||
if (expr instanceof NameExpr) {
|
||||
String name = ((NameExpr) expr).getNameAsString();
|
||||
String constVal = lookupConstant(enclosingClass, name);
|
||||
if (constVal != null) {
|
||||
return constVal;
|
||||
}
|
||||
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
|
||||
return methodVal != null ? methodVal : "*";
|
||||
}
|
||||
if (expr instanceof FieldAccessExpr) {
|
||||
FieldAccessExpr fae = (FieldAccessExpr) expr;
|
||||
String fieldName = fae.getNameAsString();
|
||||
String scope = fae.getScope().toString();
|
||||
String external = lookupExternalConstant(scope, fieldName, context);
|
||||
if (external != null) {
|
||||
return external;
|
||||
}
|
||||
String local = lookupConstant(enclosingClass, fieldName);
|
||||
return local != null ? local : "*";
|
||||
}
|
||||
if (expr instanceof MethodCallExpr) {
|
||||
MethodCallExpr call = (MethodCallExpr) expr;
|
||||
String name = call.getNameAsString();
|
||||
if ("format".equals(name) && !call.getArguments().isEmpty()) {
|
||||
String fmt = resolveExpr(call.getArgument(0), enclosingClass, context, depth + 1);
|
||||
return fmt.replaceAll("%[-0-9.]*[sdxDX]", "*");
|
||||
}
|
||||
// buildCacheKey(...) 等本类方法:解析其 return 表达式
|
||||
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
|
||||
return methodVal != null ? methodVal : "*";
|
||||
}
|
||||
return "*";
|
||||
}
|
||||
|
||||
private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name) {
|
||||
if (clazz == null) {
|
||||
return null;
|
||||
}
|
||||
for (FieldDeclaration field : clazz.getFields()) {
|
||||
for (VariableDeclarator var : field.getVariables()) {
|
||||
if (var.getNameAsString().equals(name)) {
|
||||
Optional<Expression> init = var.getInitializer();
|
||||
if (init.isPresent() && init.get() instanceof StringLiteralExpr) {
|
||||
return ((StringLiteralExpr) init.get()).asString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String lookupExternalConstant(String scopeName, String fieldName, SourceIndex.IndexedType context) {
|
||||
String fqn = index.resolveFqn(scopeName, context);
|
||||
if (fqn == null) {
|
||||
return null;
|
||||
}
|
||||
SourceIndex.IndexedType type = index.get(fqn);
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
return lookupConstant(type.getDeclaration(), fieldName);
|
||||
}
|
||||
|
||||
private String lookupMethodReturn(ClassOrInterfaceDeclaration clazz, String methodName,
|
||||
SourceIndex.IndexedType context, int depth) {
|
||||
if (clazz == null || depth > MAX_DEPTH) {
|
||||
return null;
|
||||
}
|
||||
for (MethodDeclaration method : clazz.getMethods()) {
|
||||
if (method.getNameAsString().equals(methodName) && method.getBody().isPresent()) {
|
||||
for (ReturnStmt ret : method.getBody().get().findAll(ReturnStmt.class)) {
|
||||
if (ret.getExpression().isPresent()) {
|
||||
return resolveExpr(ret.getExpression().get(), clazz, context, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String normalize(String raw) {
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return "unknown-key";
|
||||
}
|
||||
String collapsed = raw.replaceAll("\\*+", "*");
|
||||
if (collapsed.equals("*")) {
|
||||
return "unknown-key";
|
||||
}
|
||||
return collapsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.codechecker.redis.notify;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 企业微信群机器人通知(markdown 消息)。
|
||||
*/
|
||||
public class WeComNotifier {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
/**
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public boolean sendMarkdown(String webhookUrl, String markdown) {
|
||||
if (webhookUrl == null || webhookUrl.trim().isEmpty()) {
|
||||
System.err.println("[WeComNotifier] 未配置 webhook,跳过通知");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> md = new LinkedHashMap<>();
|
||||
md.put("content", markdown);
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("msgtype", "markdown");
|
||||
payload.put("markdown", md);
|
||||
|
||||
String body = mapper.writeValueAsString(payload);
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(webhookUrl))
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == 200) {
|
||||
return true;
|
||||
}
|
||||
System.err.println("[WeComNotifier] 通知失败, HTTP " + response.statusCode() + ": " + response.body());
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
System.err.println("[WeComNotifier] 通知异常: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.codechecker.redis.report;
|
||||
|
||||
import com.codechecker.redis.diff.SchemaChange;
|
||||
import com.codechecker.redis.diff.Severity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一次检测的完整结果。
|
||||
*/
|
||||
public class CheckReport {
|
||||
|
||||
private String repository;
|
||||
private String branch;
|
||||
private String oldSha;
|
||||
private String newSha;
|
||||
private String modifier;
|
||||
private String modifyTime;
|
||||
private String mode;
|
||||
|
||||
private final List<SchemaChange> changes = new ArrayList<>();
|
||||
private boolean blocked;
|
||||
private int exitCode;
|
||||
|
||||
public boolean hasChanges() {
|
||||
return !changes.isEmpty();
|
||||
}
|
||||
|
||||
public long count(Severity severity) {
|
||||
return changes.stream().filter(c -> c.getSeverity() == severity).count();
|
||||
}
|
||||
|
||||
public String getRepository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
public void setRepository(String repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public String getBranch() {
|
||||
return branch;
|
||||
}
|
||||
|
||||
public void setBranch(String branch) {
|
||||
this.branch = branch;
|
||||
}
|
||||
|
||||
public String getOldSha() {
|
||||
return oldSha;
|
||||
}
|
||||
|
||||
public void setOldSha(String oldSha) {
|
||||
this.oldSha = oldSha;
|
||||
}
|
||||
|
||||
public String getNewSha() {
|
||||
return newSha;
|
||||
}
|
||||
|
||||
public void setNewSha(String newSha) {
|
||||
this.newSha = newSha;
|
||||
}
|
||||
|
||||
public String getModifier() {
|
||||
return modifier;
|
||||
}
|
||||
|
||||
public void setModifier(String modifier) {
|
||||
this.modifier = modifier;
|
||||
}
|
||||
|
||||
public String getModifyTime() {
|
||||
return modifyTime;
|
||||
}
|
||||
|
||||
public void setModifyTime(String modifyTime) {
|
||||
this.modifyTime = modifyTime;
|
||||
}
|
||||
|
||||
public String getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(String mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public List<SchemaChange> getChanges() {
|
||||
return changes;
|
||||
}
|
||||
|
||||
public boolean isBlocked() {
|
||||
return blocked;
|
||||
}
|
||||
|
||||
public void setBlocked(boolean blocked) {
|
||||
this.blocked = blocked;
|
||||
}
|
||||
|
||||
public int getExitCode() {
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
public void setExitCode(int exitCode) {
|
||||
this.exitCode = exitCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.codechecker.redis.report;
|
||||
|
||||
import com.codechecker.redis.diff.SchemaChange;
|
||||
import com.codechecker.redis.diff.Severity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
|
||||
*/
|
||||
public class ReportBuilder {
|
||||
|
||||
private final String titlePrefix;
|
||||
|
||||
public ReportBuilder(String titlePrefix) {
|
||||
this.titlePrefix = titlePrefix == null ? "[Redis结构变更]" : titlePrefix;
|
||||
}
|
||||
|
||||
public String toMarkdown(CheckReport report) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("## ").append(titlePrefix).append(' ')
|
||||
.append(nvl(report.getRepository())).append('\n');
|
||||
sb.append("> 分支: ").append(nvl(report.getBranch())).append('\n');
|
||||
sb.append("> 提交: ").append(shortSha(report.getOldSha()))
|
||||
.append(" → ").append(shortSha(report.getNewSha())).append('\n');
|
||||
sb.append("> 提交人: ").append(nvl(report.getModifier())).append('\n');
|
||||
sb.append("> 时间: ").append(nvl(report.getModifyTime())).append('\n');
|
||||
sb.append("> 模式: ").append(nvl(report.getMode()));
|
||||
if (report.isBlocked()) {
|
||||
sb.append("(已阻断)");
|
||||
}
|
||||
sb.append('\n');
|
||||
sb.append("> 汇总: P0=").append(report.count(Severity.P0))
|
||||
.append(" P1=").append(report.count(Severity.P1))
|
||||
.append(" P2=").append(report.count(Severity.P2)).append("\n\n");
|
||||
|
||||
Map<Severity, List<SchemaChange>> grouped = new EnumMap<>(Severity.class);
|
||||
for (SchemaChange c : report.getChanges()) {
|
||||
grouped.computeIfAbsent(c.getSeverity(), k -> new ArrayList<>()).add(c);
|
||||
}
|
||||
|
||||
for (Severity severity : Severity.values()) {
|
||||
List<SchemaChange> list = grouped.get(severity);
|
||||
if (list == null || list.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
sb.append("### ").append(severity).append('\n');
|
||||
for (SchemaChange c : list) {
|
||||
sb.append("- **").append(c.getChangeType().getLabel()).append("**");
|
||||
if (c.getKeyPattern() != null) {
|
||||
sb.append(" `").append(c.getKeyPattern()).append('`');
|
||||
}
|
||||
sb.append('\n');
|
||||
if (c.getWriteLocation() != null) {
|
||||
sb.append(" - 位置: ").append(c.getWriteLocation()).append('\n');
|
||||
}
|
||||
if (c.getMessage() != null) {
|
||||
sb.append(" - ").append(c.getMessage()).append('\n');
|
||||
}
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public String toConsole(CheckReport report) {
|
||||
if (!report.hasChanges()) {
|
||||
return "未检测到 Redis 序列化结构变更。";
|
||||
}
|
||||
return toMarkdown(report);
|
||||
}
|
||||
|
||||
private String shortSha(String sha) {
|
||||
if (sha == null) {
|
||||
return "";
|
||||
}
|
||||
return sha.length() > 8 ? sha.substring(0, 8) : sha;
|
||||
}
|
||||
|
||||
private String nvl(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.codechecker.redis.schema;
|
||||
|
||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.MemberValuePair;
|
||||
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
|
||||
import com.github.javaparser.ast.expr.StringLiteralExpr;
|
||||
|
||||
/**
|
||||
* 处理 Fastjson / Jackson 序列化相关注解:字段忽略与字段名映射。
|
||||
*/
|
||||
public final class AnnotationSupport {
|
||||
|
||||
private AnnotationSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段是否参与序列化(未被 @JSONField(serialize=false)/@JsonIgnore 等排除)。
|
||||
*/
|
||||
public static boolean isSerialized(FieldDeclaration field) {
|
||||
for (AnnotationExpr annotation : field.getAnnotations()) {
|
||||
String name = annotation.getNameAsString();
|
||||
if (name.equals("JsonIgnore")) {
|
||||
return false;
|
||||
}
|
||||
if (name.equals("JSONField") && annotation instanceof NormalAnnotationExpr) {
|
||||
for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) {
|
||||
if (pair.getNameAsString().equals("serialize")
|
||||
&& pair.getValue().toString().equals("false")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析字段序列化后的 JSON 名称:优先 @JSONField(name=)/@JsonProperty(),否则用原字段名。
|
||||
*/
|
||||
public static String jsonName(FieldDeclaration field, String defaultName) {
|
||||
for (AnnotationExpr annotation : field.getAnnotations()) {
|
||||
String name = annotation.getNameAsString();
|
||||
if (name.equals("JsonProperty")) {
|
||||
String v = singleStringValue(annotation);
|
||||
if (v != null && !v.isEmpty()) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
if (name.equals("JSONField") && annotation instanceof NormalAnnotationExpr) {
|
||||
for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) {
|
||||
if (pair.getNameAsString().equals("name")
|
||||
&& pair.getValue() instanceof StringLiteralExpr) {
|
||||
String v = ((StringLiteralExpr) pair.getValue()).asString();
|
||||
if (!v.isEmpty()) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultName;
|
||||
}
|
||||
|
||||
private static String singleStringValue(AnnotationExpr annotation) {
|
||||
if (annotation instanceof SingleMemberAnnotationExpr) {
|
||||
if (((SingleMemberAnnotationExpr) annotation).getMemberValue() instanceof StringLiteralExpr) {
|
||||
return ((StringLiteralExpr) ((SingleMemberAnnotationExpr) annotation)
|
||||
.getMemberValue()).asString();
|
||||
}
|
||||
}
|
||||
if (annotation instanceof NormalAnnotationExpr) {
|
||||
for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) {
|
||||
if (pair.getNameAsString().equals("value")
|
||||
&& pair.getValue() instanceof StringLiteralExpr) {
|
||||
return ((StringLiteralExpr) pair.getValue()).asString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.codechecker.redis.schema;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 扁平化后的单个字段节点。path 使用点号分隔,数组元素以 {@code []} 标记。
|
||||
* 例如:{@code vo.linkList[].id}。
|
||||
*/
|
||||
public class FieldSchema {
|
||||
|
||||
private final String path;
|
||||
private final JsonType jsonType;
|
||||
private final String javaType;
|
||||
|
||||
public FieldSchema(String path, JsonType jsonType, String javaType) {
|
||||
this.path = path;
|
||||
this.jsonType = jsonType;
|
||||
this.javaType = javaType;
|
||||
}
|
||||
|
||||
public String getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public JsonType getJsonType() {
|
||||
return jsonType;
|
||||
}
|
||||
|
||||
public String getJavaType() {
|
||||
return javaType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof FieldSchema)) {
|
||||
return false;
|
||||
}
|
||||
FieldSchema that = (FieldSchema) o;
|
||||
return Objects.equals(path, that.path)
|
||||
&& jsonType == that.jsonType
|
||||
&& Objects.equals(javaType, that.javaType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(path, jsonType, javaType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return path + ":" + jsonType + "(" + javaType + ")";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.codechecker.redis.schema;
|
||||
|
||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||
import com.github.javaparser.ast.body.VariableDeclarator;
|
||||
import com.github.javaparser.ast.type.ArrayType;
|
||||
import com.github.javaparser.ast.type.ClassOrInterfaceType;
|
||||
import com.github.javaparser.ast.type.PrimitiveType;
|
||||
import com.github.javaparser.ast.type.Type;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 将 Java 类型递归展开为扁平化 {@link TypeSchema}。仅基于本仓库源码,不解析依赖 jar 内类型。
|
||||
*/
|
||||
public class JavaSchemaExtractor {
|
||||
|
||||
private static final Set<String> STRING_TYPES = new HashSet<>(Arrays.asList(
|
||||
"String", "CharSequence", "char", "Character", "UUID",
|
||||
"Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp",
|
||||
"BigDecimal"));
|
||||
private static final Set<String> NUMBER_TYPES = new HashSet<>(Arrays.asList(
|
||||
"int", "long", "short", "byte", "double", "float",
|
||||
"Integer", "Long", "Short", "Byte", "Double", "Float",
|
||||
"Number", "BigInteger", "AtomicInteger", "AtomicLong"));
|
||||
private static final Set<String> BOOLEAN_TYPES = new HashSet<>(Arrays.asList(
|
||||
"boolean", "Boolean"));
|
||||
private static final Set<String> COLLECTION_TYPES = new HashSet<>(Arrays.asList(
|
||||
"List", "ArrayList", "LinkedList", "Set", "HashSet", "LinkedHashSet",
|
||||
"TreeSet", "Collection", "Iterable"));
|
||||
private static final Set<String> MAP_TYPES = new HashSet<>(Arrays.asList(
|
||||
"Map", "HashMap", "LinkedHashMap", "TreeMap", "ConcurrentHashMap"));
|
||||
|
||||
private final SourceIndex index;
|
||||
private final int maxDepth;
|
||||
private int unknownCount;
|
||||
|
||||
public JavaSchemaExtractor(SourceIndex index, int maxDepth) {
|
||||
this.index = index;
|
||||
this.maxDepth = maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从根类型 FQN 生成 Schema。根类型无法在本仓库解析时返回低置信度空 Schema。
|
||||
*/
|
||||
public TypeSchema extract(String rootTypeFqn) {
|
||||
return extract(rootTypeFqn, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rootArray 根类型是否为集合(value 序列化为 JSON 数组)
|
||||
*/
|
||||
public TypeSchema extract(String rootTypeFqn, boolean rootArray) {
|
||||
unknownCount = 0;
|
||||
TypeSchema schema = new TypeSchema(rootTypeFqn);
|
||||
SourceIndex.IndexedType root = index.get(rootTypeFqn);
|
||||
if (root == null) {
|
||||
schema.setConfidence(0.4);
|
||||
return schema;
|
||||
}
|
||||
String prefix = rootArray ? "[]" : "";
|
||||
if (rootArray) {
|
||||
schema.add(new FieldSchema("[]", JsonType.OBJECT, rootTypeFqn));
|
||||
}
|
||||
expandObject(root, prefix, schema, new LinkedHashSet<>(), 0);
|
||||
schema.setConfidence(unknownCount == 0 ? 1.0 : Math.max(0.5, 1.0 - 0.15 * unknownCount));
|
||||
return schema;
|
||||
}
|
||||
|
||||
private void expandObject(SourceIndex.IndexedType type, String prefix, TypeSchema schema,
|
||||
Set<String> ancestors, int depth) {
|
||||
if (depth > maxDepth || ancestors.contains(type.getFqn())) {
|
||||
return;
|
||||
}
|
||||
Set<String> nextAncestors = new LinkedHashSet<>(ancestors);
|
||||
nextAncestors.add(type.getFqn());
|
||||
|
||||
for (FieldDeclaration field : collectFields(type, new HashSet<>())) {
|
||||
if (field.isStatic() || field.isTransient()) {
|
||||
continue;
|
||||
}
|
||||
if (!AnnotationSupport.isSerialized(field)) {
|
||||
continue;
|
||||
}
|
||||
for (VariableDeclarator var : field.getVariables()) {
|
||||
String jsonName = AnnotationSupport.jsonName(field, var.getNameAsString());
|
||||
String path = prefix.isEmpty() ? jsonName : prefix + "." + jsonName;
|
||||
expandType(var.getType(), path, type, schema, nextAncestors, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void expandType(Type type, String path, SourceIndex.IndexedType context,
|
||||
TypeSchema schema, Set<String> ancestors, int depth) {
|
||||
if (type instanceof PrimitiveType) {
|
||||
schema.add(new FieldSchema(path, scalarJsonType(type.asString()), type.asString()));
|
||||
return;
|
||||
}
|
||||
if (type instanceof ArrayType) {
|
||||
Type component = ((ArrayType) type).getComponentType();
|
||||
schema.add(new FieldSchema(path, JsonType.ARRAY, type.asString()));
|
||||
expandType(component, path + "[]", context, schema, ancestors, depth + 1);
|
||||
return;
|
||||
}
|
||||
if (type instanceof ClassOrInterfaceType) {
|
||||
ClassOrInterfaceType cit = (ClassOrInterfaceType) type;
|
||||
String simple = cit.getNameAsString();
|
||||
|
||||
if (isScalar(simple)) {
|
||||
schema.add(new FieldSchema(path, scalarJsonType(simple), simple));
|
||||
return;
|
||||
}
|
||||
if (COLLECTION_TYPES.contains(simple)) {
|
||||
schema.add(new FieldSchema(path, JsonType.ARRAY, simple));
|
||||
Optional<Type> arg = firstTypeArgument(cit);
|
||||
if (arg.isPresent()) {
|
||||
expandType(arg.get(), path + "[]", context, schema, ancestors, depth + 1);
|
||||
} else {
|
||||
schema.add(new FieldSchema(path + "[]", JsonType.UNKNOWN, "?"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (MAP_TYPES.contains(simple)) {
|
||||
// 动态键结构,不展开
|
||||
schema.add(new FieldSchema(path, JsonType.MAP, simple));
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试解析为本仓库对象类型
|
||||
String fqn = index.resolveFqn(cit.getNameWithScope(), context);
|
||||
if (fqn == null) {
|
||||
fqn = index.resolveFqn(simple, context);
|
||||
}
|
||||
SourceIndex.IndexedType resolved = fqn == null ? null : index.get(fqn);
|
||||
if (resolved != null) {
|
||||
schema.add(new FieldSchema(path, JsonType.OBJECT, fqn));
|
||||
expandObject(resolved, path, schema, ancestors, depth + 1);
|
||||
} else {
|
||||
// 无法解析(可能是枚举/依赖 jar 类型):作为叶子处理
|
||||
unknownCount++;
|
||||
schema.add(new FieldSchema(path, JsonType.UNKNOWN, simple));
|
||||
}
|
||||
return;
|
||||
}
|
||||
schema.add(new FieldSchema(path, JsonType.UNKNOWN, type.asString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集类自身及父类(本仓库可解析部分)的字段。
|
||||
*/
|
||||
private java.util.List<FieldDeclaration> collectFields(SourceIndex.IndexedType type, Set<String> visited) {
|
||||
java.util.List<FieldDeclaration> result = new java.util.ArrayList<>();
|
||||
if (type == null || visited.contains(type.getFqn())) {
|
||||
return result;
|
||||
}
|
||||
visited.add(type.getFqn());
|
||||
ClassOrInterfaceDeclaration decl = type.getDeclaration();
|
||||
for (FieldDeclaration field : decl.getFields()) {
|
||||
result.add(field);
|
||||
}
|
||||
for (ClassOrInterfaceType parent : decl.getExtendedTypes()) {
|
||||
String parentFqn = index.resolveFqn(parent.getNameWithScope(), type);
|
||||
if (parentFqn == null) {
|
||||
parentFqn = index.resolveFqn(parent.getNameAsString(), type);
|
||||
}
|
||||
SourceIndex.IndexedType parentType = parentFqn == null ? null : index.get(parentFqn);
|
||||
if (parentType != null) {
|
||||
result.addAll(collectFields(parentType, visited));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Optional<Type> firstTypeArgument(ClassOrInterfaceType cit) {
|
||||
return cit.getTypeArguments()
|
||||
.filter(args -> !args.isEmpty())
|
||||
.map(args -> args.get(0));
|
||||
}
|
||||
|
||||
private boolean isScalar(String simpleName) {
|
||||
return STRING_TYPES.contains(simpleName)
|
||||
|| NUMBER_TYPES.contains(simpleName)
|
||||
|| BOOLEAN_TYPES.contains(simpleName);
|
||||
}
|
||||
|
||||
private JsonType scalarJsonType(String simpleName) {
|
||||
if (NUMBER_TYPES.contains(simpleName)) {
|
||||
return JsonType.NUMBER;
|
||||
}
|
||||
if (BOOLEAN_TYPES.contains(simpleName)) {
|
||||
return JsonType.BOOLEAN;
|
||||
}
|
||||
if (STRING_TYPES.contains(simpleName)) {
|
||||
return JsonType.STRING;
|
||||
}
|
||||
return JsonType.UNKNOWN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.codechecker.redis.schema;
|
||||
|
||||
/**
|
||||
* 序列化后 JSON 值的粗粒度类型。
|
||||
*/
|
||||
public enum JsonType {
|
||||
OBJECT,
|
||||
ARRAY,
|
||||
MAP,
|
||||
STRING,
|
||||
NUMBER,
|
||||
BOOLEAN,
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.codechecker.redis.schema;
|
||||
|
||||
import com.github.javaparser.ParserConfiguration;
|
||||
import com.github.javaparser.StaticJavaParser;
|
||||
import com.github.javaparser.ast.CompilationUnit;
|
||||
import com.github.javaparser.ast.ImportDeclaration;
|
||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 一次提交快照下的源码类型索引。仅索引本仓库源码(不含依赖 jar),供类型解析与字段展开使用。
|
||||
*
|
||||
* <p>由于无法获取业务仓库完整依赖 classpath,本索引采用「手动符号解析」而非 JavaParser
|
||||
* SymbolSolver:基于文件内 import、同包、内部类进行 FQN 解析,保证在只读源码场景下的稳定性。</p>
|
||||
*/
|
||||
public class SourceIndex {
|
||||
|
||||
/** FQN(以 . 分隔,含内部类) -> 类型信息 */
|
||||
private final Map<String, IndexedType> byFqn = new LinkedHashMap<>();
|
||||
/** 简单类名 -> FQN 列表(兜底解析) */
|
||||
private final Map<String, List<String>> bySimpleName = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
ParserConfiguration config = new ParserConfiguration()
|
||||
.setLanguageLevel(ParserConfiguration.LanguageLevel.BLEEDING_EDGE);
|
||||
StaticJavaParser.setConfiguration(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并加入一个 Java 源文件内容。解析失败时静默跳过(返回 false)。
|
||||
*/
|
||||
public boolean addSource(String content) {
|
||||
if (content == null || content.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
CompilationUnit cu;
|
||||
try {
|
||||
cu = StaticJavaParser.parse(content);
|
||||
} catch (RuntimeException e) {
|
||||
return false;
|
||||
}
|
||||
String packageName = cu.getPackageDeclaration()
|
||||
.map(pd -> pd.getNameAsString())
|
||||
.orElse("");
|
||||
List<String> imports = new ArrayList<>();
|
||||
for (ImportDeclaration imp : cu.getImports()) {
|
||||
imports.add((imp.isAsterisk() ? imp.getNameAsString() + ".*" : imp.getNameAsString()));
|
||||
}
|
||||
for (TypeDeclaration<?> type : cu.getTypes()) {
|
||||
registerType(type, packageName, imports, packageName);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void registerType(TypeDeclaration<?> type, String packageName, List<String> imports, String enclosingFqn) {
|
||||
String simpleName = type.getNameAsString();
|
||||
String fqn = enclosingFqn.isEmpty() ? simpleName : enclosingFqn + "." + simpleName;
|
||||
if (type instanceof ClassOrInterfaceDeclaration) {
|
||||
IndexedType indexed = new IndexedType((ClassOrInterfaceDeclaration) type, packageName, imports, fqn);
|
||||
byFqn.put(fqn, indexed);
|
||||
bySimpleName.computeIfAbsent(simpleName, k -> new ArrayList<>()).add(fqn);
|
||||
}
|
||||
// 递归内部类型
|
||||
for (Object member : type.getMembers()) {
|
||||
if (member instanceof TypeDeclaration) {
|
||||
registerType((TypeDeclaration<?>) member, packageName, imports, fqn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IndexedType get(String fqn) {
|
||||
if (fqn == null) {
|
||||
return null;
|
||||
}
|
||||
return byFqn.get(fqn.replace('$', '.'));
|
||||
}
|
||||
|
||||
public boolean contains(String fqn) {
|
||||
return fqn != null && byFqn.containsKey(fqn.replace('$', '.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将简单类名或部分限定名解析为本仓库内的 FQN;无法解析时返回 null。
|
||||
*/
|
||||
public String resolveFqn(String name, IndexedType context) {
|
||||
if (name == null || name.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = name.replace('$', '.');
|
||||
|
||||
// 1. 已经是本仓库已知 FQN
|
||||
if (byFqn.containsKey(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
String simple = normalized.contains(".")
|
||||
? normalized.substring(normalized.lastIndexOf('.') + 1)
|
||||
: normalized;
|
||||
|
||||
if (context != null) {
|
||||
// 2. 上下文自身或其内部类
|
||||
String selfNested = context.getFqn() + "." + simple;
|
||||
if (byFqn.containsKey(selfNested)) {
|
||||
return selfNested;
|
||||
}
|
||||
// 2b. 上下文的外层链中的内部类
|
||||
String outer = context.getFqn();
|
||||
while (outer.contains(".")) {
|
||||
outer = outer.substring(0, outer.lastIndexOf('.'));
|
||||
String candidate = outer + "." + simple;
|
||||
if (byFqn.containsKey(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// 3. 同包
|
||||
String samePackage = context.getPackageName().isEmpty()
|
||||
? simple : context.getPackageName() + "." + simple;
|
||||
if (byFqn.containsKey(samePackage)) {
|
||||
return samePackage;
|
||||
}
|
||||
// 4. 精确 import
|
||||
for (String imp : context.getImports()) {
|
||||
if (imp.endsWith("." + simple)) {
|
||||
if (byFqn.containsKey(imp)) {
|
||||
return imp;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 5. 通配 import
|
||||
for (String imp : context.getImports()) {
|
||||
if (imp.endsWith(".*")) {
|
||||
String pkg = imp.substring(0, imp.length() - 2);
|
||||
String candidate = pkg + "." + simple;
|
||||
if (byFqn.containsKey(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 简单名唯一命中兜底
|
||||
List<String> candidates = bySimpleName.get(simple);
|
||||
if (candidates != null && candidates.size() == 1) {
|
||||
return candidates.get(0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return byFqn.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引中的类型条目。
|
||||
*/
|
||||
public static final class IndexedType {
|
||||
private final ClassOrInterfaceDeclaration declaration;
|
||||
private final String packageName;
|
||||
private final List<String> imports;
|
||||
private final String fqn;
|
||||
|
||||
IndexedType(ClassOrInterfaceDeclaration declaration, String packageName, List<String> imports, String fqn) {
|
||||
this.declaration = declaration;
|
||||
this.packageName = packageName;
|
||||
this.imports = imports;
|
||||
this.fqn = fqn;
|
||||
}
|
||||
|
||||
public ClassOrInterfaceDeclaration getDeclaration() {
|
||||
return declaration;
|
||||
}
|
||||
|
||||
public String getPackageName() {
|
||||
return packageName;
|
||||
}
|
||||
|
||||
public List<String> getImports() {
|
||||
return imports;
|
||||
}
|
||||
|
||||
public String getFqn() {
|
||||
return fqn;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.codechecker.redis.schema;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 某个 Redis value 类型展开后的扁平化 Schema:path -> {@link FieldSchema}。
|
||||
*/
|
||||
public class TypeSchema {
|
||||
|
||||
private final String rootType;
|
||||
private double confidence = 1.0;
|
||||
private final Map<String, FieldSchema> fields = new LinkedHashMap<>();
|
||||
|
||||
public TypeSchema(String rootType) {
|
||||
this.rootType = rootType;
|
||||
}
|
||||
|
||||
public String getRootType() {
|
||||
return rootType;
|
||||
}
|
||||
|
||||
public double getConfidence() {
|
||||
return confidence;
|
||||
}
|
||||
|
||||
public void setConfidence(double confidence) {
|
||||
this.confidence = confidence;
|
||||
}
|
||||
|
||||
public Map<String, FieldSchema> getFields() {
|
||||
return fields;
|
||||
}
|
||||
|
||||
public void add(FieldSchema field) {
|
||||
fields.put(field.getPath(), field);
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return fields.isEmpty();
|
||||
}
|
||||
}
|
||||
64
redis-schema-checker/src/main/resources/default-config.yaml
Normal file
64
redis-schema-checker/src/main/resources/default-config.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
# redis-schema-checker 内置默认配置
|
||||
# 业务仓库通过 --config 指定的配置会与本文件深度合并(业务配置优先)。
|
||||
|
||||
# 运行模式:notify(仅通知)| block(按 block_severities 阻断,exit 1)
|
||||
mode: notify
|
||||
|
||||
# block 模式下触发阻断的严重级别(默认全部阻断)
|
||||
block_severities:
|
||||
- P0
|
||||
- P1
|
||||
- P2
|
||||
|
||||
# 是否扫描测试代码(第一版固定 false)
|
||||
scan_test_sources: false
|
||||
|
||||
# 源码扫描根目录(相对被检测模块)
|
||||
source_roots:
|
||||
- "src/main/java"
|
||||
|
||||
# 通知配置
|
||||
notify:
|
||||
enabled: true
|
||||
webhook_env: "WECOM_ROBOT_WEBHOOK"
|
||||
notify_on_clean: false
|
||||
title_prefix: "[Redis结构变更]"
|
||||
|
||||
# 忽略规则
|
||||
ignore:
|
||||
# 忽略的 key 模式(glob:* 单层,** 多层)
|
||||
key_patterns:
|
||||
- "*:lock"
|
||||
- "*:lock:*"
|
||||
- "*lock*"
|
||||
- "loginCount:*"
|
||||
- "Authorization:*"
|
||||
# 忽略的文件路径模式
|
||||
file_patterns:
|
||||
- "**/test/**"
|
||||
# 忽略的写入方法(类全名#方法名)
|
||||
writer_methods: []
|
||||
|
||||
# 检测规则
|
||||
detection:
|
||||
patterns:
|
||||
- W01 # redisUtil.insert(key, JSON.toJSONString(x), ttl)
|
||||
- W02 # redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)
|
||||
- W03 # stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)
|
||||
# 类型推断最低置信度,低于此值降级为 P2 提示
|
||||
min_confidence: 0.6
|
||||
# 字段展开最大深度(防止循环引用)
|
||||
max_field_depth: 8
|
||||
|
||||
# 严重级别覆盖
|
||||
severity_overrides: {}
|
||||
|
||||
# 人工补充映射
|
||||
manual_mappings: []
|
||||
|
||||
# 抑制规则(已知误报)
|
||||
suppressions: []
|
||||
|
||||
# 模块过滤(空表示全部模块)
|
||||
include_modules: []
|
||||
exclude_modules: []
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.codechecker.redis;
|
||||
|
||||
import com.codechecker.redis.detector.RedisWritePointDetector;
|
||||
import com.codechecker.redis.detector.WritePoint;
|
||||
import com.codechecker.redis.diff.ChangeType;
|
||||
import com.codechecker.redis.diff.SchemaChange;
|
||||
import com.codechecker.redis.diff.SchemaDiffer;
|
||||
import com.codechecker.redis.schema.JavaSchemaExtractor;
|
||||
import com.codechecker.redis.schema.SourceIndex;
|
||||
import com.codechecker.redis.schema.TypeSchema;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 端到端组件级验证:租户缓存由 TenantVO 变为 CacheEnvelope{vo, expiresAtMs} 的结构变更。
|
||||
*/
|
||||
class TenantScenarioTest {
|
||||
|
||||
private static final Set<String> PATTERNS = new HashSet<>();
|
||||
|
||||
static {
|
||||
PATTERNS.add("W01");
|
||||
PATTERNS.add("W02");
|
||||
PATTERNS.add("W03");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectsTenantEnvelopeWrapping() {
|
||||
String tenantVo = TestSupport.fixture("fixtures/tenant/TenantVO.txt");
|
||||
String tenantLink = TestSupport.fixture("fixtures/tenant/TenantLinkModel.txt");
|
||||
String helperOld = TestSupport.fixture("fixtures/tenant/HelperOld.txt");
|
||||
String helperNew = TestSupport.fixture("fixtures/tenant/HelperNew.txt");
|
||||
|
||||
SourceIndex oldIndex = new SourceIndex();
|
||||
oldIndex.addSource(tenantVo);
|
||||
oldIndex.addSource(tenantLink);
|
||||
oldIndex.addSource(helperOld);
|
||||
|
||||
SourceIndex newIndex = new SourceIndex();
|
||||
newIndex.addSource(tenantVo);
|
||||
newIndex.addSource(tenantLink);
|
||||
newIndex.addSource(helperNew);
|
||||
|
||||
WritePoint oldWp = single(new RedisWritePointDetector(oldIndex, PATTERNS)
|
||||
.detect("Helper.java", helperOld));
|
||||
WritePoint newWp = single(new RedisWritePointDetector(newIndex, PATTERNS)
|
||||
.detect("Helper.java", helperNew));
|
||||
|
||||
// key 推断
|
||||
assertEquals("tenant:db:content:*", oldWp.getResolvedKeyPattern());
|
||||
assertEquals("tenant:db:content:*", newWp.getResolvedKeyPattern());
|
||||
// 写入点配对签名一致
|
||||
assertEquals(oldWp.signature(), newWp.signature());
|
||||
|
||||
// value 类型推断
|
||||
assertEquals("jnpf.model.TenantVO", oldWp.getResolvedValueType());
|
||||
assertNotNull(newWp.getResolvedValueType());
|
||||
assertTrue(newWp.getResolvedValueType().endsWith("CacheEnvelope"));
|
||||
|
||||
JavaSchemaExtractor oldEx = new JavaSchemaExtractor(oldIndex, 8);
|
||||
JavaSchemaExtractor newEx = new JavaSchemaExtractor(newIndex, 8);
|
||||
TypeSchema oldSchema = oldEx.extract(oldWp.getResolvedValueType(), oldWp.isRootArray());
|
||||
TypeSchema newSchema = newEx.extract(newWp.getResolvedValueType(), newWp.isRootArray());
|
||||
|
||||
// 旧结构包含顶层 dbName、linkList[].id
|
||||
assertTrue(oldSchema.getFields().containsKey("dbName"));
|
||||
assertTrue(oldSchema.getFields().containsKey("linkList[].id"));
|
||||
// 新结构包含 vo.dbName、expiresAtMs
|
||||
assertTrue(newSchema.getFields().containsKey("vo.dbName"));
|
||||
assertTrue(newSchema.getFields().containsKey("expiresAtMs"));
|
||||
|
||||
List<SchemaChange> changes = new SchemaDiffer().diff(oldSchema, newSchema);
|
||||
List<ChangeType> types = changes.stream().map(SchemaChange::getChangeType).collect(Collectors.toList());
|
||||
|
||||
assertTrue(types.contains(ChangeType.WRAPPER_ADDED), "应检测到包装层 vo");
|
||||
assertTrue(types.contains(ChangeType.FIELD_PATH_MOVED), "应检测到字段路径迁移");
|
||||
assertTrue(types.contains(ChangeType.FIELD_ADDED), "应检测到 expiresAtMs 新增");
|
||||
}
|
||||
|
||||
private WritePoint single(List<WritePoint> wps) {
|
||||
assertEquals(1, wps.size(), "应恰好检测到 1 个写入点");
|
||||
return wps.get(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.codechecker.redis;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
/**
|
||||
* 测试通用工具:加载 classpath 下的 fixture 文本。
|
||||
*/
|
||||
public final class TestSupport {
|
||||
|
||||
private TestSupport() {
|
||||
}
|
||||
|
||||
public static String fixture(String path) {
|
||||
try (InputStream in = TestSupport.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (in == null) {
|
||||
throw new IllegalArgumentException("fixture 不存在: " + path);
|
||||
}
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[8192];
|
||||
int read;
|
||||
while ((read = in.read(chunk)) != -1) {
|
||||
buffer.write(chunk, 0, read);
|
||||
}
|
||||
return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.codechecker.redis.analyze;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class GlobMatcherTest {
|
||||
|
||||
@Test
|
||||
void matchesPrefixWildcard() {
|
||||
assertTrue(GlobMatcher.matches("tenant:db:content:*", "tenant:db:content:abc"));
|
||||
assertTrue(GlobMatcher.matches("*:lock", "device:add:lock"));
|
||||
assertTrue(GlobMatcher.matches("*lock*", "abTaskDetailUpdate:lock:x"));
|
||||
assertTrue(GlobMatcher.matches("loginCount:*", "loginCount:13800000000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesDoubleStarPath() {
|
||||
assertTrue(GlobMatcher.matches("**/test/**", "jnpf-x/src/test/java/Foo.java"));
|
||||
assertFalse(GlobMatcher.matches("**/test/**", "jnpf-x/src/main/java/Foo.java"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void literalNoMatch() {
|
||||
assertFalse(GlobMatcher.matches("tenant:db:content:*", "other:key"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.codechecker.redis.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ConfigLoaderTest {
|
||||
|
||||
@Test
|
||||
void loadsDefaultsWhenNoBusinessConfig() {
|
||||
CheckerConfig config = ConfigLoader.load(null);
|
||||
assertEquals("notify", config.getMode());
|
||||
assertTrue(config.getBlockSeverities().contains("P0"));
|
||||
assertTrue(config.getDetection().getPatterns().contains("W01"));
|
||||
assertFalse(config.isScanTestSources());
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessConfigOverridesDefaults(@org.junit.jupiter.api.io.TempDir Path tmp) throws IOException {
|
||||
Path cfg = tmp.resolve("biz.yaml");
|
||||
Files.write(cfg, ("mode: block\n"
|
||||
+ "include_modules:\n - jnpf-tenant\n"
|
||||
+ "notify:\n enabled: false\n").getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
CheckerConfig config = ConfigLoader.load(cfg);
|
||||
assertEquals("block", config.getMode());
|
||||
assertTrue(config.isBlockMode());
|
||||
assertEquals(1, config.getIncludeModules().size());
|
||||
assertEquals("jnpf-tenant", config.getIncludeModules().get(0));
|
||||
// 未覆盖项保留默认
|
||||
assertFalse(config.getNotify().isEnabled());
|
||||
assertTrue(config.getBlockSeverities().contains("P2"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.codechecker.redis.diff;
|
||||
|
||||
import com.codechecker.redis.schema.FieldSchema;
|
||||
import com.codechecker.redis.schema.JsonType;
|
||||
import com.codechecker.redis.schema.TypeSchema;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SchemaDifferTest {
|
||||
|
||||
@Test
|
||||
void detectsWrapperMoveAndAddition() {
|
||||
TypeSchema oldS = new TypeSchema("TenantVO");
|
||||
oldS.add(new FieldSchema("dbName", JsonType.STRING, "String"));
|
||||
oldS.add(new FieldSchema("linkList", JsonType.ARRAY, "List"));
|
||||
oldS.add(new FieldSchema("linkList[]", JsonType.OBJECT, "TenantLinkModel"));
|
||||
oldS.add(new FieldSchema("linkList[].id", JsonType.STRING, "String"));
|
||||
|
||||
TypeSchema newS = new TypeSchema("CacheEnvelope");
|
||||
newS.add(new FieldSchema("vo", JsonType.OBJECT, "TenantVO"));
|
||||
newS.add(new FieldSchema("vo.dbName", JsonType.STRING, "String"));
|
||||
newS.add(new FieldSchema("vo.linkList", JsonType.ARRAY, "List"));
|
||||
newS.add(new FieldSchema("vo.linkList[]", JsonType.OBJECT, "TenantLinkModel"));
|
||||
newS.add(new FieldSchema("vo.linkList[].id", JsonType.STRING, "String"));
|
||||
newS.add(new FieldSchema("expiresAtMs", JsonType.NUMBER, "Long"));
|
||||
|
||||
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||
List<ChangeType> types = changes.stream().map(SchemaChange::getChangeType).collect(Collectors.toList());
|
||||
|
||||
assertTrue(types.contains(ChangeType.WRAPPER_ADDED), "应检测到包装层新增");
|
||||
assertTrue(types.contains(ChangeType.FIELD_PATH_MOVED), "应检测到字段路径迁移");
|
||||
assertTrue(types.contains(ChangeType.FIELD_ADDED), "应检测到新增字段 expiresAtMs");
|
||||
|
||||
long moved = changes.stream().filter(c -> c.getChangeType() == ChangeType.FIELD_PATH_MOVED).count();
|
||||
assertEquals(2, moved, "dbName 与 linkList[].id 均应迁移");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectsTypeChange() {
|
||||
TypeSchema oldS = new TypeSchema("A");
|
||||
oldS.add(new FieldSchema("count", JsonType.STRING, "String"));
|
||||
TypeSchema newS = new TypeSchema("A");
|
||||
newS.add(new FieldSchema("count", JsonType.NUMBER, "Integer"));
|
||||
|
||||
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||
assertEquals(1, changes.size());
|
||||
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
||||
assertEquals(Severity.P0, changes.get(0).getSeverity());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noChangeWhenIdentical() {
|
||||
TypeSchema a = new TypeSchema("A");
|
||||
a.add(new FieldSchema("x", JsonType.STRING, "String"));
|
||||
TypeSchema b = new TypeSchema("A");
|
||||
b.add(new FieldSchema("x", JsonType.STRING, "String"));
|
||||
assertTrue(new SchemaDiffer().diff(a, b).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package jnpf.util;
|
||||
|
||||
import jnpf.model.TenantVO;
|
||||
|
||||
public class TenantDbContentCacheHelper {
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "tenant:db:content:";
|
||||
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
public String buildCacheKey(String encode) {
|
||||
return CACHE_KEY_PREFIX + encode;
|
||||
}
|
||||
|
||||
public void cacheSuccess(String encode, TenantVO vo, Long expiresAtMs) {
|
||||
CacheEnvelope envelope = new CacheEnvelope();
|
||||
envelope.setVo(vo);
|
||||
envelope.setExpiresAtMs(expiresAtMs);
|
||||
redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), 100);
|
||||
}
|
||||
|
||||
public static class CacheEnvelope {
|
||||
private TenantVO vo;
|
||||
private Long expiresAtMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package jnpf.util;
|
||||
|
||||
import jnpf.model.TenantVO;
|
||||
|
||||
public class TenantDbContentCacheHelper {
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "tenant:db:content:";
|
||||
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
public String buildCacheKey(String encode) {
|
||||
return CACHE_KEY_PREFIX + encode;
|
||||
}
|
||||
|
||||
public void cacheSuccess(String encode, TenantVO vo) {
|
||||
redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(vo), 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package jnpf.model;
|
||||
|
||||
public class TenantLinkModel {
|
||||
public String id;
|
||||
public String serviceName;
|
||||
public String userName;
|
||||
public String port;
|
||||
public String fullName;
|
||||
public String host;
|
||||
public String password;
|
||||
public String dbSchema;
|
||||
public Integer configType;
|
||||
public String dbType;
|
||||
public String connectionStr;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package jnpf.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class TenantVO {
|
||||
private String dbName;
|
||||
private List<TenantLinkModel> linkList;
|
||||
}
|
||||
Reference in New Issue
Block a user