From 603a0472039f72ba84c11bef31b5a6df2e1851a1 Mon Sep 17 00:00:00 2001 From: dongzi Date: Mon, 13 Jul 2026 15:34:28 +0800 Subject: [PATCH] feat: first commit --- .gitea/demo.yaml | 83 +++ .gitignore | 4 + .idea/.gitignore | 10 + .idea/compiler.xml | 13 + .idea/encodings.xml | 9 + .idea/jarRepositories.xml | 30 + .idea/misc.xml | 14 + .idea/redisCheck.iml | 9 + .idea/vcs.xml | 6 + docs/CI集成说明.md | 204 ++++++ docs/实施方案.md | 665 ++++++++++++++++++ docs/配置说明.md | 295 ++++++++ pom.xml | 31 + redis-schema-checker/pom.xml | 97 +++ .../redis/analyze/FileScanner.java | 66 ++ .../redis/analyze/GlobMatcher.java | 34 + .../redis/analyze/SchemaCheckAnalyzer.java | 308 ++++++++ .../redis/cli/RedisSchemaCheckerMain.java | 98 +++ .../redis/config/CheckerConfig.java | 332 +++++++++ .../redis/config/ConfigLoader.java | 195 +++++ .../detector/RedisWritePointDetector.java | 234 ++++++ .../redis/detector/WritePoint.java | 127 ++++ .../codechecker/redis/diff/ChangeType.java | 32 + .../codechecker/redis/diff/SchemaChange.java | 85 +++ .../codechecker/redis/diff/SchemaDiffer.java | 151 ++++ .../com/codechecker/redis/diff/Severity.java | 10 + .../codechecker/redis/git/GitDiffScanner.java | 117 +++ .../codechecker/redis/git/GitException.java | 15 + .../redis/key/RedisKeyResolver.java | 143 ++++ .../redis/notify/WeComNotifier.java | 58 ++ .../codechecker/redis/report/CheckReport.java | 109 +++ .../redis/report/ReportBuilder.java | 86 +++ .../redis/schema/AnnotationSupport.java | 83 +++ .../codechecker/redis/schema/FieldSchema.java | 56 ++ .../redis/schema/JavaSchemaExtractor.java | 202 ++++++ .../codechecker/redis/schema/JsonType.java | 14 + .../codechecker/redis/schema/SourceIndex.java | 190 +++++ .../codechecker/redis/schema/TypeSchema.java | 42 ++ .../src/main/resources/default-config.yaml | 64 ++ .../codechecker/redis/TenantScenarioTest.java | 92 +++ .../com/codechecker/redis/TestSupport.java | 33 + .../redis/analyze/GlobMatcherTest.java | 28 + .../redis/config/ConfigLoaderTest.java | 41 ++ .../redis/diff/SchemaDifferTest.java | 64 ++ .../resources/fixtures/tenant/HelperNew.txt | 26 + .../resources/fixtures/tenant/HelperOld.txt | 18 + .../fixtures/tenant/TenantLinkModel.txt | 15 + .../resources/fixtures/tenant/TenantVO.txt | 8 + 48 files changed, 4646 insertions(+) create mode 100644 .gitea/demo.yaml create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/compiler.xml create mode 100644 .idea/encodings.xml create mode 100644 .idea/jarRepositories.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/redisCheck.iml create mode 100644 .idea/vcs.xml create mode 100644 docs/CI集成说明.md create mode 100644 docs/实施方案.md create mode 100644 docs/配置说明.md create mode 100644 pom.xml create mode 100644 redis-schema-checker/pom.xml create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/analyze/FileScanner.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/analyze/GlobMatcher.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/analyze/SchemaCheckAnalyzer.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/cli/RedisSchemaCheckerMain.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/config/CheckerConfig.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/config/ConfigLoader.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/detector/RedisWritePointDetector.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/detector/WritePoint.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/diff/ChangeType.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/diff/SchemaChange.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/diff/SchemaDiffer.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/diff/Severity.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/git/GitDiffScanner.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/git/GitException.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/key/RedisKeyResolver.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/notify/WeComNotifier.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/report/CheckReport.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/report/ReportBuilder.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/schema/AnnotationSupport.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/schema/FieldSchema.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/schema/JavaSchemaExtractor.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/schema/JsonType.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/schema/SourceIndex.java create mode 100644 redis-schema-checker/src/main/java/com/codechecker/redis/schema/TypeSchema.java create mode 100644 redis-schema-checker/src/main/resources/default-config.yaml create mode 100644 redis-schema-checker/src/test/java/com/codechecker/redis/TenantScenarioTest.java create mode 100644 redis-schema-checker/src/test/java/com/codechecker/redis/TestSupport.java create mode 100644 redis-schema-checker/src/test/java/com/codechecker/redis/analyze/GlobMatcherTest.java create mode 100644 redis-schema-checker/src/test/java/com/codechecker/redis/config/ConfigLoaderTest.java create mode 100644 redis-schema-checker/src/test/java/com/codechecker/redis/diff/SchemaDifferTest.java create mode 100644 redis-schema-checker/src/test/resources/fixtures/tenant/HelperNew.txt create mode 100644 redis-schema-checker/src/test/resources/fixtures/tenant/HelperOld.txt create mode 100644 redis-schema-checker/src/test/resources/fixtures/tenant/TenantLinkModel.txt create mode 100644 redis-schema-checker/src/test/resources/fixtures/tenant/TenantVO.txt diff --git a/.gitea/demo.yaml b/.gitea/demo.yaml new file mode 100644 index 0000000..1c48526 --- /dev/null +++ b/.gitea/demo.yaml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac96afb --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +*.class +*.log +.DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..7bf28e0 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..bb6937f --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..e9f33c1 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..accd629 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/redisCheck.iml b/.idea/redisCheck.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/redisCheck.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docs/CI集成说明.md b/docs/CI集成说明.md new file mode 100644 index 0000000..f827004 --- /dev/null +++ b/docs/CI集成说明.md @@ -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 实现)仅输出报告不发企微。 diff --git a/docs/实施方案.md b/docs/实施方案.md new file mode 100644 index 0000000..e8f44b9 --- /dev/null +++ b/docs/实施方案.md @@ -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 +com.codechecker +redis-schema-checker +1.0.0-SNAPSHOT +``` + +打包为 **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` +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 changes; + boolean blocked; + int exitCode; +} +``` diff --git a/docs/配置说明.md b/docs/配置说明.md new file mode 100644 index 0000000..247833f --- /dev/null +++ b/docs/配置说明.md @@ -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)" + +# 抑制规则(已知误报) +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] +``` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..e0b42b6 --- /dev/null +++ b/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + com.codechecker + redis-schema-checker-parent + 1.0.0-SNAPSHOT + pom + + redis-schema-checker-parent + Redis 序列化结构变更检测工具(父工程) + + + redis-schema-checker + + + + 11 + 11 + UTF-8 + + 3.25.10 + 2.2 + 2.15.4 + 4.7.5 + 5.10.2 + 3.5.1 + + diff --git a/redis-schema-checker/pom.xml b/redis-schema-checker/pom.xml new file mode 100644 index 0000000..40f293f --- /dev/null +++ b/redis-schema-checker/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + + + com.codechecker + redis-schema-checker-parent + 1.0.0-SNAPSHOT + + + redis-schema-checker + jar + + redis-schema-checker + 基于 JavaParser 的 Redis value 序列化结构变更检测器 + + + + com.github.javaparser + javaparser-symbol-solver-core + ${javaparser.version} + + + + org.yaml + snakeyaml + ${snakeyaml.version} + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + info.picocli + picocli + ${picocli.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + redis-schema-checker-${project.version} + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-shade-plugin + ${maven.shade.version} + + + package + + shade + + + false + + + com.codechecker.redis.cli.RedisSchemaCheckerMain + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + + + + + diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/analyze/FileScanner.java b/redis-schema-checker/src/main/java/com/codechecker/redis/analyze/FileScanner.java new file mode 100644 index 0000000..d599a55 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/analyze/FileScanner.java @@ -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 includeModules; + private final List excludeModules; + + public FileScanner(Path repoRoot, List includeModules, List excludeModules) { + this.repoRoot = repoRoot; + this.includeModules = includeModules; + this.excludeModules = excludeModules; + } + + /** + * @return 相对仓库根(/ 分隔)-> 文件内容 + */ + public Map scan() { + Map result = new LinkedHashMap<>(); + try (Stream 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); + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/analyze/GlobMatcher.java b/redis-schema-checker/src/main/java/com/codechecker/redis/analyze/GlobMatcher.java new file mode 100644 index 0000000..c81f6a5 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/analyze/GlobMatcher.java @@ -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(); + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/analyze/SchemaCheckAnalyzer.java b/redis-schema-checker/src/main/java/com/codechecker/redis/analyze/SchemaCheckAnalyzer.java new file mode 100644 index 0000000..41e1c03 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/analyze/SchemaCheckAnalyzer.java @@ -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 changedRaw = scanner.changedJavaFiles(oldSha, newSha); + Set 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 newContents = fileScanner.scan(); + + // 旧版本内容:以工作树为基线,用 old 版本覆盖变更文件 + Map 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 changedTypeNames = new HashSet<>(); + for (String path : changedFiles) { + collectTypeNames(newContents.get(path), changedTypeNames); + collectTypeNames(oldContents.get(path), changedTypeNames); + } + + Set candidates = new LinkedHashSet<>(changedFiles); + Pattern typePattern = buildTypePattern(changedTypeNames); + if (typePattern != null) { + for (Map.Entry e : newContents.entrySet()) { + if (!candidates.contains(e.getKey()) && typePattern.matcher(e.getValue()).find()) { + candidates.add(e.getKey()); + } + } + } + + Set 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 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 newWps = detectorNew.detect(path, newContent); + List oldWps = oldContent == null + ? new ArrayList<>() : detectorOld.detect(path, oldContent); + + Map oldBySig = new LinkedHashMap<>(); + for (WritePoint wp : oldWps) { + oldBySig.put(wp.signature(), wp); + } + Set 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 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 finalChanges = postProcess(allChanges); + return buildReport(oldSha, newSha, finalChanges); + } + + private void enrich(List 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 postProcess(List changes) { + List result = new ArrayList<>(); + Set 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 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 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 contents) { + SourceIndex index = new SourceIndex(); + for (String content : contents) { + index.addSource(content); + } + return index; + } + + private void collectTypeNames(String content, Set 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 typeNames) { + List 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; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/cli/RedisSchemaCheckerMain.java b/redis-schema-checker/src/main/java/com/codechecker/redis/cli/RedisSchemaCheckerMain.java new file mode 100644 index 0000000..ac7069f --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/cli/RedisSchemaCheckerMain.java @@ -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 { + + @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); + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/config/CheckerConfig.java b/redis-schema-checker/src/main/java/com/codechecker/redis/config/CheckerConfig.java new file mode 100644 index 0000000..dc587bb --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/config/CheckerConfig.java @@ -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 blockSeverities = new ArrayList<>(); + + private boolean scanTestSources = false; + + private List sourceRoots = new ArrayList<>(); + + private Notify notify = new Notify(); + + private Ignore ignore = new Ignore(); + + private Detection detection = new Detection(); + + private Map severityOverrides = new LinkedHashMap<>(); + + private List manualMappings = new ArrayList<>(); + + private List suppressions = new ArrayList<>(); + + private List includeModules = new ArrayList<>(); + + private List 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 keyPatterns = new ArrayList<>(); + private List filePatterns = new ArrayList<>(); + private List writerMethods = new ArrayList<>(); + + public List getKeyPatterns() { + return keyPatterns; + } + + public void setKeyPatterns(List keyPatterns) { + this.keyPatterns = keyPatterns; + } + + public List getFilePatterns() { + return filePatterns; + } + + public void setFilePatterns(List filePatterns) { + this.filePatterns = filePatterns; + } + + public List getWriterMethods() { + return writerMethods; + } + + public void setWriterMethods(List writerMethods) { + this.writerMethods = writerMethods; + } + } + + public static class Detection { + private List patterns = new ArrayList<>(); + private double minConfidence = 0.6; + private int maxFieldDepth = 8; + + public List getPatterns() { + return patterns; + } + + public void setPatterns(List 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 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 getChangeTypes() { + return changeTypes; + } + + public void setChangeTypes(List 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 getBlockSeverities() { + return blockSeverities; + } + + public void setBlockSeverities(List blockSeverities) { + this.blockSeverities = blockSeverities; + } + + public boolean isScanTestSources() { + return scanTestSources; + } + + public void setScanTestSources(boolean scanTestSources) { + this.scanTestSources = scanTestSources; + } + + public List getSourceRoots() { + return sourceRoots; + } + + public void setSourceRoots(List 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 getSeverityOverrides() { + return severityOverrides; + } + + public void setSeverityOverrides(Map severityOverrides) { + this.severityOverrides = severityOverrides; + } + + public List getManualMappings() { + return manualMappings; + } + + public void setManualMappings(List manualMappings) { + this.manualMappings = manualMappings; + } + + public List getSuppressions() { + return suppressions; + } + + public void setSuppressions(List suppressions) { + this.suppressions = suppressions; + } + + public List getIncludeModules() { + return includeModules; + } + + public void setIncludeModules(List includeModules) { + this.includeModules = includeModules; + } + + public List getExcludeModules() { + return excludeModules; + } + + public void setExcludeModules(List excludeModules) { + this.excludeModules = excludeModules; + } + + public boolean isBlockMode() { + return "block".equalsIgnoreCase(mode); + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/config/ConfigLoader.java b/redis-schema-checker/src/main/java/com/codechecker/redis/config/ConfigLoader.java new file mode 100644 index 0000000..27b9371 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/config/ConfigLoader.java @@ -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 merged = loadDefault(); + if (businessConfigPath != null && Files.exists(businessConfigPath)) { + Map business = loadYaml(businessConfigPath); + merged = deepMerge(merged, business); + } + return bind(merged); + } + + @SuppressWarnings("unchecked") + static Map 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) obj; + } catch (IOException e) { + throw new IllegalStateException("读取默认配置失败", e); + } + } + + @SuppressWarnings("unchecked") + static Map loadYaml(Path path) { + try (InputStream in = Files.newInputStream(path)) { + Object obj = new Yaml().load(in); + return obj == null ? new LinkedHashMap<>() : (Map) obj; + } catch (IOException e) { + throw new IllegalStateException("读取业务配置失败: " + path, e); + } + } + + @SuppressWarnings("unchecked") + static Map deepMerge(Map base, Map override) { + Map result = new LinkedHashMap<>(base); + for (Map.Entry 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) baseValue, (Map) overrideValue)); + } else { + // 标量、列表:业务配置直接覆盖 + result.put(key, overrideValue); + } + } + return result; + } + + @SuppressWarnings("unchecked") + private static CheckerConfig bind(Map 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 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 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 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 severityOverrides = asMap(map.get("severity_overrides")); + Map so = new LinkedHashMap<>(); + for (Map.Entry e : severityOverrides.entrySet()) { + so.put(e.getKey(), String.valueOf(e.getValue())); + } + config.setSeverityOverrides(so); + + List mappings = new ArrayList<>(); + for (Object item : asList(map.get("manual_mappings"))) { + Map 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 suppressions = new ArrayList<>(); + for (Object item : asList(map.get("suppressions"))) { + Map 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 asMap(Object obj) { + if (obj instanceof Map) { + return (Map) obj; + } + return new LinkedHashMap<>(); + } + + private static List asList(Object obj) { + if (obj instanceof List) { + return (List) obj; + } + return new ArrayList<>(); + } + + private static List strList(Object obj) { + List 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 map, String key, String def) { + Object v = map.get(key); + return v == null ? def : String.valueOf(v); + } + + private static boolean bool(Map 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 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 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)); + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/detector/RedisWritePointDetector.java b/redis-schema-checker/src/main/java/com/codechecker/redis/detector/RedisWritePointDetector.java new file mode 100644 index 0000000..2063f3a --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/detector/RedisWritePointDetector.java @@ -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 SERIALIZE_METHODS = new HashSet<>(Arrays.asList( + "toJSONString", "toJsonString", "getObjectToString", "toJsonStr", "writeValueAsString")); + private static final Set WRITE_METHODS = new HashSet<>(Arrays.asList("set", "insert")); + private static final Set COLLECTION_SIMPLE = new HashSet<>(Arrays.asList( + "List", "ArrayList", "LinkedList", "Set", "HashSet", "Collection")); + + private final SourceIndex index; + private final Set enabledPatterns; + private final RedisKeyResolver keyResolver; + + public RedisWritePointDetector(SourceIndex index, Set enabledPatterns) { + this.index = index; + this.enabledPatterns = enabledPatterns; + this.keyResolver = new RedisKeyResolver(index); + } + + public List detect(String filePath, String content) { + List 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 clazz = mce.findAncestor(ClassOrInterfaceDeclaration.class); + wp.setEnclosingClass(clazz.map(this::fqnOf).orElse("")); + Optional method = mce.findAncestor(CallableDeclaration.class); + wp.setEnclosingMethod(method.map(NodeName::of).orElse("")); + } + + 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 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 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 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(); + } + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/detector/WritePoint.java b/redis-schema-checker/src/main/java/com/codechecker/redis/detector/WritePoint.java new file mode 100644 index 0000000..bbe8524 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/detector/WritePoint.java @@ -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; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/diff/ChangeType.java b/redis-schema-checker/src/main/java/com/codechecker/redis/diff/ChangeType.java new file mode 100644 index 0000000..878acef --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/diff/ChangeType.java @@ -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; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/diff/SchemaChange.java b/redis-schema-checker/src/main/java/com/codechecker/redis/diff/SchemaChange.java new file mode 100644 index 0000000..ba2c6a8 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/diff/SchemaChange.java @@ -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; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/diff/SchemaDiffer.java b/redis-schema-checker/src/main/java/com/codechecker/redis/diff/SchemaDiffer.java new file mode 100644 index 0000000..d2b7363 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/diff/SchemaDiffer.java @@ -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 diff(TypeSchema oldSchema, TypeSchema newSchema) { + List changes = new ArrayList<>(); + + Map oldLeaves = leaves(oldSchema); + Map newLeaves = leaves(newSchema); + + Set removed = new LinkedHashSet<>(oldLeaves.keySet()); + removed.removeAll(newLeaves.keySet()); + Set 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 moves = new ArrayList<>(); + Set matchedRemoved = new LinkedHashSet<>(); + Set 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 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 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 leaves(TypeSchema schema) { + Map 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; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/diff/Severity.java b/redis-schema-checker/src/main/java/com/codechecker/redis/diff/Severity.java new file mode 100644 index 0000000..d1751d2 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/diff/Severity.java @@ -0,0 +1,10 @@ +package com.codechecker.redis.diff; + +/** + * 变更严重级别。 + */ +public enum Severity { + P0, + P1, + P2 +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/git/GitDiffScanner.java b/redis-schema-checker/src/main/java/com/codechecker/redis/git/GitDiffScanner.java new file mode 100644 index 0000000..336524e --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/git/GitDiffScanner.java @@ -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 命令的差异扫描与双版本文件内容读取。 + * + *

通过 {@code git diff --name-only} 获取变更的 .java 文件,通过 {@code git show sha:path} + * 读取指定提交下的文件内容,避免检出两个完整 worktree。

+ */ +public class GitDiffScanner { + + private final Path repoRoot; + + public GitDiffScanner(Path repoRoot) { + this.repoRoot = repoRoot; + } + + /** + * 返回 old..new 之间变更的 Java 文件路径(相对仓库根,使用 / 分隔)。 + */ + public List changedJavaFiles(String oldSha, String newSha) throws GitException { + List lines = runLines( + "git", "diff", "--name-only", "--diff-filter=ACMR", oldSha, newSha, "--", "*.java"); + List 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 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 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; + } + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/git/GitException.java b/redis-schema-checker/src/main/java/com/codechecker/redis/git/GitException.java new file mode 100644 index 0000000..6a485c9 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/git/GitException.java @@ -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); + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/key/RedisKeyResolver.java b/redis-schema-checker/src/main/java/com/codechecker/redis/key/RedisKeyResolver.java new file mode 100644 index 0000000..64b2c9d --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/key/RedisKeyResolver.java @@ -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 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; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/notify/WeComNotifier.java b/redis-schema-checker/src/main/java/com/codechecker/redis/notify/WeComNotifier.java new file mode 100644 index 0000000..507cd47 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/notify/WeComNotifier.java @@ -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 md = new LinkedHashMap<>(); + md.put("content", markdown); + Map 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 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; + } + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/report/CheckReport.java b/redis-schema-checker/src/main/java/com/codechecker/redis/report/CheckReport.java new file mode 100644 index 0000000..b33373d --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/report/CheckReport.java @@ -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 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 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; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/report/ReportBuilder.java b/redis-schema-checker/src/main/java/com/codechecker/redis/report/ReportBuilder.java new file mode 100644 index 0000000..6cab34c --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/report/ReportBuilder.java @@ -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> 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 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; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/schema/AnnotationSupport.java b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/AnnotationSupport.java new file mode 100644 index 0000000..8d0cff8 --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/AnnotationSupport.java @@ -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; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/schema/FieldSchema.java b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/FieldSchema.java new file mode 100644 index 0000000..4e32c2b --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/FieldSchema.java @@ -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 + ")"; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/schema/JavaSchemaExtractor.java b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/JavaSchemaExtractor.java new file mode 100644 index 0000000..249e26e --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/JavaSchemaExtractor.java @@ -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_TYPES = new HashSet<>(Arrays.asList( + "String", "CharSequence", "char", "Character", "UUID", + "Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp", + "BigDecimal")); + private static final Set 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 BOOLEAN_TYPES = new HashSet<>(Arrays.asList( + "boolean", "Boolean")); + private static final Set COLLECTION_TYPES = new HashSet<>(Arrays.asList( + "List", "ArrayList", "LinkedList", "Set", "HashSet", "LinkedHashSet", + "TreeSet", "Collection", "Iterable")); + private static final Set 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 ancestors, int depth) { + if (depth > maxDepth || ancestors.contains(type.getFqn())) { + return; + } + Set 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 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 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 collectFields(SourceIndex.IndexedType type, Set visited) { + java.util.List 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 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; + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/schema/JsonType.java b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/JsonType.java new file mode 100644 index 0000000..a96c3ef --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/JsonType.java @@ -0,0 +1,14 @@ +package com.codechecker.redis.schema; + +/** + * 序列化后 JSON 值的粗粒度类型。 + */ +public enum JsonType { + OBJECT, + ARRAY, + MAP, + STRING, + NUMBER, + BOOLEAN, + UNKNOWN +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/schema/SourceIndex.java b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/SourceIndex.java new file mode 100644 index 0000000..01035ee --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/SourceIndex.java @@ -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),供类型解析与字段展开使用。 + * + *

由于无法获取业务仓库完整依赖 classpath,本索引采用「手动符号解析」而非 JavaParser + * SymbolSolver:基于文件内 import、同包、内部类进行 FQN 解析,保证在只读源码场景下的稳定性。

+ */ +public class SourceIndex { + + /** FQN(以 . 分隔,含内部类) -> 类型信息 */ + private final Map byFqn = new LinkedHashMap<>(); + /** 简单类名 -> FQN 列表(兜底解析) */ + private final Map> 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 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 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 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 imports; + private final String fqn; + + IndexedType(ClassOrInterfaceDeclaration declaration, String packageName, List 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 getImports() { + return imports; + } + + public String getFqn() { + return fqn; + } + } +} diff --git a/redis-schema-checker/src/main/java/com/codechecker/redis/schema/TypeSchema.java b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/TypeSchema.java new file mode 100644 index 0000000..12ff62a --- /dev/null +++ b/redis-schema-checker/src/main/java/com/codechecker/redis/schema/TypeSchema.java @@ -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 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 getFields() { + return fields; + } + + public void add(FieldSchema field) { + fields.put(field.getPath(), field); + } + + public boolean isEmpty() { + return fields.isEmpty(); + } +} diff --git a/redis-schema-checker/src/main/resources/default-config.yaml b/redis-schema-checker/src/main/resources/default-config.yaml new file mode 100644 index 0000000..d36f717 --- /dev/null +++ b/redis-schema-checker/src/main/resources/default-config.yaml @@ -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: [] diff --git a/redis-schema-checker/src/test/java/com/codechecker/redis/TenantScenarioTest.java b/redis-schema-checker/src/test/java/com/codechecker/redis/TenantScenarioTest.java new file mode 100644 index 0000000..87ee888 --- /dev/null +++ b/redis-schema-checker/src/test/java/com/codechecker/redis/TenantScenarioTest.java @@ -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 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 changes = new SchemaDiffer().diff(oldSchema, newSchema); + List 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 wps) { + assertEquals(1, wps.size(), "应恰好检测到 1 个写入点"); + return wps.get(0); + } +} diff --git a/redis-schema-checker/src/test/java/com/codechecker/redis/TestSupport.java b/redis-schema-checker/src/test/java/com/codechecker/redis/TestSupport.java new file mode 100644 index 0000000..ff56ec9 --- /dev/null +++ b/redis-schema-checker/src/test/java/com/codechecker/redis/TestSupport.java @@ -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); + } + } +} diff --git a/redis-schema-checker/src/test/java/com/codechecker/redis/analyze/GlobMatcherTest.java b/redis-schema-checker/src/test/java/com/codechecker/redis/analyze/GlobMatcherTest.java new file mode 100644 index 0000000..edb91af --- /dev/null +++ b/redis-schema-checker/src/test/java/com/codechecker/redis/analyze/GlobMatcherTest.java @@ -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")); + } +} diff --git a/redis-schema-checker/src/test/java/com/codechecker/redis/config/ConfigLoaderTest.java b/redis-schema-checker/src/test/java/com/codechecker/redis/config/ConfigLoaderTest.java new file mode 100644 index 0000000..9c3f5a2 --- /dev/null +++ b/redis-schema-checker/src/test/java/com/codechecker/redis/config/ConfigLoaderTest.java @@ -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")); + } +} diff --git a/redis-schema-checker/src/test/java/com/codechecker/redis/diff/SchemaDifferTest.java b/redis-schema-checker/src/test/java/com/codechecker/redis/diff/SchemaDifferTest.java new file mode 100644 index 0000000..a9fa2ff --- /dev/null +++ b/redis-schema-checker/src/test/java/com/codechecker/redis/diff/SchemaDifferTest.java @@ -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 changes = new SchemaDiffer().diff(oldS, newS); + List 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 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()); + } +} diff --git a/redis-schema-checker/src/test/resources/fixtures/tenant/HelperNew.txt b/redis-schema-checker/src/test/resources/fixtures/tenant/HelperNew.txt new file mode 100644 index 0000000..d5c44d6 --- /dev/null +++ b/redis-schema-checker/src/test/resources/fixtures/tenant/HelperNew.txt @@ -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; + } +} diff --git a/redis-schema-checker/src/test/resources/fixtures/tenant/HelperOld.txt b/redis-schema-checker/src/test/resources/fixtures/tenant/HelperOld.txt new file mode 100644 index 0000000..8d4ebd5 --- /dev/null +++ b/redis-schema-checker/src/test/resources/fixtures/tenant/HelperOld.txt @@ -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); + } +} diff --git a/redis-schema-checker/src/test/resources/fixtures/tenant/TenantLinkModel.txt b/redis-schema-checker/src/test/resources/fixtures/tenant/TenantLinkModel.txt new file mode 100644 index 0000000..2db592e --- /dev/null +++ b/redis-schema-checker/src/test/resources/fixtures/tenant/TenantLinkModel.txt @@ -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; +} diff --git a/redis-schema-checker/src/test/resources/fixtures/tenant/TenantVO.txt b/redis-schema-checker/src/test/resources/fixtures/tenant/TenantVO.txt new file mode 100644 index 0000000..7ee4c31 --- /dev/null +++ b/redis-schema-checker/src/test/resources/fixtures/tenant/TenantVO.txt @@ -0,0 +1,8 @@ +package jnpf.model; + +import java.util.List; + +public class TenantVO { + private String dbName; + private List linkList; +}