Compare commits

25 Commits

Author SHA1 Message Date
89a48a98aa feat: w06 类型推断增强辅助
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 18:05:08 +08:00
c780b269d6 feat: 文档更新 2026-07-14 17:47:11 +08:00
d4590fbb86 feat: 文档更新 2026-07-14 17:44:24 +08:00
b7f4bbe03c feat: 流水线更新,修复commit次数统计不正确的问题
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 17:30:35 +08:00
96a3bd46cf feat: 流水线更新,兼容多次commit
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 17:17:35 +08:00
1f119edb7c feat: 文档更新 2026-07-14 17:11:58 +08:00
6e06cbf5b5 feat: md样式优化
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 16:22:05 +08:00
cab3fea666 feat: 完成二阶段的优化处理
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 15:49:40 +08:00
875a5edbd1 feat: 新增对 未知key 的处理逻辑
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 15:28:29 +08:00
c65bee5a76 feat: 新增对 未知key 的处理逻辑
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 15:21:34 +08:00
e7aa970ef8 feat:
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
1、多 key 默认拼成 1 条企微通知
2、UTF-8 超过 4096 字节 → 按 key 拆开,每条带完整抬头,依次发送
3、条间间隔 200ms,降低机器人限频风险
4、CI 控制台会标明「超长已按 key 拆为 N 条」并逐条打印
2026-07-14 14:30:24 +08:00
59326ebe3c feat: 效果样式修改
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 14:08:26 +08:00
6140d091e4 feat: 效果样式修改
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 14:07:41 +08:00
a6fdc5fc5e feat: 效果修改
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 11:38:18 +08:00
4da632bd5c feat: 效果修改
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 11:32:30 +08:00
d0f10e5479 feat: 1、将 - saas:period-config:migration:current 修改为 - Key --> saas:period-config:migration:current
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2、将 -   saas:period-config:migration:current 加粗
      3、将旧骨架标注为绿色
      4、将新骨架标注为warn颜色
2026-07-14 11:17:58 +08:00
110beb79c0 feat: 项目整体命名修改cache-schema-checker
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 11:03:34 +08:00
114b053733 feat: 1、流水线总开关配置 2、删除p0p1p2级别阻断
All checks were successful
Redis序列化结构检查 / redis-schema-check (push) Has been skipped
2026-07-14 10:58:05 +08:00
7a8ca7ce0b feat: 1、企微通知中隐藏,CI中打印字段明细,另外将整体输出到企微中的md,在CI控制台中打印出来
All checks were successful
Redis序列化结构检查 / redis-schema-check (push) Has been skipped
2、整段截断,具体改动的字段不可截断
      3、不再区分P0\P1\P2
2026-07-14 10:43:38 +08:00
ea7bcaff2f feat: 修改从gitea上获取配置的逻辑
All checks were successful
Redis序列化结构检查 / redis-schema-check (push) Has been skipped
2026-07-14 10:23:51 +08:00
797f435ccd feat: 修改从gitea上获取配置的逻辑
All checks were successful
Redis序列化结构检查 / redis-schema-check (push) Has been skipped
2026-07-13 16:49:00 +08:00
327546b127 feat: 修改deploy问题
All checks were successful
Redis序列化结构检查 / redis-schema-check (push) Has been skipped
2026-07-13 16:04:13 +08:00
30e530c0bf feat: 删除pom
Some checks failed
Redis序列化结构检查 / redis-schema-check (push) Has been cancelled
2026-07-13 15:54:38 +08:00
436460cfdb feat: 删除无用流水线
Some checks failed
Redis序列化结构检查 / redis-schema-check (push) Has been cancelled
2026-07-13 15:42:47 +08:00
6aab14bd1e feat: 流水线补充
Some checks failed
Redis序列化结构检查 / redis-schema-check (push) Has been cancelled
2026-07-13 15:41:53 +08:00
61 changed files with 4044 additions and 1289 deletions

View File

@@ -0,0 +1,35 @@
# ============================================================
# 缓存序列化结构变更检测 — 业务仓库配置
# ============================================================
# 说明:
# - 本配置文件为业务覆盖配置,会与 jar 内 default-config.yaml 深度合并
# - 未声明的项沿用工具内置默认值(忽略规则、检测模式等)
# - 当前实现以 Redis 写入检测为主,后续可扩展其他缓存
# 总开关 true-执行检测 false-跳过检测(流水线直接通过,不发通知)
enabled: true
# 运行模式 notify-仅通知,不阻断流水线 block-检测到结构变更即阻断流水线exit 1
mode: notify
# 通知配置
notify:
enabled: true
webhook_url: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=fa14f0b3-e01a-40f6-96bd-e18beb94e85e
notify_on_clean: false
title_prefix: "【缓存结构变更】"
# 观察期:先只扫描 jnpf-tenant 模块,稳定后改为 include_modules: []
include_modules:
- jnpf-tenant
# 手动补充映射(自动推断不准时使用)
manual_mappings:
- id: tenant-db-content
writer_method: "jnpf.util.TenantDbContentCacheHelper#cacheSuccess"
key_pattern: "tenant:db:content:*"
value_type: "jnpf.util.TenantDbContentCacheHelper.CacheEnvelope"
description: "租户库信息缓存"
# 误报忽略(按需添加)
suppressions: []

View File

@@ -1,83 +0,0 @@
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"

View File

@@ -0,0 +1,209 @@
name: 缓存序列化结构检查
run-name: ${{ gitea.actor }}的缓存结构检查
on:
push:
workflow_dispatch:
env:
# cache-schema-checker 私库坐标com.codechecker:cache-schema-checker:1.0.0
CACHE_SCHEMA_CHECKER_VERSION: "1.0.0"
CACHE_SCHEMA_CHECKER_REPO_URL: "http://192.168.3.25:18081/nexus/repository/maven-releases"
jobs:
cache-schema-check:
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
runs-on: jdk11
steps:
# 浅克隆当前 tip对比基准用 push before覆盖一次 push 的多 commit 累计 diff
# 不必全量历史:只需能 git show before / after 两边的文件内容
- name: 检出代码
run: |
git config --global http.sslVerify false
REPO_URL="https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git"
BRANCH="${{ gitea.ref_name }}"
NEW_SHA="${{ gitea.sha }}"
git clone --depth 1 --single-branch --branch "${BRANCH}" "${REPO_URL}" .
git checkout -B "${BRANCH}" "${NEW_SHA}"
echo "${NEW_SHA}" > /tmp/cache-schema-new-sha.txt
- name: 检查配置文件
run: |
if [ ! -f .gitea/config/cache-schema-check-config.yaml ]; then
echo "错误: 缺少 .gitea/config/cache-schema-check-config.yaml"
exit 1
fi
# 顶层总开关 enabled: false 时跳过后续步骤(与 notify.enabled 区分,仅匹配行首)
if grep -Eq '^enabled:[[:space:]]*false([[:space:]]|#|$)' .gitea/config/cache-schema-check-config.yaml; then
echo "总开关 enabled=false跳过缓存结构检查"
touch /tmp/cache-schema-check.skip
fi
- name: 从 Nexus 私库下载 cache-schema-checker
run: |
if [ -f /tmp/cache-schema-check.skip ]; then
echo "总开关已关闭,跳过下载"
exit 0
fi
GROUP_PATH="com/codechecker/cache-schema-checker"
JAR_NAME="cache-schema-checker-${CACHE_SCHEMA_CHECKER_VERSION}.jar"
JAR_URL="${CACHE_SCHEMA_CHECKER_REPO_URL}/${GROUP_PATH}/${CACHE_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无法从 Nexus 下载 jar"
exit 1
fi
if [ ! -s "${JAR_PATH}" ]; then
echo "错误: 下载失败或文件为空: ${JAR_PATH}"
exit 1
fi
ls -lh "${JAR_PATH}"
- name: 验证 JDK
run: |
if [ -f /tmp/cache-schema-check.skip ]; then
echo "总开关已关闭,跳过"
exit 0
fi
echo "Java: $(java -version 2>&1 | head -1)"
- name: 执行缓存序列化结构检测
env:
# push 前 tip新分支首次 push 时为全 0。workflow_dispatch 可能为空,下方会回退。
PUSH_BEFORE: ${{ gitea.event.before }}
# push 事件携带的 commits 列表JSON用于准确统计 commit 数(不受浅克隆影响)
PUSH_COMMITS_JSON: ${{ toJson(gitea.event.commits) }}
run: |
if [ -f /tmp/cache-schema-check.skip ]; then
echo "总开关已关闭,跳过检测"
exit 0
fi
NEW_SHA=$(cat /tmp/cache-schema-new-sha.txt)
OLD_SHA="${PUSH_BEFORE}"
# 新分支首次 pushbefore 全 0→ 跳过
if [ -z "$OLD_SHA" ] || echo "$OLD_SHA" | grep -Eq '^0+$'; then
# 手动触发:无 before回退为 HEAD~1加深 1 层后取父提交)
if [ "${{ gitea.event_name }}" = "workflow_dispatch" ]; then
git fetch --deepen 1 || true
OLD_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "")
if [ -z "$OLD_SHA" ]; then
echo "手动触发且无父提交,跳过检测"
exit 0
fi
echo "workflow_dispatch无 push before回退使用 HEAD~1=${OLD_SHA}"
else
echo "新分支首次 pushbefore 为空/全 0跳过检测"
exit 0
fi
fi
if [ "$OLD_SHA" = "$NEW_SHA" ]; then
echo "before 与 after 相同,无需检测"
exit 0
fi
# 确保提交对象可读
ensure_commit() {
local sha="$1"
if git cat-file -e "${sha}^{commit}" 2>/dev/null; then
return 0
fi
echo "本地缺少 ${sha},尝试按 SHA 浅取…"
if git fetch --depth 1 origin "${sha}"; then
git cat-file -e "${sha}^{commit}" 2>/dev/null && return 0
fi
for d in 10 30 50 100; do
echo "deepen ${d}…"
git fetch --deepen "${d}" || true
if git cat-file -e "${sha}^{commit}" 2>/dev/null; then
return 0
fi
done
return 1
}
# 加深当前 tip 的历史,直到 before 成为 after 的祖先(否则 rev-list 在浅克隆下会少算)
ensure_range() {
local old="$1"
local new="$2"
local i=0
while [ "$i" -lt 15 ]; do
if git merge-base --is-ancestor "${old}" "${new}" 2>/dev/null; then
return 0
fi
echo "区间历史不完整deepen 20…第 $((i + 1)) 次)"
git fetch --deepen 20 || true
i=$((i + 1))
done
# force-push 等非祖先关系:对象在即可做 git show但 rev-list 不可靠
if git cat-file -e "${old}^{commit}" 2>/dev/null \
&& git cat-file -e "${new}^{commit}" 2>/dev/null; then
echo "警告: ${old} 不是 ${new} 的祖先(可能 force-pushcommit 计数将改用事件 payload"
return 0
fi
return 1
}
# 优先用 Gitea push 事件的 commits 数组长度(准确且不依赖 shallow
count_from_payload() {
if [ -z "${PUSH_COMMITS_JSON:-}" ] || [ "${PUSH_COMMITS_JSON}" = "null" ] || [ "${PUSH_COMMITS_JSON}" = "[]" ]; then
return 1
fi
# Gitea/GitHub push payload: [{"id":"<40位sha>", ...}, ...]
local n
n=$(printf '%s' "${PUSH_COMMITS_JSON}" | grep -oE '"id"[[:space:]]*:[[:space:]]*"[0-9a-fA-F]{7,40}"' | wc -l | tr -d ' ')
if [ -n "$n" ] && [ "$n" -gt 0 ] 2>/dev/null; then
echo "$n"
return 0
fi
return 1
}
if ! ensure_commit "$OLD_SHA"; then
echo "错误: 无法获取 push 前 tip ${OLD_SHA},请检查 shallow/权限"
exit 2
fi
if ! ensure_commit "$NEW_SHA"; then
echo "错误: 无法解析当前 tip ${NEW_SHA}"
exit 2
fi
if ! ensure_range "$OLD_SHA" "$NEW_SHA"; then
echo "错误: 无法补齐 ${OLD_SHA}..${NEW_SHA} 历史"
exit 2
fi
echo "对比区间: ${OLD_SHA} → ${NEW_SHA}"
COMMIT_COUNT=""
if COMMIT_COUNT=$(count_from_payload); then
echo "本次 push 累计 commit 数: ${COMMIT_COUNT}(来自事件 payload"
elif git merge-base --is-ancestor "$OLD_SHA" "$NEW_SHA" 2>/dev/null; then
COMMIT_COUNT=$(git rev-list --count "${OLD_SHA}..${NEW_SHA}" 2>/dev/null || echo "?")
echo "本次 push 累计 commit 数: ${COMMIT_COUNT}(来自 git rev-list"
else
echo "本次 push 累计 commit 数: ?(非祖先关系且无 payload"
fi
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S' "${NEW_SHA}")
java -jar "/tmp/cache-schema-checker-${CACHE_SCHEMA_CHECKER_VERSION}.jar" \
--config .gitea/config/cache-schema-check-config.yaml \
--repo-root . \
--old-sha "$OLD_SHA" \
--new-sha "$NEW_SHA" \
--branch "${{ gitea.ref_name }}" \
--modifier "${{ gitea.actor }}" \
--modify-time "$COMMIT_TIME"

5
.idea/compiler.xml generated
View File

@@ -6,8 +6,11 @@
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="redis-schema-checker" />
<module name="cache-schema-checker" />
</profile>
</annotationProcessing>
<bytecodeTargetLevel>
<module name="cache-schema-checker" target="11" />
</bytecodeTargetLevel>
</component>
</project>

2
.idea/encodings.xml generated
View File

@@ -1,8 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/redis-schema-checker/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/redis-schema-checker/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>

View File

