Compare commits
35 Commits
main
...
4361fe767f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4361fe767f | |||
| bb406cd354 | |||
| d884aa36ff | |||
| e88408e9b6 | |||
| 14f9f1fce7 | |||
| 4dd4944107 | |||
| a03e1b4819 | |||
| 27dfeb8a4c | |||
| fd5aab5e7f | |||
| 8ef5abc262 | |||
| 89a48a98aa | |||
| c780b269d6 | |||
| d4590fbb86 | |||
| b7f4bbe03c | |||
| 96a3bd46cf | |||
| 1f119edb7c | |||
| 6e06cbf5b5 | |||
| cab3fea666 | |||
| 875a5edbd1 | |||
| c65bee5a76 | |||
| e7aa970ef8 | |||
| 59326ebe3c | |||
| 6140d091e4 | |||
| a6fdc5fc5e | |||
| 4da632bd5c | |||
| d0f10e5479 | |||
| 110beb79c0 | |||
| 114b053733 | |||
| 7a8ca7ce0b | |||
| ea7bcaff2f | |||
| 797f435ccd | |||
| 327546b127 | |||
| 30e530c0bf | |||
| 436460cfdb | |||
| 6aab14bd1e |
41
.gitea/config/serialization-schema-check-config.yaml
Normal file
41
.gitea/config/serialization-schema-check-config.yaml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# ============================================================
|
||||||
|
# 序列化结构变更检测 — 业务仓库配置
|
||||||
|
# ============================================================
|
||||||
|
# 说明:
|
||||||
|
# - 本配置文件为业务覆盖配置,会与 jar 内 default-config.yaml 深度合并
|
||||||
|
# - 未声明的项沿用工具内置默认值(忽略规则、检测模式等)
|
||||||
|
# - 当前覆盖 Redis 缓存 + MQ(RocketMQ/Kafka)生产侧投递与 MQ-R 读侧补强
|
||||||
|
|
||||||
|
# 总开关 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: []
|
||||||
|
|
||||||
|
# MQ destination 忽略示例(按需取消注释)
|
||||||
|
# ignore:
|
||||||
|
# mq_destinations:
|
||||||
|
# - "benchmark:*"
|
||||||
|
# - "*:TEST"
|
||||||
@@ -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"
|
|
||||||
209
.gitea/workflows/serialization-schema-check.yaml
Normal file
209
.gitea/workflows/serialization-schema-check.yaml
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
name: 序列化结构检查
|
||||||
|
run-name: ${{ gitea.actor }}的序列化结构检查
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# serialization-schema-checker 私库坐标:com.codechecker:serialization-schema-checker:1.0.0
|
||||||
|
SERIALIZATION_SCHEMA_CHECKER_VERSION: "1.0.0"
|
||||||
|
SERIALIZATION_SCHEMA_CHECKER_REPO_URL: "http://192.168.3.25:18081/nexus/repository/maven-releases"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
serialization-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/serialization-schema-new-sha.txt
|
||||||
|
|
||||||
|
- name: 检查配置文件
|
||||||
|
run: |
|
||||||
|
if [ ! -f .gitea/config/serialization-schema-check-config.yaml ]; then
|
||||||
|
echo "错误: 缺少 .gitea/config/serialization-schema-check-config.yaml"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# 顶层总开关 enabled: false 时跳过后续步骤(与 notify.enabled 区分,仅匹配行首)
|
||||||
|
if grep -Eq '^enabled:[[:space:]]*false([[:space:]]|#|$)' .gitea/config/serialization-schema-check-config.yaml; then
|
||||||
|
echo "总开关 enabled=false,跳过序列化结构检查"
|
||||||
|
touch /tmp/serialization-schema-check.skip
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 从 Nexus 私库下载 serialization-schema-checker
|
||||||
|
run: |
|
||||||
|
if [ -f /tmp/serialization-schema-check.skip ]; then
|
||||||
|
echo "总开关已关闭,跳过下载"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
GROUP_PATH="com/codechecker/serialization-schema-checker"
|
||||||
|
JAR_NAME="serialization-schema-checker-${SERIALIZATION_SCHEMA_CHECKER_VERSION}.jar"
|
||||||
|
JAR_URL="${SERIALIZATION_SCHEMA_CHECKER_REPO_URL}/${GROUP_PATH}/${SERIALIZATION_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/serialization-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/serialization-schema-check.skip ]; then
|
||||||
|
echo "总开关已关闭,跳过检测"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEW_SHA=$(cat /tmp/serialization-schema-new-sha.txt)
|
||||||
|
OLD_SHA="${PUSH_BEFORE}"
|
||||||
|
|
||||||
|
# 新分支首次 push(before 全 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 "新分支首次 push(before 为空/全 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-push),commit 计数将改用事件 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/serialization-schema-checker-${SERIALIZATION_SCHEMA_CHECKER_VERSION}.jar" \
|
||||||
|
--config .gitea/config/serialization-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"
|
||||||
7
.idea/compiler.xml
generated
7
.idea/compiler.xml
generated
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<annotationProcessing>
|
<annotationProcessing>
|
||||||
@@ -6,8 +6,11 @@
|
|||||||
<sourceOutputDir name="target/generated-sources/annotations" />
|
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||||
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||||
<outputRelativeToContentRoot value="true" />
|
<outputRelativeToContentRoot value="true" />
|
||||||
<module name="redis-schema-checker" />
|
<module name="serialization-schema-checker" />
|
||||||
</profile>
|
</profile>
|
||||||
</annotationProcessing>
|
</annotationProcessing>
|
||||||
|
<bytecodeTargetLevel>
|
||||||
|
<module name="serialization-schema-checker" target="11" />
|
||||||
|
</bytecodeTargetLevel>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
2
.idea/encodings.xml
generated
2
.idea/encodings.xml
generated
@@ -1,8 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="Encoding">
|
<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/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
192
docs/CI集成说明.md
192
docs/CI集成说明.md
@@ -1,25 +1,43 @@
|
|||||||
# Redis 序列化结构检测 — CI 集成说明
|
# 序列化结构检测 — CI 集成说明
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 集成概览
|
## 1. 集成概览
|
||||||
|
|
||||||
```text
|
```text
|
||||||
开发者 push 代码
|
开发者 push 代码(可含多个 commit)
|
||||||
↓
|
↓
|
||||||
Gitea Actions 触发
|
Gitea Actions 触发
|
||||||
↓
|
↓
|
||||||
浅克隆业务仓库(depth=2)
|
浅克隆业务仓库 tip(depth=1)+ 按需取 push 前 tip(before)
|
||||||
↓
|
↓
|
||||||
从 Nexus 下载 redis-schema-checker.jar
|
从 Nexus 下载 serialization-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. 前置条件
|
## 2. 前置条件
|
||||||
@@ -28,8 +46,7 @@ mode=block 且含 P0/P1/P2 任一变更 → exit 1(流水线失败)
|
|||||||
|----|------|
|
|----|------|
|
||||||
| Gitea Runner | 标签 `jdk11`,已安装 Java 11 |
|
| Gitea Runner | 标签 `jdk11`,已安装 Java 11 |
|
||||||
| Nexus 私库 | 可访问 `http://192.168.3.25:18081/nexus/repository/maven-releases` |
|
| Nexus 私库 | 可访问 `http://192.168.3.25:18081/nexus/repository/maven-releases` |
|
||||||
| 工具 JAR | `com.codechecker:redis-schema-checker:1.0.0` 已发布 |
|
| 工具 JAR | `com.codechecker:serialization-schema-checker:1.0.0` 已发布 |
|
||||||
| 仓库 Secret | `WECOM_ROBOT_WEBHOOK` 已配置 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,93 +58,36 @@ mode=block 且含 P0/P1/P2 任一变更 → exit 1(流水线失败)
|
|||||||
jnpf-java-cloud/
|
jnpf-java-cloud/
|
||||||
├── .gitea/
|
├── .gitea/
|
||||||
│ ├── workflows/
|
│ ├── workflows/
|
||||||
│ │ └── redis-schema-check.yaml # 流水线
|
│ │ └── serialization-schema-check.yaml # 流水线
|
||||||
│ └── config/
|
│ └── config/
|
||||||
│ └── redis-schema-check-config.yaml # 检测配置
|
│ └── serialization-schema-check-config.yaml # 检测配置
|
||||||
```
|
```
|
||||||
|
|
||||||
|
请以本仓库 `.gitea/workflows/serialization-schema-check.yaml` 为模板同步到业务仓。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 流水线模板
|
## 4. 流水线模板(要点)
|
||||||
|
|
||||||
```yaml
|
完整可运行版本见:`.gitea/workflows/serialization-schema-check.yaml`。
|
||||||
name: Redis序列化结构检查
|
|
||||||
run-name: ${{ gitea.actor }}的Redis结构检查
|
|
||||||
|
|
||||||
on:
|
核心逻辑摘要:
|
||||||
push:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
```bash
|
||||||
REDIS_SCHEMA_CHECKER_VERSION: "1.0.0"
|
# 1) 浅拉 tip
|
||||||
REDIS_SCHEMA_CHECKER_REPO_URL: "http://192.168.3.25:18081/nexus/repository/maven-releases"
|
git clone --depth 1 --single-branch --branch "$BRANCH" "$REPO_URL" .
|
||||||
|
git checkout -B "$BRANCH" "$NEW_SHA" # NEW_SHA = gitea.sha
|
||||||
|
|
||||||
jobs:
|
# 2) OLD_SHA = gitea.event.before(全 0 则跳过;手动触发回退 HEAD~1)
|
||||||
redis-schema-check:
|
# 3) 本地没有 OLD_SHA 时:
|
||||||
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
|
git fetch --depth 1 origin "$OLD_SHA" # 优先
|
||||||
runs-on: jdk11
|
# 或 git fetch --deepen N # 兜底
|
||||||
steps:
|
|
||||||
- name: 检出代码
|
|
||||||
run: |
|
|
||||||
git config --global http.sslVerify false
|
|
||||||
git clone --depth 2 --single-branch --branch "${{ gitea.ref_name }}" \
|
|
||||||
"https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
|
|
||||||
git checkout -B "${{ gitea.ref_name }}" "${{ gitea.sha }}"
|
|
||||||
|
|
||||||
- name: 检查配置文件
|
# 4) 执行
|
||||||
run: |
|
java -jar serialization-schema-checker-1.0.0.jar \
|
||||||
if [ ! -f .gitea/config/redis-schema-check-config.yaml ]; then
|
|
||||||
echo "错误: 缺少 .gitea/config/redis-schema-check-config.yaml"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: 从 Nexus 下载检测工具
|
|
||||||
run: |
|
|
||||||
GROUP_PATH="com/codechecker/redis-schema-checker"
|
|
||||||
JAR_NAME="redis-schema-checker-${REDIS_SCHEMA_CHECKER_VERSION}.jar"
|
|
||||||
JAR_URL="${REDIS_SCHEMA_CHECKER_REPO_URL}/${GROUP_PATH}/${REDIS_SCHEMA_CHECKER_VERSION}/${JAR_NAME}"
|
|
||||||
JAR_PATH="/tmp/${JAR_NAME}"
|
|
||||||
|
|
||||||
echo "下载: ${JAR_URL}"
|
|
||||||
if command -v curl >/dev/null 2>&1; then
|
|
||||||
curl -fsSL -o "${JAR_PATH}" "${JAR_URL}"
|
|
||||||
elif command -v wget >/dev/null 2>&1; then
|
|
||||||
wget -q -O "${JAR_PATH}" "${JAR_URL}"
|
|
||||||
else
|
|
||||||
echo "错误: Runner 缺少 curl 或 wget"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -s "${JAR_PATH}" ]; then
|
|
||||||
echo "错误: 下载失败或文件为空"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ls -lh "${JAR_PATH}"
|
|
||||||
|
|
||||||
- name: 验证 JDK
|
|
||||||
run: java -version
|
|
||||||
|
|
||||||
- name: 执行 Redis 结构检测
|
|
||||||
env:
|
|
||||||
WECOM_ROBOT_WEBHOOK: ${{ secrets.WECOM_ROBOT_WEBHOOK }}
|
|
||||||
run: |
|
|
||||||
OLD_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "")
|
|
||||||
if [ -z "$OLD_SHA" ]; then
|
|
||||||
echo "首次提交,跳过检测"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
|
||||||
|
|
||||||
java -jar "/tmp/redis-schema-checker-${REDIS_SCHEMA_CHECKER_VERSION}.jar" \
|
|
||||||
--config .gitea/config/redis-schema-check-config.yaml \
|
|
||||||
--repo-root . \
|
|
||||||
--old-sha "$OLD_SHA" \
|
--old-sha "$OLD_SHA" \
|
||||||
--new-sha "$(git rev-parse HEAD)" \
|
--new-sha "$NEW_SHA" \
|
||||||
--branch "${{ gitea.ref_name }}" \
|
...
|
||||||
--modifier "${{ gitea.actor }}" \
|
|
||||||
--modify-time "$COMMIT_TIME"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -138,28 +98,27 @@ jobs:
|
|||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| `demo.yaml` (AI代码质量分析) | AI Code Review | 并行,互不影响 |
|
| `demo.yaml` (AI代码质量分析) | AI Code Review | 并行,互不影响 |
|
||||||
| `code-check` (CodeChecker) | 通用变更检测 | **同模式**,可并列执行 |
|
| `code-check` (CodeChecker) | 通用变更检测 | **同模式**,可并列执行 |
|
||||||
| `redis-schema-check` | Redis 结构检测 | 新增 |
|
| `serialization-schema-check` | 缓存结构检测 | 新增 |
|
||||||
|
|
||||||
建议:三个 job 独立并行,各自 exit code 独立。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 工具发布流程(redisCheck 仓库)
|
## 6. 工具发布流程(redisCheck 仓库)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在 redisCheck 仓库
|
# 在 redisCheck 仓库根目录
|
||||||
mvn clean package -DskipTests
|
mvn clean package -DskipTests
|
||||||
|
|
||||||
# 发布到 Nexus(需配置 settings.xml)
|
# 发布到 Nexus(需配置 settings.xml)
|
||||||
mvn deploy -DskipTests
|
mvn clean deploy -DskipTests
|
||||||
```
|
```
|
||||||
|
|
||||||
发布产物:
|
发布产物:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
com/codechecker/redis-schema-checker/1.0.0/
|
com/codechecker/serialization-schema-checker/1.0.0/
|
||||||
├── redis-schema-checker-1.0.0.jar # 可执行 fat-jar
|
├── serialization-schema-checker-1.0.0.jar # 可执行 fat-jar
|
||||||
└── redis-schema-checker-1.0.0.pom
|
└── serialization-schema-checker-1.0.0.pom
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -168,9 +127,9 @@ com/codechecker/redis-schema-checker/1.0.0/
|
|||||||
|
|
||||||
| 退出码 | 含义 | 流水线表现 |
|
| 退出码 | 含义 | 流水线表现 |
|
||||||
|--------|------|------------|
|
|--------|------|------------|
|
||||||
| 0 | 通过(含 notify 模式下的告警) | 绿色 |
|
| 0 | 通过(含 notify 模式下的告警) / 跳过 | 绿色 |
|
||||||
| 1 | 阻断(block 模式 + P0/P1/P2 任一变更) | 红色 |
|
| 1 | 阻断(block 模式) | 红色 |
|
||||||
| 2 | 执行错误(配置缺失、jar 异常等) | 红色 |
|
| 2 | 执行错误(配置缺失、无法取 before、jar 异常等) | 红色 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -178,27 +137,46 @@ 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 变量 |
|
| 下载 jar 失败 | Nexus 地址/版本错误 | 检查 env 变量 |
|
||||||
| 未收到企微 | Secret 未配 / notify.enabled=false | 检查配置 |
|
| 未收到企微 | Secret 未配 / notify.enabled=false / webhook_url 空 | 检查配置 |
|
||||||
| 大量误报 | 锁/计数器未过滤 | 补充 ignore.key_patterns |
|
| 大量 Redis 误报 | 锁/计数器未过滤 | 补充 ignore.key_patterns |
|
||||||
| 漏报 | 写入模式未覆盖 | 启用 W04/W05 或补充 manual_mappings |
|
| 大量 MQ 误报 | 压测/临时 topic | 补充 ignore.mq_destinations |
|
||||||
| 类型展开不完整 | 类型在依赖 jar 中 | 补充 manual_mappings.value_type |
|
| commit 数显示为 1(实际多个) | 浅克隆下 `rev-list` 看不到中间提交 | 已修复:优先事件 `commits` 长度;并 deepen 到 before 为祖先 |
|
||||||
|
| 漏报(模式/模块) | W0x/MQ 未开 / include_modules 过窄 | 确认 W01~W05 与 mq_patterns(含 MQ03~05、MQ-K03/K04);检查 `mq_read_hints_enabled` 与模块过滤 |
|
||||||
|
| 类型展开不完整 | 类型在依赖 jar 中 | 补充 `manual_mappings.value_type` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 本地调试
|
## 9. 本地调试
|
||||||
|
|
||||||
|
模拟一次「多 commit push」的累计区间:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在 jnpf-java-cloud 根目录
|
# OLD = 推送前 tip,NEW = 当前 tip(可用 origin/branch@{1} 或显式 sha)
|
||||||
java -jar /path/to/redis-schema-checker-1.0.0.jar \
|
OLD_SHA=$(git rev-parse origin/$(git branch --show-current)~3) # 示例:假设 ahead 3
|
||||||
--config .gitea/config/redis-schema-check-config.yaml \
|
NEW_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
java -jar /path/to/serialization-schema-checker-1.0.0.jar \
|
||||||
|
--config .gitea/config/serialization-schema-check-config.yaml \
|
||||||
--repo-root . \
|
--repo-root . \
|
||||||
--old-sha HEAD~1 \
|
--old-sha "$OLD_SHA" \
|
||||||
--new-sha HEAD \
|
--new-sha "$NEW_SHA" \
|
||||||
--branch $(git branch --show-current) \
|
--branch $(git branch --show-current) \
|
||||||
--modifier "$(git log -1 --format=%an)" \
|
--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`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 相关文档
|
||||||
|
|
||||||
|
| 文档 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| [实施方案.md](./实施方案.md) | 缓存检测总体方案 |
|
||||||
|
| [配置说明.md](./配置说明.md) | YAML 配置项 |
|
||||||
|
| [MQ序列化结构检测方案.md](./MQ序列化结构检测方案.md) | MQ 消息体 Schema(RocketMQ + Kafka,方案已落地) |
|
||||||
|
|||||||
343
docs/MQ序列化结构检测方案.md
Normal file
343
docs/MQ序列化结构检测方案.md
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
# MQ 消息体序列化结构变更检测 — 方案
|
||||||
|
|
||||||
|
> 版本:v0.4
|
||||||
|
> 日期:2026-07-15
|
||||||
|
> 状态:**Phase M1 + M2 已实现**(生产侧 MQ01~05 / MQ-K01~K04 + 读侧 MQ-R)
|
||||||
|
> 关联:复用 `serialization-schema-checker` 的 Schema 提取、Diff、企微通知与 CI 框架
|
||||||
|
> 业务样本仓:`jnpf-java-cloud`(**RocketMQ + Kafka**)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 为什么要做
|
||||||
|
|
||||||
|
业务同时使用 **RocketMQ** 与 **Kafka** 投递业务对象:
|
||||||
|
|
||||||
|
| 中间件 | 典型写法 | 序列化要点 |
|
||||||
|
|--------|----------|------------|
|
||||||
|
| RocketMQ | `rocketMQTemplate.syncSend(topic:tag, dto)` | Spring MessageConverter(多为 Jackson)把对象变成消息体 |
|
||||||
|
| Kafka | `kafkaTemplate.send(topic, vo|List)` | Spring `KafkaTemplate` + value serializer(多为 Json)编码对象 |
|
||||||
|
| Kafka 消费 | `@KafkaListener` + `parseObject(message, Xxx.class)` | 常以 String 接收后再 Fastjson 反序列化 |
|
||||||
|
|
||||||
|
当消息 DTO / VO **删字段、改类型、加包装层**时:
|
||||||
|
|
||||||
|
- Topic / 重试队列里仍可能有**旧结构消息**
|
||||||
|
- 新消费代码反序列化失败,或字段为空导致静默逻辑错误
|
||||||
|
|
||||||
|
这与 Redis 缓存「残留旧 value」同一类问题:
|
||||||
|
|
||||||
|
| | Redis | RocketMQ | Kafka |
|
||||||
|
|--|-------|----------|-------|
|
||||||
|
| 残留形态 | 未过期 key | Topic 积压 / 重试 | Topic 积压 / 消费 lag |
|
||||||
|
| 路由标识 | key 模式 | **topic:tag** | **topic**(一般无 tag;动态后缀可归一 `*`) |
|
||||||
|
| 典型序列化 | Fastjson 字符串或 Template 直写 | MessageConverter | Kafka JsonSerializer / 手写 JSON 字符串 |
|
||||||
|
|
||||||
|
### 1.2 目标
|
||||||
|
|
||||||
|
在 push 时静态分析 **消息体类型的序列化 Schema** 是否相对对比区间发生变更,覆盖 **RocketMQ + Kafka**,并复用现有企微通知 / notify|block 能力。
|
||||||
|
|
||||||
|
### 1.3 非目标
|
||||||
|
|
||||||
|
- 不连接真实 Broker,不拉取积压消息做运行时校验
|
||||||
|
- 不解析依赖 jar 内消息类型(仅本仓 `src/main/java`)
|
||||||
|
- 不替代权限、幂等、消费失败重试等业务正确性检查
|
||||||
|
- 不扫仅 Admin 建 Topic 的工具类(如 `KafkaTopicUtil`,无业务 body)
|
||||||
|
- RabbitMQ 等若后续出现再扩展(当前仓以 RocketMQ / Kafka 为主)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 业务调研结论(jnpf-java-cloud)
|
||||||
|
|
||||||
|
### 2.1 RocketMQ
|
||||||
|
|
||||||
|
| 写法 | 出现情况 | 策略 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `rocketMQTemplate.syncSend(dest, dto)` | 高(如钱包扣费) | **纳入** |
|
||||||
|
| `asyncSend` / `syncSendOrderly` 等 | 中 | **纳入** |
|
||||||
|
| `convertAndSend` | 视封装而定 | **纳入** |
|
||||||
|
| `MessageBuilder.withPayload` 再 send | 中(如 IM 延时消息) | **纳入**(MQ04) |
|
||||||
|
| 先 `JSON.toJSONString` 再发 String | 较低 | unwrap 后取类型(MQ05) |
|
||||||
|
| 只发 `String` / `byte[]` / 无泛型 `Message` | 有 | **默认忽略** |
|
||||||
|
|
||||||
|
样本(资金钱包):
|
||||||
|
|
||||||
|
```java
|
||||||
|
rocketMQTemplate.syncSend(CapitalMqConstants.TOPIC + ":" + tag, req); // WalletDeductReq
|
||||||
|
|
||||||
|
@RocketMQMessageListener(...)
|
||||||
|
public class WalletDeductConsumer implements RocketMQListener<WalletDeductReq> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Kafka(已确认需纳入)
|
||||||
|
|
||||||
|
| 写法 | 出现情况 | 策略 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `kafkaTemplate.send(topic, dto)` | 中(值班食安项等) | **纳入** |
|
||||||
|
| `kafkaTemplate.send(topic, List<Xxx>)` | 中(巡店食安项列表) | **纳入**(rootArray) |
|
||||||
|
| `kafkaTemplate.send(ProducerRecord)` | 中 | **纳入**(MQ-K03) |
|
||||||
|
| 先 JSON 再 `send(topic, json)` | 较低 | unwrap(MQ-K04) |
|
||||||
|
| `@KafkaListener` + `String` + `JSONObject.parseObject(..., Xxx.class)` | 有(数据分析中差评) | **读侧补强 MQ-R** |
|
||||||
|
| `KafkaTopicUtil` 仅创建 Topic | 有(租户) | **忽略**(无消息体) |
|
||||||
|
|
||||||
|
生产样本(巡店):
|
||||||
|
|
||||||
|
```java
|
||||||
|
List<CheckItemDetailVo> thousandsData = ...;
|
||||||
|
kafkaTemplate.send(topicBuilder.patrolStoreTopic(tenantId), thousandsData);
|
||||||
|
```
|
||||||
|
|
||||||
|
生产样本(值班):
|
||||||
|
|
||||||
|
```java
|
||||||
|
KafkaTemplate<String, Object> kafkaTemplate;
|
||||||
|
kafkaTemplate.send(topic, data); // CheckItemDetailVO
|
||||||
|
```
|
||||||
|
|
||||||
|
消费样本(数据分析):
|
||||||
|
|
||||||
|
```java
|
||||||
|
@KafkaListener(topics = "ftb-evaluate-real-notification${...}", groupId = "...")
|
||||||
|
public void handleMessage(String message) {
|
||||||
|
AddedMessageNotificationToVO vo = JSONObject.parseObject(message, AddedMessageNotificationToVO.class);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
动态 Topic(如按租户拼接)静态推断结果形如 `patrol-store-topic:*`,与 Redis key `*` 规则一致。
|
||||||
|
|
||||||
|
### 2.3 「Key」等价物(destination)
|
||||||
|
|
||||||
|
| 中间件 | 聚合键形态 | 来源 |
|
||||||
|
|--------|------------|------|
|
||||||
|
| RocketMQ | `topic:tag` | 字面量、常量、`TOPIC + ":" + TAG` |
|
||||||
|
| Kafka | `topic` | 字面量、常量、`topicBuilder.xxx(tenantId)` → 前缀+`*` |
|
||||||
|
|
||||||
|
无法解析时:展示表达式 + `<font color="comment">(destination 未解析)</font>`。
|
||||||
|
|
||||||
|
### 2.4 读侧补强(类比 W06)
|
||||||
|
|
||||||
|
| 中间件 | 补强来源 |
|
||||||
|
|--------|----------|
|
||||||
|
| RocketMQ | `RocketMQListener<T>`、`onMessage(T)` + `@RocketMQMessageListener` |
|
||||||
|
| Kafka | `@KafkaListener` 方法参数类型;或方法内 `parseObject/parseArray(..., Xxx.class)`(与现有 W06 共享解析能力) |
|
||||||
|
|
||||||
|
开关:`detection.mq_read_hints_enabled`(默认 **true**)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 方案总览
|
||||||
|
|
||||||
|
### 3.1 产品形态
|
||||||
|
|
||||||
|
并入现有 `serialization-schema-checker`:
|
||||||
|
|
||||||
|
- 同一 CLI / 流水线 / Schema Diff / 企微模板
|
||||||
|
- 配置增加 `mq_patterns`(含 RocketMQ + Kafka)
|
||||||
|
- 通知按 **Topic / destination** 分块;文案统一 `Topic -->`
|
||||||
|
|
||||||
|
### 3.2 与现有链路
|
||||||
|
|
||||||
|
```text
|
||||||
|
Git Diff → 变更 Java 文件
|
||||||
|
├─ Redis:W01~W05 + W06 ← 已有
|
||||||
|
└─ MQ:RocketMQ(MQ01~05)+ Kafka(MQ-K01~K04)
|
||||||
|
+ Listener / parse 补强(MQ-R) ← 已实现
|
||||||
|
↓
|
||||||
|
同一套 TypeSchema / SchemaDiffer / Skeleton / WeCom
|
||||||
|
```
|
||||||
|
|
||||||
|
对比区间:push **`before` → `after`**。
|
||||||
|
|
||||||
|
### 3.3 核心原则
|
||||||
|
|
||||||
|
1. 只关心**消息体对象 Schema**,不关心 Broker / ACL / 限流
|
||||||
|
2. **有结构变更即告警**;`block` 与缓存共用
|
||||||
|
3. **静态分析**;仅本仓 `src/main/java`
|
||||||
|
4. RocketMQ 与 Kafka **同一 Diff / 通知模型**,仅投递 AST 模式不同
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 检测模式设计
|
||||||
|
|
||||||
|
### 4.1 生产侧 — RocketMQ
|
||||||
|
|
||||||
|
| 模式 ID | 匹配表达式 | 提取 |
|
||||||
|
|---------|------------|------|
|
||||||
|
| MQ01 | `rocketMQTemplate.syncSend(dest, payload, …)` | dest、payload 类型 |
|
||||||
|
| MQ02 | `asyncSend` / `syncSendOrderly` / `sendOneWay` 等 | 同上 |
|
||||||
|
| MQ03 | `convertAndSend(dest, payload)` | 同上 |
|
||||||
|
| MQ04 | `MessageBuilder.withPayload(obj)` 再 send,或 `Message<T>` | payload / `T` |
|
||||||
|
| MQ05 | 先 `toJSONString`/`getObjectToString` 再 send String | unwrap 后类型 |
|
||||||
|
|
||||||
|
### 4.2 生产侧 — Kafka
|
||||||
|
|
||||||
|
| 模式 ID | 匹配表达式 | 提取 |
|
||||||
|
|---------|------------|------|
|
||||||
|
| MQ-K01 | `kafkaTemplate.send(topic, payload)` | topic、payload 类型 |
|
||||||
|
| MQ-K02 | `kafkaTemplate.send(topic, key, payload)` | 同上(忽略分区 key) |
|
||||||
|
| MQ-K03 | `send(ProducerRecord)` | topic + value 类型 |
|
||||||
|
| MQ-K04 | 先 JSON 序列化为 String 再 `send(topic, json)` | unwrap 后类型 |
|
||||||
|
|
||||||
|
Payload 为 `List<Xxx>` / `Collection` 时标记 **rootArray**,骨架为 JSON 数组(与 Redis List 一致)。
|
||||||
|
|
||||||
|
**忽略**:
|
||||||
|
|
||||||
|
- payload 为字面量、纯无结构 `String`/`byte[]`(无业务类型时)
|
||||||
|
- 仅 Topic Admin API(`AdminClient.createTopics` 等)
|
||||||
|
- destination 命中 `ignore.mq_destinations`
|
||||||
|
|
||||||
|
### 4.3 消费侧辅助(不单独告警)
|
||||||
|
|
||||||
|
| 模式 ID | 匹配 | 作用 |
|
||||||
|
|---------|------|------|
|
||||||
|
| MQ-R01 | `RocketMQListener<T>` / `@RocketMQMessageListener` | 补强同 destination 生产点 |
|
||||||
|
| MQ-R02 | `@KafkaListener` + 参数类型 `T`(非 String) | 补强同 topic |
|
||||||
|
| MQ-R03 | Listener 内 `parseObject`/`parseArray(..., Xxx.class)` | 补强(复用 parse AST) |
|
||||||
|
|
||||||
|
开关:`detection.mq_read_hints_enabled`(默认 true)。
|
||||||
|
|
||||||
|
### 4.4 Schema Diff
|
||||||
|
|
||||||
|
复用现有变更类型与注解规则。
|
||||||
|
序列化方言:首版按字段名;Jackson / Fastjson / Kafka JsonSerializer 差异必要时用 `manual_mappings`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 报告与通知
|
||||||
|
|
||||||
|
### 5.1 企微块(RocketMQ / Kafka 统一)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Topic --> `capital-topic:WALLET_DEDUCT`
|
||||||
|
> **通道**: `RocketMQ`
|
||||||
|
> **位置**: `WalletDeductProducer#send:38`
|
||||||
|
> **类型**: `WalletDeductReq`
|
||||||
|
> **value值由:** “{...}”
|
||||||
|
> **变更为:** “{...}”
|
||||||
|
|
||||||
|
- Topic --> `patrol-store-food-safe:*`
|
||||||
|
> **通道**: `Kafka`
|
||||||
|
> **位置**: `PatrolServiceImpl#sendFoodSafeData:3140`
|
||||||
|
> **类型**: `List<CheckItemDetailVo>`
|
||||||
|
> **value值由:** “[{...}]”
|
||||||
|
> **变更为:** “[{...}]”
|
||||||
|
```
|
||||||
|
|
||||||
|
- 删除字段橙 `warning`;新增绿 `info`
|
||||||
|
- destination 未解析时灰色提示
|
||||||
|
|
||||||
|
「通道」字段用于区分中间件;若模板求简,可省略通道仅靠 Topic 形态区分。
|
||||||
|
|
||||||
|
### 5.2 CI 控制台
|
||||||
|
|
||||||
|
字段明细可标注 `RocketMQ` / `Kafka`;再输出与企微一致的 Markdown。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 配置草案
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
detection:
|
||||||
|
patterns: [W01, W02, W03, W04, W05]
|
||||||
|
read_hints_enabled: true
|
||||||
|
|
||||||
|
mq_patterns:
|
||||||
|
# RocketMQ
|
||||||
|
- MQ01
|
||||||
|
- MQ02
|
||||||
|
- MQ03
|
||||||
|
- MQ04
|
||||||
|
- MQ05
|
||||||
|
# Kafka
|
||||||
|
- MQ-K01
|
||||||
|
- MQ-K02
|
||||||
|
- MQ-K03
|
||||||
|
- MQ-K04
|
||||||
|
mq_read_hints_enabled: true
|
||||||
|
|
||||||
|
ignore:
|
||||||
|
mq_destinations:
|
||||||
|
- "*:TEST"
|
||||||
|
- "benchmark:*"
|
||||||
|
|
||||||
|
manual_mappings:
|
||||||
|
- id: wallet-deduct-mq
|
||||||
|
writer_method: "jnpf.capital.module.wallet.mq.WalletDeductProducer#send"
|
||||||
|
key_pattern: "capital-topic:WALLET_DEDUCT"
|
||||||
|
value_type: "jnpf.model.capital.dto.WalletDeductReq"
|
||||||
|
|
||||||
|
- id: patrol-kafka-food-safe
|
||||||
|
writer_method: "jnpf.service.impl.PatrolServiceImpl#sendFoodSafeData"
|
||||||
|
key_pattern: "*-patrol-store-*" # 按实际 topic 规则调整
|
||||||
|
value_type: "jnpf.model.analyses.CheckItemDetailVo"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 分阶段交付
|
||||||
|
|
||||||
|
### Phase M1 — MVP(RocketMQ + Kafka 基础投递)✅
|
||||||
|
|
||||||
|
| 任务 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| MQ01/MQ02 | RocketMQ `syncSend` / `asyncSend` |
|
||||||
|
| MQ-K01/MQ-K02 | Kafka `send(topic, payload)` / 三参 send |
|
||||||
|
| destination 推断 | 字面量、常量、拼接;Kafka 动态 topic → `*` |
|
||||||
|
| Schema Diff + 骨架通知 | 复用 ReportBuilder;「通道」行 |
|
||||||
|
| 夹具 | `fixtures/mq/rocket-wallet/`、`fixtures/mq/kafka-patrol/` |
|
||||||
|
| 配置 | `mq_patterns`(含 MQ-K*)、`ignore.mq_destinations` |
|
||||||
|
| List 根数组骨架 | `List<CheckItemDetailVo>` 等(与 M2 需求合并交付) |
|
||||||
|
|
||||||
|
**验收**:
|
||||||
|
|
||||||
|
1. 删 `WalletDeductReq` 字段 → 企微出现 RocketMQ Topic 骨架变更
|
||||||
|
2. 删 `CheckItemDetailVo` 字段 → 企微出现 Kafka Topic 骨架变更
|
||||||
|
|
||||||
|
### Phase M2 — 增强 ✅
|
||||||
|
|
||||||
|
| 任务 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| MQ03~MQ05、MQ-K03/K04 | convertAndSend、MessageBuilder/`Message<T>`、JSON 字符串发送、ProducerRecord |
|
||||||
|
| MQ-R01~R03 | RocketMQ Listener + Kafka `@KafkaListener` / parse 补强 |
|
||||||
|
| 夹具 | `fixtures/mq/rocket-im/`、`fixtures/mq/kafka-record/` |
|
||||||
|
|
||||||
|
**验收**:
|
||||||
|
|
||||||
|
1. `MessageBuilder.withPayload(DutyImNotice)` + `asyncSend` → 命中 MQ04,改 VO 字段可告警
|
||||||
|
2. `kafkaTemplate.send(ProducerRecord)` → 命中 MQ-K03
|
||||||
|
3. Listener / `parseObject` 可补强同 Topic 弱类型生产点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 风险与限制
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|------|------|
|
||||||
|
| Kafka topic 按租户动态拼接 | 归一 `prefix:*`;`manual_mappings` |
|
||||||
|
| RocketMQ / Kafka 混用同一 VO | 各投递点独立告警(符合预期) |
|
||||||
|
| Listener 收 String、parse 在方法深处 | MQ-R03 + parse AST |
|
||||||
|
| 生产/消费跨模块对不齐 | 同仓索引 + destination 对齐;失败则仅写侧 |
|
||||||
|
| Jackson / Fastjson / Kafka JsonSerializer 细节差 | 首版字段名;必要时方言或 mapping |
|
||||||
|
| 只改消费未改生产类型 | 不告警(工具职责是消息体 Schema) |
|
||||||
|
| `include_modules` 过窄 | 观察期扩大模块或置空全仓 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 决策对齐
|
||||||
|
|
||||||
|
| 项 | 结论 |
|
||||||
|
|----|------|
|
||||||
|
| 中间件范围 | **RocketMQ + Kafka**(本仓已确认);Rabbit 暂不纳入 |
|
||||||
|
| 对比区间 | `gitea.event.before` → `gitea.sha` |
|
||||||
|
| 阻断 | 与现网 `mode` 共用 |
|
||||||
|
| 级别 | 不引入 P0/P1/P2 产品展示 |
|
||||||
|
| 交付 | 同一 jar;patterns 区分 Redis / MQ(含 MQ-K*) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 下一步
|
||||||
|
|
||||||
|
1. ~~按 Phase M1 开发~~ **已完成**(MQ01/02 + MQ-K01/K02)
|
||||||
|
2. ~~按 Phase M2 开发~~ **已完成**(MQ03~05、MQ-K03/K04、MQ-R)
|
||||||
|
3. 业务仓验收:`MessageBuilder` IM Topic、巡店/值班 Kafka、`capital-topic:WALLET_DEDUCT`
|
||||||
|
4. 观察期按需扩大 `include_modules`
|
||||||
398
docs/实施方案.md
398
docs/实施方案.md
@@ -1,9 +1,10 @@
|
|||||||
# Redis 序列化结构变更检测 — 实施方案
|
# 序列化结构变更检测 — 实施方案
|
||||||
|
|
||||||
> 版本:v0.1
|
> 版本:v0.3
|
||||||
> 日期:2026-07-13
|
> 日期:2026-07-15
|
||||||
> 技术栈:Java 11 + Maven + JavaParser
|
> 技术栈:Java 11 + Maven + JavaParser
|
||||||
> 目标仓库:`redisCheck`(工具) / `jnpf-java-cloud`(被检测业务仓库)
|
> 目标仓库:`schemaCheck`(工具) / `jnpf-java-cloud`(被检测业务仓库)
|
||||||
|
> 当前阶段:**Phase 1 + Phase 2 已完成**;Phase 3 运营待做;**MQ 扩展方案已文档落地**(见 `docs/MQ序列化结构检测方案.md`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
4. 通过企微机器人发送通知
|
4. 通过企微机器人发送通知
|
||||||
5. 通过开关控制 **仅通知** 或 **阻断流水线**
|
5. 通过开关控制 **仅通知** 或 **阻断流水线**
|
||||||
|
|
||||||
### 1.3 非目标(第一版不做)
|
### 1.3 非目标
|
||||||
|
|
||||||
- 不连接真实 Redis 实例做运行时校验
|
- 不连接真实 Redis 实例做运行时校验
|
||||||
- 不扫描 Maven 依赖 jar 中的类(仅分析业务仓库源码)
|
- 不扫描 Maven 依赖 jar 中的类(仅分析业务仓库源码)
|
||||||
@@ -71,20 +72,20 @@
|
|||||||
|
|
||||||
### 2.1 Redis 序列化方式
|
### 2.1 Redis 序列化方式
|
||||||
|
|
||||||
| 类型 | 出现频率 | 第一版策略 |
|
| 类型 | 出现频率 | 策略 |
|
||||||
|------|----------|------------|
|
|------|----------|------|
|
||||||
| `JSON.toJSONString(obj)` / `JSONObject.toJSONString(obj)` | 高 | **重点支持** |
|
| `JSON.toJSONString(obj)` / `JSONObject.toJSONString(obj)` | 高 | **已支持**(W01/W02) |
|
||||||
| `JsonUtil.getObjectToString(obj)` | 高 | **重点支持**(按 Fastjson/Jackson 默认字段规则推断) |
|
| `JsonUtil.getObjectToString(obj)` | 高 | **已支持**(W03) |
|
||||||
| `RedisTemplate.opsForValue().set(key, obj)` 直接写对象 | 中 | 第二阶段支持 |
|
| `RedisTemplate.opsForValue().set(key, obj)` 直接写对象 | 中 | **已支持**(W04) |
|
||||||
| `RedisTemplate.opsForHash().put(key, field, obj)` | 中 | 第二阶段支持 |
|
| `RedisTemplate.opsForHash().put(key, field, obj)` | 中 | **已支持**(W05) |
|
||||||
| `StringRedisTemplate` 写 JSON 字符串 | 高 | **重点支持** |
|
| `StringRedisTemplate` 写 JSON 字符串 | 高 | **已支持** |
|
||||||
| 锁 / 计数器 / token 简单值 | 高 | **默认忽略** |
|
| 锁 / 计数器 / token 简单值 | 高 | **默认忽略** |
|
||||||
|
|
||||||
### 2.2 Key 与实体映射
|
### 2.2 Key 与实体映射
|
||||||
|
|
||||||
- 不存在统一的「Key → 类型」注册中心
|
- 不存在统一的「Key → 类型」注册中心
|
||||||
- 存在大量 `static final String`、`String.format(...)`、`buildCacheKey(...)` 等模式
|
- 存在大量 `static final String`、`String.format(...)`、`buildCacheKey(...)` 等模式
|
||||||
- 第一版采用:**写入点静态推断 + 可选 YAML 人工补充映射**
|
- 因此采用:**写入点静态推断 + 可选 YAML 人工补充映射**
|
||||||
|
|
||||||
### 2.3 多模块特征
|
### 2.3 多模块特征
|
||||||
|
|
||||||
@@ -98,18 +99,17 @@
|
|||||||
|
|
||||||
### 3.1 交付形态
|
### 3.1 交付形态
|
||||||
|
|
||||||
沿用现有 `code-checker` 模式(见 `redisCheck/.gitea/demo.yaml`):
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
redisCheck 仓库
|
schemaCheck 仓库
|
||||||
├── 开发 Java 分析工具
|
├── 开发 Java 分析工具
|
||||||
├── mvn package 打 fat-jar
|
├── mvn package 打 fat-jar
|
||||||
├── 发布到 Nexus:com.codechecker:redis-schema-checker:{version}
|
├── 发布到 Nexus:com.codechecker:serialization-schema-checker:{version}
|
||||||
└── 提供默认配置模板
|
└── 提供默认配置模板
|
||||||
|
|
||||||
jnpf-java-cloud 仓库
|
jnpf-java-cloud 仓库
|
||||||
├── .gitea/workflows/redis-schema-check.yaml
|
├── .gitea/workflows/serialization-schema-check.yaml
|
||||||
├── .gitea/config/redis-schema-check-config.yaml
|
├── .gitea/config/serialization-schema-check-config.yaml
|
||||||
└── push 时下载 jar 并执行检测
|
└── push 时下载 jar 并执行检测
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -119,11 +119,11 @@ jnpf-java-cloud 仓库
|
|||||||
flowchart TB
|
flowchart TB
|
||||||
subgraph Gitea["Gitea Push Pipeline"]
|
subgraph Gitea["Gitea Push Pipeline"]
|
||||||
A[push 事件] --> B[浅克隆 old/new 提交]
|
A[push 事件] --> B[浅克隆 old/new 提交]
|
||||||
B --> C[下载 redis-schema-checker.jar]
|
B --> C[下载 serialization-schema-checker.jar]
|
||||||
C --> D[java -jar 执行检测]
|
C --> D[java -jar 执行检测]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Checker["redis-schema-checker (JDK 11)"]
|
subgraph Checker["serialization-schema-checker (JDK 11)"]
|
||||||
D --> E[GitDiffScanner]
|
D --> E[GitDiffScanner]
|
||||||
E --> F[RedisWritePointDetector]
|
E --> F[RedisWritePointDetector]
|
||||||
F --> G[JavaSchemaExtractor]
|
F --> G[JavaSchemaExtractor]
|
||||||
@@ -143,8 +143,8 @@ flowchart TB
|
|||||||
1. **纯静态分析**:基于 Java 源码 AST + 符号解析,不启动 Spring 容器
|
1. **纯静态分析**:基于 Java 源码 AST + 符号解析,不启动 Spring 容器
|
||||||
2. **Diff 驱动**:只分析本次 push 变更涉及的文件及其关联类型
|
2. **Diff 驱动**:只分析本次 push 变更涉及的文件及其关联类型
|
||||||
3. **本仓限定**:类型解析仅在业务仓库 `src/main/java` 范围内
|
3. **本仓限定**:类型解析仅在业务仓库 `src/main/java` 范围内
|
||||||
4. **可配置**:忽略规则、严重级别、通知开关、阻断开关均可 YAML 配置
|
4. **可配置**:忽略规则、通知开关、阻断开关均可 YAML 配置
|
||||||
5. **可演进**:第一版聚焦 JSON 字符串写入,后续扩展 Template 直写对象
|
5. **可演进**:已覆盖 JSON 字符串写入与 Template 直写 / Hash;后续可扩展读路径反向确认、报告落盘等
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -163,76 +163,53 @@ flowchart TB
|
|||||||
|
|
||||||
**不采用** Spoon / Eclipse JDT 的原因:JavaParser 足够覆盖第一版需求,依赖更轻,CLI 启动更快。
|
**不采用** Spoon / Eclipse JDT 的原因:JavaParser 足够覆盖第一版需求,依赖更轻,CLI 启动更快。
|
||||||
|
|
||||||
**Lombok 处理策略**:第一版基于源码字段 + `@Data` 等注解推断序列化字段;对 `@Builder`、`@SuperBuilder` 等复杂场景标记为「低置信度」并降级为 P2 提示。后续可选集成 `lombok.ast` 或 delombok 预处理。
|
**Lombok 处理策略**:基于源码字段 + `@Data` 等注解推断序列化字段;对 `@Builder`、`@SuperBuilder` 等复杂场景标记为低置信度提示。后续可选集成 `lombok.ast` 或 delombok 预处理。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 工程结构(redisCheck 仓库)
|
## 5. 工程结构(redisCheck 仓库)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
redisCheck/
|
schemaCheck/
|
||||||
├── pom.xml
|
├── pom.xml # 单模块工程(无父子结构)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── 实施方案.md # 本文档
|
│ ├── 实施方案.md
|
||||||
│ ├── 配置说明.md # YAML 配置项详解
|
│ ├── 配置说明.md
|
||||||
│ └── CI集成说明.md # 业务仓库接入步骤
|
│ ├── CI集成说明.md
|
||||||
├── redis-schema-checker/
|
│ └── MQ序列化结构检测方案.md # MQ 消息体 Schema 扩展(方案)
|
||||||
│ ├── pom.xml
|
├── src/
|
||||||
│ └── src/
|
|
||||||
│ ├── main/
|
│ ├── main/
|
||||||
│ │ ├── resources/
|
│ │ ├── resources/
|
||||||
│ │ │ └── default-config.yaml # 内置默认配置(随 jar 发布)
|
│ │ │ └── default-config.yaml # 内置默认配置(随 jar 发布)
|
||||||
│ │ └── java/com/codechecker/redis/
|
│ │ └── java/com/codechecker/cache/
|
||||||
│ │ ├── cli/ # 命令行入口
|
│ │ ├── cli/ # 命令行入口
|
||||||
│ │ │ └── RedisSchemaCheckerMain.java
|
|
||||||
│ │ ├── config/ # 配置模型
|
│ │ ├── config/ # 配置模型
|
||||||
│ │ │ ├── CheckerConfig.java
|
|
||||||
│ │ │ └── ConfigLoader.java
|
|
||||||
│ │ ├── git/ # Git 操作
|
│ │ ├── git/ # Git 操作
|
||||||
│ │ │ ├── GitDiffScanner.java
|
|
||||||
│ │ │ └── GitException.java
|
|
||||||
│ │ ├── analyze/ # 编排与工作树扫描
|
│ │ ├── analyze/ # 编排与工作树扫描
|
||||||
│ │ │ ├── SchemaCheckAnalyzer.java
|
│ │ ├── detector/ # Redis 写入点检测(W01~W05)
|
||||||
│ │ │ ├── FileScanner.java
|
│ │ ├── schema/ # Schema 提取与注解
|
||||||
│ │ │ └── GlobMatcher.java
|
|
||||||
│ │ ├── detector/ # Redis 写入点检测
|
|
||||||
│ │ │ ├── RedisWritePointDetector.java
|
|
||||||
│ │ │ └── WritePoint.java
|
|
||||||
│ │ ├── schema/ # Schema 提取
|
|
||||||
│ │ │ ├── JavaSchemaExtractor.java
|
|
||||||
│ │ │ ├── SourceIndex.java
|
|
||||||
│ │ │ ├── TypeSchema.java
|
|
||||||
│ │ │ ├── FieldSchema.java
|
|
||||||
│ │ │ ├── JsonType.java
|
|
||||||
│ │ │ └── AnnotationSupport.java
|
|
||||||
│ │ ├── diff/ # 结构对比
|
│ │ ├── diff/ # 结构对比
|
||||||
│ │ │ ├── SchemaDiffer.java
|
|
||||||
│ │ │ ├── SchemaChange.java
|
|
||||||
│ │ │ ├── ChangeType.java
|
|
||||||
│ │ │ └── Severity.java
|
|
||||||
│ │ ├── key/ # Key 推断
|
│ │ ├── key/ # Key 推断
|
||||||
│ │ │ └── RedisKeyResolver.java
|
│ │ ├── report/ # 报告 / 企微 Markdown
|
||||||
│ │ ├── report/ # 报告
|
|
||||||
│ │ │ ├── ReportBuilder.java
|
|
||||||
│ │ │ └── CheckReport.java
|
|
||||||
│ │ └── notify/ # 企微通知
|
│ │ └── notify/ # 企微通知
|
||||||
│ │ └── WeComNotifier.java
|
|
||||||
│ └── test/
|
│ └── test/
|
||||||
│ ├── resources/fixtures/tenant/ # 夹具:TenantVO/Helper 新旧版本
|
│ ├── resources/fixtures/{tenant,lock,template}/
|
||||||
│ └── java/... # 各模块单测
|
│ └── java/...
|
||||||
└── .gitea/
|
├── .gitea/
|
||||||
└── demo.yaml # 工具自身 CI(可选)
|
│ ├── workflows/serialization-schema-check.yaml
|
||||||
|
│ └── config/serialization-schema-check-config.yaml
|
||||||
|
└── target/ # 构建产物
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.1 Maven 坐标
|
### 5.1 Maven 坐标
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<groupId>com.codechecker</groupId>
|
<groupId>com.codechecker</groupId>
|
||||||
<artifactId>redis-schema-checker</artifactId>
|
<artifactId>serialization-schema-checker</artifactId>
|
||||||
<version>1.0.0-SNAPSHOT</version>
|
<version>1.0.0</version>
|
||||||
```
|
```
|
||||||
|
|
||||||
打包为 **shaded/fat jar**,主类:`com.codechecker.redis.cli.RedisSchemaCheckerMain`
|
打包为 **shaded/fat jar**,主类:`com.codechecker.cache.cli.SerializationSchemaCheckerMain`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -241,8 +218,8 @@ redisCheck/
|
|||||||
### 6.1 CLI 参数
|
### 6.1 CLI 参数
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -jar redis-schema-checker-1.0.0.jar \
|
java -jar serialization-schema-checker-1.0.0.jar \
|
||||||
--config .gitea/config/redis-schema-check-config.yaml \
|
--config .gitea/config/serialization-schema-check-config.yaml \
|
||||||
--repo-root /path/to/jnpf-java-cloud \
|
--repo-root /path/to/jnpf-java-cloud \
|
||||||
--old-sha abc123 \
|
--old-sha abc123 \
|
||||||
--new-sha def456 \
|
--new-sha def456 \
|
||||||
@@ -263,19 +240,23 @@ java -jar redis-schema-checker-1.0.0.jar \
|
|||||||
|
|
||||||
### 6.2 对比基准(old-sha)获取策略
|
### 6.2 对比基准(old-sha)获取策略
|
||||||
|
|
||||||
与 `demo.yaml` 保持一致,优先级:
|
流水线使用 **push 区间累计对比**(方案:`before` → `after`):
|
||||||
|
|
||||||
1. 流水线显式传入 `--old-sha`(通常为 `HEAD~1`)
|
1. `--new-sha` = `gitea.sha`(push 后 tip)
|
||||||
2. 若 `HEAD~1` 不存在(首次提交)→ 跳过检测,`exit 0`
|
2. `--old-sha` = `gitea.event.before`(push 前 tip)
|
||||||
3. 浅克隆 `--depth 2` 确保 `HEAD~1` 可用
|
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 处理步骤
|
### 6.3 处理步骤
|
||||||
|
|
||||||
#### Step 1:加载配置
|
#### Step 1:加载配置
|
||||||
|
|
||||||
读取 `redis-schema-check-config.yaml`,合并默认值(见 `docs/配置说明.md`)。
|
读取 `serialization-schema-check-config.yaml`,合并默认值(见 `docs/配置说明.md`)。
|
||||||
|
|
||||||
#### Step 2:Git Diff 扫描
|
#### Step 2:Git Diff 扫描
|
||||||
|
|
||||||
@@ -297,20 +278,32 @@ git diff --name-only {old-sha} {new-sha} -- '*.java'
|
|||||||
|
|
||||||
在 **变更文件** 中扫描以下 AST 模式:
|
在 **变更文件** 中扫描以下 AST 模式:
|
||||||
|
|
||||||
| 模式 ID | 匹配表达式 | 提取信息 |
|
| 模式 ID | 匹配表达式 | 提取信息 | 状态 |
|
||||||
|---------|------------|----------|
|
|---------|------------|----------|------|
|
||||||
| W01 | `redisUtil.insert(key, JSON.toJSONString(expr), ttl)` | key 表达式、value 表达式 |
|
| W01 | `redisUtil.insert(key, JSON.toJSONString(expr), ttl)` | key 表达式、value 表达式 | ✅ |
|
||||||
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(expr), ...)` | 同上 |
|
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(expr), ...)` | 同上 | ✅ |
|
||||||
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(expr), ...)` | 同上 |
|
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(expr), ...)` | 同上 | ✅ |
|
||||||
| W04 | `redisTemplate.opsForValue().set(key, expr, ...)` 且 expr 非字面量 | 第二阶段 |
|
| W04 | `redisTemplate.opsForValue().set(key, expr, ...)` 且 expr 非字面量 | 直写对象类型 | ✅ |
|
||||||
| W05 | `redisTemplate.opsForHash().put(key, field, expr)` | 第二阶段 |
|
| W05 | `redisTemplate.opsForHash().put(key, field, expr)` | Hash 写出 value 类型 | ✅ |
|
||||||
| W06 | `JSON.parseObject(cacheValue, Xxx.class)` | 辅助反向确认读取类型 |
|
|
||||||
|
|
||||||
**忽略规则**(自动):
|
**忽略规则**(自动):
|
||||||
|
|
||||||
- value 为字符串字面量、数字、`UUID`、`"1"` 等
|
- value 为字符串字面量、数字、`UUID`、`"1"` 等琐碎值
|
||||||
- 方法名含 `setIfAbsent`、`increment`、`delete`、`remove`、`expire`
|
- 方法名含 `setIfAbsent`、`increment`、`delete`、`remove`、`expire` 等
|
||||||
- key 匹配 `ignore_key_patterns` 配置
|
- 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:类型推断
|
#### Step 5:类型推断
|
||||||
|
|
||||||
@@ -332,15 +325,16 @@ redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), ttl);
|
|||||||
4. 递归展开 `TenantVO` → `dbName: String`、`linkList: List<TenantLinkModel>`
|
4. 递归展开 `TenantVO` → `dbName: String`、`linkList: List<TenantLinkModel>`
|
||||||
5. 继续展开 `TenantLinkModel` 全部字段
|
5. 继续展开 `TenantLinkModel` 全部字段
|
||||||
|
|
||||||
**注解处理**(第一版):
|
**注解处理**:
|
||||||
|
|
||||||
| 注解 | 行为 |
|
| 注解 | 行为 | 状态 |
|
||||||
|------|------|
|
|------|------|------|
|
||||||
| `@JSONField(serialize = false)` | 排除字段 |
|
| `@JSONField(serialize = false)` | 排除字段 | ✅ |
|
||||||
| `@JSONField(name = "xxx")` | 字段名映射 |
|
| `@JSONField(name = "xxx")` | 字段名映射 | ✅ |
|
||||||
| `@JsonIgnore` | 排除字段 |
|
| `@JsonIgnore` | 排除字段 | ✅ |
|
||||||
| `@JsonProperty("xxx")` | 字段名映射 |
|
| `@JsonProperty("xxx")` | 字段名映射 | ✅ |
|
||||||
| `@Schema` | 忽略(不影响序列化) |
|
| `@JsonIgnoreProperties({...})` | 类级忽略字段 | ✅ |
|
||||||
|
| `@Schema` | 忽略(不影响序列化) | ✅ |
|
||||||
|
|
||||||
#### Step 6:生成 JSON Schema
|
#### Step 6:生成 JSON Schema
|
||||||
|
|
||||||
@@ -372,37 +366,47 @@ redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), ttl);
|
|||||||
|
|
||||||
对比同一写入点在 old/new 两个版本的 `TypeSchema`,输出 `SchemaChange` 列表。
|
对比同一写入点在 old/new 两个版本的 `TypeSchema`,输出 `SchemaChange` 列表。
|
||||||
|
|
||||||
**变更类型与严重级别**:
|
**变更类型**(有结构差异即告警;`block` 下任意变更均阻断):
|
||||||
|
|
||||||
| 变更类型 | 示例 | 默认级别 |
|
| 变更类型 | 示例 |
|
||||||
|----------|------|----------|
|
|----------|------|
|
||||||
| `FIELD_REMOVED` | 删除 `dbName` | P0 |
|
| `FIELD_REMOVED` | 删除 `dbName` |
|
||||||
| `TYPE_CHANGED` | `linkList` 从数组变对象 | P0 |
|
| `TYPE_CHANGED` | `linkList` 从数组变对象 |
|
||||||
| `WRAPPER_ADDED` | 顶层增加 `vo` 包装 | P0 |
|
| `WRAPPER_ADDED` | 顶层增加 `vo` 包装 |
|
||||||
| `FIELD_PATH_MOVED` | `dbName` → `vo.dbName` | P0 |
|
| `FIELD_PATH_MOVED` | `dbName` → `vo.dbName` |
|
||||||
| `FIELD_ADDED` | 新增 `expiresAtMs` | P1 |
|
| `FIELD_ADDED` | 新增 `expiresAtMs` |
|
||||||
| `KEY_PATTERN_CHANGED` | key 常量变更 | P1 |
|
| `KEY_PATTERN_CHANGED` | key 常量变更 |
|
||||||
| `WRITE_POINT_REMOVED` | 删除缓存写入 | P1 |
|
| `WRITE_POINT_REMOVED` | 删除缓存写入 |
|
||||||
| `WRITE_POINT_ADDED` | 新增缓存写入 | P2 |
|
| `WRITE_POINT_ADDED` | 新增缓存写入 |
|
||||||
| `LOW_CONFIDENCE` | 类型推断失败 | P2 |
|
| `LOW_CONFIDENCE` | 类型推断失败(仍提示,置信度较低) |
|
||||||
|
|
||||||
#### Step 8:报告与通知
|
#### Step 8:报告与通知
|
||||||
|
|
||||||
生成 `CheckReport`,包含:
|
生成 `CheckReport`,包含:
|
||||||
|
|
||||||
- 仓库名、分支、old/new sha、提交人、时间
|
- 仓库名、分支、old/new sha、提交人、时间
|
||||||
- 变更列表(按严重级别排序)
|
- 按 Key 聚合的结构变更(骨架 before/after)+ 字段级明细
|
||||||
- 每项:Key 模式、写入位置(类#方法:行号)、旧结构、新结构、变更摘要
|
- 每项通用展示:**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:退出码
|
#### Step 9:退出码
|
||||||
|
|
||||||
| 条件 | 退出码 |
|
| 条件 | 退出码 |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| 无变更 / 仅 P2 | 0 |
|
| `enabled=false` / 无变更 | 0 |
|
||||||
| `mode=notify` 且存在 P0/P1 | 0(仍通知) |
|
| `mode=notify` 且存在变更 | 0(仍通知) |
|
||||||
| `mode=block` 且存在 P0/P1/P2 | 1 |
|
| `mode=block` 且存在任意结构变更 | 1 |
|
||||||
| 配置错误 / 执行异常 | 2 |
|
| 配置错误 / 执行异常 | 2 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -439,16 +443,16 @@ manual_mappings:
|
|||||||
|
|
||||||
| 层级 | 位置 | 职责 |
|
| 层级 | 位置 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 默认配置 | 工具 jar 内 `default-config.yaml` | 检测模式、忽略规则、严重级别默认值 |
|
| 默认配置 | 工具 jar 内 `default-config.yaml` | 检测模式、忽略规则等默认值 |
|
||||||
| 业务覆盖 | `jnpf-java-cloud/.gitea/config/redis-schema-check-config.yaml` | mode、notify、include_modules、manual_mappings |
|
| 业务覆盖 | `jnpf-java-cloud/.gitea/config/serialization-schema-check-config.yaml` | mode、notify、include_modules、manual_mappings |
|
||||||
|
|
||||||
合并规则:**业务配置覆盖默认配置**,未声明的项沿用默认值。
|
合并规则:**业务配置覆盖默认配置**,未声明的项沿用默认值。
|
||||||
|
|
||||||
CLI 调用:
|
CLI 调用:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
java -jar redis-schema-checker.jar \
|
java -jar serialization-schema-checker.jar \
|
||||||
--config .gitea/config/redis-schema-check-config.yaml \
|
--config .gitea/config/serialization-schema-check-config.yaml \
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -457,21 +461,20 @@ java -jar redis-schema-checker.jar \
|
|||||||
详见 `docs/配置说明.md`。核心开关:
|
详见 `docs/配置说明.md`。核心开关:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# 运行模式:notify(仅通知)| block(P0/P1/P2 全部阻断流水线)
|
# 总开关:false 时跳过检测与通知,流水线直接通过
|
||||||
mode: notify
|
enabled: true
|
||||||
|
|
||||||
# block 模式下触发 exit 1 的严重级别(全部阻断)
|
# 运行模式:notify(仅通知)| block(检测到结构变更即阻断流水线)
|
||||||
block_severities:
|
mode: notify
|
||||||
- P0
|
|
||||||
- P1
|
|
||||||
- P2
|
|
||||||
|
|
||||||
# 是否发送企微通知
|
# 是否发送企微通知
|
||||||
notify:
|
notify:
|
||||||
enabled: true
|
enabled: true
|
||||||
webhook_env: WECOM_ROBOT_WEBHOOK
|
webhook_url: "" # 企微 Webhook 完整 URL;兼容旧字段 webhook_env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
企微消息约定见 `docs/配置说明.md` §5:**位置/类型**为每个 Key 的通用项;删除字段橙色、新增字段绿色。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. CI 集成方案
|
## 9. CI 集成方案
|
||||||
@@ -479,85 +482,76 @@ notify:
|
|||||||
详见 `docs/CI集成说明.md`。核心流程:
|
详见 `docs/CI集成说明.md`。核心流程:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# jnpf-java-cloud/.gitea/workflows/redis-schema-check.yaml
|
# jnpf-java-cloud/.gitea/workflows/serialization-schema-check.yaml(要点)
|
||||||
name: Redis序列化结构检查
|
# 检出:浅克隆 tip(depth 1)
|
||||||
on: [push]
|
# 检测:--old-sha = gitea.event.before,--new-sha = gitea.sha
|
||||||
|
# 按需 fetch before 提交对象,覆盖一次 push 的多 commit 累计 diff
|
||||||
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')"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
完整模板见 `docs/CI集成说明.md` / `.gitea/workflows/serialization-schema-check.yaml`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 分阶段交付计划
|
## 10. 分阶段交付计划
|
||||||
|
|
||||||
### Phase 1 — MVP(约 1.5 周)
|
### Phase 1 — MVP✅
|
||||||
|
|
||||||
**目标**:跑通端到端链路,覆盖租户缓存典型场景。
|
**目标**:跑通端到端链路,覆盖租户缓存典型场景。
|
||||||
|
|
||||||
| 任务 | 产出 |
|
| 任务 | 产出 | 状态 |
|
||||||
|------|------|
|
|------|------|------|
|
||||||
| Maven 工程骨架 + CLI | 可执行 fat-jar |
|
| Maven 工程骨架 + CLI | 可执行 fat-jar | ✅ |
|
||||||
| Git diff 扫描 | 变更文件列表 |
|
| Git diff 扫描 | 变更文件列表 | ✅ |
|
||||||
| W01~W03 写入点检测 | 覆盖 JSON 字符串写入 |
|
| W01~W03 写入点检测 | 覆盖 JSON 字符串写入 | ✅ |
|
||||||
| 基础 Schema 提取 | 支持普通类、内部类、List、嵌套 |
|
| 基础 Schema 提取 | 支持普通类、内部类、List、嵌套 | ✅ |
|
||||||
| Schema Diff P0/P1 | 字段增删、包装、路径迁移 |
|
| Schema Diff | 字段增删、包装、路径迁移 | ✅ |
|
||||||
| 企微通知 | Markdown 消息 |
|
| 企微通知 | 按 Key 骨架 Markdown | ✅ |
|
||||||
| notify/block 开关 | 配置驱动 |
|
| notify/block / enabled | 配置驱动 | ✅ |
|
||||||
| 夹具测试 | TenantVO/CacheEnvelope 样本 |
|
| 样本夹具测试 | TenantVO/CacheEnvelope 等 | ✅ |
|
||||||
|
|
||||||
**验收标准**:
|
**验收标准**:
|
||||||
|
|
||||||
- 对 `TenantDbContentCacheHelper` 的结构变更能输出 P0 报告
|
- 对 `TenantDbContentCacheHelper` 的结构变更能输出报告并通知
|
||||||
- 流水线 push 后能收到企微通知
|
- 流水线 push 后能收到企微通知
|
||||||
- `mode=block` 时任意 P0/P1/P2 变更导致 exit 1
|
- `mode=block` 时任意结构变更导致 exit 1
|
||||||
|
|
||||||
### Phase 2 — 增强(约 1 周)
|
### Phase 2 — 增强✅
|
||||||
|
|
||||||
| 任务 | 说明 |
|
| 任务 | 说明 | 状态 |
|
||||||
|------|------|
|
|------|------|------|
|
||||||
| W04/W05 模式 | RedisTemplate 直写对象、Hash 写入 |
|
| W04/W05 模式 | RedisTemplate 直写对象、Hash 写入 | ✅ |
|
||||||
| 注解完整支持 | Fastjson/Jackson 注解 |
|
| 注解完整支持 | Fastjson/Jackson(含 `@JsonIgnoreProperties`) | ✅ |
|
||||||
| Key 推断增强 | `String.format`、常量追溯 |
|
| Key 推断增强 | `String.format`、常量拼接、`buildXxxKey` | ✅ |
|
||||||
| 忽略规则完善 | 锁/计数器/token 自动过滤 |
|
| 忽略规则完善 | 锁/计数器/token/字面量/setIfAbsent | ✅ |
|
||||||
| 多模块性能优化 | 并行解析、缓存索引 |
|
| 多模块性能 | 并行读文件、索引批量装载、`manual_mappings` | ✅ |
|
||||||
|
| 企微高亮 | 删除橙 `warning` / 新增绿 `info`;位置+类型通用项 | ✅ |
|
||||||
|
| W06 读侧辅助 | `parseObject`/`parseArray` 等补强写入 value 类型 | ✅ |
|
||||||
|
|
||||||
### Phase 3 — 运营(约 0.5 周)
|
### Phase 3 — 运营
|
||||||
|
|
||||||
| 任务 | 说明 |
|
| 任务 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 报告落盘 | 可选输出 JSON 报告文件 |
|
| 报告落盘 | 可选输出 JSON 报告文件 |
|
||||||
| 误报反馈 | `suppressions` 配置支持按写入点忽略 |
|
| 误报反馈 | `suppressions` 按写入点 / change_types 精细忽略 |
|
||||||
| 更多业务场景覆盖 | 考勤、文件下载进度等 |
|
| 更多业务场景覆盖 | 考勤、文件下载进度等 |
|
||||||
|
|
||||||
|
### Phase 4 — MQ 消息体结构检测(已落地,含 Kafka)
|
||||||
|
|
||||||
|
业务仓同时存在:
|
||||||
|
|
||||||
|
- **RocketMQ**:`RocketMQTemplate.syncSend` / `asyncSend` / `MessageBuilder` / `RocketMQListener<T>`
|
||||||
|
- **Kafka**:`KafkaTemplate.send(topic|ProducerRecord, …)` / `@KafkaListener` + `parseObject`
|
||||||
|
|
||||||
|
消息体字段变更会导致积压旧消息反序列化失败,风险模型与 Redis 同类。
|
||||||
|
|
||||||
|
| 任务 | 说明 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| 方案文档 | RocketMQ + Kafka 模式、destination、复用 Schema Diff/企微 | ✅ |
|
||||||
|
| Phase M1 | RocketMQ sync/async + Kafka send + 骨架通知 + 夹具 | ✅ |
|
||||||
|
| Phase M2 | MessageBuilder / convertAndSend / ProducerRecord / JSON 字符串 + MQ-R | ✅ |
|
||||||
|
|
||||||
|
**专用方案:** [`docs/MQ序列化结构检测方案.md`](./MQ序列化结构检测方案.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. 测试策略
|
## 11. 测试策略
|
||||||
@@ -567,18 +561,25 @@ jobs:
|
|||||||
- `SchemaDifferTest`:纯字段路径对比逻辑
|
- `SchemaDifferTest`:纯字段路径对比逻辑
|
||||||
- `JavaSchemaExtractorTest`:类字段展开、注解、内部类
|
- `JavaSchemaExtractorTest`:类字段展开、注解、内部类
|
||||||
- `RedisWritePointDetectorTest`:各种写入 AST 模式匹配
|
- `RedisWritePointDetectorTest`:各种写入 AST 模式匹配
|
||||||
|
- `MqWritePointDetectorTest`:RocketMQ/Kafka 投递(含 MQ04 MessageBuilder、MQ-K03 ProducerRecord)
|
||||||
|
- `MqReadHintDetectorTest`:MQ-R Listener / parseObject 补强
|
||||||
- `RedisKeyResolverTest`:常量、format、拼接推断
|
- `RedisKeyResolverTest`:常量、format、拼接推断
|
||||||
|
|
||||||
### 11.2 夹具集成测试
|
### 11.2 样本夹具测试(fixtures)
|
||||||
|
|
||||||
在 `src/test/resources/fixtures/` 放置真实业务代码片段(从 `jnpf-java-cloud` 提取并脱敏),模拟 old/new 两个版本:
|
「夹具」= 放在测试资源里的**脱敏源码样本**(不是连真实 Redis / 不是起 Gitea 流水线)。
|
||||||
|
|
||||||
| 夹具 | 验证点 |
|
路径:`src/test/resources/fixtures/`。测试代码加载这些 `.txt`/Java 片段,在内存中跑检测器 / Schema 对比,用来验证典型业务场景是否被正确识别。
|
||||||
|------|--------|
|
|
||||||
| `tenant-cache/` | 包装结构变更 P0 |
|
| 夹具目录 | 验证点 |
|
||||||
| `attendance-base-setting/` | Map 结构缓存 |
|
|----------|--------|
|
||||||
| `evaluate-config/` | VO 字段新增 P1 |
|
| `fixtures/tenant/` | 包装结构变更(TenantVO → CacheEnvelope) |
|
||||||
| `lock-only/` | 应被忽略 |
|
| `fixtures/lock/` | 锁/计数器/token 应被忽略 |
|
||||||
|
| `fixtures/template/` | W04 Template 直写 |
|
||||||
|
| `fixtures/mq/rocket-wallet/` | RocketMQ syncSend(MQ01) |
|
||||||
|
| `fixtures/mq/rocket-im/` | MessageBuilder + asyncSend(MQ04)、convertAndSend(MQ03) |
|
||||||
|
| `fixtures/mq/kafka-patrol/` | Kafka List 根数组(MQ-K01) |
|
||||||
|
| `fixtures/mq/kafka-record/` | ProducerRecord(MQ-K03)、JSON 字符串 send(MQ-K04) |
|
||||||
|
|
||||||
### 11.3 端到端测试
|
### 11.3 端到端测试
|
||||||
|
|
||||||
@@ -593,8 +594,8 @@ jobs:
|
|||||||
| 类型推断失败 | 漏报 | 标记 `LOW_CONFIDENCE`,配置 `manual_mappings` |
|
| 类型推断失败 | 漏报 | 标记 `LOW_CONFIDENCE`,配置 `manual_mappings` |
|
||||||
| Lombok 复杂注解 | 字段遗漏 | 基于源码字段 + 注解;后续 delombok |
|
| Lombok 复杂注解 | 字段遗漏 | 基于源码字段 + 注解;后续 delombok |
|
||||||
| 同一 key 多分支写不同类型 | 误报 | 报告注明置信度;人工 suppression |
|
| 同一 key 多分支写不同类型 | 误报 | 报告注明置信度;人工 suppression |
|
||||||
| 浅克隆 parent 不可用 | 跳过检测 | `--depth 2`;文档明确要求 |
|
| 浅克隆拿不到 before | 漏检 / exit 2 | 按 SHA `fetch --depth 1` + deepen 兜底;见 CI 说明 |
|
||||||
| 一次 push 多 commit | 仅检最后一个 | 文档说明;后续扩展 range |
|
| 一次 push 多 commit | 旧方案仅看末 commit 会漏检 | 已改为 `before..after` 累计对比 |
|
||||||
| 依赖 jar 中的类型 | 字段展开不完整 | 配置 `manual_mappings` 补充 |
|
| 依赖 jar 中的类型 | 字段展开不完整 | 配置 `manual_mappings` 补充 |
|
||||||
| JsonUtil 实现不可见 | 序列化规则猜测 | 默认按字段名序列化;与 Fastjson 对齐 |
|
| JsonUtil 实现不可见 | 序列化规则猜测 | 默认按字段名序列化;与 Fastjson 对齐 |
|
||||||
|
|
||||||
@@ -604,13 +605,21 @@ jobs:
|
|||||||
|
|
||||||
| # | 决策项 | 结论 |
|
| # | 决策项 | 结论 |
|
||||||
|---|--------|------|
|
|---|--------|------|
|
||||||
| 1 | 阻断范围 | `block` 模式下 **P0/P1/P2 全部阻断**(exit 1) |
|
| 1 | 阻断范围 | `block` 模式下 **任意结构变更均阻断**(exit 1) |
|
||||||
| 2 | 发布坐标 | 独立产物 `com.codechecker:redis-schema-checker:1.0.0` |
|
| 2 | 发布坐标 | 独立产物 `com.codechecker:serialization-schema-checker:1.0.0` |
|
||||||
| 3 | 配置归属 | **双层配置**:jar 内 `default-config.yaml` + 业务仓覆盖合并 |
|
| 3 | 配置归属 | **双层配置**:jar 内 `default-config.yaml` + 业务仓覆盖合并 |
|
||||||
| 4 | 上线策略 | 先 `notify` 观察 **1 周**,稳定后手动切 `block` |
|
| 4 | 上线策略 | 先 `notify` ,稳定后手动切 `block` |
|
||||||
| 5 | 检测范围 | **仅 `src/main/java`**,不扫描测试代码 |
|
| 5 | 检测范围 | **仅 `src/main/java`**,不扫描测试代码 |
|
||||||
|
|
||||||
以上决策已纳入实施方案,可进入开发阶段。
|
以上决策已纳入实施方案;**Phase 1 / Phase 2 已交付**。缓存侧可进入 Phase 3;MQ 侧以 [`MQ序列化结构检测方案.md`](./MQ序列化结构检测方案.md) 为准评审后开发。
|
||||||
|
|
||||||
|
相关文档:
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| `docs/配置说明.md` | 缓存检测双层配置 |
|
||||||
|
| `docs/CI集成说明.md` | 流水线 before/after、排障 |
|
||||||
|
| `docs/MQ序列化结构检测方案.md` | MQ 消息体 Schema 监控方案(扩展) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -636,7 +645,6 @@ public class WritePoint {
|
|||||||
|
|
||||||
```java
|
```java
|
||||||
public class SchemaChange {
|
public class SchemaChange {
|
||||||
Severity severity; // P0, P1, P2
|
|
||||||
ChangeType changeType; // FIELD_REMOVED, WRAPPER_ADDED, ...
|
ChangeType changeType; // FIELD_REMOVED, WRAPPER_ADDED, ...
|
||||||
String keyPattern;
|
String keyPattern;
|
||||||
String writeLocation; // class#method:line
|
String writeLocation; // class#method:line
|
||||||
|
|||||||
274
docs/配置说明.md
274
docs/配置说明.md
@@ -1,6 +1,8 @@
|
|||||||
# Redis 序列化结构检测 — 配置说明
|
# 序列化结构检测 — 配置说明
|
||||||
|
|
||||||
> **双层配置**:工具 jar 内置 `default-config.yaml`(默认) + 业务仓库 `.gitea/config/redis-schema-check-config.yaml`(覆盖)
|
> **双层配置**:工具 jar 内置 `default-config.yaml`(默认) + 业务仓库 `.gitea/config/serialization-schema-check-config.yaml`(覆盖)
|
||||||
|
> 工具坐标:`com.codechecker:serialization-schema-checker:1.0.0`
|
||||||
|
> 主类:`com.codechecker.cache.cli.SerializationSchemaCheckerMain`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,7 +11,7 @@
|
|||||||
```text
|
```text
|
||||||
jar 内 default-config.yaml(工具仓维护)
|
jar 内 default-config.yaml(工具仓维护)
|
||||||
↓ 深度合并
|
↓ 深度合并
|
||||||
业务仓 redis-schema-check-config.yaml(业务仓维护)
|
业务仓 serialization-schema-check-config.yaml(业务仓维护)
|
||||||
↓
|
↓
|
||||||
最终生效配置
|
最终生效配置
|
||||||
```
|
```
|
||||||
@@ -21,42 +23,47 @@ jar 内 default-config.yaml(工具仓维护)
|
|||||||
### 1.1 业务仓最小配置示例
|
### 1.1 业务仓最小配置示例
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# jnpf-java-cloud/.gitea/config/redis-schema-check-config.yaml
|
# jnpf-java-cloud/.gitea/config/serialization-schema-check-config.yaml
|
||||||
|
enabled: true
|
||||||
mode: notify
|
mode: notify
|
||||||
|
|
||||||
notify:
|
notify:
|
||||||
enabled: true
|
enabled: true
|
||||||
webhook_env: WECOM_ROBOT_WEBHOOK
|
# 推荐直接写完整 Webhook;也可用环境变量在流水线注入
|
||||||
|
webhook_url: ""
|
||||||
|
|
||||||
include_modules:
|
include_modules:
|
||||||
- jnpf-tenant
|
- jnpf-tenant
|
||||||
```
|
```
|
||||||
|
|
||||||
|
流水线通常把 Secret 注入环境或配置文件中的 `webhook_url`。兼容旧字段:`webhook_env`(值为 `http` 开头时当作 URL 使用)。
|
||||||
|
|
||||||
### 1.2 工具内置默认配置(jar 内 default-config.yaml)
|
### 1.2 工具内置默认配置(jar 内 default-config.yaml)
|
||||||
|
|
||||||
由 `redisCheck` 仓库维护,随 jar 发布,包含:
|
由 `redisCheck` 仓库维护,随 jar 发布,默认包含:
|
||||||
|
|
||||||
- `detection.patterns`(W01~W03)
|
- `detection.patterns`:**W01~W05**(JSON 字符串写入 + Template 直写 + Hash)
|
||||||
- `ignore.key_patterns`(锁/计数器/token)
|
- `detection.mq_patterns`:**MQ01~MQ05、MQ-K01~MQ-K04**(RocketMQ / Kafka 生产侧)
|
||||||
- `block_severities`(P0/P1/P2)
|
- `detection.read_hints_enabled`:W06 读侧反序列化类型辅助(默认 true)
|
||||||
- `detection.min_confidence`、`max_field_depth` 等
|
- `detection.mq_read_hints_enabled`:MQ-R 读侧 Listener / parse 补强(默认 true)
|
||||||
|
- `ignore.key_patterns`(锁 / 计数器 / token)
|
||||||
|
- `ignore.mq_destinations`(MQ destination 忽略,默认真空)
|
||||||
|
- `detection.min_confidence`、`max_field_depth`
|
||||||
|
- `mode: notify`、`enabled: true`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 业务仓完整配置示例
|
## 2. 业务仓完整配置示例
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
# 总开关:false 时不执行检测、不发通知、流水线直接通过
|
||||||
|
enabled: true
|
||||||
|
|
||||||
# 运行模式
|
# 运行模式
|
||||||
# notify - 仅通知,不阻断流水线
|
# notify - 仅通知,不阻断流水线
|
||||||
# block - 按 block_severities 阻断流水线(exit 1)
|
# block - 检测到结构变更即阻断流水线(exit 1)
|
||||||
mode: notify
|
mode: notify
|
||||||
|
|
||||||
# block 模式下触发 exit 1 的严重级别(全部阻断:P0/P1/P2)
|
|
||||||
block_severities:
|
|
||||||
- P0
|
|
||||||
- P1
|
|
||||||
- P2
|
|
||||||
|
|
||||||
# 是否扫描测试代码(已确认:不扫描)
|
# 是否扫描测试代码(已确认:不扫描)
|
||||||
scan_test_sources: false
|
scan_test_sources: false
|
||||||
|
|
||||||
@@ -67,12 +74,14 @@ source_roots:
|
|||||||
# 通知配置
|
# 通知配置
|
||||||
notify:
|
notify:
|
||||||
enabled: true
|
enabled: true
|
||||||
# 从环境变量读取 Webhook URL
|
# Webhook 完整 URL(优先)
|
||||||
webhook_env: WECOM_ROBOT_WEBHOOK
|
webhook_url: ""
|
||||||
|
# 兼容旧字段:值为 http 开头时视为 URL
|
||||||
|
# webhook_env: WECOM_ROBOT_WEBHOOK
|
||||||
# 无变更时是否也发通知(一般 false)
|
# 无变更时是否也发通知(一般 false)
|
||||||
notify_on_clean: false
|
notify_on_clean: false
|
||||||
# 消息标题前缀
|
# 消息标题前缀
|
||||||
title_prefix: "[Redis结构变更]"
|
title_prefix: "[序列化结构变更]"
|
||||||
|
|
||||||
# 忽略规则
|
# 忽略规则
|
||||||
ignore:
|
ignore:
|
||||||
@@ -80,9 +89,9 @@ ignore:
|
|||||||
key_patterns:
|
key_patterns:
|
||||||
- "*:lock"
|
- "*:lock"
|
||||||
- "*:lock:*"
|
- "*:lock:*"
|
||||||
|
- "*lock*"
|
||||||
- "loginCount:*"
|
- "loginCount:*"
|
||||||
- "Authorization:*"
|
- "Authorization:*"
|
||||||
- "Authorization:login:session:*"
|
|
||||||
|
|
||||||
# 忽略的文件路径模式
|
# 忽略的文件路径模式
|
||||||
file_patterns:
|
file_patterns:
|
||||||
@@ -91,27 +100,43 @@ ignore:
|
|||||||
# 忽略的写入方法(类全名#方法名)
|
# 忽略的写入方法(类全名#方法名)
|
||||||
writer_methods: []
|
writer_methods: []
|
||||||
|
|
||||||
|
# 忽略的 MQ destination(topic 或 topic:tag,glob)
|
||||||
|
mq_destinations: []
|
||||||
|
|
||||||
# 检测规则
|
# 检测规则
|
||||||
detection:
|
detection:
|
||||||
# 启用的写入模式
|
# 启用的写入模式(默认已全部开启)
|
||||||
patterns:
|
patterns:
|
||||||
- W01 # redisUtil.insert + JSON.toJSONString
|
- W01 # redisUtil.insert + JSON.toJSONString
|
||||||
- W02 # redisTemplate.opsForValue().set + JSON.toJSONString
|
- W02 # redisTemplate.opsForValue().set + JSON.toJSONString
|
||||||
- W03 # stringRedisTemplate + JsonUtil.getObjectToString
|
- W03 # stringRedisTemplate + JsonUtil.getObjectToString
|
||||||
# - W04 # redisTemplate 直写对象(Phase 2)
|
- W04 # redisTemplate 直写对象
|
||||||
# - W05 # opsForHash().put(Phase 2)
|
- W05 # opsForHash().put
|
||||||
|
|
||||||
# 类型推断最低置信度,低于此值仅输出 P2 提示
|
# MQ 生产侧投递(Phase M1 + M2)
|
||||||
|
mq_patterns:
|
||||||
|
- MQ01 # rocketMQTemplate.syncSend
|
||||||
|
- MQ02 # asyncSend / syncSendOrderly / sendOneWay
|
||||||
|
- MQ03 # convertAndSend
|
||||||
|
- MQ04 # MessageBuilder.withPayload / Message<T>
|
||||||
|
- MQ05 # 先 toJSONString 再 send String
|
||||||
|
- MQ-K01 # kafkaTemplate.send(topic, payload)
|
||||||
|
- MQ-K02 # kafkaTemplate.send(topic, key, payload)
|
||||||
|
- MQ-K03 # kafkaTemplate.send(ProducerRecord)
|
||||||
|
- MQ-K04 # 先 JSON 序列化为 String 再 send
|
||||||
|
|
||||||
|
# W06:读侧反序列化类型辅助(不产生独立告警)
|
||||||
|
read_hints_enabled: true
|
||||||
|
|
||||||
|
# MQ-R 读侧补强(Listener / parseObject)
|
||||||
|
mq_read_hints_enabled: true
|
||||||
|
|
||||||
|
# 类型推断最低置信度,低于此值标记为低置信度提示
|
||||||
min_confidence: 0.6
|
min_confidence: 0.6
|
||||||
|
|
||||||
# 字段展开最大深度(防止循环引用死循环)
|
# 字段展开最大深度(防止循环引用死循环)
|
||||||
max_field_depth: 8
|
max_field_depth: 8
|
||||||
|
|
||||||
# 严重级别覆盖(可选)
|
|
||||||
severity_overrides:
|
|
||||||
FIELD_ADDED: P1
|
|
||||||
WRITE_POINT_ADDED: P2
|
|
||||||
|
|
||||||
# 人工补充映射(自动推断失败或需精确指定时使用)
|
# 人工补充映射(自动推断失败或需精确指定时使用)
|
||||||
manual_mappings:
|
manual_mappings:
|
||||||
- id: tenant-db-content
|
- id: tenant-db-content
|
||||||
@@ -146,25 +171,28 @@ include_modules:
|
|||||||
|
|
||||||
## 3. 配置项说明
|
## 3. 配置项说明
|
||||||
|
|
||||||
### 3.1 mode
|
### 3.1 enabled
|
||||||
|
|
||||||
|
总开关。默认 `true`。
|
||||||
|
|
||||||
|
| 值 | 行为 |
|
||||||
|
|----|------|
|
||||||
|
| `true` | 正常执行检测(再按 `mode` / `notify` 行为) |
|
||||||
|
| `false` | 跳过检测与通知,流水线 `exit 0`(与 `notify.enabled` 无关) |
|
||||||
|
|
||||||
|
临时关闭时可仅改此项,无需删除 workflow。
|
||||||
|
|
||||||
|
### 3.2 mode
|
||||||
|
|
||||||
| 值 | 行为 |
|
| 值 | 行为 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| `notify` | 检测到变更 → 发企微 → `exit 0` |
|
| `notify` | 检测到变更 → 发企微 → `exit 0` |
|
||||||
| `block` | 检测到 `block_severities` 中的级别 → 发企微 → `exit 1` |
|
| `block` | 检测到任意结构变更 → 发企微 → `exit 1` |
|
||||||
|
|
||||||
### 3.2 block_severities
|
|
||||||
|
|
||||||
默认 `["P0", "P1", "P2"]`,`block` 模式下任意级别变更均 `exit 1`。
|
|
||||||
|
|
||||||
建议上线初期仍使用 `mode: notify` 观察误报情况,确认稳定后再切换:
|
建议上线初期仍使用 `mode: notify` 观察误报情况,确认稳定后再切换:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
mode: block
|
mode: block
|
||||||
block_severities:
|
|
||||||
- P0
|
|
||||||
- P1
|
|
||||||
- P2
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 notify
|
### 3.3 notify
|
||||||
@@ -172,9 +200,10 @@ block_severities:
|
|||||||
| 字段 | 类型 | 默认值 | 说明 |
|
| 字段 | 类型 | 默认值 | 说明 |
|
||||||
|------|------|--------|------|
|
|------|------|--------|------|
|
||||||
| `enabled` | boolean | true | 是否发企微 |
|
| `enabled` | boolean | true | 是否发企微 |
|
||||||
| `webhook_env` | string | WECOM_ROBOT_WEBHOOK | 环境变量名 |
|
| `webhook_url` | string | `""` | 企微机器人 Webhook 完整 URL(优先) |
|
||||||
|
| `webhook_env` | string | — | 兼容旧字段;值为 `http` 开头时当作 URL |
|
||||||
| `notify_on_clean` | boolean | false | 无变更时是否通知 |
|
| `notify_on_clean` | boolean | false | 无变更时是否通知 |
|
||||||
| `title_prefix` | string | [Redis结构变更] | 消息标题前缀 |
|
| `title_prefix` | string | [序列化结构变更] | 消息标题前缀 |
|
||||||
|
|
||||||
### 3.4 ignore.key_patterns
|
### 3.4 ignore.key_patterns
|
||||||
|
|
||||||
@@ -183,25 +212,63 @@ block_severities:
|
|||||||
- `*` 匹配单层
|
- `*` 匹配单层
|
||||||
- `**` 匹配多层
|
- `**` 匹配多层
|
||||||
|
|
||||||
常见内置忽略(代码层也有硬编码兜底):
|
内置/代码层常见过滤:
|
||||||
|
|
||||||
- 分布式锁 key
|
- 分布式锁 key(配置 glob + 方法名忽略)
|
||||||
- 登录计数
|
- 登录计数、session/token
|
||||||
- session/token
|
- 琐碎 value:字面量、`"1"`、`UUID.randomUUID()` 等
|
||||||
|
- 方法:`setIfAbsent` / `increment` / `delete` / `expire` 等
|
||||||
|
|
||||||
### 3.5 detection.patterns
|
### 3.5 detection.patterns
|
||||||
|
|
||||||
| 模式 | 说明 | 阶段 |
|
| 模式 | 说明 | 状态 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| W01 | `redisUtil.insert(key, JSON.toJSONString(x), ttl)` | Phase 1 |
|
| W01 | `redisUtil.insert(key, JSON.toJSONString(x), ttl)` | 已启用 |
|
||||||
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)` | Phase 1 |
|
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)` | 已启用 |
|
||||||
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)` | Phase 1 |
|
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)` | 已启用 |
|
||||||
| W04 | `redisTemplate.opsForValue().set(key, obj, ...)` | Phase 2 |
|
| W04 | `redisTemplate.opsForValue().set(key, obj, ...)` 直写对象 | 已启用(Phase 2) |
|
||||||
| W05 | `redisTemplate.opsForHash().put(key, field, 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_enabled(W06 辅助)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 默认 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `read_hints_enabled` | boolean | true | 扫描 `parseObject` / `parseArray` / `getJsonToBean` 等,补强同文件或同 key 写入点的 value 类型 |
|
||||||
|
|
||||||
|
- **不是写入模式**:不会单独因为「多了一处 parse」而告警
|
||||||
|
- 能关联到 `redis get(key)` 时,还可补强 unresolved key
|
||||||
|
- 覆盖优先级:`manual_mappings` > W06 > 写侧 AST
|
||||||
|
- **仅补强 Redis 写入点**;MQ 投递点走 `mq_read_hints_enabled`(MQ-R)
|
||||||
|
|
||||||
|
关闭示例:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
detection:
|
||||||
|
read_hints_enabled: false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5.2 detection.mq_patterns / ignore.mq_destinations(MQ)
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `mq_patterns` | RocketMQ:`MQ01`~`MQ05`;Kafka:`MQ-K01`~`MQ-K04`;默认已全部开启 |
|
||||||
|
| `mq_read_hints_enabled` | MQ-R 读侧补强(Listener / parse),**默认 true** |
|
||||||
|
| `ignore.mq_destinations` | 忽略 destination(glob),如 `benchmark:*` |
|
||||||
|
|
||||||
|
企微 MQ 块:`- Topic --> ...`,并带 `> **通道**: RocketMQ|Kafka`。
|
||||||
|
|
||||||
|
详见:[MQ序列化结构检测方案.md](./MQ序列化结构检测方案.md)
|
||||||
|
|
||||||
### 3.6 manual_mappings
|
### 3.6 manual_mappings
|
||||||
|
|
||||||
当自动推断不准确时使用。匹配优先级 **高于** 自动推断。
|
当自动推断不准确时使用。匹配优先级 **高于** 自动推断(按 `类全名#方法名` 覆盖 key 模式与 value 类型)。
|
||||||
|
|
||||||
| 字段 | 必填 | 说明 |
|
| 字段 | 必填 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@@ -218,78 +285,105 @@ block_severities:
|
|||||||
```yaml
|
```yaml
|
||||||
suppressions:
|
suppressions:
|
||||||
- id: my-suppression
|
- id: my-suppression
|
||||||
writer_method: "com.example.FooService#cacheBar"
|
key_pattern: "file:download:user:progress:*"
|
||||||
change_types:
|
change_types:
|
||||||
- FIELD_ADDED
|
- FIELD_ADDED
|
||||||
reason: "新增字段向后兼容"
|
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 中配置。
|
在 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
|
```markdown
|
||||||
## [Redis结构变更] jnpf-java-cloud
|
## [序列化结构变更] jnpf-java-cloud
|
||||||
|
|
||||||
> 分支: feature/tenant-cache
|
> **分支**: code/redis_change_detection_v1.0
|
||||||
> 提交: a1b2c3d → e4f5g6h
|
> **提交**: cedd161c → 67c8a6eb
|
||||||
> 提交人: zhangsan
|
> **提交人**: dongzi
|
||||||
> 时间: 2026-07-13 14:00:00
|
> **时间**: 2026-07-13 16:54:17
|
||||||
> 模式: notify
|
|
||||||
|
|
||||||
### P0 - 顶层结构包装变更
|
- Key --> `tenant:db:content:*`
|
||||||
- **Key**: `tenant:db:content:*`
|
> **位置**: `TenantDbContentCacheHelper#cacheSuccess:92`
|
||||||
- **位置**: `TenantDbContentCacheHelper#cacheSuccess:92`
|
> **类型**: `CacheEnvelope`
|
||||||
- **变更**:
|
> **value值由:** “{"dbName":"","linkList":[{"id":""}]}”
|
||||||
- `dbName` → `vo.dbName`(字段路径迁移)
|
> **变更为:** “…(仅新增/迁移字段片段带 <font color="info">绿色</font>)…”
|
||||||
- `linkList` → `vo.linkList`(字段路径迁移)
|
|
||||||
- 新增顶层字段 `expiresAtMs`
|
|
||||||
- **影响**: 旧缓存反序列化可能失败,需评估缓存刷新策略
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
实际发送时仅对改动属性片段染色:新增 → `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. 推荐上线配置
|
||||||
|
|
||||||
### 6.1 观察期(第 1 周,已确认策略)
|
|
||||||
|
|
||||||
业务仓默认配置:
|
业务仓默认配置:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
enabled: true
|
||||||
mode: notify
|
mode: notify
|
||||||
|
|
||||||
notify:
|
notify:
|
||||||
enabled: true
|
enabled: true
|
||||||
webhook_env: WECOM_ROBOT_WEBHOOK
|
webhook_url: "" # 由流水线写入真实 Webhook
|
||||||
|
|
||||||
include_modules:
|
include_modules: [] # 全仓
|
||||||
- jnpf-tenant
|
|
||||||
```
|
```
|
||||||
|
|
||||||
观察满 1 周、确认误报可接受后,手动切换:
|
---
|
||||||
|
|
||||||
```yaml
|
## 7. MQ 消息体检测(Phase M1 + M2 已落地)
|
||||||
mode: block
|
|
||||||
block_severities: [P0, P1, P2]
|
|
||||||
include_modules: [] # 扩至全仓
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 全量启用(观察期结束后)
|
同一 jar 默认启用 RocketMQ / Kafka **生产侧**投递检测(`detection.mq_patterns`)与 **MQ-R** 读侧补强,与 Redis 共用 Diff、骨架与企微模板。
|
||||||
|
|
||||||
```yaml
|
- 配置说明见 §3.5.2
|
||||||
mode: block
|
- 含 `MessageBuilder` / `convertAndSend` / `ProducerRecord` / JSON 字符串发送、Listener/`parseObject` 补强
|
||||||
block_severities: [P0, P1, P2]
|
- 完整设计:[MQ序列化结构检测方案.md](./MQ序列化结构检测方案.md)
|
||||||
include_modules: [] # 空表示全部模块
|
|
||||||
detection:
|
|
||||||
patterns: [W01, W02, W03, W04, W05]
|
|
||||||
```
|
|
||||||
113
pom.xml
113
pom.xml
@@ -5,16 +5,12 @@
|
|||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<groupId>com.codechecker</groupId>
|
<groupId>com.codechecker</groupId>
|
||||||
<artifactId>redis-schema-checker-parent</artifactId>
|
<artifactId>serialization-schema-checker</artifactId>
|
||||||
<version>1.0.0-SNAPSHOT</version>
|
<version>1.0.0</version>
|
||||||
<packaging>pom</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<name>redis-schema-checker-parent</name>
|
<name>serialization-schema-checker</name>
|
||||||
<description>Redis 序列化结构变更检测工具(父工程)</description>
|
<description>基于 JavaParser 的缓存/MQ value 序列化结构变更检测器</description>
|
||||||
|
|
||||||
<modules>
|
|
||||||
<module>redis-schema-checker</module>
|
|
||||||
</modules>
|
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.source>11</maven.compiler.source>
|
<maven.compiler.source>11</maven.compiler.source>
|
||||||
@@ -27,5 +23,104 @@
|
|||||||
<picocli.version>4.7.5</picocli.version>
|
<picocli.version>4.7.5</picocli.version>
|
||||||
<junit.version>5.10.2</junit.version>
|
<junit.version>5.10.2</junit.version>
|
||||||
<maven.shade.version>3.5.1</maven.shade.version>
|
<maven.shade.version>3.5.1</maven.shade.version>
|
||||||
|
<maven.deploy.plugin.version>3.1.2</maven.deploy.plugin.version>
|
||||||
</properties>
|
</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>serialization-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.SerializationSchemaCheckerMain</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>
|
</project>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package com.codechecker.redis.diff;
|
|
||||||
|
|
||||||
import com.codechecker.redis.schema.FieldSchema;
|
|
||||||
import com.codechecker.redis.schema.JsonType;
|
|
||||||
import com.codechecker.redis.schema.TypeSchema;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对比两个版本的 {@link TypeSchema},输出结构变更列表。基于叶子字段路径集合进行差异分析。
|
|
||||||
*/
|
|
||||||
public class SchemaDiffer {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return 结构变更列表(不含 keyPattern/location 上下文,由调用方补充)
|
|
||||||
*/
|
|
||||||
public List<SchemaChange> diff(TypeSchema oldSchema, TypeSchema newSchema) {
|
|
||||||
List<SchemaChange> changes = new ArrayList<>();
|
|
||||||
|
|
||||||
Map<String, JsonType> oldLeaves = leaves(oldSchema);
|
|
||||||
Map<String, JsonType> newLeaves = leaves(newSchema);
|
|
||||||
|
|
||||||
Set<String> removed = new LinkedHashSet<>(oldLeaves.keySet());
|
|
||||||
removed.removeAll(newLeaves.keySet());
|
|
||||||
Set<String> added = new LinkedHashSet<>(newLeaves.keySet());
|
|
||||||
added.removeAll(oldLeaves.keySet());
|
|
||||||
|
|
||||||
// 类型变更(同路径)
|
|
||||||
for (String path : oldLeaves.keySet()) {
|
|
||||||
if (newLeaves.containsKey(path)) {
|
|
||||||
JsonType oldType = oldLeaves.get(path);
|
|
||||||
JsonType newType = newLeaves.get(path);
|
|
||||||
if (oldType != newType) {
|
|
||||||
SchemaChange c = new SchemaChange(ChangeType.TYPE_CHANGED);
|
|
||||||
c.setFieldPath(path);
|
|
||||||
c.setOldValue(oldType.name());
|
|
||||||
c.setNewValue(newType.name());
|
|
||||||
c.setMessage("字段 " + path + " 类型由 " + oldType + " 变为 " + newType);
|
|
||||||
changes.add(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 路径迁移检测(removed 的路径是某 added 路径的后缀)
|
|
||||||
List<String[]> moves = new ArrayList<>();
|
|
||||||
Set<String> matchedRemoved = new LinkedHashSet<>();
|
|
||||||
Set<String> matchedAdded = new LinkedHashSet<>();
|
|
||||||
for (String r : removed) {
|
|
||||||
for (String a : added) {
|
|
||||||
if (matchedAdded.contains(a)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isSuffix(a, r)) {
|
|
||||||
moves.add(new String[]{r, a});
|
|
||||||
matchedRemoved.add(r);
|
|
||||||
matchedAdded.add(a);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 包装层检测:多个迁移共享同一新前缀
|
|
||||||
Map<String, Integer> prefixCount = new LinkedHashMap<>();
|
|
||||||
for (String[] move : moves) {
|
|
||||||
String a = move[1];
|
|
||||||
if (a.contains(".")) {
|
|
||||||
String prefix = a.substring(0, a.indexOf('.'));
|
|
||||||
prefixCount.merge(prefix, 1, Integer::sum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (Map.Entry<String, Integer> e : prefixCount.entrySet()) {
|
|
||||||
if (e.getValue() >= 2) {
|
|
||||||
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED);
|
|
||||||
c.setFieldPath(e.getKey());
|
|
||||||
c.setNewValue(e.getKey());
|
|
||||||
c.setMessage("新增包装层 " + e.getKey() + ",原顶层字段被下移至该层(影响 " + e.getValue() + " 个字段)");
|
|
||||||
changes.add(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (String[] move : moves) {
|
|
||||||
SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
|
||||||
c.setFieldPath(move[1]);
|
|
||||||
c.setOldValue(move[0]);
|
|
||||||
c.setNewValue(move[1]);
|
|
||||||
c.setMessage("字段路径迁移:" + move[0] + " → " + move[1]);
|
|
||||||
changes.add(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 剩余删除
|
|
||||||
for (String r : removed) {
|
|
||||||
if (matchedRemoved.contains(r)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED);
|
|
||||||
c.setFieldPath(r);
|
|
||||||
c.setOldValue(oldLeaves.get(r).name());
|
|
||||||
c.setMessage("删除字段 " + r);
|
|
||||||
changes.add(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 剩余新增
|
|
||||||
for (String a : added) {
|
|
||||||
if (matchedAdded.contains(a)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED);
|
|
||||||
c.setFieldPath(a);
|
|
||||||
c.setNewValue(newLeaves.get(a).name());
|
|
||||||
c.setMessage("新增字段 " + a);
|
|
||||||
changes.add(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, JsonType> leaves(TypeSchema schema) {
|
|
||||||
Map<String, JsonType> result = new LinkedHashMap<>();
|
|
||||||
for (FieldSchema f : schema.getFields().values()) {
|
|
||||||
if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) {
|
|
||||||
result.put(f.getPath(), f.getJsonType());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断 full 的按段后缀是否等于 suffix(如 vo.dbName 的后缀是 dbName)。
|
|
||||||
*/
|
|
||||||
private boolean isSuffix(String full, String suffix) {
|
|
||||||
if (full.equals(suffix)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
String[] fullSeg = full.split("\\.");
|
|
||||||
String[] sufSeg = suffix.split("\\.");
|
|
||||||
if (sufSeg.length >= fullSeg.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (int i = 1; i <= sufSeg.length; i++) {
|
|
||||||
if (!fullSeg[fullSeg.length - i].equals(sufSeg[sufSeg.length - i])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# redis-schema-checker 内置默认配置
|
|
||||||
# 业务仓库通过 --config 指定的配置会与本文件深度合并(业务配置优先)。
|
|
||||||
|
|
||||||
# 运行模式:notify(仅通知)| block(按 block_severities 阻断,exit 1)
|
|
||||||
mode: notify
|
|
||||||
|
|
||||||
# block 模式下触发阻断的严重级别(默认全部阻断)
|
|
||||||
block_severities:
|
|
||||||
- P0
|
|
||||||
- P1
|
|
||||||
- P2
|
|
||||||
|
|
||||||
# 是否扫描测试代码(第一版固定 false)
|
|
||||||
scan_test_sources: false
|
|
||||||
|
|
||||||
# 源码扫描根目录(相对被检测模块)
|
|
||||||
source_roots:
|
|
||||||
- "src/main/java"
|
|
||||||
|
|
||||||
# 通知配置
|
|
||||||
notify:
|
|
||||||
enabled: true
|
|
||||||
webhook_env: "WECOM_ROBOT_WEBHOOK"
|
|
||||||
notify_on_clean: false
|
|
||||||
title_prefix: "[Redis结构变更]"
|
|
||||||
|
|
||||||
# 忽略规则
|
|
||||||
ignore:
|
|
||||||
# 忽略的 key 模式(glob:* 单层,** 多层)
|
|
||||||
key_patterns:
|
|
||||||
- "*:lock"
|
|
||||||
- "*:lock:*"
|
|
||||||
- "*lock*"
|
|
||||||
- "loginCount:*"
|
|
||||||
- "Authorization:*"
|
|
||||||
# 忽略的文件路径模式
|
|
||||||
file_patterns:
|
|
||||||
- "**/test/**"
|
|
||||||
# 忽略的写入方法(类全名#方法名)
|
|
||||||
writer_methods: []
|
|
||||||
|
|
||||||
# 检测规则
|
|
||||||
detection:
|
|
||||||
patterns:
|
|
||||||
- W01 # redisUtil.insert(key, JSON.toJSONString(x), ttl)
|
|
||||||
- W02 # redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)
|
|
||||||
- W03 # stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)
|
|
||||||
# 类型推断最低置信度,低于此值降级为 P2 提示
|
|
||||||
min_confidence: 0.6
|
|
||||||
# 字段展开最大深度(防止循环引用)
|
|
||||||
max_field_depth: 8
|
|
||||||
|
|
||||||
# 严重级别覆盖
|
|
||||||
severity_overrides: {}
|
|
||||||
|
|
||||||
# 人工补充映射
|
|
||||||
manual_mappings: []
|
|
||||||
|
|
||||||
# 抑制规则(已知误报)
|
|
||||||
suppressions: []
|
|
||||||
|
|
||||||
# 模块过滤(空表示全部模块)
|
|
||||||
include_modules: []
|
|
||||||
exclude_modules: []
|
|
||||||
@@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package com.codechecker.redis.diff;
|
|
||||||
|
|
||||||
import com.codechecker.redis.schema.FieldSchema;
|
|
||||||
import com.codechecker.redis.schema.JsonType;
|
|
||||||
import com.codechecker.redis.schema.TypeSchema;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
class SchemaDifferTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void detectsWrapperMoveAndAddition() {
|
|
||||||
TypeSchema oldS = new TypeSchema("TenantVO");
|
|
||||||
oldS.add(new FieldSchema("dbName", JsonType.STRING, "String"));
|
|
||||||
oldS.add(new FieldSchema("linkList", JsonType.ARRAY, "List"));
|
|
||||||
oldS.add(new FieldSchema("linkList[]", JsonType.OBJECT, "TenantLinkModel"));
|
|
||||||
oldS.add(new FieldSchema("linkList[].id", JsonType.STRING, "String"));
|
|
||||||
|
|
||||||
TypeSchema newS = new TypeSchema("CacheEnvelope");
|
|
||||||
newS.add(new FieldSchema("vo", JsonType.OBJECT, "TenantVO"));
|
|
||||||
newS.add(new FieldSchema("vo.dbName", JsonType.STRING, "String"));
|
|
||||||
newS.add(new FieldSchema("vo.linkList", JsonType.ARRAY, "List"));
|
|
||||||
newS.add(new FieldSchema("vo.linkList[]", JsonType.OBJECT, "TenantLinkModel"));
|
|
||||||
newS.add(new FieldSchema("vo.linkList[].id", JsonType.STRING, "String"));
|
|
||||||
newS.add(new FieldSchema("expiresAtMs", JsonType.NUMBER, "Long"));
|
|
||||||
|
|
||||||
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
|
||||||
List<ChangeType> types = changes.stream().map(SchemaChange::getChangeType).collect(Collectors.toList());
|
|
||||||
|
|
||||||
assertTrue(types.contains(ChangeType.WRAPPER_ADDED), "应检测到包装层新增");
|
|
||||||
assertTrue(types.contains(ChangeType.FIELD_PATH_MOVED), "应检测到字段路径迁移");
|
|
||||||
assertTrue(types.contains(ChangeType.FIELD_ADDED), "应检测到新增字段 expiresAtMs");
|
|
||||||
|
|
||||||
long moved = changes.stream().filter(c -> c.getChangeType() == ChangeType.FIELD_PATH_MOVED).count();
|
|
||||||
assertEquals(2, moved, "dbName 与 linkList[].id 均应迁移");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void detectsTypeChange() {
|
|
||||||
TypeSchema oldS = new TypeSchema("A");
|
|
||||||
oldS.add(new FieldSchema("count", JsonType.STRING, "String"));
|
|
||||||
TypeSchema newS = new TypeSchema("A");
|
|
||||||
newS.add(new FieldSchema("count", JsonType.NUMBER, "Integer"));
|
|
||||||
|
|
||||||
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
|
||||||
assertEquals(1, changes.size());
|
|
||||||
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
|
||||||
assertEquals(Severity.P0, changes.get(0).getSeverity());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void noChangeWhenIdentical() {
|
|
||||||
TypeSchema a = new TypeSchema("A");
|
|
||||||
a.add(new FieldSchema("x", JsonType.STRING, "String"));
|
|
||||||
TypeSchema b = new TypeSchema("A");
|
|
||||||
b.add(new FieldSchema("x", JsonType.STRING, "String"));
|
|
||||||
assertTrue(new SchemaDiffer().diff(a, b).isEmpty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.analyze;
|
package com.codechecker.cache.analyze;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.UncheckedIOException;
|
import java.io.UncheckedIOException;
|
||||||
@@ -31,18 +31,20 @@ public class FileScanner {
|
|||||||
public Map<String, String> scan() {
|
public Map<String, String> scan() {
|
||||||
Map<String, String> result = new LinkedHashMap<>();
|
Map<String, String> result = new LinkedHashMap<>();
|
||||||
try (Stream<Path> stream = Files.walk(repoRoot)) {
|
try (Stream<Path> stream = Files.walk(repoRoot)) {
|
||||||
stream.filter(Files::isRegularFile)
|
List<Path> javaFiles = stream
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
.filter(p -> p.toString().endsWith(".java"))
|
.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('\\', '/');
|
String rel = repoRoot.relativize(p).toString().replace('\\', '/');
|
||||||
if (!rel.contains("/src/main/java/")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!moduleAllowed(rel)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
synchronized (result) {
|
||||||
result.put(rel, new String(Files.readAllBytes(p), StandardCharsets.UTF_8));
|
result.put(rel, new String(Files.readAllBytes(p), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException(e);
|
throw new UncheckedIOException(e);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.analyze;
|
package com.codechecker.cache.analyze;
|
||||||
|
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
668
src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java
vendored
Normal file
668
src/main/java/com/codechecker/cache/analyze/SchemaCheckAnalyzer.java
vendored
Normal file
@@ -0,0 +1,668 @@
|
|||||||
|
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.MqReadHintDetector;
|
||||||
|
import com.codechecker.cache.detector.MqWritePointDetector;
|
||||||
|
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());
|
||||||
|
Set<String> mqPatterns = new HashSet<>(config.getDetection().getMqPatterns());
|
||||||
|
RedisWritePointDetector detectorNew = new RedisWritePointDetector(newIndex, patterns);
|
||||||
|
RedisWritePointDetector detectorOld = new RedisWritePointDetector(oldIndex, patterns);
|
||||||
|
MqWritePointDetector mqDetectorNew = mqPatterns.isEmpty()
|
||||||
|
? null : new MqWritePointDetector(newIndex, mqPatterns);
|
||||||
|
MqWritePointDetector mqDetectorOld = mqPatterns.isEmpty()
|
||||||
|
? null : new MqWritePointDetector(oldIndex, mqPatterns);
|
||||||
|
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<CacheReadHint> mqHintsNew = collectMqReadHints(newContents, newIndex);
|
||||||
|
List<CacheReadHint> mqHintsOld = collectMqReadHints(oldContents, oldIndex);
|
||||||
|
|
||||||
|
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 = new ArrayList<>(detectorNew.detect(path, newContent));
|
||||||
|
List<WritePoint> oldWps = oldContent == null
|
||||||
|
? new ArrayList<>() : new ArrayList<>(detectorOld.detect(path, oldContent));
|
||||||
|
if (mqDetectorNew != null) {
|
||||||
|
newWps.addAll(mqDetectorNew.detect(path, newContent));
|
||||||
|
}
|
||||||
|
if (mqDetectorOld != null && oldContent != null) {
|
||||||
|
oldWps.addAll(mqDetectorOld.detect(path, oldContent));
|
||||||
|
}
|
||||||
|
applyReadHints(newWps, path, newContent, newIndex);
|
||||||
|
if (oldContent != null) {
|
||||||
|
applyReadHints(oldWps, path, oldContent, oldIndex);
|
||||||
|
}
|
||||||
|
applyMqReadHints(newWps, mqHintsNew);
|
||||||
|
applyMqReadHints(oldWps, mqHintsOld);
|
||||||
|
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 (isWritePointIgnored(nw) || 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(nw.isMq()
|
||||||
|
? "新增 MQ 投递点,value 类型: " + displayType(nw)
|
||||||
|
: "新增缓存写入点,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())
|
||||||
|
&& !isWritePointIgnored(ow) && !isWriterIgnored(ow)) {
|
||||||
|
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_REMOVED);
|
||||||
|
fillFromWritePoint(c, ow);
|
||||||
|
c.setMessage(ow.isMq()
|
||||||
|
? "删除 MQ 投递点,原 value 类型: " + displayType(ow)
|
||||||
|
: "删除缓存写入点,原 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()));
|
||||||
|
n.setChannel(wp.getChannel());
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
if (kc.getChannel() == null || kc.getChannel().isEmpty()) {
|
||||||
|
kc.setChannel(wp.getChannel());
|
||||||
|
}
|
||||||
|
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 channel = wp.getChannel() == null ? WritePoint.CHANNEL_REDIS : wp.getChannel();
|
||||||
|
String pattern = wp.getResolvedKeyPattern();
|
||||||
|
if (!isUnresolvedKey(pattern)) {
|
||||||
|
return channel + "|" + (pattern == null ? "<unknown>" : pattern);
|
||||||
|
}
|
||||||
|
return channel + "|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.getChangeType() == ChangeType.WRAPPER_REMOVED) {
|
||||||
|
if (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) {
|
||||||
|
ChangeType t = c.getChangeType();
|
||||||
|
// 新增/删除投递点:按位置区分
|
||||||
|
if (t == ChangeType.WRITE_POINT_ADDED || t == ChangeType.WRITE_POINT_REMOVED) {
|
||||||
|
return t + "|" + c.getKeyPattern() + "|" + c.getWriteLocation();
|
||||||
|
}
|
||||||
|
// 同一 destination 上多处 send 同源 VO:字段级结构变更只保留一条
|
||||||
|
return t + "|" + c.getKeyPattern() + "|" + c.getFieldPath()
|
||||||
|
+ "|" + nvl(c.getOldValue()) + "|" + nvl(c.getNewValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (seen.add(changeDedupKey(c))) {
|
||||||
|
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) {
|
||||||
|
if (wp.isMq()) {
|
||||||
|
continue; // MQ 投递点由 MQ-R 补强
|
||||||
|
}
|
||||||
|
enrichWritePointFromHints(wp, hints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CacheReadHint> collectMqReadHints(Map<String, String> contents, SourceIndex index) {
|
||||||
|
if (!config.getDetection().isMqReadHintsEnabled() || contents == null || contents.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
MqReadHintDetector detector = new MqReadHintDetector(index);
|
||||||
|
List<CacheReadHint> all = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, String> e : contents.entrySet()) {
|
||||||
|
all.addAll(detector.detect(e.getKey(), e.getValue()));
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MQ-R:按 destination 匹配,补强低置信度 / 缺类型的 MQ 投递点。 */
|
||||||
|
private void applyMqReadHints(List<WritePoint> writePoints, List<CacheReadHint> hints) {
|
||||||
|
if (!config.getDetection().isMqReadHintsEnabled()
|
||||||
|
|| writePoints == null || writePoints.isEmpty()
|
||||||
|
|| hints == null || hints.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (WritePoint wp : writePoints) {
|
||||||
|
if (wp.isMq()) {
|
||||||
|
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 isWritePointIgnored(WritePoint wp) {
|
||||||
|
if (wp.isMq()) {
|
||||||
|
return isMqDestinationIgnored(wp.getResolvedKeyPattern());
|
||||||
|
}
|
||||||
|
return isKeyIgnored(wp.getResolvedKeyPattern());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isMqDestinationIgnored(String destination) {
|
||||||
|
if (destination == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (String glob : config.getIgnore().getMqDestinations()) {
|
||||||
|
if (GlobMatcher.matches(glob, destination)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
package com.codechecker.redis.cli;
|
package com.codechecker.cache.cli;
|
||||||
|
|
||||||
import com.codechecker.redis.analyze.SchemaCheckAnalyzer;
|
import com.codechecker.cache.analyze.SchemaCheckAnalyzer;
|
||||||
import com.codechecker.redis.config.CheckerConfig;
|
import com.codechecker.cache.config.CheckerConfig;
|
||||||
import com.codechecker.redis.config.ConfigLoader;
|
import com.codechecker.cache.config.ConfigLoader;
|
||||||
import com.codechecker.redis.notify.WeComNotifier;
|
import com.codechecker.cache.notify.WeComNotifier;
|
||||||
import com.codechecker.redis.report.CheckReport;
|
import com.codechecker.cache.report.CheckReport;
|
||||||
import com.codechecker.redis.report.ReportBuilder;
|
import com.codechecker.cache.report.ReportBuilder;
|
||||||
import picocli.CommandLine;
|
import picocli.CommandLine;
|
||||||
import picocli.CommandLine.Command;
|
import picocli.CommandLine.Command;
|
||||||
import picocli.CommandLine.Option;
|
import picocli.CommandLine.Option;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.util.List;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 命令行入口。退出码:0 通过 / 1 阻断 / 2 执行错误。
|
* 命令行入口。退出码:0 通过 / 1 阻断 / 2 执行错误。
|
||||||
*/
|
*/
|
||||||
@Command(name = "redis-schema-checker",
|
@Command(name = "serialization-schema-checker",
|
||||||
mixinStandardHelpOptions = true,
|
mixinStandardHelpOptions = true,
|
||||||
version = "redis-schema-checker 1.0.0",
|
version = "serialization-schema-checker 1.0.0",
|
||||||
description = "检测两次提交间 Redis value 序列化结构变更并通过企微机器人通知。")
|
description = "检测两次提交间缓存/MQ 等 value 序列化结构变更并通过企微机器人通知。")
|
||||||
public class RedisSchemaCheckerMain implements Callable<Integer> {
|
public class SerializationSchemaCheckerMain implements Callable<Integer> {
|
||||||
|
|
||||||
@Option(names = "--config", required = true, description = "业务仓库检测配置文件路径")
|
@Option(names = "--config", required = true, description = "业务仓库检测配置文件路径")
|
||||||
private Path configPath;
|
private Path configPath;
|
||||||
@@ -55,8 +55,13 @@ public class RedisSchemaCheckerMain implements Callable<Integer> {
|
|||||||
try {
|
try {
|
||||||
CheckerConfig config = ConfigLoader.load(configPath);
|
CheckerConfig config = ConfigLoader.load(configPath);
|
||||||
|
|
||||||
|
if (!config.isEnabled()) {
|
||||||
|
System.out.println("[serialization-schema-checker] 总开关 enabled=false,跳过检测。");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (oldSha == null || oldSha.trim().isEmpty()) {
|
if (oldSha == null || oldSha.trim().isEmpty()) {
|
||||||
System.out.println("[redis-schema-checker] 无对比基准提交,跳过检测。");
|
System.out.println("[serialization-schema-checker] 无对比基准提交,跳过检测。");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,24 +80,27 @@ public class RedisSchemaCheckerMain implements Callable<Integer> {
|
|||||||
boolean shouldNotify = config.getNotify().isEnabled()
|
boolean shouldNotify = config.getNotify().isEnabled()
|
||||||
&& (report.hasChanges() || config.getNotify().isNotifyOnClean());
|
&& (report.hasChanges() || config.getNotify().isNotifyOnClean());
|
||||||
if (shouldNotify && !dryRun) {
|
if (shouldNotify && !dryRun) {
|
||||||
String webhook = System.getenv(config.getNotify().getWebhookEnv());
|
String webhook = config.getNotify().getWebhookUrl();
|
||||||
boolean ok = new WeComNotifier().sendMarkdown(webhook, builder.toMarkdown(report));
|
List<String> messages = builder.toWeComMessages(report);
|
||||||
System.out.println("[redis-schema-checker] 企微通知发送: " + (ok ? "成功" : "失败/跳过"));
|
int ok = new WeComNotifier().sendMarkdownMessages(webhook, messages);
|
||||||
|
System.out.println("[serialization-schema-checker] 企微通知发送: "
|
||||||
|
+ ok + "/" + messages.size()
|
||||||
|
+ (messages.size() > 1 ? "(已按 key 拆分)" : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (report.isBlocked()) {
|
if (report.isBlocked()) {
|
||||||
System.out.println("[redis-schema-checker] block 模式命中,流水线将被阻断(exit 1)。");
|
System.out.println("[serialization-schema-checker] block 模式命中,流水线将被阻断(exit 1)。");
|
||||||
}
|
}
|
||||||
return report.getExitCode();
|
return report.getExitCode();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("[redis-schema-checker] 执行错误: " + e.getMessage());
|
System.err.println("[serialization-schema-checker] 执行错误: " + e.getMessage());
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
int exitCode = new CommandLine(new RedisSchemaCheckerMain()).execute(args);
|
int exitCode = new CommandLine(new SerializationSchemaCheckerMain()).execute(args);
|
||||||
System.exit(exitCode);
|
System.exit(exitCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.config;
|
package com.codechecker.cache.config;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -10,11 +10,12 @@ import java.util.Map;
|
|||||||
*/
|
*/
|
||||||
public class CheckerConfig {
|
public class CheckerConfig {
|
||||||
|
|
||||||
|
/** 总开关:false 时跳过检测与通知(流水线 exit 0) */
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
/** notify | block */
|
/** notify | block */
|
||||||
private String mode = "notify";
|
private String mode = "notify";
|
||||||
|
|
||||||
private List<String> blockSeverities = new ArrayList<>();
|
|
||||||
|
|
||||||
private boolean scanTestSources = false;
|
private boolean scanTestSources = false;
|
||||||
|
|
||||||
private List<String> sourceRoots = new ArrayList<>();
|
private List<String> sourceRoots = new ArrayList<>();
|
||||||
@@ -37,9 +38,10 @@ public class CheckerConfig {
|
|||||||
|
|
||||||
public static class Notify {
|
public static class Notify {
|
||||||
private boolean enabled = true;
|
private boolean enabled = true;
|
||||||
private String webhookEnv = "WECOM_ROBOT_WEBHOOK";
|
/** 企微机器人 Webhook 完整 URL */
|
||||||
|
private String webhookUrl = "";
|
||||||
private boolean notifyOnClean = false;
|
private boolean notifyOnClean = false;
|
||||||
private String titlePrefix = "[Redis结构变更]";
|
private String titlePrefix = "[序列化结构变更]";
|
||||||
|
|
||||||
public boolean isEnabled() {
|
public boolean isEnabled() {
|
||||||
return enabled;
|
return enabled;
|
||||||
@@ -49,12 +51,12 @@ public class CheckerConfig {
|
|||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getWebhookEnv() {
|
public String getWebhookUrl() {
|
||||||
return webhookEnv;
|
return webhookUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setWebhookEnv(String webhookEnv) {
|
public void setWebhookUrl(String webhookUrl) {
|
||||||
this.webhookEnv = webhookEnv;
|
this.webhookUrl = webhookUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isNotifyOnClean() {
|
public boolean isNotifyOnClean() {
|
||||||
@@ -78,6 +80,8 @@ public class CheckerConfig {
|
|||||||
private List<String> keyPatterns = new ArrayList<>();
|
private List<String> keyPatterns = new ArrayList<>();
|
||||||
private List<String> filePatterns = new ArrayList<>();
|
private List<String> filePatterns = new ArrayList<>();
|
||||||
private List<String> writerMethods = new ArrayList<>();
|
private List<String> writerMethods = new ArrayList<>();
|
||||||
|
/** 忽略的 MQ destination 模式(topic / topic:tag) */
|
||||||
|
private List<String> mqDestinations = new ArrayList<>();
|
||||||
|
|
||||||
public List<String> getKeyPatterns() {
|
public List<String> getKeyPatterns() {
|
||||||
return keyPatterns;
|
return keyPatterns;
|
||||||
@@ -102,12 +106,26 @@ public class CheckerConfig {
|
|||||||
public void setWriterMethods(List<String> writerMethods) {
|
public void setWriterMethods(List<String> writerMethods) {
|
||||||
this.writerMethods = writerMethods;
|
this.writerMethods = writerMethods;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> getMqDestinations() {
|
||||||
|
return mqDestinations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMqDestinations(List<String> mqDestinations) {
|
||||||
|
this.mqDestinations = mqDestinations;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Detection {
|
public static class Detection {
|
||||||
private List<String> patterns = new ArrayList<>();
|
private List<String> patterns = new ArrayList<>();
|
||||||
|
/** MQ 投递检测模式:MQ01~MQ05、MQ-K01~MQ-K04 */
|
||||||
|
private List<String> mqPatterns = new ArrayList<>();
|
||||||
private double minConfidence = 0.6;
|
private double minConfidence = 0.6;
|
||||||
private int maxFieldDepth = 8;
|
private int maxFieldDepth = 8;
|
||||||
|
/** W06:是否启用读侧反序列化类型辅助补强 */
|
||||||
|
private boolean readHintsEnabled = true;
|
||||||
|
/** MQ-R:读侧 Listener / parse 补强 */
|
||||||
|
private boolean mqReadHintsEnabled = true;
|
||||||
|
|
||||||
public List<String> getPatterns() {
|
public List<String> getPatterns() {
|
||||||
return patterns;
|
return patterns;
|
||||||
@@ -117,6 +135,14 @@ public class CheckerConfig {
|
|||||||
this.patterns = patterns;
|
this.patterns = patterns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> getMqPatterns() {
|
||||||
|
return mqPatterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMqPatterns(List<String> mqPatterns) {
|
||||||
|
this.mqPatterns = mqPatterns;
|
||||||
|
}
|
||||||
|
|
||||||
public double getMinConfidence() {
|
public double getMinConfidence() {
|
||||||
return minConfidence;
|
return minConfidence;
|
||||||
}
|
}
|
||||||
@@ -132,6 +158,22 @@ public class CheckerConfig {
|
|||||||
public void setMaxFieldDepth(int maxFieldDepth) {
|
public void setMaxFieldDepth(int maxFieldDepth) {
|
||||||
this.maxFieldDepth = maxFieldDepth;
|
this.maxFieldDepth = maxFieldDepth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isReadHintsEnabled() {
|
||||||
|
return readHintsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReadHintsEnabled(boolean readHintsEnabled) {
|
||||||
|
this.readHintsEnabled = readHintsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMqReadHintsEnabled() {
|
||||||
|
return mqReadHintsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMqReadHintsEnabled(boolean mqReadHintsEnabled) {
|
||||||
|
this.mqReadHintsEnabled = mqReadHintsEnabled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ManualMapping {
|
public static class ManualMapping {
|
||||||
@@ -230,6 +272,14 @@ public class CheckerConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
public String getMode() {
|
public String getMode() {
|
||||||
return mode;
|
return mode;
|
||||||
}
|
}
|
||||||
@@ -238,14 +288,6 @@ public class CheckerConfig {
|
|||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<String> getBlockSeverities() {
|
|
||||||
return blockSeverities;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setBlockSeverities(List<String> blockSeverities) {
|
|
||||||
this.blockSeverities = blockSeverities;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isScanTestSources() {
|
public boolean isScanTestSources() {
|
||||||
return scanTestSources;
|
return scanTestSources;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.config;
|
package com.codechecker.cache.config;
|
||||||
|
|
||||||
import org.yaml.snakeyaml.Yaml;
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
@@ -75,8 +75,8 @@ public final class ConfigLoader {
|
|||||||
private static CheckerConfig bind(Map<String, Object> map) {
|
private static CheckerConfig bind(Map<String, Object> map) {
|
||||||
CheckerConfig config = new CheckerConfig();
|
CheckerConfig config = new CheckerConfig();
|
||||||
|
|
||||||
|
config.setEnabled(bool(map, "enabled", true));
|
||||||
config.setMode(str(map, "mode", "notify"));
|
config.setMode(str(map, "mode", "notify"));
|
||||||
config.setBlockSeverities(strList(map.get("block_severities")));
|
|
||||||
config.setScanTestSources(bool(map, "scan_test_sources", false));
|
config.setScanTestSources(bool(map, "scan_test_sources", false));
|
||||||
config.setSourceRoots(strList(map.get("source_roots")));
|
config.setSourceRoots(strList(map.get("source_roots")));
|
||||||
config.setIncludeModules(strList(map.get("include_modules")));
|
config.setIncludeModules(strList(map.get("include_modules")));
|
||||||
@@ -85,21 +85,25 @@ public final class ConfigLoader {
|
|||||||
Map<String, Object> notify = asMap(map.get("notify"));
|
Map<String, Object> notify = asMap(map.get("notify"));
|
||||||
CheckerConfig.Notify n = config.getNotify();
|
CheckerConfig.Notify n = config.getNotify();
|
||||||
n.setEnabled(bool(notify, "enabled", true));
|
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.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"));
|
Map<String, Object> ignore = asMap(map.get("ignore"));
|
||||||
CheckerConfig.Ignore ig = config.getIgnore();
|
CheckerConfig.Ignore ig = config.getIgnore();
|
||||||
ig.setKeyPatterns(strList(ignore.get("key_patterns")));
|
ig.setKeyPatterns(strList(ignore.get("key_patterns")));
|
||||||
ig.setFilePatterns(strList(ignore.get("file_patterns")));
|
ig.setFilePatterns(strList(ignore.get("file_patterns")));
|
||||||
ig.setWriterMethods(strList(ignore.get("writer_methods")));
|
ig.setWriterMethods(strList(ignore.get("writer_methods")));
|
||||||
|
ig.setMqDestinations(strList(ignore.get("mq_destinations")));
|
||||||
|
|
||||||
Map<String, Object> detection = asMap(map.get("detection"));
|
Map<String, Object> detection = asMap(map.get("detection"));
|
||||||
CheckerConfig.Detection d = config.getDetection();
|
CheckerConfig.Detection d = config.getDetection();
|
||||||
d.setPatterns(strList(detection.get("patterns")));
|
d.setPatterns(strList(detection.get("patterns")));
|
||||||
|
d.setMqPatterns(strList(detection.get("mq_patterns")));
|
||||||
d.setMinConfidence(dbl(detection, "min_confidence", 0.6));
|
d.setMinConfidence(dbl(detection, "min_confidence", 0.6));
|
||||||
d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8));
|
d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8));
|
||||||
|
d.setReadHintsEnabled(bool(detection, "read_hints_enabled", true));
|
||||||
|
d.setMqReadHintsEnabled(bool(detection, "mq_read_hints_enabled", true));
|
||||||
|
|
||||||
Map<String, Object> severityOverrides = asMap(map.get("severity_overrides"));
|
Map<String, Object> severityOverrides = asMap(map.get("severity_overrides"));
|
||||||
Map<String, String> so = new LinkedHashMap<>();
|
Map<String, String> so = new LinkedHashMap<>();
|
||||||
@@ -137,6 +141,21 @@ public final class ConfigLoader {
|
|||||||
return config;
|
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")
|
@SuppressWarnings("unchecked")
|
||||||
private static Map<String, Object> asMap(Object obj) {
|
private static Map<String, Object> asMap(Object obj) {
|
||||||
if (obj instanceof Map) {
|
if (obj instanceof Map) {
|
||||||
92
src/main/java/com/codechecker/cache/detector/CacheReadHint.java
vendored
Normal file
92
src/main/java/com/codechecker/cache/detector/CacheReadHint.java
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
189
src/main/java/com/codechecker/cache/detector/CacheReadHintDetector.java
vendored
Normal file
189
src/main/java/com/codechecker/cache/detector/CacheReadHintDetector.java
vendored
Normal 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<Foo> 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)) {
|
||||||
|
// 部分 API:parseObject(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
365
src/main/java/com/codechecker/cache/detector/MqReadHintDetector.java
vendored
Normal file
365
src/main/java/com/codechecker/cache/detector/MqReadHintDetector.java
vendored
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
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.ClassOrInterfaceDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.Parameter;
|
||||||
|
import com.github.javaparser.ast.expr.AnnotationExpr;
|
||||||
|
import com.github.javaparser.ast.expr.ArrayInitializerExpr;
|
||||||
|
import com.github.javaparser.ast.expr.ClassExpr;
|
||||||
|
import com.github.javaparser.ast.expr.Expression;
|
||||||
|
import com.github.javaparser.ast.expr.MarkerAnnotationExpr;
|
||||||
|
import com.github.javaparser.ast.expr.MemberValuePair;
|
||||||
|
import com.github.javaparser.ast.expr.MethodCallExpr;
|
||||||
|
import com.github.javaparser.ast.expr.NormalAnnotationExpr;
|
||||||
|
import com.github.javaparser.ast.expr.SingleMemberAnnotationExpr;
|
||||||
|
import com.github.javaparser.ast.type.ClassOrInterfaceType;
|
||||||
|
import com.github.javaparser.ast.type.Type;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MQ-R:消费侧类型提示,补强同 destination 生产点的 value 类型(不单独告警)。
|
||||||
|
* <ul>
|
||||||
|
* <li>MQ-R01:{@code RocketMQListener<T>} + {@code @RocketMQMessageListener}</li>
|
||||||
|
* <li>MQ-R02:{@code @KafkaListener} 非 String 参数类型</li>
|
||||||
|
* <li>MQ-R03:Listener 内 {@code parseObject/parseArray(..., Xxx.class)}</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class MqReadHintDetector {
|
||||||
|
|
||||||
|
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> SKIP_PARAM_TYPES = new HashSet<>(Arrays.asList(
|
||||||
|
"String", "byte", "Byte", "ConsumerRecord", "MessageExt", "Message"));
|
||||||
|
|
||||||
|
private final SourceIndex index;
|
||||||
|
private final RedisKeyResolver keyResolver;
|
||||||
|
|
||||||
|
public MqReadHintDetector(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 (ClassOrInterfaceDeclaration clazz : cu.findAll(ClassOrInterfaceDeclaration.class)) {
|
||||||
|
result.addAll(detectRocketListener(clazz, filePath));
|
||||||
|
for (MethodDeclaration md : clazz.getMethods()) {
|
||||||
|
result.addAll(detectKafkaListener(md, clazz, filePath));
|
||||||
|
result.addAll(detectParseInListener(md, clazz, filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MQ-R01 */
|
||||||
|
private List<CacheReadHint> detectRocketListener(ClassOrInterfaceDeclaration clazz, String filePath) {
|
||||||
|
List<CacheReadHint> result = new ArrayList<>();
|
||||||
|
Optional<ClassOrInterfaceType> listenerType = findRocketListenerType(clazz);
|
||||||
|
if (!listenerType.isPresent()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
AnnotationExpr ann = findAnnotation(clazz.getAnnotations(), "RocketMQMessageListener");
|
||||||
|
if (ann == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
String dest = resolveRocketDestination(ann, clazz);
|
||||||
|
InferredType payload = resolveTypeArg(listenerType.get(), index.get(
|
||||||
|
clazz.getFullyQualifiedName().orElse(clazz.getNameAsString())));
|
||||||
|
if (payload.fqn == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
CacheReadHint hint = baseHint(filePath, clazz, "<listener>", dest, payload);
|
||||||
|
hint.setConfidence(0.85);
|
||||||
|
result.add(hint);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MQ-R02 */
|
||||||
|
private List<CacheReadHint> detectKafkaListener(MethodDeclaration md,
|
||||||
|
ClassOrInterfaceDeclaration clazz,
|
||||||
|
String filePath) {
|
||||||
|
List<CacheReadHint> result = new ArrayList<>();
|
||||||
|
AnnotationExpr ann = findAnnotation(md.getAnnotations(), "KafkaListener");
|
||||||
|
if (ann == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
List<String> topics = resolveKafkaTopics(ann, clazz);
|
||||||
|
if (topics.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
SourceIndex.IndexedType context = index.get(
|
||||||
|
clazz.getFullyQualifiedName().orElse(clazz.getNameAsString()));
|
||||||
|
InferredType payload = null;
|
||||||
|
for (Parameter p : md.getParameters()) {
|
||||||
|
InferredType t = resolveParamPayload(p.getType(), context);
|
||||||
|
if (t != null && t.fqn != null) {
|
||||||
|
payload = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (String topic : topics) {
|
||||||
|
CacheReadHint hint = baseHint(filePath, clazz, md.getNameAsString(), topic, payload);
|
||||||
|
hint.setConfidence(0.85);
|
||||||
|
result.add(hint);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MQ-R03:在已标注 KafkaListener 的方法,或 RocketMQListener 类内 parse */
|
||||||
|
private List<CacheReadHint> detectParseInListener(MethodDeclaration md,
|
||||||
|
ClassOrInterfaceDeclaration clazz,
|
||||||
|
String filePath) {
|
||||||
|
List<CacheReadHint> result = new ArrayList<>();
|
||||||
|
boolean kafka = findAnnotation(md.getAnnotations(), "KafkaListener") != null;
|
||||||
|
boolean rocket = findRocketListenerType(clazz).isPresent();
|
||||||
|
if (!kafka && !rocket) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
List<String> destinations = new ArrayList<>();
|
||||||
|
if (kafka) {
|
||||||
|
destinations.addAll(resolveKafkaTopics(
|
||||||
|
findAnnotation(md.getAnnotations(), "KafkaListener"), clazz));
|
||||||
|
}
|
||||||
|
if (rocket) {
|
||||||
|
AnnotationExpr ann = findAnnotation(clazz.getAnnotations(), "RocketMQMessageListener");
|
||||||
|
if (ann != null) {
|
||||||
|
String dest = resolveRocketDestination(ann, clazz);
|
||||||
|
if (dest != null && !dest.isEmpty()) {
|
||||||
|
destinations.add(dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (destinations.isEmpty()) {
|
||||||
|
destinations.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceIndex.IndexedType context = index.get(
|
||||||
|
clazz.getFullyQualifiedName().orElse(clazz.getNameAsString()));
|
||||||
|
for (MethodCallExpr mce : md.findAll(MethodCallExpr.class)) {
|
||||||
|
String name = mce.getNameAsString();
|
||||||
|
boolean array = ARRAY_PARSE.contains(name);
|
||||||
|
boolean object = OBJECT_PARSE.contains(name);
|
||||||
|
if (!array && !object || mce.getArguments().size() < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Expression classArg = mce.getArgument(1);
|
||||||
|
if (!(classArg instanceof ClassExpr)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Type type = ((ClassExpr) classArg).getType();
|
||||||
|
if (!(type instanceof ClassOrInterfaceType)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameWithScope(), context);
|
||||||
|
if (fqn == null) {
|
||||||
|
fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameAsString(), context);
|
||||||
|
}
|
||||||
|
if (fqn == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
InferredType payload = new InferredType(fqn, array);
|
||||||
|
for (String dest : destinations) {
|
||||||
|
CacheReadHint hint = baseHint(filePath, clazz, md.getNameAsString(), dest, payload);
|
||||||
|
hint.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0));
|
||||||
|
hint.setConfidence(dest == null ? 0.7 : 0.8);
|
||||||
|
result.add(hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CacheReadHint baseHint(String filePath, ClassOrInterfaceDeclaration clazz,
|
||||||
|
String method, String dest, InferredType payload) {
|
||||||
|
CacheReadHint hint = new CacheReadHint();
|
||||||
|
hint.setFilePath(filePath);
|
||||||
|
hint.setLineNumber(clazz.getBegin().map(p -> p.line).orElse(0));
|
||||||
|
hint.setEnclosingClass(clazz.getFullyQualifiedName().orElse(clazz.getNameAsString()));
|
||||||
|
hint.setEnclosingMethod(method);
|
||||||
|
hint.setResolvedKeyPattern(dest);
|
||||||
|
hint.setResolvedValueType(payload.fqn);
|
||||||
|
hint.setRootArray(payload.isArray);
|
||||||
|
return hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<ClassOrInterfaceType> findRocketListenerType(ClassOrInterfaceDeclaration clazz) {
|
||||||
|
for (ClassOrInterfaceType t : clazz.getImplementedTypes()) {
|
||||||
|
String n = t.getNameAsString();
|
||||||
|
if ("RocketMQListener".equals(n) || "RocketMQReplyListener".equals(n)) {
|
||||||
|
return Optional.of(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private InferredType resolveParamPayload(Type type, SourceIndex.IndexedType context) {
|
||||||
|
if (!(type instanceof ClassOrInterfaceType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ClassOrInterfaceType cit = (ClassOrInterfaceType) type;
|
||||||
|
String simple = cit.getNameAsString();
|
||||||
|
if (SKIP_PARAM_TYPES.contains(simple) && !"ConsumerRecord".equals(simple)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ("ConsumerRecord".equals(simple) || "List".equals(simple) || "ArrayList".equals(simple)) {
|
||||||
|
Optional<Type> last = cit.getTypeArguments()
|
||||||
|
.filter(a -> !a.isEmpty())
|
||||||
|
.map(a -> a.get(a.size() - 1));
|
||||||
|
if (!last.isPresent()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
InferredType inner = resolveTypeArgFromType(last.get(), context);
|
||||||
|
if (inner != null && ("List".equals(simple) || "ArrayList".equals(simple))) {
|
||||||
|
return new InferredType(inner.fqn, true);
|
||||||
|
}
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
return resolveTypeArgFromType(type, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InferredType resolveTypeArg(ClassOrInterfaceType listenerType, SourceIndex.IndexedType context) {
|
||||||
|
Optional<Type> arg = listenerType.getTypeArguments().filter(a -> !a.isEmpty()).map(a -> a.get(0));
|
||||||
|
if (!arg.isPresent()) {
|
||||||
|
return new InferredType(null, false);
|
||||||
|
}
|
||||||
|
return resolveTypeArgFromType(arg.get(), context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private InferredType resolveTypeArgFromType(Type type, SourceIndex.IndexedType context) {
|
||||||
|
if (!(type instanceof ClassOrInterfaceType)) {
|
||||||
|
return new InferredType(null, false);
|
||||||
|
}
|
||||||
|
ClassOrInterfaceType cit = (ClassOrInterfaceType) type;
|
||||||
|
if ("List".equals(cit.getNameAsString()) || "ArrayList".equals(cit.getNameAsString())) {
|
||||||
|
Optional<Type> el = cit.getTypeArguments().filter(a -> !a.isEmpty()).map(a -> a.get(0));
|
||||||
|
if (el.isPresent() && el.get() instanceof ClassOrInterfaceType) {
|
||||||
|
return new InferredType(resolveFqn((ClassOrInterfaceType) el.get(), context), true);
|
||||||
|
}
|
||||||
|
return new InferredType(null, true);
|
||||||
|
}
|
||||||
|
String simple = cit.getNameAsString();
|
||||||
|
if (SKIP_PARAM_TYPES.contains(simple)) {
|
||||||
|
return new InferredType(null, false);
|
||||||
|
}
|
||||||
|
return new InferredType(resolveFqn(cit, context), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveFqn(ClassOrInterfaceType cit, SourceIndex.IndexedType context) {
|
||||||
|
String fqn = index.resolveFqn(cit.getNameWithScope(), context);
|
||||||
|
if (fqn == null) {
|
||||||
|
fqn = index.resolveFqn(cit.getNameAsString(), context);
|
||||||
|
}
|
||||||
|
return fqn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveRocketDestination(AnnotationExpr ann, ClassOrInterfaceDeclaration clazz) {
|
||||||
|
SourceIndex.IndexedType context = index.get(
|
||||||
|
clazz.getFullyQualifiedName().orElse(clazz.getNameAsString()));
|
||||||
|
Expression topicExpr = annotationValue(ann, "topic");
|
||||||
|
if (topicExpr == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String topicPattern = keyResolver.resolve(topicExpr, clazz, context);
|
||||||
|
Expression tagExpr = annotationValue(ann, "selectorExpression");
|
||||||
|
if (tagExpr != null) {
|
||||||
|
String tagPattern = keyResolver.resolve(tagExpr, clazz, context);
|
||||||
|
if (tagPattern != null && !tagPattern.isEmpty()
|
||||||
|
&& !"*".equals(tagPattern) && !tagPattern.contains("||")) {
|
||||||
|
return topicPattern + ":" + tagPattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return topicPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveKafkaTopics(AnnotationExpr ann, ClassOrInterfaceDeclaration clazz) {
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
if (ann == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
SourceIndex.IndexedType context = index.get(
|
||||||
|
clazz.getFullyQualifiedName().orElse(clazz.getNameAsString()));
|
||||||
|
Expression topicsExpr = annotationValue(ann, "topics");
|
||||||
|
if (topicsExpr == null && ann instanceof SingleMemberAnnotationExpr) {
|
||||||
|
topicsExpr = ((SingleMemberAnnotationExpr) ann).getMemberValue();
|
||||||
|
}
|
||||||
|
if (topicsExpr == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
List<Expression> items = new ArrayList<>();
|
||||||
|
if (topicsExpr instanceof ArrayInitializerExpr) {
|
||||||
|
items.addAll(((ArrayInitializerExpr) topicsExpr).getValues());
|
||||||
|
} else {
|
||||||
|
items.add(topicsExpr);
|
||||||
|
}
|
||||||
|
for (Expression item : items) {
|
||||||
|
String resolved = keyResolver.resolve(item, clazz, context);
|
||||||
|
if (resolved != null && !resolved.isEmpty()) {
|
||||||
|
result.add(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expression annotationValue(AnnotationExpr ann, String name) {
|
||||||
|
if (ann instanceof NormalAnnotationExpr) {
|
||||||
|
for (MemberValuePair pair : ((NormalAnnotationExpr) ann).getPairs()) {
|
||||||
|
if (name.equals(pair.getNameAsString())) {
|
||||||
|
return pair.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AnnotationExpr findAnnotation(List<AnnotationExpr> annotations, String simpleName) {
|
||||||
|
for (AnnotationExpr ann : annotations) {
|
||||||
|
String n;
|
||||||
|
if (ann instanceof MarkerAnnotationExpr) {
|
||||||
|
n = ((MarkerAnnotationExpr) ann).getNameAsString();
|
||||||
|
} else if (ann instanceof SingleMemberAnnotationExpr) {
|
||||||
|
n = ((SingleMemberAnnotationExpr) ann).getNameAsString();
|
||||||
|
} else if (ann instanceof NormalAnnotationExpr) {
|
||||||
|
n = ((NormalAnnotationExpr) ann).getNameAsString();
|
||||||
|
} else {
|
||||||
|
n = ann.getNameAsString();
|
||||||
|
}
|
||||||
|
if (n.equals(simpleName) || n.endsWith("." + simpleName)) {
|
||||||
|
return ann;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class InferredType {
|
||||||
|
final String fqn;
|
||||||
|
final boolean isArray;
|
||||||
|
|
||||||
|
InferredType(String fqn, boolean isArray) {
|
||||||
|
this.fqn = fqn;
|
||||||
|
this.isArray = isArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
548
src/main/java/com/codechecker/cache/detector/MqWritePointDetector.java
vendored
Normal file
548
src/main/java/com/codechecker/cache/detector/MqWritePointDetector.java
vendored
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
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.FieldDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||||
|
import com.github.javaparser.ast.body.Parameter;
|
||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测 RocketMQ / Kafka 生产侧投递点(Phase M1 + M2)。
|
||||||
|
* <ul>
|
||||||
|
* <li>M1:MQ01/MQ02、MQ-K01/MQ-K02</li>
|
||||||
|
* <li>M2:MQ03 convertAndSend、MQ04 MessageBuilder/`Message<T>`、MQ05 JSON 字符串、
|
||||||
|
* MQ-K03 ProducerRecord、MQ-K04 JSON 字符串</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class MqWritePointDetector {
|
||||||
|
|
||||||
|
private static final Set<String> SERIALIZE_METHODS = new HashSet<>(Arrays.asList(
|
||||||
|
"toJSONString", "toJsonString", "getObjectToString", "toJsonStr", "writeValueAsString"));
|
||||||
|
private static final Set<String> ROCKET_SYNC = new HashSet<>(Arrays.asList("syncSend"));
|
||||||
|
private static final Set<String> ROCKET_ASYNC = new HashSet<>(Arrays.asList(
|
||||||
|
"asyncSend", "syncSendOrderly", "sendOneWay", "asyncSendOrderly"));
|
||||||
|
private static final Set<String> ROCKET_CONVERT = new HashSet<>(Arrays.asList("convertAndSend"));
|
||||||
|
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"));
|
||||||
|
private static final Set<String> ENVELOPE_TYPES = new HashSet<>(Arrays.asList(
|
||||||
|
"Message", "MessageExt", "ProducerRecord"));
|
||||||
|
private static final Set<String> IGNORE_BARE_TYPES = new HashSet<>(Arrays.asList(
|
||||||
|
"String", "byte", "Byte"));
|
||||||
|
|
||||||
|
private final SourceIndex index;
|
||||||
|
private final Set<String> enabledPatterns;
|
||||||
|
private final RedisKeyResolver keyResolver;
|
||||||
|
|
||||||
|
public MqWritePointDetector(SourceIndex index, Set<String> enabledPatterns) {
|
||||||
|
this.index = index;
|
||||||
|
this.enabledPatterns = enabledPatterns == null ? new HashSet<>() : enabledPatterns;
|
||||||
|
this.keyResolver = new RedisKeyResolver(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WritePoint> detect(String filePath, String content) {
|
||||||
|
List<WritePoint> result = new ArrayList<>();
|
||||||
|
if (content == null || content.isEmpty() || enabledPatterns.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
CompilationUnit cu;
|
||||||
|
try {
|
||||||
|
cu = StaticJavaParser.parse(content);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (MethodCallExpr mce : cu.findAll(MethodCallExpr.class)) {
|
||||||
|
WritePoint wp = tryDetectRocketMq(mce, filePath);
|
||||||
|
if (wp == null) {
|
||||||
|
wp = tryDetectKafka(mce, filePath);
|
||||||
|
}
|
||||||
|
if (wp != null) {
|
||||||
|
result.add(wp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WritePoint tryDetectRocketMq(MethodCallExpr mce, String filePath) {
|
||||||
|
String method = mce.getNameAsString();
|
||||||
|
String basePattern = null;
|
||||||
|
if (ROCKET_SYNC.contains(method) && enabledPatterns.contains("MQ01")) {
|
||||||
|
basePattern = "MQ01";
|
||||||
|
} else if (ROCKET_ASYNC.contains(method) && enabledPatterns.contains("MQ02")) {
|
||||||
|
basePattern = "MQ02";
|
||||||
|
} else if (ROCKET_CONVERT.contains(method) && enabledPatterns.contains("MQ03")) {
|
||||||
|
basePattern = "MQ03";
|
||||||
|
}
|
||||||
|
if (basePattern == null || !isRocketMqScope(mce) || mce.getArguments().size() < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildMqWritePoint(mce, filePath, basePattern, WritePoint.CHANNEL_ROCKETMQ,
|
||||||
|
mce.getArgument(0), mce.getArgument(1), "MQ04", "MQ05");
|
||||||
|
}
|
||||||
|
|
||||||
|
private WritePoint tryDetectKafka(MethodCallExpr mce, String filePath) {
|
||||||
|
if (!"send".equals(mce.getNameAsString()) || !isKafkaScope(mce)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int argc = mce.getArguments().size();
|
||||||
|
if (argc == 1 && enabledPatterns.contains("MQ-K03")) {
|
||||||
|
return buildFromProducerRecord(mce, filePath);
|
||||||
|
}
|
||||||
|
String basePattern;
|
||||||
|
Expression topicArg;
|
||||||
|
Expression payloadArg;
|
||||||
|
if (argc == 2 && enabledPatterns.contains("MQ-K01")) {
|
||||||
|
basePattern = "MQ-K01";
|
||||||
|
topicArg = mce.getArgument(0);
|
||||||
|
payloadArg = mce.getArgument(1);
|
||||||
|
} else if (argc >= 3 && enabledPatterns.contains("MQ-K02")) {
|
||||||
|
basePattern = "MQ-K02";
|
||||||
|
topicArg = mce.getArgument(0);
|
||||||
|
payloadArg = mce.getArgument(argc - 1);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return buildMqWritePoint(mce, filePath, basePattern, WritePoint.CHANNEL_KAFKA,
|
||||||
|
topicArg, payloadArg, null, "MQ-K04");
|
||||||
|
}
|
||||||
|
|
||||||
|
private WritePoint buildFromProducerRecord(MethodCallExpr mce, String filePath) {
|
||||||
|
Expression recordArg = mce.getArgument(0);
|
||||||
|
ObjectCreationExpr creation = findProducerRecordCreation(recordArg, mce);
|
||||||
|
if (creation == null || creation.getArguments().size() < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Expression topicArg = creation.getArgument(0);
|
||||||
|
Expression payloadArg = creation.getArgument(creation.getArguments().size() - 1);
|
||||||
|
WritePoint wp = buildMqWritePoint(mce, filePath, "MQ-K03", WritePoint.CHANNEL_KAFKA,
|
||||||
|
topicArg, payloadArg, null, "MQ-K04");
|
||||||
|
if (wp != null) {
|
||||||
|
wp.setValueExpression(recordArg.toString());
|
||||||
|
}
|
||||||
|
return wp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectCreationExpr findProducerRecordCreation(Expression expr, MethodCallExpr contextCall) {
|
||||||
|
if (expr instanceof ObjectCreationExpr) {
|
||||||
|
ObjectCreationExpr oce = (ObjectCreationExpr) expr;
|
||||||
|
if ("ProducerRecord".equals(oce.getType().getNameAsString())) {
|
||||||
|
return oce;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (expr instanceof NameExpr) {
|
||||||
|
String name = ((NameExpr) expr).getNameAsString();
|
||||||
|
Optional<CallableDeclaration> callable = contextCall.findAncestor(CallableDeclaration.class);
|
||||||
|
if (!callable.isPresent()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (VariableDeclarator var : callable.get().findAll(VariableDeclarator.class)) {
|
||||||
|
if (!var.getNameAsString().equals(name) || !var.getInitializer().isPresent()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Expression init = var.getInitializer().get();
|
||||||
|
if (init instanceof ObjectCreationExpr
|
||||||
|
&& "ProducerRecord".equals(((ObjectCreationExpr) init).getType().getNameAsString())) {
|
||||||
|
return (ObjectCreationExpr) init;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WritePoint buildMqWritePoint(MethodCallExpr mce, String filePath, String basePattern,
|
||||||
|
String channel, Expression destArg, Expression payloadArg,
|
||||||
|
String messagePattern, String jsonPattern) {
|
||||||
|
PayloadResolution resolved = resolvePayload(payloadArg, mce, messagePattern, jsonPattern);
|
||||||
|
if (resolved == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String pattern = resolved.overridePattern != null ? resolved.overridePattern : basePattern;
|
||||||
|
if (!enabledPatterns.contains(pattern)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
WritePoint wp = new WritePoint();
|
||||||
|
wp.setFilePath(filePath);
|
||||||
|
wp.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0));
|
||||||
|
wp.setPattern(pattern);
|
||||||
|
wp.setChannel(channel);
|
||||||
|
wp.setKeyExpression(destArg.toString());
|
||||||
|
wp.setValueExpression(payloadArg.toString());
|
||||||
|
fillEnclosing(mce, wp);
|
||||||
|
|
||||||
|
SourceIndex.IndexedType context = index.get(wp.getEnclosingClass());
|
||||||
|
ClassOrInterfaceDeclaration enclosingDecl = mce
|
||||||
|
.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null);
|
||||||
|
wp.setResolvedKeyPattern(keyResolver.resolve(destArg, enclosingDecl, context));
|
||||||
|
|
||||||
|
InferredType inferred = inferType(resolved.typeExpr, mce, context);
|
||||||
|
if (inferred != null) {
|
||||||
|
wp.setResolvedValueType(inferred.fqn);
|
||||||
|
wp.setRootArray(inferred.isArray);
|
||||||
|
wp.setConfidence(inferred.fqn == null ? 0.4 : 1.0);
|
||||||
|
} else {
|
||||||
|
wp.setConfidence(0.4);
|
||||||
|
}
|
||||||
|
return wp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 payload:Message/MessageBuilder(MQ04)、JSON 字符串(MQ05/MQ-K04)、直传对象。
|
||||||
|
* 无法解析的 Message/纯 String 返回 null(忽略)。
|
||||||
|
*/
|
||||||
|
private PayloadResolution resolvePayload(Expression payloadArg, MethodCallExpr mce,
|
||||||
|
String messagePattern, String jsonPattern) {
|
||||||
|
if (payloadArg == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// MQ04:Message<T> 泛型 / MessageBuilder.withPayload
|
||||||
|
if (messagePattern != null && enabledPatterns.contains(messagePattern)) {
|
||||||
|
Expression fromMessage = unwrapMessagePayload(payloadArg, mce);
|
||||||
|
if (fromMessage != null) {
|
||||||
|
return new PayloadResolution(fromMessage, messagePattern);
|
||||||
|
}
|
||||||
|
if (isEnvelopeBare(payloadArg, mce)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (isEnvelopeBare(payloadArg, mce)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内联 JSON 序列化
|
||||||
|
Expression serialized = unwrapSerializer(payloadArg);
|
||||||
|
if (serialized != null) {
|
||||||
|
if (jsonPattern != null && enabledPatterns.contains(jsonPattern)) {
|
||||||
|
return new PayloadResolution(serialized, jsonPattern);
|
||||||
|
}
|
||||||
|
return new PayloadResolution(serialized, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 局部 String = toJSONString(...)
|
||||||
|
if (jsonPattern != null && enabledPatterns.contains(jsonPattern)) {
|
||||||
|
Expression fromLocalJson = unwrapJsonStringVar(payloadArg, mce);
|
||||||
|
if (fromLocalJson != null) {
|
||||||
|
return new PayloadResolution(fromLocalJson, jsonPattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTrivialValue(payloadArg) || isBareStringOrBytesPayload(payloadArg, mce)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new PayloadResolution(payloadArg, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression unwrapMessagePayload(Expression payloadArg, MethodCallExpr mce) {
|
||||||
|
Expression fromBuilder = unwrapMessageBuilderPayload(payloadArg, mce);
|
||||||
|
if (fromBuilder != null) {
|
||||||
|
return fromBuilder;
|
||||||
|
}
|
||||||
|
// Message<DutyImNotice> → DutyImNotice(无 withPayload 也可)
|
||||||
|
if (payloadArg instanceof NameExpr) {
|
||||||
|
Type declared = findVariableType(((NameExpr) payloadArg).getNameAsString(), mce);
|
||||||
|
if (declared instanceof ClassOrInterfaceType) {
|
||||||
|
ClassOrInterfaceType cit = (ClassOrInterfaceType) declared;
|
||||||
|
if (ENVELOPE_TYPES.contains(cit.getNameAsString())) {
|
||||||
|
Optional<Type> payloadType = cit.getTypeArguments()
|
||||||
|
.filter(a -> !a.isEmpty())
|
||||||
|
.map(a -> a.get(a.size() - 1));
|
||||||
|
if (payloadType.isPresent() && payloadType.get() instanceof ClassOrInterfaceType) {
|
||||||
|
// 用伪 ObjectCreation 表达类型不便;改为在 infer 前用 NameExpr 不够
|
||||||
|
// 返回一个标记:借助 CastExpr 包装类型信息
|
||||||
|
return new CastExpr(payloadType.get(), payloadArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression unwrapMessageBuilderPayload(Expression payloadArg, MethodCallExpr mce) {
|
||||||
|
Expression chain = payloadArg;
|
||||||
|
if (payloadArg instanceof NameExpr) {
|
||||||
|
String name = ((NameExpr) payloadArg).getNameAsString();
|
||||||
|
Optional<CallableDeclaration> callable = mce.findAncestor(CallableDeclaration.class);
|
||||||
|
if (callable.isPresent()) {
|
||||||
|
for (VariableDeclarator var : callable.get().findAll(VariableDeclarator.class)) {
|
||||||
|
if (var.getNameAsString().equals(name) && var.getInitializer().isPresent()) {
|
||||||
|
chain = var.getInitializer().get();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findWithPayloadArg(chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression findWithPayloadArg(Expression expr) {
|
||||||
|
Expression current = expr;
|
||||||
|
int guard = 0;
|
||||||
|
while (current instanceof MethodCallExpr && guard++ < 16) {
|
||||||
|
MethodCallExpr call = (MethodCallExpr) current;
|
||||||
|
if ("withPayload".equals(call.getNameAsString()) && !call.getArguments().isEmpty()) {
|
||||||
|
return call.getArgument(0);
|
||||||
|
}
|
||||||
|
if (!call.getScope().isPresent()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = call.getScope().get();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression unwrapJsonStringVar(Expression payloadArg, MethodCallExpr mce) {
|
||||||
|
if (!(payloadArg instanceof NameExpr)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String name = ((NameExpr) payloadArg).getNameAsString();
|
||||||
|
Type declared = findVariableType(name, mce);
|
||||||
|
if (declared != null) {
|
||||||
|
String simple = declared.isClassOrInterfaceType()
|
||||||
|
? declared.asClassOrInterfaceType().getNameAsString()
|
||||||
|
: declared.asString();
|
||||||
|
if (!"String".equals(simple)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Optional<CallableDeclaration> callable = mce.findAncestor(CallableDeclaration.class);
|
||||||
|
if (!callable.isPresent()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (VariableDeclarator var : callable.get().findAll(VariableDeclarator.class)) {
|
||||||
|
if (!var.getNameAsString().equals(name) || !var.getInitializer().isPresent()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return unwrapSerializer(var.getInitializer().get());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isEnvelopeBare(Expression typeExpr, MethodCallExpr contextCall) {
|
||||||
|
if (!(typeExpr instanceof NameExpr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Type declared = findVariableType(((NameExpr) typeExpr).getNameAsString(), contextCall);
|
||||||
|
if (declared == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String simple = declared.isClassOrInterfaceType()
|
||||||
|
? declared.asClassOrInterfaceType().getNameAsString()
|
||||||
|
: declared.asString();
|
||||||
|
return ENVELOPE_TYPES.contains(simple);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRocketMqScope(MethodCallExpr mce) {
|
||||||
|
String scope = mce.getScope().map(Expression::toString).orElse("").toLowerCase();
|
||||||
|
return scope.contains("rocketmqtemplate") || scope.contains("rocketmq");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isKafkaScope(MethodCallExpr mce) {
|
||||||
|
String scope = mce.getScope().map(Expression::toString).orElse("").toLowerCase();
|
||||||
|
return scope.contains("kafkatemplate") || "kafkatemplate".equals(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBareStringOrBytesPayload(Expression typeExpr, MethodCallExpr contextCall) {
|
||||||
|
if (typeExpr instanceof StringLiteralExpr) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeExpr instanceof NameExpr) {
|
||||||
|
Type declared = findVariableType(((NameExpr) typeExpr).getNameAsString(), contextCall);
|
||||||
|
if (declared != null) {
|
||||||
|
String simple = declared.isClassOrInterfaceType()
|
||||||
|
? declared.asClassOrInterfaceType().getNameAsString()
|
||||||
|
: declared.asString();
|
||||||
|
if (IGNORE_BARE_TYPES.contains(simple) || "byte[]".equals(declared.asString())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return "UUID".equals(((ObjectCreationExpr) expr).getType().getNameAsString());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression unwrapSerializer(Expression valueArg) {
|
||||||
|
if (valueArg instanceof MethodCallExpr) {
|
||||||
|
MethodCallExpr call = (MethodCallExpr) valueArg;
|
||||||
|
if (SERIALIZE_METHODS.contains(call.getNameAsString()) && !call.getArguments().isEmpty()) {
|
||||||
|
return call.getArgument(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillEnclosing(MethodCallExpr mce, WritePoint wp) {
|
||||||
|
Optional<ClassOrInterfaceDeclaration> clazz = mce.findAncestor(ClassOrInterfaceDeclaration.class);
|
||||||
|
wp.setEnclosingClass(clazz.map(d -> d.getFullyQualifiedName().orElse(d.getNameAsString()))
|
||||||
|
.orElse("<unknown>"));
|
||||||
|
Optional<CallableDeclaration> method = mce.findAncestor(CallableDeclaration.class);
|
||||||
|
wp.setEnclosingMethod(method.map(CallableDeclaration::getNameAsString).orElse("<unknown>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private InferredType inferType(Expression expr, MethodCallExpr contextCall,
|
||||||
|
SourceIndex.IndexedType context) {
|
||||||
|
if (expr instanceof CastExpr) {
|
||||||
|
return resolveTypeNode(((CastExpr) expr).getType(), context);
|
||||||
|
}
|
||||||
|
if (expr instanceof ObjectCreationExpr) {
|
||||||
|
return resolveTypeNode(((ObjectCreationExpr) expr).getType(), context);
|
||||||
|
}
|
||||||
|
if (expr instanceof NameExpr) {
|
||||||
|
String name = ((NameExpr) expr).getNameAsString();
|
||||||
|
Type declared = findVariableType(name, contextCall);
|
||||||
|
if (declared != null) {
|
||||||
|
return resolveTypeNode(declared, context);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Type findVariableType(String name, MethodCallExpr contextCall) {
|
||||||
|
Optional<CallableDeclaration> callable = contextCall.findAncestor(CallableDeclaration.class);
|
||||||
|
if (callable.isPresent()) {
|
||||||
|
CallableDeclaration<?> decl = callable.get();
|
||||||
|
for (VariableDeclarator var : decl.findAll(VariableDeclarator.class)) {
|
||||||
|
if (var.getNameAsString().equals(name)) {
|
||||||
|
return var.getType();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Parameter p : decl.getParameters()) {
|
||||||
|
if (p.getNameAsString().equals(name)) {
|
||||||
|
return p.getType();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Optional<ClassOrInterfaceDeclaration> clazz = contextCall.findAncestor(ClassOrInterfaceDeclaration.class);
|
||||||
|
if (clazz.isPresent()) {
|
||||||
|
for (FieldDeclaration field : clazz.get().getFields()) {
|
||||||
|
for (VariableDeclarator var : field.getVariables()) {
|
||||||
|
if (var.getNameAsString().equals(name)) {
|
||||||
|
return var.getType();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private InferredType resolveTypeNode(Type type, SourceIndex.IndexedType context) {
|
||||||
|
if (!(type instanceof ClassOrInterfaceType)) {
|
||||||
|
return new InferredType(null, false);
|
||||||
|
}
|
||||||
|
ClassOrInterfaceType cit = (ClassOrInterfaceType) type;
|
||||||
|
String simple = cit.getNameAsString();
|
||||||
|
if (COLLECTION_SIMPLE.contains(simple)) {
|
||||||
|
Optional<Type> arg = cit.getTypeArguments().filter(a -> !a.isEmpty()).map(a -> a.get(0));
|
||||||
|
if (arg.isPresent() && arg.get() instanceof ClassOrInterfaceType) {
|
||||||
|
String elementFqn = resolveFqn((ClassOrInterfaceType) arg.get(), context);
|
||||||
|
return new InferredType(elementFqn, true);
|
||||||
|
}
|
||||||
|
return new InferredType(null, true);
|
||||||
|
}
|
||||||
|
if (ENVELOPE_TYPES.contains(simple)) {
|
||||||
|
Optional<Type> payload = cit.getTypeArguments()
|
||||||
|
.filter(a -> !a.isEmpty())
|
||||||
|
.map(a -> a.get(a.size() - 1));
|
||||||
|
if (payload.isPresent()) {
|
||||||
|
return resolveTypeNode(payload.get(), context);
|
||||||
|
}
|
||||||
|
return new InferredType(null, false);
|
||||||
|
}
|
||||||
|
return new InferredType(resolveFqn(cit, context), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveFqn(ClassOrInterfaceType cit, SourceIndex.IndexedType context) {
|
||||||
|
String fqn = index.resolveFqn(cit.getNameWithScope(), context);
|
||||||
|
if (fqn == null) {
|
||||||
|
fqn = index.resolveFqn(cit.getNameAsString(), context);
|
||||||
|
}
|
||||||
|
return fqn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class PayloadResolution {
|
||||||
|
final Expression typeExpr;
|
||||||
|
final String overridePattern;
|
||||||
|
|
||||||
|
PayloadResolution(Expression typeExpr, String overridePattern) {
|
||||||
|
this.typeExpr = typeExpr;
|
||||||
|
this.overridePattern = overridePattern;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class InferredType {
|
||||||
|
final String fqn;
|
||||||
|
final boolean isArray;
|
||||||
|
|
||||||
|
InferredType(String fqn, boolean isArray) {
|
||||||
|
this.fqn = fqn;
|
||||||
|
this.isArray = isArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.codechecker.redis.detector;
|
package com.codechecker.cache.detector;
|
||||||
|
|
||||||
import com.codechecker.redis.key.RedisKeyResolver;
|
import com.codechecker.cache.key.RedisKeyResolver;
|
||||||
import com.codechecker.redis.schema.SourceIndex;
|
import com.codechecker.cache.schema.SourceIndex;
|
||||||
import com.github.javaparser.StaticJavaParser;
|
import com.github.javaparser.StaticJavaParser;
|
||||||
import com.github.javaparser.ast.CompilationUnit;
|
import com.github.javaparser.ast.CompilationUnit;
|
||||||
import com.github.javaparser.ast.body.CallableDeclaration;
|
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.Parameter;
|
||||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||||
import com.github.javaparser.ast.body.VariableDeclarator;
|
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.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.MethodCallExpr;
|
||||||
import com.github.javaparser.ast.expr.NameExpr;
|
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.ObjectCreationExpr;
|
||||||
|
import com.github.javaparser.ast.expr.StringLiteralExpr;
|
||||||
import com.github.javaparser.ast.type.ClassOrInterfaceType;
|
import com.github.javaparser.ast.type.ClassOrInterfaceType;
|
||||||
import com.github.javaparser.ast.type.Type;
|
import com.github.javaparser.ast.type.Type;
|
||||||
|
|
||||||
@@ -26,13 +33,19 @@ import java.util.Optional;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从单个 Java 源文件中检测 Redis value 写入点(W01~W03:JSON 字符串写入)。
|
* 从单个 Java 源文件中检测 Redis value 写入点(W01~W05)。
|
||||||
*/
|
*/
|
||||||
public class RedisWritePointDetector {
|
public class RedisWritePointDetector {
|
||||||
|
|
||||||
private static final Set<String> SERIALIZE_METHODS = new HashSet<>(Arrays.asList(
|
private static final Set<String> SERIALIZE_METHODS = new HashSet<>(Arrays.asList(
|
||||||
"toJSONString", "toJsonString", "getObjectToString", "toJsonStr", "writeValueAsString"));
|
"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(
|
private static final Set<String> COLLECTION_SIMPLE = new HashSet<>(Arrays.asList(
|
||||||
"List", "ArrayList", "LinkedList", "Set", "HashSet", "Collection"));
|
"List", "ArrayList", "LinkedList", "Set", "HashSet", "Collection"));
|
||||||
|
|
||||||
@@ -59,41 +72,79 @@ public class RedisWritePointDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (MethodCallExpr mce : cu.findAll(MethodCallExpr.class)) {
|
for (MethodCallExpr mce : cu.findAll(MethodCallExpr.class)) {
|
||||||
String method = mce.getNameAsString();
|
WritePoint wp = tryDetectValueWrite(mce, filePath);
|
||||||
if (!WRITE_METHODS.contains(method)) {
|
if (wp == null) {
|
||||||
continue;
|
wp = tryDetectHashWrite(mce, filePath);
|
||||||
}
|
}
|
||||||
String scope = mce.getScope().map(Expression::toString).orElse("");
|
if (wp != null) {
|
||||||
if (!isRedisScope(scope)) {
|
result.add(wp);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (mce.getArguments().size() < 2) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
Expression valueArg = mce.getArgument(1);
|
return result;
|
||||||
Expression serialized = unwrapSerializer(valueArg);
|
|
||||||
if (serialized == null) {
|
|
||||||
continue; // 非 JSON 字符串写入(W04/W05 归后续阶段)
|
|
||||||
}
|
|
||||||
String pattern = classify(method, valueArg);
|
|
||||||
if (!enabledPatterns.contains(pattern)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
WritePoint wp = new WritePoint();
|
||||||
wp.setFilePath(filePath);
|
wp.setFilePath(filePath);
|
||||||
wp.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0));
|
wp.setLineNumber(mce.getBegin().map(p -> p.line).orElse(0));
|
||||||
wp.setPattern(pattern);
|
wp.setPattern(pattern);
|
||||||
wp.setKeyExpression(mce.getArgument(0).toString());
|
wp.setKeyExpression(keyArg.toString());
|
||||||
wp.setValueExpression(valueArg.toString());
|
wp.setValueExpression(valueArg.toString());
|
||||||
|
|
||||||
fillEnclosing(mce, wp);
|
fillEnclosing(mce, wp);
|
||||||
|
|
||||||
SourceIndex.IndexedType context = index.get(wp.getEnclosingClass());
|
SourceIndex.IndexedType context = index.get(wp.getEnclosingClass());
|
||||||
ClassOrInterfaceDeclaration enclosingDecl = mce
|
ClassOrInterfaceDeclaration enclosingDecl = mce
|
||||||
.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null);
|
.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null);
|
||||||
wp.setResolvedKeyPattern(keyResolver.resolve(mce.getArgument(0), enclosingDecl, context));
|
wp.setResolvedKeyPattern(keyResolver.resolve(keyArg, enclosingDecl, context));
|
||||||
InferredType inferred = inferType(serialized, mce, context);
|
InferredType inferred = inferType(typeExpr, mce, context);
|
||||||
if (inferred != null) {
|
if (inferred != null) {
|
||||||
wp.setResolvedValueType(inferred.fqn);
|
wp.setResolvedValueType(inferred.fqn);
|
||||||
wp.setRootArray(inferred.isArray);
|
wp.setRootArray(inferred.isArray);
|
||||||
@@ -101,16 +152,53 @@ public class RedisWritePointDetector {
|
|||||||
} else {
|
} else {
|
||||||
wp.setConfidence(0.4);
|
wp.setConfidence(0.4);
|
||||||
}
|
}
|
||||||
result.add(wp);
|
return wp;
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isRedisScope(String scope) {
|
private boolean isValueOpsScope(MethodCallExpr mce) {
|
||||||
|
String scope = mce.getScope().map(Expression::toString).orElse("");
|
||||||
String lower = scope.toLowerCase();
|
String lower = scope.toLowerCase();
|
||||||
return lower.contains("redis") || lower.contains("opsforvalue") || lower.contains("boundvalueops");
|
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) {
|
private Expression unwrapSerializer(Expression valueArg) {
|
||||||
if (valueArg instanceof MethodCallExpr) {
|
if (valueArg instanceof MethodCallExpr) {
|
||||||
MethodCallExpr call = (MethodCallExpr) valueArg;
|
MethodCallExpr call = (MethodCallExpr) valueArg;
|
||||||
@@ -121,7 +209,7 @@ public class RedisWritePointDetector {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String classify(String method, Expression valueArg) {
|
private String classifySerialized(String method, Expression valueArg) {
|
||||||
String serializer = valueArg instanceof MethodCallExpr
|
String serializer = valueArg instanceof MethodCallExpr
|
||||||
? ((MethodCallExpr) valueArg).getNameAsString() : "";
|
? ((MethodCallExpr) valueArg).getNameAsString() : "";
|
||||||
if ("insert".equals(method)) {
|
if ("insert".equals(method)) {
|
||||||
@@ -145,6 +233,9 @@ public class RedisWritePointDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private InferredType inferType(Expression expr, MethodCallExpr contextCall, SourceIndex.IndexedType context) {
|
private InferredType inferType(Expression expr, MethodCallExpr contextCall, SourceIndex.IndexedType context) {
|
||||||
|
if (expr instanceof CastExpr) {
|
||||||
|
return resolveTypeNode(((CastExpr) expr).getType(), context);
|
||||||
|
}
|
||||||
if (expr instanceof ObjectCreationExpr) {
|
if (expr instanceof ObjectCreationExpr) {
|
||||||
ClassOrInterfaceType t = ((ObjectCreationExpr) expr).getType();
|
ClassOrInterfaceType t = ((ObjectCreationExpr) expr).getType();
|
||||||
return resolveTypeNode(t, context);
|
return resolveTypeNode(t, context);
|
||||||
@@ -157,6 +248,18 @@ public class RedisWritePointDetector {
|
|||||||
}
|
}
|
||||||
return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,20 +267,17 @@ public class RedisWritePointDetector {
|
|||||||
Optional<CallableDeclaration> callable = contextCall.findAncestor(CallableDeclaration.class);
|
Optional<CallableDeclaration> callable = contextCall.findAncestor(CallableDeclaration.class);
|
||||||
if (callable.isPresent()) {
|
if (callable.isPresent()) {
|
||||||
CallableDeclaration<?> decl = callable.get();
|
CallableDeclaration<?> decl = callable.get();
|
||||||
// 局部变量
|
|
||||||
for (VariableDeclarator var : decl.findAll(VariableDeclarator.class)) {
|
for (VariableDeclarator var : decl.findAll(VariableDeclarator.class)) {
|
||||||
if (var.getNameAsString().equals(name)) {
|
if (var.getNameAsString().equals(name)) {
|
||||||
return var.getType();
|
return var.getType();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 方法参数
|
|
||||||
for (Parameter p : decl.getParameters()) {
|
for (Parameter p : decl.getParameters()) {
|
||||||
if (p.getNameAsString().equals(name)) {
|
if (p.getNameAsString().equals(name)) {
|
||||||
return p.getType();
|
return p.getType();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 类字段
|
|
||||||
Optional<ClassOrInterfaceDeclaration> clazz = contextCall.findAncestor(ClassOrInterfaceDeclaration.class);
|
Optional<ClassOrInterfaceDeclaration> clazz = contextCall.findAncestor(ClassOrInterfaceDeclaration.class);
|
||||||
if (clazz.isPresent()) {
|
if (clazz.isPresent()) {
|
||||||
for (FieldDeclaration field : clazz.get().getFields()) {
|
for (FieldDeclaration field : clazz.get().getFields()) {
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.codechecker.redis.detector;
|
package com.codechecker.cache.detector;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一个 Redis value 写入点的静态描述。
|
* 一个 Redis / MQ value 写入(投递)点的静态描述。
|
||||||
*/
|
*/
|
||||||
public class WritePoint {
|
public class WritePoint {
|
||||||
|
|
||||||
|
public static final String CHANNEL_REDIS = "REDIS";
|
||||||
|
public static final String CHANNEL_ROCKETMQ = "ROCKETMQ";
|
||||||
|
public static final String CHANNEL_KAFKA = "KAFKA";
|
||||||
|
|
||||||
private String filePath;
|
private String filePath;
|
||||||
private int lineNumber;
|
private int lineNumber;
|
||||||
private String enclosingClass;
|
private String enclosingClass;
|
||||||
@@ -20,6 +24,9 @@ public class WritePoint {
|
|||||||
|
|
||||||
private double confidence = 1.0;
|
private double confidence = 1.0;
|
||||||
|
|
||||||
|
/** REDIS / ROCKETMQ / KAFKA;默认 REDIS 兼容现网 */
|
||||||
|
private String channel = CHANNEL_REDIS;
|
||||||
|
|
||||||
/** 稳定标识:用于在 old/new 两个版本间配对同一写入点 */
|
/** 稳定标识:用于在 old/new 两个版本间配对同一写入点 */
|
||||||
public String signature() {
|
public String signature() {
|
||||||
return enclosingClass + "#" + enclosingMethod + "|" + normalizeKey();
|
return enclosingClass + "#" + enclosingMethod + "|" + normalizeKey();
|
||||||
@@ -29,6 +36,10 @@ public class WritePoint {
|
|||||||
return keyExpression == null ? "" : keyExpression.replaceAll("\\s+", "");
|
return keyExpression == null ? "" : keyExpression.replaceAll("\\s+", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isMq() {
|
||||||
|
return CHANNEL_ROCKETMQ.equals(channel) || CHANNEL_KAFKA.equals(channel);
|
||||||
|
}
|
||||||
|
|
||||||
public String getFilePath() {
|
public String getFilePath() {
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
@@ -117,6 +128,14 @@ public class WritePoint {
|
|||||||
this.confidence = confidence;
|
this.confidence = confidence;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getChannel() {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChannel(String channel) {
|
||||||
|
this.channel = channel == null || channel.isEmpty() ? CHANNEL_REDIS : channel;
|
||||||
|
}
|
||||||
|
|
||||||
public String location() {
|
public String location() {
|
||||||
String simpleClass = enclosingClass;
|
String simpleClass = enclosingClass;
|
||||||
if (simpleClass != null && simpleClass.contains(".")) {
|
if (simpleClass != null && simpleClass.contains(".")) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.diff;
|
package com.codechecker.cache.diff;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 结构变更类型及其默认严重级别。
|
* 结构变更类型及其默认严重级别。
|
||||||
@@ -7,6 +7,7 @@ public enum ChangeType {
|
|||||||
FIELD_REMOVED(Severity.P0, "字段删除"),
|
FIELD_REMOVED(Severity.P0, "字段删除"),
|
||||||
TYPE_CHANGED(Severity.P0, "字段类型变更"),
|
TYPE_CHANGED(Severity.P0, "字段类型变更"),
|
||||||
WRAPPER_ADDED(Severity.P0, "新增包装层"),
|
WRAPPER_ADDED(Severity.P0, "新增包装层"),
|
||||||
|
WRAPPER_REMOVED(Severity.P0, "删除包装层"),
|
||||||
FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"),
|
FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"),
|
||||||
FIELD_ADDED(Severity.P1, "新增字段"),
|
FIELD_ADDED(Severity.P1, "新增字段"),
|
||||||
KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"),
|
KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"),
|
||||||
@@ -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 Severity severity;
|
||||||
private ChangeType changeType;
|
private ChangeType changeType;
|
||||||
private String keyPattern;
|
private String keyPattern;
|
||||||
|
private String keyExpression;
|
||||||
private String writeLocation;
|
private String writeLocation;
|
||||||
|
private String valueType;
|
||||||
private String fieldPath;
|
private String fieldPath;
|
||||||
private String oldValue;
|
private String oldValue;
|
||||||
private String newValue;
|
private String newValue;
|
||||||
@@ -43,6 +45,14 @@ public class SchemaChange {
|
|||||||
this.keyPattern = keyPattern;
|
this.keyPattern = keyPattern;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getKeyExpression() {
|
||||||
|
return keyExpression;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKeyExpression(String keyExpression) {
|
||||||
|
this.keyExpression = keyExpression;
|
||||||
|
}
|
||||||
|
|
||||||
public String getWriteLocation() {
|
public String getWriteLocation() {
|
||||||
return writeLocation;
|
return writeLocation;
|
||||||
}
|
}
|
||||||
@@ -51,6 +61,14 @@ public class SchemaChange {
|
|||||||
this.writeLocation = writeLocation;
|
this.writeLocation = writeLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getValueType() {
|
||||||
|
return valueType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValueType(String valueType) {
|
||||||
|
this.valueType = valueType;
|
||||||
|
}
|
||||||
|
|
||||||
public String getFieldPath() {
|
public String getFieldPath() {
|
||||||
return fieldPath;
|
return fieldPath;
|
||||||
}
|
}
|
||||||
280
src/main/java/com/codechecker/cache/diff/SchemaDiffer.java
vendored
Normal file
280
src/main/java/com/codechecker/cache/diff/SchemaDiffer.java
vendored
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package com.codechecker.cache.diff;
|
||||||
|
|
||||||
|
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;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比两个版本的 {@link TypeSchema},输出结构变更列表。基于叶子字段路径集合进行差异分析。
|
||||||
|
*/
|
||||||
|
public class SchemaDiffer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return 结构变更列表(不含 keyPattern/location 上下文,由调用方补充)
|
||||||
|
*/
|
||||||
|
public List<SchemaChange> diff(TypeSchema oldSchema, TypeSchema newSchema) {
|
||||||
|
List<SchemaChange> changes = new ArrayList<>();
|
||||||
|
|
||||||
|
Map<String, FieldSchema> oldAll = fieldsByPath(oldSchema);
|
||||||
|
Map<String, FieldSchema> newAll = fieldsByPath(newSchema);
|
||||||
|
|
||||||
|
Map<String, FieldSchema> oldLeaves = leaves(oldSchema);
|
||||||
|
Map<String, FieldSchema> newLeaves = leaves(newSchema);
|
||||||
|
|
||||||
|
Set<String> typeChangedPaths = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
// 类型变更:同路径下 JsonType 或 Java 类型任一变化(覆盖 BigDecimal→String 等同桶变更)
|
||||||
|
for (String path : oldAll.keySet()) {
|
||||||
|
FieldSchema oldField = oldAll.get(path);
|
||||||
|
FieldSchema newField = newAll.get(path);
|
||||||
|
if (newField == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isTypeChange(oldField, newField)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
typeChangedPaths.add(path);
|
||||||
|
String oldLabel = typeLabel(oldField);
|
||||||
|
String newLabel = typeLabel(newField);
|
||||||
|
SchemaChange c = new SchemaChange(ChangeType.TYPE_CHANGED);
|
||||||
|
c.setFieldPath(path);
|
||||||
|
c.setOldValue(oldLabel);
|
||||||
|
c.setNewValue(newLabel);
|
||||||
|
c.setMessage("字段 " + path + " 类型由 " + oldLabel + " 变为 " + newLabel);
|
||||||
|
changes.add(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> removed = new LinkedHashSet<>(oldLeaves.keySet());
|
||||||
|
removed.removeAll(newLeaves.keySet());
|
||||||
|
removed.removeAll(typeChangedPaths);
|
||||||
|
Set<String> added = new LinkedHashSet<>(newLeaves.keySet());
|
||||||
|
added.removeAll(oldLeaves.keySet());
|
||||||
|
added.removeAll(typeChangedPaths);
|
||||||
|
|
||||||
|
// 路径迁移:
|
||||||
|
// - 下沉(加包装):旧路径是新路径后缀,如 dbName → vo.dbName
|
||||||
|
// - 上提(拆包装):新路径是旧路径后缀,如 vo.dbName → dbName
|
||||||
|
List<String[]> moves = new ArrayList<>();
|
||||||
|
Set<String> matchedRemoved = new LinkedHashSet<>();
|
||||||
|
Set<String> matchedAdded = new LinkedHashSet<>();
|
||||||
|
for (String r : removed) {
|
||||||
|
if (matchedRemoved.contains(r)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (String a : added) {
|
||||||
|
if (matchedAdded.contains(a)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isSuffix(a, r) || isSuffix(r, a)) {
|
||||||
|
moves.add(new String[]{r, a});
|
||||||
|
matchedRemoved.add(r);
|
||||||
|
matchedAdded.add(a);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增包装层:多个迁移共享同一新前缀
|
||||||
|
Map<String, Integer> newPrefixCount = new LinkedHashMap<>();
|
||||||
|
for (String[] move : moves) {
|
||||||
|
String a = move[1];
|
||||||
|
if (a.contains(".")) {
|
||||||
|
String prefix = a.substring(0, a.indexOf('.'));
|
||||||
|
newPrefixCount.merge(prefix, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, Integer> e : newPrefixCount.entrySet()) {
|
||||||
|
if (e.getValue() >= 2) {
|
||||||
|
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED);
|
||||||
|
c.setFieldPath(e.getKey());
|
||||||
|
c.setNewValue(e.getKey());
|
||||||
|
c.setMessage("新增包装层 " + e.getKey() + ",原顶层字段被下移至该层(影响 " + e.getValue() + " 个字段)");
|
||||||
|
changes.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除包装层:多个迁移共享同一旧前缀
|
||||||
|
Map<String, Integer> oldPrefixCount = new LinkedHashMap<>();
|
||||||
|
for (String[] move : moves) {
|
||||||
|
String r = move[0];
|
||||||
|
if (r.contains(".")) {
|
||||||
|
String prefix = r.substring(0, r.indexOf('.'));
|
||||||
|
oldPrefixCount.merge(prefix, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, Integer> e : oldPrefixCount.entrySet()) {
|
||||||
|
if (e.getValue() >= 2) {
|
||||||
|
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_REMOVED);
|
||||||
|
c.setFieldPath(e.getKey());
|
||||||
|
c.setOldValue(e.getKey());
|
||||||
|
c.setMessage("删除包装层 " + e.getKey() + ",其下字段被提升为外层(影响 " + e.getValue() + " 个字段)");
|
||||||
|
changes.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String[] move : moves) {
|
||||||
|
SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||||
|
c.setFieldPath(move[1]);
|
||||||
|
c.setOldValue(move[0]);
|
||||||
|
c.setNewValue(move[1]);
|
||||||
|
c.setMessage("字段路径迁移:" + move[0] + " → " + move[1]);
|
||||||
|
changes.add(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剩余删除
|
||||||
|
for (String r : removed) {
|
||||||
|
if (matchedRemoved.contains(r)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED);
|
||||||
|
c.setFieldPath(r);
|
||||||
|
c.setOldValue(typeLabel(oldLeaves.get(r)));
|
||||||
|
c.setMessage("删除字段 " + r);
|
||||||
|
changes.add(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 剩余新增
|
||||||
|
for (String a : added) {
|
||||||
|
if (matchedAdded.contains(a)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED);
|
||||||
|
c.setFieldPath(a);
|
||||||
|
c.setNewValue(typeLabel(newLeaves.get(a)));
|
||||||
|
c.setMessage("新增字段 " + a);
|
||||||
|
changes.add(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTypeChange(FieldSchema oldField, FieldSchema newField) {
|
||||||
|
if (oldField.getJsonType() != newField.getJsonType()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String oldJava = normalizeJavaType(oldField.getJavaType());
|
||||||
|
String newJava = normalizeJavaType(newField.getJavaType());
|
||||||
|
if (oldJava.isEmpty() || newJava.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !oldJava.equals(newJava);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示用类型标签:优先 Java 简单名,并附带 JsonType(便于识别同桶变更)。
|
||||||
|
*/
|
||||||
|
private String typeLabel(FieldSchema field) {
|
||||||
|
if (field == null) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
String java = simpleJavaType(field.getJavaType());
|
||||||
|
JsonType json = field.getJsonType();
|
||||||
|
if (java.isEmpty() || "?".equals(java)) {
|
||||||
|
return json == null ? "?" : json.name();
|
||||||
|
}
|
||||||
|
if (json == null) {
|
||||||
|
return java;
|
||||||
|
}
|
||||||
|
return java + "/" + json.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化后用于相等比较:去掉泛型与包名,基本类型与包装类型视为同一序列化类型。
|
||||||
|
*/
|
||||||
|
static String normalizeJavaType(String javaType) {
|
||||||
|
String simple = simpleJavaType(javaType);
|
||||||
|
if (simple.isEmpty() || "?".equals(simple)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
switch (simple) {
|
||||||
|
case "int":
|
||||||
|
return "Integer";
|
||||||
|
case "long":
|
||||||
|
return "Long";
|
||||||
|
case "short":
|
||||||
|
return "Short";
|
||||||
|
case "byte":
|
||||||
|
return "Byte";
|
||||||
|
case "double":
|
||||||
|
return "Double";
|
||||||
|
case "float":
|
||||||
|
return "Float";
|
||||||
|
case "boolean":
|
||||||
|
return "Boolean";
|
||||||
|
case "char":
|
||||||
|
return "Character";
|
||||||
|
default:
|
||||||
|
return simple;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String simpleJavaType(String javaType) {
|
||||||
|
if (javaType == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = javaType.trim();
|
||||||
|
if (s.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int lt = s.indexOf('<');
|
||||||
|
if (lt > 0) {
|
||||||
|
s = s.substring(0, lt).trim();
|
||||||
|
}
|
||||||
|
int dot = s.lastIndexOf('.');
|
||||||
|
if (dot >= 0 && dot < s.length() - 1) {
|
||||||
|
s = s.substring(dot + 1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, FieldSchema> fieldsByPath(TypeSchema schema) {
|
||||||
|
Map<String, FieldSchema> result = new LinkedHashMap<>();
|
||||||
|
if (schema == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (FieldSchema f : schema.getFields().values()) {
|
||||||
|
result.put(f.getPath(), f);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, FieldSchema> leaves(TypeSchema schema) {
|
||||||
|
Map<String, FieldSchema> result = new LinkedHashMap<>();
|
||||||
|
if (schema == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (FieldSchema f : schema.getFields().values()) {
|
||||||
|
if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) {
|
||||||
|
result.put(f.getPath(), f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 full 的按段后缀是否等于 suffix(如 vo.dbName 的后缀是 dbName)。
|
||||||
|
*/
|
||||||
|
private boolean isSuffix(String full, String suffix) {
|
||||||
|
if (full.equals(suffix)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String[] fullSeg = full.split("\\.");
|
||||||
|
String[] sufSeg = suffix.split("\\.");
|
||||||
|
if (sufSeg.length >= fullSeg.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 1; i <= sufSeg.length; i++) {
|
||||||
|
if (!fullSeg[fullSeg.length - i].equals(sufSeg[sufSeg.length - i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.diff;
|
package com.codechecker.cache.diff;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 变更严重级别。
|
* 变更严重级别。
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.git;
|
package com.codechecker.cache.git;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.git;
|
package com.codechecker.cache.git;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Git 操作异常。
|
* Git 操作异常。
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
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.Position;
|
||||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||||
import com.github.javaparser.ast.body.VariableDeclarator;
|
import com.github.javaparser.ast.body.VariableDeclarator;
|
||||||
|
import com.github.javaparser.ast.expr.AssignExpr;
|
||||||
import com.github.javaparser.ast.expr.BinaryExpr;
|
import com.github.javaparser.ast.expr.BinaryExpr;
|
||||||
import com.github.javaparser.ast.expr.Expression;
|
import com.github.javaparser.ast.expr.Expression;
|
||||||
import com.github.javaparser.ast.expr.FieldAccessExpr;
|
import com.github.javaparser.ast.expr.FieldAccessExpr;
|
||||||
@@ -17,10 +19,12 @@ import java.util.Optional;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。
|
* 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。
|
||||||
|
* <p>
|
||||||
|
* 对局部变量名:先查同名静态常量 / 同名方法 return,再回溯方法内使用点之前最近一次声明或赋值。
|
||||||
*/
|
*/
|
||||||
public class RedisKeyResolver {
|
public class RedisKeyResolver {
|
||||||
|
|
||||||
private static final int MAX_DEPTH = 6;
|
private static final int MAX_DEPTH = 8;
|
||||||
|
|
||||||
private final SourceIndex index;
|
private final SourceIndex index;
|
||||||
|
|
||||||
@@ -52,22 +56,38 @@ public class RedisKeyResolver {
|
|||||||
}
|
}
|
||||||
if (expr instanceof NameExpr) {
|
if (expr instanceof NameExpr) {
|
||||||
String name = ((NameExpr) expr).getNameAsString();
|
String name = ((NameExpr) expr).getNameAsString();
|
||||||
String constVal = lookupConstant(enclosingClass, name);
|
// 1) 静态常量 2) 同名方法 return 3) 局部变量定值(不改变既有优先级)
|
||||||
|
String constVal = lookupConstant(enclosingClass, name, context, depth);
|
||||||
if (constVal != null) {
|
if (constVal != null) {
|
||||||
return constVal;
|
return constVal;
|
||||||
}
|
}
|
||||||
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
|
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
|
||||||
return methodVal != null ? methodVal : "*";
|
if (methodVal != null) {
|
||||||
|
return methodVal;
|
||||||
|
}
|
||||||
|
String localVal = lookupLocalBinding(expr, name, enclosingClass, context, depth);
|
||||||
|
return localVal != null ? localVal : "*";
|
||||||
}
|
}
|
||||||
if (expr instanceof FieldAccessExpr) {
|
if (expr instanceof FieldAccessExpr) {
|
||||||
FieldAccessExpr fae = (FieldAccessExpr) expr;
|
FieldAccessExpr fae = (FieldAccessExpr) expr;
|
||||||
String fieldName = fae.getNameAsString();
|
String fieldName = fae.getNameAsString();
|
||||||
String scope = fae.getScope().toString();
|
if (fae.getScope() instanceof NameExpr) {
|
||||||
String external = lookupExternalConstant(scope, fieldName, context);
|
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) {
|
if (external != null) {
|
||||||
return external;
|
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 : "*";
|
return local != null ? local : "*";
|
||||||
}
|
}
|
||||||
if (expr instanceof MethodCallExpr) {
|
if (expr instanceof MethodCallExpr) {
|
||||||
@@ -77,23 +97,96 @@ public class RedisKeyResolver {
|
|||||||
String fmt = resolveExpr(call.getArgument(0), enclosingClass, context, depth + 1);
|
String fmt = resolveExpr(call.getArgument(0), enclosingClass, context, depth + 1);
|
||||||
return fmt.replaceAll("%[-0-9.]*[sdxDX]", "*");
|
return fmt.replaceAll("%[-0-9.]*[sdxDX]", "*");
|
||||||
}
|
}
|
||||||
// buildCacheKey(...) 等本类方法:解析其 return 表达式
|
|
||||||
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
|
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
|
||||||
return methodVal != null ? methodVal : "*";
|
return methodVal != null ? methodVal : "*";
|
||||||
}
|
}
|
||||||
return "*";
|
return "*";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name) {
|
/**
|
||||||
|
* 在包围方法内,取使用点之前对 {@code name} 最近一次声明初始化或赋值的右侧表达式。
|
||||||
|
*/
|
||||||
|
private String lookupLocalBinding(Expression useSite, String name,
|
||||||
|
ClassOrInterfaceDeclaration enclosingClass,
|
||||||
|
SourceIndex.IndexedType context, int depth) {
|
||||||
|
if (useSite == null || name == null || name.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MethodDeclaration method = useSite.findAncestor(MethodDeclaration.class).orElse(null);
|
||||||
|
if (method == null || !method.getBody().isPresent()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Position usePos = useSite.getBegin().orElse(null);
|
||||||
|
Expression bestRhs = null;
|
||||||
|
Position bestPos = null;
|
||||||
|
|
||||||
|
for (VariableDeclarator var : method.findAll(VariableDeclarator.class)) {
|
||||||
|
if (!name.equals(var.getNameAsString()) || !var.getInitializer().isPresent()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Position pos = var.getBegin().orElse(null);
|
||||||
|
if (!isUsableBinding(pos, usePos, bestPos)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bestPos = pos;
|
||||||
|
bestRhs = var.getInitializer().get();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (AssignExpr assign : method.findAll(AssignExpr.class)) {
|
||||||
|
if (!(assign.getTarget() instanceof NameExpr)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!name.equals(((NameExpr) assign.getTarget()).getNameAsString())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Position pos = assign.getBegin().orElse(null);
|
||||||
|
if (!isUsableBinding(pos, usePos, bestPos)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bestPos = pos;
|
||||||
|
bestRhs = assign.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestRhs == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 避免自引用(String key = key)陷入无意义递归
|
||||||
|
if (bestRhs instanceof NameExpr
|
||||||
|
&& name.equals(((NameExpr) bestRhs).getNameAsString())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolveExpr(bestRhs, enclosingClass, context, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 候选须严格在使用点之前,并在多个候选中取最近一次。 */
|
||||||
|
private static boolean isUsableBinding(Position candidate, Position usePos, Position bestPos) {
|
||||||
|
if (candidate == null) {
|
||||||
|
return bestPos == null;
|
||||||
|
}
|
||||||
|
if (usePos != null && !candidate.isBefore(usePos)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (bestPos == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return candidate.isAfter(bestPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name,
|
||||||
|
SourceIndex.IndexedType context, int depth) {
|
||||||
if (clazz == null) {
|
if (clazz == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
for (FieldDeclaration field : clazz.getFields()) {
|
for (FieldDeclaration field : clazz.getFields()) {
|
||||||
|
if (!field.isStatic()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (VariableDeclarator var : field.getVariables()) {
|
for (VariableDeclarator var : field.getVariables()) {
|
||||||
if (var.getNameAsString().equals(name)) {
|
if (var.getNameAsString().equals(name)) {
|
||||||
Optional<Expression> init = var.getInitializer();
|
Optional<Expression> init = var.getInitializer();
|
||||||
if (init.isPresent() && init.get() instanceof StringLiteralExpr) {
|
if (init.isPresent()) {
|
||||||
return ((StringLiteralExpr) init.get()).asString();
|
return resolveExpr(init.get(), clazz, context, depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +194,8 @@ public class RedisKeyResolver {
|
|||||||
return null;
|
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);
|
String fqn = index.resolveFqn(scopeName, context);
|
||||||
if (fqn == null) {
|
if (fqn == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -110,7 +204,7 @@ public class RedisKeyResolver {
|
|||||||
if (type == null) {
|
if (type == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return lookupConstant(type.getDeclaration(), fieldName);
|
return lookupConstant(type.getDeclaration(), fieldName, type, depth);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String lookupMethodReturn(ClassOrInterfaceDeclaration clazz, String methodName,
|
private String lookupMethodReturn(ClassOrInterfaceDeclaration clazz, String methodName,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.notify;
|
package com.codechecker.cache.notify;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import java.net.http.HttpResponse;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +47,15 @@ public class WeComNotifier {
|
|||||||
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
if (response.statusCode() == 200) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
System.err.println("[WeComNotifier] 通知失败, HTTP " + response.statusCode() + ": " + response.body());
|
System.err.println("[WeComNotifier] 通知失败, HTTP " + response.statusCode() + ": " + response.body());
|
||||||
@@ -55,4 +65,36 @@ public class WeComNotifier {
|
|||||||
return false;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.codechecker.redis.report;
|
package com.codechecker.cache.report;
|
||||||
|
|
||||||
import com.codechecker.redis.diff.SchemaChange;
|
import com.codechecker.cache.diff.SchemaChange;
|
||||||
import com.codechecker.redis.diff.Severity;
|
import com.codechecker.cache.diff.Severity;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -20,11 +20,12 @@ public class CheckReport {
|
|||||||
private String mode;
|
private String mode;
|
||||||
|
|
||||||
private final List<SchemaChange> changes = new ArrayList<>();
|
private final List<SchemaChange> changes = new ArrayList<>();
|
||||||
|
private final List<KeyStructureChange> keyChanges = new ArrayList<>();
|
||||||
private boolean blocked;
|
private boolean blocked;
|
||||||
private int exitCode;
|
private int exitCode;
|
||||||
|
|
||||||
public boolean hasChanges() {
|
public boolean hasChanges() {
|
||||||
return !changes.isEmpty();
|
return !changes.isEmpty() || !keyChanges.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public long count(Severity severity) {
|
public long count(Severity severity) {
|
||||||
@@ -91,6 +92,10 @@ public class CheckReport {
|
|||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<KeyStructureChange> getKeyChanges() {
|
||||||
|
return keyChanges;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isBlocked() {
|
public boolean isBlocked() {
|
||||||
return blocked;
|
return blocked;
|
||||||
}
|
}
|
||||||
139
src/main/java/com/codechecker/cache/report/KeyStructureChange.java
vendored
Normal file
139
src/main/java/com/codechecker/cache/report/KeyStructureChange.java
vendored
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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 / MQ Topic(或未知时的写入点)聚合后的结构变更摘要。
|
||||||
|
*/
|
||||||
|
public class KeyStructureChange {
|
||||||
|
|
||||||
|
/** 解析到的 key / destination 模式;未知时为 unknown-key */
|
||||||
|
private String keyPattern;
|
||||||
|
/** 源码中的 key / destination 表达式 */
|
||||||
|
private String keyExpression;
|
||||||
|
/** 写入位置 Class#method:line */
|
||||||
|
private String writeLocation;
|
||||||
|
/** 展示用 value 类型,如 List<ClockInExportVo> */
|
||||||
|
private String valueType;
|
||||||
|
private boolean keyUnresolved;
|
||||||
|
/** REDIS / ROCKETMQ / KAFKA */
|
||||||
|
private String channel = "REDIS";
|
||||||
|
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 getChannel() {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChannel(String channel) {
|
||||||
|
this.channel = channel == null || channel.isEmpty() ? "REDIS" : channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMq() {
|
||||||
|
return "ROCKETMQ".equals(channel) || "KAFKA".equals(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String channelDisplay() {
|
||||||
|
if ("ROCKETMQ".equals(channel)) {
|
||||||
|
return "RocketMQ";
|
||||||
|
}
|
||||||
|
if ("KAFKA".equals(channel)) {
|
||||||
|
return "Kafka";
|
||||||
|
}
|
||||||
|
return "Redis";
|
||||||
|
}
|
||||||
|
|
||||||
|
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/Topic 行展示文本:未解析优先用表达式。 */
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
395
src/main/java/com/codechecker/cache/report/ReportBuilder.java
vendored
Normal file
395
src/main/java/com/codechecker/cache/report/ReportBuilder.java
vendored
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
package com.codechecker.cache.report;
|
||||||
|
|
||||||
|
import com.codechecker.cache.diff.ChangeType;
|
||||||
|
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.LinkedHashSet;
|
||||||
|
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();
|
||||||
|
appendDestinationLine(sb, kc);
|
||||||
|
if (kc.isMq()) {
|
||||||
|
sb.append(" > **通道**: ").append(kc.channelDisplay()).append('\n');
|
||||||
|
}
|
||||||
|
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> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(kc.getFieldDetails());
|
||||||
|
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
|
||||||
|
Set<String> wrappersAdded = SkeletonAnnotator.pathsForWrapperAdded(kc.getFieldDetails());
|
||||||
|
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails());
|
||||||
|
String oldRendered = oldJson.isEmpty()
|
||||||
|
? "" : "“" + SkeletonAnnotator.annotateOldForWecom(
|
||||||
|
oldJson, oldHighlight, typeGray, wrappersRemoved) + "”";
|
||||||
|
String newRendered = newJson.isEmpty()
|
||||||
|
? "" : "“" + SkeletonAnnotator.annotateNewForWecom(
|
||||||
|
newJson, newGreen, typeGray, wrappersAdded) + "”";
|
||||||
|
if (oldJson.isEmpty() && !newJson.isEmpty()) {
|
||||||
|
sb.append(" > **value 新增为:** ").append(newRendered).append('\n');
|
||||||
|
} else if (!oldJson.isEmpty() && newJson.isEmpty()) {
|
||||||
|
sb.append(" > **value 原结构:** ").append(oldRendered)
|
||||||
|
.append(kc.isMq() ? "(已删除投递)\n" : "(已删除写入)\n");
|
||||||
|
} else {
|
||||||
|
sb.append(" > **value值由:** ").append(oldRendered).append('\n');
|
||||||
|
sb.append(" > **变更为:** ").append(newRendered).append('\n');
|
||||||
|
}
|
||||||
|
appendTypeChangeSummary(sb, kc.getFieldDetails());
|
||||||
|
sb.append('\n');
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型变更单独一行摘要,避免仅靠骨架颜色/占位难以识别。
|
||||||
|
* 例:{@code > **类型变更**: amount <font color="warning">BigDecimal → Integer</font>}
|
||||||
|
*/
|
||||||
|
private void appendTypeChangeSummary(StringBuilder sb, List<SchemaChange> details) {
|
||||||
|
if (details == null || details.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
for (SchemaChange c : details) {
|
||||||
|
if (c == null || c.getChangeType() != ChangeType.TYPE_CHANGED) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String path = nvl(c.getFieldPath());
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String oldType = displayJavaType(c.getOldValue());
|
||||||
|
String newType = displayJavaType(c.getNewValue());
|
||||||
|
String dedup = path + "|" + oldType + "|" + newType;
|
||||||
|
if (!seen.add(dedup)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
StringBuilder part = new StringBuilder();
|
||||||
|
// 字段名用普通文本(避免反引号被企微渲染成色块)
|
||||||
|
part.append(path);
|
||||||
|
if (!oldType.isEmpty() || !newType.isEmpty()) {
|
||||||
|
part.append(" <font color=\"warning\">")
|
||||||
|
.append(oldType.isEmpty() ? "?" : oldType)
|
||||||
|
.append(" → ")
|
||||||
|
.append(newType.isEmpty() ? "?" : newType)
|
||||||
|
.append("</font>");
|
||||||
|
}
|
||||||
|
parts.add(part.toString());
|
||||||
|
}
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sb.append(" > **类型变更**: ");
|
||||||
|
for (int i = 0; i < parts.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append(";");
|
||||||
|
}
|
||||||
|
sb.append(parts.get(i));
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 {@code BigDecimal/NUMBER} 转为展示用 {@code BigDecimal}。 */
|
||||||
|
private static String displayJavaType(String typeLabel) {
|
||||||
|
if (typeLabel == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = typeLabel.trim();
|
||||||
|
if (s.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int slash = s.indexOf('/');
|
||||||
|
if (slash > 0) {
|
||||||
|
s = s.substring(0, slash).trim();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key / Topic 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
|
||||||
|
*/
|
||||||
|
private void appendDestinationLine(StringBuilder sb, KeyStructureChange kc) {
|
||||||
|
boolean mq = kc.isMq();
|
||||||
|
String label = mq ? "Topic -->" : "Key -->";
|
||||||
|
String keyText = kc.isKeyUnresolved() && kc.getKeyExpression() != null
|
||||||
|
&& !kc.getKeyExpression().trim().isEmpty()
|
||||||
|
? kc.getKeyExpression().trim()
|
||||||
|
: nvl(kc.displayKey());
|
||||||
|
if (keyText.isEmpty()) {
|
||||||
|
keyText = "unknown-key";
|
||||||
|
}
|
||||||
|
sb.append("- ").append(label).append(" `").append(escapeWeComCode(keyText)).append('`');
|
||||||
|
if (kc.isKeyUnresolved()) {
|
||||||
|
sb.append(mq
|
||||||
|
? " <font color=\"comment\">(destination 未解析)</font>"
|
||||||
|
: " <font color=\"comment\">(key 无法解析)</font>");
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key 行(无 KeyStructureChange 时的退路)。
|
||||||
|
*/
|
||||||
|
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(escapeWeComCode(keyText)).append('`');
|
||||||
|
if (unresolved) {
|
||||||
|
sb.append(" <font color=\"comment\">(key 无法解析)</font>");
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企微 markdown 代码片段内的转义:{@code *} 会触发斜体(即使包在反引号里)。
|
||||||
|
*/
|
||||||
|
static String escapeWeComCode(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
// 反斜杠转义对企微不可靠;用全角 * 保留通配语义且不被吃掉
|
||||||
|
return text.replace("*", "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 位置、类型作为每个 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(escapeWeComCode(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
409
src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java
vendored
Normal file
409
src/main/java/com/codechecker/cache/report/SkeletonAnnotator.java
vendored
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
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>删除包装层 → 旧骨架优先整段标橙(先于子路径)</li>
|
||||||
|
* <li>删除字段 / 路径迁移旧侧 → 旧骨架,橙色 {@code warning}</li>
|
||||||
|
* <li>新增包装层 → 新骨架优先整体标绿(先于子路径)</li>
|
||||||
|
* <li>新增字段 / 路径迁移新侧 → 新骨架,绿色 {@code info}</li>
|
||||||
|
* <li>类型变更(修改类)→ 新旧骨架均为灰色 {@code comment}</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
final class SkeletonAnnotator {
|
||||||
|
|
||||||
|
/** 企微橙:删除 / 路径迁移旧侧 / 删除包装层 */
|
||||||
|
static final String COLOR_REMOVE = "warning";
|
||||||
|
/** 企微绿:新增 / 新侧路径迁移 */
|
||||||
|
static final String COLOR_ADD = "info";
|
||||||
|
/** 企微灰:类型变更(修改类) */
|
||||||
|
static final String COLOR_TYPE = "comment";
|
||||||
|
|
||||||
|
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> pathsForWrapperRemoved(List<SchemaChange> details) {
|
||||||
|
Set<String> paths = new LinkedHashSet<>();
|
||||||
|
if (details == null) {
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
for (SchemaChange c : details) {
|
||||||
|
if (c != null && c.getChangeType() == ChangeType.WRAPPER_REMOVED) {
|
||||||
|
addIfPresent(paths, c.getFieldPath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增包装层路径(新骨架优先整段标绿)。 */
|
||||||
|
static Set<String> pathsForWrapperAdded(List<SchemaChange> details) {
|
||||||
|
Set<String> paths = new LinkedHashSet<>();
|
||||||
|
if (details == null) {
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
for (SchemaChange c : details) {
|
||||||
|
if (c != null && c.getChangeType() == ChangeType.WRAPPER_ADDED) {
|
||||||
|
addIfPresent(paths, c.getFieldPath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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 Set<String> pathsForTypeChanged(List<SchemaChange> details) {
|
||||||
|
Set<String> paths = new LinkedHashSet<>();
|
||||||
|
if (details == null) {
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
for (SchemaChange c : details) {
|
||||||
|
if (c != null && c.getChangeType() == ChangeType.TYPE_CHANGED) {
|
||||||
|
addIfPresent(paths, c.getFieldPath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标注旧骨架:删除等橙色;类型变更灰色。
|
||||||
|
*/
|
||||||
|
static String annotateOldForWecom(String json, Set<String> orangePaths) {
|
||||||
|
return annotateOldForWecom(json, orangePaths, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String annotateOldForWecom(String json, Set<String> orangePaths, Set<String> grayPaths) {
|
||||||
|
return annotateOldForWecom(json, orangePaths, grayPaths, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param wrapperRemovedPaths 删除的包装层,优先于普通橙路径标注
|
||||||
|
*/
|
||||||
|
static String annotateOldForWecom(String json, Set<String> orangePaths,
|
||||||
|
Set<String> grayPaths, Set<String> wrapperRemovedPaths) {
|
||||||
|
String result = annotateForWecom(json, grayPaths, COLOR_TYPE);
|
||||||
|
result = annotateForWecom(result, wrapperRemovedPaths, COLOR_REMOVE);
|
||||||
|
return annotateForWecom(result, orangePaths, COLOR_REMOVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标注新骨架:类型变更灰色,新增绿色。
|
||||||
|
*/
|
||||||
|
static String annotateNewForWecom(String json, Set<String> greenPaths) {
|
||||||
|
return annotateNewForWecom(json, greenPaths, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标注新骨架(类型变更 → 灰色;包装层先绿;其余新增/迁移 → 绿)。
|
||||||
|
*/
|
||||||
|
static String annotateNewForWecom(String json, Set<String> greenPaths, Set<String> grayTypePaths) {
|
||||||
|
return annotateNewForWecom(json, greenPaths, grayTypePaths, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param wrapperPaths 包装层路径,优先于 {@code greenPaths} 标注,使 {@code "vo":{...}} 能整段染色
|
||||||
|
*/
|
||||||
|
static String annotateNewForWecom(String json, Set<String> greenPaths,
|
||||||
|
Set<String> grayTypePaths, Set<String> wrapperPaths) {
|
||||||
|
String result = annotateForWecom(json, grayTypePaths, COLOR_TYPE);
|
||||||
|
result = annotateForWecom(result, wrapperPaths, COLOR_ADD);
|
||||||
|
return annotateForWecom(result, greenPaths, COLOR_ADD);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅标注改动字段:其余正文保持普通文本。
|
||||||
|
* 改动片段格式:{@code <font color="warning|info|comment">"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/main/java/com/codechecker/cache/schema/AnnotationSupport.java
vendored
Normal file
150
src/main/java/com/codechecker/cache/schema/AnnotationSupport.java
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.schema;
|
package com.codechecker.cache.schema;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@@ -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.ClassOrInterfaceDeclaration;
|
||||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||||
@@ -21,12 +21,11 @@ public class JavaSchemaExtractor {
|
|||||||
|
|
||||||
private static final Set<String> STRING_TYPES = new HashSet<>(Arrays.asList(
|
private static final Set<String> STRING_TYPES = new HashSet<>(Arrays.asList(
|
||||||
"String", "CharSequence", "char", "Character", "UUID",
|
"String", "CharSequence", "char", "Character", "UUID",
|
||||||
"Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp",
|
"Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp"));
|
||||||
"BigDecimal"));
|
|
||||||
private static final Set<String> NUMBER_TYPES = new HashSet<>(Arrays.asList(
|
private static final Set<String> NUMBER_TYPES = new HashSet<>(Arrays.asList(
|
||||||
"int", "long", "short", "byte", "double", "float",
|
"int", "long", "short", "byte", "double", "float",
|
||||||
"Integer", "Long", "Short", "Byte", "Double", "Float",
|
"Integer", "Long", "Short", "Byte", "Double", "Float",
|
||||||
"Number", "BigInteger", "AtomicInteger", "AtomicLong"));
|
"Number", "BigInteger", "BigDecimal", "AtomicInteger", "AtomicLong"));
|
||||||
private static final Set<String> BOOLEAN_TYPES = new HashSet<>(Arrays.asList(
|
private static final Set<String> BOOLEAN_TYPES = new HashSet<>(Arrays.asList(
|
||||||
"boolean", "Boolean"));
|
"boolean", "Boolean"));
|
||||||
private static final Set<String> COLLECTION_TYPES = new HashSet<>(Arrays.asList(
|
private static final Set<String> COLLECTION_TYPES = new HashSet<>(Arrays.asList(
|
||||||
@@ -79,6 +78,7 @@ public class JavaSchemaExtractor {
|
|||||||
Set<String> nextAncestors = new LinkedHashSet<>(ancestors);
|
Set<String> nextAncestors = new LinkedHashSet<>(ancestors);
|
||||||
nextAncestors.add(type.getFqn());
|
nextAncestors.add(type.getFqn());
|
||||||
|
|
||||||
|
Set<String> classIgnored = AnnotationSupport.ignoredProperties(type.getDeclaration());
|
||||||
for (FieldDeclaration field : collectFields(type, new HashSet<>())) {
|
for (FieldDeclaration field : collectFields(type, new HashSet<>())) {
|
||||||
if (field.isStatic() || field.isTransient()) {
|
if (field.isStatic() || field.isTransient()) {
|
||||||
continue;
|
continue;
|
||||||
@@ -88,6 +88,9 @@ public class JavaSchemaExtractor {
|
|||||||
}
|
}
|
||||||
for (VariableDeclarator var : field.getVariables()) {
|
for (VariableDeclarator var : field.getVariables()) {
|
||||||
String jsonName = AnnotationSupport.jsonName(field, var.getNameAsString());
|
String jsonName = AnnotationSupport.jsonName(field, var.getNameAsString());
|
||||||
|
if (classIgnored.contains(jsonName) || classIgnored.contains(var.getNameAsString())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
String path = prefix.isEmpty() ? jsonName : prefix + "." + jsonName;
|
String path = prefix.isEmpty() ? jsonName : prefix + "." + jsonName;
|
||||||
expandType(var.getType(), path, type, schema, nextAncestors, depth);
|
expandType(var.getType(), path, type, schema, nextAncestors, depth);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.schema;
|
package com.codechecker.cache.schema;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 序列化后 JSON 值的粗粒度类型。
|
* 序列化后 JSON 值的粗粒度类型。
|
||||||
411
src/main/java/com/codechecker/cache/schema/SkeletonJsonRenderer.java
vendored
Normal file
411
src/main/java/com/codechecker/cache/schema/SkeletonJsonRenderer.java
vendored
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
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(), field.getJavaType());
|
||||||
|
}
|
||||||
|
// 根数组:字段以 [] / [].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, String javaType) {
|
||||||
|
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) {
|
||||||
|
elem.type = type;
|
||||||
|
elem.javaType = javaType;
|
||||||
|
if (type != JsonType.OBJECT && type != JsonType.ARRAY && type != JsonType.MAP) {
|
||||||
|
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.javaType = javaType;
|
||||||
|
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, node.javaType);
|
||||||
|
}
|
||||||
|
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, child.javaType));
|
||||||
|
} 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, child.javaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数字类型示意占位(合法 JSON number):整数 {@code 0}、浮点 {@code 0.0}、BigDecimal {@code 0.00}。
|
||||||
|
* 仅用于通知可读性,不代表真实精度/scale。
|
||||||
|
*/
|
||||||
|
private String placeholder(JsonType type, String javaType) {
|
||||||
|
if (type == null) {
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case NUMBER:
|
||||||
|
return numberPlaceholder(javaType);
|
||||||
|
case BOOLEAN:
|
||||||
|
return "false";
|
||||||
|
case STRING:
|
||||||
|
return "\"\"";
|
||||||
|
case MAP:
|
||||||
|
case OBJECT:
|
||||||
|
return "{}";
|
||||||
|
case ARRAY:
|
||||||
|
return "[]";
|
||||||
|
case UNKNOWN:
|
||||||
|
default:
|
||||||
|
return "null";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String numberPlaceholder(String javaType) {
|
||||||
|
String simple = simpleJavaType(javaType);
|
||||||
|
if ("BigDecimal".equals(simple)) {
|
||||||
|
return "0.00";
|
||||||
|
}
|
||||||
|
if ("float".equals(simple) || "Float".equals(simple)
|
||||||
|
|| "double".equals(simple) || "Double".equals(simple)) {
|
||||||
|
return "0.0";
|
||||||
|
}
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String simpleJavaType(String javaType) {
|
||||||
|
if (javaType == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = javaType.trim();
|
||||||
|
if (s.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int lt = s.indexOf('<');
|
||||||
|
if (lt > 0) {
|
||||||
|
s = s.substring(0, lt).trim();
|
||||||
|
}
|
||||||
|
int dot = s.lastIndexOf('.');
|
||||||
|
if (dot >= 0 && dot < s.length() - 1) {
|
||||||
|
s = s.substring(dot + 1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
String javaType;
|
||||||
|
boolean leaf;
|
||||||
|
final Map<String, Node> children = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
Node(JsonType type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.schema;
|
package com.codechecker.cache.schema;
|
||||||
|
|
||||||
import com.github.javaparser.ParserConfiguration;
|
import com.github.javaparser.ParserConfiguration;
|
||||||
import com.github.javaparser.StaticJavaParser;
|
import com.github.javaparser.StaticJavaParser;
|
||||||
@@ -8,9 +8,10 @@ import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
|||||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一次提交快照下的源码类型索引。仅索引本仓库源码(不含依赖 jar),供类型解析与字段展开使用。
|
* 一次提交快照下的源码类型索引。仅索引本仓库源码(不含依赖 jar),供类型解析与字段展开使用。
|
||||||
@@ -21,9 +22,9 @@ import java.util.Map;
|
|||||||
public class SourceIndex {
|
public class SourceIndex {
|
||||||
|
|
||||||
/** FQN(以 . 分隔,含内部类) -> 类型信息 */
|
/** FQN(以 . 分隔,含内部类) -> 类型信息 */
|
||||||
private final Map<String, IndexedType> byFqn = new LinkedHashMap<>();
|
private final Map<String, IndexedType> byFqn = new ConcurrentHashMap<>();
|
||||||
/** 简单类名 -> FQN 列表(兜底解析) */
|
/** 简单类名 -> FQN 列表(兜底解析) */
|
||||||
private final Map<String, List<String>> bySimpleName = new LinkedHashMap<>();
|
private final Map<String, List<String>> bySimpleName = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
static {
|
static {
|
||||||
ParserConfiguration config = new ParserConfiguration()
|
ParserConfiguration config = new ParserConfiguration()
|
||||||
@@ -31,6 +32,20 @@ public class SourceIndex {
|
|||||||
StaticJavaParser.setConfiguration(config);
|
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)。
|
* 解析并加入一个 Java 源文件内容。解析失败时静默跳过(返回 false)。
|
||||||
*/
|
*/
|
||||||
@@ -65,7 +80,6 @@ public class SourceIndex {
|
|||||||
byFqn.put(fqn, indexed);
|
byFqn.put(fqn, indexed);
|
||||||
bySimpleName.computeIfAbsent(simpleName, k -> new ArrayList<>()).add(fqn);
|
bySimpleName.computeIfAbsent(simpleName, k -> new ArrayList<>()).add(fqn);
|
||||||
}
|
}
|
||||||
// 递归内部类型
|
|
||||||
for (Object member : type.getMembers()) {
|
for (Object member : type.getMembers()) {
|
||||||
if (member instanceof TypeDeclaration) {
|
if (member instanceof TypeDeclaration) {
|
||||||
registerType((TypeDeclaration<?>) member, packageName, imports, fqn);
|
registerType((TypeDeclaration<?>) member, packageName, imports, fqn);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.schema;
|
package com.codechecker.cache.schema;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
80
src/main/resources/default-config.yaml
Normal file
80
src/main/resources/default-config.yaml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# serialization-schema-checker 内置默认配置
|
||||||
|
# 业务仓库通过 --config 指定的配置会与本文件深度合并(业务配置优先)。
|
||||||
|
|
||||||
|
# 总开关:false 时不执行检测、不发通知、流水线直接通过
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# 运行模式:notify(仅通知)| block(检测到结构变更即阻断,exit 1)
|
||||||
|
mode: notify
|
||||||
|
|
||||||
|
# 是否扫描测试代码(第一版固定 false)
|
||||||
|
scan_test_sources: false
|
||||||
|
|
||||||
|
# 源码扫描根目录(相对被检测模块)
|
||||||
|
source_roots:
|
||||||
|
- "src/main/java"
|
||||||
|
|
||||||
|
# 通知配置
|
||||||
|
notify:
|
||||||
|
enabled: true
|
||||||
|
webhook_url: ""
|
||||||
|
notify_on_clean: false
|
||||||
|
title_prefix: "[序列化结构变更]"
|
||||||
|
|
||||||
|
# 忽略规则
|
||||||
|
ignore:
|
||||||
|
# 忽略的 key 模式(glob:* 单层,** 多层)
|
||||||
|
key_patterns:
|
||||||
|
- "*:lock"
|
||||||
|
- "*:lock:*"
|
||||||
|
- "*lock*"
|
||||||
|
- "loginCount:*"
|
||||||
|
- "Authorization:*"
|
||||||
|
# 忽略的文件路径模式
|
||||||
|
file_patterns:
|
||||||
|
- "**/test/**"
|
||||||
|
# 忽略的写入方法(类全名#方法名)
|
||||||
|
writer_methods: []
|
||||||
|
# 忽略的 MQ destination(topic 或 topic:tag,glob)
|
||||||
|
mq_destinations: []
|
||||||
|
|
||||||
|
# 检测规则
|
||||||
|
detection:
|
||||||
|
patterns:
|
||||||
|
- W01 # redisUtil.insert(key, JSON.toJSONString(x), ttl)
|
||||||
|
- W02 # redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)
|
||||||
|
- W03 # stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)
|
||||||
|
- W04 # redisTemplate.opsForValue().set(key, obj, ...)
|
||||||
|
- W05 # redisTemplate.opsForHash().put(key, field, obj)
|
||||||
|
# MQ 生产侧投递检测(Phase M1 + M2)
|
||||||
|
mq_patterns:
|
||||||
|
- MQ01 # rocketMQTemplate.syncSend(dest, payload)
|
||||||
|
- MQ02 # asyncSend / syncSendOrderly / sendOneWay
|
||||||
|
- MQ03 # convertAndSend(dest, payload)
|
||||||
|
- MQ04 # MessageBuilder.withPayload / Message<T>
|
||||||
|
- MQ05 # 先 toJSONString 再 send String
|
||||||
|
- MQ-K01 # kafkaTemplate.send(topic, payload)
|
||||||
|
- MQ-K02 # kafkaTemplate.send(topic, key, payload)
|
||||||
|
- MQ-K03 # kafkaTemplate.send(ProducerRecord)
|
||||||
|
- MQ-K04 # 先 JSON 序列化为 String 再 send
|
||||||
|
# W06 读侧辅助:用 parseObject / getJsonToBean 等补强写入点 value 类型(非写入模式)
|
||||||
|
read_hints_enabled: true
|
||||||
|
# MQ-R 读侧补强(Listener / parseObject)
|
||||||
|
mq_read_hints_enabled: true
|
||||||
|
# 类型推断最低置信度,低于此值标记为低置信度提示
|
||||||
|
min_confidence: 0.6
|
||||||
|
# 字段展开最大深度(防止循环引用)
|
||||||
|
max_field_depth: 8
|
||||||
|
|
||||||
|
# 严重级别覆盖
|
||||||
|
severity_overrides: {}
|
||||||
|
|
||||||
|
# 人工补充映射
|
||||||
|
manual_mappings: []
|
||||||
|
|
||||||
|
# 抑制规则(已知误报)
|
||||||
|
suppressions: []
|
||||||
|
|
||||||
|
# 模块过滤(空表示全部模块)
|
||||||
|
include_modules: []
|
||||||
|
exclude_modules: []
|
||||||
185
src/test/java/com/codechecker/cache/MqScenarioTest.java
vendored
Normal file
185
src/test/java/com/codechecker/cache/MqScenarioTest.java
vendored
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package com.codechecker.cache;
|
||||||
|
|
||||||
|
import com.codechecker.cache.detector.CacheReadHint;
|
||||||
|
import com.codechecker.cache.detector.MqReadHintDetector;
|
||||||
|
import com.codechecker.cache.detector.MqWritePointDetector;
|
||||||
|
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.report.CheckReport;
|
||||||
|
import com.codechecker.cache.report.KeyStructureChange;
|
||||||
|
import com.codechecker.cache.report.ReportBuilder;
|
||||||
|
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;
|
||||||
|
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.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class MqScenarioTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rocketMqFieldRemovalProducesTopicDiff() {
|
||||||
|
String constants = TestSupport.fixture("fixtures/mq/rocket-wallet/CapitalMqConstants.txt");
|
||||||
|
String oldReq = TestSupport.fixture("fixtures/mq/rocket-wallet/WalletDeductReqOld.txt");
|
||||||
|
String newReq = TestSupport.fixture("fixtures/mq/rocket-wallet/WalletDeductReqNew.txt");
|
||||||
|
String producer = TestSupport.fixture("fixtures/mq/rocket-wallet/WalletDeductProducer.txt");
|
||||||
|
|
||||||
|
SourceIndex oldIndex = new SourceIndex();
|
||||||
|
oldIndex.addSource(constants);
|
||||||
|
oldIndex.addSource(oldReq);
|
||||||
|
oldIndex.addSource(producer);
|
||||||
|
|
||||||
|
SourceIndex newIndex = new SourceIndex();
|
||||||
|
newIndex.addSource(constants);
|
||||||
|
newIndex.addSource(newReq);
|
||||||
|
newIndex.addSource(producer);
|
||||||
|
|
||||||
|
Set<String> patterns = new HashSet<>();
|
||||||
|
patterns.add("MQ01");
|
||||||
|
WritePoint wp = new MqWritePointDetector(newIndex, patterns)
|
||||||
|
.detect("WalletDeductProducer.java", producer).get(0);
|
||||||
|
|
||||||
|
TypeSchema oldSchema = new JavaSchemaExtractor(oldIndex, 8)
|
||||||
|
.extract(wp.getResolvedValueType(), wp.isRootArray());
|
||||||
|
TypeSchema newSchema = new JavaSchemaExtractor(newIndex, 8)
|
||||||
|
.extract(wp.getResolvedValueType(), wp.isRootArray());
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldSchema, newSchema);
|
||||||
|
List<ChangeType> types = changes.stream().map(SchemaChange::getChangeType).collect(Collectors.toList());
|
||||||
|
assertTrue(types.contains(ChangeType.FIELD_REMOVED), "应删除 remark 字段");
|
||||||
|
|
||||||
|
CheckReport report = new CheckReport();
|
||||||
|
report.setRepository("demo");
|
||||||
|
report.setBranch("feature/mq");
|
||||||
|
report.setOldSha("aaa");
|
||||||
|
report.setNewSha("bbb");
|
||||||
|
KeyStructureChange key = new KeyStructureChange();
|
||||||
|
key.setKeyPattern(wp.getResolvedKeyPattern());
|
||||||
|
key.setChannel(WritePoint.CHANNEL_ROCKETMQ);
|
||||||
|
key.setWriteLocation(wp.location());
|
||||||
|
key.setValueType("WalletDeductReq");
|
||||||
|
key.setOldSkeletonJson("{\"walletId\":\"\",\"amount\":0,\"remark\":\"\"}");
|
||||||
|
key.setNewSkeletonJson("{\"walletId\":\"\",\"amount\":0}");
|
||||||
|
key.getFieldDetails().addAll(changes);
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
report.getChanges().addAll(changes);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
assertTrue(md.contains("- Topic --> `capital-topic:WALLET_DEDUCT`"), md);
|
||||||
|
assertTrue(md.contains("> **通道**: RocketMQ"), md);
|
||||||
|
assertTrue(md.contains("<font color=\"warning\">\"remark\":\"\"</font>")
|
||||||
|
|| md.contains("remark"), md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void kafkaListFieldRemovalProducesTopicDiff() {
|
||||||
|
String oldVo = TestSupport.fixture("fixtures/mq/kafka-patrol/CheckItemDetailVoOld.txt");
|
||||||
|
String newVo = TestSupport.fixture("fixtures/mq/kafka-patrol/CheckItemDetailVoNew.txt");
|
||||||
|
String service = TestSupport.fixture("fixtures/mq/kafka-patrol/PatrolService.txt");
|
||||||
|
|
||||||
|
SourceIndex oldIndex = new SourceIndex();
|
||||||
|
oldIndex.addSource(oldVo);
|
||||||
|
oldIndex.addSource(service);
|
||||||
|
SourceIndex newIndex = new SourceIndex();
|
||||||
|
newIndex.addSource(newVo);
|
||||||
|
newIndex.addSource(service);
|
||||||
|
|
||||||
|
Set<String> patterns = new HashSet<>();
|
||||||
|
patterns.add("MQ-K01");
|
||||||
|
WritePoint wp = new MqWritePointDetector(newIndex, patterns)
|
||||||
|
.detect("PatrolService.java", service).get(0);
|
||||||
|
assertEquals("patrol-store-food-safe:*", wp.getResolvedKeyPattern());
|
||||||
|
assertTrue(wp.isRootArray());
|
||||||
|
|
||||||
|
TypeSchema oldSchema = new JavaSchemaExtractor(oldIndex, 8)
|
||||||
|
.extract(wp.getResolvedValueType(), true);
|
||||||
|
TypeSchema newSchema = new JavaSchemaExtractor(newIndex, 8)
|
||||||
|
.extract(wp.getResolvedValueType(), true);
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldSchema, newSchema);
|
||||||
|
assertTrue(changes.stream().anyMatch(c -> c.getChangeType() == ChangeType.FIELD_REMOVED));
|
||||||
|
|
||||||
|
CheckReport report = new CheckReport();
|
||||||
|
report.setRepository("demo");
|
||||||
|
KeyStructureChange key = new KeyStructureChange();
|
||||||
|
key.setKeyPattern(wp.getResolvedKeyPattern());
|
||||||
|
key.setChannel(WritePoint.CHANNEL_KAFKA);
|
||||||
|
key.setValueType("List<CheckItemDetailVo>");
|
||||||
|
key.setWriteLocation(wp.location());
|
||||||
|
key.setOldSkeletonJson("[{\"itemId\":\"\",\"itemName\":\"\",\"score\":0}]");
|
||||||
|
key.setNewSkeletonJson("[{\"itemId\":\"\",\"itemName\":\"\"}]");
|
||||||
|
key.getFieldDetails().addAll(changes);
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
assertTrue(md.contains("- Topic --> `patrol-store-food-safe:*`"), md);
|
||||||
|
assertTrue(md.contains("> **通道**: Kafka"), md);
|
||||||
|
assertTrue(md.contains("> **类型**: List<CheckItemDetailVo>"), md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void messageBuilderProducerResolvesDutyImNotice() {
|
||||||
|
String notice = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNotice.txt");
|
||||||
|
String producer = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNoticeProducer.txt");
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(notice);
|
||||||
|
index.addSource(producer);
|
||||||
|
|
||||||
|
Set<String> patterns = new HashSet<>();
|
||||||
|
patterns.add("MQ02");
|
||||||
|
patterns.add("MQ04");
|
||||||
|
WritePoint wp = new MqWritePointDetector(index, patterns)
|
||||||
|
.detect("DutyImNoticeProducer.java", producer).stream()
|
||||||
|
.filter(w -> "MQ04".equals(w.getPattern()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("应命中 MQ04"));
|
||||||
|
assertEquals("duty-im-notice-topic", wp.getResolvedKeyPattern());
|
||||||
|
assertTrue(wp.getResolvedValueType().endsWith("DutyImNotice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mqReadHintEnrichesWritePointByDestination() {
|
||||||
|
String notice = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNotice.txt");
|
||||||
|
String producer = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNoticeProducer.txt");
|
||||||
|
String consumer = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNoticeConsumer.txt");
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(notice);
|
||||||
|
index.addSource(producer);
|
||||||
|
index.addSource(consumer);
|
||||||
|
|
||||||
|
List<CacheReadHint> hints = new MqReadHintDetector(index)
|
||||||
|
.detect("DutyImNoticeConsumer.java", consumer);
|
||||||
|
assertFalse(hints.isEmpty());
|
||||||
|
|
||||||
|
Set<String> patterns = new HashSet<>();
|
||||||
|
patterns.add("MQ02");
|
||||||
|
patterns.add("MQ04");
|
||||||
|
WritePoint wp = new MqWritePointDetector(index, patterns)
|
||||||
|
.detect("DutyImNoticeProducer.java", producer).stream()
|
||||||
|
.filter(w -> "MQ04".equals(w.getPattern()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("应命中 MQ04"));
|
||||||
|
|
||||||
|
// 模拟无泛型 Message 导致类型缺失时,MQ-R 可按 destination 补强
|
||||||
|
wp.setResolvedValueType(null);
|
||||||
|
wp.setConfidence(0.4);
|
||||||
|
CacheReadHint hint = hints.get(0);
|
||||||
|
assertEquals(wp.getResolvedKeyPattern(), hint.getResolvedKeyPattern());
|
||||||
|
|
||||||
|
if (hint.getResolvedValueType() != null) {
|
||||||
|
wp.setResolvedValueType(hint.getResolvedValueType());
|
||||||
|
wp.setConfidence(Math.max(wp.getConfidence(), hint.getConfidence()));
|
||||||
|
}
|
||||||
|
assertTrue(wp.getResolvedValueType().endsWith("DutyImNotice"));
|
||||||
|
assertTrue(wp.getConfidence() >= 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
package com.codechecker.redis;
|
package com.codechecker.cache;
|
||||||
|
|
||||||
import com.codechecker.redis.detector.RedisWritePointDetector;
|
import com.codechecker.cache.detector.RedisWritePointDetector;
|
||||||
import com.codechecker.redis.detector.WritePoint;
|
import com.codechecker.cache.detector.WritePoint;
|
||||||
import com.codechecker.redis.diff.ChangeType;
|
import com.codechecker.cache.diff.ChangeType;
|
||||||
import com.codechecker.redis.diff.SchemaChange;
|
import com.codechecker.cache.diff.SchemaChange;
|
||||||
import com.codechecker.redis.diff.SchemaDiffer;
|
import com.codechecker.cache.diff.SchemaDiffer;
|
||||||
import com.codechecker.redis.schema.JavaSchemaExtractor;
|
import com.codechecker.cache.schema.JavaSchemaExtractor;
|
||||||
import com.codechecker.redis.schema.SourceIndex;
|
import com.codechecker.cache.schema.SourceIndex;
|
||||||
import com.codechecker.redis.schema.TypeSchema;
|
import com.codechecker.cache.schema.TypeSchema;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis;
|
package com.codechecker.cache;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.codechecker.redis.analyze;
|
package com.codechecker.cache.analyze;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
72
src/test/java/com/codechecker/cache/config/ConfigLoaderTest.java
vendored
Normal file
72
src/test/java/com/codechecker/cache/config/ConfigLoaderTest.java
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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"));
|
||||||
|
assertTrue(config.getDetection().getMqPatterns().contains("MQ01"));
|
||||||
|
assertTrue(config.getDetection().getMqPatterns().contains("MQ-K01"));
|
||||||
|
assertTrue(config.getDetection().getMqPatterns().contains("MQ04"));
|
||||||
|
assertTrue(config.getDetection().getMqPatterns().contains("MQ-K03"));
|
||||||
|
assertTrue(config.getDetection().isMqReadHintsEnabled());
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/test/java/com/codechecker/cache/detector/CacheReadHintDetectorTest.java
vendored
Normal file
71
src/test/java/com/codechecker/cache/detector/CacheReadHintDetectorTest.java
vendored
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/test/java/com/codechecker/cache/detector/MqReadHintDetectorTest.java
vendored
Normal file
51
src/test/java/com/codechecker/cache/detector/MqReadHintDetectorTest.java
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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.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 MqReadHintDetectorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsRocketMqListenerGenericType() {
|
||||||
|
String notice = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNotice.txt");
|
||||||
|
String producer = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNoticeProducer.txt");
|
||||||
|
String consumer = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNoticeConsumer.txt");
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(notice);
|
||||||
|
index.addSource(producer);
|
||||||
|
index.addSource(consumer);
|
||||||
|
|
||||||
|
List<CacheReadHint> hints = new MqReadHintDetector(index)
|
||||||
|
.detect("DutyImNoticeConsumer.java", consumer);
|
||||||
|
assertFalse(hints.isEmpty());
|
||||||
|
CacheReadHint hint = hints.get(0);
|
||||||
|
assertTrue(hint.getResolvedValueType().endsWith("DutyImNotice"));
|
||||||
|
assertEquals("duty-im-notice-topic", hint.getResolvedKeyPattern());
|
||||||
|
assertFalse(hint.isRootArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsKafkaListenerParseObject() {
|
||||||
|
String vo = TestSupport.fixture("fixtures/mq/kafka-record/PatrolNotifyVo.txt");
|
||||||
|
String listener = TestSupport.fixture("fixtures/mq/kafka-record/PatrolNotifyListener.txt");
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(vo);
|
||||||
|
index.addSource(listener);
|
||||||
|
|
||||||
|
List<CacheReadHint> hints = new MqReadHintDetector(index)
|
||||||
|
.detect("PatrolNotifyListener.java", listener);
|
||||||
|
assertTrue(hints.stream().anyMatch(h ->
|
||||||
|
h.getResolvedValueType() != null
|
||||||
|
&& h.getResolvedValueType().endsWith("PatrolNotifyVo")
|
||||||
|
&& "patrol-notify-topic".equals(h.getResolvedKeyPattern())));
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/test/java/com/codechecker/cache/detector/MqWritePointDetectorTest.java
vendored
Normal file
175
src/test/java/com/codechecker/cache/detector/MqWritePointDetectorTest.java
vendored
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
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.HashSet;
|
||||||
|
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 MqWritePointDetectorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsRocketMqSyncSendWithTopicTag() {
|
||||||
|
String constants = TestSupport.fixture("fixtures/mq/rocket-wallet/CapitalMqConstants.txt");
|
||||||
|
String req = TestSupport.fixture("fixtures/mq/rocket-wallet/WalletDeductReqOld.txt");
|
||||||
|
String producer = TestSupport.fixture("fixtures/mq/rocket-wallet/WalletDeductProducer.txt");
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(constants);
|
||||||
|
index.addSource(req);
|
||||||
|
index.addSource(producer);
|
||||||
|
|
||||||
|
Set<String> patterns = new HashSet<>();
|
||||||
|
patterns.add("MQ01");
|
||||||
|
patterns.add("MQ02");
|
||||||
|
patterns.add("MQ03");
|
||||||
|
patterns.add("MQ04");
|
||||||
|
patterns.add("MQ05");
|
||||||
|
List<WritePoint> wps = new MqWritePointDetector(index, patterns)
|
||||||
|
.detect("WalletDeductProducer.java", producer);
|
||||||
|
|
||||||
|
assertEquals(1, wps.size());
|
||||||
|
WritePoint wp = wps.get(0);
|
||||||
|
assertEquals("MQ01", wp.getPattern());
|
||||||
|
assertEquals(WritePoint.CHANNEL_ROCKETMQ, wp.getChannel());
|
||||||
|
assertEquals("capital-topic:WALLET_DEDUCT", wp.getResolvedKeyPattern());
|
||||||
|
assertTrue(wp.getResolvedValueType().endsWith("WalletDeductReq"));
|
||||||
|
assertFalse(wp.isRootArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsKafkaSendListAsRootArray() {
|
||||||
|
String vo = TestSupport.fixture("fixtures/mq/kafka-patrol/CheckItemDetailVoOld.txt");
|
||||||
|
String service = TestSupport.fixture("fixtures/mq/kafka-patrol/PatrolService.txt");
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(vo);
|
||||||
|
index.addSource(service);
|
||||||
|
|
||||||
|
Set<String> patterns = new HashSet<>();
|
||||||
|
patterns.add("MQ-K01");
|
||||||
|
patterns.add("MQ-K02");
|
||||||
|
List<WritePoint> wps = new MqWritePointDetector(index, patterns)
|
||||||
|
.detect("PatrolService.java", service);
|
||||||
|
|
||||||
|
assertEquals(1, wps.size());
|
||||||
|
WritePoint wp = wps.get(0);
|
||||||
|
assertEquals("MQ-K01", wp.getPattern());
|
||||||
|
assertEquals(WritePoint.CHANNEL_KAFKA, wp.getChannel());
|
||||||
|
assertEquals("patrol-store-food-safe:*", wp.getResolvedKeyPattern());
|
||||||
|
assertTrue(wp.getResolvedValueType().endsWith("CheckItemDetailVo"));
|
||||||
|
assertTrue(wp.isRootArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsMessageBuilderAsyncSendAsMq04() {
|
||||||
|
String notice = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNotice.txt");
|
||||||
|
String producer = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNoticeProducer.txt");
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(notice);
|
||||||
|
index.addSource(producer);
|
||||||
|
|
||||||
|
Set<String> patterns = allRocketPatterns();
|
||||||
|
List<WritePoint> wps = new MqWritePointDetector(index, patterns)
|
||||||
|
.detect("DutyImNoticeProducer.java", producer);
|
||||||
|
|
||||||
|
WritePoint mq04 = findByPattern(wps, "MQ04");
|
||||||
|
assertEquals(WritePoint.CHANNEL_ROCKETMQ, mq04.getChannel());
|
||||||
|
assertEquals("duty-im-notice-topic", mq04.getResolvedKeyPattern());
|
||||||
|
assertTrue(mq04.getResolvedValueType().endsWith("DutyImNotice"), mq04.getResolvedValueType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsConvertAndSendAsMq03() {
|
||||||
|
String notice = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNotice.txt");
|
||||||
|
String producer = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNoticeProducer.txt");
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(notice);
|
||||||
|
index.addSource(producer);
|
||||||
|
|
||||||
|
WritePoint mq03 = findByPattern(
|
||||||
|
new MqWritePointDetector(index, allRocketPatterns())
|
||||||
|
.detect("DutyImNoticeProducer.java", producer),
|
||||||
|
"MQ03");
|
||||||
|
assertTrue(mq03.getResolvedValueType().endsWith("DutyImNotice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsJsonStringSyncSendAsMq05() {
|
||||||
|
String notice = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNotice.txt");
|
||||||
|
String producer = TestSupport.fixture("fixtures/mq/rocket-im/DutyImNoticeProducer.txt");
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(notice);
|
||||||
|
index.addSource(producer);
|
||||||
|
|
||||||
|
WritePoint mq05 = findByPattern(
|
||||||
|
new MqWritePointDetector(index, allRocketPatterns())
|
||||||
|
.detect("DutyImNoticeProducer.java", producer),
|
||||||
|
"MQ05");
|
||||||
|
assertTrue(mq05.getResolvedValueType().endsWith("DutyImNotice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsProducerRecordSendAsMqK03() {
|
||||||
|
String vo = TestSupport.fixture("fixtures/mq/kafka-record/PatrolNotifyVo.txt");
|
||||||
|
String producer = TestSupport.fixture("fixtures/mq/kafka-record/PatrolNotifyProducer.txt");
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(vo);
|
||||||
|
index.addSource(producer);
|
||||||
|
|
||||||
|
Set<String> patterns = allKafkaPatterns();
|
||||||
|
WritePoint wp = findByPattern(
|
||||||
|
new MqWritePointDetector(index, patterns).detect("PatrolNotifyProducer.java", producer),
|
||||||
|
"MQ-K03");
|
||||||
|
assertEquals("patrol-notify-topic", wp.getResolvedKeyPattern());
|
||||||
|
assertTrue(wp.getResolvedValueType().endsWith("PatrolNotifyVo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsKafkaJsonStringAsMqK04() {
|
||||||
|
String vo = TestSupport.fixture("fixtures/mq/kafka-record/PatrolNotifyVo.txt");
|
||||||
|
String producer = TestSupport.fixture("fixtures/mq/kafka-record/PatrolNotifyProducer.txt");
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(vo);
|
||||||
|
index.addSource(producer);
|
||||||
|
|
||||||
|
WritePoint wp = findByPattern(
|
||||||
|
new MqWritePointDetector(index, allKafkaPatterns())
|
||||||
|
.detect("PatrolNotifyProducer.java", producer),
|
||||||
|
"MQ-K04");
|
||||||
|
assertTrue(wp.getResolvedValueType().endsWith("PatrolNotifyVo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> allRocketPatterns() {
|
||||||
|
Set<String> patterns = new HashSet<>();
|
||||||
|
patterns.add("MQ01");
|
||||||
|
patterns.add("MQ02");
|
||||||
|
patterns.add("MQ03");
|
||||||
|
patterns.add("MQ04");
|
||||||
|
patterns.add("MQ05");
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> allKafkaPatterns() {
|
||||||
|
Set<String> patterns = new HashSet<>();
|
||||||
|
patterns.add("MQ-K01");
|
||||||
|
patterns.add("MQ-K02");
|
||||||
|
patterns.add("MQ-K03");
|
||||||
|
patterns.add("MQ-K04");
|
||||||
|
return patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WritePoint findByPattern(List<WritePoint> wps, String pattern) {
|
||||||
|
return wps.stream()
|
||||||
|
.filter(w -> pattern.equals(w.getPattern()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("缺少模式 " + pattern + ",实际: " + wps));
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/test/java/com/codechecker/cache/detector/RedisWritePointDetectorTest.java
vendored
Normal file
101
src/test/java/com/codechecker/cache/detector/RedisWritePointDetectorTest.java
vendored
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/test/java/com/codechecker/cache/diff/SchemaDifferTest.java
vendored
Normal file
150
src/test/java/com/codechecker/cache/diff/SchemaDifferTest.java
vendored
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package com.codechecker.cache.diff;
|
||||||
|
|
||||||
|
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;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class SchemaDifferTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsWrapperMoveAndAddition() {
|
||||||
|
TypeSchema oldS = new TypeSchema("TenantVO");
|
||||||
|
oldS.add(new FieldSchema("dbName", JsonType.STRING, "String"));
|
||||||
|
oldS.add(new FieldSchema("linkList", JsonType.ARRAY, "List"));
|
||||||
|
oldS.add(new FieldSchema("linkList[]", JsonType.OBJECT, "TenantLinkModel"));
|
||||||
|
oldS.add(new FieldSchema("linkList[].id", JsonType.STRING, "String"));
|
||||||
|
|
||||||
|
TypeSchema newS = new TypeSchema("CacheEnvelope");
|
||||||
|
newS.add(new FieldSchema("vo", JsonType.OBJECT, "TenantVO"));
|
||||||
|
newS.add(new FieldSchema("vo.dbName", JsonType.STRING, "String"));
|
||||||
|
newS.add(new FieldSchema("vo.linkList", JsonType.ARRAY, "List"));
|
||||||
|
newS.add(new FieldSchema("vo.linkList[]", JsonType.OBJECT, "TenantLinkModel"));
|
||||||
|
newS.add(new FieldSchema("vo.linkList[].id", JsonType.STRING, "String"));
|
||||||
|
newS.add(new FieldSchema("expiresAtMs", JsonType.NUMBER, "Long"));
|
||||||
|
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||||
|
List<ChangeType> types = changes.stream().map(SchemaChange::getChangeType).collect(Collectors.toList());
|
||||||
|
|
||||||
|
assertTrue(types.contains(ChangeType.WRAPPER_ADDED), "应检测到包装层新增");
|
||||||
|
assertTrue(types.contains(ChangeType.FIELD_PATH_MOVED), "应检测到字段路径迁移");
|
||||||
|
assertTrue(types.contains(ChangeType.FIELD_ADDED), "应检测到新增字段 expiresAtMs");
|
||||||
|
|
||||||
|
long moved = changes.stream().filter(c -> c.getChangeType() == ChangeType.FIELD_PATH_MOVED).count();
|
||||||
|
assertEquals(2, moved, "dbName 与 linkList[].id 均应迁移");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsWrapperRemovalAndUpwardMoves() {
|
||||||
|
TypeSchema oldS = new TypeSchema("CacheEnvelope");
|
||||||
|
oldS.add(new FieldSchema("vo", JsonType.OBJECT, "TenantVO"));
|
||||||
|
oldS.add(new FieldSchema("vo.dbName", JsonType.STRING, "String"));
|
||||||
|
oldS.add(new FieldSchema("vo.linkList", JsonType.ARRAY, "List"));
|
||||||
|
oldS.add(new FieldSchema("vo.linkList[]", JsonType.OBJECT, "TenantLinkModel"));
|
||||||
|
oldS.add(new FieldSchema("vo.linkList[].id", JsonType.STRING, "String"));
|
||||||
|
|
||||||
|
TypeSchema newS = new TypeSchema("TenantVO");
|
||||||
|
newS.add(new FieldSchema("dbName", JsonType.STRING, "String"));
|
||||||
|
newS.add(new FieldSchema("linkList", JsonType.ARRAY, "List"));
|
||||||
|
newS.add(new FieldSchema("linkList[]", JsonType.OBJECT, "TenantLinkModel"));
|
||||||
|
newS.add(new FieldSchema("linkList[].id", JsonType.STRING, "String"));
|
||||||
|
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||||
|
List<ChangeType> types = changes.stream().map(SchemaChange::getChangeType).collect(Collectors.toList());
|
||||||
|
|
||||||
|
assertTrue(types.contains(ChangeType.WRAPPER_REMOVED), "应检测到包装层删除");
|
||||||
|
assertTrue(types.contains(ChangeType.FIELD_PATH_MOVED), "应检测到字段上提迁移");
|
||||||
|
assertTrue(types.stream().noneMatch(t -> t == ChangeType.FIELD_ADDED),
|
||||||
|
"上提字段不应再被当成新增");
|
||||||
|
assertTrue(types.stream().noneMatch(t -> t == ChangeType.FIELD_REMOVED),
|
||||||
|
"上提字段不应再被当成删除");
|
||||||
|
|
||||||
|
SchemaChange wrapper = changes.stream()
|
||||||
|
.filter(c -> c.getChangeType() == ChangeType.WRAPPER_REMOVED)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(IllegalStateException::new);
|
||||||
|
assertEquals("vo", wrapper.getFieldPath());
|
||||||
|
|
||||||
|
long moved = changes.stream().filter(c -> c.getChangeType() == ChangeType.FIELD_PATH_MOVED).count();
|
||||||
|
assertEquals(2, moved, "vo.dbName / vo.linkList[].id 均应上提");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsTypeChange() {
|
||||||
|
TypeSchema oldS = new TypeSchema("A");
|
||||||
|
oldS.add(new FieldSchema("count", JsonType.STRING, "String"));
|
||||||
|
TypeSchema newS = new TypeSchema("A");
|
||||||
|
newS.add(new FieldSchema("count", JsonType.NUMBER, "Integer"));
|
||||||
|
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||||
|
assertEquals(1, changes.size());
|
||||||
|
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
||||||
|
assertEquals(Severity.P0, changes.get(0).getSeverity());
|
||||||
|
assertTrue(changes.get(0).getMessage().contains("String"));
|
||||||
|
assertTrue(changes.get(0).getMessage().contains("Integer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsBigDecimalToStringEvenWhenBothWereStringBucketHistorically() {
|
||||||
|
// 即便 JsonType 同为 STRING,javaType 变化也必须检出(用户真实漏报场景)
|
||||||
|
TypeSchema oldS = new TypeSchema("A");
|
||||||
|
oldS.add(new FieldSchema("amount", JsonType.STRING, "BigDecimal"));
|
||||||
|
TypeSchema newS = new TypeSchema("A");
|
||||||
|
newS.add(new FieldSchema("amount", JsonType.STRING, "String"));
|
||||||
|
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||||
|
assertEquals(1, changes.size());
|
||||||
|
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
||||||
|
assertEquals("amount", changes.get(0).getFieldPath());
|
||||||
|
assertTrue(changes.get(0).getOldValue().contains("BigDecimal"));
|
||||||
|
assertTrue(changes.get(0).getNewValue().contains("String"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsBigDecimalToStringViaJsonTypeAndJavaType() {
|
||||||
|
TypeSchema oldS = new TypeSchema("A");
|
||||||
|
oldS.add(new FieldSchema("amount", JsonType.NUMBER, "BigDecimal"));
|
||||||
|
TypeSchema newS = new TypeSchema("A");
|
||||||
|
newS.add(new FieldSchema("amount", JsonType.STRING, "String"));
|
||||||
|
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||||
|
assertEquals(1, changes.size());
|
||||||
|
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsIntegerToLongWithinNumberBucket() {
|
||||||
|
TypeSchema oldS = new TypeSchema("A");
|
||||||
|
oldS.add(new FieldSchema("id", JsonType.NUMBER, "Integer"));
|
||||||
|
TypeSchema newS = new TypeSchema("A");
|
||||||
|
newS.add(new FieldSchema("id", JsonType.NUMBER, "Long"));
|
||||||
|
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||||
|
assertEquals(1, changes.size());
|
||||||
|
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noChangeWhenPrimitiveAndWrapperEquivalent() {
|
||||||
|
TypeSchema a = new TypeSchema("A");
|
||||||
|
a.add(new FieldSchema("n", JsonType.NUMBER, "int"));
|
||||||
|
TypeSchema b = new TypeSchema("A");
|
||||||
|
b.add(new FieldSchema("n", JsonType.NUMBER, "Integer"));
|
||||||
|
assertTrue(new SchemaDiffer().diff(a, b).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noChangeWhenIdentical() {
|
||||||
|
TypeSchema a = new TypeSchema("A");
|
||||||
|
a.add(new FieldSchema("x", JsonType.STRING, "String"));
|
||||||
|
TypeSchema b = new TypeSchema("A");
|
||||||
|
b.add(new FieldSchema("x", JsonType.STRING, "String"));
|
||||||
|
assertTrue(new SchemaDiffer().diff(a, b).isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/test/java/com/codechecker/cache/key/RedisKeyResolverTest.java
vendored
Normal file
155
src/test/java/com/codechecker/cache/key/RedisKeyResolverTest.java
vendored
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesLocalVariableAssignedFromStringFormat() {
|
||||||
|
String source = ""
|
||||||
|
+ "package demo;\n"
|
||||||
|
+ "public class CombinationRepo {\n"
|
||||||
|
+ " private static final String COMBINATION_QUERY_KEY = "
|
||||||
|
+ "\"data_analysis:combination_query_key:%s:%s\";\n"
|
||||||
|
+ " public void write(String tenantId, String hashKey) {\n"
|
||||||
|
+ " String key = String.format(COMBINATION_QUERY_KEY, tenantId, hashKey);\n"
|
||||||
|
+ " redisSet(key);\n"
|
||||||
|
+ " }\n"
|
||||||
|
+ " void redisSet(String k) {}\n"
|
||||||
|
+ "}\n";
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(source);
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
|
||||||
|
// redisSet(key) 的实参 key —— 局部变量使用点
|
||||||
|
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
|
||||||
|
.filter(m -> "redisSet".equals(m.getNameAsString()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(IllegalStateException::new);
|
||||||
|
Expression keyArg = redisSet.getArgument(0);
|
||||||
|
|
||||||
|
String pattern = new RedisKeyResolver(index).resolve(
|
||||||
|
keyArg, clazz, index.get("demo.CombinationRepo"));
|
||||||
|
assertEquals("data_analysis:combination_query_key:*:*", pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void localReassignmentUsesNearestBindingBeforeUse() {
|
||||||
|
String source = ""
|
||||||
|
+ "package demo;\n"
|
||||||
|
+ "public class ReassignRepo {\n"
|
||||||
|
+ " private static final String A = \"prefix:a:%s\";\n"
|
||||||
|
+ " private static final String B = \"prefix:b:%s\";\n"
|
||||||
|
+ " public void write(String id) {\n"
|
||||||
|
+ " String key = String.format(A, id);\n"
|
||||||
|
+ " key = String.format(B, id);\n"
|
||||||
|
+ " redisSet(key);\n"
|
||||||
|
+ " }\n"
|
||||||
|
+ " void redisSet(String k) {}\n"
|
||||||
|
+ "}\n";
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(source);
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
|
||||||
|
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
|
||||||
|
.filter(m -> "redisSet".equals(m.getNameAsString()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(IllegalStateException::new);
|
||||||
|
|
||||||
|
String pattern = new RedisKeyResolver(index).resolve(
|
||||||
|
redisSet.getArgument(0), clazz, index.get("demo.ReassignRepo"));
|
||||||
|
assertEquals("prefix:b:*", pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void staticConstantStillPreferredOverLocalNameCollision() {
|
||||||
|
// 局部变量名与静态常量同名时:仍优先静态常量(保持旧语义)
|
||||||
|
String source = ""
|
||||||
|
+ "package demo;\n"
|
||||||
|
+ "public class Collision {\n"
|
||||||
|
+ " private static final String KEY = \"const:prefix:%s\";\n"
|
||||||
|
+ " public void write(String id) {\n"
|
||||||
|
+ " String KEY = \"local:\" + id;\n"
|
||||||
|
+ " redisSet(KEY);\n"
|
||||||
|
+ " }\n"
|
||||||
|
+ " void redisSet(String k) {}\n"
|
||||||
|
+ "}\n";
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(source);
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
|
||||||
|
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
|
||||||
|
.filter(m -> "redisSet".equals(m.getNameAsString()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(IllegalStateException::new);
|
||||||
|
|
||||||
|
String pattern = new RedisKeyResolver(index).resolve(
|
||||||
|
redisSet.getArgument(0), clazz, index.get("demo.Collision"));
|
||||||
|
assertEquals("const:prefix:%s", pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
474
src/test/java/com/codechecker/cache/report/ReportBuilderTest.java
vendored
Normal file
474
src/test/java/com/codechecker/cache/report/ReportBuilderTest.java
vendored
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
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.ArrayList;
|
||||||
|
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 typeChangedHighlightedGrayInBothSkeletons() {
|
||||||
|
SchemaChange typeChanged = new SchemaChange(ChangeType.TYPE_CHANGED);
|
||||||
|
typeChanged.setFieldPath("amount");
|
||||||
|
typeChanged.setOldValue("BigDecimal/NUMBER");
|
||||||
|
typeChanged.setNewValue("String/STRING");
|
||||||
|
typeChanged.setMessage("字段 amount 类型由 BigDecimal/NUMBER 变为 String/STRING");
|
||||||
|
|
||||||
|
String oldJson = "{\"amount\":0,\"name\":\"\"}";
|
||||||
|
String newJson = "{\"amount\":\"\",\"name\":\"\"}";
|
||||||
|
List<SchemaChange> details = Collections.singletonList(typeChanged);
|
||||||
|
|
||||||
|
Set<String> oldPaths = SkeletonAnnotator.pathsForOldSkeleton(details);
|
||||||
|
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
|
||||||
|
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(details);
|
||||||
|
|
||||||
|
assertTrue(oldPaths.isEmpty());
|
||||||
|
assertTrue(newGreen.isEmpty());
|
||||||
|
assertEquals(Collections.singleton("amount"), typeGray);
|
||||||
|
|
||||||
|
String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldPaths, typeGray);
|
||||||
|
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, typeGray);
|
||||||
|
|
||||||
|
assertTrue(oldMd.contains("<font color=\"comment\">\"amount\":0</font>"));
|
||||||
|
assertFalse(oldMd.contains("<font color=\"warning\">\"amount\""));
|
||||||
|
assertFalse(oldMd.contains("<font color=\"comment\">\"name\":\"\"</font>"));
|
||||||
|
assertTrue(newMd.contains("<font color=\"comment\">\"amount\":\"\"</font>"));
|
||||||
|
assertFalse(newMd.contains("<font color=\"info\">\"amount\""));
|
||||||
|
assertFalse(newMd.contains("<font color=\"warning\">\"amount\""));
|
||||||
|
assertFalse(newMd.contains("<font color=\"comment\">\"name\":\"\"</font>"));
|
||||||
|
|
||||||
|
CheckReport report = baseReport();
|
||||||
|
KeyStructureChange key = new KeyStructureChange();
|
||||||
|
key.setKeyPattern("cache:amount");
|
||||||
|
key.setWriteLocation("Demo#put:1");
|
||||||
|
key.setValueType("AmountVo");
|
||||||
|
key.setOldSkeletonJson(oldJson);
|
||||||
|
key.setNewSkeletonJson(newJson);
|
||||||
|
key.getFieldDetails().add(typeChanged);
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
int oldSection = md.indexOf("> **value值由:**");
|
||||||
|
int newSection = md.indexOf("> **变更为:**");
|
||||||
|
assertTrue(oldSection >= 0);
|
||||||
|
assertTrue(newSection > oldSection);
|
||||||
|
String oldPart = md.substring(oldSection, newSection);
|
||||||
|
String newPart = md.substring(newSection);
|
||||||
|
assertTrue(oldPart.contains("<font color=\"comment\">\"amount\":0</font>"));
|
||||||
|
assertTrue(newPart.contains("<font color=\"comment\">\"amount\":\"\"</font>"));
|
||||||
|
assertFalse(newPart.contains("<font color=\"info\">\"amount\""));
|
||||||
|
assertFalse(oldPart.contains("<font color=\"warning\">\"amount\""));
|
||||||
|
assertTrue(md.contains("> **类型变更**: amount <font color=\"warning\">BigDecimal → String</font>"),
|
||||||
|
"应包含类型变更摘要行,实际 markdown:\n" + md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void typeChangeSummaryDedupesSameFieldFromMultipleWrites() {
|
||||||
|
CheckReport report = baseReport();
|
||||||
|
KeyStructureChange key = new KeyStructureChange();
|
||||||
|
key.setKeyPattern("data-analysis-task-topic");
|
||||||
|
key.setChannel("ROCKETMQ");
|
||||||
|
key.setWriteLocation("FtbDataAnalysisTaskServiceImpl#createTasks:48");
|
||||||
|
key.setValueType("DataAnalysisBO");
|
||||||
|
key.setOldSkeletonJson("{\"test1\":\"\"}");
|
||||||
|
key.setNewSkeletonJson("{\"test1\":0}");
|
||||||
|
|
||||||
|
SchemaChange a = new SchemaChange(ChangeType.TYPE_CHANGED);
|
||||||
|
a.setFieldPath("test1");
|
||||||
|
a.setOldValue("String/STRING");
|
||||||
|
a.setNewValue("Integer/NUMBER");
|
||||||
|
a.setWriteLocation("FtbDataAnalysisTaskServiceImpl#createTasks:48");
|
||||||
|
SchemaChange b = new SchemaChange(ChangeType.TYPE_CHANGED);
|
||||||
|
b.setFieldPath("test1");
|
||||||
|
b.setOldValue("String/STRING");
|
||||||
|
b.setNewValue("Integer/NUMBER");
|
||||||
|
b.setWriteLocation("FtbDataAnalysisTaskServiceImpl#createTasks:52");
|
||||||
|
key.getFieldDetails().add(a);
|
||||||
|
key.getFieldDetails().add(b);
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
assertTrue(md.contains("> **类型变更**: test1 <font color=\"warning\">String → Integer</font>"), md);
|
||||||
|
assertFalse(md.contains("String → Integer</font>;test1"),
|
||||||
|
"同字段类型变更不应重复,实际:\n" + md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrapperAddedHighlightedBeforeNestedMovedFields() {
|
||||||
|
SchemaChange wrapper = new SchemaChange(ChangeType.WRAPPER_ADDED);
|
||||||
|
wrapper.setFieldPath("vo");
|
||||||
|
wrapper.setMessage("新增包装层 vo");
|
||||||
|
|
||||||
|
SchemaChange movedDb = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||||
|
movedDb.setFieldPath("vo.dbName");
|
||||||
|
movedDb.setOldValue("dbName");
|
||||||
|
movedDb.setNewValue("vo.dbName");
|
||||||
|
|
||||||
|
SchemaChange movedLink = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||||
|
movedLink.setFieldPath("vo.linkList[].id");
|
||||||
|
movedLink.setOldValue("linkList[].id");
|
||||||
|
movedLink.setNewValue("vo.linkList[].id");
|
||||||
|
|
||||||
|
List<SchemaChange> details = new ArrayList<>();
|
||||||
|
details.add(wrapper);
|
||||||
|
details.add(movedDb);
|
||||||
|
details.add(movedLink);
|
||||||
|
|
||||||
|
String oldJson = "{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}";
|
||||||
|
String newJson = "{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}}";
|
||||||
|
|
||||||
|
Set<String> wrappers = SkeletonAnnotator.pathsForWrapperAdded(details);
|
||||||
|
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
|
||||||
|
assertEquals(Collections.singleton("vo"), wrappers);
|
||||||
|
assertTrue(newGreen.contains("vo.dbName"));
|
||||||
|
assertFalse(newGreen.contains("vo"), "包装层不应再混入普通新增路径");
|
||||||
|
|
||||||
|
// 先标包装层:整段 vo 对象应绿;子路径随后因已在 font 内可跳过
|
||||||
|
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, null, wrappers);
|
||||||
|
assertTrue(newMd.contains("<font color=\"info\">\"vo\":{"), newMd);
|
||||||
|
assertTrue(newMd.contains("</font>"), newMd);
|
||||||
|
|
||||||
|
CheckReport report = baseReport();
|
||||||
|
KeyStructureChange key = new KeyStructureChange();
|
||||||
|
key.setKeyPattern("tenant:db:content:*");
|
||||||
|
key.setWriteLocation("TenantDbContentCacheHelper#cacheNotFound:62");
|
||||||
|
key.setValueType("TenantVO");
|
||||||
|
key.setOldSkeletonJson(oldJson);
|
||||||
|
key.setNewSkeletonJson(newJson);
|
||||||
|
key.getFieldDetails().addAll(details);
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
int newSection = md.indexOf("> **变更为:**");
|
||||||
|
assertTrue(newSection >= 0);
|
||||||
|
String newPart = md.substring(newSection);
|
||||||
|
assertTrue(newPart.contains("<font color=\"info\">\"vo\":{"),
|
||||||
|
"包装层 vo 应整段绿色,实际:\n" + newPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrapperRemovedHighlightedBeforeNestedMovedFieldsOnOldSkeleton() {
|
||||||
|
SchemaChange wrapper = new SchemaChange(ChangeType.WRAPPER_REMOVED);
|
||||||
|
wrapper.setFieldPath("vo");
|
||||||
|
wrapper.setOldValue("vo");
|
||||||
|
wrapper.setMessage("删除包装层 vo");
|
||||||
|
|
||||||
|
SchemaChange movedDb = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||||
|
movedDb.setFieldPath("dbName");
|
||||||
|
movedDb.setOldValue("vo.dbName");
|
||||||
|
movedDb.setNewValue("dbName");
|
||||||
|
|
||||||
|
SchemaChange movedLink = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||||
|
movedLink.setFieldPath("linkList[].id");
|
||||||
|
movedLink.setOldValue("vo.linkList[].id");
|
||||||
|
movedLink.setNewValue("linkList[].id");
|
||||||
|
|
||||||
|
List<SchemaChange> details = new ArrayList<>();
|
||||||
|
details.add(wrapper);
|
||||||
|
details.add(movedDb);
|
||||||
|
details.add(movedLink);
|
||||||
|
|
||||||
|
String oldJson = "{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}}";
|
||||||
|
String newJson = "{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}";
|
||||||
|
|
||||||
|
Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(details);
|
||||||
|
Set<String> oldOrange = SkeletonAnnotator.pathsForOldSkeleton(details);
|
||||||
|
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
|
||||||
|
|
||||||
|
assertEquals(Collections.singleton("vo"), wrappersRemoved);
|
||||||
|
assertTrue(oldOrange.contains("vo.dbName"));
|
||||||
|
assertTrue(newGreen.contains("dbName"));
|
||||||
|
assertFalse(newGreen.contains("vo"));
|
||||||
|
|
||||||
|
String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldOrange, null, wrappersRemoved);
|
||||||
|
assertTrue(oldMd.contains("<font color=\"warning\">\"vo\":{"), oldMd);
|
||||||
|
|
||||||
|
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, null, null);
|
||||||
|
assertTrue(newMd.contains("<font color=\"info\">\"dbName\":\"\"</font>"), newMd);
|
||||||
|
assertFalse(newMd.contains("<font color=\"info\">\"vo\""), newMd);
|
||||||
|
|
||||||
|
CheckReport report = baseReport();
|
||||||
|
KeyStructureChange key = new KeyStructureChange();
|
||||||
|
key.setKeyPattern("tenant:db:content:*");
|
||||||
|
key.setWriteLocation("TenantDbContentCacheHelper#cacheNotFound:62");
|
||||||
|
key.setValueType("TenantVO");
|
||||||
|
key.setOldSkeletonJson(oldJson);
|
||||||
|
key.setNewSkeletonJson(newJson);
|
||||||
|
key.getFieldDetails().addAll(details);
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
int oldSection = md.indexOf("> **value值由:**");
|
||||||
|
int newSection = md.indexOf("> **变更为:**");
|
||||||
|
assertTrue(oldSection >= 0 && newSection > oldSection);
|
||||||
|
assertTrue(md.substring(oldSection, newSection).contains("<font color=\"warning\">\"vo\":{"),
|
||||||
|
"旧骨架应整段橙标 vo,实际:\n" + md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 条"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wildcardAsteriskInKeyEscapedForWeCom() {
|
||||||
|
assertEquals("data_analysis:combination_query_key:*:*",
|
||||||
|
ReportBuilder.escapeWeComCode("data_analysis:combination_query_key:*:*"));
|
||||||
|
|
||||||
|
CheckReport report = baseReport();
|
||||||
|
KeyStructureChange key = simpleKey(
|
||||||
|
"data_analysis:combination_query_key:*:*",
|
||||||
|
"{\"a\":0}", "{\"a\":0,\"b\":0}", "b");
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
assertTrue(md.contains("- Key --> `data_analysis:combination_query_key:*:*`"),
|
||||||
|
"通配符 * 应转成全角*,实际:\n" + md);
|
||||||
|
assertFalse(md.contains("combination_query_key:*:*"),
|
||||||
|
"不应再输出未转义的 *:*, 实际:\n" + md);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/test/java/com/codechecker/cache/schema/JavaSchemaExtractorTest.java
vendored
Normal file
101
src/test/java/com/codechecker/cache/schema/JavaSchemaExtractorTest.java
vendored
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bigDecimalMappedAsNumberNotString() {
|
||||||
|
String source = ""
|
||||||
|
+ "package demo;\n"
|
||||||
|
+ "import java.math.BigDecimal;\n"
|
||||||
|
+ "public class MoneyVo {\n"
|
||||||
|
+ " private BigDecimal amount;\n"
|
||||||
|
+ " private String currency;\n"
|
||||||
|
+ "}\n";
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(source);
|
||||||
|
TypeSchema schema = new JavaSchemaExtractor(index, 8).extract("demo.MoneyVo");
|
||||||
|
assertEquals(JsonType.NUMBER, schema.getFields().get("amount").getJsonType());
|
||||||
|
assertEquals("BigDecimal", schema.getFields().get("amount").getJavaType());
|
||||||
|
assertEquals(JsonType.STRING, schema.getFields().get("currency").getJsonType());
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/test/java/com/codechecker/cache/schema/SkeletonJsonRendererTest.java
vendored
Normal file
110
src/test/java/com/codechecker/cache/schema/SkeletonJsonRendererTest.java
vendored
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void numberPlaceholdersDistinguishIntegerFloatAndBigDecimal() {
|
||||||
|
TypeSchema schema = new TypeSchema("MoneyVo");
|
||||||
|
schema.add(new FieldSchema("count", JsonType.NUMBER, "Integer"));
|
||||||
|
schema.add(new FieldSchema("rate", JsonType.NUMBER, "Double"));
|
||||||
|
schema.add(new FieldSchema("amount", JsonType.NUMBER, "BigDecimal"));
|
||||||
|
schema.add(new FieldSchema("score", JsonType.NUMBER, "Float"));
|
||||||
|
|
||||||
|
String json = new SkeletonJsonRenderer().render(schema);
|
||||||
|
assertTrue(json.contains("\"count\":0"), json);
|
||||||
|
assertTrue(json.contains("\"rate\":0.0"), json);
|
||||||
|
assertTrue(json.contains("\"amount\":0.00"), json);
|
||||||
|
assertTrue(json.contains("\"score\":0.0"), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void integerToBigDecimalSkeletonsLookDifferent() {
|
||||||
|
TypeSchema oldS = new TypeSchema("A");
|
||||||
|
oldS.add(new FieldSchema("amount", JsonType.NUMBER, "Integer"));
|
||||||
|
TypeSchema newS = new TypeSchema("A");
|
||||||
|
newS.add(new FieldSchema("amount", JsonType.NUMBER, "BigDecimal"));
|
||||||
|
|
||||||
|
SkeletonJsonRenderer renderer = new SkeletonJsonRenderer();
|
||||||
|
assertEquals("{\"amount\":0}", renderer.render(oldS));
|
||||||
|
assertEquals("{\"amount\":0.00}", renderer.render(newS));
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/test/resources/fixtures/lock/LockService.txt
Normal file
16
src/test/resources/fixtures/lock/LockService.txt
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
public class CheckItemDetailVo {
|
||||||
|
private String itemId;
|
||||||
|
private String itemName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
public class CheckItemDetailVo {
|
||||||
|
private String itemId;
|
||||||
|
private String itemName;
|
||||||
|
private Integer score;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.kafka.core.KafkaTemplate;
|
||||||
|
|
||||||
|
public class PatrolService {
|
||||||
|
private KafkaTemplate kafkaTemplate;
|
||||||
|
private static final String TOPIC = "patrol-store-food-safe:%s";
|
||||||
|
|
||||||
|
public void sendFoodSafeData(String tenantId, List<CheckItemDetailVo> thousandsData) {
|
||||||
|
String topic = String.format(TOPIC, tenantId);
|
||||||
|
kafkaTemplate.send(topic, thousandsData);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
import org.springframework.kafka.annotation.KafkaListener;
|
||||||
|
|
||||||
|
public class PatrolNotifyListener {
|
||||||
|
@KafkaListener(topics = "patrol-notify-topic", groupId = "patrol-group")
|
||||||
|
public void handle(String message) {
|
||||||
|
PatrolNotifyVo vo = JSONObject.parseObject(message, PatrolNotifyVo.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
import org.apache.kafka.clients.producer.ProducerRecord;
|
||||||
|
import org.springframework.kafka.core.KafkaTemplate;
|
||||||
|
|
||||||
|
public class PatrolNotifyProducer {
|
||||||
|
private KafkaTemplate kafkaTemplate;
|
||||||
|
private static final String TOPIC = "patrol-notify-topic";
|
||||||
|
|
||||||
|
public void sendRecord(PatrolNotifyVo vo) {
|
||||||
|
ProducerRecord<String, PatrolNotifyVo> record = new ProducerRecord<>(TOPIC, vo);
|
||||||
|
kafkaTemplate.send(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendJson(PatrolNotifyVo vo) {
|
||||||
|
String json = JSON.toJSONString(vo);
|
||||||
|
kafkaTemplate.send(TOPIC, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
public class PatrolNotifyVo {
|
||||||
|
private String storeId;
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
public class DutyImNotice {
|
||||||
|
private String tenantId;
|
||||||
|
private String id;
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||||
|
|
||||||
|
@RocketMQMessageListener(topic = DutyImNoticeProducer.MQ_TOPIC, consumerGroup = "duty-im-group")
|
||||||
|
public class DutyImNoticeConsumer implements RocketMQListener<DutyImNotice> {
|
||||||
|
public void onMessage(DutyImNotice message) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||||
|
import org.springframework.messaging.Message;
|
||||||
|
import org.springframework.messaging.support.MessageBuilder;
|
||||||
|
|
||||||
|
public class DutyImNoticeProducer {
|
||||||
|
private RocketMQTemplate rocketMQTemplate;
|
||||||
|
public static final String MQ_TOPIC = "duty-im-notice-topic";
|
||||||
|
|
||||||
|
public void addMq(DutyImNotice message, long timestamp) {
|
||||||
|
String uniqueKey = message.getTenantId() + "_" + message.getId();
|
||||||
|
Message<DutyImNotice> mqMessage = MessageBuilder.withPayload(message)
|
||||||
|
.setHeader("KEYS", uniqueKey)
|
||||||
|
.setHeader("executeTime", timestamp)
|
||||||
|
.build();
|
||||||
|
rocketMQTemplate.asyncSend(MQ_TOPIC, mqMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void convertSend(DutyImNotice message) {
|
||||||
|
rocketMQTemplate.convertAndSend(MQ_TOPIC, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendJson(DutyImNotice message) {
|
||||||
|
String body = JSON.toJSONString(message);
|
||||||
|
rocketMQTemplate.syncSend(MQ_TOPIC, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
public final class CapitalMqConstants {
|
||||||
|
public static final String TOPIC = "capital-topic";
|
||||||
|
public static final String TAG_WALLET_DEDUCT = "WALLET_DEDUCT";
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||||
|
|
||||||
|
public class WalletDeductProducer {
|
||||||
|
private RocketMQTemplate rocketMQTemplate;
|
||||||
|
|
||||||
|
public void send(WalletDeductReq req) {
|
||||||
|
rocketMQTemplate.syncSend(CapitalMqConstants.TOPIC + ":" + CapitalMqConstants.TAG_WALLET_DEDUCT, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
public class WalletDeductReq {
|
||||||
|
private String walletId;
|
||||||
|
private Long amount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
public class WalletDeductReq {
|
||||||
|
private String walletId;
|
||||||
|
private Long amount;
|
||||||
|
private String remark;
|
||||||
|
}
|
||||||
12
src/test/resources/fixtures/template/TemplateWrite.txt
Normal file
12
src/test/resources/fixtures/template/TemplateWrite.txt
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user