@@ -1,25 +1,43 @@
# Redis 序列化结构检测 — CI 集成说明
# 缓存序列化结构检测 — CI 集成说明
---
## 1. 集成概览
```text
开发者 push 代码
开发者 push 代码(可含多个 commit
Gitea Actions 触发
浅克隆业务仓库depth=2
浅克隆业务仓库 tipdepth=1+ 按需取 push 前 tipbefore
从 Nexus 下载 redis-schema-checker.jar
从 Nexus 下载 cache-schema-checker.jar
java -jar 执行(对比 HEAD~1 与 HEAD
java -jar 执行(对比 before → after累计 diff
P0/P1 变更 → 企微通知
结构变更 → 企微通知(按 Key 骨架;删除橙/新增绿)
mode=block 且含 P0/P1/P2 任一变更 → exit 1流水线失败
mode=block 且含任意结构变更 → exit 1流水线失败
```
### 1.1 对比区间(重要)
| 参数 | 取值 | 含义 |
|------|------|------|
| `--old-sha` | `gitea.event.before` | 本次 push **前**的远端 tip |
| `--new-sha` | `gitea.sha` | 本次 push **后**的 tip |
一次 push 推了多个 commit 时,只跑 **一次** 检测,覆盖整次 push 的累计代码差,**不会**因为「变更发生在中间 commit、最后一个 commit 没改相关文件」而漏检。
不需要全量历史:工作树是当前 tip`before` 通过 `git fetch --depth 1 <sha>`(或 deepen取到即可。
边界:
- `before``0` / 空 → 新分支首次 push跳过
- `workflow_dispatch` 无 before → 回退 `HEAD~1`
- 中间 commit 改坏又被末 commit 改回 → 累计可能无告警(以最终结构为准)
- commit 计数:优先取 `gitea.event.commits` 长度;否则 deepen 到 `before` 为祖先后再 `git rev-list`(浅克隆下直接 `rev-list` 会少算)
---
## 2. 前置条件
@@ -28,8 +46,7 @@ mode=block 且含 P0/P1/P2 任一变更 → exit 1流水线失败
|----|------|
| 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` 已配置 |
| 工具 JAR | `com.codechecker:cache-schema-checker:1.0.0` 已发布 |
---
@@ -41,93 +58,36 @@ mode=block 且含 P0/P1/P2 任一变更 → exit 1流水线失败
jnpf-java-cloud/
├── .gitea/
│ ├── workflows/
│ │ └── redis-schema-check.yaml # 流水线
│ │ └── cache-schema-check.yaml # 流水线
│ └── config/
│ └── redis-schema-check-config.yaml # 检测配置
│ └── cache-schema-check-config.yaml # 检测配置
```
请以本仓库 `.gitea/workflows/cache-schema-check.yaml` 为模板同步到业务仓。
---
## 4. 流水线模板
## 4. 流水线模板(要点)
```yaml
name: Redis序列化结构检查
run-name: ${{ gitea.actor }}的Redis结构检查
完整可运行版本见:`.gitea/workflows/cache-schema-check.yaml`
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"
```bash
# 1) 浅拉 tip
git clone --depth 1 --single-branch --branch "$BRANCH" "$REPO_URL" .
git checkout -B "$BRANCH" "$NEW_SHA" # NEW_SHA = gitea.sha
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 }}"
# 2) OLD_SHA = gitea.event.before全 0 则跳过;手动触发回退 HEAD~1
# 3) 本地没有 OLD_SHA 时:
git fetch --depth 1 origin "$OLD_SHA" # 优先
# 或 git fetch --deepen N # 兜底
- 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 . \
# 4) 执行
java -jar cache-schema-checker-1.0.0.jar \
--old-sha "$OLD_SHA" \
--new-sha "$(git rev-parse HEAD)" \
--branch "${{ gitea.ref_name }}" \
--modifier "${{ gitea.actor }}" \
--modify-time "$COMMIT_TIME"
--new-sha "$NEW_SHA" \
...
```
---
@@ -138,28 +98,27 @@ jobs:
|--------|------|------|
| `demo.yaml` (AI代码质量分析) | AI Code Review | 并行,互不影响 |
| `code-check` (CodeChecker) | 通用变更检测 | **同模式**,可并列执行 |
| `redis-schema-check` | Redis 结构检测 | 新增 |
| `cache-schema-check` | 缓存结构检测 | 新增 |
建议:三个 job 独立并行,各自 exit code 独立。
---
## 6. 工具发布流程redisCheck 仓库)
```bash
# 在 redisCheck 仓库
# 在 redisCheck 仓库根目录
mvn clean package -DskipTests
# 发布到 Nexus需配置 settings.xml
mvn deploy -DskipTests
mvn clean 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
com/codechecker/cache-schema-checker/1.0.0/
├── cache-schema-checker-1.0.0.jar # 可执行 fat-jar
└── cache-schema-checker-1.0.0.pom
```
---
@@ -168,9 +127,9 @@ com/codechecker/redis-schema-checker/1.0.0/
| 退出码 | 含义 | 流水线表现 |
|--------|------|------------|
| 0 | 通过(含 notify 模式下的告警) | 绿色 |
| 1 | 阻断block 模式 + P0/P1/P2 任一变更 | 红色 |
| 2 | 执行错误配置缺失、jar 异常等) | 红色 |
| 0 | 通过(含 notify 模式下的告警) / 跳过 | 绿色 |
| 1 | 阻断block 模式) | 红色 |
| 2 | 执行错误(配置缺失、无法取 before、jar 异常等) | 红色 |
---
@@ -178,27 +137,35 @@ com/codechecker/redis-schema-checker/1.0.0/
| 现象 | 可能原因 | 处理 |
|------|----------|------|
| 首次提交跳过 | 无 HEAD~1 | 正常行为 |
| 新分支首次 push 跳过 | `before` 全 0 | 正常行为 |
| 无法获取 before / exit 2 | 浅克隆未取到对象、服务端禁 fetch SHA | 看日志中的 deepen确认 Gitea 允许按 SHA fetch |
| 下载 jar 失败 | Nexus 地址/版本错误 | 检查 env 变量 |
| 未收到企微 | Secret 未配 / notify.enabled=false | 检查配置 |
| 未收到企微 | Secret 未配 / notify.enabled=false / webhook_url 空 | 检查配置 |
| 大量误报 | 锁/计数器未过滤 | 补充 ignore.key_patterns |
| 漏报 | 写入模式未覆盖 | 启用 W04/W05 或补充 manual_mappings |
| 类型展开不完整 | 类型在依赖 jar 中 | 补充 manual_mappings.value_type |
| commit 数显示为 1实际多个 | 浅克隆下 `rev-list` 看不到中间提交 | 已修复:优先事件 `commits` 长度;并 deepen 到 before 为祖先 |
| 漏报(模式/模块) | W0x 未开 / include_modules 过窄 | 确认 W01~W05检查模块过滤 |
| 类型展开不完整 | 类型在依赖 jar 中 | 补充 `manual_mappings.value_type` |
---
## 9. 本地调试
模拟一次「多 commit push」的累计区间
```bash
# 在 jnpf-java-cloud 根目录
java -jar /path/to/redis-schema-checker-1.0.0.jar \
--config .gitea/config/redis-schema-check-config.yaml \
# OLD = 推送前 tipNEW = 当前 tip可用 origin/branch@{1} 或显式 sha
OLD_SHA=$(git rev-parse origin/$(git branch --show-current)~3) # 示例:假设 ahead 3
NEW_SHA=$(git rev-parse HEAD)
java -jar /path/to/cache-schema-checker-1.0.0.jar \
--config .gitea/config/cache-schema-check-config.yaml \
--repo-root . \
--old-sha HEAD~1 \
--new-sha HEAD \
--old-sha "$OLD_SHA" \
--new-sha "$NEW_SHA" \
--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')"
--modify-time "$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')" \
--dry-run
```
可加 `--dry-run`Phase 2 实现)仅输出报告不发企微
单 commit 自测仍可用 `--old-sha HEAD~1 --new-sha HEAD`

View File

@@ -1,9 +1,10 @@
# Redis 序列化结构变更检测 — 实施方案
# 缓存序列化结构变更检测 — 实施方案
> 版本v0.1
> 日期2026-07-13
> 版本v0.2
> 日期2026-07-14
> 技术栈Java 11 + Maven + JavaParser
> 目标仓库:`redisCheck`(工具) / `jnpf-java-cloud`(被检测业务仓库)
> 当前阶段:**Phase 1 + Phase 2 已完成**Phase 3 待做
---
@@ -56,7 +57,7 @@
4. 通过企微机器人发送通知
5. 通过开关控制 **仅通知****阻断流水线**
### 1.3 非目标(第一版不做)
### 1.3 非目标
- 不连接真实 Redis 实例做运行时校验
- 不扫描 Maven 依赖 jar 中的类(仅分析业务仓库源码)
@@ -71,20 +72,20 @@
### 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 字符串 | 高 | **重点支持** |
| 类型 | 出现频率 | 策略 |
|------|----------|------|
| `JSON.toJSONString(obj)` / `JSONObject.toJSONString(obj)` | 高 | **支持**W01/W02 |
| `JsonUtil.getObjectToString(obj)` | 高 | **支持**W03 |
| `RedisTemplate.opsForValue().set(key, obj)` 直接写对象 | 中 | **已支持**W04 |
| `RedisTemplate.opsForHash().put(key, field, obj)` | 中 | **已支持**W05 |
| `StringRedisTemplate` 写 JSON 字符串 | 高 | **支持** |
| 锁 / 计数器 / token 简单值 | 高 | **默认忽略** |
### 2.2 Key 与实体映射
- 不存在统一的「Key → 类型」注册中心
- 存在大量 `static final String``String.format(...)``buildCacheKey(...)` 等模式
- 第一版采用:**写入点静态推断 + 可选 YAML 人工补充映射**
- 因此采用:**写入点静态推断 + 可选 YAML 人工补充映射**
### 2.3 多模块特征
@@ -98,18 +99,17 @@
### 3.1 交付形态
沿用现有 `code-checker` 模式(见 `redisCheck/.gitea/demo.yaml`
```text
redisCheck 仓库
schemaCheck 仓库
├── 开发 Java 分析工具
├── mvn package 打 fat-jar
├── 发布到 Nexuscom.codechecker:redis-schema-checker:{version}
├── 发布到 Nexuscom.codechecker:cache-schema-checker:{version}
└── 提供默认配置模板
jnpf-java-cloud 仓库
├── .gitea/workflows/redis-schema-check.yaml
├── .gitea/config/redis-schema-check-config.yaml
├── .gitea/workflows/cache-schema-check.yaml
├── .gitea/config/cache-schema-check-config.yaml
└── push 时下载 jar 并执行检测
```
@@ -119,11 +119,11 @@ jnpf-java-cloud 仓库
flowchart TB
subgraph Gitea["Gitea Push Pipeline"]
A[push 事件] --> B[浅克隆 old/new 提交]
B --> C[下载 redis-schema-checker.jar]
B --> C[下载 cache-schema-checker.jar]
C --> D[java -jar 执行检测]
end
subgraph Checker["redis-schema-checker (JDK 11)"]
subgraph Checker["cache-schema-checker (JDK 11)"]
D --> E[GitDiffScanner]
E --> F[RedisWritePointDetector]
F --> G[JavaSchemaExtractor]
@@ -143,8 +143,8 @@ flowchart TB
1. **纯静态分析**:基于 Java 源码 AST + 符号解析,不启动 Spring 容器
2. **Diff 驱动**:只分析本次 push 变更涉及的文件及其关联类型
3. **本仓限定**:类型解析仅在业务仓库 `src/main/java` 范围内
4. **可配置**:忽略规则、严重级别、通知开关、阻断开关均可 YAML 配置
5. **可演进**第一版聚焦 JSON 字符串写入,后续扩展 Template 直写对象
4. **可配置**:忽略规则、通知开关、阻断开关均可 YAML 配置
5. **可演进**已覆盖 JSON 字符串写入 Template 直写 / Hash后续可扩展读路径反向确认、报告落盘等
---
@@ -163,76 +163,52 @@ flowchart TB
**不采用** Spoon / Eclipse JDT 的原因JavaParser 足够覆盖第一版需求依赖更轻CLI 启动更快。
**Lombok 处理策略**第一版基于源码字段 + `@Data` 等注解推断序列化字段;对 `@Builder``@SuperBuilder` 等复杂场景标记为低置信度」并降级为 P2 提示。后续可选集成 `lombok.ast` 或 delombok 预处理。
**Lombok 处理策略**:基于源码字段 + `@Data` 等注解推断序列化字段;对 `@Builder``@SuperBuilder` 等复杂场景标记为低置信度提示。后续可选集成 `lombok.ast` 或 delombok 预处理。
---
## 5. 工程结构redisCheck 仓库)
```text
redisCheck/
├── pom.xml
schemaCheck/
├── pom.xml # 单模块工程(无父子结构)
├── docs/
│ ├── 实施方案.md # 本文档
│ ├── 配置说明.md # YAML 配置项详解
│ └── CI集成说明.md # 业务仓库接入步骤
├── redis-schema-checker/
│ ├── pom.xml
│ └── src/
│ ├── 实施方案.md
│ ├── 配置说明.md
│ └── CI集成说明.md
├── src/
│ ├── main/
│ │ ├── resources/
│ │ │ └── default-config.yaml # 内置默认配置(随 jar 发布)
│ └── java/com/codechecker/redis/
│ │ └── java/com/codechecker/cache/
│ │ ├── 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
│ │ ├── detector/ # Redis 写入点检测W01~W05
├── schema/ # Schema 提取与注解
│ │ ├── diff/ # 结构对比
│ │ │ ├── SchemaDiffer.java
│ │ │ ├── SchemaChange.java
│ │ │ ├── ChangeType.java
│ │ │ └── Severity.java
│ │ ├── key/ # Key 推断
│ └── RedisKeyResolver.java
│ │ ├── report/ # 报告
│ │ │ ├── ReportBuilder.java
│ │ │ └── CheckReport.java
├── report/ # 报告 / 企微 Markdown
│ │ └── notify/ # 企微通知
│ │ └── WeComNotifier.java
│ └── test/
├── resources/fixtures/tenant/ # 夹具TenantVO/Helper 新旧版本
└── java/... # 各模块单测
── .gitea/
── demo.yaml # 工具自身 CI可选
│ ├── resources/fixtures/{tenant,lock,template}/
│ └── java/...
── .gitea/
── workflows/cache-schema-check.yaml
│ └── config/cache-schema-check-config.yaml
└── target/ # 构建产物
```
### 5.1 Maven 坐标
```xml
<groupId>com.codechecker</groupId>
<artifactId>redis-schema-checker</artifactId>
<version>1.0.0-SNAPSHOT</version>
<artifactId>cache-schema-checker</artifactId>
<version>1.0.0</version>
```
打包为 **shaded/fat jar**,主类:`com.codechecker.redis.cli.RedisSchemaCheckerMain`
打包为 **shaded/fat jar**,主类:`com.codechecker.cache.cli.CacheSchemaCheckerMain`
---
@@ -241,8 +217,8 @@ redisCheck/
### 6.1 CLI 参数
```bash
java -jar redis-schema-checker-1.0.0.jar \
--config .gitea/config/redis-schema-check-config.yaml \
java -jar cache-schema-checker-1.0.0.jar \
--config .gitea/config/cache-schema-check-config.yaml \
--repo-root /path/to/jnpf-java-cloud \
--old-sha abc123 \
--new-sha def456 \
@@ -263,19 +239,23 @@ java -jar redis-schema-checker-1.0.0.jar \
### 6.2 对比基准old-sha获取策略
`demo.yaml` 保持一致,优先级
流水线使用 **push 区间累计对比**(方案:`before``after`
1. 流水线显式传入 `--old-sha`(通常为 `HEAD~1`
2. `HEAD~1` 不存在(首次提交)→ 跳过检测,`exit 0`
3. 浅克隆 `--depth 2` 确保 `HEAD~1` 可用
1. `--new-sha` = `gitea.sha`push 后 tip
2. `--old-sha` = `gitea.event.before`push 前 tip
3. `before` 为空或全 `0`(新分支首次 push→ 跳过检测,`exit 0`
4. `workflow_dispatch` 无 before 时回退 `HEAD~1`
5. 浅克隆当前 tip`--depth 1`),再按需 `git fetch --depth 1 <before>`;统计 commit 数时 deepen 至 `before` 为祖先,或直接用事件 `commits` 数组长度(**无需全量历史**
> 不支持一次 push 多个 commit 时逐个分析;第一版仅对比 `HEAD~1..HEAD`。后续可扩展为 `before..after` 范围分析。
> 一次 push 多个 commit 时,只做 **一次** 检测,对比区间为整次 push 的累计 diff`before..after`),不会漏掉中间 commit 留下的结构变更。
> 不做「每个 commit 各告警一条」;中间引入又被末 commit 改回的净无变更,累计结果可能为「无变更」(符合阻断「最终结构」的目标)。
> 日志中的 commit 数:优先 `gitea.event.commits`;勿在未 deepen 时用浅库 `rev-list`(会少算)。
### 6.3 处理步骤
#### Step 1加载配置
读取 `redis-schema-check-config.yaml`,合并默认值(见 `docs/配置说明.md`)。
读取 `cache-schema-check-config.yaml`,合并默认值(见 `docs/配置说明.md`)。
#### Step 2Git Diff 扫描
@@ -297,20 +277,32 @@ git diff --name-only {old-sha} {new-sha} -- '*.java'
**变更文件** 中扫描以下 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)` | 辅助反向确认读取类型 |
| 模式 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)` | Hash 写出 value 类型 | ✅ |
**忽略规则**(自动):
- value 为字符串字面量、数字、`UUID``"1"`
- 方法名含 `setIfAbsent``increment``delete``remove``expire`
- key 匹配 `ignore_key_patterns` 配置
- value 为字符串字面量、数字、`UUID``"1"`琐碎值
- 方法名含 `setIfAbsent``increment``delete``remove``expire`
- key 匹配 `ignore.key_patterns` 配置(锁 / token / 登录计数等)
#### Step 4b读侧类型辅助W06非写入模式
W06 **不产生独立告警**,只扫描反序列化调用,用读到的 `Xxx.class` **补强**同文件(或同 key写入点的 value 类型 / 根数组标记。
| 匹配示例 | 作用 |
|----------|------|
| `JSON.parseObject(raw, Xxx.class)` | 补强对象类型 |
| `JSON.parseArray(raw, Xxx.class)` / `JsonUtil.getJsonToList` | 补强 `List<Xxx>`rootArray |
| `JsonUtil.getJsonToBean(raw, Xxx.class)` | 同上 |
| 可关联到 `redisUtil.getString(key)` / `opsForValue().get(key)` | 同时补强 key 模式 |
开关:`detection.read_hints_enabled`(默认 `true`)。优先级:`manual_mappings` > W06 补强 > 写侧 AST 推断。
#### Step 5类型推断
@@ -332,15 +324,16 @@ redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), ttl);
4. 递归展开 `TenantVO``dbName: String``linkList: List<TenantLinkModel>`
5. 继续展开 `TenantLinkModel` 全部字段
**注解处理**(第一版)
**注解处理**
| 注解 | 行为 |
|------|------|
| `@JSONField(serialize = false)` | 排除字段 |
| `@JSONField(name = "xxx")` | 字段名映射 |
| `@JsonIgnore` | 排除字段 |
| `@JsonProperty("xxx")` | 字段名映射 |
| `@Schema` | 忽略(不影响序列化) |
| 注解 | 行为 | 状态 |
|------|------|------|
| `@JSONField(serialize = false)` | 排除字段 | ✅ |
| `@JSONField(name = "xxx")` | 字段名映射 | ✅ |
| `@JsonIgnore` | 排除字段 | ✅ |
| `@JsonProperty("xxx")` | 字段名映射 | ✅ |
| `@JsonIgnoreProperties({...})` | 类级忽略字段 | ✅ |
| `@Schema` | 忽略(不影响序列化) | ✅ |
#### Step 6生成 JSON Schema
@@ -372,37 +365,47 @@ redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), ttl);
对比同一写入点在 old/new 两个版本的 `TypeSchema`,输出 `SchemaChange` 列表。
**变更类型与严重级别**
**变更类型**(有结构差异即告警;`block` 下任意变更均阻断)
| 变更类型 | 示例 | 默认级别 |
|----------|------|----------|
| `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 |
| 变更类型 | 示例 |
|----------|------|
| `FIELD_REMOVED` | 删除 `dbName` |
| `TYPE_CHANGED` | `linkList` 从数组变对象 |
| `WRAPPER_ADDED` | 顶层增加 `vo` 包装 |
| `FIELD_PATH_MOVED` | `dbName``vo.dbName` |
| `FIELD_ADDED` | 新增 `expiresAtMs` |
| `KEY_PATTERN_CHANGED` | key 常量变更 |
| `WRITE_POINT_REMOVED` | 删除缓存写入 |
| `WRITE_POINT_ADDED` | 新增缓存写入 |
| `LOW_CONFIDENCE` | 类型推断失败(仍提示,置信度较低) |
#### Step 8报告与通知
生成 `CheckReport`,包含:
- 仓库名、分支、old/new sha、提交人、时间
- 变更列表(按严重级别排序)
- 每项Key 模式、写入位置(类#方法:行号)、旧结构、新结构、变更摘要
- 按 Key 聚合的结构变更(骨架 before/after+ 字段级明细
- 每项通用展示:**Key**、**位置**`Class#method:line`)、**类型**、旧/新序列化骨架
调用企微 Webhook 发送 Markdown 消息。
企微 Markdown 规则:
- 抬头不含 mode 汇总;正文按 Key 展示骨架
- **删除字段**:旧骨架中橙色 `<font color="warning">`
- **新增字段**:新骨架中绿色 `<font color="info">`
- key 未解析时展示源码表达式 + 灰色「key 未解析)」
- 单条超 4096 UTF-8 字节时按 Key 拆成多条依次发送
CI 控制台额外输出字段级变更明细(变更类型 + 位置 + 摘要),再打印与企微一致的 Markdown。
调用企微 Webhook 发送 Markdown支持 `--dry-run` 仅本地输出)。
#### Step 9退出码
| 条件 | 退出码 |
|------|--------|
| 无变更 / 仅 P2 | 0 |
| `mode=notify` 且存在 P0/P1 | 0仍通知 |
| `mode=block` 且存在 P0/P1/P2 | 1 |
| `enabled=false` / 无变更 | 0 |
| `mode=notify` 且存在变更 | 0仍通知 |
| `mode=block` 且存在任意结构变更 | 1 |
| 配置错误 / 执行异常 | 2 |
---
@@ -439,16 +442,16 @@ manual_mappings:
| 层级 | 位置 | 职责 |
|------|------|------|
| 默认配置 | 工具 jar 内 `default-config.yaml` | 检测模式、忽略规则、严重级别默认值 |
| 业务覆盖 | `jnpf-java-cloud/.gitea/config/redis-schema-check-config.yaml` | mode、notify、include_modules、manual_mappings |
| 默认配置 | 工具 jar 内 `default-config.yaml` | 检测模式、忽略规则默认值 |
| 业务覆盖 | `jnpf-java-cloud/.gitea/config/cache-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 \
java -jar cache-schema-checker.jar \
--config .gitea/config/cache-schema-check-config.yaml \
...
```
@@ -457,21 +460,20 @@ java -jar redis-schema-checker.jar \
详见 `docs/配置说明.md`。核心开关:
```yaml
# 运行模式notify仅通知| blockP0/P1/P2 全部阻断流水线)
mode: notify
# 总开关false 时跳过检测与通知,流水线直接通过
enabled: true
# block 模式下触发 exit 1 的严重级别(全部阻断
block_severities:
- P0
- P1
- P2
# 运行模式notify仅通知| block检测到结构变更即阻断流水线
mode: notify
# 是否发送企微通知
notify:
enabled: true
webhook_env: WECOM_ROBOT_WEBHOOK
webhook_url: "" # 企微 Webhook 完整 URL兼容旧字段 webhook_env
```
企微消息约定见 `docs/配置说明.md` §5**位置/类型**为每个 Key 的通用项;删除字段橙色、新增字段绿色。
---
## 9. CI 集成方案
@@ -479,83 +481,57 @@ notify:
详见 `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')"
# jnpf-java-cloud/.gitea/workflows/cache-schema-check.yaml(要点)
# 检出:浅克隆 tipdepth 1
# 检测:--old-sha = gitea.event.before--new-sha = gitea.sha
# 按需 fetch before 提交对象,覆盖一次 push 的多 commit 累计 diff
```
完整模板见 `docs/CI集成说明.md` / `.gitea/workflows/cache-schema-check.yaml`
---
## 10. 分阶段交付计划
### Phase 1 — MVP(约 1.5 周)
### Phase 1 — MVP
**目标**:跑通端到端链路,覆盖租户缓存典型场景。
| 任务 | 产出 |
|------|------|
| Maven 工程骨架 + CLI | 可执行 fat-jar |
| Git diff 扫描 | 变更文件列表 |
| W01~W03 写入点检测 | 覆盖 JSON 字符串写入 |
| 基础 Schema 提取 | 支持普通类、内部类、List、嵌套 |
| Schema Diff P0/P1 | 字段增删、包装、路径迁移 |
| 企微通知 | Markdown 消息 |
| notify/block 开关 | 配置驱动 |
| 夹具测试 | TenantVO/CacheEnvelope 样本 |
| 任务 | 产出 | 状态 |
|------|------|------|
| Maven 工程骨架 + CLI | 可执行 fat-jar | ✅ |
| Git diff 扫描 | 变更文件列表 | ✅ |
| W01~W03 写入点检测 | 覆盖 JSON 字符串写入 | ✅ |
| 基础 Schema 提取 | 支持普通类、内部类、List、嵌套 | ✅ |
| Schema Diff | 字段增删、包装、路径迁移 | ✅ |
| 企微通知 | 按 Key 骨架 Markdown | ✅ |
| notify/block / enabled | 配置驱动 | ✅ |
| 样本夹具测试 | TenantVO/CacheEnvelope 等 | ✅ |
**验收标准**
-`TenantDbContentCacheHelper` 的结构变更能输出 P0 报告
-`TenantDbContentCacheHelper` 的结构变更能输出报告并通知
- 流水线 push 后能收到企微通知
- `mode=block` 时任意 P0/P1/P2 变更导致 exit 1
- `mode=block` 时任意结构变更导致 exit 1
### Phase 2 — 增强(约 1 周)
### Phase 2 — 增强
| 任务 | 说明 |
|------|------|
| W04/W05 模式 | RedisTemplate 直写对象、Hash 写入 |
| 注解完整支持 | Fastjson/Jackson 注解 |
| Key 推断增强 | `String.format`、常量追溯 |
| 忽略规则完善 | 锁/计数器/token 自动过滤 |
| 多模块性能优化 | 并行解析、缓存索引 |
| 任务 | 说明 | 状态 |
|------|------|------|
| W04/W05 模式 | RedisTemplate 直写对象、Hash 写入 | ✅ |
| 注解完整支持 | Fastjson/Jackson(含 `@JsonIgnoreProperties` | ✅ |
| Key 推断增强 | `String.format`、常量拼接、`buildXxxKey` | ✅ |
| 忽略规则完善 | 锁/计数器/token/字面量/setIfAbsent | ✅ |
| 多模块性能 | 并行读文件、索引批量装载、`manual_mappings` | ✅ |
| 企微高亮 | 删除橙 `warning` / 新增绿 `info`;位置+类型通用项 | ✅ |
| W06 读侧辅助 | `parseObject`/`parseArray` 等补强写入 value 类型 | ✅ |
### Phase 3 — 运营(约 0.5 周)
### Phase 3 — 运营
| 任务 | 说明 |
|------|------|
| 报告落盘 | 可选输出 JSON 报告文件 |
| 误报反馈 | `suppressions` 配置支持按写入点忽略 |
| 误报反馈 | `suppressions` 按写入点 / change_types 精细忽略 |
| 更多业务场景覆盖 | 考勤、文件下载进度等 |
---
@@ -569,16 +545,17 @@ jobs:
- `RedisWritePointDetectorTest`:各种写入 AST 模式匹配
- `RedisKeyResolverTest`常量、format、拼接推断
### 11.2 夹具集成测试
### 11.2 样本夹具测试fixtures
`src/test/resources/fixtures/` 放置真实业务代码片段(从 `jnpf-java-cloud` 提取并脱敏),模拟 old/new 两个版本:
「夹具」= 放在测试资源里的**脱敏源码样本**(不是连真实 Redis / 不是起 Gitea 流水线)。
| 夹具 | 验证点 |
|------|--------|
| `tenant-cache/` | 包装结构变更 P0 |
| `attendance-base-setting/` | Map 结构缓存 |
| `evaluate-config/` | VO 字段新增 P1 |
| `lock-only/` | 应被忽略 |
路径:`src/test/resources/fixtures/`。测试代码加载这些 `.txt`/Java 片段,在内存中跑检测器 / Schema 对比,用来验证典型业务场景是否被正确识别。
| 夹具目录 | 验证点 |
|----------|--------|
| `fixtures/tenant/` | 包装结构变更TenantVO → CacheEnvelope |
| `fixtures/lock/` | 锁/计数器/token 应被忽略 |
| `fixtures/template/` | W04 Template 直写 |
### 11.3 端到端测试
@@ -593,8 +570,8 @@ jobs:
| 类型推断失败 | 漏报 | 标记 `LOW_CONFIDENCE`,配置 `manual_mappings` |
| Lombok 复杂注解 | 字段遗漏 | 基于源码字段 + 注解;后续 delombok |
| 同一 key 多分支写不同类型 | 误报 | 报告注明置信度;人工 suppression |
| 浅克隆 parent 不可用 | 跳过检测 | `--depth 2`;文档明确要求 |
| 一次 push 多 commit | 仅检最后一个 | 文档说明;后续扩展 range |
| 浅克隆拿不到 before | 漏检 / exit 2 | 按 SHA `fetch --depth 1` + deepen 兜底;见 CI 说明 |
| 一次 push 多 commit | 旧方案仅看末 commit 会漏检 | 已改为 `before..after` 累计对比 |
| 依赖 jar 中的类型 | 字段展开不完整 | 配置 `manual_mappings` 补充 |
| JsonUtil 实现不可见 | 序列化规则猜测 | 默认按字段名序列化;与 Fastjson 对齐 |
@@ -604,13 +581,13 @@ jobs:
| # | 决策项 | 结论 |
|---|--------|------|
| 1 | 阻断范围 | `block` 模式下 **P0/P1/P2 全部阻断**exit 1 |
| 2 | 发布坐标 | 独立产物 `com.codechecker:redis-schema-checker:1.0.0` |
| 1 | 阻断范围 | `block` 模式下 **任意结构变更均阻断**exit 1 |
| 2 | 发布坐标 | 独立产物 `com.codechecker:cache-schema-checker:1.0.0` |
| 3 | 配置归属 | **双层配置**jar 内 `default-config.yaml` + 业务仓覆盖合并 |
| 4 | 上线策略 | 先 `notify` 观察 **1 周**,稳定后手动切 `block` |
| 4 | 上线策略 | 先 `notify` ,稳定后手动切 `block` |
| 5 | 检测范围 | **仅 `src/main/java`**,不扫描测试代码 |
以上决策已纳入实施方案,可进入开发阶段
以上决策已纳入实施方案**Phase 1 / Phase 2 已交付**,可进入 Phase 3 或业务仓全量观察
---
@@ -636,7 +613,6 @@ public class WritePoint {
```java
public class SchemaChange {
Severity severity; // P0, P1, P2
ChangeType changeType; // FIELD_REMOVED, WRAPPER_ADDED, ...
String keyPattern;
String writeLocation; // class#method:line

View File

@@ -1,6 +1,8 @@
# Redis 序列化结构检测 — 配置说明
# 缓存序列化结构检测 — 配置说明
> **双层配置**:工具 jar 内置 `default-config.yaml`(默认) + 业务仓库 `.gitea/config/redis-schema-check-config.yaml`(覆盖)
> **双层配置**:工具 jar 内置 `default-config.yaml`(默认) + 业务仓库 `.gitea/config/cache-schema-check-config.yaml`(覆盖)
> 工具坐标:`com.codechecker:cache-schema-checker:1.0.0`
> 主类:`com.codechecker.cache.cli.CacheSchemaCheckerMain`
---
@@ -9,7 +11,7 @@
```text
jar 内 default-config.yaml工具仓维护
↓ 深度合并
业务仓 redis-schema-check-config.yaml业务仓维护
业务仓 cache-schema-check-config.yaml业务仓维护
最终生效配置
```
@@ -21,42 +23,44 @@ jar 内 default-config.yaml工具仓维护
### 1.1 业务仓最小配置示例
```yaml
# jnpf-java-cloud/.gitea/config/redis-schema-check-config.yaml
# jnpf-java-cloud/.gitea/config/cache-schema-check-config.yaml
enabled: true
mode: notify
notify:
enabled: true
webhook_env: WECOM_ROBOT_WEBHOOK
# 推荐直接写完整 Webhook也可用环境变量在流水线注入
webhook_url: ""
include_modules:
- jnpf-tenant
```
流水线通常把 Secret 注入环境或配置文件中的 `webhook_url`。兼容旧字段:`webhook_env`(值为 `http` 开头时当作 URL 使用)。
### 1.2 工具内置默认配置jar 内 default-config.yaml
`redisCheck` 仓库维护,随 jar 发布,包含:
`redisCheck` 仓库维护,随 jar 发布,默认包含:
- `detection.patterns`W01~W03
- `ignore.key_patterns`(锁/计数器/token
- `block_severities`P0/P1/P2
- `detection.min_confidence``max_field_depth`
- `detection.patterns`**W01~W05**JSON 字符串写入 + Template 直写 + Hash
- `detection.read_hints_enabled`W06 读侧反序列化类型辅助(默认 true
- `ignore.key_patterns`(锁 / 计数器 / token
- `detection.min_confidence``max_field_depth`
- `mode: notify``enabled: true`
---
## 2. 业务仓完整配置示例
```yaml
# 总开关false 时不执行检测、不发通知、流水线直接通过
enabled: true
# 运行模式
# notify - 仅通知,不阻断流水线
# block - 按 block_severities 阻断流水线exit 1
# block - 检测到结构变更即阻断流水线exit 1
mode: notify
# block 模式下触发 exit 1 的严重级别全部阻断P0/P1/P2
block_severities:
- P0
- P1
- P2
# 是否扫描测试代码(已确认:不扫描)
scan_test_sources: false
@@ -67,12 +71,14 @@ source_roots:
# 通知配置
notify:
enabled: true
# 从环境变量读取 Webhook URL
webhook_env: WECOM_ROBOT_WEBHOOK
# Webhook 完整 URL优先
webhook_url: ""
# 兼容旧字段:值为 http 开头时视为 URL
# webhook_env: WECOM_ROBOT_WEBHOOK
# 无变更时是否也发通知(一般 false
notify_on_clean: false
# 消息标题前缀
title_prefix: "[Redis结构变更]"
title_prefix: "[缓存结构变更]"
# 忽略规则
ignore:
@@ -80,9 +86,9 @@ ignore:
key_patterns:
- "*:lock"
- "*:lock:*"
- "*lock*"
- "loginCount:*"
- "Authorization:*"
- "Authorization:login:session:*"
# 忽略的文件路径模式
file_patterns:
@@ -93,25 +99,23 @@ ignore:
# 检测规则
detection:
# 启用的写入模式
# 启用的写入模式(默认已全部开启)
patterns:
- W01 # redisUtil.insert + JSON.toJSONString
- W02 # redisTemplate.opsForValue().set + JSON.toJSONString
- W03 # stringRedisTemplate + JsonUtil.getObjectToString
# - W04 # redisTemplate 直写对象Phase 2
# - W05 # opsForHash().putPhase 2
- W04 # redisTemplate 直写对象
- W05 # opsForHash().put
# 类型推断最低置信度,低于此值仅输出 P2 提示
# W06读侧反序列化类型辅助不产生独立告警
read_hints_enabled: true
# 类型推断最低置信度,低于此值标记为低置信度提示
min_confidence: 0.6
# 字段展开最大深度(防止循环引用死循环)
max_field_depth: 8
# 严重级别覆盖(可选)
severity_overrides:
FIELD_ADDED: P1
WRITE_POINT_ADDED: P2
# 人工补充映射(自动推断失败或需精确指定时使用)
manual_mappings:
- id: tenant-db-content
@@ -146,25 +150,28 @@ include_modules:
## 3. 配置项说明
### 3.1 mode
### 3.1 enabled
总开关。默认 `true`
| 值 | 行为 |
|----|------|
| `true` | 正常执行检测(再按 `mode` / `notify` 行为) |
| `false` | 跳过检测与通知,流水线 `exit 0`(与 `notify.enabled` 无关) |
临时关闭时可仅改此项,无需删除 workflow。
### 3.2 mode
| 值 | 行为 |
|----|------|
| `notify` | 检测到变更 → 发企微 → `exit 0` |
| `block` | 检测到 `block_severities` 中的级别 → 发企微 → `exit 1` |
### 3.2 block_severities
默认 `["P0", "P1", "P2"]``block` 模式下任意级别变更均 `exit 1`
| `block` | 检测到任意结构变更 → 发企微 → `exit 1` |
建议上线初期仍使用 `mode: notify` 观察误报情况,确认稳定后再切换:
```yaml
mode: block
block_severities:
- P0
- P1
- P2
```
### 3.3 notify
@@ -172,9 +179,10 @@ block_severities:
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `enabled` | boolean | true | 是否发企微 |
| `webhook_env` | string | WECOM_ROBOT_WEBHOOK | 环境变量名 |
| `webhook_url` | string | `""` | 企微机器人 Webhook 完整 URL优先 |
| `webhook_env` | string | — | 兼容旧字段;值为 `http` 开头时当作 URL |
| `notify_on_clean` | boolean | false | 无变更时是否通知 |
| `title_prefix` | string | [Redis结构变更] | 消息标题前缀 |
| `title_prefix` | string | [缓存结构变更] | 消息标题前缀 |
### 3.4 ignore.key_patterns
@@ -183,25 +191,50 @@ block_severities:
- `*` 匹配单层
- `**` 匹配多层
常见内置忽略(代码层也有硬编码兜底)
内置/代码层常见过滤
- 分布式锁 key
- 登录计数
- session/token
- 分布式锁 key(配置 glob + 方法名忽略)
- 登录计数、session/token
- 琐碎 value字面量、`"1"``UUID.randomUUID()`
- 方法:`setIfAbsent` / `increment` / `delete` / `expire`
### 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 |
| W01 | `redisUtil.insert(key, JSON.toJSONString(x), ttl)` | 已启用 |
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)` | 已启用 |
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)` | 已启用 |
| W04 | `redisTemplate.opsForValue().set(key, obj, ...)` 直写对象 | 已启用(Phase 2 |
| W05 | `redisTemplate.opsForHash().put(key, field, obj)` | 已启用(Phase 2 |
业务仓可通过只声明子集暂时关闭某些模式,例如仅保留 JSON 写入:
```yaml
detection:
patterns: [W01, W02, W03]
```
### 3.5.1 detection.read_hints_enabledW06 辅助)
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `read_hints_enabled` | boolean | true | 扫描 `parseObject` / `parseArray` / `getJsonToBean` 等,补强同文件或同 key 写入点的 value 类型 |
- **不是写入模式**:不会单独因为「多了一处 parse」而告警
- 能关联到 `redis get(key)` 时,还可补强 unresolved key
- 覆盖优先级:`manual_mappings` > W06 > 写侧 AST
关闭示例:
```yaml
detection:
read_hints_enabled: false
```
### 3.6 manual_mappings
当自动推断不准确时使用。匹配优先级 **高于** 自动推断。
当自动推断不准确时使用。匹配优先级 **高于** 自动推断(按 `类全名#方法名` 覆盖 key 模式与 value 类型)
| 字段 | 必填 | 说明 |
|------|------|------|
@@ -218,78 +251,95 @@ block_severities:
```yaml
suppressions:
- id: my-suppression
writer_method: "com.example.FooService#cacheBar"
key_pattern: "file:download:user:progress:*"
change_types:
- FIELD_ADDED
reason: "新增字段向后兼容"
```
### 3.8 include_modules / exclude_modules
| 字段 | 说明 |
|------|------|
| `include_modules` | 非空时仅扫描列出的顶层模块;空表示全仓 |
| `exclude_modules` | 始终排除的顶层模块 |
顶层模块取路径第一段,例如 `jnpf-tenant/jnpf-tenant-biz/src/main/java/...``jnpf-tenant`
---
## 4. 环境变量
## 4. 环境变量 / Secret
| 变量 | 必填 | 说明 |
|------|------|------|
| `WECOM_ROBOT_WEBHOOK` | notify.enabled=true 时必填 | 企微机器人 Webhook 完整 URL |
| `WECOM_ROBOT_WEBHOOK` | 视流水线写法 | 可将 Secret 写入配置中的 `webhook_url`,或在启动前注入 |
在 Gitea 仓库 Settings → Secrets 中配置。
---
## 5. 企微消息格式示例
## 5. 企微消息格式
### 5.1 结构说明
- 抬头:仓库、分支、提交、提交人、时间(不展示 mode
- 正文:按 **一个 Redis Key 一块**,展示位置、类型与前后序列化骨架
- 超长UTF-8 > 4096 字节)时按 key **拆成多条**消息依次发送
- CI 控制台另打「字段明细」(变更类型 / 位置 / 摘要),企微侧按骨架展示
### 5.2 字段高亮颜色
| 变更 | 企微颜色 | Markdown |
|------|----------|----------|
| 字段删除(标在旧骨架) | 橙色 | `<font color="warning">…</font>` |
| 字段新增 / 包装层(标在新骨架) | 绿色 | `<font color="info">…</font>` |
| 路径迁移 | 旧橙 / 新绿 | 同上 |
| key 未解析提示 | 灰色 | `<font color="comment">key 未解析)</font>` |
### 5.3 示例(已解析 key
```markdown
## [Redis结构变更] jnpf-java-cloud
## [缓存结构变更] jnpf-java-cloud
> 分支: feature/tenant-cache
> 提交: a1b2c3d → e4f5g6h
> 提交人: zhangsan
> 时间: 2026-07-13 14:00:00
> 模式: notify
> **分支**: code/redis_change_detection_v1.0
> **提交**: cedd161c → 67c8a6eb
> **提交人**: dongzi
> **时间**: 2026-07-13 16:54:17
### P0 - 顶层结构包装变更
- **Key**: `tenant:db:content:*`
- **位置**: `TenantDbContentCacheHelper#cacheSuccess:92`
- **变更**:
- `dbName``vo.dbName`(字段路径迁移)
- `linkList``vo.linkList`(字段路径迁移)
- 新增顶层字段 `expiresAtMs`
- **影响**: 旧缓存反序列化可能失败,需评估缓存刷新策略
- Key --> `tenant:db:content:*`
> **位置**: `TenantDbContentCacheHelper#cacheSuccess:92`
> **类型**: `CacheEnvelope`
> **value值由** “{"dbName":"","linkList":[{"id":""}]}”
> **变更为:** “…(仅新增/迁移字段片段带 <font color="info">绿色</font>)…”
```
实际发送时仅对改动属性片段染色:新增 → `info`(绿),删除 → `warning`(橙)。
### 5.4 示例key 未解析)
```markdown
- Key --> `req.getKey()` <font color="comment">key 未解析)</font>
> **位置**: `ClockInXxxService#export:128`
> **类型**: `List<ClockInExportVo>`
> **value值由** “{"a":""}”
> **变更为:** “…新增字段带绿色高亮…”
```
未解析时按「写入位置 + key 表达式」拆分聚合,避免多个未知 key 串在一起。
---
## 6. 推荐上线配置
### 6.1 观察期(第 1 周,已确认策略)
业务仓默认配置:
```yaml
enabled: true
mode: notify
notify:
enabled: true
webhook_env: WECOM_ROBOT_WEBHOOK
webhook_url: "" # 由流水线写入真实 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]
include_modules: [] # 全仓
```

113
pom.xml
View File

@@ -5,16 +5,12 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.codechecker</groupId>
<artifactId>redis-schema-checker-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<artifactId>cache-schema-checker</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>redis-schema-checker-parent</name>
<description>Redis 序列化结构变更检测工具(父工程)</description>
<modules>
<module>redis-schema-checker</module>
</modules>
<name>cache-schema-checker</name>
<description>基于 JavaParser 的缓存 value 序列化结构变更检测</description>
<properties>
<maven.compiler.source>11</maven.compiler.source>
@@ -27,5 +23,104 @@
<picocli.version>4.7.5</picocli.version>
<junit.version>5.10.2</junit.version>
<maven.shade.version>3.5.1</maven.shade.version>
<maven.deploy.plugin.version>3.1.2</maven.deploy.plugin.version>
</properties>
<!--
发布到 Nexus与 code-checker / scheduletask 一致,写在主 POM不用 profile 包裹)
凭证:~/.m2/settings.xml 中 server.id 必须为 fantaibao-fantaibao-maven-repository
mvn clean deploy -DskipTests
-->
<distributionManagement>
<repository>
<id>fantaibao-fantaibao-maven-repository</id>
<name>maven-releases</name>
<url>http://192.168.3.25:18081/nexus/repository/maven-releases/</url>
</repository>
</distributionManagement>
<dependencies>
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-symbol-solver-core</artifactId>
<version>${javaparser.version}</version>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>${snakeyaml.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>${picocli.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>cache-schema-checker-${project.version}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
<version>${maven.deploy.plugin.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>${maven.shade.version}</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.codechecker.cache.cli.CacheSchemaCheckerMain</mainClass>
</transformer>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>module-info.class</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,97 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.codechecker</groupId>
<artifactId>redis-schema-checker-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>redis-schema-checker</artifactId>
<packaging>jar</packaging>
<name>redis-schema-checker</name>
<description>基于 JavaParser 的 Redis value 序列化结构变更检测器</description>
<dependencies>
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-symbol-solver-core</artifactId>
<version>${javaparser.version}</version>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>${snakeyaml.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>${picocli.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>redis-schema-checker-${project.version}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>${maven.shade.version}</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.codechecker.redis.cli.RedisSchemaCheckerMain</mainClass>
</transformer>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>module-info.class</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,308 +0,0 @@
package com.codechecker.redis.analyze;
import com.codechecker.redis.config.CheckerConfig;
import com.codechecker.redis.detector.RedisWritePointDetector;
import com.codechecker.redis.detector.WritePoint;
import com.codechecker.redis.diff.ChangeType;
import com.codechecker.redis.diff.SchemaChange;
import com.codechecker.redis.diff.SchemaDiffer;
import com.codechecker.redis.diff.Severity;
import com.codechecker.redis.git.GitDiffScanner;
import com.codechecker.redis.git.GitException;
import com.codechecker.redis.report.CheckReport;
import com.codechecker.redis.schema.JavaSchemaExtractor;
import com.codechecker.redis.schema.SourceIndex;
import com.codechecker.redis.schema.TypeSchema;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.TypeDeclaration;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 端到端分析编排git diff → 定位写入点 → 双版本 Schema 提取 → 结构对比 → 生成报告。
*/
public class SchemaCheckAnalyzer {
private final CheckerConfig config;
private final Path repoRoot;
public SchemaCheckAnalyzer(CheckerConfig config, Path repoRoot) {
this.config = config;
this.repoRoot = repoRoot;
}
public CheckReport analyze(String oldSha, String newSha) throws GitException {
GitDiffScanner scanner = new GitDiffScanner(repoRoot);
List<String> changedRaw = scanner.changedJavaFiles(oldSha, newSha);
Set<String> changedFiles = new LinkedHashSet<>();
FileScanner fileScanner = new FileScanner(repoRoot, config.getIncludeModules(), config.getExcludeModules());
for (String path : changedRaw) {
if (path.contains("/src/main/java/") && fileScanner.moduleAllowed(path) && !isFileIgnored(path)) {
changedFiles.add(path);
}
}
// 当前工作树(= newSha 检出)内容
Map<String, String> newContents = fileScanner.scan();
// 旧版本内容:以工作树为基线,用 old 版本覆盖变更文件
Map<String, String> oldContents = new LinkedHashMap<>(newContents);
for (String path : changedFiles) {
String oldContent = scanner.fileContentAt(oldSha, path);
if (oldContent == null) {
oldContents.remove(path); // 新增文件在旧版本不存在
} else {
oldContents.put(path, oldContent);
}
}
SourceIndex newIndex = buildIndex(newContents.values());
SourceIndex oldIndex = buildIndex(oldContents.values());
// 变更涉及的类型简单名(用于扩展候选写入点文件)
Set<String> changedTypeNames = new HashSet<>();
for (String path : changedFiles) {
collectTypeNames(newContents.get(path), changedTypeNames);
collectTypeNames(oldContents.get(path), changedTypeNames);
}
Set<String> candidates = new LinkedHashSet<>(changedFiles);
Pattern typePattern = buildTypePattern(changedTypeNames);
if (typePattern != null) {
for (Map.Entry<String, String> e : newContents.entrySet()) {
if (!candidates.contains(e.getKey()) && typePattern.matcher(e.getValue()).find()) {
candidates.add(e.getKey());
}
}
}
Set<String> patterns = new HashSet<>(config.getDetection().getPatterns());
RedisWritePointDetector detectorNew = new RedisWritePointDetector(newIndex, patterns);
RedisWritePointDetector detectorOld = new RedisWritePointDetector(oldIndex, patterns);
JavaSchemaExtractor extractorNew = new JavaSchemaExtractor(newIndex, config.getDetection().getMaxFieldDepth());
JavaSchemaExtractor extractorOld = new JavaSchemaExtractor(oldIndex, config.getDetection().getMaxFieldDepth());
SchemaDiffer differ = new SchemaDiffer();
List<SchemaChange> allChanges = new ArrayList<>();
for (String path : candidates) {
String newContent = newContents.get(path);
if (newContent == null) {
continue;
}
boolean fileChanged = changedFiles.contains(path);
String oldContent = fileChanged ? oldContents.get(path) : newContent;
List<WritePoint> newWps = detectorNew.detect(path, newContent);
List<WritePoint> oldWps = oldContent == null
? new ArrayList<>() : detectorOld.detect(path, oldContent);
Map<String, WritePoint> oldBySig = new LinkedHashMap<>();
for (WritePoint wp : oldWps) {
oldBySig.put(wp.signature(), wp);
}
Set<String> newSigs = new HashSet<>();
for (WritePoint nw : newWps) {
newSigs.add(nw.signature());
if (isKeyIgnored(nw.getResolvedKeyPattern()) || isWriterIgnored(nw)) {
continue;
}
WritePoint ow = oldBySig.get(nw.signature());
if (ow != null) {
TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray());
TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray());
List<SchemaChange> changes = differ.diff(oldSchema, newSchema);
double confidence = min(nw.getConfidence(), ow.getConfidence(),
oldSchema.getConfidence(), newSchema.getConfidence());
enrich(changes, nw, confidence);
allChanges.addAll(changes);
} else if (fileChanged) {
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
c.setKeyPattern(nw.getResolvedKeyPattern());
c.setWriteLocation(nw.location());
c.setMessage("新增 Redis 写入点value 类型: " + shortType(nw.getResolvedValueType()));
allChanges.add(c);
}
}
if (fileChanged) {
for (WritePoint ow : oldWps) {
if (!newSigs.contains(ow.signature())
&& !isKeyIgnored(ow.getResolvedKeyPattern()) && !isWriterIgnored(ow)) {
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_REMOVED);
c.setKeyPattern(ow.getResolvedKeyPattern());
c.setWriteLocation(ow.location());
c.setMessage("删除 Redis 写入点,原 value 类型: " + shortType(ow.getResolvedValueType()));
allChanges.add(c);
}
}
}
}
List<SchemaChange> finalChanges = postProcess(allChanges);
return buildReport(oldSha, newSha, finalChanges);
}
private void enrich(List<SchemaChange> changes, WritePoint wp, double confidence) {
boolean lowConfidence = confidence < config.getDetection().getMinConfidence();
for (SchemaChange c : changes) {
c.setKeyPattern(wp.getResolvedKeyPattern());
c.setWriteLocation(wp.location());
if (lowConfidence) {
c.setSeverity(Severity.P2);
c.setMessage(c.getMessage() + "(低置信度,建议人工确认)");
}
}
}
private List<SchemaChange> postProcess(List<SchemaChange> changes) {
List<SchemaChange> result = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (SchemaChange c : changes) {
applySeverityOverride(c);
if (isSuppressed(c)) {
continue;
}
String dedupKey = c.getChangeType() + "|" + c.getKeyPattern() + "|"
+ c.getWriteLocation() + "|" + c.getFieldPath();
if (seen.add(dedupKey)) {
result.add(c);
}
}
return result;
}
private void applySeverityOverride(SchemaChange c) {
String override = config.getSeverityOverrides().get(c.getChangeType().name());
if (override != null) {
try {
c.setSeverity(Severity.valueOf(override.trim().toUpperCase()));
} catch (IllegalArgumentException ignored) {
// 无效级别忽略
}
}
}
private boolean isSuppressed(SchemaChange c) {
for (CheckerConfig.Suppression s : config.getSuppressions()) {
boolean keyMatch = s.getKeyPattern() == null
|| s.getKeyPattern().equals(c.getKeyPattern());
boolean typeMatch = s.getChangeTypes() == null || s.getChangeTypes().isEmpty()
|| s.getChangeTypes().contains(c.getChangeType().name());
if (keyMatch && typeMatch && (s.getKeyPattern() != null
|| (s.getChangeTypes() != null && !s.getChangeTypes().isEmpty()))) {
return true;
}
}
return false;
}
private CheckReport buildReport(String oldSha, String newSha, List<SchemaChange> changes) {
CheckReport report = new CheckReport();
report.setOldSha(oldSha);
report.setNewSha(newSha);
report.setMode(config.getMode());
report.getChanges().addAll(changes);
boolean blocked = false;
if (config.isBlockMode()) {
Set<String> blockSev = new HashSet<>(config.getBlockSeverities());
for (SchemaChange c : changes) {
if (blockSev.contains(c.getSeverity().name())) {
blocked = true;
break;
}
}
}
report.setBlocked(blocked);
report.setExitCode(blocked ? 1 : 0);
return report;
}
private SourceIndex buildIndex(Iterable<String> contents) {
SourceIndex index = new SourceIndex();
for (String content : contents) {
index.addSource(content);
}
return index;
}
private void collectTypeNames(String content, Set<String> out) {
if (content == null || content.isEmpty()) {
return;
}
try {
CompilationUnit cu = StaticJavaParser.parse(content);
for (TypeDeclaration<?> type : cu.findAll(TypeDeclaration.class)) {
out.add(type.getNameAsString());
}
} catch (RuntimeException ignored) {
// 解析失败跳过
}
}
private Pattern buildTypePattern(Set<String> typeNames) {
List<String> valid = new ArrayList<>();
for (String name : typeNames) {
if (name != null && name.matches("[A-Za-z_][A-Za-z0-9_]*")) {
valid.add(Pattern.quote(name));
}
}
if (valid.isEmpty()) {
return null;
}
return Pattern.compile("\\b(" + String.join("|", valid) + ")\\b");
}
private boolean isFileIgnored(String path) {
for (String glob : config.getIgnore().getFilePatterns()) {
if (GlobMatcher.matches(glob, path)) {
return true;
}
}
return false;
}
private boolean isKeyIgnored(String keyPattern) {
if (keyPattern == null) {
return false;
}
for (String glob : config.getIgnore().getKeyPatterns()) {
if (GlobMatcher.matches(glob, keyPattern)) {
return true;
}
}
return false;
}
private boolean isWriterIgnored(WritePoint wp) {
String sig = wp.getEnclosingClass() + "#" + wp.getEnclosingMethod();
return config.getIgnore().getWriterMethods().contains(sig);
}
private String shortType(String fqn) {
if (fqn == null) {
return "<未解析>";
}
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
}
private double min(double... values) {
double m = 1.0;
for (double v : values) {
m = Math.min(m, v);
}
return m;
}
}

View File

@@ -1,86 +0,0 @@
package com.codechecker.redis.report;
import com.codechecker.redis.diff.SchemaChange;
import com.codechecker.redis.diff.Severity;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
*/
public class ReportBuilder {
private final String titlePrefix;
public ReportBuilder(String titlePrefix) {
this.titlePrefix = titlePrefix == null ? "[Redis结构变更]" : titlePrefix;
}
public String toMarkdown(CheckReport report) {
StringBuilder sb = new StringBuilder();
sb.append("## ").append(titlePrefix).append(' ')
.append(nvl(report.getRepository())).append('\n');
sb.append("> 分支: ").append(nvl(report.getBranch())).append('\n');
sb.append("> 提交: ").append(shortSha(report.getOldSha()))
.append("").append(shortSha(report.getNewSha())).append('\n');
sb.append("> 提交人: ").append(nvl(report.getModifier())).append('\n');
sb.append("> 时间: ").append(nvl(report.getModifyTime())).append('\n');
sb.append("> 模式: ").append(nvl(report.getMode()));
if (report.isBlocked()) {
sb.append("(已阻断)");
}
sb.append('\n');
sb.append("> 汇总: P0=").append(report.count(Severity.P0))
.append(" P1=").append(report.count(Severity.P1))
.append(" P2=").append(report.count(Severity.P2)).append("\n\n");
Map<Severity, List<SchemaChange>> grouped = new EnumMap<>(Severity.class);
for (SchemaChange c : report.getChanges()) {
grouped.computeIfAbsent(c.getSeverity(), k -> new ArrayList<>()).add(c);
}
for (Severity severity : Severity.values()) {
List<SchemaChange> list = grouped.get(severity);
if (list == null || list.isEmpty()) {
continue;
}
sb.append("### ").append(severity).append('\n');
for (SchemaChange c : list) {
sb.append("- **").append(c.getChangeType().getLabel()).append("**");
if (c.getKeyPattern() != null) {
sb.append(" `").append(c.getKeyPattern()).append('`');
}
sb.append('\n');
if (c.getWriteLocation() != null) {
sb.append(" - 位置: ").append(c.getWriteLocation()).append('\n');
}
if (c.getMessage() != null) {
sb.append(" - ").append(c.getMessage()).append('\n');
}
}
sb.append('\n');
}
return sb.toString();
}
public String toConsole(CheckReport report) {
if (!report.hasChanges()) {
return "未检测到 Redis 序列化结构变更。";
}
return toMarkdown(report);
}
private String shortSha(String sha) {
if (sha == null) {
return "";
}
return sha.length() > 8 ? sha.substring(0, 8) : sha;
}
private String nvl(String s) {
return s == null ? "" : s;
}
}

View File

@@ -1,83 +0,0 @@
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;
}
}

View File

@@ -1,41 +0,0 @@
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"));
}
}

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.analyze;
package com.codechecker.cache.analyze;
import java.io.IOException;
import java.io.UncheckedIOException;
@@ -31,18 +31,20 @@ public class FileScanner {
public Map<String, String> scan() {
Map<String, String> result = new LinkedHashMap<>();
try (Stream<Path> stream = Files.walk(repoRoot)) {
stream.filter(Files::isRegularFile)
List<Path> javaFiles = stream
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.forEach(p -> {
.filter(p -> {
String rel = repoRoot.relativize(p).toString().replace('\\', '/');
return rel.contains("/src/main/java/") && moduleAllowed(rel);
})
.collect(java.util.stream.Collectors.toList());
javaFiles.parallelStream().forEach(p -> {
String rel = repoRoot.relativize(p).toString().replace('\\', '/');
if (!rel.contains("/src/main/java/")) {
return;
}
if (!moduleAllowed(rel)) {
return;
}
try {
synchronized (result) {
result.put(rel, new String(Files.readAllBytes(p), StandardCharsets.UTF_8));
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.analyze;
package com.codechecker.cache.analyze;
import java.util.regex.Pattern;

View File

@@ -0,0 +1,587 @@
package com.codechecker.cache.analyze;
import com.codechecker.cache.config.CheckerConfig;
import com.codechecker.cache.detector.CacheReadHint;
import com.codechecker.cache.detector.CacheReadHintDetector;
import com.codechecker.cache.detector.RedisWritePointDetector;
import com.codechecker.cache.detector.WritePoint;
import com.codechecker.cache.diff.ChangeType;
import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.SchemaDiffer;
import com.codechecker.cache.diff.Severity;
import com.codechecker.cache.git.GitDiffScanner;
import com.codechecker.cache.git.GitException;
import com.codechecker.cache.report.CheckReport;
import com.codechecker.cache.report.KeyStructureChange;
import com.codechecker.cache.schema.JavaSchemaExtractor;
import com.codechecker.cache.schema.SkeletonJsonRenderer;
import com.codechecker.cache.schema.SourceIndex;
import com.codechecker.cache.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.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 端到端分析编排git diff → 定位写入点 → 双版本 Schema 提取 → 结构对比 → 生成报告。
*/
public class SchemaCheckAnalyzer {
private final CheckerConfig config;
private final Path repoRoot;
public SchemaCheckAnalyzer(CheckerConfig config, Path repoRoot) {
this.config = config;
this.repoRoot = repoRoot;
}
public CheckReport analyze(String oldSha, String newSha) throws GitException {
GitDiffScanner scanner = new GitDiffScanner(repoRoot);
List<String> changedRaw = scanner.changedJavaFiles(oldSha, newSha);
Set<String> changedFiles = new LinkedHashSet<>();
FileScanner fileScanner = new FileScanner(repoRoot, config.getIncludeModules(), config.getExcludeModules());
for (String path : changedRaw) {
if (path.contains("/src/main/java/") && fileScanner.moduleAllowed(path) && !isFileIgnored(path)) {
changedFiles.add(path);
}
}
// 当前工作树(= newSha 检出)内容
Map<String, String> newContents = fileScanner.scan();
// 旧版本内容:以工作树为基线,用 old 版本覆盖变更文件
Map<String, String> oldContents = new LinkedHashMap<>(newContents);
for (String path : changedFiles) {
String oldContent = scanner.fileContentAt(oldSha, path);
if (oldContent == null) {
oldContents.remove(path); // 新增文件在旧版本不存在
} else {
oldContents.put(path, oldContent);
}
}
SourceIndex newIndex = buildIndex(newContents.values());
SourceIndex oldIndex = buildIndex(oldContents.values());
// 变更涉及的类型简单名(用于扩展候选写入点文件)
Set<String> changedTypeNames = new HashSet<>();
for (String path : changedFiles) {
collectTypeNames(newContents.get(path), changedTypeNames);
collectTypeNames(oldContents.get(path), changedTypeNames);
}
Set<String> candidates = new LinkedHashSet<>(changedFiles);
Pattern typePattern = buildTypePattern(changedTypeNames);
if (typePattern != null) {
for (Map.Entry<String, String> e : newContents.entrySet()) {
if (!candidates.contains(e.getKey()) && typePattern.matcher(e.getValue()).find()) {
candidates.add(e.getKey());
}
}
}
Set<String> patterns = new HashSet<>(config.getDetection().getPatterns());
RedisWritePointDetector detectorNew = new RedisWritePointDetector(newIndex, patterns);
RedisWritePointDetector detectorOld = new RedisWritePointDetector(oldIndex, patterns);
JavaSchemaExtractor extractorNew = new JavaSchemaExtractor(newIndex, config.getDetection().getMaxFieldDepth());
JavaSchemaExtractor extractorOld = new JavaSchemaExtractor(oldIndex, config.getDetection().getMaxFieldDepth());
SchemaDiffer differ = new SchemaDiffer();
SkeletonJsonRenderer skeletonRenderer = new SkeletonJsonRenderer();
List<SchemaChange> allChanges = new ArrayList<>();
Map<String, KeyStructureChange> keyChanges = new LinkedHashMap<>();
for (String path : candidates) {
String newContent = newContents.get(path);
if (newContent == null) {
continue;
}
boolean fileChanged = changedFiles.contains(path);
String oldContent = fileChanged ? oldContents.get(path) : newContent;
List<WritePoint> newWps = detectorNew.detect(path, newContent);
List<WritePoint> oldWps = oldContent == null
? new ArrayList<>() : detectorOld.detect(path, oldContent);
applyReadHints(newWps, path, newContent, newIndex);
if (oldContent != null) {
applyReadHints(oldWps, path, oldContent, oldIndex);
}
newWps.forEach(this::applyManualMappings);
oldWps.forEach(this::applyManualMappings);
Map<String, WritePoint> oldBySig = new LinkedHashMap<>();
for (WritePoint wp : oldWps) {
oldBySig.put(wp.signature(), wp);
}
Set<String> newSigs = new HashSet<>();
for (WritePoint nw : newWps) {
newSigs.add(nw.signature());
if (isKeyIgnored(nw.getResolvedKeyPattern()) || isWriterIgnored(nw)) {
continue;
}
WritePoint ow = oldBySig.get(nw.signature());
if (ow != null) {
TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray());
TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray());
List<SchemaChange> changes = differ.diff(oldSchema, newSchema);
if (changes.isEmpty()) {
continue;
}
double confidence = min(nw.getConfidence(), ow.getConfidence(),
oldSchema.getConfidence(), newSchema.getConfidence());
enrich(changes, nw, confidence);
allChanges.addAll(changes);
mergeKeyChange(keyChanges, nw, changes,
skeletonRenderer.render(oldSchema, protectedPaths(changes),
SkeletonJsonRenderer.DEFAULT_MAX_LEN),
skeletonRenderer.render(newSchema, protectedPaths(changes),
SkeletonJsonRenderer.DEFAULT_MAX_LEN));
} else if (fileChanged) {
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
fillFromWritePoint(c, nw);
c.setMessage("新增缓存写入点value 类型: " + displayType(nw));
allChanges.add(c);
TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray());
mergeKeyChange(keyChanges, nw,
Collections.singletonList(c),
"",
skeletonRenderer.render(newSchema, protectedPaths(
Collections.singletonList(c)),
SkeletonJsonRenderer.DEFAULT_MAX_LEN));
}
}
if (fileChanged) {
for (WritePoint ow : oldWps) {
if (!newSigs.contains(ow.signature())
&& !isKeyIgnored(ow.getResolvedKeyPattern()) && !isWriterIgnored(ow)) {
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_REMOVED);
fillFromWritePoint(c, ow);
c.setMessage("删除缓存写入点,原 value 类型: " + displayType(ow));
allChanges.add(c);
TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray());
mergeKeyChange(keyChanges, ow,
Collections.singletonList(c),
skeletonRenderer.render(oldSchema, protectedPaths(
Collections.singletonList(c)),
SkeletonJsonRenderer.DEFAULT_MAX_LEN),
"");
}
}
}
}
List<SchemaChange> finalChanges = postProcess(allChanges);
Map<String, KeyStructureChange> finalKeys = filterKeyChanges(keyChanges, finalChanges);
return buildReport(oldSha, newSha, finalChanges, finalKeys);
}
private void mergeKeyChange(Map<String, KeyStructureChange> keyChanges, WritePoint wp,
List<SchemaChange> changes, String oldSkeleton, String newSkeleton) {
String aggKey = aggregationKey(wp);
KeyStructureChange kc = keyChanges.computeIfAbsent(aggKey, k -> {
KeyStructureChange n = new KeyStructureChange();
n.setKeyPattern(wp.getResolvedKeyPattern());
n.setKeyExpression(wp.getKeyExpression());
n.setWriteLocation(wp.location());
n.setValueType(displayType(wp));
n.setKeyUnresolved(isUnresolvedKey(wp.getResolvedKeyPattern()));
return n;
});
if (kc.getWriteLocation() == null || kc.getWriteLocation().isEmpty()) {
kc.setWriteLocation(wp.location());
}
if (kc.getValueType() == null || kc.getValueType().isEmpty()) {
kc.setValueType(displayType(wp));
}
if (kc.getKeyExpression() == null || kc.getKeyExpression().isEmpty()) {
kc.setKeyExpression(wp.getKeyExpression());
}
kc.getFieldDetails().addAll(changes);
for (SchemaChange c : changes) {
kc.raiseSeverity(c.getSeverity());
}
if (oldSkeleton != null && !oldSkeleton.isEmpty()) {
kc.setOldSkeletonJson(oldSkeleton);
}
if (newSkeleton != null && !newSkeleton.isEmpty()) {
kc.setNewSkeletonJson(newSkeleton);
}
if (kc.getOldSkeletonJson() == null) {
kc.setOldSkeletonJson(oldSkeleton == null ? "" : oldSkeleton);
}
if (kc.getNewSkeletonJson() == null) {
kc.setNewSkeletonJson(newSkeleton == null ? "" : newSkeleton);
}
}
/** 已解析 key 按模式聚合;未解析按「位置+表达式」拆分,避免串单。 */
private String aggregationKey(WritePoint wp) {
String pattern = wp.getResolvedKeyPattern();
if (!isUnresolvedKey(pattern)) {
return pattern == null ? "<unknown>" : pattern;
}
return "unknown|" + nvl(wp.location()) + "|" + nvl(wp.getKeyExpression());
}
private boolean isUnresolvedKey(String keyPattern) {
return keyPattern == null
|| keyPattern.isEmpty()
|| "unknown-key".equals(keyPattern)
|| "<unknown>".equals(keyPattern);
}
private String displayType(WritePoint wp) {
String simple = shortType(wp.getResolvedValueType());
if (wp.isRootArray()) {
return "List<" + simple + ">";
}
return simple;
}
private void fillFromWritePoint(SchemaChange c, WritePoint wp) {
c.setKeyPattern(wp.getResolvedKeyPattern());
c.setKeyExpression(wp.getKeyExpression());
c.setWriteLocation(wp.location());
c.setValueType(displayType(wp));
}
private String nvl(String s) {
return s == null ? "" : s;
}
private Set<String> protectedPaths(List<SchemaChange> changes) {
Set<String> paths = new LinkedHashSet<>();
for (SchemaChange c : changes) {
if (c.getFieldPath() != null && !c.getFieldPath().isEmpty()) {
paths.add(c.getFieldPath());
}
// 路径迁移时 oldValue 为旧路径
if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED
&& c.getOldValue() != null && !c.getOldValue().isEmpty()) {
paths.add(c.getOldValue());
}
if (c.getChangeType() == ChangeType.WRAPPER_ADDED
&& c.getFieldPath() != null) {
paths.add(c.getFieldPath());
}
}
return paths;
}
private Map<String, KeyStructureChange> filterKeyChanges(
Map<String, KeyStructureChange> keyChanges, List<SchemaChange> finalChanges) {
Set<String> liveDedup = new HashSet<>();
for (SchemaChange c : finalChanges) {
liveDedup.add(changeDedupKey(c));
}
Map<String, KeyStructureChange> result = new LinkedHashMap<>();
for (Map.Entry<String, KeyStructureChange> e : keyChanges.entrySet()) {
KeyStructureChange kc = e.getValue();
List<SchemaChange> retained = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (SchemaChange c : kc.getFieldDetails()) {
String dk = changeDedupKey(c);
if (liveDedup.contains(dk) && seen.add(dk)) {
retained.add(c);
}
}
if (retained.isEmpty()) {
continue;
}
kc.getFieldDetails().clear();
kc.getFieldDetails().addAll(retained);
// 从明细回填通用展示字段(若聚合时未带上)
for (SchemaChange c : retained) {
if ((kc.getWriteLocation() == null || kc.getWriteLocation().isEmpty())
&& c.getWriteLocation() != null) {
kc.setWriteLocation(c.getWriteLocation());
}
if ((kc.getValueType() == null || kc.getValueType().isEmpty())
&& c.getValueType() != null) {
kc.setValueType(c.getValueType());
}
if ((kc.getKeyExpression() == null || kc.getKeyExpression().isEmpty())
&& c.getKeyExpression() != null) {
kc.setKeyExpression(c.getKeyExpression());
}
}
Severity max = Severity.P2;
for (SchemaChange c : retained) {
if (c.getSeverity() != null && c.getSeverity().ordinal() < max.ordinal()) {
max = c.getSeverity();
}
}
kc.setSeverity(max);
result.put(e.getKey(), kc);
}
return result;
}
private String changeDedupKey(SchemaChange c) {
return c.getChangeType() + "|" + c.getKeyPattern() + "|"
+ c.getWriteLocation() + "|" + c.getFieldPath();
}
private void enrich(List<SchemaChange> changes, WritePoint wp, double confidence) {
boolean lowConfidence = confidence < config.getDetection().getMinConfidence();
String type = displayType(wp);
for (SchemaChange c : changes) {
fillFromWritePoint(c, wp);
c.setValueType(type);
if (lowConfidence) {
c.setSeverity(Severity.P2);
c.setMessage(c.getMessage() + "(低置信度,建议人工确认)");
}
}
}
private List<SchemaChange> postProcess(List<SchemaChange> changes) {
List<SchemaChange> result = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (SchemaChange c : changes) {
applySeverityOverride(c);
if (isSuppressed(c)) {
continue;
}
String dedupKey = c.getChangeType() + "|" + c.getKeyPattern() + "|"
+ c.getWriteLocation() + "|" + c.getFieldPath();
if (seen.add(dedupKey)) {
result.add(c);
}
}
return result;
}
private void applySeverityOverride(SchemaChange c) {
String override = config.getSeverityOverrides().get(c.getChangeType().name());
if (override != null) {
try {
c.setSeverity(Severity.valueOf(override.trim().toUpperCase()));
} catch (IllegalArgumentException ignored) {
// 无效级别忽略
}
}
}
private boolean isSuppressed(SchemaChange c) {
for (CheckerConfig.Suppression s : config.getSuppressions()) {
boolean keyMatch = s.getKeyPattern() == null
|| s.getKeyPattern().equals(c.getKeyPattern());
boolean typeMatch = s.getChangeTypes() == null || s.getChangeTypes().isEmpty()
|| s.getChangeTypes().contains(c.getChangeType().name());
if (keyMatch && typeMatch && (s.getKeyPattern() != null
|| (s.getChangeTypes() != null && !s.getChangeTypes().isEmpty()))) {
return true;
}
}
return false;
}
private CheckReport buildReport(String oldSha, String newSha, List<SchemaChange> changes,
Map<String, KeyStructureChange> keyChanges) {
CheckReport report = new CheckReport();
report.setOldSha(oldSha);
report.setNewSha(newSha);
report.setMode(config.getMode());
report.getChanges().addAll(changes);
report.getKeyChanges().addAll(keyChanges.values());
boolean blocked = config.isBlockMode() && !changes.isEmpty();
report.setBlocked(blocked);
report.setExitCode(blocked ? 1 : 0);
return report;
}
private SourceIndex buildIndex(Iterable<String> contents) {
SourceIndex index = new SourceIndex();
List<String> list = new ArrayList<>();
for (String content : contents) {
list.add(content);
}
index.addSources(list);
return index;
}
private void applyManualMappings(WritePoint wp) {
String location = wp.getEnclosingClass() + "#" + wp.getEnclosingMethod();
for (CheckerConfig.ManualMapping mapping : config.getManualMappings()) {
if (mapping.getWriterMethod() == null || !mapping.getWriterMethod().equals(location)) {
continue;
}
if (mapping.getKeyPattern() != null && !mapping.getKeyPattern().isEmpty()) {
wp.setResolvedKeyPattern(mapping.getKeyPattern());
}
if (mapping.getValueType() != null && !mapping.getValueType().isEmpty()) {
wp.setResolvedValueType(mapping.getValueType());
wp.setConfidence(Math.max(wp.getConfidence(), 1.0));
}
}
}
/**
* W06用同文件读侧反序列化类型提示补强低置信度 / 缺类型的写入点。
* manual_mappings 仍在其后执行,可覆盖本补强结果。
*/
private void applyReadHints(List<WritePoint> writePoints, String path, String content,
SourceIndex index) {
if (!config.getDetection().isReadHintsEnabled()
|| writePoints == null || writePoints.isEmpty()) {
return;
}
List<CacheReadHint> hints = new CacheReadHintDetector(index).detect(path, content);
if (hints.isEmpty()) {
return;
}
for (WritePoint wp : writePoints) {
enrichWritePointFromHints(wp, hints);
}
}
private void enrichWritePointFromHints(WritePoint wp, List<CacheReadHint> hints) {
boolean needType = wp.getResolvedValueType() == null || wp.getResolvedValueType().isEmpty()
|| wp.getConfidence() < config.getDetection().getMinConfidence();
boolean needKey = isUnresolvedKey(wp.getResolvedKeyPattern());
if (!needType && !needKey) {
return;
}
CacheReadHint best = null;
int bestScore = -1;
for (CacheReadHint hint : hints) {
int score = scoreHint(wp, hint);
if (score > bestScore) {
bestScore = score;
best = hint;
}
}
if (best == null || bestScore <= 0) {
return;
}
if (needType && best.getResolvedValueType() != null) {
wp.setResolvedValueType(best.getResolvedValueType());
wp.setRootArray(best.isRootArray());
wp.setConfidence(Math.max(wp.getConfidence(), best.getConfidence()));
}
if (needKey && best.getResolvedKeyPattern() != null
&& !isUnresolvedKey(best.getResolvedKeyPattern())) {
wp.setResolvedKeyPattern(best.getResolvedKeyPattern());
if (best.getKeyExpression() != null) {
// 保留写入侧原始表达式,仅补 key 模式
}
}
}
/** 匹配得分:同 key > 同类同方法 > 同类;无交集则 0。 */
private int scoreHint(WritePoint wp, CacheReadHint hint) {
if (hint.getResolvedValueType() == null) {
return 0;
}
boolean sameClass = wp.getEnclosingClass() != null
&& wp.getEnclosingClass().equals(hint.getEnclosingClass());
if (!sameClass) {
// 跨文件仅允许 key 模式已解析且一致
if (wp.getResolvedKeyPattern() != null
&& wp.getResolvedKeyPattern().equals(hint.getResolvedKeyPattern())
&& !isUnresolvedKey(wp.getResolvedKeyPattern())) {
return 40;
}
return 0;
}
int score = 10;
if (wp.getEnclosingMethod() != null
&& wp.getEnclosingMethod().equals(hint.getEnclosingMethod())) {
score += 20;
}
if (wp.getResolvedKeyPattern() != null
&& wp.getResolvedKeyPattern().equals(hint.getResolvedKeyPattern())
&& !isUnresolvedKey(wp.getResolvedKeyPattern())) {
score += 50;
} else if (hint.getResolvedKeyPattern() != null
&& !isUnresolvedKey(hint.getResolvedKeyPattern())
&& isUnresolvedKey(wp.getResolvedKeyPattern())) {
score += 30;
}
return score;
}
private void collectTypeNames(String content, Set<String> out) {
if (content == null || content.isEmpty()) {
return;
}
try {
CompilationUnit cu = StaticJavaParser.parse(content);
for (TypeDeclaration<?> type : cu.findAll(TypeDeclaration.class)) {
out.add(type.getNameAsString());
}
} catch (RuntimeException ignored) {
// 解析失败跳过
}
}
private Pattern buildTypePattern(Set<String> typeNames) {
List<String> valid = new ArrayList<>();
for (String name : typeNames) {
if (name != null && name.matches("[A-Za-z_][A-Za-z0-9_]*")) {
valid.add(Pattern.quote(name));
}
}
if (valid.isEmpty()) {
return null;
}
return Pattern.compile("\\b(" + String.join("|", valid) + ")\\b");
}
private boolean isFileIgnored(String path) {
for (String glob : config.getIgnore().getFilePatterns()) {
if (GlobMatcher.matches(glob, path)) {
return true;
}
}
return false;
}
private boolean isKeyIgnored(String keyPattern) {
if (keyPattern == null) {
return false;
}
for (String glob : config.getIgnore().getKeyPatterns()) {
if (GlobMatcher.matches(glob, keyPattern)) {
return true;
}
}
return false;
}
private boolean isWriterIgnored(WritePoint wp) {
String sig = wp.getEnclosingClass() + "#" + wp.getEnclosingMethod();
return config.getIgnore().getWriterMethods().contains(sig);
}
private String shortType(String fqn) {
if (fqn == null) {
return "<未解析>";
}
return fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
}
private double min(double... values) {
double m = 1.0;
for (double v : values) {
m = Math.min(m, v);
}
return m;
}
}

View File

@@ -1,27 +1,27 @@
package com.codechecker.redis.cli;
package com.codechecker.cache.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 com.codechecker.cache.analyze.SchemaCheckAnalyzer;
import com.codechecker.cache.config.CheckerConfig;
import com.codechecker.cache.config.ConfigLoader;
import com.codechecker.cache.notify.WeComNotifier;
import com.codechecker.cache.report.CheckReport;
import com.codechecker.cache.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.List;
import java.util.concurrent.Callable;
/**
* 命令行入口退出码0 通过 / 1 阻断 / 2 执行错误
*/
@Command(name = "redis-schema-checker",
@Command(name = "cache-schema-checker",
mixinStandardHelpOptions = true,
version = "redis-schema-checker 1.0.0",
description = "检测两次提交间 Redis value 序列化结构变更并通过企微机器人通知。")
public class RedisSchemaCheckerMain implements Callable<Integer> {
version = "cache-schema-checker 1.0.0",
description = "检测两次提交间缓存 value 序列化结构变更并通过企微机器人通知。")
public class CacheSchemaCheckerMain implements Callable<Integer> {
@Option(names = "--config", required = true, description = "业务仓库检测配置文件路径")
private Path configPath;
@@ -55,8 +55,13 @@ public class RedisSchemaCheckerMain implements Callable<Integer> {
try {
CheckerConfig config = ConfigLoader.load(configPath);
if (!config.isEnabled()) {
System.out.println("[cache-schema-checker] 总开关 enabled=false跳过检测。");
return 0;
}
if (oldSha == null || oldSha.trim().isEmpty()) {
System.out.println("[redis-schema-checker] 无对比基准提交,跳过检测。");
System.out.println("[cache-schema-checker] 无对比基准提交,跳过检测。");
return 0;
}
@@ -70,29 +75,33 @@ public class RedisSchemaCheckerMain implements Callable<Integer> {
report.setRepository(repository != null ? repository : root.getFileName().toString());
ReportBuilder builder = new ReportBuilder(config.getNotify().getTitlePrefix());
// CI字段明细 + 完整企微 Markdown
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 ? "成功" : "失败/跳过"));
String webhook = config.getNotify().getWebhookUrl();
List<String> messages = builder.toWeComMessages(report);
int ok = new WeComNotifier().sendMarkdownMessages(webhook, messages);
System.out.println("[cache-schema-checker] 企微通知发送: "
+ ok + "/" + messages.size()
+ (messages.size() > 1 ? "(已按 key 拆分)" : ""));
}
if (report.isBlocked()) {
System.out.println("[redis-schema-checker] block 模式命中流水线将被阻断exit 1");
System.out.println("[cache-schema-checker] block 模式命中流水线将被阻断exit 1");
}
return report.getExitCode();
} catch (Exception e) {
System.err.println("[redis-schema-checker] 执行错误: " + e.getMessage());
System.err.println("[cache-schema-checker] 执行错误: " + e.getMessage());
e.printStackTrace();
return 2;
}
}
public static void main(String[] args) {
int exitCode = new CommandLine(new RedisSchemaCheckerMain()).execute(args);
int exitCode = new CommandLine(new CacheSchemaCheckerMain()).execute(args);
System.exit(exitCode);
}
}

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.config;
package com.codechecker.cache.config;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -10,11 +10,12 @@ import java.util.Map;
*/
public class CheckerConfig {
/** 总开关false 时跳过检测与通知(流水线 exit 0 */
private boolean enabled = true;
/** notify | block */
private String mode = "notify";
private List<String> blockSeverities = new ArrayList<>();
private boolean scanTestSources = false;
private List<String> sourceRoots = new ArrayList<>();
@@ -37,9 +38,10 @@ public class CheckerConfig {
public static class Notify {
private boolean enabled = true;
private String webhookEnv = "WECOM_ROBOT_WEBHOOK";
/** 企微机器人 Webhook 完整 URL */
private String webhookUrl = "";
private boolean notifyOnClean = false;
private String titlePrefix = "[Redis结构变更]";
private String titlePrefix = "[缓存结构变更]";
public boolean isEnabled() {
return enabled;
@@ -49,12 +51,12 @@ public class CheckerConfig {
this.enabled = enabled;
}
public String getWebhookEnv() {
return webhookEnv;
public String getWebhookUrl() {
return webhookUrl;
}
public void setWebhookEnv(String webhookEnv) {
this.webhookEnv = webhookEnv;
public void setWebhookUrl(String webhookUrl) {
this.webhookUrl = webhookUrl;
}
public boolean isNotifyOnClean() {
@@ -108,6 +110,8 @@ public class CheckerConfig {
private List<String> patterns = new ArrayList<>();
private double minConfidence = 0.6;
private int maxFieldDepth = 8;
/** W06是否启用读侧反序列化类型辅助补强 */
private boolean readHintsEnabled = true;
public List<String> getPatterns() {
return patterns;
@@ -132,6 +136,14 @@ public class CheckerConfig {
public void setMaxFieldDepth(int maxFieldDepth) {
this.maxFieldDepth = maxFieldDepth;
}
public boolean isReadHintsEnabled() {
return readHintsEnabled;
}
public void setReadHintsEnabled(boolean readHintsEnabled) {
this.readHintsEnabled = readHintsEnabled;
}
}
public static class ManualMapping {
@@ -230,6 +242,14 @@ public class CheckerConfig {
}
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getMode() {
return mode;
}
@@ -238,14 +258,6 @@ public class CheckerConfig {
this.mode = mode;
}
public List<String> getBlockSeverities() {
return blockSeverities;
}
public void setBlockSeverities(List<String> blockSeverities) {
this.blockSeverities = blockSeverities;
}
public boolean isScanTestSources() {
return scanTestSources;
}

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.config;
package com.codechecker.cache.config;
import org.yaml.snakeyaml.Yaml;
@@ -75,8 +75,8 @@ public final class ConfigLoader {
private static CheckerConfig bind(Map<String, Object> map) {
CheckerConfig config = new CheckerConfig();
config.setEnabled(bool(map, "enabled", true));
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")));
@@ -85,9 +85,9 @@ public final class ConfigLoader {
Map<String, Object> notify = asMap(map.get("notify"));
CheckerConfig.Notify n = config.getNotify();
n.setEnabled(bool(notify, "enabled", true));
n.setWebhookEnv(str(notify, "webhook_env", "WECOM_ROBOT_WEBHOOK"));
n.setWebhookUrl(resolveWebhookUrl(notify));
n.setNotifyOnClean(bool(notify, "notify_on_clean", false));
n.setTitlePrefix(str(notify, "title_prefix", "[Redis结构变更]"));
n.setTitlePrefix(str(notify, "title_prefix", "[缓存结构变更]"));
Map<String, Object> ignore = asMap(map.get("ignore"));
CheckerConfig.Ignore ig = config.getIgnore();
@@ -100,6 +100,7 @@ public final class ConfigLoader {
d.setPatterns(strList(detection.get("patterns")));
d.setMinConfidence(dbl(detection, "min_confidence", 0.6));
d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8));
d.setReadHintsEnabled(bool(detection, "read_hints_enabled", true));
Map<String, Object> severityOverrides = asMap(map.get("severity_overrides"));
Map<String, String> so = new LinkedHashMap<>();
@@ -137,6 +138,21 @@ public final class ConfigLoader {
return config;
}
/**
* 优先读取 webhook_url兼容旧字段 webhook_env值为 http 开头时视为 URL
*/
private static String resolveWebhookUrl(Map<String, Object> notify) {
String url = str(notify, "webhook_url", "");
if (url != null && !url.trim().isEmpty()) {
return url.trim();
}
String legacy = str(notify, "webhook_env", "");
if (legacy != null && legacy.trim().startsWith("http")) {
return legacy.trim();
}
return "";
}
@SuppressWarnings("unchecked")
private static Map<String, Object> asMap(Object obj) {
if (obj instanceof Map) {

View File

@@ -0,0 +1,92 @@
package com.codechecker.cache.detector;
/**
* 读侧反序列化类型提示W06从 parseObject / getJsonToBean 等推断「缓存 value 被当成什么类型用」。
* 不单独产生告警,仅用于补强同文件/同 key 写入点的 value 类型。
*/
public class CacheReadHint {
private String filePath;
private int lineNumber;
private String enclosingClass;
private String enclosingMethod;
/** 推断出的 key 模式;无法关联 redis get 时为 null */
private String resolvedKeyPattern;
private String keyExpression;
/** value 元素/对象 FQN */
private String resolvedValueType;
private boolean rootArray;
private double confidence = 0.7;
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 getResolvedKeyPattern() {
return resolvedKeyPattern;
}
public void setResolvedKeyPattern(String resolvedKeyPattern) {
this.resolvedKeyPattern = resolvedKeyPattern;
}
public String getKeyExpression() {
return keyExpression;
}
public void setKeyExpression(String keyExpression) {
this.keyExpression = keyExpression;
}
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;
}
}

View File

@@ -0,0 +1,189 @@
package com.codechecker.cache.detector;
import com.codechecker.cache.key.RedisKeyResolver;
import com.codechecker.cache.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.VariableDeclarator;
import com.github.javaparser.ast.expr.ClassExpr;
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.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.Locale;
import java.util.Optional;
import java.util.Set;
/**
* W06扫描读侧反序列化提取「缓存字符串 → 业务类型」提示,供写入点类型补强。
* <p>典型模式:</p>
* <pre>
* String raw = redisUtil.getString(key);
* Foo vo = JSON.parseObject(raw, Foo.class);
* List&lt;Foo&gt; list = JSON.parseArray(raw, Foo.class);
* </pre>
*/
public class CacheReadHintDetector {
private static final Set<String> OBJECT_PARSE = new HashSet<>(Arrays.asList(
"parseObject", "parse", "getJsonToBean", "toJavaObject", "readValue"));
private static final Set<String> ARRAY_PARSE = new HashSet<>(Arrays.asList(
"parseArray", "getJsonToList", "parseArrayObject"));
private static final Set<String> REDIS_GET = new HashSet<>(Arrays.asList(
"get", "getString", "opsForValue"));
private final SourceIndex index;
private final RedisKeyResolver keyResolver;
public CacheReadHintDetector(SourceIndex index) {
this.index = index;
this.keyResolver = new RedisKeyResolver(index);
}
public List<CacheReadHint> detect(String filePath, String content) {
List<CacheReadHint> 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)) {
CacheReadHint hint = tryParseHint(mce, filePath);
if (hint != null) {
result.add(hint);
}
}
return result;
}
private CacheReadHint tryParseHint(MethodCallExpr mce, String filePath) {
String name = mce.getNameAsString();
boolean array = ARRAY_PARSE.contains(name);
boolean object = OBJECT_PARSE.contains(name);
if (!array && !object) {
return null;
}
if (mce.getArguments().size() < 2) {
return null;
}
Expression classArg = mce.getArgument(1);
// readValue(str, TypeReference) 等暂不支持;要求 ClassLiteral Xxx.class
if (!(classArg instanceof ClassExpr)) {
// 部分 APIparseObject(str, Xxx.class, Feature...) 仍是第 2 参
return null;
}
Type type = ((ClassExpr) classArg).getType();
if (!(type instanceof ClassOrInterfaceType)) {
return null;
}
ClassOrInterfaceDeclaration enclosing = mce
.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null);
String enclosingFqn = enclosing == null
? "<unknown>"
: enclosing.getFullyQualifiedName().orElse(enclosing.getNameAsString());
SourceIndex.IndexedType context = index.get(enclosingFqn);
String fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameWithScope(), context);
if (fqn == null) {
fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameAsString(), context);
}
if (fqn == null) {
return null;
}
CacheReadHint hint = new CacheReadHint();
hint.setFilePath(filePath);
hint.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0));
hint.setEnclosingClass(enclosingFqn);
hint.setEnclosingMethod(mce.findAncestor(CallableDeclaration.class)
.map(CallableDeclaration::getNameAsString).orElse("<unknown>"));
hint.setResolvedValueType(fqn);
hint.setRootArray(array || "parseArray".equals(name) || "getJsonToList".equals(name));
hint.setConfidence(0.7);
Expression rawExpr = mce.getArgument(0);
Optional<RedisGetRef> getRef = findRedisGetForVar(rawExpr, mce);
if (getRef.isPresent()) {
hint.setKeyExpression(getRef.get().keyExpr.toString());
hint.setResolvedKeyPattern(keyResolver.resolve(
getRef.get().keyExpr, enclosing, context));
hint.setConfidence(0.85);
}
return hint;
}
/**
* 若 parse 的第 1 参是局部变量,追溯其是否来自 redis get(key)。
*/
private Optional<RedisGetRef> findRedisGetForVar(Expression rawExpr, MethodCallExpr parseCall) {
if (!(rawExpr instanceof NameExpr)) {
// 直接 parseObject(redis.get(key), Xxx.class)
if (rawExpr instanceof MethodCallExpr) {
return extractGetKey((MethodCallExpr) rawExpr);
}
return Optional.empty();
}
String varName = ((NameExpr) rawExpr).getNameAsString();
Optional<CallableDeclaration> callable = parseCall.findAncestor(CallableDeclaration.class);
if (!callable.isPresent()) {
return Optional.empty();
}
for (VariableDeclarator var : callable.get().findAll(VariableDeclarator.class)) {
if (!var.getNameAsString().equals(varName) || !var.getInitializer().isPresent()) {
continue;
}
Expression init = var.getInitializer().get();
if (init instanceof MethodCallExpr) {
return extractGetKey((MethodCallExpr) init);
}
}
return Optional.empty();
}
private Optional<RedisGetRef> extractGetKey(MethodCallExpr call) {
String name = call.getNameAsString();
String scope = call.getScope().map(Expression::toString).orElse("").toLowerCase(Locale.ROOT);
boolean redisScope = scope.contains("redis") || scope.contains("opsforvalue")
|| scope.contains("boundvalueops");
if ("get".equals(name) || "getString".equals(name)) {
if (!redisScope && !REDIS_GET.contains(name)) {
// getString 也常见于 RedisUtil
if (!"getString".equals(name)) {
return Optional.empty();
}
}
if (call.getArguments().isEmpty()) {
return Optional.empty();
}
return Optional.of(new RedisGetRef(call.getArgument(0)));
}
// redisTemplate.opsForValue().get(key)
if ("get".equals(name) && scope.contains("opsforvalue") && !call.getArguments().isEmpty()) {
return Optional.of(new RedisGetRef(call.getArgument(0)));
}
return Optional.empty();
}
private static final class RedisGetRef {
final Expression keyExpr;
RedisGetRef(Expression keyExpr) {
this.keyExpr = keyExpr;
}
}
}

View File

@@ -1,7 +1,7 @@
package com.codechecker.redis.detector;
package com.codechecker.cache.detector;
import com.codechecker.redis.key.RedisKeyResolver;
import com.codechecker.redis.schema.SourceIndex;
import com.codechecker.cache.key.RedisKeyResolver;
import com.codechecker.cache.schema.SourceIndex;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.CallableDeclaration;
@@ -11,10 +11,17 @@ 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.BinaryExpr;
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
import com.github.javaparser.ast.expr.CastExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.IntegerLiteralExpr;
import com.github.javaparser.ast.expr.LongLiteralExpr;
import com.github.javaparser.ast.expr.MethodCallExpr;
import com.github.javaparser.ast.expr.NameExpr;
import com.github.javaparser.ast.expr.NullLiteralExpr;
import com.github.javaparser.ast.expr.ObjectCreationExpr;
import com.github.javaparser.ast.expr.StringLiteralExpr;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.type.Type;
@@ -26,13 +33,19 @@ import java.util.Optional;
import java.util.Set;
/**
* 从单个 Java 源文件中检测 Redis value 写入点W01~W03JSON 字符串写入
* 从单个 Java 源文件中检测 Redis value 写入点W01~W05
*/
public class RedisWritePointDetector {
private static final Set<String> SERIALIZE_METHODS = new HashSet<>(Arrays.asList(
"toJSONString", "toJsonString", "getObjectToString", "toJsonStr", "writeValueAsString"));
private static final Set<String> WRITE_METHODS = new HashSet<>(Arrays.asList("set", "insert"));
private static final Set<String> VALUE_WRITE_METHODS = new HashSet<>(Arrays.asList("set", "insert"));
private static final Set<String> SKIP_METHODS = new HashSet<>(Arrays.asList(
"setIfAbsent", "setIfPresent", "increment", "decrement", "delete", "remove",
"expire", "get", "hasKey", "exists", "setnx", "getAndSet", "getAndDelete",
"multiGet", "multiSet", "keys", "scan"));
private static final Set<String> TRIVIAL_VALUE_CALLS = new HashSet<>(Arrays.asList(
"randomUUID", "toString", "valueOf"));
private static final Set<String> COLLECTION_SIMPLE = new HashSet<>(Arrays.asList(
"List", "ArrayList", "LinkedList", "Set", "HashSet", "Collection"));
@@ -59,41 +72,79 @@ public class RedisWritePointDetector {
}
for (MethodCallExpr mce : cu.findAll(MethodCallExpr.class)) {
String method = mce.getNameAsString();
if (!WRITE_METHODS.contains(method)) {
continue;
WritePoint wp = tryDetectValueWrite(mce, filePath);
if (wp == null) {
wp = tryDetectHashWrite(mce, filePath);
}
String scope = mce.getScope().map(Expression::toString).orElse("");
if (!isRedisScope(scope)) {
continue;
if (wp != null) {
result.add(wp);
}
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;
return result;
}
private WritePoint tryDetectValueWrite(MethodCallExpr mce, String filePath) {
String method = mce.getNameAsString();
if (!VALUE_WRITE_METHODS.contains(method) || SKIP_METHODS.contains(method)) {
return null;
}
if (!isValueOpsScope(mce)) {
return null;
}
if (mce.getArguments().size() < 2) {
return null;
}
Expression valueArg = mce.getArgument(1);
Expression serialized = unwrapSerializer(valueArg);
String pattern;
Expression typeExpr;
if (serialized != null) {
pattern = classifySerialized(method, valueArg);
typeExpr = serialized;
} else if (enabledPatterns.contains("W04") && !isTrivialValue(valueArg)) {
pattern = "W04";
typeExpr = valueArg;
} else {
return null;
}
if (!enabledPatterns.contains(pattern)) {
return null;
}
return buildWritePoint(mce, filePath, pattern, mce.getArgument(0), valueArg, typeExpr);
}
private WritePoint tryDetectHashWrite(MethodCallExpr mce, String filePath) {
if (!"put".equals(mce.getNameAsString()) || !enabledPatterns.contains("W05")) {
return null;
}
if (!isHashOpsScope(mce) || mce.getArguments().size() < 3) {
return null;
}
Expression valueArg = mce.getArgument(2);
if (isTrivialValue(valueArg)) {
return null;
}
Expression serialized = unwrapSerializer(valueArg);
Expression typeExpr = serialized != null ? serialized : valueArg;
return buildWritePoint(mce, filePath, "W05", mce.getArgument(0), valueArg, typeExpr);
}
private WritePoint buildWritePoint(MethodCallExpr mce, String filePath, String pattern,
Expression keyArg, Expression valueArg, Expression typeExpr) {
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.setKeyExpression(keyArg.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);
wp.setResolvedKeyPattern(keyResolver.resolve(keyArg, enclosingDecl, context));
InferredType inferred = inferType(typeExpr, mce, context);
if (inferred != null) {
wp.setResolvedValueType(inferred.fqn);
wp.setRootArray(inferred.isArray);
@@ -101,16 +152,53 @@ public class RedisWritePointDetector {
} else {
wp.setConfidence(0.4);
}
result.add(wp);
}
return result;
return wp;
}
private boolean isRedisScope(String scope) {
private boolean isValueOpsScope(MethodCallExpr mce) {
String scope = mce.getScope().map(Expression::toString).orElse("");
String lower = scope.toLowerCase();
return lower.contains("redis") || lower.contains("opsforvalue") || lower.contains("boundvalueops");
}
private boolean isHashOpsScope(MethodCallExpr mce) {
String scope = mce.getScope().map(Expression::toString).orElse("");
String lower = scope.toLowerCase();
return lower.contains("opsforhash") || lower.contains("boundhashops");
}
private boolean isTrivialValue(Expression expr) {
if (expr instanceof StringLiteralExpr
|| expr instanceof IntegerLiteralExpr
|| expr instanceof LongLiteralExpr
|| expr instanceof BooleanLiteralExpr
|| expr instanceof NullLiteralExpr) {
return true;
}
if (expr instanceof BinaryExpr) {
BinaryExpr be = (BinaryExpr) expr;
if (be.getOperator() == BinaryExpr.Operator.PLUS) {
return isTrivialValue(be.getLeft()) && isTrivialValue(be.getRight());
}
}
if (expr instanceof MethodCallExpr) {
MethodCallExpr call = (MethodCallExpr) expr;
String name = call.getNameAsString();
if (TRIVIAL_VALUE_CALLS.contains(name)) {
return true;
}
if ("valueOf".equals(name) && !call.getArguments().isEmpty()) {
return isTrivialValue(call.getArgument(0));
}
}
if (expr instanceof ObjectCreationExpr) {
ObjectCreationExpr oce = (ObjectCreationExpr) expr;
String typeName = oce.getType().getNameAsString();
return "UUID".equals(typeName);
}
return false;
}
private Expression unwrapSerializer(Expression valueArg) {
if (valueArg instanceof MethodCallExpr) {
MethodCallExpr call = (MethodCallExpr) valueArg;
@@ -121,7 +209,7 @@ public class RedisWritePointDetector {
return null;
}
private String classify(String method, Expression valueArg) {
private String classifySerialized(String method, Expression valueArg) {
String serializer = valueArg instanceof MethodCallExpr
? ((MethodCallExpr) valueArg).getNameAsString() : "";
if ("insert".equals(method)) {
@@ -145,6 +233,9 @@ public class RedisWritePointDetector {
}
private InferredType inferType(Expression expr, MethodCallExpr contextCall, SourceIndex.IndexedType context) {
if (expr instanceof CastExpr) {
return resolveTypeNode(((CastExpr) expr).getType(), context);
}
if (expr instanceof ObjectCreationExpr) {
ClassOrInterfaceType t = ((ObjectCreationExpr) expr).getType();
return resolveTypeNode(t, context);
@@ -157,6 +248,18 @@ public class RedisWritePointDetector {
}
return null;
}
if (expr instanceof MethodCallExpr) {
MethodCallExpr call = (MethodCallExpr) expr;
Optional<ClassOrInterfaceDeclaration> clazz = contextCall
.findAncestor(ClassOrInterfaceDeclaration.class);
if (clazz.isPresent()) {
for (MethodDeclaration md : clazz.get().getMethods()) {
if (md.getNameAsString().equals(call.getNameAsString())) {
return resolveTypeNode(md.getType(), context);
}
}
}
}
return null;
}
@@ -164,20 +267,17 @@ public class RedisWritePointDetector {
Optional<CallableDeclaration> callable = contextCall.findAncestor(CallableDeclaration.class);
if (callable.isPresent()) {
CallableDeclaration<?> decl = callable.get();
// 局部变量
for (VariableDeclarator var : decl.findAll(VariableDeclarator.class)) {
if (var.getNameAsString().equals(name)) {
return var.getType();
}
}
// 方法参数
for (Parameter p : decl.getParameters()) {
if (p.getNameAsString().equals(name)) {
return p.getType();
}
}
}
// 类字段
Optional<ClassOrInterfaceDeclaration> clazz = contextCall.findAncestor(ClassOrInterfaceDeclaration.class);
if (clazz.isPresent()) {
for (FieldDeclaration field : clazz.get().getFields()) {

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.detector;
package com.codechecker.cache.detector;
/**
* 一个 Redis value 写入点的静态描述

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.diff;
package com.codechecker.cache.diff;
/**
* 结构变更类型及其默认严重级别

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.diff;
package com.codechecker.cache.diff;
/**
* 一条结构变更记录
@@ -8,7 +8,9 @@ public class SchemaChange {
private Severity severity;
private ChangeType changeType;
private String keyPattern;
private String keyExpression;
private String writeLocation;
private String valueType;
private String fieldPath;
private String oldValue;
private String newValue;
@@ -43,6 +45,14 @@ public class SchemaChange {
this.keyPattern = keyPattern;
}
public String getKeyExpression() {
return keyExpression;
}
public void setKeyExpression(String keyExpression) {
this.keyExpression = keyExpression;
}
public String getWriteLocation() {
return writeLocation;
}
@@ -51,6 +61,14 @@ public class SchemaChange {
this.writeLocation = writeLocation;
}
public String getValueType() {
return valueType;
}
public void setValueType(String valueType) {
this.valueType = valueType;
}
public String getFieldPath() {
return fieldPath;
}

View File

@@ -1,8 +1,8 @@
package com.codechecker.redis.diff;
package com.codechecker.cache.diff;
import com.codechecker.redis.schema.FieldSchema;
import com.codechecker.redis.schema.JsonType;
import com.codechecker.redis.schema.TypeSchema;
import com.codechecker.cache.schema.FieldSchema;
import com.codechecker.cache.schema.JsonType;
import com.codechecker.cache.schema.TypeSchema;
import java.util.ArrayList;
import java.util.LinkedHashMap;

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.diff;
package com.codechecker.cache.diff;
/**
* 变更严重级别

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.git;
package com.codechecker.cache.git;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.git;
package com.codechecker.cache.git;
/**
* Git 操作异常

View File

@@ -1,6 +1,6 @@
package com.codechecker.redis.key;
package com.codechecker.cache.key;
import com.codechecker.redis.schema.SourceIndex;
import com.codechecker.cache.schema.SourceIndex;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
@@ -20,7 +20,7 @@ import java.util.Optional;
*/
public class RedisKeyResolver {
private static final int MAX_DEPTH = 6;
private static final int MAX_DEPTH = 8;
private final SourceIndex index;
@@ -52,7 +52,7 @@ public class RedisKeyResolver {
}
if (expr instanceof NameExpr) {
String name = ((NameExpr) expr).getNameAsString();
String constVal = lookupConstant(enclosingClass, name);
String constVal = lookupConstant(enclosingClass, name, context, depth);
if (constVal != null) {
return constVal;
}
@@ -62,12 +62,23 @@ public class RedisKeyResolver {
if (expr instanceof FieldAccessExpr) {
FieldAccessExpr fae = (FieldAccessExpr) expr;
String fieldName = fae.getNameAsString();
String scope = fae.getScope().toString();
String external = lookupExternalConstant(scope, fieldName, context);
if (fae.getScope() instanceof NameExpr) {
String scope = ((NameExpr) fae.getScope()).getNameAsString();
String local = lookupConstant(enclosingClass, fieldName, context, depth);
if (local != null && scope.equals(enclosingClass == null ? "" : enclosingClass.getNameAsString())) {
return local;
}
String external = lookupExternalConstant(scope, fieldName, context, depth);
if (external != null) {
return external;
}
String local = lookupConstant(enclosingClass, fieldName);
}
String scope = fae.getScope().toString();
String external = lookupExternalConstant(scope, fieldName, context, depth);
if (external != null) {
return external;
}
String local = lookupConstant(enclosingClass, fieldName, context, depth);
return local != null ? local : "*";
}
if (expr instanceof MethodCallExpr) {
@@ -77,23 +88,26 @@ public class RedisKeyResolver {
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) {
private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name,
SourceIndex.IndexedType context, int depth) {
if (clazz == null) {
return null;
}
for (FieldDeclaration field : clazz.getFields()) {
if (!field.isStatic()) {
continue;
}
for (VariableDeclarator var : field.getVariables()) {
if (var.getNameAsString().equals(name)) {
Optional<Expression> init = var.getInitializer();
if (init.isPresent() && init.get() instanceof StringLiteralExpr) {
return ((StringLiteralExpr) init.get()).asString();
if (init.isPresent()) {
return resolveExpr(init.get(), clazz, context, depth + 1);
}
}
}
@@ -101,7 +115,8 @@ public class RedisKeyResolver {
return null;
}
private String lookupExternalConstant(String scopeName, String fieldName, SourceIndex.IndexedType context) {
private String lookupExternalConstant(String scopeName, String fieldName,
SourceIndex.IndexedType context, int depth) {
String fqn = index.resolveFqn(scopeName, context);
if (fqn == null) {
return null;
@@ -110,7 +125,7 @@ public class RedisKeyResolver {
if (type == null) {
return null;
}
return lookupConstant(type.getDeclaration(), fieldName);
return lookupConstant(type.getDeclaration(), fieldName, type, depth);
}
private String lookupMethodReturn(ClassOrInterfaceDeclaration clazz, String methodName,

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.notify;
package com.codechecker.cache.notify;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -9,6 +9,7 @@ import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -46,6 +47,15 @@ public class WeComNotifier {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
// 企微成功时 body 一般为 {"errcode":0,...}
if (response.body() != null && response.body().contains("\"errcode\":0")) {
return true;
}
if (response.body() != null && response.body().contains("errcode")
&& !response.body().contains("\"errcode\":0")) {
System.err.println("[WeComNotifier] 通知失败: " + response.body());
return false;
}
return true;
}
System.err.println("[WeComNotifier] 通知失败, HTTP " + response.statusCode() + ": " + response.body());
@@ -55,4 +65,36 @@ public class WeComNotifier {
return false;
}
}
/**
* 按序发送多条 Markdown用于超长按 key 拆分后的通知
*
* @return 成功条数
*/
public int sendMarkdownMessages(String webhookUrl, List<String> markdownMessages) {
if (markdownMessages == null || markdownMessages.isEmpty()) {
return 0;
}
int ok = 0;
for (int i = 0; i < markdownMessages.size(); i++) {
String content = markdownMessages.get(i);
boolean success = sendMarkdown(webhookUrl, content);
if (success) {
ok++;
}
System.out.println("[WeComNotifier] 第 " + (i + 1) + "/" + markdownMessages.size()
+ " 条发送" + (success ? "成功" : "失败")
+ " (" + content.getBytes(StandardCharsets.UTF_8).length + " bytes)");
// 避免触发机器人频率限制 20 /分钟
if (i < markdownMessages.size() - 1) {
try {
Thread.sleep(200L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
return ok;
}
}

View File

@@ -1,7 +1,7 @@
package com.codechecker.redis.report;
package com.codechecker.cache.report;
import com.codechecker.redis.diff.SchemaChange;
import com.codechecker.redis.diff.Severity;
import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity;
import java.util.ArrayList;
import java.util.List;
@@ -20,11 +20,12 @@ public class CheckReport {
private String mode;
private final List<SchemaChange> changes = new ArrayList<>();
private final List<KeyStructureChange> keyChanges = new ArrayList<>();
private boolean blocked;
private int exitCode;
public boolean hasChanges() {
return !changes.isEmpty();
return !changes.isEmpty() || !keyChanges.isEmpty();
}
public long count(Severity severity) {
@@ -91,6 +92,10 @@ public class CheckReport {
return changes;
}
public List<KeyStructureChange> getKeyChanges() {
return keyChanges;
}
public boolean isBlocked() {
return blocked;
}

View File

@@ -0,0 +1,115 @@
package com.codechecker.cache.report;
import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity;
import java.util.ArrayList;
import java.util.List;
/**
* 按 Redis key或未知 key 时的写入点)聚合后的结构变更摘要。
*/
public class KeyStructureChange {
/** 解析到的 key 模式;未知时为 unknown-key */
private String keyPattern;
/** 源码中的 key 表达式,如 req.getKey() */
private String keyExpression;
/** 写入位置 Class#method:line */
private String writeLocation;
/** 展示用 value 类型,如 List&lt;ClockInExportVo&gt; */
private String valueType;
private boolean keyUnresolved;
private String oldSkeletonJson;
private String newSkeletonJson;
private Severity severity = Severity.P2;
private final List<SchemaChange> fieldDetails = new ArrayList<>();
public String getKeyPattern() {
return keyPattern;
}
public void setKeyPattern(String keyPattern) {
this.keyPattern = keyPattern;
}
public String getKeyExpression() {
return keyExpression;
}
public void setKeyExpression(String keyExpression) {
this.keyExpression = keyExpression;
}
public String getWriteLocation() {
return writeLocation;
}
public void setWriteLocation(String writeLocation) {
this.writeLocation = writeLocation;
}
public String getValueType() {
return valueType;
}
public void setValueType(String valueType) {
this.valueType = valueType;
}
public boolean isKeyUnresolved() {
return keyUnresolved;
}
public void setKeyUnresolved(boolean keyUnresolved) {
this.keyUnresolved = keyUnresolved;
}
public String getOldSkeletonJson() {
return oldSkeletonJson;
}
public void setOldSkeletonJson(String oldSkeletonJson) {
this.oldSkeletonJson = oldSkeletonJson;
}
public String getNewSkeletonJson() {
return newSkeletonJson;
}
public void setNewSkeletonJson(String newSkeletonJson) {
this.newSkeletonJson = newSkeletonJson;
}
public Severity getSeverity() {
return severity;
}
public void setSeverity(Severity severity) {
this.severity = severity;
}
public List<SchemaChange> getFieldDetails() {
return fieldDetails;
}
public void raiseSeverity(Severity candidate) {
if (candidate == null) {
return;
}
if (severity == null || candidate.ordinal() < severity.ordinal()) {
severity = candidate;
}
}
/** 通知里 Key 行展示文本:未解析优先用表达式。 */
public String displayKey() {
if (keyUnresolved && keyExpression != null && !keyExpression.trim().isEmpty()) {
return keyExpression.trim();
}
if (keyPattern != null && !keyPattern.trim().isEmpty()) {
return keyPattern.trim();
}
return keyExpression != null ? keyExpression : "unknown-key";
}
}

View File

@@ -0,0 +1,285 @@
package com.codechecker.cache.report;
import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
* <ul>
* <li>企微:按 key 展示位置/类型/序列化骨架变更,不含字段明细,不分 P0/P1/P2</li>
* <li>未解析 key 展示源码表达式 + 灰色「key 无法解析」提示</li>
* <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
* <li>CI先打字段明细再完整输出企微 Markdown拆分后的每条</li>
* </ul>
*/
public class ReportBuilder {
/** 企微 markdown content 字节上限UTF-8。 */
public static final int WECOM_MARKDOWN_MAX_BYTES = 4096;
private final String titlePrefix;
public ReportBuilder(String titlePrefix) {
this.titlePrefix = titlePrefix == null ? "[缓存结构变更]" : titlePrefix;
}
/**
* 完整单条 Markdown多 key 全部拼接),供本地预览;发送请用 {@link #toWeComMessages}。
*/
public String toMarkdown(CheckReport report) {
List<String> messages = toWeComMessages(report);
if (messages.isEmpty()) {
return "";
}
if (messages.size() == 1) {
return messages.get(0);
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < messages.size(); i++) {
if (i > 0) {
sb.append("\n---\n");
}
sb.append(messages.get(i));
}
return sb.toString();
}
/**
* 生成待发送的企微 Markdown 列表:
* <ol>
* <li>所有 key 拼成一条,未超长则返回 1 条</li>
* <li>超长则按 key 拆分,每条带完整抬头,返回 N 条</li>
* </ol>
*/
public List<String> toWeComMessages(CheckReport report) {
String header = buildHeader(report);
List<String> bodies = buildKeyBodies(report);
if (bodies.isEmpty()) {
return Collections.singletonList(header + "未检测到缓存序列化结构变更。\n");
}
StringBuilder combined = new StringBuilder(header);
for (String body : bodies) {
combined.append(body);
}
String allInOne = combined.toString();
if (utf8Bytes(allInOne) <= WECOM_MARKDOWN_MAX_BYTES) {
return Collections.singletonList(allInOne);
}
List<String> split = new ArrayList<>(bodies.size());
for (String body : bodies) {
split.add(header + body);
}
return split;
}
private String buildHeader(CheckReport report) {
StringBuilder sb = new StringBuilder();
sb.append("## ").append(titlePrefix).append(' ')
.append(nvl(report.getRepository())).append("\n\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\n");
return sb.toString();
}
/**
* 每个 key 一段正文(不含抬头)。无 keyChanges 时退化为字段明细段落列表(仍尽量合并)。
*/
private List<String> buildKeyBodies(CheckReport report) {
List<KeyStructureChange> keys = report.getKeyChanges();
if (keys != null && !keys.isEmpty()) {
List<String> bodies = new ArrayList<>(keys.size());
for (KeyStructureChange kc : keys) {
bodies.add(renderKeyBlock(kc));
}
return bodies;
}
if (report.getChanges().isEmpty()) {
return Collections.emptyList();
}
// 无 key 摘要时:整段字段明细作为一块(仍可超长再整体发,不按字段拆)
StringBuilder sb = new StringBuilder();
for (SchemaChange c : report.getChanges()) {
appendKeyLine(sb, c.getKeyPattern(), c.getKeyExpression(), isUnresolvedKey(c.getKeyPattern()));
appendMetaLines(sb, c.getWriteLocation(), c.getValueType());
if (c.getMessage() != null) {
sb.append(" ").append(c.getMessage()).append('\n');
}
}
return Collections.singletonList(sb.toString());
}
private String renderKeyBlock(KeyStructureChange kc) {
StringBuilder sb = new StringBuilder();
appendKeyLine(sb, kc.displayKey(), kc.getKeyExpression(), kc.isKeyUnresolved());
appendMetaLines(sb, kc.getWriteLocation(), kc.getValueType());
String oldJson = nvl(kc.getOldSkeletonJson());
String newJson = nvl(kc.getNewSkeletonJson());
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
Set<String> newHighlight = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
String oldRendered = oldJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateOldForWecom(oldJson, oldHighlight) + "";
String newRendered = newJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateNewForWecom(newJson, newHighlight) + "";
if (oldJson.isEmpty() && !newJson.isEmpty()) {
sb.append(" > **value 新增为:** ").append(newRendered).append("\n\n");
} else if (!oldJson.isEmpty() && newJson.isEmpty()) {
sb.append(" > **value 原结构:** ").append(oldRendered).append("(已删除写入)\n\n");
} else {
sb.append(" > **value值由** ").append(oldRendered).append('\n');
sb.append(" > **变更为:** ").append(newRendered).append("\n\n");
}
return sb.toString();
}
/**
* Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
* 反引号仅包裹 key 文本,避免与加粗/颜色嵌套冲突。
*/
private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression,
boolean unresolved) {
String keyText = unresolved && keyExpression != null && !keyExpression.trim().isEmpty()
? keyExpression.trim()
: nvl(displayKey);
if (keyText.isEmpty()) {
keyText = "unknown-key";
}
sb.append("- Key --> `").append(keyText).append('`');
if (unresolved) {
sb.append(" <font color=\"comment\">key 无法解析)</font>");
}
sb.append('\n');
}
/** 位置、类型作为每个 Key 块的通用项。 */
private void appendMetaLines(StringBuilder sb, String writeLocation, String valueType) {
sb.append(" > **位置**: `").append(nvl(writeLocation)).append("`\n");
sb.append(" > **类型**: `").append(nvl(valueType)).append("`\n");
}
private boolean isUnresolvedKey(String keyPattern) {
return keyPattern == null
|| keyPattern.isEmpty()
|| "unknown-key".equals(keyPattern)
|| "<unknown>".equals(keyPattern);
}
/**
* CI 控制台:字段明细 + 企微 Markdown若拆分则逐条打印
*/
public String toConsole(CheckReport report) {
StringBuilder sb = new StringBuilder();
if (!report.hasChanges()) {
sb.append("未检测到缓存序列化结构变更。\n");
return sb.toString();
}
sb.append("======== 字段明细 ========\n");
Map<Severity, List<SchemaChange>> grouped = new EnumMap<>(Severity.class);
for (SchemaChange c : report.getChanges()) {
grouped.computeIfAbsent(c.getSeverity(), k -> new ArrayList<>()).add(c);
}
boolean anyDetail = false;
for (Severity severity : Severity.values()) {
List<SchemaChange> list = grouped.get(severity);
if (list == null || list.isEmpty()) {
continue;
}
anyDetail = true;
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(formatDetailMessage(c.getMessage())).append('\n');
}
}
sb.append('\n');
}
if (!anyDetail) {
sb.append("(无字段级明细)\n\n");
}
List<String> messages = toWeComMessages(report);
if (messages.size() == 1) {
sb.append("======== 企微 Markdown ========\n");
sb.append(messages.get(0));
} else {
sb.append("======== 企微 Markdown超长已按 key 拆为 ")
.append(messages.size()).append(" 条)========\n");
for (int i = 0; i < messages.size(); i++) {
sb.append("--- 第 ").append(i + 1).append('/').append(messages.size())
.append(" 条 (").append(utf8Bytes(messages.get(i))).append(" bytes) ---\n");
sb.append(messages.get(i));
if (i < messages.size() - 1) {
sb.append('\n');
}
}
}
return sb.toString();
}
private String formatDetailMessage(String message) {
String trimmed = message.trim();
int colonCn = trimmed.indexOf('');
if (colonCn > 0 && colonCn <= 24) {
return "**" + trimmed.substring(0, colonCn) + "**: "
+ trimmed.substring(colonCn + 1).trim();
}
int colonEn = trimmed.indexOf(':');
if (colonEn > 0 && colonEn <= 24 && !trimmed.substring(0, colonEn).contains(" ")) {
return "**" + trimmed.substring(0, colonEn) + "**: "
+ trimmed.substring(colonEn + 1).trim();
}
String[] knownPrefixes = {
"删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "新增缓存写入点,",
"删除缓存写入点,原 value 类型: "
};
for (String prefix : knownPrefixes) {
if (trimmed.startsWith(prefix)) {
String key = prefix.trim();
if (key.endsWith("") || key.endsWith(",")) {
key = key.substring(0, key.length() - 1);
}
return "**" + key + "**: " + trimmed.substring(prefix.length()).trim();
}
}
if (trimmed.startsWith("字段 ") && trimmed.contains(" 类型由 ")) {
return "**字段类型变更**: " + trimmed.substring("字段 ".length()).trim();
}
return trimmed;
}
static int utf8Bytes(String s) {
return s.getBytes(StandardCharsets.UTF_8).length;
}
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;
}
}

View File

@@ -0,0 +1,331 @@
package com.codechecker.cache.report;
import com.codechecker.cache.diff.ChangeType;
import com.codechecker.cache.diff.SchemaChange;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 在骨架 JSON 中为改动字段加企微颜色标注(仅改动片段染色,其余明文)。
* <ul>
* <li>删除字段 → 旧骨架,橙色 {@code warning}</li>
* <li>新增字段 / 新增包装层 → 新骨架,绿色 {@code info}</li>
* <li>路径迁移 → 旧路径橙、新路径绿</li>
* </ul>
*/
final class SkeletonAnnotator {
/** 企微橙:删除 / 旧侧变更 */
static final String COLOR_REMOVE = "warning";
/** 企微绿:新增 / 新侧变更 */
static final String COLOR_ADD = "info";
private static final String FONT_CLOSE = "</font>";
private SkeletonAnnotator() {
}
static Set<String> pathsForOldSkeleton(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>();
if (details == null) {
return paths;
}
for (SchemaChange c : details) {
if (c == null || c.getChangeType() == null) {
continue;
}
if (c.getChangeType() == ChangeType.FIELD_REMOVED) {
addIfPresent(paths, c.getFieldPath());
} else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) {
addIfPresent(paths, c.getOldValue());
}
}
return paths;
}
static Set<String> pathsForNewSkeleton(List<SchemaChange> details) {
Set<String> paths = new LinkedHashSet<>();
if (details == null) {
return paths;
}
for (SchemaChange c : details) {
if (c == null || c.getChangeType() == null) {
continue;
}
if (c.getChangeType() == ChangeType.FIELD_ADDED
|| c.getChangeType() == ChangeType.WRAPPER_ADDED) {
addIfPresent(paths, c.getFieldPath());
} else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) {
addIfPresent(paths, c.getFieldPath());
if (c.getNewValue() != null && !c.getNewValue().equals(c.getFieldPath())) {
addIfPresent(paths, c.getNewValue());
}
}
}
return paths;
}
/**
* 标注旧骨架改动字段(删除 → 橙色)。
*/
static String annotateOldForWecom(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths, COLOR_REMOVE);
}
/**
* 标注新骨架改动字段(新增 → 绿色)。
*/
static String annotateNewForWecom(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths, COLOR_ADD);
}
/**
* 仅标注改动字段:其余正文保持普通文本。
* 改动片段格式:{@code <font color="warning|info">"field":value</font>}
*/
static String annotateForWecom(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths, COLOR_REMOVE);
}
static String annotateForWecom(String json, Set<String> highlightPaths, String color) {
if (json == null || json.isEmpty()) {
return "";
}
return annotate(json, highlightPaths, color);
}
/** @deprecated 使用 {@link #annotateForWecom},勿再整段包代码块 */
static String annotateAsCodeBlock(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths);
}
static String annotate(String json, Set<String> highlightPaths) {
return annotate(json, highlightPaths, COLOR_REMOVE);
}
static String annotate(String json, Set<String> highlightPaths, String color) {
if (json == null || json.isEmpty() || highlightPaths == null || highlightPaths.isEmpty()) {
return json == null ? "" : json;
}
String fontOpen = fontOpen(color);
String result = json;
List<String> sorted = new ArrayList<>(highlightPaths);
sorted.sort(Comparator.comparingInt(String::length).reversed());
for (String path : sorted) {
result = wrapOnePath(result, path, fontOpen);
}
return result;
}
private static String fontOpen(String color) {
String c = color == null || color.isEmpty() ? COLOR_REMOVE : color;
return "<font color=\"" + c + "\">";
}
private static void addIfPresent(Set<String> paths, String path) {
if (path != null && !path.trim().isEmpty()) {
paths.add(path.trim());
}
}
private static String wrapOnePath(String json, String path, String fontOpen) {
if (path == null || path.isEmpty()) {
return json;
}
String key = lastSegment(path);
if (key.isEmpty()) {
return json;
}
int propStart = findPropertyStart(json, path);
if (propStart < 0) {
return json;
}
int fontBefore = json.lastIndexOf("<font color=\"", propStart);
int fontCloseBefore = json.lastIndexOf(FONT_CLOSE, propStart);
if (fontBefore > fontCloseBefore) {
return json;
}
String needle = "\"" + key + "\"";
int keyEnd = propStart + needle.length();
int valueEnd = findValueEnd(json, keyEnd);
if (valueEnd <= propStart) {
return json;
}
String frag = json.substring(propStart, valueEnd);
if (frag.contains("<font color=\"")) {
return json;
}
return json.substring(0, propStart)
+ fontOpen + frag + FONT_CLOSE
+ json.substring(valueEnd);
}
/**
* 按点分路径定位属性名起始下标(指向开头引号)。
*/
private static int findPropertyStart(String json, String path) {
List<String> segs = splitPath(path);
if (segs.isEmpty()) {
return -1;
}
int searchFrom = 0;
int scopeEnd = json.length();
for (int s = 0; s < segs.size(); s++) {
String seg = segs.get(s);
boolean last = s == segs.size() - 1;
String needle = "\"" + seg + "\"";
int idx = indexOfPropertyInScope(json, needle, searchFrom, scopeEnd);
if (idx < 0) {
return -1;
}
if (last) {
return idx;
}
int keyEnd = idx + needle.length();
int valueStart = skipColon(json, keyEnd);
if (valueStart >= json.length()) {
return -1;
}
char c = json.charAt(valueStart);
if (c == '{') {
searchFrom = valueStart + 1;
scopeEnd = findMatchingBracket(json, valueStart);
if (scopeEnd < 0) {
return -1;
}
} else if (c == '[') {
int arrEnd = findMatchingBracket(json, valueStart);
int elemObj = json.indexOf('{', valueStart);
if (elemObj < 0 || elemObj >= arrEnd) {
searchFrom = valueStart + 1;
scopeEnd = arrEnd;
} else {
searchFrom = elemObj + 1;
scopeEnd = findMatchingBracket(json, elemObj);
}
} else {
return -1;
}
}
return -1;
}
private static int indexOfPropertyInScope(String json, String needle, int from, int to) {
int idx = from;
while (idx >= 0 && idx < to) {
idx = json.indexOf(needle, idx);
if (idx < 0 || idx >= to) {
return -1;
}
int p = idx - 1;
while (p >= from && Character.isWhitespace(json.charAt(p))) {
p--;
}
if (p < from || json.charAt(p) == '{' || json.charAt(p) == ',' || json.charAt(p) == '[') {
return idx;
}
idx += needle.length();
}
return -1;
}
private static int skipColon(String json, int afterKey) {
int i = afterKey;
while (i < json.length() && (json.charAt(i) == ':' || Character.isWhitespace(json.charAt(i)))) {
i++;
}
return i;
}
private static int findMatchingBracket(String json, int openIdx) {
if (openIdx < 0 || openIdx >= json.length()) {
return -1;
}
char open = json.charAt(openIdx);
char close = open == '{' ? '}' : ']';
int depth = 0;
for (int i = openIdx; i < json.length(); i++) {
char ch = json.charAt(i);
if (ch == '"') {
i++;
while (i < json.length()) {
char x = json.charAt(i++);
if (x == '\\' && i < json.length()) {
i++;
} else if (x == '"') {
break;
}
}
i--;
continue;
}
if (ch == open) {
depth++;
} else if (ch == close) {
depth--;
if (depth == 0) {
return i;
}
}
}
return -1;
}
private static int findValueEnd(String json, int afterKey) {
int i = skipColon(json, afterKey);
if (i >= json.length()) {
return json.length();
}
char c = json.charAt(i);
if (c == '"') {
i++;
while (i < json.length()) {
char ch = json.charAt(i++);
if (ch == '\\' && i < json.length()) {
i++;
} else if (ch == '"') {
break;
}
}
return i;
}
if (c == '{' || c == '[') {
int end = findMatchingBracket(json, i);
return end < 0 ? json.length() : end + 1;
}
while (i < json.length() && json.charAt(i) != ',' && json.charAt(i) != '}' && json.charAt(i) != ']') {
i++;
}
return i;
}
private static List<String> splitPath(String path) {
List<String> segs = new ArrayList<>();
if (path == null || path.isEmpty()) {
return segs;
}
for (String raw : path.split("\\.")) {
if (raw.isEmpty() || "[]".equals(raw)) {
continue;
}
if (raw.endsWith("[]")) {
String name = raw.substring(0, raw.length() - 2);
if (!name.isEmpty()) {
segs.add(name);
}
} else {
segs.add(raw);
}
}
return segs;
}
private static String lastSegment(String path) {
List<String> segs = splitPath(path);
return segs.isEmpty() ? "" : segs.get(segs.size() - 1);
}
}

View File

@@ -0,0 +1,150 @@
package com.codechecker.cache.schema;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.expr.AnnotationExpr;
import com.github.javaparser.ast.expr.ArrayInitializerExpr;
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
import com.github.javaparser.ast.expr.Expression;
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;
import java.util.HashSet;
import java.util.Set;
/**
* 处理 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 = simpleName(annotation);
if (isIgnoreAnnotation(name)) {
return false;
}
if (name.equals("JSONField") && isJsonFieldSerializeDisabled(annotation)) {
return false;
}
}
return true;
}
/**
* 解析字段序列化后的 JSON 名称:优先 @JSONField(name=)/@JsonProperty(),否则用原字段名。
*/
public static String jsonName(FieldDeclaration field, String defaultName) {
for (AnnotationExpr annotation : field.getAnnotations()) {
String name = simpleName(annotation);
if (name.equals("JsonProperty")) {
String v = stringMember(annotation, "value");
if (v != null && !v.isEmpty()) {
return v;
}
}
if (name.equals("JSONField")) {
String v = stringMember(annotation, "name");
if (v != null && !v.isEmpty()) {
return v;
}
}
}
return defaultName;
}
/**
* 类级别 @JsonIgnoreProperties 声明的忽略字段名。
*/
public static Set<String> ignoredProperties(ClassOrInterfaceDeclaration type) {
Set<String> ignored = new HashSet<>();
for (AnnotationExpr annotation : type.getAnnotations()) {
if (!"JsonIgnoreProperties".equals(simpleName(annotation))) {
continue;
}
collectIgnoredNames(annotation, ignored);
}
return ignored;
}
private static boolean isIgnoreAnnotation(String name) {
return name.equals("JsonIgnore")
|| name.equals("Transient")
|| name.equals("JsonIgnoreType");
}
private static boolean isJsonFieldSerializeDisabled(AnnotationExpr annotation) {
if (annotation instanceof NormalAnnotationExpr) {
for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) {
if (pair.getNameAsString().equals("serialize")
&& isFalseLiteral(pair.getValue())) {
return true;
}
}
}
return false;
}
private static void collectIgnoredNames(AnnotationExpr annotation, Set<String> out) {
if (annotation instanceof SingleMemberAnnotationExpr) {
addStringArray(((SingleMemberAnnotationExpr) annotation).getMemberValue(), out);
return;
}
if (annotation instanceof NormalAnnotationExpr) {
for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) {
if ("value".equals(pair.getNameAsString())) {
addStringArray(pair.getValue(), out);
}
}
}
}
private static void addStringArray(Expression expr, Set<String> out) {
if (expr instanceof StringLiteralExpr) {
out.add(((StringLiteralExpr) expr).asString());
return;
}
if (expr instanceof ArrayInitializerExpr) {
for (Expression value : ((ArrayInitializerExpr) expr).getValues()) {
if (value instanceof StringLiteralExpr) {
out.add(((StringLiteralExpr) value).asString());
}
}
}
}
private static String stringMember(AnnotationExpr annotation, String member) {
if (annotation instanceof SingleMemberAnnotationExpr) {
Expression value = ((SingleMemberAnnotationExpr) annotation).getMemberValue();
if (value instanceof StringLiteralExpr) {
return ((StringLiteralExpr) value).asString();
}
}
if (annotation instanceof NormalAnnotationExpr) {
for (MemberValuePair pair : ((NormalAnnotationExpr) annotation).getPairs()) {
if (pair.getNameAsString().equals(member)
&& pair.getValue() instanceof StringLiteralExpr) {
return ((StringLiteralExpr) pair.getValue()).asString();
}
}
}
return null;
}
private static boolean isFalseLiteral(Expression expr) {
return expr instanceof BooleanLiteralExpr && !((BooleanLiteralExpr) expr).getValue();
}
private static String simpleName(AnnotationExpr annotation) {
String name = annotation.getNameAsString();
int dot = name.lastIndexOf('.');
return dot >= 0 ? name.substring(dot + 1) : name;
}
}

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.schema;
package com.codechecker.cache.schema;
import java.util.Objects;

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.schema;
package com.codechecker.cache.schema;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
@@ -79,6 +79,7 @@ public class JavaSchemaExtractor {
Set<String> nextAncestors = new LinkedHashSet<>(ancestors);
nextAncestors.add(type.getFqn());
Set<String> classIgnored = AnnotationSupport.ignoredProperties(type.getDeclaration());
for (FieldDeclaration field : collectFields(type, new HashSet<>())) {
if (field.isStatic() || field.isTransient()) {
continue;
@@ -88,6 +89,9 @@ public class JavaSchemaExtractor {
}
for (VariableDeclarator var : field.getVariables()) {
String jsonName = AnnotationSupport.jsonName(field, var.getNameAsString());
if (classIgnored.contains(jsonName) || classIgnored.contains(var.getNameAsString())) {
continue;
}
String path = prefix.isEmpty() ? jsonName : prefix + "." + jsonName;
expandType(var.getType(), path, type, schema, nextAncestors, depth);
}

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.schema;
package com.codechecker.cache.schema;
/**
* 序列化后 JSON 值的粗粒度类型

View File

@@ -0,0 +1,375 @@
package com.codechecker.cache.schema;
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} 还原为带占位符的序列化骨架 JSON。
* 超长时整段压缩/截断,但受保护(改动)字段路径对应片段不被截断。
*/
public class SkeletonJsonRenderer {
/** 单侧骨架默认最大长度(企微 markdown 总长约 4096。 */
public static final int DEFAULT_MAX_LEN = 1500;
public String render(TypeSchema schema) {
return render(schema, null, Integer.MAX_VALUE);
}
/**
* @param protectedPaths 改动相关字段路径(如 vo.dbName、expiresAtMs压缩时优先保留
* @param maxLen 输出最大字符数
*/
public String render(TypeSchema schema, Set<String> protectedPaths, int maxLen) {
if (schema == null || schema.isEmpty()) {
return "{}";
}
Set<String> protectedSet = protectedPaths == null
? new LinkedHashSet<>() : new LinkedHashSet<>(protectedPaths);
Node root = buildTree(schema);
String full = write(root, "", protectedSet, false);
if (full.length() <= maxLen) {
return full;
}
String compact = write(root, "", protectedSet, true);
if (compact.length() <= maxLen) {
return compact;
}
return truncatePreserve(compact, protectedSet, maxLen);
}
private Node buildTree(TypeSchema schema) {
Node root = new Node(JsonType.OBJECT);
for (FieldSchema field : schema.getFields().values()) {
putPath(root, field.getPath(), field.getJsonType());
}
// 根数组:字段以 [] / [].xxx 记录
if (root.children.size() == 1 && root.children.containsKey("[]")) {
Node arr = new Node(JsonType.ARRAY);
arr.children.put("[]", root.children.get("[]"));
return arr;
}
return root;
}
private void putPath(Node root, String path, JsonType type) {
List<Seg> segs = parsePath(path);
if (segs.isEmpty()) {
return;
}
Node cur = root;
for (int i = 0; i < segs.size(); i++) {
Seg seg = segs.get(i);
boolean last = i == segs.size() - 1;
if (seg.array) {
Node arr = cur.children.computeIfAbsent(seg.name, k -> new Node(JsonType.ARRAY));
arr.type = JsonType.ARRAY;
Node elem = arr.children.computeIfAbsent("[]", k -> new Node(JsonType.OBJECT));
if (last) {
if (type == JsonType.OBJECT || type == JsonType.ARRAY || type == JsonType.MAP) {
elem.type = type;
} else {
elem.type = type;
elem.leaf = true;
}
}
cur = elem;
} else {
Node child = cur.children.computeIfAbsent(seg.name,
k -> new Node(last ? type : JsonType.OBJECT));
if (last) {
child.type = type;
child.leaf = type != JsonType.OBJECT && type != JsonType.ARRAY && type != JsonType.MAP;
} else if (child.type != JsonType.ARRAY) {
child.type = JsonType.OBJECT;
}
cur = child;
}
}
}
private List<Seg> parsePath(String path) {
List<Seg> segs = new ArrayList<>();
if (path == null || path.isEmpty()) {
return segs;
}
for (String raw : path.split("\\.")) {
if (raw.isEmpty()) {
continue;
}
if ("[]".equals(raw)) {
segs.add(new Seg("[]", false));
} else if (raw.endsWith("[]")) {
segs.add(new Seg(raw.substring(0, raw.length() - 2), true));
} else {
segs.add(new Seg(raw, false));
}
}
return segs;
}
private String write(Node node, String pathPrefix, Set<String> protectedPaths, boolean compact) {
if (node == null) {
return "null";
}
if (node.type == JsonType.ARRAY) {
Node elem = node.children.get("[]");
if (elem == null) {
return "[]";
}
String elemPath = pathPrefix.isEmpty() ? "[]" : pathPrefix + "[]";
if (compact && !isProtectedUnder(pathPrefix, protectedPaths)
&& !isProtectedUnder(elemPath, protectedPaths)) {
return "[...]";
}
return "[" + write(elem, elemPath, protectedPaths, compact) + "]";
}
if (node.leaf || isScalar(node.type)) {
return placeholder(node.type);
}
if (node.type == JsonType.MAP) {
return "{}";
}
StringBuilder sb = new StringBuilder();
sb.append('{');
boolean first = true;
for (Map.Entry<String, Node> e : node.children.entrySet()) {
String name = e.getKey();
if (name == null || name.isEmpty()) {
continue;
}
Node child = e.getValue();
String childPath = pathPrefix.isEmpty() ? name : pathPrefix + "." + name;
// 根数组占位名 [] 不作为 JSON key 输出(由上层 ARRAY 处理)
if ("[]".equals(name) && pathPrefix.isEmpty()) {
continue;
}
if (!first) {
sb.append(',');
}
first = false;
sb.append('"').append(escape(name)).append("\":");
if (compact && !isProtectedUnder(childPath, protectedPaths) && child.type != JsonType.ARRAY) {
sb.append(collapsedValue(child));
} else if (child.type == JsonType.ARRAY) {
sb.append(write(child, childPath, protectedPaths, compact));
} else if (child.leaf || isScalar(child.type)) {
sb.append(placeholder(child.type));
} else {
sb.append(write(child, childPath, protectedPaths, compact));
}
}
sb.append('}');
return sb.toString();
}
private String collapsedValue(Node child) {
if (child.type == JsonType.ARRAY) {
return "[...]";
}
if (child.type == JsonType.OBJECT || child.type == JsonType.MAP) {
return "\"...\"";
}
return placeholder(child.type);
}
private boolean isProtectedUnder(String pathPrefix, Set<String> protectedPaths) {
if (protectedPaths == null || protectedPaths.isEmpty() || pathPrefix == null) {
return false;
}
for (String p : protectedPaths) {
if (p == null || p.isEmpty()) {
continue;
}
if (p.equals(pathPrefix)) {
return true;
}
if (pathPrefix.isEmpty()) {
continue;
}
if (p.startsWith(pathPrefix + ".") || p.startsWith(pathPrefix + "[")) {
return true;
}
}
return false;
}
private String truncatePreserve(String json, Set<String> protectedPaths, int maxLen) {
List<String> fragments = new ArrayList<>();
for (String path : protectedPaths) {
String key = lastSegment(path);
if (key.isEmpty() || "[]".equals(key)) {
continue;
}
String needle = "\"" + key + "\"";
int idx = json.indexOf(needle);
if (idx < 0) {
continue;
}
int end = findValueEnd(json, idx + needle.length());
String frag = json.substring(idx, Math.min(json.length(), end));
if (!fragments.contains(frag)) {
fragments.add(frag);
}
}
StringBuilder kept = new StringBuilder();
for (String f : fragments) {
if (kept.length() > 0 && kept.length() + f.length() + 1 > maxLen) {
// 不截断改动字段:装不下则整段保留已收集的改动片段
break;
}
if (kept.length() > 0) {
kept.append(',');
}
kept.append(f);
// 单条改动字段允许超过 maxLen不可截断
if (fragments.size() == 1 && kept.length() > maxLen) {
return kept.toString();
}
}
String focus = kept.toString();
if (focus.length() >= maxLen) {
return focus;
}
int markerLen = 20;
int budget = maxLen - focus.length() - (focus.isEmpty() ? 0 : markerLen);
if (budget < 8) {
return focus.isEmpty()
? json.substring(0, Math.min(maxLen, json.length()))
: "{...(truncated)," + focus + "}";
}
String head = json.substring(0, Math.min(budget, json.length()));
if (focus.isEmpty()) {
return head + (json.length() > head.length() ? "..." : "");
}
return head + "...[改动字段]..." + focus;
}
private int findValueEnd(String json, int afterKey) {
int i = afterKey;
while (i < json.length() && (json.charAt(i) == ':' || Character.isWhitespace(json.charAt(i)))) {
i++;
}
if (i >= json.length()) {
return json.length();
}
char c = json.charAt(i);
if (c == '"') {
i++;
while (i < json.length()) {
char ch = json.charAt(i++);
if (ch == '\\' && i < json.length()) {
i++;
} else if (ch == '"') {
break;
}
}
return i;
}
if (c == '{' || c == '[') {
int depth = 0;
for (; i < json.length(); i++) {
char ch = json.charAt(i);
if (ch == '{' || ch == '[') {
depth++;
} else if (ch == '}' || ch == ']') {
depth--;
if (depth == 0) {
return i + 1;
}
} else if (ch == '"') {
i++;
while (i < json.length()) {
char x = json.charAt(i++);
if (x == '\\' && i < json.length()) {
i++;
} else if (x == '"') {
break;
}
}
i--;
}
}
return json.length();
}
while (i < json.length() && json.charAt(i) != ',' && json.charAt(i) != '}' && json.charAt(i) != ']') {
i++;
}
return i;
}
private String lastSegment(String path) {
if (path == null || path.isEmpty()) {
return "";
}
String p = path;
if (p.endsWith("[]")) {
p = p.substring(0, p.length() - 2);
}
int dot = p.lastIndexOf('.');
String seg = dot >= 0 ? p.substring(dot + 1) : p;
if (seg.endsWith("[]")) {
seg = seg.substring(0, seg.length() - 2);
}
return seg.replace("[]", "");
}
private boolean isScalar(JsonType type) {
return type == JsonType.STRING || type == JsonType.NUMBER
|| type == JsonType.BOOLEAN || type == JsonType.UNKNOWN;
}
private String placeholder(JsonType type) {
if (type == null) {
return "null";
}
switch (type) {
case NUMBER:
return "0";
case BOOLEAN:
return "false";
case STRING:
return "\"\"";
case MAP:
case OBJECT:
return "{}";
case ARRAY:
return "[]";
case UNKNOWN:
default:
return "null";
}
}
private String escape(String s) {
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static final class Seg {
final String name;
final boolean array;
Seg(String name, boolean array) {
this.name = name;
this.array = array;
}
}
private static final class Node {
JsonType type;
boolean leaf;
final Map<String, Node> children = new LinkedHashMap<>();
Node(JsonType type) {
this.type = type;
}
}
}

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.schema;
package com.codechecker.cache.schema;
import com.github.javaparser.ParserConfiguration;
import com.github.javaparser.StaticJavaParser;
@@ -8,9 +8,10 @@ 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.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 一次提交快照下的源码类型索引仅索引本仓库源码不含依赖 jar供类型解析与字段展开使用
@@ -21,9 +22,9 @@ import java.util.Map;
public class SourceIndex {
/** FQN以 . 分隔,含内部类) -> 类型信息 */
private final Map<String, IndexedType> byFqn = new LinkedHashMap<>();
private final Map<String, IndexedType> byFqn = new ConcurrentHashMap<>();
/** 简单类名 -> FQN 列表(兜底解析) */
private final Map<String, List<String>> bySimpleName = new LinkedHashMap<>();
private final Map<String, List<String>> bySimpleName = new ConcurrentHashMap<>();
static {
ParserConfiguration config = new ParserConfiguration()
@@ -31,6 +32,20 @@ public class SourceIndex {
StaticJavaParser.setConfiguration(config);
}
/**
* 批量解析源文件并建立索引
* <p>文件读取可在 {@link com.codechecker.cache.analyze.FileScanner} 中并行
* AST 解析使用 StaticJavaParser 串行写入避免其全局配置的线程安全问题</p>
*/
public void addSources(Collection<String> contents) {
if (contents == null || contents.isEmpty()) {
return;
}
for (String content : contents) {
addSource(content);
}
}
/**
* 解析并加入一个 Java 源文件内容解析失败时静默跳过返回 false
*/
@@ -65,7 +80,6 @@ public class SourceIndex {
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);

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.schema;
package com.codechecker.cache.schema;
import java.util.LinkedHashMap;
import java.util.Map;

View File

@@ -1,14 +1,11 @@
# redis-schema-checker 内置默认配置
# cache-schema-checker 内置默认配置
# 业务仓库通过 --config 指定的配置会与本文件深度合并(业务配置优先)。
# 运行模式notify仅通知| block按 block_severities 阻断exit 1
mode: notify
# 总开关false 时不执行检测、不发通知、流水线直接通过
enabled: true
# block 模式下触发阻断的严重级别(默认全部阻断
block_severities:
- P0
- P1
- P2
# 运行模式notify仅通知| block检测到结构变更即阻断exit 1
mode: notify
# 是否扫描测试代码(第一版固定 false
scan_test_sources: false
@@ -20,9 +17,9 @@ source_roots:
# 通知配置
notify:
enabled: true
webhook_env: "WECOM_ROBOT_WEBHOOK"
webhook_url: ""
notify_on_clean: false
title_prefix: "[Redis结构变更]"
title_prefix: "[缓存结构变更]"
# 忽略规则
ignore:
@@ -45,7 +42,11 @@ detection:
- 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 提示
- W04 # redisTemplate.opsForValue().set(key, obj, ...)
- W05 # redisTemplate.opsForHash().put(key, field, obj)
# W06 读侧辅助:用 parseObject / getJsonToBean 等补强写入点 value 类型(非写入模式)
read_hints_enabled: true
# 类型推断最低置信度,低于此值标记为低置信度提示
min_confidence: 0.6
# 字段展开最大深度(防止循环引用)
max_field_depth: 8

View File

@@ -1,13 +1,13 @@
package com.codechecker.redis;
package com.codechecker.cache;
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 com.codechecker.cache.detector.RedisWritePointDetector;
import com.codechecker.cache.detector.WritePoint;
import com.codechecker.cache.diff.ChangeType;
import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.SchemaDiffer;
import com.codechecker.cache.schema.JavaSchemaExtractor;
import com.codechecker.cache.schema.SourceIndex;
import com.codechecker.cache.schema.TypeSchema;
import org.junit.jupiter.api.Test;
import java.util.HashSet;

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis;
package com.codechecker.cache;
import java.io.IOException;
import java.io.InputStream;

View File

@@ -1,4 +1,4 @@
package com.codechecker.redis.analyze;
package com.codechecker.cache.analyze;
import org.junit.jupiter.api.Test;

View File

@@ -0,0 +1,67 @@
package com.codechecker.cache.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);
assertTrue(config.isEnabled());
assertEquals("notify", config.getMode());
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, ("enabled: false\n"
+ "mode: block\n"
+ "include_modules:\n - jnpf-tenant\n"
+ "notify:\n enabled: false\n").getBytes(StandardCharsets.UTF_8));
CheckerConfig config = ConfigLoader.load(cfg);
assertFalse(config.isEnabled());
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.getDetection().getPatterns().contains("W01"));
}
@Test
void loadsWebhookUrlFromConfig(@org.junit.jupiter.api.io.TempDir Path tmp) throws IOException {
Path cfg = tmp.resolve("biz.yaml");
Files.write(cfg, ("notify:\n"
+ " webhook_url: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test\n"
).getBytes(StandardCharsets.UTF_8));
CheckerConfig config = ConfigLoader.load(cfg);
assertEquals("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
config.getNotify().getWebhookUrl());
}
@Test
void legacyWebhookEnvUrlStillWorks(@org.junit.jupiter.api.io.TempDir Path tmp) throws IOException {
Path cfg = tmp.resolve("biz.yaml");
Files.write(cfg, ("notify:\n"
+ " webhook_env: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=legacy\n"
).getBytes(StandardCharsets.UTF_8));
CheckerConfig config = ConfigLoader.load(cfg);
assertEquals("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=legacy",
config.getNotify().getWebhookUrl());
}
}

View File

@@ -0,0 +1,71 @@
package com.codechecker.cache.detector;
import com.codechecker.cache.schema.SourceIndex;
import org.junit.jupiter.api.Test;
import java.util.List;
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 CacheReadHintDetectorTest {
@Test
void detectsParseObjectWithRedisGetPairing() {
String vo = ""
+ "package demo.model;\n"
+ "public class FooVo { private String name; }\n";
String source = ""
+ "package demo;\n"
+ "import demo.model.FooVo;\n"
+ "public class CacheService {\n"
+ " private RedisUtil redisUtil;\n"
+ " private static final String KEY = \"demo:foo:\";\n"
+ " public FooVo load(String id) {\n"
+ " String raw = redisUtil.getString(KEY + id);\n"
+ " return JSON.parseObject(raw, FooVo.class);\n"
+ " }\n"
+ " public void save(String id, FooVo vo) {\n"
+ " redisUtil.insert(KEY + id, JSON.toJSONString(vo), 60);\n"
+ " }\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(vo);
index.addSource(source);
List<CacheReadHint> hints = new CacheReadHintDetector(index)
.detect("CacheService.java", source);
assertEquals(1, hints.size());
CacheReadHint hint = hints.get(0);
assertEquals("demo.model.FooVo", hint.getResolvedValueType());
assertFalse(hint.isRootArray());
assertEquals("demo:foo:*", hint.getResolvedKeyPattern());
}
@Test
void detectsParseArray() {
String vo = ""
+ "package demo.model;\n"
+ "public class ItemVo { private int rank; }\n";
String source = ""
+ "package demo;\n"
+ "import demo.model.ItemVo;\n"
+ "public class ListCache {\n"
+ " public List<ItemVo> load(String raw) {\n"
+ " return JSON.parseArray(raw, ItemVo.class);\n"
+ " }\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(vo);
index.addSource(source);
List<CacheReadHint> hints = new CacheReadHintDetector(index)
.detect("ListCache.java", source);
assertEquals(1, hints.size());
assertTrue(hints.get(0).isRootArray());
assertEquals("demo.model.ItemVo", hints.get(0).getResolvedValueType());
}
}

View File

@@ -0,0 +1,101 @@
package com.codechecker.cache.detector;
import com.codechecker.cache.TestSupport;
import com.codechecker.cache.schema.SourceIndex;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RedisWritePointDetectorTest {
@Test
void detectsW04DirectObjectWrite() {
String source = ""
+ "package demo;\n"
+ "import demo.model.DemoVo;\n"
+ "public class DemoService {\n"
+ " private RedisTemplate<String, DemoVo> redisTemplate;\n"
+ " public void save(String key, DemoVo vo) {\n"
+ " redisTemplate.opsForValue().set(key, vo, 60, TimeUnit.SECONDS);\n"
+ " }\n"
+ "}\n";
String vo = ""
+ "package demo.model;\n"
+ "public class DemoVo { private String name; }\n";
SourceIndex index = new SourceIndex();
index.addSource(vo);
index.addSource(source);
Set<String> patterns = new HashSet<>(Arrays.asList("W04"));
List<WritePoint> wps = new RedisWritePointDetector(index, patterns)
.detect("DemoService.java", source);
assertEquals(1, wps.size());
WritePoint wp = wps.get(0);
assertEquals("W04", wp.getPattern());
assertEquals("demo.model.DemoVo", wp.getResolvedValueType());
}
@Test
void detectsW05HashPut() {
String source = ""
+ "package demo;\n"
+ "import demo.model.DemoVo;\n"
+ "public class DemoService {\n"
+ " private RedisTemplate<String, Object> redisTemplate;\n"
+ " public void save(String key, String field, DemoVo vo) {\n"
+ " redisTemplate.opsForHash().put(key, field, vo);\n"
+ " }\n"
+ "}\n";
String vo = ""
+ "package demo.model;\n"
+ "public class DemoVo { private String name; }\n";
SourceIndex index = new SourceIndex();
index.addSource(vo);
index.addSource(source);
Set<String> patterns = new HashSet<>(Arrays.asList("W05"));
List<WritePoint> wps = new RedisWritePointDetector(index, patterns)
.detect("DemoService.java", source);
assertEquals(1, wps.size());
assertEquals("W05", wps.get(0).getPattern());
assertEquals("demo.model.DemoVo", wps.get(0).getResolvedValueType());
}
@Test
void ignoresLockAndLiteralWrites() {
String source = TestSupport.fixture("fixtures/lock/LockService.txt");
Set<String> patterns = new HashSet<>(Arrays.asList("W01", "W02", "W03", "W04", "W05"));
List<WritePoint> wps = new RedisWritePointDetector(new SourceIndex(), patterns)
.detect("LockService.java", source);
assertTrue(wps.isEmpty(), "锁/计数器/token 写入应被忽略");
}
@Test
void stillDetectsW01ToW03() {
String helper = TestSupport.fixture("fixtures/tenant/HelperOld.txt");
String tenantVo = TestSupport.fixture("fixtures/tenant/TenantVO.txt");
String tenantLink = TestSupport.fixture("fixtures/tenant/TenantLinkModel.txt");
SourceIndex index = new SourceIndex();
index.addSource(tenantVo);
index.addSource(tenantLink);
index.addSource(helper);
Set<String> patterns = new HashSet<>(Arrays.asList("W01", "W02", "W03"));
List<WritePoint> wps = new RedisWritePointDetector(index, patterns)
.detect("Helper.java", helper);
assertEquals(1, wps.size());
assertEquals("W01", wps.get(0).getPattern());
}
}

View File

@@ -1,8 +1,8 @@
package com.codechecker.redis.diff;
package com.codechecker.cache.diff;
import com.codechecker.redis.schema.FieldSchema;
import com.codechecker.redis.schema.JsonType;
import com.codechecker.redis.schema.TypeSchema;
import com.codechecker.cache.schema.FieldSchema;
import com.codechecker.cache.schema.JsonType;
import com.codechecker.cache.schema.TypeSchema;
import org.junit.jupiter.api.Test;
import java.util.List;

View File

@@ -0,0 +1,68 @@
package com.codechecker.cache.key;
import com.codechecker.cache.schema.SourceIndex;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.MethodCallExpr;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class RedisKeyResolverTest {
@Test
void resolvesStringFormatWithConstant() {
String source = ""
+ "package demo;\n"
+ "public class AttendanceService {\n"
+ " private static final String ATTENDANCE_BASE_SETTING_CACHE_KEY = "
+ "\"fbt:attendance:base_setting:cache:%s\";\n"
+ " public void save(String tenantId) {\n"
+ " String key = String.format(ATTENDANCE_BASE_SETTING_CACHE_KEY, tenantId);\n"
+ " }\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
CompilationUnit cu = StaticJavaParser.parse(source);
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
MethodCallExpr formatCall = cu.findAll(MethodCallExpr.class).stream()
.filter(m -> "format".equals(m.getNameAsString()))
.findFirst()
.orElseThrow(IllegalStateException::new);
String pattern = new RedisKeyResolver(index).resolve(
formatCall, clazz, index.get("demo.AttendanceService"));
assertEquals("fbt:attendance:base_setting:cache:*", pattern);
}
@Test
void resolvesConstantConcatAndBuildMethod() {
String source = ""
+ "package jnpf.util;\n"
+ "public class TenantDbContentCacheHelper {\n"
+ " private static final String CACHE_KEY_PREFIX = \"tenant:db:content:\";\n"
+ " public String buildCacheKey(String encode) {\n"
+ " return CACHE_KEY_PREFIX + encode;\n"
+ " }\n"
+ " public void cache(String encode) {\n"
+ " String key = buildCacheKey(encode);\n"
+ " }\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
CompilationUnit cu = StaticJavaParser.parse(source);
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
MethodCallExpr buildCall = cu.findAll(MethodCallExpr.class).stream()
.filter(m -> "buildCacheKey".equals(m.getNameAsString()))
.findFirst()
.orElseThrow(IllegalStateException::new);
String pattern = new RedisKeyResolver(index).resolve(
buildCall, clazz, index.get("jnpf.util.TenantDbContentCacheHelper"));
assertEquals("tenant:db:content:*", pattern);
}
}

View File

@@ -0,0 +1,256 @@
package com.codechecker.cache.report;
import com.codechecker.cache.diff.ChangeType;
import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
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 ReportBuilderTest {
@Test
void onlyAddedFieldsHighlightedInNewSkeletonNotWholeJson() {
CheckReport report = new CheckReport();
report.setRepository("jnpf-java-cloud");
report.setBranch("code/redis_change_detection_v1.0");
report.setOldSha("cedd161c");
report.setNewSha("67c8a6eb");
report.setModifier("dongzi");
report.setModifyTime("2026-07-13 16:54:17");
SchemaChange addedMsg = new SchemaChange(ChangeType.FIELD_ADDED);
addedMsg.setSeverity(Severity.P1);
addedMsg.setKeyPattern("saas:period-config:migration:current");
addedMsg.setFieldPath("message");
addedMsg.setMessage("新增字段 message");
SchemaChange addedErr = new SchemaChange(ChangeType.FIELD_ADDED);
addedErr.setSeverity(Severity.P1);
addedErr.setKeyPattern("saas:period-config:migration:current");
addedErr.setFieldPath("lastError");
addedErr.setMessage("新增字段 lastError");
report.getChanges().add(addedMsg);
report.getChanges().add(addedErr);
String oldJson = "{\"taskId\":\"\",\"status\":\"\",\"progress\":{\"totalTenants\":0}}";
String newJson = "{\"taskId\":\"\",\"status\":\"\",\"progress\":{\"totalTenants\":0},"
+ "\"message\":\"\",\"lastError\":\"\"}";
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("saas:period-config:migration:current");
key.setWriteLocation("SaasPeriodConfigMigrationRedisSupport#putCurrent:41");
key.setValueType("MigrationCurrentVo");
key.setOldSkeletonJson(oldJson);
key.setNewSkeletonJson(newJson);
key.setSeverity(Severity.P1);
key.getFieldDetails().add(addedMsg);
key.getFieldDetails().add(addedErr);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report);
assertTrue(md.contains("- Key --> `saas:period-config:migration:current`"));
assertFalse(md.contains("key 无法解析)"));
assertTrue(md.contains("> **位置**: `SaasPeriodConfigMigrationRedisSupport#putCurrent:41`"));
assertTrue(md.contains("> **类型**: `MigrationCurrentVo`"));
int oldSection = md.indexOf("> **value值由**");
int newSection = md.indexOf("> **变更为:**");
assertTrue(oldSection >= 0, "应包含「value值由」段落实际 markdown:\n" + md);
assertTrue(newSection > oldSection, "应包含「变更为」段落且在旧值之后,实际 markdown:\n" + md);
String oldPart = md.substring(oldSection, newSection);
String newPart = md.substring(newSection);
// 旧骨架:无染色,且不能整段被反引号包裹
assertFalse(oldPart.contains("<font color=\"warning\">"));
assertFalse(oldPart.contains("<font color=\"info\">"));
assertFalse(oldPart.contains("`" + oldJson + "`"));
assertTrue(oldPart.contains(oldJson));
// 新骨架:新增字段绿色 info其余明文
assertTrue(newPart.contains("<font color=\"info\">\"message\":\"\"</font>"));
assertTrue(newPart.contains("<font color=\"info\">\"lastError\":\"\"</font>"));
assertTrue(newPart.contains("\"taskId\":\"\""));
assertFalse(newPart.contains("<font color=\"info\">\"taskId\":\"\"</font>"));
assertFalse(newPart.contains("<font color=\"warning\">"));
// 禁止整段代码块
assertFalse(newPart.contains("`" + newJson));
assertFalse(md.contains("### P0"));
}
@Test
void removedFieldsHighlightedOrangeInOldSkeleton() {
SchemaChange removed = new SchemaChange(ChangeType.FIELD_REMOVED);
removed.setFieldPath("lastError");
removed.setMessage("删除字段 lastError");
String oldJson = "{\"taskId\":\"\",\"lastError\":\"\"}";
String newJson = "{\"taskId\":\"\"}";
Set<String> oldPaths = SkeletonAnnotator.pathsForOldSkeleton(Collections.singletonList(removed));
Set<String> newPaths = SkeletonAnnotator.pathsForNewSkeleton(Collections.singletonList(removed));
assertEquals(Collections.singleton("lastError"), oldPaths);
assertTrue(newPaths.isEmpty());
String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldPaths);
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newPaths);
assertTrue(oldMd.contains("<font color=\"warning\">\"lastError\":\"\"</font>"));
assertFalse(oldMd.contains("<font color=\"warning\">\"taskId\":\"\"</font>"));
assertFalse(oldMd.contains("<font color=\"info\">"));
assertEquals(newJson, newMd);
assertFalse(newMd.startsWith("`"));
}
@Test
void consoleContainsFieldDetailsAndWecomMarkdown() {
CheckReport report = new CheckReport();
report.setRepository("demo");
report.setOldSha("aaa");
report.setNewSha("bbb");
SchemaChange detail = new SchemaChange(ChangeType.FIELD_REMOVED);
detail.setSeverity(Severity.P0);
detail.setKeyPattern("k1");
detail.setWriteLocation("Foo#bar:1");
detail.setFieldPath("x");
detail.setMessage("删除字段 x");
report.getChanges().add(detail);
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("k1");
key.setOldSkeletonJson("{\"x\":\"\"}");
key.setNewSkeletonJson("{}");
key.getFieldDetails().add(detail);
report.getKeyChanges().add(key);
String console = new ReportBuilder("[缓存结构变更]").toConsole(report);
assertTrue(console.contains("======== 字段明细 ========"));
assertTrue(console.contains("**删除字段**: x"));
assertTrue(console.contains("<font color=\"warning\">\"x\":\"\"</font>"));
assertFalse(console.contains("<font color=\"info\">\"x\":\"\"</font>"));
assertTrue(console.contains("> **位置**: `"));
assertTrue(console.contains("> **类型**: `"));
assertTrue(console.contains("> **value值由**"));
assertTrue(console.contains("> **变更为:**"));
assertFalse(console.contains("`{\"x\":\"\"}`"));
}
@Test
void unresolvedKeyShowsExpressionAndCommentHint() {
CheckReport report = baseReport();
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("unknown-key");
key.setKeyExpression("req.getKey()");
key.setKeyUnresolved(true);
key.setWriteLocation("ClockInXxxService#export:128");
key.setValueType("List<ClockInExportVo>");
key.setOldSkeletonJson("{\"a\":\"\"}");
key.setNewSkeletonJson("{\"a\":\"\",\"b\":\"\"}");
SchemaChange added = new SchemaChange(ChangeType.FIELD_ADDED);
added.setFieldPath("b");
key.getFieldDetails().add(added);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report);
assertTrue(md.contains("- Key --> `req.getKey()` <font color=\"comment\">key 无法解析)</font>"));
assertTrue(md.contains("> **位置**: `ClockInXxxService#export:128`"));
assertTrue(md.contains("> **类型**: `List<ClockInExportVo>`"));
assertFalse(md.contains("`unknown-key`"));
}
@Test
void pathAwareNestedHighlight() {
String json = "{\"vo\":{\"dbName\":\"\",\"id\":\"\"},\"expiresAtMs\":0}";
Set<String> paths = new LinkedHashSet<>();
paths.add("vo.dbName");
String out = SkeletonAnnotator.annotate(json, paths, SkeletonAnnotator.COLOR_ADD);
assertTrue(out.contains("<font color=\"info\">\"dbName\":\"\"</font>"));
assertFalse(out.contains("<font color=\"info\">\"id\":\"\"</font>"));
assertFalse(out.contains("<font color=\"warning\">\"dbName\":\"\"</font>"));
}
@Test
void multipleSmallKeysStayInOneWeComMessage() {
CheckReport report = baseReport();
report.getKeyChanges().add(simpleKey("k-a", "{\"a\":\"\"}", "{\"a\":\"\",\"x\":\"\"}", "x"));
report.getKeyChanges().add(simpleKey("k-b", "{\"b\":\"\"}", "{\"b\":\"\",\"y\":\"\"}", "y"));
List<String> messages = new ReportBuilder("[缓存结构变更]").toWeComMessages(report);
assertEquals(1, messages.size());
assertTrue(messages.get(0).contains("k-a"));
assertTrue(messages.get(0).contains("k-b"));
assertTrue(ReportBuilder.utf8Bytes(messages.get(0)) <= ReportBuilder.WECOM_MARKDOWN_MAX_BYTES);
}
@Test
void oversizedMultiKeysSplitOneMessagePerKey() {
CheckReport report = baseReport();
// 两侧骨架各约 1.8KB,两条拼在一起超 4096单条仍应落在上限内
String fat = fatJson(1800);
report.getKeyChanges().add(simpleKey("fat-key-1", fat, fat + "1", null));
report.getKeyChanges().add(simpleKey("fat-key-2", fat, fat + "2", null));
List<String> messages = new ReportBuilder("[缓存结构变更]").toWeComMessages(report);
assertEquals(2, messages.size(), "超长应按 key 拆成 2 条");
assertTrue(messages.get(0).contains("fat-key-1"));
assertFalse(messages.get(0).contains("fat-key-2"));
assertTrue(messages.get(1).contains("fat-key-2"));
assertFalse(messages.get(1).contains("fat-key-1"));
assertTrue(ReportBuilder.utf8Bytes(messages.get(0)) <= ReportBuilder.WECOM_MARKDOWN_MAX_BYTES);
assertTrue(ReportBuilder.utf8Bytes(messages.get(1)) <= ReportBuilder.WECOM_MARKDOWN_MAX_BYTES);
// 抬头在每条中重复
assertTrue(messages.get(0).contains("## [缓存结构变更] jnpf-java-cloud"));
assertTrue(messages.get(1).contains("## [缓存结构变更] jnpf-java-cloud"));
String console = new ReportBuilder("[缓存结构变更]").toConsole(report);
assertTrue(console.contains("超长已按 key 拆为 2 条"));
}
private static CheckReport baseReport() {
CheckReport report = new CheckReport();
report.setRepository("jnpf-java-cloud");
report.setBranch("feature/x");
report.setOldSha("aaa11111");
report.setNewSha("bbb22222");
report.setModifier("dongzi");
report.setModifyTime("2026-07-14 12:00:00");
return report;
}
private static KeyStructureChange simpleKey(String pattern, String oldJson, String newJson,
String addedField) {
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern(pattern);
key.setWriteLocation("DemoService#write:1");
key.setValueType("DemoVo");
key.setOldSkeletonJson(oldJson);
key.setNewSkeletonJson(newJson);
if (addedField != null) {
SchemaChange added = new SchemaChange(ChangeType.FIELD_ADDED);
added.setFieldPath(addedField);
added.setKeyPattern(pattern);
key.getFieldDetails().add(added);
// 保证 hasChanges / 控制台字段明细有数据
}
return key;
}
private static String fatJson(int minChars) {
StringBuilder sb = new StringBuilder("{\"pad\":\"");
while (sb.length() < minChars) {
sb.append('x');
}
sb.append("\"}");
return sb.toString();
}
}

View File

@@ -0,0 +1,84 @@
package com.codechecker.cache.schema;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
import org.junit.jupiter.api.Test;
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 JavaSchemaExtractorTest {
@Test
void honorsJsonIgnoreAndPropertyRename() {
String source = ""
+ "package demo;\n"
+ "import com.fasterxml.jackson.annotation.JsonIgnore;\n"
+ "import com.fasterxml.jackson.annotation.JsonProperty;\n"
+ "public class AnnotatedVo {\n"
+ " @JsonProperty(\"display_name\")\n"
+ " private String name;\n"
+ " @JsonIgnore\n"
+ " private String secret;\n"
+ " private String visible;\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
TypeSchema schema = new JavaSchemaExtractor(index, 8).extract("demo.AnnotatedVo");
assertTrue(schema.getFields().containsKey("display_name"));
assertTrue(schema.getFields().containsKey("visible"));
assertFalse(schema.getFields().containsKey("secret"));
assertFalse(schema.getFields().containsKey("name"));
}
@Test
void honorsFastjsonFieldAndClassIgnoreProperties() {
String source = ""
+ "package demo;\n"
+ "import com.alibaba.fastjson.annotation.JSONField;\n"
+ "import com.fasterxml.jackson.annotation.JsonIgnoreProperties;\n"
+ "@JsonIgnoreProperties({\"password\"})\n"
+ "public class FastVo {\n"
+ " @JSONField(name = \"user_id\")\n"
+ " private String userId;\n"
+ " @JSONField(serialize = false)\n"
+ " private String token;\n"
+ " private String password;\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(source);
TypeSchema schema = new JavaSchemaExtractor(index, 8).extract("demo.FastVo");
assertTrue(schema.getFields().containsKey("user_id"));
assertFalse(schema.getFields().containsKey("token"));
assertFalse(schema.getFields().containsKey("password"));
}
@Test
void jsonNameHelpersWorkOnFieldAnnotations() {
CompilationUnit cu = StaticJavaParser.parse(""
+ "class X {\n"
+ " @com.fasterxml.jackson.annotation.JsonProperty(\"alias\")\n"
+ " private String field;\n"
+ "}");
FieldDeclaration field = cu.getType(0).asClassOrInterfaceDeclaration().getFields().get(0);
assertEquals("alias", AnnotationSupport.jsonName(field, "field"));
assertTrue(AnnotationSupport.isSerialized(field));
}
@Test
void classIgnorePropertiesCollected() {
CompilationUnit cu = StaticJavaParser.parse(""
+ "@com.fasterxml.jackson.annotation.JsonIgnoreProperties({\"a\", \"b\"})\n"
+ "class X {}");
ClassOrInterfaceDeclaration type = cu.getType(0).asClassOrInterfaceDeclaration();
assertTrue(AnnotationSupport.ignoredProperties(type).contains("a"));
assertTrue(AnnotationSupport.ignoredProperties(type).contains("b"));
}
}

View File

@@ -0,0 +1,83 @@
package com.codechecker.cache.schema;
import com.codechecker.cache.TestSupport;
import com.codechecker.cache.detector.RedisWritePointDetector;
import com.codechecker.cache.detector.WritePoint;
import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.SchemaDiffer;
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.assertTrue;
class SkeletonJsonRendererTest {
@Test
void rendersTenantVoThenEnvelopeSkeletons() {
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");
Set<String> patterns = new HashSet<>();
patterns.add("W01");
patterns.add("W02");
patterns.add("W03");
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 = new RedisWritePointDetector(oldIndex, patterns)
.detect("Helper.java", helperOld).get(0);
WritePoint newWp = new RedisWritePointDetector(newIndex, patterns)
.detect("Helper.java", helperNew).get(0);
TypeSchema oldSchema = new JavaSchemaExtractor(oldIndex, 8)
.extract(oldWp.getResolvedValueType(), oldWp.isRootArray());
TypeSchema newSchema = new JavaSchemaExtractor(newIndex, 8)
.extract(newWp.getResolvedValueType(), newWp.isRootArray());
List<SchemaChange> changes = new SchemaDiffer().diff(oldSchema, newSchema);
Set<String> protectedPaths = changes.stream()
.map(SchemaChange::getFieldPath)
.filter(p -> p != null && !p.isEmpty())
.collect(Collectors.toCollection(HashSet::new));
changes.stream()
.filter(c -> c.getOldValue() != null && c.getOldValue().contains("."))
.forEach(c -> protectedPaths.add(c.getOldValue()));
// also plain old paths without dot
changes.forEach(c -> {
if (c.getOldValue() != null && !c.getOldValue().isEmpty()
&& c.getOldValue().indexOf(' ') < 0) {
protectedPaths.add(c.getOldValue());
}
});
SkeletonJsonRenderer renderer = new SkeletonJsonRenderer();
String oldJson = renderer.render(oldSchema);
String newJson = renderer.render(newSchema);
assertTrue(oldJson.contains("\"dbName\":\"\""));
assertTrue(oldJson.contains("\"linkList\":["));
assertTrue(newJson.contains("\"vo\":{"));
assertTrue(newJson.contains("\"expiresAtMs\":0"));
assertTrue(newJson.contains("\"dbName\":\"\""));
String truncated = renderer.render(newSchema, protectedPaths, 80);
assertTrue(truncated.contains("expiresAtMs") || truncated.contains("vo"),
"截断后仍应保留改动相关字段: " + truncated);
assertEquals(true, truncated.length() >= 10);
}
}

View File

@@ -0,0 +1,16 @@
package jnpf.lock;
public class LockService {
private RedisTemplate<String, String> redisTemplate;
private RedisUtil redisUtil;
public void acquire(String bizId) {
redisTemplate.opsForValue().setIfAbsent("order:lock:" + bizId, "1", 30, TimeUnit.SECONDS);
redisTemplate.opsForValue().increment("loginCount:" + bizId);
redisTemplate.delete("temp:" + bizId);
redisUtil.insert("Authorization:" + bizId, "token-abc", 60);
redisTemplate.opsForValue().set("plain:flag", "1", 60, TimeUnit.SECONDS);
redisTemplate.opsForValue().set("uuid:key", UUID.randomUUID().toString(), 60, TimeUnit.SECONDS);
}
}

View File

@@ -0,0 +1,12 @@
package demo;
import demo.model.DemoVo;
public class TemplateService {
private RedisTemplate<String, DemoVo> redisTemplate;
public void cache(String key, DemoVo vo) {
redisTemplate.opsForValue().set(key, vo, 3600, TimeUnit.SECONDS);
}
}