Compare commits

28 Commits

Author SHA1 Message Date
0a5b04ffe6 feat: V1.1 - 责任人区分,多次合并时误触修复
All checks were successful
序列化结构检查 / serialization-schema-check (push) Successful in 2s
2026-08-05 17:29:17 +08:00
81baacffa4 feat: V1.1 - 责任人区分
All checks were successful
序列化结构检查 / serialization-schema-check (push) Successful in 2s
2026-08-03 17:26:17 +08:00
c40e4eff61 feat: V1.1 - 责任人区分
All checks were successful
序列化结构检查 / serialization-schema-check (push) Successful in 2s
2026-08-03 17:22:53 +08:00
f734455d6a feat: V1.1 - 注释补充 2026-08-03 14:48:29 +08:00
20b86592cb feat: V1.1 - lombok集成 2026-08-03 14:30:55 +08:00
99aae6dc77 feat: V1.1 - 测试类,注释补充 2026-08-03 14:25:38 +08:00
7cbbfae503 feat: V1.1 - 注释补充 2026-08-03 14:24:41 +08:00
a41176c6ce feat: V1.1 - 文件结构修改 2026-08-03 13:42:31 +08:00
875c0f3ccb feat: V1.1 - 写入点展示修复 2026-08-03 13:40:40 +08:00
69d581f034 feat: V1.1 - 问题一修复 2026-08-03 11:03:22 +08:00
182e49c4e1 feat: V1.1 - 问题一单元测试复现 2026-08-03 10:17:20 +08:00
bef70dd032 feat: 文档更新
All checks were successful
序列化结构检查 / serialization-schema-check (push) Successful in 3s
2026-07-15 18:06:31 +08:00
4361fe767f Merge remote-tracking branch origin/dev-1.0-MQ into dev-1.0-MQ
All checks were successful
序列化结构检查 / serialization-schema-check (push) Successful in 2s
保留本地 Phase M2(MQ03~05 / MQ-K03/K04 / MQ-R),解决与远程一阶段提交的冲突。
2026-07-15 17:43:59 +08:00
bb406cd354 feat: 二阶段 2026-07-15 17:38:02 +08:00
d884aa36ff feat: 一阶段 2026-07-15 16:50:04 +08:00
e88408e9b6 feat: 一阶段
All checks were successful
序列化结构检查 / serialization-schema-check (push) Successful in 1s
2026-07-15 16:48:46 +08:00
14f9f1fce7 feat: 1、Vo包装识别增强 2、去除 位置&类型 的色块
All checks were successful
序列化结构检查 / serialization-schema-check (push) Has been skipped
2026-07-15 15:45:35 +08:00
4dd4944107 feat: md渲染优化 2026-07-15 14:40:00 +08:00
a03e1b4819 feat: int/float/decimal精度优化
All checks were successful
序列化结构检查 / serialization-schema-check (push) Has been skipped
2026-07-15 14:03:24 +08:00
27dfeb8a4c feat: 修改字段类型修改不生效的问题
All checks were successful
序列化结构检查 / serialization-schema-check (push) Has been skipped
2026-07-15 13:50:14 +08:00
fd5aab5e7f feat: 坐标重命名
All checks were successful
序列化结构检查 / serialization-schema-check (push) Has been skipped
2026-07-15 11:33:24 +08:00
8ef5abc262 feat: MQ文档补充 2026-07-15 11:29:12 +08:00
89a48a98aa feat: w06 类型推断增强辅助
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 18:05:08 +08:00
c780b269d6 feat: 文档更新 2026-07-14 17:47:11 +08:00
d4590fbb86 feat: 文档更新 2026-07-14 17:44:24 +08:00
b7f4bbe03c feat: 流水线更新,修复commit次数统计不正确的问题
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 17:30:35 +08:00
96a3bd46cf feat: 流水线更新,兼容多次commit
All checks were successful
缓存序列化结构检查 / cache-schema-check (push) Has been skipped
2026-07-14 17:17:35 +08:00
1f119edb7c feat: 文档更新 2026-07-14 17:11:58 +08:00
74 changed files with 5918 additions and 1317 deletions

View File

@@ -1,10 +1,10 @@
# ============================================================
# 缓存序列化结构变更检测 — 业务仓库配置
# 序列化结构变更检测 — 业务仓库配置
# ============================================================
# 说明:
# - 本配置文件为业务覆盖配置,会与 jar 内 default-config.yaml 深度合并
# - 未声明的项沿用工具内置默认值(忽略规则、检测模式等)
# - 当前实现以 Redis 写入检测为主,后续可扩展其他缓存
# - 当前覆盖 Redis 缓存 + MQRocketMQ/Kafka生产侧投递与 MQ-R 读侧补强
# 总开关 true-执行检测 false-跳过检测(流水线直接通过,不发通知)
enabled: true
@@ -17,7 +17,7 @@ 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: "【缓存结构变更】"
title_prefix: "【序列化结构变更】"
# 观察期:先只扫描 jnpf-tenant 模块,稳定后改为 include_modules: []
include_modules:
@@ -33,3 +33,9 @@ manual_mappings:
# 误报忽略(按需添加)
suppressions: []
# MQ destination 忽略示例(按需取消注释)
# ignore:
# mq_destinations:
# - "benchmark:*"
# - "*:TEST"

View File

@@ -1,98 +0,0 @@
name: 缓存序列化结构检查
run-name: ${{ gitea.actor }}的缓存结构检查
on:
push:
workflow_dispatch:
env:
# cache-schema-checker 私库坐标com.codechecker:cache-schema-checker:1.0.0
CACHE_SCHEMA_CHECKER_VERSION: "1.0.0"
CACHE_SCHEMA_CHECKER_REPO_URL: "http://192.168.3.25:18081/nexus/repository/maven-releases"
jobs:
cache-schema-check:
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
runs-on: jdk11
steps:
# 浅克隆:仅需 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" .
git checkout -B "${{ gitea.ref_name }}" "${{ gitea.sha }}"
- name: 检查配置文件
run: |
if [ ! -f .gitea/config/cache-schema-check-config.yaml ]; then
echo "错误: 缺少 .gitea/config/cache-schema-check-config.yaml"
exit 1
fi
# 顶层总开关 enabled: false 时跳过后续步骤(与 notify.enabled 区分,仅匹配行首)
if grep -Eq '^enabled:[[:space:]]*false([[:space:]]|#|$)' .gitea/config/cache-schema-check-config.yaml; then
echo "总开关 enabled=false跳过缓存结构检查"
touch /tmp/cache-schema-check.skip
fi
- name: 从 Nexus 私库下载 cache-schema-checker
run: |
if [ -f /tmp/cache-schema-check.skip ]; then
echo "总开关已关闭,跳过下载"
exit 0
fi
GROUP_PATH="com/codechecker/cache-schema-checker"
JAR_NAME="cache-schema-checker-${CACHE_SCHEMA_CHECKER_VERSION}.jar"
JAR_URL="${CACHE_SCHEMA_CHECKER_REPO_URL}/${GROUP_PATH}/${CACHE_SCHEMA_CHECKER_VERSION}/${JAR_NAME}"
JAR_PATH="/tmp/${JAR_NAME}"
echo "下载: ${JAR_URL}"
if command -v curl >/dev/null 2>&1; then
curl -fsSL -o "${JAR_PATH}" "${JAR_URL}"
elif command -v wget >/dev/null 2>&1; then
wget -q -O "${JAR_PATH}" "${JAR_URL}"
else
echo "错误: Runner 缺少 curl 或 wget无法从 Nexus 下载 jar"
exit 1
fi
if [ ! -s "${JAR_PATH}" ]; then
echo "错误: 下载失败或文件为空: ${JAR_PATH}"
exit 1
fi
ls -lh "${JAR_PATH}"
- name: 验证 JDK
run: |
if [ -f /tmp/cache-schema-check.skip ]; then
echo "总开关已关闭,跳过"
exit 0
fi
echo "Java: $(java -version 2>&1 | head -1)"
- name: 执行缓存序列化结构检测
run: |
if [ -f /tmp/cache-schema-check.skip ]; then
echo "总开关已关闭,跳过检测"
exit 0
fi
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/cache-schema-checker-${CACHE_SCHEMA_CHECKER_VERSION}.jar" \
--config .gitea/config/cache-schema-check-config.yaml \
--repo-root . \
--old-sha "$OLD_SHA" \
--new-sha "$(git rev-parse HEAD)" \
--branch "${{ gitea.ref_name }}" \
--modifier "${{ gitea.actor }}" \
--modify-time "$COMMIT_TIME"

View File

@@ -0,0 +1,212 @@
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' }}
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' && !startsWith(gitea.ref, 'refs/heads/release/') }}
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}"
# 新分支首次 pushbefore 全 0→ 跳过
if [ -z "$OLD_SHA" ] || echo "$OLD_SHA" | grep -Eq '^0+$'; then
# 手动触发:无 before回退为 HEAD~1加深 1 层后取父提交)
if [ "${{ gitea.event_name }}" = "workflow_dispatch" ]; then
git fetch --deepen 1 || true
OLD_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "")
if [ -z "$OLD_SHA" ]; then
echo "手动触发且无父提交,跳过检测"
exit 0
fi
echo "workflow_dispatch无 push before回退使用 HEAD~1=${OLD_SHA}"
else
echo "新分支首次 pushbefore 为空/全 0跳过检测"
exit 0
fi
fi
if [ "$OLD_SHA" = "$NEW_SHA" ]; then
echo "before 与 after 相同,无需检测"
exit 0
fi
# 确保提交对象可读
ensure_commit() {
local sha="$1"
if git cat-file -e "${sha}^{commit}" 2>/dev/null; then
return 0
fi
echo "本地缺少 ${sha},尝试按 SHA 浅取…"
if git fetch --depth 1 origin "${sha}"; then
git cat-file -e "${sha}^{commit}" 2>/dev/null && return 0
fi
for d in 10 30 50 100; do
echo "deepen ${d}…"
git fetch --deepen "${d}" || true
if git cat-file -e "${sha}^{commit}" 2>/dev/null; then
return 0
fi
done
return 1
}
# 加深当前 tip 的历史,直到 before 成为 after 的祖先(否则 rev-list 在浅克隆下会少算)
ensure_range() {
local old="$1"
local new="$2"
local i=0
while [ "$i" -lt 15 ]; do
if git merge-base --is-ancestor "${old}" "${new}" 2>/dev/null; then
return 0
fi
echo "区间历史不完整deepen 20…第 $((i + 1)) 次)"
git fetch --deepen 20 || true
i=$((i + 1))
done
# force-push 等非祖先关系:对象在即可做 git show但 rev-list 不可靠
if git cat-file -e "${old}^{commit}" 2>/dev/null \
&& git cat-file -e "${new}^{commit}" 2>/dev/null; then
echo "警告: ${old} 不是 ${new} 的祖先(可能 force-pushcommit 计数将改用事件 payload"
return 0
fi
return 1
}
# 优先用 Gitea push 事件的 commits 数组长度(准确且不依赖 shallow
count_from_payload() {
if [ -z "${PUSH_COMMITS_JSON:-}" ] || [ "${PUSH_COMMITS_JSON}" = "null" ] || [ "${PUSH_COMMITS_JSON}" = "[]" ]; then
return 1
fi
# Gitea/GitHub push payload: [{"id":"<40位sha>", ...}, ...]
local n
n=$(printf '%s' "${PUSH_COMMITS_JSON}" | grep -oE '"id"[[:space:]]*:[[:space:]]*"[0-9a-fA-F]{7,40}"' | wc -l | tr -d ' ')
if [ -n "$n" ] && [ "$n" -gt 0 ] 2>/dev/null; then
echo "$n"
return 0
fi
return 1
}
if ! ensure_commit "$OLD_SHA"; then
echo "错误: 无法获取 push 前 tip ${OLD_SHA},请检查 shallow/权限"
exit 2
fi
if ! ensure_commit "$NEW_SHA"; then
echo "错误: 无法解析当前 tip ${NEW_SHA}"
exit 2
fi
if ! ensure_range "$OLD_SHA" "$NEW_SHA"; then
echo "错误: 无法补齐 ${OLD_SHA}..${NEW_SHA} 历史"
exit 2
fi
echo "对比区间: ${OLD_SHA} → ${NEW_SHA}"
COMMIT_COUNT=""
if COMMIT_COUNT=$(count_from_payload); then
echo "本次 push 累计 commit 数: ${COMMIT_COUNT}(来自事件 payload"
elif git merge-base --is-ancestor "$OLD_SHA" "$NEW_SHA" 2>/dev/null; then
COMMIT_COUNT=$(git rev-list --count "${OLD_SHA}..${NEW_SHA}" 2>/dev/null || echo "?")
echo "本次 push 累计 commit 数: ${COMMIT_COUNT}(来自 git rev-list"
else
echo "本次 push 累计 commit 数: ?(非祖先关系且无 payload"
fi
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S' "${NEW_SHA}")
# 对比区间仍为 before→after 全量;责任人触发集=first-parent 非 merge + --author纯 merge 不告警)
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 }}" \
--responsible-author "${{ gitea.actor }}" \
--modify-time "$COMMIT_TIME"

15
.idea/compiler.xml generated
View File

@@ -2,15 +2,26 @@
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="cache-schema-checker" />
<module name="serialization-schema-checker" />
</profile>
<profile name="Annotation profile for serialization-schema-checker" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<processorPath useClasspath="false">
<entry name="$PROJECT_DIR$/../../../my_java/mvn_resp/org/projectlombok/lombok/1.18.34/lombok-1.18.34.jar" />
<entry name="$PROJECT_DIR$/../../../my_java/mvn_resp/org/projectlombok/lombok/1.18.34/lombok-1.18.34.jar" />
</processorPath>
<module name="redis-schema-checker-parent" />
</profile>
</annotationProcessing>
<bytecodeTargetLevel>
<module name="cache-schema-checker" target="11" />
<module name="serialization-schema-checker" target="11" />
</bytecodeTargetLevel>
</component>
</project>

1
.idea/vcs.xml generated
View File

@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/tmp-shallow2/repo" vcs="Git" />
</component>
</project>

View File

@@ -1,23 +1,64 @@
# 缓存序列化结构检测 — CI 集成说明
# 序列化结构检测 — CI 集成说明
---
## 1. 集成概览
```text
开发者 push 代码
开发者 push 代码(可含多个 commit
Gitea Actions 触发
浅克隆业务仓库depth=2
浅克隆业务仓库 tipdepth=1+ 按需取 push 前 tipbefore
从 Nexus 下载 cache-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取到即可。责任人过滤依赖 `git log old..new`,流水线已通过 `ensure_range` deepen保证区间提交可见。
边界:
- `before``0` / 空 → 新分支首次 push跳过
- `workflow_dispatch` 无 before → 回退 `HEAD~1`
- 中间 commit 改坏又被末 commit 改回 → 累计可能无告警(以最终结构为准)
- commit 计数:优先取 `gitea.event.commits` 长度;否则 deepen 到 `before` 为祖先后再 `git rev-list`(浅克隆下直接 `rev-list` 会少算)
### 1.2 责任人过滤(合并推送不背锅)
**前提**A/B 各自 push 都会跑检测B 的结构变更已在 B 的 push 告警。合并/代推时不应再让推送人背他人的锅,也不按人拆多条通知。
| 项 | 行为 |
|----|------|
| 对比区间 | 仍为整次 push 的 `before → after`Schema 新旧用全量 diff 回填) |
| 触发集 | 仅 **责任人 first-parent 非 merge 提交** 触及的 Java 文件 ∩ 区间 diff |
| 责任人参数 | `--responsible-author`(流水线传 `gitea.actor`);未传时回退 `--modifier` |
| Author 匹配 | `git log --author` + `--regexp-ignore-case`(子串;特殊字符已转义) |
| `--first-parent` | 大合并不把二路带入的他人历史算进推送人触及集 |
| `--no-merges` | 纯 merge 推送 → 触发集为空 → **不告警** |
| 责任人无自有 Java 改动 | 空报告、打日志跳过 |
```bash
java -jar serialization-schema-checker-1.0.0.jar \
--old-sha "$OLD_SHA" \
--new-sha "$NEW_SHA" \
--modifier "${{ gitea.actor }}" \
--responsible-author "${{ gitea.actor }}" \
...
```
---
@@ -28,8 +69,7 @@ mode=block 且含 P0/P1/P2 任一变更 → exit 1流水线失败
|----|------|
| Gitea Runner | 标签 `jdk11`,已安装 Java 11 |
| Nexus 私库 | 可访问 `http://192.168.3.25:18081/nexus/repository/maven-releases` |
| 工具 JAR | `com.codechecker:cache-schema-checker:1.0.0` 已发布 |
| 仓库 Secret | `WECOM_ROBOT_WEBHOOK` 已配置 |
| 工具 JAR | `com.codechecker:serialization-schema-checker:1.0.0` 已发布 |
---
@@ -41,93 +81,38 @@ mode=block 且含 P0/P1/P2 任一变更 → exit 1流水线失败
jnpf-java-cloud/
├── .gitea/
│ ├── workflows/
│ │ └── cache-schema-check.yaml # 流水线
│ │ └── serialization-schema-check.yaml # 流水线
│ └── config/
│ └── cache-schema-check-config.yaml # 检测配置
│ └── serialization-schema-check-config.yaml # 检测配置
```
请以本仓库 `.gitea/workflows/serialization-schema-check.yaml` 为模板同步到业务仓。
---
## 4. 流水线模板
## 4. 流水线模板(要点)
```yaml
name: 缓存序列化结构检查
run-name: ${{ gitea.actor }}的缓存结构检查
完整可运行版本见:`.gitea/workflows/serialization-schema-check.yaml`
on:
push:
workflow_dispatch:
核心逻辑摘要:
env:
CACHE_SCHEMA_CHECKER_VERSION: "1.0.0"
CACHE_SCHEMA_CHECKER_REPO_URL: "http://192.168.3.25:18081/nexus/repository/maven-releases"
```bash
# 1) 浅拉 tip
git clone --depth 1 --single-branch --branch "$BRANCH" "$REPO_URL" .
git checkout -B "$BRANCH" "$NEW_SHA" # NEW_SHA = gitea.sha
jobs:
cache-schema-check:
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
runs-on: jdk11
steps:
- name: 检出代码
run: |
git config --global http.sslVerify false
git clone --depth 2 --single-branch --branch "${{ gitea.ref_name }}" \
"https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
git checkout -B "${{ gitea.ref_name }}" "${{ gitea.sha }}"
# 2) OLD_SHA = gitea.event.before全 0 则跳过;手动触发回退 HEAD~1
# 3) 本地没有 OLD_SHA 时:
git fetch --depth 1 origin "$OLD_SHA" # 优先
# 或 git fetch --deepen N # 兜底
- name: 检查配置文件
run: |
if [ ! -f .gitea/config/cache-schema-check-config.yaml ]; then
echo "错误: 缺少 .gitea/config/cache-schema-check-config.yaml"
exit 1
fi
- name: 从 Nexus 下载检测工具
run: |
GROUP_PATH="com/codechecker/cache-schema-checker"
JAR_NAME="cache-schema-checker-${CACHE_SCHEMA_CHECKER_VERSION}.jar"
JAR_URL="${CACHE_SCHEMA_CHECKER_REPO_URL}/${GROUP_PATH}/${CACHE_SCHEMA_CHECKER_VERSION}/${JAR_NAME}"
JAR_PATH="/tmp/${JAR_NAME}"
echo "下载: ${JAR_URL}"
if command -v curl >/dev/null 2>&1; then
curl -fsSL -o "${JAR_PATH}" "${JAR_URL}"
elif command -v wget >/dev/null 2>&1; then
wget -q -O "${JAR_PATH}" "${JAR_URL}"
else
echo "错误: Runner 缺少 curl 或 wget"
exit 1
fi
if [ ! -s "${JAR_PATH}" ]; then
echo "错误: 下载失败或文件为空"
exit 1
fi
ls -lh "${JAR_PATH}"
- name: 验证 JDK
run: java -version
- name: 执行 缓存结构检测
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/cache-schema-checker-${CACHE_SCHEMA_CHECKER_VERSION}.jar" \
--config .gitea/config/cache-schema-check-config.yaml \
--repo-root . \
# 4) 执行(对比区间全量;责任人过滤触发集)
java -jar serialization-schema-checker-1.0.0.jar \
--old-sha "$OLD_SHA" \
--new-sha "$(git rev-parse HEAD)" \
--branch "${{ gitea.ref_name }}" \
--new-sha "$NEW_SHA" \
--modifier "${{ gitea.actor }}" \
--modify-time "$COMMIT_TIME"
--responsible-author "${{ gitea.actor }}" \
...
```
---
@@ -135,31 +120,30 @@ jobs:
## 5. 与现有流水线的关系
| 流水线 | 作用 | 关系 |
|--------|------|------|
|--------|----------------|------|
| `demo.yaml` (AI代码质量分析) | AI Code Review | 并行,互不影响 |
| `code-check` (CodeChecker) | 通用变更检测 | **同模式**,可并列执行 |
| `cache-schema-check` | 缓存结构检测 | 新增 |
| `serialization-schema-check` | 序列化结构检测 | 新增 |
建议:三个 job 独立并行,各自 exit code 独立。
---
## 6. 工具发布流程(redisCheck 仓库)
## 6. 工具发布流程(schemaCheck 仓库)
```bash
# 在 redisCheck 仓库根目录
# 在 schemaCheck 仓库根目录
mvn clean package -DskipTests
# 发布到 Nexus需配置 settings.xml,并开启 deploy-nexus profile
mvn clean deploy -Dnexus.deploy.enabled=true -DskipTests
# 发布到 Nexus需配置 settings.xml
mvn clean deploy -DskipTests
```
发布产物:
```text
com/codechecker/cache-schema-checker/1.0.0/
├── cache-schema-checker-1.0.0.jar # 可执行 fat-jar
└── cache-schema-checker-1.0.0.pom
com/codechecker/serialization-schema-checker/1.0.0/
├── serialization-schema-checker-1.0.0.jar # 可执行 fat-jar
└── serialization-schema-checker-1.0.0.pom
```
---
@@ -168,9 +152,9 @@ com/codechecker/cache-schema-checker/1.0.0/
| 退出码 | 含义 | 流水线表现 |
|--------|------|------------|
| 0 | 通过(含 notify 模式下的告警) | 绿色 |
| 1 | 阻断block 模式 + P0/P1/P2 任一变更 | 红色 |
| 2 | 执行错误配置缺失、jar 异常等) | 红色 |
| 0 | 通过(含 notify 模式下的告警) / 跳过 | 绿色 |
| 1 | 阻断block 模式) | 红色 |
| 2 | 执行错误(配置缺失、无法取 before、jar 异常等) | 红色 |
---
@@ -178,27 +162,50 @@ com/codechecker/cache-schema-checker/1.0.0/
| 现象 | 可能原因 | 处理 |
|------|----------|------|
| 首次提交跳过 | 无 HEAD~1 | 正常行为 |
| 新分支首次 push 跳过 | `before` 全 0 | 正常行为 |
| 无法获取 before / exit 2 | 浅克隆未取到对象、服务端禁 fetch SHA | 看日志中的 deepen确认 Gitea 允许按 SHA fetch |
| 下载 jar 失败 | Nexus 地址/版本错误 | 检查 env 变量 |
| 未收到企微 | Secret 未配 / notify.enabled=false | 检查配置 |
| 大量误报 | 锁/计数器未过滤 | 补充 ignore.key_patterns |
| 漏报 | 写入模式未覆盖 | 启用 W04/W05 或补充 manual_mappings |
| 类型展开不完整 | 类型在依赖 jar 中 | 补充 manual_mappings.value_type |
| 未收到企微 | Secret 未配 / notify.enabled=false / webhook_url 空 | 检查配置 |
| 大量 Redis 误报 | 锁/计数器未过滤 | 补充 ignore.key_patterns |
| 大量 MQ 误报 | 压测/临时 topic | 补充 ignore.mq_destinations |
| 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` |
| 合并推送仍告他人改动 | jar 过旧(未含 first-parent/`--author` | 重新 deploy 含本次修复的 jar 并确认 Runner 下载到新包 |
| 责任人过滤后漏报自己的改动 | actor 与 commit author/email 对不上 | 看日志「责任人过滤…」;核对 `%an <%ae>` 是否含 actor 子串 |
---
## 9. 本地调试
模拟一次「多 commit push」的累计区间
```bash
# 在 jnpf-java-cloud 根目录
java -jar /path/to/cache-schema-checker-1.0.0.jar \
--config .gitea/config/cache-schema-check-config.yaml \
# OLD = 推送前 tipNEW = 当前 tip可用 origin/branch@{1} 或显式 sha
OLD_SHA=$(git rev-parse origin/$(git branch --show-current)~3) # 示例:假设 ahead 3
NEW_SHA=$(git rev-parse HEAD)
AUTHOR=$(git log -1 --format=%an)
java -jar /path/to/serialization-schema-checker-1.0.0.jar \
--config .gitea/config/serialization-schema-check-config.yaml \
--repo-root . \
--old-sha HEAD~1 \
--new-sha HEAD \
--old-sha "$OLD_SHA" \
--new-sha "$NEW_SHA" \
--branch $(git branch --show-current) \
--modifier "$(git log -1 --format=%an)" \
--modify-time "$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')"
--modifier "$AUTHOR" \
--responsible-author "$AUTHOR" \
--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`。未传 `--responsible-author` 时不做作者过滤(全量 diff 触发)
---
## 10. 相关文档
| 文档 | 说明 |
|------|------|
| [实施方案.md](v1.0/redis序列化结构检测实施方案.md) | 缓存检测总体方案 |
| [配置说明.md](./配置说明.md) | YAML 配置项 |
| [MQ序列化结构检测方案.md](v1.0/MQ序列化结构检测实施方案.md) | MQ 消息体 SchemaRocketMQ + Kafka方案已落地 |

View File

@@ -0,0 +1,268 @@
# V1.1 新增写入点展示 — 整改方案
> 版本v1.1
> 日期2026-08-03
> 状态:**已落地**
> 关联:`docs/配置说明.md` §5、`docs/V1.1-非结构写入收敛整改方案.md`(建点收敛已落地,本方案只改**展示层**
> 约束:**不新增配置项**;文案与染色为工具默认行为
---
## 1. 背景与实跑样本
### 1.1 企微原文(问题形态)
```text
- Topic --> duty_im_notice_topic
通道: RocketMQ
位置: DutyDelayQueueImNoticeService#addMq:43
类型: DutyImNotice
value 新增为: “{"objectId":"","content":"","type":0,"pushStatus":0,"tenantId":"","deleteMark":0,"executeTime":0}”
```
含义:对比区间内**首次出现**该 Topic 的投递点(`WRITE_POINT_ADDED`),消息体类型与骨架已正确解析,但展示层体验差。
### 1.2 问题拆解
| # | 现象 | 影响 |
|---|------|------|
| P1 | 文案「value 新增为」语义含糊 | 看不出是「新增投递 / 首次写入」,与字段级「变更为」易混淆 |
| P2 | 新骨架整段无绿色 | 字段级变更有 `<font color="info">`,新增整 Key/Topic 时全是明文,**结构颜色未渲染** |
| P3 | 缺少「变更类型」明示 | 读者需自行推断这是新增写入点还是结构变更 |
| P4 | (次要)删除写入点文案不对称 | 「value 原结构 + 已删除投递/写入」可读,但可与新增侧统一语气 |
Redis 侧新增 Key 走同一 `ReportBuilder#renderKeyBlock` 分支(`oldJson` 空、`newJson` 非空),**同病**,须一并改。
### 1.3 非目标
- 不改检测 / Schema 提取 / Diff 逻辑(建点收敛见另一文档)
- 不新增 YAML 开关
- 不改字段级变更的片段染色规则(`FIELD_ADDED` 等仍按路径染色)
- 不改企微 4096 拆条策略
---
## 2. 根因分析
### 2.1 调用链
```text
SchemaCheckAnalyzer
→ WRITE_POINT_ADDED + newSkeletonJson完整骨架
→ KeyStructureChange.fieldDetails = [WRITE_POINT_ADDED] // 无 fieldPath
ReportBuilder.renderKeyBlock
→ pathsForNewSkeleton(details) // 只认 FIELD_ADDED / FIELD_PATH_MOVED
→ newGreen = ∅
→ annotateNewForWecom(json, ∅) → 原样明文
→ 文案固定「value 新增为:**
```
### 2.2 根因表
| 编号 | 根因 | 证据 |
|------|------|------|
| R1 | 新增写入只有 `ChangeType.WRITE_POINT_ADDED`**无字段路径** | `SchemaCheckAnalyzer` 建点逻辑 |
| R2 | `SkeletonAnnotator.pathsForNewSkeleton` **不处理** `WRITE_POINT_ADDED` | 仅 `FIELD_ADDED` / `FIELD_PATH_MOVED` |
| R3 | `ReportBuilder` 对「旧空新有」写死 `value 新增为`,且 **不区分 Redis / MQ** | `renderKeyBlock` 约 146~147 行 |
| R4 | 无「变更类型」行;控制台 `formatDetailMessage` 对 MQ 新增前缀也不完整 | `ReportBuilder` |
结论:**数据已够用(有完整 newJson + 通道 + 类型),缺口全在报告渲染。**
---
## 3. 整改目标
1. 一眼可读:这是 **新增投递 / 首次写入该 Key**,不是字段 diff。
2. 新骨架在企微中有 **绿色** 渲染(整段 `info`)。
3. Redis / MQ 文案区分清楚与字段级「value值由 / 变更为」不打架。
4. 删除写入点展示与新增侧语气对称;控制台明细前缀对齐。
5. 既有字段级染色回归不变。
---
## 4. 方案设计
### 4.1 判定:何时走「整段新增/删除」展示
`renderKeyBlock` 中:
```text
仅当 fieldDetails 全部为 WRITE_POINT_ADDED或全部为 WRITE_POINT_REMOVED
且不混有 FIELD_* / WRAPPER_* / TYPE_CHANGED 时
→ 走「整段染色 + 专用文案」
否则
→ 保持现有片段染色逻辑
```
实跑新增 Topic/Key 场景几乎总是「仅一条 WRITE_POINT_ADDED」命中上述分支。
### 4.2 文案(定稿)
| 场景 | 通道 | 现行 | 整改后 |
|------|------|------|--------|
| 旧空、新有 | Redis | `value 新增为:` | `首次写入该 Keyvalue 结构为:` |
| 旧空、新有 | MQ | 同上 | `新增投递,消息体结构为:` |
| 旧有、新空 | Redis | `value 原结构:…(已删除写入)` | `原 value 结构:…(写入点已删除)` |
| 旧有、新空 | MQ | `…(已删除投递)` | `原消息体结构:…(投递点已删除)` |
| 旧有、新有 | 共用 | `value值由` / `变更为` | **保持不变** |
建议在 meta位置/类型)之后、骨架之前增加一行:
```markdown
> **变更类型**: 新增写入点
```
删除侧:
```markdown
> **变更类型**: 删除写入点
```
字段级变更块不加此行(避免噪音);仅整段新增/删除分支输出。
### 4.3 颜色渲染(定稿)
| 场景 | 染色 |
|------|------|
| 整段新增(仅 WRITE_POINT_ADDED | 新骨架整段包裹 `<font color="info">…</font>`,再外包中文引号 `“…”` |
| 整段删除(仅 WRITE_POINT_REMOVED | 旧骨架整段包裹 `<font color="warning">…</font>` |
| 字段级变更 | **不改**:仍按路径片段染色 |
实现建议(择一,推荐 A
**A. ReportBuilder 内直接整段 wrap简单**
```text
if (isWritePointAddedOnly(details) && !newJson.isEmpty()) {
newRendered = "“" + fontInfo(newJson) + "”";
}
```
**B. SkeletonAnnotator 增加 `wrapAll(json, color)`**
供 ReportBuilder 调用,便于单测与复用。
本方案采用 **A + 可选抽出 wrap 小方法到 SkeletonAnnotator**,避免改路径匹配算法。
企微颜色约定与现网一致:
| 语义 | color |
|------|-------|
| 新增 / 新结构 | `info`(绿) |
| 删除 / 旧结构 | `warning`(橙) |
### 4.4 目标企微形态(对照样本)
整改后期望接近:
```markdown
- Topic --> `duty_im_notice_topic`
> **通道**: RocketMQ
> **位置**: DutyDelayQueueImNoticeService#addMq:43
> **类型**: DutyImNotice
> **变更类型**: 新增写入点
> **新增投递,消息体结构为:** “<font color="info">{"objectId":"","content":"","type":0,"pushStatus":0,"tenantId":"","deleteMark":0,"executeTime":0}</font>”
```
Redis 新增 Key 对称示例:
```markdown
- Key --> `saas:period-config:migration:current`
> **位置**: …
> **类型**: MigrationCurrentVo
> **变更类型**: 新增写入点
> **首次写入该 Keyvalue 结构为:** “<font color="info">{…}</font>”
```
### 4.5 未解析 Key / Topic顺带小优化可选同批
`keyUnresolved`
- 提示由 `key 无法解析)` / `destination 未解析)`
调整为:`key 无法解析,建议 manual_mappings 补充)`MQ 用 destination 措辞)
- 表达式长度 > 80 时截断为前 77 字符 + `...`(常量即可,**不进配置**
本项为体验增强,可与主改动同 PR若排期紧可二期。
### 4.6 控制台明细
`formatDetailMessage``knownPrefixes` 补齐:
```text
"新增 MQ 投递点,"
"删除 MQ 投递点,原 value 类型: "
```
与 analyzer 现有 `setMessage` 对齐,避免 CI 明细不加粗。
---
## 5. 涉及文件
| 文件 | 变更 |
|------|------|
| `report/ReportBuilder.java` | 文案分支、变更类型行、整段染色、可选未解析截断、prefixes |
| `report/SkeletonAnnotator.java` | 可选:`wrapEntire(json, color)` |
| `report/ReportBuilderTest.java` | 新增 WRITE_POINT_ADDEDMQ/Redis展示断言字段级染色回归 |
| `docs/配置说明.md` §5 | 同步新增/删除写入点文案与整段绿色约定 |
**不改**`SchemaCheckAnalyzer` 建点逻辑、`SkeletonJsonRenderer`、检测器。
---
## 6. 测试计划
| 用例 | 输入要点 | 期望 |
|------|----------|------|
| T1 | 仅 `WRITE_POINT_ADDED` + MQ + 非空 newJson对齐 duty_im 样本) | 含「新增投递,消息体结构为」;含「变更类型: 新增写入点」newJson 外包 `<font color="info">` |
| T2 | 仅 `WRITE_POINT_ADDED` + Redis | 含「首次写入该 Keyvalue 结构为」;整段绿 |
| T3 | 仅 `WRITE_POINT_REMOVED` + 非空 oldJson | 「原…结构」+ 整段 `<font color="warning">` |
| T4 | `FIELD_ADDED` 字段级变更(既有) | 仍为片段绿,**不**整段包 info文案仍为 value值由/变更为 |
| T5 | `toConsole` 含「新增 MQ 投递点」 | 明细前缀加粗正确 |
| T6 | (若做)超长未解析 key 表达式 | 截断 + mapping 提示 |
可用最小化 `CheckReport` / `KeyStructureChange` 构造,不必起 git骨架 JSON 可用样本中的 DutyImNotice 字段串。
---
## 7. 风险与回滚
| 风险 | 缓解 |
|------|------|
| 整段绿色在超长 JSON 下刺眼 | 骨架已有 maxLen新增点通常可接受 |
| 企微对超长 font 标签不友好 | 保持 4096 拆条;单块过长仍按 key 拆 |
| 文案变更影响阅读习惯 | 发布说明给对照表§4.2 |
| 误把字段级变更整段染色 | 严格 `isWritePointAddedOnly` 判定 |
回滚:还原 `ReportBuilder` / `SkeletonAnnotator` 展示逻辑即可,与检测无关。
---
## 8. 与「非结构收敛」的边界
| | 非结构写入收敛(已落地) | 本方案(展示) |
|--|-------------------------|----------------|
| 解决什么 | 不该建的点String/`{}`)不再告警 | **该告的点**怎么读得懂、看得见绿 |
| 样本 | RecordingTodo 原文缓存 | DutyImNotice 新增投递(类型/骨架正确) |
| 落点 | Detector + Analyzer | ReportBuilder+ Annotator |
两者互补:收敛后留下的新增写入点,更需要本方案的文案与整段染色。
---
## 9. 实施检查表
- [x] 认可 §4.2 文案与 §4.3 整段染色(不新增配置)
- [x] 同批做未解析 Key 截断§4.5
- [x] 代码落地 + T1~T6
- [x] 同步 `docs/配置说明.md` §5
- [x] 全量 `mvn test`
---
## 10. 文案对照速查
| 场景 | V1.0 | V1.1(本方案) |
|------|------|----------------|
| MQ 新增投递 | `value 新增为:“{…}”`(无色) | `变更类型: 新增写入点` + `新增投递,消息体结构为:“<font color="info">{…}</font>”` |
| Redis 新增写入 | 同上 | `首次写入该 Keyvalue 结构为:“<font color="info">{…}</font>”` |
| 字段级变更 | `value值由` / `变更为` + 片段染色 | 保持 |

View File

@@ -0,0 +1,287 @@
# V1.1 非结构写入收敛 — 整改方案
> 版本v1.1
> 日期2026-08-03
> 状态:**已落地**
> 依据:业务仓实跑误报 + 单测复现 `RecordingTodoReplaceScenarioTest`
> 关联:`docs/redis序列化结构检测实施方案.md`、`docs/配置说明.md`
> 约束:**不新增配置项**;收敛为工具默认行为,随 jar 升级生效
---
## 1. 背景与已证实问题
### 1.1 实跑样本jnpf-java-cloud
```text
【序列化结构变更】 jnpf-java-cloud
分支: patrol/dev_patrol_2.0
提交: adbb15c6 → 6ddc72dc
- Key --> recording:todo:replace::
位置: RecordingTodoDomainServiceImpl#replaceText:437
类型: <未解析>
value 新增为: “{}”
- Key --> recording:todo:replace:title::
位置: RecordingTodoDomainServiceImpl#replaceText:452
类型: <未解析>
value 新增为: “{}”
```
业务代码(摘要):
```java
// 缓存替换前原文,纯 String无 JSON / VO 序列化
stringRedisTemplate.opsForValue().set(contentRedisKey, originalContent);
stringRedisTemplate.opsForValue().set(titleRedisKey, originalTitle);
```
### 1.2 复现结论
单测 `RecordingTodoReplaceScenarioTest` 已稳定复现:
| 环节 | 现状 |
|------|------|
| 检测 | 命中 **W04**(直写对象) |
| 类型 | `String` 不在本仓 `SourceIndex``resolvedValueType = null` → 展示 `<未解析>` |
| Schema | `extract(null)` 空结构 → 骨架 `"{}"` |
| 报告 | 旧提交无该写入点 → `WRITE_POINT_ADDED` →「value 新增为」 |
**核心缺口**:新增写入点(`WRITE_POINT_ADDED`)路径上,**没有对「无结构 / 不可展开」的 value 做收敛**;只要 AST 命中 W04/W05及部分边缘写法就会告警。
### 1.3 本方案范围
| 纳入 | 不纳入(另案) |
|------|----------------|
| Redis 非结构写入误报收敛 | MQ `asyncSend` 漏检(作用域过窄) |
| 同类场景枚举与统一收敛规则 | 新增 Key 企微文案 / 整段染色体验 |
| 检测层 + 分析层双闸门设计 | 业务仓 ignore 配置补丁(仅作临时手段) |
---
## 2. 问题本质
工具目标是监控 **可 JSON/对象 Schema 化的序列化结构变更**
当前实现把「能调用 `set/insert/put`」近似当成「值得监控」,收敛只做了很窄的一层:
```text
现有收敛
├─ 方法黑名单setIfAbsent / increment / delete …
├─ isTrivialValue字面量、UUID、toString/valueOf/randomUUID
└─ ignore.key_patternslock / loginCount / Authorization …
缺失收敛
├─ value 声明类型为 String / 数字 / 布尔 / byte[](变量,非字面量)
├─ value 类型无法在本仓展开null / 空 Schema
├─ 先 JSON 序列化到局部 String 再写入Redis 未 unwrap误成 String-W04
└─ WRITE_POINT_ADDED 不检查「是否有可对比结构」
```
因此:**凡是「新增一段无结构写入」都会变成「类型未解析 + value 新增为 {}」**,与 RecordingTodo 同形。
---
## 3. 同类场景排查(是否会中招)
判定标准能否在默认配置W01~W05 全开)下产出与实跑同形的告警
`WRITE_POINT_ADDED` + `<未解析>` 或空/`{}` 骨架,或明显无业务结构)。
### 3.1 高概率同形(与实跑同类,应优先收敛)
| ID | 场景 | 典型代码 | 命中模式 | 结果形态 | 与实跑关系 |
|----|------|----------|----------|----------|------------|
| S1 | **String 变量直写** | `stringRedisTemplate.opsForValue().set(k, originalContent)` | W04 | `<未解析>` + `{}` | **已证实** |
| S2 | **String 方法返回值直写** | `set(k, todo.getContent())` | W04 | 同 S1返回类型 String 时) | 同形;`isTrivialValue` 不认 `getXxx` |
| S3 | **数字 / 布尔变量直写** | `set(k, ttlSeconds)` / `set(k, enabled)` | W04 | `<未解析>` + `{}` | 同形;标量无字段 |
| S4 | **Hash 写入 String/标量** | `opsForHash().put(k, f, nameStr)` | W05 | 同 S1 | W05 仅挡字面量,不挡变量 |
| S5 | **redisUtil.insert 写 String 变量** | `redisUtil.insert(k, token, ttl)` | W04 | 同 S1 | `insert` + 含 redis 的 scope → 走 W04 分支 |
| S6 | **外部依赖类型直写** | `set(k, externalDto)` 且 DTO 不在本仓 | W04 | `<未解析>` + `{}` | 同形;「有对象语义」但工具无法展开,告警无信息量 |
| S7 | **类型擦除 Object / 原始 Map** | `set(k, (Object) x)` / `set(k, map)` 且无法解析元素 | W04 | 常为 `<未解析>` / 空或弱 Schema | 同形或近似 |
### 3.2 中概率变形(会误报,但表现略不同)
| ID | 场景 | 典型代码 | 命中模式 | 结果形态 | 说明 |
|----|------|----------|----------|----------|------|
| S8 | **局部 JSON 字符串再写入** | `String json = JSON.toJSONString(vo); set(k, json)` | W04 | 类型按 String → `<未解析>`+`{}` | Redis **无** `unwrapJsonStringVar`MQ 已有);应归 W01~W03却被当成无结构 |
| S9 | **显式序列化标量** | `set(k, JSON.toJSONString(someString))` | W02 | 偶发低价值点 | 少见unwrap 后仍是 String应丢弃 |
| S10 | **本仓 VO 但字段全 transient / 全忽略注解** | `set(k, emptyVo)` | W04 | 类型有名,骨架仍可能 `{}` | 有类型名,但「新增 {}」仍噪音 |
| S11 | **删除无结构写入点** | 删掉 S1 类代码 | WRITE_POINT_REMOVED | 「原结构 {}」 | 对称噪音,收敛规则应一并覆盖 |
### 3.3 低概率 / 已部分防护(记录边界)
| ID | 场景 | 现状 | 结论 |
|----|------|------|------|
| S12 | 字面量 `"1"` / `UUID.toString()` | `isTrivialValue` 已忽略 | 一般不中招 |
| S13 | key 命中 `*:lock` / `loginCount:*` 等 | `ignore.key_patterns` | 仅覆盖命名约定,**挡不住** `recording:todo:replace:*` |
| S14 | MQ 直传 String / byte[] | `isBareStringOrBytesPayload` 已忽略 | Redis 应对齐MQ 数字变量仍可能漏(见下) |
| S15 | MQ 直传业务 VO | 正常监控 | 不属本问题 |
| S16 | Redis W01~W03 + 本仓 VO | 正常监控 | 不属本问题 |
### 3.4 MQ 侧对照(同类思想,程度不同)
| 点 | Redis现状 | MQ现状 |
|----|---------------|------------|
| 裸 String / byte[] 变量 | **不忽略** → S1 | **忽略** |
| 数字 / 布尔变量 | **不忽略** → S3 | **不忽略**(仅 trivial 字面量)→ **MQ 也有 S3 风险** |
| 局部 `toJSONString` 再 send/set | Redis **不 unwrap** → S8 | **已 unwrap** |
| 无法展开类型仍建点 | 会建点 → S6 | 会建点(直传对象时) |
| WRITE_POINT_ADDED 空 Schema 闸门 | **无** | **无**(同样会「新增 {}」) |
结论:
- Redis 是重灾区;
- MQ 在 String 上已收敛,但 **Integer/Long/Boolean 变量****空 Schema 仍告警** 与 Redis 同类,应在统一规则里一并处理。
### 3.5 分析层放大效应(与模式无关)
`SchemaCheckAnalyzer` 在「新写入点、旧无配对」时:
```text
fileChanged && oldWritePoint == null
→ 无条件 WRITE_POINT_ADDED
→ extract(type) 即使为空也 render → "{}"
→ 进入企微
```
对比「已有写入点」路径:`differ.diff` 空则 `continue`**不会**为空 Schema 刷屏。
因此同类问题在 **新增代码** 时被放大;存量无结构写入若从未变更,不一定出告警。整改必须同时考虑:
1. **检测层少建点**(源头)
2. **分析层对无结构新增/删除做闸门**(兜底)
---
## 4. 收敛规则设计
### 4.1 原则
1. **只监控有结构的序列化 value**(本仓可展开的业务类型,或 unwrap 后得到的业务类型)。
2. **宁漏勿滥**:无法证明「有结构」则不告警。
3. **检测层为主、分析层为辅**:避免脏 WritePoint 进入报告链路。
4. **Redis / MQ 规则对齐**,避免一边收敛一边漏。
### 4.2 规则表(建议落码)
| 规则 | 适用 | 行为 |
|------|------|------|
| R-A 标量类型忽略 | Redis W04/W05MQ 直传 payload | value/payload 声明类型 ∈ `{String, CharSequence, 原始/包装数字, Boolean, UUID, byte/Byte/byte[]}`**不建点** |
| R-B 本仓可展开 | Redis W04/W05MQ 直传对象 | `resolvedFqn == null``SourceIndex` 无此类 → **不建点** |
| R-C 空 Schema 闸门 | AnalyzerADDED / REMOVED | `extract``schema.isEmpty()`**不产生** WRITE_POINT_ADDED/REMOVED字段级 diff 路径保持:空 diff 已 skip |
| R-D 局部 JSON unwrap | Redis对齐 MQ | `String json = toJSONString(x); set/insert(k, json)` → 按 W01~W03 建点,类型为 `x`;若 `x` 仍为标量 → 再走 R-A |
| R-E 方法返回值 | 与 R-A/R-B 相同 | `set(k, obj.getFoo())` 按方法返回类型判定,不因「非字面量」直接放行 |
| R-F 集合 | W04/W05 / MQ | `List<T>` / `Set<T>`:元素 `T` 满足 R-B 才保留;`List<String>` 丢弃 |
### 4.3 明确保留(不应误杀)
| 写法 | 期望 |
|------|------|
| `set(k, JSON.toJSONString(vo))` / `insert` + 序列化 | W01~W03监控 VO |
| `redisTemplate.set(k, demoVo)` 且 DemoVo 在本仓 | W04监控 |
| `opsForHash().put(k, f, demoVo)` 本仓 VO | W05监控 |
| MQ `syncSend/asyncSend(topic, dto)` 本仓 DTO | 监控 |
| 局部 JSON unwrap 后得到本仓 VO | 监控 |
### 4.4 配置策略
**不新增任何 YAML / 检测开关。** R-A~R-F 均为工具内置默认行为,升级 jar 即生效。
业务侧若需临时止血,仍可沿用既有 `ignore.key_patterns` / `ignore.writer_methods` / `suppressions`,但不作为本问题根治手段。
---
## 5. 落地设计
### 5.1 推荐分层
```text
┌─────────────────────────────────────────┐
│ RedisWritePointDetector / MqWritePointDetector
│ R-A 标量忽略
│ R-D Redis 局部 JSON unwrap
│ R-B 本仓类型门槛(直写对象)
│ R-E/R-F 返回值与集合元素
└──────────────────┬──────────────────────┘
┌─────────────────────────────────────────┐
│ SchemaCheckAnalyzer
│ R-C 空 Schema → 跳过 ADDED/REMOVED
│ (字段级 diff 逻辑不变)
└─────────────────────────────────────────┘
```
### 5.2 涉及文件(实施时)
| 文件 | 变更要点 |
|------|----------|
| `RedisWritePointDetector.java` | R-A / R-B / R-D / R-E / R-F |
| `MqWritePointDetector.java` | R-A 扩展数字/布尔;直传对象可加 R-B与 Redis 对齐) |
| `SchemaCheckAnalyzer.java` | R-C 空 Schema 闸门 |
| (可选)抽取 `BareValueTypes` 工具类 | Redis/MQ 共用标量集合,防漂移 |
| `RecordingTodoReplaceScenarioTest` | 修复后期望 **0 写入点**(或拆「修复前/后」) |
| 新增场景单测 | 覆盖 §3.1 / §3.2 表中 S2~S8、S11 |
| `docs/配置说明.md` | 同步 W04/W05 与空 Schema 行为 |
### 5.3 实施顺序建议
本版本一次性落地 R-A~R-FRedis + Analyzer + MQ 对齐),无分阶段开关、无灰度配置。
---
## 6. 测试与验收
### 6.1 复现用例(已有)
- `RecordingTodoReplaceScenarioTest`:当前断言「会误报」。
- 整改后应改为:检测结果为空,或 Analyzer 不产出 keyChanges。
### 6.2 建议补充用例
| 用例 | 期望(整改后) |
|------|----------------|
| `set(k, getContent())` 返回 String | 0 点 |
| `set(k, longVar)` | 0 点 |
| `opsForHash().put(k, f, strVar)` | 0 点 |
| `redisUtil.insert(k, tokenStr, ttl)` | 0 点 |
| `set(k, externalDto)` 不在 SourceIndex | 0 点 |
| `String json = toJSONString(vo); set(k, json)` | 1 点,类型 = VOW02/W01 |
| `set(k, demoVo)` 本仓 VO | 仍 1 点 W04 |
| 删除 String 直写 | 不产生 WRITE_POINT_REMOVED |
| MQ `send(topic, longVar)` | 0 点Phase 4 |
### 6.3 回归
- `TenantScenarioTest`、既有 W01~W05 / MQ fixture 全绿。
- 业务仓抽样RecordingTodo 类告警消失;真实 VO 包装层变更仍能检出。
---
## 7. 临时手段与风险
| 项 | 说明 |
|----|------|
| 无新配置 | 不增加 `keep_empty_schema_*` 等开关;行为固化在检测/分析逻辑 |
| 业务 ignore | 可对 `recording:todo:replace:*` 加 key 忽略,仅止血,不解决 S1~S7 类问题 |
| 误杀风险 | 依赖 jar 内 DTO 的 W04 将被忽略 → 可用既有 `manual_mappings` 或把类型源码纳入仓 |
| Object 擦除 | 可能漏检真实结构 → 接受宁漏勿滥;或要求业务写明 VO 类型 |
| 与「展示优化」关系 | 即使将来给新增 Key 染色,空 `{}` 仍无意义;**必须先收敛再建点** |
---
## 8. 总结
| 问题 | 结论 |
|------|------|
| RecordingTodo 为何告警? | W04 把 String 原文当成对象写入;新增路径无空结构闸门 |
| 是否只有这一处? | **否**。S2~S7 同形S8 变形S11 对称MQ 在数字变量与空 Schema 上同类 |
| 根因一句话 | **新增写入检测缺少「有结构才告警」的收敛** |
| 整改抓手 | 检测层标量/本仓类型收敛 + Analyzer 空 Schema 闸门 + Redis JSON 局部 unwrap |
---
## 9. 检查表
- [x] 认可 R-A~R-F 与「宁漏勿滥」、**不新增配置**
- [x] 外部 jar DTOS6默认不监控
- [x] 代码落地 + `RecordingTodoReplaceScenarioTest` 期望 0 点
- [x] 同步 `配置说明.md`
- [x] 全量 `mvn test` 通过

View File

@@ -0,0 +1,339 @@
# 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)` | 较低 | unwrapMQ-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」等价物
| 中间件 | 聚合键形态 | 来源 |
|--------|------------|------|
| RocketMQ | `topic:tag` | 字面量、常量、`TOPIC + ":" + TAG` |
| Kafka | `topic` | 字面量、常量、`topicBuilder.xxx(tenantId)` → 前缀+`*` |
无法解析时:展示表达式 + `<font color="comment">destination 未解析)</font>`
### 2.4 读侧补强
| 中间件 | 补强来源 |
|--------|----------|
| 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 文件
├─ RedisW01~W05 + W06 ← 已有
└─ MQRocketMQMQ01~05+ KafkaMQ-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 — MVPRocketMQ + 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 |
---
## 9. 决策对齐
| 项 | 结论 |
|----|------|
| 中间件范围 | **RocketMQ + Kafka**|
| 对比区间 | `gitea.event.before``gitea.sha` |
| 交付 | 同一 jarpatterns 区分 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`

View File

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

198
docs/工程结构.md Normal file
View File

@@ -0,0 +1,198 @@
# 序列化结构检测器 — 工程结构说明
> 工程:`serialization-schema-checker``com.codechecker:serialization-schema-checker`
> 定位:基于 JavaParser 的 **Redis 缓存 / MQRocketMQ、Kafkavalue 序列化结构变更** 静态检测工具
> 当前版本:`1.1.0` · Java 11 · 可执行 Fat Jarmaven-shade
---
## 1. 仓库顶层结构
```text
redisCheck/
├── pom.xml # Maven 单模块工程
├── .gitea/ # Gitea Actions 与示例/自检配置
│ ├── workflows/
│ │ └── serialization-schema-check.yaml
│ └── config/
│ └── serialization-schema-check-config.yaml
├── docs/ # 方案、配置与集成文档(本目录)
├── src/
│ ├── main/
│ │ ├── java/com/codechecker/cache/ # 主代码(按职责分包)
│ │ └── resources/
│ │ └── default-config.yaml # jar 内置默认配置
│ └── test/
│ ├── java/com/codechecker/cache/ # 单测 / 场景测
│ └── resources/fixtures/ # 源码片段夹具
└── target/ # 构建产物(不入库)
```
---
## 2. 主代码包结构
根包:`com.codechecker.cache`
```text
com.codechecker.cache
├── cli/ # 命令行入口
├── config/ # 配置模型与加载
├── git/ # Git diff / show 封装
├── analyze/ # 扫描编排与主分析流程
├── detector/ # 写入点 / 读侧提示检测
├── key/ # Redis key 表达式解析
├── schema/ # 类型索引、字段 Schema、骨架 JSON
├── diff/ # Schema Diff 与变更类型
├── report/ # 报告模型与渲染
└── notify/ # 企微机器人通知
```
### 2.1 包职责与主要类
| 包 | 职责 | 主要类 |
|----|------|--------|
| **cli** | picocli 入口;加载配置 → 分析 → 控制台报告 → 可选通知 | `SerializationSchemaCheckerMain` |
| **config** | 默认配置与业务配置深度合并;运行时配置模型 | `ConfigLoader``CheckerConfig` |
| **git** | 变更 Java 文件列表、按 SHA 取文件内容 | `GitDiffScanner``GitException` |
| **analyze** | 端到端编排:候选文件 → 写点检测 → Schema 提取 → Diff → 报告 | `SchemaCheckAnalyzer``FileScanner``GlobMatcher` |
| **detector** | 识别 Redis/MQ 写入(投递)点与读侧类型提示 | `RedisWritePointDetector``MqWritePointDetector``CacheReadHintDetector``MqReadHintDetector``BareValueTypes``WritePoint``CacheReadHint` |
| **key** | 将 key 表达式解析为模式(如 `demo:foo:*` | `RedisKeyResolver` |
| **schema** | 源码类型索引、字段展开、骨架 JSON 渲染 | `SourceIndex``JavaSchemaExtractor``TypeSchema``FieldSchema``SkeletonJsonRenderer``AnnotationSupport``JsonType` |
| **diff** | 新旧 Schema 对比,产出变更明细 | `SchemaDiffer``SchemaChange``ChangeType``Severity` |
| **report** | 按 Key/Topic 聚合、企微 Markdown / 控制台文本 | `CheckReport``KeyStructureChange``ReportBuilder``SkeletonAnnotator` |
| **notify** | 企微机器人 Webhook 发送 | `WeComNotifier` |
### 2.2 核心数据模型(实体)
| 类 | 含义 |
|----|------|
| `WritePoint` | 一处 Redis 写入或 MQ 投递点通道、key/topic、value 类型、位置) |
| `CacheReadHint` | 读侧反序列化推断出的类型提示(补强写入点,不单独告警) |
| `TypeSchema` / `FieldSchema` | value 类型展开后的扁平 Schema |
| `SchemaChange` | 单条结构变更(类型、路径、严重级别、说明) |
| `KeyStructureChange` | 按 Key/Topic 聚合的摘要(含前后骨架 JSON |
| `CheckReport` | 一次检测的完整结果 |
| `CheckerConfig` | 运行配置(含 Notify / Ignore / Detection 等嵌套结构) |
---
## 3. 运行时数据流
```text
SerializationSchemaCheckerMain
ConfigLoader.load(--config)
│ jar 内 default-config.yaml ⊕ 业务配置
SchemaCheckAnalyzer.analyze(oldSha, newSha)
├─ GitDiffScanner → 变更 .java 文件
├─ FileScanner → 读新旧源码内容
├─ SourceIndex → 建类型索引
├─ Redis/Mq WritePointDetector → 写入/投递点
├─ Cache/Mq ReadHintDetector → 读侧类型补强
├─ JavaSchemaExtractor → 展开 TypeSchema
├─ SchemaDiffer → SchemaChange 列表
└─ 聚合 KeyStructureChange → CheckReport
ReportBuilder (控制台 + 企微 Markdown
WeComNotifier (可选,--dry-run 时跳过)
```
**退出码约定**
| 码 | 含义 |
|----|------|
| `0` | 通过、跳过(`enabled=false` / 无基准 SHA或 notify 模式有变更 |
| `1` | `mode=block` 且检测到结构变更 |
| `2` | 执行异常 |
---
## 4. 资源与配置
| 路径 | 说明 |
|------|------|
| `src/main/resources/default-config.yaml` | 工具内置默认:检测模式、忽略规则、严重级别等 |
| 业务仓 `.gitea/config/serialization-schema-check-config.yaml` | 业务覆盖配置(流水线 `--config` 指向) |
| 本仓 `.gitea/config/serialization-schema-check-config.yaml` | 工具仓自用/示例配置 |
配置合并与字段说明见:[配置说明.md](./配置说明.md)
---
## 5. 测试结构
```text
src/test/java/com/codechecker/cache/
├── TestSupport.java # 夹具加载等公共方法
├── *ScenarioTest.java # 端到端场景tenant / mq / recording-todo
├── analyze/ / config/ / detector/
├── diff/ / key/ / report/ / schema/ # 与主包对应的单测
└── ...
src/test/resources/fixtures/
├── lock/ # 分布式锁等非结构写入样例
├── template/ # 模板写入样例
├── tenant/ # 包装层 / 字段迁移场景
├── recording-todo/ # Redis 写入场景
└── mq/
├── kafka-patrol/ # Kafka 巡检相关
├── kafka-record/ # Kafka Record 投递/消费
├── rocket-im/ # RocketMQ IM 通知
└── rocket-wallet/ # RocketMQ 钱包扣款
```
夹具多为 `.txt` 形式的 Java 源码片段,由场景测试拼装为新旧版本对比输入。
---
## 6. 构建与产物
| 项 | 说明 |
|----|------|
| 构建 | `mvn clean package` |
| 主类 | `com.codechecker.cache.cli.SerializationSchemaCheckerMain` |
| 产物 | `target/serialization-schema-checker-1.1.0.jar`shade 可执行包) |
| 主要依赖 | JavaParser、SnakeYAML、Jackson、picocli、Lombokprovided、JUnit 5test |
---
## 7. 文档索引
| 文档 | 内容 |
|------|------|
| [工程结构.md](./工程结构.md) | 本文:目录与模块职责 |
| [配置说明.md](./配置说明.md) | 配置项、合并规则、忽略/抑制 |
| [CI集成说明.md](./CI集成说明.md) | 流水线接入方式 |
| [v1.0/](./v1.0/) | Redis / MQ 检测实施方案(历史方案) |
| [V1.1/](./V1.1/) | 非结构写入收敛、新增写入点展示等整改方案 |
---
## 8. 模块依赖关系(简图)
```mermaid
flowchart TB
CLI[cli] --> CFG[config]
CLI --> AN[analyze]
CLI --> RPT[report]
CLI --> NT[notify]
AN --> GIT[git]
AN --> DET[detector]
AN --> SCH[schema]
AN --> DIFF[diff]
AN --> RPT
DET --> KEY[key]
DET --> SCH
DIFF --> SCH
RPT --> DIFF
NT --> RPT
```
上层调用下层;`detector` / `schema` / `diff` 不依赖 `cli``notify`,便于单测与复用。

View File

@@ -1,6 +1,8 @@
# 缓存序列化结构检测 — 配置说明
# 序列化结构检测 — 配置说明
> **双层配置**:工具 jar 内置 `default-config.yaml`(默认) + 业务仓库 `.gitea/config/cache-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
jar 内 default-config.yaml工具仓维护
↓ 深度合并
业务仓 cache-schema-check-config.yaml业务仓维护
业务仓 serialization-schema-check-config.yaml业务仓维护
最终生效配置
```
@@ -21,24 +23,33 @@ jar 内 default-config.yaml工具仓维护
### 1.1 业务仓最小配置示例
```yaml
# jnpf-java-cloud/.gitea/config/cache-schema-check-config.yaml
# jnpf-java-cloud/.gitea/config/serialization-schema-check-config.yaml
enabled: true
mode: notify
notify:
enabled: true
webhook_env: WECOM_ROBOT_WEBHOOK
# 推荐直接写完整 Webhook也可用环境变量在流水线注入
webhook_url: ""
include_modules:
- jnpf-tenant
```
流水线通常把 Secret 注入环境或配置文件中的 `webhook_url`。兼容旧字段:`webhook_env`(值为 `http` 开头时当作 URL 使用)。
### 1.2 工具内置默认配置jar 内 default-config.yaml
`redisCheck` 仓库维护,随 jar 发布,包含:
`redisCheck` 仓库维护,随 jar 发布,默认包含:
- `detection.patterns`W01~W03
- `detection.patterns`**W01~W05**JSON 字符串写入 + Template 直写 + Hash
- `detection.mq_patterns`**MQ01~MQ05、MQ-K01~MQ-K04**RocketMQ / Kafka 生产侧)
- `detection.read_hints_enabled`W06 读侧反序列化类型辅助(默认 true
- `detection.mq_read_hints_enabled`MQ-R 读侧 Listener / parse 补强(默认 true
- `ignore.key_patterns`(锁 / 计数器 / token
- `detection.min_confidence``max_field_depth`
- `ignore.mq_destinations`MQ destination 忽略,默认真空)
- `detection.min_confidence``max_field_depth`
- `mode: notify``enabled: true`
---
@@ -63,12 +74,14 @@ source_roots:
# 通知配置
notify:
enabled: true
# 从环境变量读取 Webhook URL
webhook_env: WECOM_ROBOT_WEBHOOK
# Webhook 完整 URL优先
webhook_url: ""
# 兼容旧字段:值为 http 开头时视为 URL
# webhook_env: WECOM_ROBOT_WEBHOOK
# 无变更时是否也发通知(一般 false
notify_on_clean: false
# 消息标题前缀
title_prefix: "[缓存结构变更]"
title_prefix: "[序列化结构变更]"
# 忽略规则
ignore:
@@ -76,9 +89,9 @@ ignore:
key_patterns:
- "*:lock"
- "*:lock:*"
- "*lock*"
- "loginCount:*"
- "Authorization:*"
- "Authorization:login:session:*"
# 忽略的文件路径模式
file_patterns:
@@ -87,9 +100,12 @@ ignore:
# 忽略的写入方法(类全名#方法名)
writer_methods: []
# 忽略的 MQ destinationtopic 或 topic:tagglob
mq_destinations: []
# 检测规则
detection:
# 启用的写入模式
# 启用的写入模式(默认已全部开启)
patterns:
- W01 # redisUtil.insert + JSON.toJSONString
- W02 # redisTemplate.opsForValue().set + JSON.toJSONString
@@ -97,17 +113,30 @@ detection:
- W04 # redisTemplate 直写对象
- 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
# 字段展开最大深度(防止循环引用死循环)
max_field_depth: 8
# 严重级别覆盖(可选)
severity_overrides:
FIELD_ADDED: P1
WRITE_POINT_ADDED: P2
# 人工补充映射(自动推断失败或需精确指定时使用)
manual_mappings:
- id: tenant-db-content
@@ -171,9 +200,10 @@ mode: block
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `enabled` | boolean | true | 是否发企微 |
| `webhook_env` | string | WECOM_ROBOT_WEBHOOK | 环境变量名 |
| `webhook_url` | string | `""` | 企微机器人 Webhook 完整 URL优先 |
| `webhook_env` | string | — | 兼容旧字段;值为 `http` 开头时当作 URL |
| `notify_on_clean` | boolean | false | 无变更时是否通知 |
| `title_prefix` | string | [缓存结构变更] | 消息标题前缀 |
| `title_prefix` | string | [序列化结构变更] | 消息标题前缀 |
### 3.4 ignore.key_patterns
@@ -182,25 +212,67 @@ mode: block
- `*` 匹配单层
- `**` 匹配多层
常见内置忽略(代码层也有硬编码兜底)
内置/代码层常见过滤
- 分布式锁 key
- 登录计数
- session/token
- 分布式锁 key(配置 glob + 方法名忽略)
- 登录计数、session/token
- 琐碎 value字面量、`"1"``UUID.randomUUID()`
- 方法:`setIfAbsent` / `increment` / `delete` / `expire`
### 3.5 detection.patterns
| 模式 | 说明 | 阶段 |
| 模式 | 说明 | 状态 |
|------|------|------|
| W01 | `redisUtil.insert(key, JSON.toJSONString(x), ttl)` | Phase 1 |
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)` | Phase 1 |
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)` | Phase 1 |
| W04 | `redisTemplate.opsForValue().set(key, obj, ...)` | Phase 2已启用 |
| W05 | `redisTemplate.opsForHash().put(key, field, obj)` | Phase 2已启用 |
| W01 | `redisUtil.insert(key, JSON.toJSONString(x), ttl)` | 已启用 |
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)` | 已启用 |
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)` | 已启用 |
| W04 | `redisTemplate.opsForValue().set(key, obj, ...)` 直写对象 | 已启用;**V1.1** 忽略 `String`/数字/布尔等标量,且 value 须为本仓库可解析类型 |
| W05 | `redisTemplate.opsForHash().put(key, field, obj)` | 已启用;过滤规则同 W04 |
| W01~W03 补充 | 局部 `String json = toJSONString(x)``set/insert` | **V1.1** unwrap 到 `x` |
| 空 Schema 闸门 | 新增/删除写入点且 Schema 为空 | **V1.1** 分析层直接丢弃不发「value 新增为 {}」 |
以上收敛为工具默认行为,**无额外配置开关**。升级 jar 即生效。
业务仓可通过只声明子集暂时关闭某些模式,例如仅保留 JSON 写入:
```yaml
detection:
patterns: [W01, W02, W03]
```
### 3.5.1 detection.read_hints_enabledW06 辅助)
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `read_hints_enabled` | boolean | true | 扫描 `parseObject` / `parseArray` / `getJsonToBean` 等,补强同文件或同 key 写入点的 value 类型 |
- **不是写入模式**:不会单独因为「多了一处 parse」而告警
- 能关联到 `redis get(key)` 时,还可补强 unresolved key
- 覆盖优先级:`manual_mappings` > W06 > 写侧 AST
- **仅补强 Redis 写入点**MQ 投递点走 `mq_read_hints_enabled`MQ-R
关闭示例:
```yaml
detection:
read_hints_enabled: false
```
### 3.5.2 detection.mq_patterns / ignore.mq_destinationsMQ
| 配置项 | 说明 |
|--------|------|
| `mq_patterns` | RocketMQ`MQ01`~`MQ05`Kafka`MQ-K01`~`MQ-K04`;默认已全部开启 |
| `mq_read_hints_enabled` | MQ-R 读侧补强Listener / parse**默认 true** |
| `ignore.mq_destinations` | 忽略 destinationglob`benchmark:*` |
企微 MQ 块:`- Topic --> ...`,并带 `> **通道**: RocketMQ|Kafka`
详见:[MQ序列化结构检测方案.md](v1.0/MQ序列化结构检测实施方案.md)
### 3.6 manual_mappings
当自动推断不准确时使用。匹配优先级 **高于** 自动推断。
当自动推断不准确时使用。匹配优先级 **高于** 自动推断(按 `类全名#方法名` 覆盖 key 模式与 value 类型)
| 字段 | 必填 | 说明 |
|------|------|------|
@@ -217,76 +289,116 @@ mode: block
```yaml
suppressions:
- id: my-suppression
writer_method: "com.example.FooService#cacheBar"
key_pattern: "file:download:user:progress:*"
change_types:
- FIELD_ADDED
reason: "新增字段向后兼容"
```
### 3.8 include_modules / exclude_modules
| 字段 | 说明 |
|------|------|
| `include_modules` | 非空时仅扫描列出的顶层模块;空表示全仓 |
| `exclude_modules` | 始终排除的顶层模块 |
顶层模块取路径第一段,例如 `jnpf-tenant/jnpf-tenant-biz/src/main/java/...``jnpf-tenant`
---
## 4. 环境变量
## 4. 环境变量 / Secret
| 变量 | 必填 | 说明 |
|------|------|------|
| `WECOM_ROBOT_WEBHOOK` | notify.enabled=true 时必填 | 企微机器人 Webhook 完整 URL |
| `WECOM_ROBOT_WEBHOOK` | 视流水线写法 | 可将 Secret 写入配置中的 `webhook_url`,或在启动前注入 |
在 Gitea 仓库 Settings → Secrets 中配置。
---
## 5. 企微消息格式示例
## 5. 企微消息格式
### 5.1 结构说明
- 抬头:仓库、分支、提交、提交人、时间(不展示 mode
- 正文:按 **一个 Redis Key 一块**,展示位置、类型与前后序列化骨架
- 超长UTF-8 > 4096 字节)时按 key **拆成多条**消息依次发送
- CI 控制台另打「字段明细」(变更类型 / 位置 / 摘要),企微侧按骨架展示
### 5.2 字段高亮颜色
| 变更 | 企微颜色 | Markdown |
|------|----------|----------|
| 字段删除(标在旧骨架) | 橙色 | `<font color="warning">…</font>` |
| 字段新增 / 包装层(标在新骨架) | 绿色 | `<font color="info">…</font>` |
| 路径迁移 | 旧橙 / 新绿 | 同上 |
| **新增写入点**(整段新骨架) | 绿色 | 整段 `<font color="info">…</font>` |
| **删除写入点**(整段旧骨架) | 橙色 | 整段 `<font color="warning">…</font>` |
| key 未解析提示 | 灰色 | `<font color="comment">key 无法解析,建议 manual_mappings 补充)</font>` |
### 5.3 示例(字段级变更)
```markdown
## [缓存结构变更] jnpf-java-cloud
## [序列化结构变更] jnpf-java-cloud
> 分支: feature/tenant-cache
> 提交: a1b2c3d → e4f5g6h
> 提交人: zhangsan
> 时间: 2026-07-13 14:00:00
> 模式: notify
> **分支**: code/redis_change_detection_v1.0
> **提交**: cedd161c → 67c8a6eb
> **提交人**: dongzi
> **时间**: 2026-07-13 16:54:17
### P0 - 顶层结构包装变更
- **Key**: `tenant:db:content:*`
- **位置**: `TenantDbContentCacheHelper#cacheSuccess:92`
- **变更**:
- `dbName``vo.dbName`(字段路径迁移)
- `linkList``vo.linkList`(字段路径迁移)
- 新增顶层字段 `expiresAtMs`
- **影响**: 旧缓存反序列化可能失败,需评估缓存刷新策略
- Key --> `tenant:db:content:*`
> **位置**: `TenantDbContentCacheHelper#cacheSuccess:92`
> **类型**: `CacheEnvelope`
> **value值由** “{"dbName":"","linkList":[{"id":""}]}”
> **变更为:** “…(仅新增/迁移字段片段带 <font color="info">绿色</font>)…”
```
字段级变更仍对改动属性片段染色:新增 → `info`(绿),删除 → `warning`(橙)。
### 5.4 示例(新增写入点 / 新增投递)
```markdown
- Topic --> `duty_im_notice_topic`
> **通道**: RocketMQ
> **位置**: DutyDelayQueueImNoticeService#addMq:43
> **类型**: DutyImNotice
> **变更类型**: 新增写入点
> **新增投递,消息体结构为:** “<font color="info">{"objectId":"","content":"",…}</font>”
- Key --> `saas:period-config:migration:current`
> **位置**: SaasPeriodConfigMigrationRedisSupport#putCurrent:41
> **类型**: MigrationCurrentVo
> **变更类型**: 新增写入点
> **首次写入该 Keyvalue 结构为:** “<font color="info">{…}</font>”
```
### 5.5 示例key 未解析)
```markdown
- Key --> `req.getKey()` <font color="comment">key 无法解析,建议 manual_mappings 补充)</font>
> **位置**: `ClockInXxxService#export:128`
> **类型**: `List<ClockInExportVo>`
> **value值由** “{"a":""}”
> **变更为:** “…新增字段带绿色高亮…”
```
未解析时按「写入位置 + key 表达式」拆分聚合;表达式过长(>80会截断。
---
## 6. 推荐上线配置
### 6.1 观察期(第 1 周,已确认策略)
业务仓默认配置:
```yaml
enabled: true
mode: notify
notify:
enabled: true
webhook_env: WECOM_ROBOT_WEBHOOK
webhook_url: "" # 由流水线写入真实 Webhook
include_modules:
- jnpf-tenant
include_modules: [] # 全仓
```
观察满 1 周、确认误报可接受后,手动切换:
```yaml
mode: block
include_modules: [] # 扩至全仓
```
### 6.2 全量启用(观察期结束后)
```yaml
mode: block
include_modules: [] # 空表示全部模块
detection:
patterns: [W01, W02, W03, W04, W05]
```
---

34
pom.xml
View File

@@ -5,12 +5,12 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.codechecker</groupId>
<artifactId>cache-schema-checker</artifactId>
<artifactId>serialization-schema-checker</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>cache-schema-checker</name>
<description>基于 JavaParser 的缓存 value 序列化结构变更检测器</description>
<name>serialization-schema-checker</name>
<description>基于 JavaParser 的缓存/MQ value 序列化结构变更检测器</description>
<properties>
<maven.compiler.source>11</maven.compiler.source>
@@ -22,6 +22,7 @@
<jackson.version>2.15.4</jackson.version>
<picocli.version>4.7.5</picocli.version>
<junit.version>5.10.2</junit.version>
<lombok.version>1.18.34</lombok.version>
<maven.shade.version>3.5.1</maven.shade.version>
<maven.deploy.plugin.version>3.1.2</maven.deploy.plugin.version>
</properties>
@@ -71,11 +72,34 @@
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>cache-schema-checker-${project.version}</finalName>
<finalName>serialization-schema-checker-${project.version}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
@@ -101,7 +125,7 @@
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.codechecker.cache.cli.CacheSchemaCheckerMain</mainClass>
<mainClass>com.codechecker.cache.cli.SerializationSchemaCheckerMain</mainClass>
</transformer>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>

View File

@@ -12,6 +12,7 @@ import java.util.stream.Stream;
/**
* 扫描工作树中受包含/排除模块限制的 {@code src/main/java} 下的所有 Java 源文件。
* <p>返回路径 → 源码内容映射,供 {@link SchemaCheckAnalyzer} 构建新旧 {@code SourceIndex}。</p>
*/
public class FileScanner {

View File

@@ -10,6 +10,11 @@ public final class GlobMatcher {
private GlobMatcher() {
}
/**
* @param glob glob 模式(支持 {@code *} / {@code **}
* @param input 待匹配字符串
* @return 是否整串匹配
*/
public static boolean matches(String glob, String input) {
if (glob == null || input == null) {
return false;

View File

@@ -1,6 +1,10 @@
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;
@@ -43,27 +47,70 @@ public class SchemaCheckAnalyzer {
this.repoRoot = repoRoot;
}
/**
* 对比两个提交之间的序列化结构变更。
*
* @param oldSha 基准提交
* @param newSha 当前提交(通常与工作树一致)
* @return 检测报告(含字段明细与按 Key/Topic 聚合结果)
* @throws GitException git 命令失败时
*/
public CheckReport analyze(String oldSha, String newSha) throws GitException {
return analyze(oldSha, newSha, null);
}
/**
* 对比两个提交之间的序列化结构变更。
*
* @param oldSha 基准提交
* @param newSha 当前提交
* @param responsibleAuthor 责任人过滤(如 Gitea actor非空时仅将「该作者 first-parent 非 merge
* 提交触及的文件」作为检测触发集(大合并不把二路他人历史算到推送人)
* @return 检测报告
* @throws GitException git 命令失败时
*/
public CheckReport analyze(String oldSha, String newSha, String responsibleAuthor) throws GitException {
GitDiffScanner scanner = new GitDiffScanner(repoRoot);
List<String> changedRaw = scanner.changedJavaFiles(oldSha, newSha);
Set<String> changedFiles = new LinkedHashSet<>();
Set<String> allDiffFiles = 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);
allDiffFiles.add(path);
}
}
Set<String> changedFiles = allDiffFiles;
String authorFilter = responsibleAuthor == null ? null : responsibleAuthor.trim();
if (authorFilter != null && !authorFilter.isEmpty()) {
Set<String> authorFiles = scanner.javaFilesTouchedByAuthor(oldSha, newSha, authorFilter);
Set<String> scoped = new LinkedHashSet<>();
for (String path : allDiffFiles) {
if (authorFiles.contains(path)) {
scoped.add(path);
}
}
changedFiles = scoped;
System.out.println("[serialization-schema-checker] 责任人过滤(first-parent): " + authorFilter
+ ",区间 diff 文件 " + allDiffFiles.size()
+ " → 责任人触及 " + changedFiles.size());
if (changedFiles.isEmpty()) {
System.out.println("[serialization-schema-checker] 责任人无 first-parent 非 merge 自有 Java 改动,"
+ "跳过结构告警(相关变更应已在原作者 push 时通知)。");
return buildReport(oldSha, newSha, Collections.emptyList(), Collections.emptyMap());
}
}
// 当前工作树(= newSha 检出)内容
Map<String, String> newContents = fileScanner.scan();
// 旧版本内容:以工作树为基线,用 old 版本覆盖变更文件
// 旧版本内容:必须以「区间全部 diff 文件」回填,避免他人改动的类型在索引中新旧一致导致漏 diff
Map<String, String> oldContents = new LinkedHashMap<>(newContents);
for (String path : changedFiles) {
for (String path : allDiffFiles) {
String oldContent = scanner.fileContentAt(oldSha, path);
if (oldContent == null) {
oldContents.remove(path); // 新增文件在旧版本不存在
oldContents.remove(path);
} else {
oldContents.put(path, oldContent);
}
@@ -90,13 +137,21 @@ public class SchemaCheckAnalyzer {
}
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<>();
@@ -108,9 +163,21 @@ public class SchemaCheckAnalyzer {
boolean fileChanged = changedFiles.contains(path);
String oldContent = fileChanged ? oldContents.get(path) : newContent;
List<WritePoint> newWps = detectorNew.detect(path, newContent);
List<WritePoint> newWps = new ArrayList<>(detectorNew.detect(path, newContent));
List<WritePoint> oldWps = oldContent == null
? new ArrayList<>() : detectorOld.detect(path, oldContent);
? 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);
@@ -122,7 +189,7 @@ public class SchemaCheckAnalyzer {
for (WritePoint nw : newWps) {
newSigs.add(nw.signature());
if (isKeyIgnored(nw.getResolvedKeyPattern()) || isWriterIgnored(nw)) {
if (isWritePointIgnored(nw) || isWriterIgnored(nw)) {
continue;
}
WritePoint ow = oldBySig.get(nw.signature());
@@ -143,11 +210,17 @@ public class SchemaCheckAnalyzer {
skeletonRenderer.render(newSchema, protectedPaths(changes),
SkeletonJsonRenderer.DEFAULT_MAX_LEN));
} else if (fileChanged) {
// R-C空 Schema 的新增写入点无结构可告警,收敛丢弃
TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray());
if (newSchema == null || newSchema.isEmpty()) {
continue;
}
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
fillFromWritePoint(c, nw);
c.setMessage("新增缓存写入点value 类型: " + displayType(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),
"",
@@ -160,12 +233,17 @@ public class SchemaCheckAnalyzer {
if (fileChanged) {
for (WritePoint ow : oldWps) {
if (!newSigs.contains(ow.signature())
&& !isKeyIgnored(ow.getResolvedKeyPattern()) && !isWriterIgnored(ow)) {
&& !isWritePointIgnored(ow) && !isWriterIgnored(ow)) {
TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray());
if (oldSchema == null || oldSchema.isEmpty()) {
continue;
}
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_REMOVED);
fillFromWritePoint(c, ow);
c.setMessage("删除缓存写入点,原 value 类型: " + displayType(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(
@@ -192,8 +270,12 @@ public class SchemaCheckAnalyzer {
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());
}
@@ -223,11 +305,12 @@ public class SchemaCheckAnalyzer {
/** 已解析 key 按模式聚合;未解析按「位置+表达式」拆分,避免串单。 */
private String aggregationKey(WritePoint wp) {
String channel = wp.getChannel() == null ? WritePoint.CHANNEL_REDIS : wp.getChannel();
String pattern = wp.getResolvedKeyPattern();
if (!isUnresolvedKey(pattern)) {
return pattern == null ? "<unknown>" : pattern;
return channel + "|" + (pattern == null ? "<unknown>" : pattern);
}
return "unknown|" + nvl(wp.location()) + "|" + nvl(wp.getKeyExpression());
return channel + "|unknown|" + nvl(wp.location()) + "|" + nvl(wp.getKeyExpression());
}
private boolean isUnresolvedKey(String keyPattern) {
@@ -268,10 +351,12 @@ public class SchemaCheckAnalyzer {
paths.add(c.getOldValue());
}
if (c.getChangeType() == ChangeType.WRAPPER_ADDED
&& c.getFieldPath() != null) {
|| c.getChangeType() == ChangeType.WRAPPER_REMOVED) {
if (c.getFieldPath() != null) {
paths.add(c.getFieldPath());
}
}
}
return paths;
}
@@ -325,8 +410,14 @@ public class SchemaCheckAnalyzer {
}
private String changeDedupKey(SchemaChange c) {
return c.getChangeType() + "|" + c.getKeyPattern() + "|"
+ c.getWriteLocation() + "|" + c.getFieldPath();
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) {
@@ -350,9 +441,7 @@ public class SchemaCheckAnalyzer {
if (isSuppressed(c)) {
continue;
}
String dedupKey = c.getChangeType() + "|" + c.getKeyPattern() + "|"
+ c.getWriteLocation() + "|" + c.getFieldPath();
if (seen.add(dedupKey)) {
if (seen.add(changeDedupKey(c))) {
result.add(c);
}
}
@@ -425,6 +514,121 @@ public class SchemaCheckAnalyzer {
}
}
/**
* 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;
@@ -461,6 +665,13 @@ public class SchemaCheckAnalyzer {
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;
@@ -473,6 +684,18 @@ public class SchemaCheckAnalyzer {
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);

View File

@@ -15,13 +15,15 @@ import java.util.List;
import java.util.concurrent.Callable;
/**
* 命令行入口退出码0 通过 / 1 阻断 / 2 执行错误
* 序列化结构检测工具的命令行入口picocli
* <p>加载配置 对比 {@code old-sha}/{@code new-sha} 控制台输出报告 可选企微通知</p>
* <p>退出码{@code 0} 通过或跳过{@code 1} block 模式检测到变更{@code 2} 执行错误</p>
*/
@Command(name = "cache-schema-checker",
@Command(name = "serialization-schema-checker",
mixinStandardHelpOptions = true,
version = "cache-schema-checker 1.0.0",
description = "检测两次提交间缓存 value 序列化结构变更并通过企微机器人通知。")
public class CacheSchemaCheckerMain implements Callable<Integer> {
version = "serialization-schema-checker 1.0.0",
description = "检测两次提交间缓存/MQ 等 value 序列化结构变更并通过企微机器人通知。")
public class SerializationSchemaCheckerMain implements Callable<Integer> {
@Option(names = "--config", required = true, description = "业务仓库检测配置文件路径")
private Path configPath;
@@ -38,7 +40,7 @@ public class CacheSchemaCheckerMain implements Callable<Integer> {
@Option(names = "--branch", description = "分支名(用于报告展示)")
private String branch;
@Option(names = "--modifier", description = "提交人")
@Option(names = "--modifier", description = "责任人/推送人(报告展示;建议与 --responsible-author 一致)")
private String modifier;
@Option(names = "--modify-time", description = "提交时间")
@@ -50,32 +52,38 @@ public class CacheSchemaCheckerMain implements Callable<Integer> {
@Option(names = "--dry-run", description = "只输出报告,不发送企微通知")
private boolean dryRun;
@Option(names = "--responsible-author",
description = "责任人过滤:仅检测该作者 first-parent 非 merge 提交触及的 Java 文件(通常传 Gitea actor")
private String responsibleAuthor;
@Override
public Integer call() {
try {
CheckerConfig config = ConfigLoader.load(configPath);
if (!config.isEnabled()) {
System.out.println("[cache-schema-checker] 总开关 enabled=false跳过检测。");
System.out.println("[serialization-schema-checker] 总开关 enabled=false跳过检测。");
return 0;
}
if (oldSha == null || oldSha.trim().isEmpty()) {
System.out.println("[cache-schema-checker] 无对比基准提交,跳过检测。");
System.out.println("[serialization-schema-checker] 无对比基准提交,跳过检测。");
return 0;
}
Path root = repoRoot.toAbsolutePath().normalize();
SchemaCheckAnalyzer analyzer = new SchemaCheckAnalyzer(config, root);
CheckReport report = analyzer.analyze(oldSha, newSha);
String author = responsibleAuthor != null && !responsibleAuthor.trim().isEmpty()
? responsibleAuthor.trim()
: (modifier != null ? modifier.trim() : null);
CheckReport report = analyzer.analyze(oldSha, newSha, author);
report.setBranch(branch);
report.setModifier(modifier);
report.setModifier(modifier != null ? modifier : author);
report.setModifyTime(modifyTime);
report.setRepository(repository != null ? repository : root.getFileName().toString());
ReportBuilder builder = new ReportBuilder(config.getNotify().getTitlePrefix());
// CI字段明细 + 完整企微 Markdown
System.out.println(builder.toConsole(report));
boolean shouldNotify = config.getNotify().isEnabled()
@@ -84,24 +92,24 @@ public class CacheSchemaCheckerMain implements Callable<Integer> {
String webhook = config.getNotify().getWebhookUrl();
List<String> messages = builder.toWeComMessages(report);
int ok = new WeComNotifier().sendMarkdownMessages(webhook, messages);
System.out.println("[cache-schema-checker] 企微通知发送: "
System.out.println("[serialization-schema-checker] 企微通知发送: "
+ ok + "/" + messages.size()
+ (messages.size() > 1 ? "(已按 key 拆分)" : ""));
}
if (report.isBlocked()) {
System.out.println("[cache-schema-checker] block 模式命中流水线将被阻断exit 1");
System.out.println("[serialization-schema-checker] block 模式命中流水线将被阻断exit 1");
}
return report.getExitCode();
} catch (Exception e) {
System.err.println("[cache-schema-checker] 执行错误: " + e.getMessage());
System.err.println("[serialization-schema-checker] 执行错误: " + e.getMessage());
e.printStackTrace();
return 2;
}
}
public static void main(String[] args) {
int exitCode = new CommandLine(new CacheSchemaCheckerMain()).execute(args);
int exitCode = new CommandLine(new SerializationSchemaCheckerMain()).execute(args);
System.exit(exitCode);
}
}

View File

@@ -1,5 +1,7 @@
package com.codechecker.cache.config;
import lombok.Data;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -8,327 +10,123 @@ import java.util.Map;
/**
* 检测器运行配置。由 {@link ConfigLoader} 加载 jar 内默认配置并与业务配置深度合并后构建。
*/
@Data
public class CheckerConfig {
/** 总开关false 时跳过检测与通知(流水线 exit 0 */
private boolean enabled = true;
/** notify | block */
/** 运行模式:notify(仅通知)| block(有变更则失败退出) */
private String mode = "notify";
/** 是否扫描 test 源码目录 */
private boolean scanTestSources = false;
/** 额外源码根路径(相对仓库根);空则按约定扫描 main */
private List<String> sourceRoots = new ArrayList<>();
/** 企微通知配置 */
private Notify notify = new Notify();
/** 忽略规则key / 文件 / 写入方法 / MQ destination */
private Ignore ignore = new Ignore();
/** 检测模式与推断参数 */
private Detection detection = new Detection();
/** 按 ChangeType 名覆盖默认严重级别,如 FIELD_ADDED → P0 */
private Map<String, String> severityOverrides = new LinkedHashMap<>();
/** 人工写入点映射(自动推断不准时补齐) */
private List<ManualMapping> manualMappings = new ArrayList<>();
/** 已知误报抑制规则 */
private List<Suppression> suppressions = new ArrayList<>();
/** 仅检测这些模块(路径前缀/模块名);空表示不限制 */
private List<String> includeModules = new ArrayList<>();
/** 排除这些模块,不参与检测 */
private List<String> excludeModules = new ArrayList<>();
public static class Notify {
private boolean enabled = true;
/** 企微机器人 Webhook 完整 URL */
private String webhookUrl = "";
private boolean notifyOnClean = false;
private String titlePrefix = "[缓存结构变更]";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getWebhookUrl() {
return webhookUrl;
}
public void setWebhookUrl(String webhookUrl) {
this.webhookUrl = webhookUrl;
}
public boolean isNotifyOnClean() {
return notifyOnClean;
}
public void setNotifyOnClean(boolean notifyOnClean) {
this.notifyOnClean = notifyOnClean;
}
public String getTitlePrefix() {
return titlePrefix;
}
public void setTitlePrefix(String titlePrefix) {
this.titlePrefix = titlePrefix;
}
}
public static class Ignore {
private List<String> keyPatterns = new ArrayList<>();
private List<String> filePatterns = new ArrayList<>();
private List<String> writerMethods = new ArrayList<>();
public List<String> getKeyPatterns() {
return keyPatterns;
}
public void setKeyPatterns(List<String> keyPatterns) {
this.keyPatterns = keyPatterns;
}
public List<String> getFilePatterns() {
return filePatterns;
}
public void setFilePatterns(List<String> filePatterns) {
this.filePatterns = filePatterns;
}
public List<String> getWriterMethods() {
return writerMethods;
}
public void setWriterMethods(List<String> writerMethods) {
this.writerMethods = writerMethods;
}
}
public static class Detection {
private List<String> patterns = new ArrayList<>();
private double minConfidence = 0.6;
private int maxFieldDepth = 8;
public List<String> getPatterns() {
return patterns;
}
public void setPatterns(List<String> patterns) {
this.patterns = patterns;
}
public double getMinConfidence() {
return minConfidence;
}
public void setMinConfidence(double minConfidence) {
this.minConfidence = minConfidence;
}
public int getMaxFieldDepth() {
return maxFieldDepth;
}
public void setMaxFieldDepth(int maxFieldDepth) {
this.maxFieldDepth = maxFieldDepth;
}
}
public static class ManualMapping {
private String id;
private String writerMethod;
private String keyPattern;
private String valueType;
private String description;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getWriterMethod() {
return writerMethod;
}
public void setWriterMethod(String writerMethod) {
this.writerMethod = writerMethod;
}
public String getKeyPattern() {
return keyPattern;
}
public void setKeyPattern(String keyPattern) {
this.keyPattern = keyPattern;
}
public String getValueType() {
return valueType;
}
public void setValueType(String valueType) {
this.valueType = valueType;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
public static class Suppression {
private String id;
private String writerMethod;
private String keyPattern;
private List<String> changeTypes = new ArrayList<>();
private String reason;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getWriterMethod() {
return writerMethod;
}
public void setWriterMethod(String writerMethod) {
this.writerMethod = writerMethod;
}
public String getKeyPattern() {
return keyPattern;
}
public void setKeyPattern(String keyPattern) {
this.keyPattern = keyPattern;
}
public List<String> getChangeTypes() {
return changeTypes;
}
public void setChangeTypes(List<String> changeTypes) {
this.changeTypes = changeTypes;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getMode() {
return mode;
}
public void setMode(String mode) {
this.mode = mode;
}
public boolean isScanTestSources() {
return scanTestSources;
}
public void setScanTestSources(boolean scanTestSources) {
this.scanTestSources = scanTestSources;
}
public List<String> getSourceRoots() {
return sourceRoots;
}
public void setSourceRoots(List<String> sourceRoots) {
this.sourceRoots = sourceRoots;
}
public Notify getNotify() {
return notify;
}
public void setNotify(Notify notify) {
this.notify = notify;
}
public Ignore getIgnore() {
return ignore;
}
public void setIgnore(Ignore ignore) {
this.ignore = ignore;
}
public Detection getDetection() {
return detection;
}
public void setDetection(Detection detection) {
this.detection = detection;
}
public Map<String, String> getSeverityOverrides() {
return severityOverrides;
}
public void setSeverityOverrides(Map<String, String> severityOverrides) {
this.severityOverrides = severityOverrides;
}
public List<ManualMapping> getManualMappings() {
return manualMappings;
}
public void setManualMappings(List<ManualMapping> manualMappings) {
this.manualMappings = manualMappings;
}
public List<Suppression> getSuppressions() {
return suppressions;
}
public void setSuppressions(List<Suppression> suppressions) {
this.suppressions = suppressions;
}
public List<String> getIncludeModules() {
return includeModules;
}
public void setIncludeModules(List<String> includeModules) {
this.includeModules = includeModules;
}
public List<String> getExcludeModules() {
return excludeModules;
}
public void setExcludeModules(List<String> excludeModules) {
this.excludeModules = excludeModules;
}
public boolean isBlockMode() {
return "block".equalsIgnoreCase(mode);
}
/** 企微通知相关配置。 */
@Data
public static class Notify {
/** 是否发送企微通知 */
private boolean enabled = true;
/** 企微机器人 Webhook 完整 URL */
private String webhookUrl = "";
/** 无变更时是否仍发「清洁」通知 */
private boolean notifyOnClean = false;
/** 报告标题前缀 */
private String titlePrefix = "[序列化结构变更]";
}
/** 忽略规则key / 文件路径 / 写入方法 / MQ destination。 */
@Data
public static class Ignore {
/** 忽略的 Redis key / 模式 */
private List<String> keyPatterns = new ArrayList<>();
/** 忽略的源文件 glob */
private List<String> filePatterns = new ArrayList<>();
/** 忽略的写入方法Class#method 或简单名) */
private List<String> writerMethods = new ArrayList<>();
/** 忽略的 MQ destination 模式topic / topic:tag */
private List<String> mqDestinations = new ArrayList<>();
}
/** 检测模式与推断参数Redis W*、MQ MQ*、读侧补强开关等)。 */
@Data
public static class Detection {
/** Redis 写入检测模式列表,如 W01~W06 */
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 int maxFieldDepth = 8;
/** W06是否启用读侧反序列化类型辅助补强 */
private boolean readHintsEnabled = true;
/** MQ-R读侧 Listener / parse 补强 */
private boolean mqReadHintsEnabled = true;
}
/**
* 人工补充映射:在自动推断不准时指定写入方法对应的 key 模式与 value 类型。
*/
@Data
public static class ManualMapping {
/** 规则 id便于配置追溯 */
private String id;
/** 目标写入方法标识 */
private String writerMethod;
/** 指定的 key / destination 模式 */
private String keyPattern;
/** 指定的 value 类型 */
private String valueType;
/** 备注说明 */
private String description;
}
/**
* 抑制规则:对已知误报按 key 模式和/或变更类型跳过告警。
*/
@Data
public static class Suppression {
/** 规则 id */
private String id;
/** 可选:限定写入方法 */
private String writerMethod;
/** 可选:匹配的 key / destination 模式 */
private String keyPattern;
/** 要抑制的 ChangeType 名列表;空表示不按类型过滤 */
private List<String> changeTypes = new ArrayList<>();
/** 抑制原因(文档用) */
private String reason;
}
}

View File

@@ -22,6 +22,12 @@ public final class ConfigLoader {
private ConfigLoader() {
}
/**
* 加载并合并配置。
*
* @param businessConfigPath 业务仓 YAML为 {@code null} 或不存在时仅使用 jar 内默认配置
* @return 绑定后的运行配置
*/
public static CheckerConfig load(Path businessConfigPath) {
Map<String, Object> merged = loadDefault();
if (businessConfigPath != null && Files.exists(businessConfigPath)) {
@@ -87,19 +93,23 @@ public final class ConfigLoader {
n.setEnabled(bool(notify, "enabled", true));
n.setWebhookUrl(resolveWebhookUrl(notify));
n.setNotifyOnClean(bool(notify, "notify_on_clean", false));
n.setTitlePrefix(str(notify, "title_prefix", "[缓存结构变更]"));
n.setTitlePrefix(str(notify, "title_prefix", "[序列化结构变更]"));
Map<String, Object> ignore = asMap(map.get("ignore"));
CheckerConfig.Ignore ig = config.getIgnore();
ig.setKeyPatterns(strList(ignore.get("key_patterns")));
ig.setFilePatterns(strList(ignore.get("file_patterns")));
ig.setWriterMethods(strList(ignore.get("writer_methods")));
ig.setMqDestinations(strList(ignore.get("mq_destinations")));
Map<String, Object> detection = asMap(map.get("detection"));
CheckerConfig.Detection d = config.getDetection();
d.setPatterns(strList(detection.get("patterns")));
d.setMqPatterns(strList(detection.get("mq_patterns")));
d.setMinConfidence(dbl(detection, "min_confidence", 0.6));
d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8));
d.setReadHintsEnabled(bool(detection, "read_hints_enabled", true));
d.setMqReadHintsEnabled(bool(detection, "mq_read_hints_enabled", true));
Map<String, Object> severityOverrides = asMap(map.get("severity_overrides"));
Map<String, String> so = new LinkedHashMap<>();

View File

@@ -0,0 +1,71 @@
package com.codechecker.cache.detector;
import com.github.javaparser.ast.type.ArrayType;
import com.github.javaparser.ast.type.Type;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* 无业务 JSON 结构的标量 / 裸字节类型判定Redis / MQ 检测共用)。
* <p>命中则不应建立写入点,避免对 String/数字等无结构缓存误报。</p>
*/
final class BareValueTypes {
private static final Set<String> BARE = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"String", "CharSequence", "char", "Character", "byte", "Byte", "int", "long", "short", "double", "float",
"Integer", "Long", "Short", "Double", "Float", "Number", "BigInteger", "BigDecimal", "AtomicInteger", "AtomicLong",
"boolean", "Boolean", "UUID")));
private BareValueTypes() {
}
/**
* 判断一个 Java 类型是否属于「无业务结构」的标量/裸类型集合。
*
* @param simple simple
* @return boolean
*/
static boolean isBareSimpleName(String simple) {
return simple != null && BARE.contains(simple);
}
/**
* 从 AST Type 取出简单名后调用
* @param type type
* @return boolean
*/
static boolean isBareType(Type type) {
if (type == null) {
return false;
}
if (type.isPrimitiveType()) {
return true;
}
if (type instanceof ArrayType) {
Type component = ((ArrayType) type).getComponentType();
String simple = component.isClassOrInterfaceType() ? component.asClassOrInterfaceType().getNameAsString() : component.asString();
return "byte".equals(simple) || "Byte".equals(simple);
}
if (type.isClassOrInterfaceType()) {
return isBareSimpleName(type.asClassOrInterfaceType().getNameAsString());
}
String asString = type.asString();
return "byte[]".equals(asString) || isBareSimpleName(asString);
}
/**
* 从 FQN 截取最后一段再调用
* @param fqn fqn
* @return boolean
*/
static boolean isBareFqn(String fqn) {
if (fqn == null || fqn.isEmpty()) {
return false;
}
int dot = fqn.lastIndexOf('.');
return isBareSimpleName(dot >= 0 ? fqn.substring(dot + 1) : fqn);
}
}

View File

@@ -0,0 +1,30 @@
package com.codechecker.cache.detector;
import lombok.Data;
/**
* 读侧反序列化类型提示W06从 parseObject / getJsonToBean 等推断「缓存 value 被当成什么类型用」。
* 不单独产生告警,仅用于补强同文件/同 key 写入点的 value 类型。
*/
@Data
public class CacheReadHint {
/** 源文件相对路径 */
private String filePath;
/** 反序列化调用所在行号 */
private int lineNumber;
/** 所在类 FQN */
private String enclosingClass;
/** 所在方法名 */
private String enclosingMethod;
/** 推断出的 key 模式;无法关联 redis get 时为 null */
private String resolvedKeyPattern;
/** 源码中的 key 表达式原文 */
private String keyExpression;
/** value 元素/对象 FQN */
private String resolvedValueType;
/** 是否按数组根parseArray / getJsonToList 等)理解 value */
private boolean rootArray;
/** 提示置信度:仅有类型约 0.7,关联到 redis get 约 0.85 */
private double confidence = 0.7;
}

View File

@@ -0,0 +1,205 @@
package com.codechecker.cache.detector;
import com.codechecker.cache.key.RedisKeyResolver;
import com.codechecker.cache.schema.SourceIndex;
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.CallableDeclaration;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.expr.ClassExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.MethodCallExpr;
import com.github.javaparser.ast.expr.NameExpr;
import com.github.javaparser.ast.type.ClassOrInterfaceType;
import com.github.javaparser.ast.type.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
/**
* W06扫描读侧反序列化提取「缓存字符串 → 业务类型」提示,供写入点类型补强。
* <p>典型模式:</p>
* <pre>
* String raw = redisUtil.getString(key);
* Foo vo = JSON.parseObject(raw, Foo.class);
* List&lt;Foo&gt; list = JSON.parseArray(raw, Foo.class);
* </pre>
*/
public class CacheReadHintDetector {
private static final Set<String> OBJECT_PARSE = new HashSet<>(Arrays.asList(
"parseObject", "parse", "getJsonToBean", "toJavaObject", "readValue"));
private static final Set<String> ARRAY_PARSE = new HashSet<>(Arrays.asList(
"parseArray", "getJsonToList", "parseArrayObject"));
private final SourceIndex index;
private final RedisKeyResolver keyResolver;
public CacheReadHintDetector(SourceIndex index) {
this.index = index;
this.keyResolver = new RedisKeyResolver(index);
}
/**
* 扫描读侧反序列化调用,提取可用于补强写入点的类型提示。
*
* @param filePath 仓库内相对路径
* @param content 源码全文
* @return 提示列表(不单独告警)
*/
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;
}
/**
* 尝试从单次方法调用提取读侧类型提示;非目标 API 或无法解析类型时返回 null。
* <p>
* 识别 {@code parseObject/parseArray/...} 且第 2 参为 {@code Xxx.class} 的调用,
* 解析出业务类型 FQN若第 1 参能追溯到 Redis get则附带 key 并将置信度提至 0.85
* 否则仅保留类型提示(置信度 0.7)。结果供写入点类型补强,不单独告警。
*/
private CacheReadHint tryParseHint(MethodCallExpr mce, String filePath) {
String name = mce.getNameAsString();
boolean array = ARRAY_PARSE.contains(name);
boolean object = OBJECT_PARSE.contains(name);
if (!array && !object) {
return null;
}
if (mce.getArguments().size() < 2) {
return null;
}
Expression classArg = mce.getArgument(1);
// readValue(str, TypeReference) 等暂不支持;要求 ClassLiteral Xxx.class
if (!(classArg instanceof ClassExpr)) {
// 部分 APIparseObject(str, Xxx.class, Feature...) 仍是第 2 参
return null;
}
Type type = ((ClassExpr) classArg).getType();
if (!(type instanceof ClassOrInterfaceType)) {
return null;
}
ClassOrInterfaceDeclaration enclosing = mce
.findAncestor(ClassOrInterfaceDeclaration.class).orElse(null);
String enclosingFqn = enclosing == null
? "<unknown>"
: enclosing.getFullyQualifiedName().orElse(enclosing.getNameAsString());
SourceIndex.IndexedType context = index.get(enclosingFqn);
// 优先带包前缀解析(如 com.foo.Bar.class失败再退回简单类名 + import/同包
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);
// 第 1 参:原始 JSON 字符串;能关联到 redis.get(key) 时补 key便于与写入点按 key 匹配
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();
}
/**
* 判断调用是否为 Redis 读 key是则取出第 1 参作为 key 表达式。
* <p>
* 覆盖常见形态:{@code redisUtil.get/getString(key)}、
* {@code redisTemplate.opsForValue().get(key)}、{@code boundValueOps.get(key)}。
* {@code getString} 即使接收者名不含 redis 也接受RedisUtil 习惯命名);
* 普通 {@code get} 依赖 scope 命中 redis / opsForValue / boundValueOps 启发式。
* 无法识别时返回 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)) {
// 非 Redis scope仅放行 getStringRedisUtil 习惯);裸 get如 map.get丢弃
if (!redisScope && !"getString".equals(name)) {
return Optional.empty();
}
if (call.getArguments().isEmpty()) {
return Optional.empty();
}
return Optional.of(new RedisGetRef(call.getArgument(0)));
}
return Optional.empty();
}
private static final class RedisGetRef {
final Expression keyExpr;
RedisGetRef(Expression keyExpr) {
this.keyExpr = keyExpr;
}
}
}

View File

@@ -0,0 +1,372 @@
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-R03Listener 内 {@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);
}
/**
* 扫描 MQ 消费侧Listener / parse提取 destination → 消息体类型提示。
*
* @param filePath 仓库内相对路径
* @param content 源码全文
* @return 提示列表(用于补强生产侧投递点,不单独告警)
*/
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;
}
}
}

View File

@@ -0,0 +1,589 @@
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>M1MQ01/MQ02、MQ-K01/MQ-K02</li>
* <li>M2MQ03 convertAndSend、MQ04 MessageBuilder/`Message&lt;T&gt;`、MQ05 JSON 字符串、
* MQ-K03 ProducerRecord、MQ-K04 JSON 字符串</li>
* </ul>
* V1.1:标量 payload 忽略;直传/Message 解包后须为本仓库可展开业务类型。
*/
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"));
/** JSON 字符串模式:仅标量收敛,不强制本仓类型(与 Redis W01~W03 对齐)。 */
private static final Set<String> JSON_STRING_PATTERNS = new HashSet<>(Arrays.asList("MQ05", "MQ-K04"));
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);
}
/**
* 扫描单个 Java 源文件中的 RocketMQ / Kafka 生产侧投递点。
*
* @param filePath 仓库内相对路径(仅写入报告)
* @param content 源码全文
* @return 命中的投递点列表(可能为空)
*/
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;
}
if (isBareScalarTypeExpr(resolved.typeExpr, mce)) {
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);
}
// 直传对象 / Message 解包须本仓库可展开JSON 字符串模式仅拦标量
if (!JSON_STRING_PATTERNS.contains(pattern) && !hasResolvableBusinessType(inferred)) {
return null;
}
return wp;
}
private boolean hasResolvableBusinessType(InferredType inferred) {
if (inferred == null || inferred.fqn == null || inferred.fqn.isEmpty()) {
return false;
}
if (BareValueTypes.isBareFqn(inferred.fqn)) {
return false;
}
return index.get(inferred.fqn) != null;
}
private boolean isBareScalarTypeExpr(Expression expr, MethodCallExpr contextCall) {
if (expr == null) {
return false;
}
if (expr instanceof NameExpr) {
Type declared = findVariableType(((NameExpr) expr).getNameAsString(), contextCall);
return declared != null && BareValueTypes.isBareType(declared);
}
if (expr instanceof CastExpr) {
return BareValueTypes.isBareType(((CastExpr) expr).getType());
}
if (expr instanceof ObjectCreationExpr) {
return BareValueTypes.isBareType(((ObjectCreationExpr) expr).getType());
}
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 BareValueTypes.isBareType(md.getType());
}
}
}
}
return false;
}
/**
* 解析 payloadMessage/MessageBuilderMQ04、JSON 字符串MQ05/MQ-K04、直传对象。
* 无法解析的 Message/纯 String 返回 null忽略
*/
private PayloadResolution resolvePayload(Expression payloadArg, MethodCallExpr mce,
String messagePattern, String jsonPattern) {
if (payloadArg == null) {
return null;
}
// MQ04Message&lt;T&gt; 泛型 / 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&lt;DutyImNotice&gt; → 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;
}
return isBareScalarTypeExpr(typeExpr, contextCall);
}
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;
}
}
}

View File

@@ -9,7 +9,6 @@ import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.Parameter;
import com.github.javaparser.ast.body.TypeDeclaration;
import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.expr.BinaryExpr;
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
@@ -34,6 +33,8 @@ import java.util.Set;
/**
* 从单个 Java 源文件中检测 Redis value 写入点W01~W05
* <p>
* V1.1 收敛:忽略无结构标量;局部 JSON 字符串 unwrapW04/W05 仅保留本仓库可展开业务类型。
*/
public class RedisWritePointDetector {
@@ -48,6 +49,7 @@ public class RedisWritePointDetector {
"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> DIRECT_OBJECT_PATTERNS = new HashSet<>(Arrays.asList("W04", "W05"));
private final SourceIndex index;
private final Set<String> enabledPatterns;
@@ -59,6 +61,13 @@ public class RedisWritePointDetector {
this.keyResolver = new RedisKeyResolver(index);
}
/**
* 扫描单个 Java 源文件中的 Redis 写入点。
*
* @param filePath 仓库内相对路径(仅写入报告)
* @param content 源码全文
* @return 命中的写入点列表(可能为空)
*/
public List<WritePoint> detect(String filePath, String content) {
List<WritePoint> result = new ArrayList<>();
if (content == null || content.isEmpty()) {
@@ -97,12 +106,24 @@ public class RedisWritePointDetector {
Expression valueArg = mce.getArgument(1);
Expression serialized = unwrapSerializer(valueArg);
String serializerName = null;
if (serialized != null) {
serializerName = ((MethodCallExpr) valueArg).getNameAsString();
} else {
serialized = unwrapJsonStringVar(valueArg, mce);
if (serialized != null) {
serializerName = serializerMethodOfLocal(valueArg, mce);
}
}
String pattern;
Expression typeExpr;
if (serialized != null) {
pattern = classifySerialized(method, valueArg);
pattern = classifySerialized(method, serializerName);
typeExpr = serialized;
} else if (enabledPatterns.contains("W04") && !isTrivialValue(valueArg)) {
} else if (enabledPatterns.contains("W04")
&& !isTrivialValue(valueArg)
&& !isBareScalarTypeExpr(valueArg, mce)) {
pattern = "W04";
typeExpr = valueArg;
} else {
@@ -111,6 +132,9 @@ public class RedisWritePointDetector {
if (!enabledPatterns.contains(pattern)) {
return null;
}
if (isBareScalarTypeExpr(typeExpr, mce)) {
return null;
}
return buildWritePoint(mce, filePath, pattern, mce.getArgument(0), valueArg, typeExpr);
}
@@ -126,7 +150,13 @@ public class RedisWritePointDetector {
return null;
}
Expression serialized = unwrapSerializer(valueArg);
if (serialized == null) {
serialized = unwrapJsonStringVar(valueArg, mce);
}
Expression typeExpr = serialized != null ? serialized : valueArg;
if (isBareScalarTypeExpr(typeExpr, mce)) {
return null;
}
return buildWritePoint(mce, filePath, "W05", mce.getArgument(0), valueArg, typeExpr);
}
@@ -152,9 +182,23 @@ public class RedisWritePointDetector {
} else {
wp.setConfidence(0.4);
}
if (DIRECT_OBJECT_PATTERNS.contains(pattern) && !hasResolvableBusinessType(inferred)) {
return null;
}
return wp;
}
private boolean hasResolvableBusinessType(InferredType inferred) {
if (inferred == null || inferred.fqn == null || inferred.fqn.isEmpty()) {
return false;
}
if (BareValueTypes.isBareFqn(inferred.fqn)) {
return false;
}
return index.get(inferred.fqn) != null;
}
private boolean isValueOpsScope(MethodCallExpr mce) {
String scope = mce.getScope().map(Expression::toString).orElse("");
String lower = scope.toLowerCase();
@@ -192,9 +236,36 @@ public class RedisWritePointDetector {
}
}
if (expr instanceof ObjectCreationExpr) {
ObjectCreationExpr oce = (ObjectCreationExpr) expr;
String typeName = oce.getType().getNameAsString();
return "UUID".equals(typeName);
return "UUID".equals(((ObjectCreationExpr) expr).getType().getNameAsString());
}
return false;
}
private boolean isBareScalarTypeExpr(Expression expr, MethodCallExpr contextCall) {
if (expr == null) {
return false;
}
if (expr instanceof NameExpr) {
Type declared = findVariableType(((NameExpr) expr).getNameAsString(), contextCall);
return declared != null && BareValueTypes.isBareType(declared);
}
if (expr instanceof CastExpr) {
return BareValueTypes.isBareType(((CastExpr) expr).getType());
}
if (expr instanceof ObjectCreationExpr) {
return BareValueTypes.isBareType(((ObjectCreationExpr) expr).getType());
}
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 BareValueTypes.isBareType(md.getType());
}
}
}
}
return false;
}
@@ -209,9 +280,52 @@ public class RedisWritePointDetector {
return null;
}
private String classifySerialized(String method, Expression valueArg) {
String serializer = valueArg instanceof MethodCallExpr
? ((MethodCallExpr) valueArg).getNameAsString() : "";
/** 局部 {@code String json = toJSONString(x)} 再写入:回溯到 {@code x}。 */
private Expression unwrapJsonStringVar(Expression valueArg, MethodCallExpr mce) {
if (!(valueArg instanceof NameExpr)) {
return null;
}
String name = ((NameExpr) valueArg).getNameAsString();
Type declared = findVariableType(name, mce);
if (declared != null) {
String simple = declared.isClassOrInterfaceType()
? declared.asClassOrInterfaceType().getNameAsString()
: declared.asString();
if (!"String".equals(simple)) {
return null;
}
}
return unwrapSerializer(findLocalInitializer(name, mce));
}
private String serializerMethodOfLocal(Expression valueArg, MethodCallExpr mce) {
if (!(valueArg instanceof NameExpr)) {
return "";
}
Expression init = findLocalInitializer(((NameExpr) valueArg).getNameAsString(), mce);
if (init instanceof MethodCallExpr) {
String name = ((MethodCallExpr) init).getNameAsString();
if (SERIALIZE_METHODS.contains(name)) {
return name;
}
}
return "";
}
private Expression findLocalInitializer(String name, MethodCallExpr mce) {
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()) {
return var.getInitializer().get();
}
}
return null;
}
private String classifySerialized(String method, String serializer) {
if ("insert".equals(method)) {
return "W01";
}
@@ -237,12 +351,10 @@ public class RedisWritePointDetector {
return resolveTypeNode(((CastExpr) expr).getType(), context);
}
if (expr instanceof ObjectCreationExpr) {
ClassOrInterfaceType t = ((ObjectCreationExpr) expr).getType();
return resolveTypeNode(t, context);
return resolveTypeNode(((ObjectCreationExpr) expr).getType(), context);
}
if (expr instanceof NameExpr) {
String name = ((NameExpr) expr).getNameAsString();
Type declared = findVariableType(name, contextCall);
Type declared = findVariableType(((NameExpr) expr).getNameAsString(), contextCall);
if (declared != null) {
return resolveTypeNode(declared, context);
}

View File

@@ -1,25 +1,56 @@
package com.codechecker.cache.detector;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;
/**
* 一个 Redis value 写入点的静态描述。
* 一个 Redis / MQ value 写入(投递)点的静态描述。
*/
@Data
public class WritePoint {
/** Redis 通道标识 */
public static final String CHANNEL_REDIS = "REDIS";
/** RocketMQ 通道标识 */
public static final String CHANNEL_ROCKETMQ = "ROCKETMQ";
/** Kafka 通道标识 */
public static final String CHANNEL_KAFKA = "KAFKA";
/** 源文件相对路径 */
private String filePath;
/** 写入/投递调用所在行号 */
private int lineNumber;
/** 所在类 FQN */
private String enclosingClass;
/** 所在方法名 */
private String enclosingMethod;
/** 命中的检测模式编号,如 W01、MQ01、MQ-K01 */
private String pattern;
/** 源码中的 key / destination 表达式 */
private String keyExpression;
/** 解析后的 key / Topic 模式(如 demo:foo:* */
private String resolvedKeyPattern;
/** 源码中的 value 表达式 */
private String valueExpression;
/** 推断的 value 类型 FQN */
private String resolvedValueType;
/** value 是否为数组根List / 数组序列化) */
private boolean rootArray;
/** 类型/key 推断置信度,默认 1.0 */
private double confidence = 1.0;
/** REDIS / ROCKETMQ / KAFKA默认 REDIS 兼容现网 */
@Setter(AccessLevel.NONE)
private String channel = CHANNEL_REDIS;
public void setChannel(String channel) {
this.channel = channel == null || channel.isEmpty() ? CHANNEL_REDIS : channel;
}
/** 稳定标识:用于在 old/new 两个版本间配对同一写入点 */
public String signature() {
return enclosingClass + "#" + enclosingMethod + "|" + normalizeKey();
@@ -29,94 +60,14 @@ public class WritePoint {
return keyExpression == null ? "" : keyExpression.replaceAll("\\s+", "");
}
public String getFilePath() {
return filePath;
}
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public int getLineNumber() {
return lineNumber;
}
public void setLineNumber(int lineNumber) {
this.lineNumber = lineNumber;
}
public String getEnclosingClass() {
return enclosingClass;
}
public void setEnclosingClass(String enclosingClass) {
this.enclosingClass = enclosingClass;
}
public String getEnclosingMethod() {
return enclosingMethod;
}
public void setEnclosingMethod(String enclosingMethod) {
this.enclosingMethod = enclosingMethod;
}
public String getPattern() {
return pattern;
}
public void setPattern(String pattern) {
this.pattern = pattern;
}
public String getKeyExpression() {
return keyExpression;
}
public void setKeyExpression(String keyExpression) {
this.keyExpression = keyExpression;
}
public String getResolvedKeyPattern() {
return resolvedKeyPattern;
}
public void setResolvedKeyPattern(String resolvedKeyPattern) {
this.resolvedKeyPattern = resolvedKeyPattern;
}
public String getValueExpression() {
return valueExpression;
}
public void setValueExpression(String valueExpression) {
this.valueExpression = valueExpression;
}
public String getResolvedValueType() {
return resolvedValueType;
}
public void setResolvedValueType(String resolvedValueType) {
this.resolvedValueType = resolvedValueType;
}
public boolean isRootArray() {
return rootArray;
}
public void setRootArray(boolean rootArray) {
this.rootArray = rootArray;
}
public double getConfidence() {
return confidence;
}
public void setConfidence(double confidence) {
this.confidence = confidence;
/** @return 是否为 MQ 通道RocketMQ / Kafka */
public boolean isMq() {
return CHANNEL_ROCKETMQ.equals(channel) || CHANNEL_KAFKA.equals(channel);
}
/**
* @return 展示用写入位置,形如 {@code SimpleClass#method:line}
*/
public String location() {
String simpleClass = enclosingClass;
if (simpleClass != null && simpleClass.contains(".")) {

View File

@@ -2,11 +2,14 @@ package com.codechecker.cache.diff;
/**
* 结构变更类型及其默认严重级别。
* <p>用于 Diff 结果分类与报告展示标签;实际严重级别可被 {@code severity_overrides} 覆盖。</p>
* @author DD
*/
public enum ChangeType {
FIELD_REMOVED(Severity.P0, "字段删除"),
TYPE_CHANGED(Severity.P0, "字段类型变更"),
WRAPPER_ADDED(Severity.P0, "新增包装层"),
WRAPPER_REMOVED(Severity.P0, "删除包装层"),
FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"),
FIELD_ADDED(Severity.P1, "新增字段"),
KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"),

View File

@@ -1,103 +1,37 @@
package com.codechecker.cache.diff;
import lombok.Data;
/**
* 一条结构变更记录。
* 一条结构变更记录:关联 key/destination、写入位置、字段路径与前后取值说明
* <p>由 {@link SchemaDiffer} 或分析器在「新增/删除写入点」时生成,再进入报告明细。</p>
*/
@Data
public class SchemaChange {
/** 严重级别(可由 severity_overrides 覆盖默认值) */
private Severity severity;
/** 变更类型(字段增删、包装层、写入点增删等) */
private ChangeType changeType;
/** 解析后的 key / Topic 模式 */
private String keyPattern;
/** 源码中的 key / destination 表达式 */
private String keyExpression;
/** 写入位置,形如 Class#method:line */
private String writeLocation;
/** 展示用 value 类型名 */
private String valueType;
/** 字段路径(点分,数组用 []),写入点级变更可为空 */
private String fieldPath;
/** 变更前取值说明(类型名、路径等,依 changeType 而定) */
private String oldValue;
/** 变更后取值说明 */
private String newValue;
/** 人可读变更说明,用于报告明细 */
private String message;
public SchemaChange(ChangeType changeType) {
this.changeType = changeType;
this.severity = changeType.getDefaultSeverity();
}
public Severity getSeverity() {
return severity;
}
public void setSeverity(Severity severity) {
this.severity = severity;
}
public ChangeType getChangeType() {
return changeType;
}
public void setChangeType(ChangeType changeType) {
this.changeType = changeType;
}
public String getKeyPattern() {
return keyPattern;
}
public void setKeyPattern(String keyPattern) {
this.keyPattern = keyPattern;
}
public String 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 String getFieldPath() {
return fieldPath;
}
public void setFieldPath(String fieldPath) {
this.fieldPath = fieldPath;
}
public String getOldValue() {
return oldValue;
}
public void setOldValue(String oldValue) {
this.oldValue = oldValue;
}
public String getNewValue() {
return newValue;
}
public void setNewValue(String newValue) {
this.newValue = newValue;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}

View File

@@ -22,40 +22,57 @@ public class SchemaDiffer {
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);
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);
// 类型变更(同路径)
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 路径的后缀)
// 路径迁移:
// - 下沉(加包装):旧路径是新路径后缀,如 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)) {
if (isSuffix(a, r) || isSuffix(r, a)) {
moves.add(new String[]{r, a});
matchedRemoved.add(r);
matchedAdded.add(a);
@@ -64,16 +81,16 @@ public class SchemaDiffer {
}
}
// 包装层检测:多个迁移共享同一新前缀
Map<String, Integer> prefixCount = new LinkedHashMap<>();
// 新增包装层:多个迁移共享同一新前缀
Map<String, Integer> newPrefixCount = 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);
newPrefixCount.merge(prefix, 1, Integer::sum);
}
}
for (Map.Entry<String, Integer> e : prefixCount.entrySet()) {
for (Map.Entry<String, Integer> e : newPrefixCount.entrySet()) {
if (e.getValue() >= 2) {
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED);
c.setFieldPath(e.getKey());
@@ -83,6 +100,25 @@ public class SchemaDiffer {
}
}
// 删除包装层:多个迁移共享同一旧前缀
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]);
@@ -99,7 +135,7 @@ public class SchemaDiffer {
}
SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED);
c.setFieldPath(r);
c.setOldValue(oldLeaves.get(r).name());
c.setOldValue(typeLabel(oldLeaves.get(r)));
c.setMessage("删除字段 " + r);
changes.add(c);
}
@@ -111,7 +147,7 @@ public class SchemaDiffer {
}
SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED);
c.setFieldPath(a);
c.setNewValue(newLeaves.get(a).name());
c.setNewValue(typeLabel(newLeaves.get(a)));
c.setMessage("新增字段 " + a);
changes.add(c);
}
@@ -119,11 +155,104 @@ public class SchemaDiffer {
return changes;
}
private Map<String, JsonType> leaves(TypeSchema schema) {
Map<String, JsonType> result = new LinkedHashMap<>();
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.getJsonType());
result.put(f.getPath(), f);
}
}
return result;

View File

@@ -1,10 +1,18 @@
package com.codechecker.cache.diff;
/**
* 变更严重级别。
* 变更严重级别(默认由 {@link ChangeType} 给出,可被配置覆盖)
* <ul>
* <li>{@link #P0} — 高危:字段删除、类型变更、包装层/路径迁移等</li>
* <li>{@link #P1} — 中危:新增字段、删除写入点等</li>
* <li>{@link #P2} — 低危/提示:新增写入点、低置信度变更等</li>
* </ul>
*/
public enum Severity {
/** 高危结构破坏 */
P0,
/** 中危兼容性风险 */
P1,
/** 低危或需人工确认 */
P2
}

View File

@@ -8,7 +8,10 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 基于 git 命令的差异扫描与双版本文件内容读取。
@@ -18,6 +21,9 @@ import java.util.List;
*/
public class GitDiffScanner {
/** {@code git log --author} 正则中需转义的元字符。 */
private static final Pattern AUTHOR_REGEX_META = Pattern.compile("([\\\\.\\[\\]{}()*+?^$|])");
private final Path repoRoot;
public GitDiffScanner(Path repoRoot) {
@@ -40,6 +46,70 @@ public class GitDiffScanner {
return result;
}
/**
* 返回 old..new 区间内、责任人在<strong>first-parent 非 merge</strong>提交中触及的 Java 文件。
*
* <p>用于「合并推送不背锅」:</p>
* <ul>
* <li>{@code --first-parent}:不把二路合并带入的他人历史算进责任人触及集</li>
* <li>{@code --no-merges}:纯 merge 推送 → 空集 → 上层跳过告警</li>
* <li>{@code --author}:由 git 按 author 过滤,避免自解析 pretty 在浅克隆下粘滞误归属</li>
* </ul>
*
* @param authorFilter 责任人过滤串(通常为 Gitea actor对 author name/email 做不区分大小写的子串匹配
* @return 相对路径集合;无匹配时为空
*/
public Set<String> javaFilesTouchedByAuthor(String oldSha, String newSha, String authorFilter)
throws GitException {
if (authorFilter == null || authorFilter.trim().isEmpty()) {
return new LinkedHashSet<>();
}
String authorRegex = escapeAuthorRegex(authorFilter.trim());
// pretty 置空:只输出 --name-only 的路径,交给 collectJavaPathsFromNameOnlyLog
List<String> lines = runLines(
"git", "log",
"--first-parent",
"--no-merges",
"--regexp-ignore-case",
"--author=" + authorRegex,
"--name-only",
"--pretty=format:",
oldSha + ".." + newSha,
"--", "*.java");
return collectJavaPathsFromNameOnlyLog(lines);
}
/**
* 将责任人过滤串转成 {@code git log --author} 可用的字面量正则(转义元字符)。
*/
static String escapeAuthorRegex(String authorFilter) {
if (authorFilter == null || authorFilter.isEmpty()) {
return authorFilter;
}
return AUTHOR_REGEX_META.matcher(authorFilter).replaceAll("\\\\$1");
}
/**
* 解析 {@code git log --name-only --pretty=format:} 输出:收集 .java 路径author 已由 git 滤掉)。
*/
static Set<String> collectJavaPathsFromNameOnlyLog(List<String> lines) {
Set<String> result = new LinkedHashSet<>();
if (lines == null) {
return result;
}
for (String raw : lines) {
if (raw == null) {
continue;
}
String line = raw.trim();
if (line.isEmpty() || !line.endsWith(".java")) {
continue;
}
result.add(line.replace('\\', '/'));
}
return result;
}
/**
* 读取某提交下指定文件的内容;文件在该提交不存在时返回 null。
*/

View File

@@ -1,14 +1,16 @@
package com.codechecker.cache.git;
/**
* Git 操作异常
* Git 命令执行失败时抛出的受检异常(如 {@code git diff}/{@code git show} 非零退出)
*/
public class GitException extends Exception {
/** @param message 失败说明(通常含命令与 stderr 摘要) */
public GitException(String message) {
super(message);
}
/** @param message 失败说明 @param cause 底层原因 */
public GitException(String message, Throwable cause) {
super(message, cause);
}

View File

@@ -1,10 +1,12 @@
package com.codechecker.cache.key;
import com.codechecker.cache.schema.SourceIndex;
import com.github.javaparser.Position;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.expr.AssignExpr;
import com.github.javaparser.ast.expr.BinaryExpr;
import com.github.javaparser.ast.expr.Expression;
import com.github.javaparser.ast.expr.FieldAccessExpr;
@@ -17,6 +19,8 @@ import java.util.Optional;
/**
* 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。
* <p>
* 对局部变量名:先查同名静态常量 / 同名方法 return再回溯方法内使用点之前最近一次声明或赋值。
*/
public class RedisKeyResolver {
@@ -28,6 +32,14 @@ public class RedisKeyResolver {
this.index = index;
}
/**
* 将 key / destination 表达式推断为静态模式字符串。
*
* @param keyExpr AST 表达式字面量、常量拼接、format 等)
* @param enclosingClass 所在类(用于查常量/方法)
* @param context 类型索引上下文,可为 {@code null}
* @return 规范化模式;动态段为 {@code *},无法推断时多为 {@code *} 或 {@code unknown-key} 上游处理
*/
public String resolve(Expression keyExpr, ClassOrInterfaceDeclaration enclosingClass,
SourceIndex.IndexedType context) {
String raw = resolveExpr(keyExpr, enclosingClass, context, 0);
@@ -52,12 +64,17 @@ public class RedisKeyResolver {
}
if (expr instanceof NameExpr) {
String name = ((NameExpr) expr).getNameAsString();
// 1) 静态常量 2) 同名方法 return 3) 局部变量定值(不改变既有优先级)
String constVal = lookupConstant(enclosingClass, name, context, depth);
if (constVal != null) {
return constVal;
}
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) {
FieldAccessExpr fae = (FieldAccessExpr) expr;
@@ -94,6 +111,76 @@ public class RedisKeyResolver {
return "*";
}
/**
* 在包围方法内,取使用点之前对 {@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) {

View File

@@ -13,7 +13,8 @@ import java.util.List;
import java.util.Map;
/**
* 企业微信群机器人通知markdown 消息)。
* 企业微信群机器人通知:发送 markdown 消息(单条或多条按序发送)。
* <p>Webhook 为空时跳过HTTP/errcode 失败时打 stderr 日志并返回 false。</p>
*/
public class WeComNotifier {

View File

@@ -2,113 +2,49 @@ package com.codechecker.cache.report;
import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 一次检测的完整结果
* 一次检测的完整结果:元信息、字段级 {@link SchemaChange} 列表、按 Key/Topic 聚合的
* {@link KeyStructureChange},以及是否阻断流水线与建议退出码。
*/
@Data
public class CheckReport {
/** 仓库名(报告抬头展示) */
private String repository;
/** 分支名 */
private String branch;
/** 对比基准提交 SHA */
private String oldSha;
/** 当前提交 SHA */
private String newSha;
/** 责任人 / 推送人(报告抬头展示;流水线责任人过滤时通常为 Gitea actor */
private String modifier;
/** 提交时间(展示用字符串) */
private String modifyTime;
/** 运行模式notify / block */
private String mode;
/** 字段级变更明细(去重、抑制后) */
private final List<SchemaChange> changes = new ArrayList<>();
/** 按 Key/Topic 聚合的结构变更摘要 */
private final List<KeyStructureChange> keyChanges = new ArrayList<>();
/** block 模式下是否应阻断流水线 */
private boolean blocked;
/** 建议进程退出码0 通过1 阻断2 执行错误 */
private int exitCode;
/** @return 是否存在字段级或 Key 级结构变更 */
public boolean hasChanges() {
return !changes.isEmpty() || !keyChanges.isEmpty();
}
/** @return 字段明细中指定严重级别的条数 */
public long count(Severity severity) {
return changes.stream().filter(c -> c.getSeverity() == severity).count();
}
public String getRepository() {
return repository;
}
public void setRepository(String repository) {
this.repository = repository;
}
public String getBranch() {
return branch;
}
public void setBranch(String branch) {
this.branch = branch;
}
public String getOldSha() {
return oldSha;
}
public void setOldSha(String oldSha) {
this.oldSha = oldSha;
}
public String getNewSha() {
return newSha;
}
public void setNewSha(String newSha) {
this.newSha = newSha;
}
public String getModifier() {
return modifier;
}
public void setModifier(String modifier) {
this.modifier = modifier;
}
public String getModifyTime() {
return modifyTime;
}
public void setModifyTime(String modifyTime) {
this.modifyTime = modifyTime;
}
public String getMode() {
return mode;
}
public void setMode(String mode) {
this.mode = mode;
}
public List<SchemaChange> getChanges() {
return changes;
}
public List<KeyStructureChange> getKeyChanges() {
return keyChanges;
}
public boolean isBlocked() {
return blocked;
}
public void setBlocked(boolean blocked) {
this.blocked = blocked;
}
public int getExitCode() {
return exitCode;
}
public void setExitCode(int exitCode) {
this.exitCode = exitCode;
}
}

View File

@@ -2,97 +2,66 @@ package com.codechecker.cache.report;
import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
/**
* 按 Redis key或未知 key 时的写入点)聚合后的结构变更摘要。
* 按 Redis key / MQ Topic(或未知时的写入点)聚合后的结构变更摘要。
*/
@Data
public class KeyStructureChange {
/** 解析到的 key 模式;未知时为 unknown-key */
/** 解析到的 key / destination 模式;未知时为 unknown-key */
private String keyPattern;
/** 源码中的 key 表达式,如 req.getKey() */
/** 源码中的 key / destination 表达式 */
private String keyExpression;
/** 写入位置 Class#method:line */
private String writeLocation;
/** 展示用 value 类型,如 List&lt;ClockInExportVo&gt; */
private String valueType;
/** key 是否未能静态解析(报告中标灰提示) */
private boolean keyUnresolved;
/** REDIS / ROCKETMQ / KAFKA */
@Setter(AccessLevel.NONE)
private String channel = "REDIS";
/** 变更前序列化骨架 JSON已删除写入/投递时仅有此项) */
private String oldSkeletonJson;
/** 变更后序列化骨架 JSON新增写入/投递时仅有此项) */
private String newSkeletonJson;
/** 本聚合块最高严重级别 */
private Severity severity = Severity.P2;
/** 归属本 Key/Topic 的字段级变更明细 */
private final List<SchemaChange> fieldDetails = new ArrayList<>();
public String getKeyPattern() {
return keyPattern;
public void setChannel(String channel) {
this.channel = channel == null || channel.isEmpty() ? "REDIS" : channel;
}
public void setKeyPattern(String keyPattern) {
this.keyPattern = keyPattern;
/** @return 是否为 MQ 通道 */
public boolean isMq() {
return "ROCKETMQ".equals(channel) || "KAFKA".equals(channel);
}
public String getKeyExpression() {
return keyExpression;
}
public void setKeyExpression(String keyExpression) {
this.keyExpression = keyExpression;
}
public String getWriteLocation() {
return writeLocation;
}
public void setWriteLocation(String writeLocation) {
this.writeLocation = writeLocation;
}
public String getValueType() {
return valueType;
}
public void setValueType(String valueType) {
this.valueType = valueType;
}
public boolean isKeyUnresolved() {
return keyUnresolved;
}
public void setKeyUnresolved(boolean keyUnresolved) {
this.keyUnresolved = keyUnresolved;
}
public String getOldSkeletonJson() {
return oldSkeletonJson;
}
public void setOldSkeletonJson(String oldSkeletonJson) {
this.oldSkeletonJson = oldSkeletonJson;
}
public String getNewSkeletonJson() {
return newSkeletonJson;
}
public void setNewSkeletonJson(String newSkeletonJson) {
this.newSkeletonJson = newSkeletonJson;
}
public Severity getSeverity() {
return severity;
}
public void setSeverity(Severity severity) {
this.severity = severity;
}
public List<SchemaChange> getFieldDetails() {
return fieldDetails;
/** @return 企微展示用通道名RocketMQ / Kafka / Redis */
public String channelDisplay() {
if ("ROCKETMQ".equals(channel)) {
return "RocketMQ";
}
if ("KAFKA".equals(channel)) {
return "Kafka";
}
return "Redis";
}
/**
* 用更严重的级别抬升当前聚合严重度ordinal 更小者优先)。
*
* @param candidate 候选级别;为 null 时忽略
*/
public void raiseSeverity(Severity candidate) {
if (candidate == null) {
return;
@@ -102,7 +71,7 @@ public class KeyStructureChange {
}
}
/** 通知里 Key 行展示文本:未解析优先用表达式。 */
/** 通知里 Key/Topic 行展示文本:未解析优先用表达式。 */
public String displayKey() {
if (keyUnresolved && keyExpression != null && !keyExpression.trim().isEmpty()) {
return keyExpression.trim();

View File

@@ -1,5 +1,6 @@
package com.codechecker.cache.report;
import com.codechecker.cache.diff.ChangeType;
import com.codechecker.cache.diff.SchemaChange;
import com.codechecker.cache.diff.Severity;
@@ -7,6 +8,7 @@ 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;
@@ -14,8 +16,9 @@ import java.util.Set;
/**
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
* <ul>
* <li>企微:按 key 展示位置/类型/序列化骨架变更,不含字段明细,不分 P0/P1/P2</li>
* <li>未解析 key 展示源码表达式 + 灰色「key 无法解析」提示</li>
* <li>企微:按 key 展示位置/类型/序列化骨架变更;类型变更另附摘要行,不分 P0/P1/P2</li>
* <li>新增/删除写入点:专用文案 + 骨架整段染色(绿/橙)</li>
* <li>未解析 key 展示截断后的源码表达式 + 灰色 mapping 提示</li>
* <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
* <li>CI先打字段明细再完整输出企微 Markdown拆分后的每条</li>
* </ul>
@@ -24,11 +27,13 @@ public class ReportBuilder {
/** 企微 markdown content 字节上限UTF-8。 */
public static final int WECOM_MARKDOWN_MAX_BYTES = 4096;
/** 未解析 destination 表达式展示最大字符数(超出截断)。 */
static final int UNRESOLVED_KEY_DISPLAY_MAX = 80;
private final String titlePrefix;
public ReportBuilder(String titlePrefix) {
this.titlePrefix = titlePrefix == null ? "[缓存结构变更]" : titlePrefix;
this.titlePrefix = titlePrefix == null ? "[序列化结构变更]" : titlePrefix;
}
/**
@@ -63,7 +68,7 @@ public class ReportBuilder {
String header = buildHeader(report);
List<String> bodies = buildKeyBodies(report);
if (bodies.isEmpty()) {
return Collections.singletonList(header + "未检测到缓存序列化结构变更。\n");
return Collections.singletonList(header + "未检测到序列化结构变更。\n");
}
StringBuilder combined = new StringBuilder(header);
@@ -123,50 +128,198 @@ public class ReportBuilder {
private String renderKeyBlock(KeyStructureChange kc) {
StringBuilder sb = new StringBuilder();
appendKeyLine(sb, kc.displayKey(), kc.getKeyExpression(), kc.isKeyUnresolved());
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());
boolean addedOnly = isOnlyChangeType(kc.getFieldDetails(), ChangeType.WRITE_POINT_ADDED);
boolean removedOnly = isOnlyChangeType(kc.getFieldDetails(), ChangeType.WRITE_POINT_REMOVED);
if (oldJson.isEmpty() && !newJson.isEmpty() && addedOnly) {
sb.append(" > **变更类型**: 新增写入点\n");
String label = kc.isMq() ? "新增投递,消息体结构为" : "首次写入该 Keyvalue 结构为";
sb.append(" > **").append(label).append("** “")
.append(SkeletonAnnotator.wrapEntire(newJson, SkeletonAnnotator.COLOR_ADD))
.append("\n");
} else if (!oldJson.isEmpty() && newJson.isEmpty() && removedOnly) {
sb.append(" > **变更类型**: 删除写入点\n");
String label = kc.isMq() ? "原消息体结构" : "原 value 结构";
String suffix = kc.isMq() ? "(投递点已删除)" : "(写入点已删除)";
sb.append(" > **").append(label).append("** “")
.append(SkeletonAnnotator.wrapEntire(oldJson, SkeletonAnnotator.COLOR_REMOVE))
.append("").append(suffix).append('\n');
} else {
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
Set<String> newHighlight = SkeletonAnnotator.pathsForNewSkeleton(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) + "";
? "" : "" + SkeletonAnnotator.annotateOldForWecom(
oldJson, oldHighlight, typeGray, wrappersRemoved) + "";
String newRendered = newJson.isEmpty()
? "" : "" + SkeletonAnnotator.annotateNewForWecom(newJson, newHighlight) + "";
? "" : "" + SkeletonAnnotator.annotateNewForWecom(
newJson, newGreen, typeGray, wrappersAdded) + "";
if (oldJson.isEmpty() && !newJson.isEmpty()) {
sb.append(" > **value 新增为:** ").append(newRendered).append("\n\n");
// 兜底:有新骨架但明细不是纯 WRITE_POINT_ADDED
sb.append(" > **首次写入value 结构为:** ").append(newRendered).append('\n');
} else if (!oldJson.isEmpty() && newJson.isEmpty()) {
sb.append(" > **value 结构:** ").append(oldRendered).append("(已删除写入)\n\n");
sb.append(" > **value 结构:** ").append(oldRendered)
.append(kc.isMq() ? "(投递点已删除)\n" : "(写入点已删除)\n");
} else {
sb.append(" > **value值由** ").append(oldRendered).append('\n');
sb.append(" > **变更为:** ").append(newRendered).append("\n\n");
sb.append(" > **变更为:** ").append(newRendered).append('\n');
}
appendTypeChangeSummary(sb, kc.getFieldDetails());
}
sb.append('\n');
return sb.toString();
}
/**
* Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
* 反引号仅包裹 key 文本,避免与加粗/颜色嵌套冲突。
*/
private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression,
boolean unresolved) {
String keyText = unresolved && keyExpression != null && !keyExpression.trim().isEmpty()
? keyExpression.trim()
: nvl(displayKey);
if (keyText.isEmpty()) {
keyText = "unknown-key";
/** 明细非空且全部为同一变更类型(用于整段新增/删除展示)。 */
private static boolean isOnlyChangeType(List<SchemaChange> details, ChangeType expected) {
if (details == null || details.isEmpty() || expected == null) {
return false;
}
sb.append("- Key --> `").append(keyText).append('`');
if (unresolved) {
sb.append(" <font color=\"comment\">key 无法解析)</font>");
for (SchemaChange c : details) {
if (c == null || c.getChangeType() != expected) {
return false;
}
}
return true;
}
/**
* 类型变更单独一行摘要,避免仅靠骨架颜色/占位难以识别。
* 例:{@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');
}
/** 位置、类型作为每个 Key 块的通用项。 */
/** 将 {@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()
? truncateDisplay(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 未解析,建议 manual_mappings 补充)</font>"
: " <font color=\"comment\">key 无法解析,建议 manual_mappings 补充)</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()
? truncateDisplay(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 无法解析,建议 manual_mappings 补充)</font>");
}
sb.append('\n');
}
static String truncateDisplay(String text) {
if (text == null || text.length() <= UNRESOLVED_KEY_DISPLAY_MAX) {
return text == null ? "" : text;
}
return text.substring(0, UNRESOLVED_KEY_DISPLAY_MAX - 3) + "...";
}
/**
* 企微 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");
sb.append(" > **位置**: ").append(nvl(writeLocation)).append('\n');
sb.append(" > **类型**: ").append(nvl(valueType)).append('\n');
}
private boolean isUnresolvedKey(String keyPattern) {
@@ -182,7 +335,7 @@ public class ReportBuilder {
public String toConsole(CheckReport report) {
StringBuilder sb = new StringBuilder();
if (!report.hasChanges()) {
sb.append("未检测到缓存序列化结构变更。\n");
sb.append("未检测到序列化结构变更。\n");
return sb.toString();
}
@@ -202,7 +355,7 @@ public class ReportBuilder {
for (SchemaChange c : list) {
sb.append("- **").append(c.getChangeType().getLabel()).append("**");
if (c.getKeyPattern() != null) {
sb.append(" `").append(c.getKeyPattern()).append('`');
sb.append(" `").append(escapeWeComCode(c.getKeyPattern())).append('`');
}
sb.append('\n');
if (c.getWriteLocation() != null) {
@@ -250,8 +403,9 @@ public class ReportBuilder {
+ trimmed.substring(colonEn + 1).trim();
}
String[] knownPrefixes = {
"删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "新增缓存写入点,",
"删除缓存写入点,原 value 类型: "
"删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "删除包装层 ",
"新增缓存写入点,", "删除缓存写入点,原 value 类型: ",
"新增 MQ 投递点,", "删除 MQ 投递点,原 value 类型: "
};
for (String prefix : knownPrefixes) {
if (trimmed.startsWith(prefix)) {

View File

@@ -12,23 +12,28 @@ import java.util.Set;
/**
* 在骨架 JSON 中为改动字段加企微颜色标注(仅改动片段染色,其余明文)。
* <ul>
* <li>删除字段 → 旧骨架,橙色 {@code warning}</li>
* <li>新增字段 / 新增包装层骨架,绿色 {@code info}</li>
* <li>路径迁移 → 旧路径橙、新路径绿</li>
* <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) {
@@ -47,6 +52,35 @@ final class SkeletonAnnotator {
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) {
@@ -56,8 +90,7 @@ final class SkeletonAnnotator {
if (c == null || c.getChangeType() == null) {
continue;
}
if (c.getChangeType() == ChangeType.FIELD_ADDED
|| c.getChangeType() == ChangeType.WRAPPER_ADDED) {
if (c.getChangeType() == ChangeType.FIELD_ADDED) {
addIfPresent(paths, c.getFieldPath());
} else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) {
addIfPresent(paths, c.getFieldPath());
@@ -69,23 +102,68 @@ final class SkeletonAnnotator {
return paths;
}
/**
* 标注旧骨架改动字段(删除 → 橙色)。
*/
static String annotateOldForWecom(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths, COLOR_REMOVE);
/** 类型变更路径:新旧骨架均用灰色标注。 */
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 annotateNewForWecom(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths, COLOR_ADD);
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">"field":value</font>}
* 改动片段格式:{@code <font color="warning|info|comment">"field":value</font>}
*/
static String annotateForWecom(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths, COLOR_REMOVE);
@@ -98,6 +176,17 @@ final class SkeletonAnnotator {
return annotate(json, highlightPaths, color);
}
/**
* 整段骨架染色(新增/删除写入点场景):整 JSON 包一层 font。
*/
static String wrapEntire(String json, String color) {
if (json == null || json.isEmpty()) {
return "";
}
String c = color == null || color.isEmpty() ? COLOR_ADD : color;
return "<font color=\"" + c + "\">" + json + FONT_CLOSE;
}
/** @deprecated 使用 {@link #annotateForWecom},勿再整段包代码块 */
static String annotateAsCodeBlock(String json, Set<String> highlightPaths) {
return annotateForWecom(json, highlightPaths);

View File

@@ -1,53 +1,20 @@
package com.codechecker.cache.schema;
import java.util.Objects;
import lombok.Value;
/**
* 扁平化后的单个字段节点。path 使用点号分隔,数组元素以 {@code []} 标记。
* 例如:{@code vo.linkList[].id}。
*/
@Value
public class FieldSchema {
private final String path;
private final JsonType jsonType;
private final String javaType;
public FieldSchema(String path, JsonType jsonType, String javaType) {
this.path = path;
this.jsonType = jsonType;
this.javaType = javaType;
}
public String getPath() {
return path;
}
public JsonType getJsonType() {
return jsonType;
}
public String getJavaType() {
return javaType;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof FieldSchema)) {
return false;
}
FieldSchema that = (FieldSchema) o;
return Objects.equals(path, that.path)
&& jsonType == that.jsonType
&& Objects.equals(javaType, that.javaType);
}
@Override
public int hashCode() {
return Objects.hash(path, jsonType, javaType);
}
/** 字段路径,如 {@code vo.linkList[].id} */
String path;
/** 映射到的 JSON 类型OBJECT / ARRAY / STRING 等) */
JsonType jsonType;
/** 原始 Java 类型简单名或 FQN 片段 */
String javaType;
@Override
public String toString() {

View File

@@ -21,12 +21,11 @@ public class JavaSchemaExtractor {
private static final Set<String> STRING_TYPES = new HashSet<>(Arrays.asList(
"String", "CharSequence", "char", "Character", "UUID",
"Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp",
"BigDecimal"));
"Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp"));
private static final Set<String> NUMBER_TYPES = new HashSet<>(Arrays.asList(
"int", "long", "short", "byte", "double", "float",
"Integer", "Long", "Short", "Byte", "Double", "Float",
"Number", "BigInteger", "AtomicInteger", "AtomicLong"));
"Number", "BigInteger", "BigDecimal", "AtomicInteger", "AtomicLong"));
private static final Set<String> BOOLEAN_TYPES = new HashSet<>(Arrays.asList(
"boolean", "Boolean"));
private static final Set<String> COLLECTION_TYPES = new HashSet<>(Arrays.asList(

View File

@@ -1,14 +1,21 @@
package com.codechecker.cache.schema;
/**
* 序列化后 JSON 值的粗粒度类型。
* 序列化后 JSON 值的粗粒度类型,用于 Schema 展开与骨架占位符选择
*/
public enum JsonType {
/** JSON 对象 */
OBJECT,
/** JSON 数组 */
ARRAY,
/** Map 结构 */
MAP,
/** 字符串 */
STRING,
/** 数值 */
NUMBER,
/** 布尔 */
BOOLEAN,
/** 无法判定 */
UNKNOWN
}

View File

@@ -16,6 +16,11 @@ public class SkeletonJsonRenderer {
/** 单侧骨架默认最大长度(企微 markdown 总长约 4096。 */
public static final int DEFAULT_MAX_LEN = 1500;
/**
* 渲染完整骨架 JSON不做长度截断。
*
* @param schema 扁平 Schema空或 null 时返回 {@code "{}"}
*/
public String render(TypeSchema schema) {
return render(schema, null, Integer.MAX_VALUE);
}
@@ -45,7 +50,7 @@ public class SkeletonJsonRenderer {
private Node buildTree(TypeSchema schema) {
Node root = new Node(JsonType.OBJECT);
for (FieldSchema field : schema.getFields().values()) {
putPath(root, field.getPath(), field.getJsonType());
putPath(root, field.getPath(), field.getJsonType(), field.getJavaType());
}
// 根数组:字段以 [] / [].xxx 记录
if (root.children.size() == 1 && root.children.containsKey("[]")) {
@@ -56,7 +61,7 @@ public class SkeletonJsonRenderer {
return root;
}
private void putPath(Node root, String path, JsonType type) {
private void putPath(Node root, String path, JsonType type, String javaType) {
List<Seg> segs = parsePath(path);
if (segs.isEmpty()) {
return;
@@ -70,10 +75,9 @@ public class SkeletonJsonRenderer {
arr.type = JsonType.ARRAY;
Node elem = arr.children.computeIfAbsent("[]", k -> new Node(JsonType.OBJECT));
if (last) {
if (type == JsonType.OBJECT || type == JsonType.ARRAY || type == JsonType.MAP) {
elem.type = type;
} else {
elem.type = type;
elem.javaType = javaType;
if (type != JsonType.OBJECT && type != JsonType.ARRAY && type != JsonType.MAP) {
elem.leaf = true;
}
}
@@ -83,6 +87,7 @@ public class SkeletonJsonRenderer {
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;
@@ -129,7 +134,7 @@ public class SkeletonJsonRenderer {
return "[" + write(elem, elemPath, protectedPaths, compact) + "]";
}
if (node.leaf || isScalar(node.type)) {
return placeholder(node.type);
return placeholder(node.type, node.javaType);
}
if (node.type == JsonType.MAP) {
return "{}";
@@ -161,7 +166,7 @@ public class SkeletonJsonRenderer {
} 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));
sb.append(placeholder(child.type, child.javaType));
} else {
sb.append(write(child, childPath, protectedPaths, compact));
}
@@ -177,7 +182,7 @@ public class SkeletonJsonRenderer {
if (child.type == JsonType.OBJECT || child.type == JsonType.MAP) {
return "\"...\"";
}
return placeholder(child.type);
return placeholder(child.type, child.javaType);
}
private boolean isProtectedUnder(String pathPrefix, Set<String> protectedPaths) {
@@ -327,13 +332,17 @@ public class SkeletonJsonRenderer {
|| type == JsonType.BOOLEAN || type == JsonType.UNKNOWN;
}
private String placeholder(JsonType type) {
/**
* 数字类型示意占位(合法 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 "0";
return numberPlaceholder(javaType);
case BOOLEAN:
return "false";
case STRING:
@@ -349,6 +358,37 @@ public class SkeletonJsonRenderer {
}
}
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("\"", "\\\"");
}
@@ -365,6 +405,7 @@ public class SkeletonJsonRenderer {
private static final class Node {
JsonType type;
String javaType;
boolean leaf;
final Map<String, Node> children = new LinkedHashMap<>();

View File

@@ -6,6 +6,9 @@ import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.ImportDeclaration;
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
import com.github.javaparser.ast.body.TypeDeclaration;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.ArrayList;
import java.util.Collection;
@@ -172,33 +175,16 @@ public class SourceIndex {
/**
* 索引中的类型条目。
*/
@Getter
@AllArgsConstructor(access = AccessLevel.PACKAGE)
public static final class IndexedType {
/** 类型 AST 声明 */
private final ClassOrInterfaceDeclaration declaration;
/** 所在包名(无包则为空串) */
private final String packageName;
/** 文件 import 列表(含通配 import.* */
private final List<String> imports;
/** 类型 FQN含内部类以 . 分隔) */
private final String fqn;
IndexedType(ClassOrInterfaceDeclaration declaration, String packageName, List<String> imports, String fqn) {
this.declaration = declaration;
this.packageName = packageName;
this.imports = imports;
this.fqn = fqn;
}
public ClassOrInterfaceDeclaration getDeclaration() {
return declaration;
}
public String getPackageName() {
return packageName;
}
public List<String> getImports() {
return imports;
}
public String getFqn() {
return fqn;
}
}
}

View File

@@ -1,41 +1,35 @@
package com.codechecker.cache.schema;
import lombok.Getter;
import lombok.Setter;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 某个 Redis value 类型展开后的扁平化 Schemapath -> {@link FieldSchema}。
*/
@Getter
public class TypeSchema {
/** 根 value 类型 FQN或展示名 */
private final String rootType;
/** Schema 展开置信度;类型解析不完整时会下调 */
@Setter
private double confidence = 1.0;
/** path → 字段节点(插入顺序保留) */
private final Map<String, FieldSchema> fields = new LinkedHashMap<>();
public TypeSchema(String rootType) {
this.rootType = rootType;
}
public String getRootType() {
return rootType;
}
public double getConfidence() {
return confidence;
}
public void setConfidence(double confidence) {
this.confidence = confidence;
}
public Map<String, FieldSchema> getFields() {
return fields;
}
/** 按字段 path 放入(同 path 后者覆盖)。 */
public void add(FieldSchema field) {
fields.put(field.getPath(), field);
}
/** @return 是否无任何可展开字段(空 Schema / 无法解析类型时为 true */
public boolean isEmpty() {
return fields.isEmpty();
}

View File

@@ -1,4 +1,4 @@
# cache-schema-checker 内置默认配置
# serialization-schema-checker 内置默认配置
# 业务仓库通过 --config 指定的配置会与本文件深度合并(业务配置优先)。
# 总开关false 时不执行检测、不发通知、流水线直接通过
@@ -19,7 +19,7 @@ notify:
enabled: true
webhook_url: ""
notify_on_clean: false
title_prefix: "[缓存结构变更]"
title_prefix: "[序列化结构变更]"
# 忽略规则
ignore:
@@ -35,6 +35,8 @@ ignore:
- "**/test/**"
# 忽略的写入方法(类全名#方法名)
writer_methods: []
# 忽略的 MQ destinationtopic 或 topic:tagglob
mq_destinations: []
# 检测规则
detection:
@@ -44,7 +46,22 @@ detection:
- W03 # stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)
- W04 # redisTemplate.opsForValue().set(key, obj, ...)
- W05 # redisTemplate.opsForHash().put(key, field, obj)
# 类型推断最低置信度,低于此值降级为 P2 提示
# 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

View File

@@ -0,0 +1,188 @@
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;
/**
* MQ 端到端场景生产侧投递点检测、读侧补强、Schema Diff 与报告渲染联动验证。
*/
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);
}
}

View File

@@ -0,0 +1,39 @@
package com.codechecker.cache;
import com.codechecker.cache.detector.RedisWritePointDetector;
import com.codechecker.cache.detector.WritePoint;
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.assertTrue;
/**
* 业务仓 RecordingTodoDomainServiceImpl#replaceTextStringRedisTemplate 缓存原文 String。
* V1.1 收敛后不应再检出写入点(修复前会误报 W04 + 类型未解析 + value 新增为 "{}")。
*/
class RecordingTodoReplaceScenarioTest {
private static final Set<String> PATTERNS = new HashSet<>(Arrays.asList(
"W01", "W02", "W03", "W04", "W05"));
@Test
void ignoresPlainStringCacheWritesAfterConvergence() {
String constants = TestSupport.fixture("fixtures/recording-todo/RecordingTodoConstants.txt");
String service = TestSupport.fixture("fixtures/recording-todo/RecordingTodoDomainServiceImpl.txt");
SourceIndex index = new SourceIndex();
index.addSource(constants);
index.addSource(service);
List<WritePoint> wps = new RedisWritePointDetector(index, PATTERNS)
.detect("RecordingTodoDomainServiceImpl.java", service);
assertTrue(wps.isEmpty(),
"纯 String 原文缓存不应再告警,实际: " + wps);
}
}

View File

@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** {@link GlobMatcher} 的 glob 规则单测({@code *} / {@code **} / 字面量)。 */
class GlobMatcherTest {
@Test

View File

@@ -11,6 +11,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 验证默认配置加载与业务 YAML 深度合并(业务项优先)。
*/
class ConfigLoaderTest {
@Test
@@ -19,6 +22,11 @@ class ConfigLoaderTest {
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());
}

View File

@@ -0,0 +1,72 @@
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;
/** W06 读侧反序列化类型提示检测单测。 */
class CacheReadHintDetectorTest {
@Test
void detectsParseObjectWithRedisGetPairing() {
String vo = ""
+ "package demo.model;\n"
+ "public class FooVo { private String name; }\n";
String source = ""
+ "package demo;\n"
+ "import demo.model.FooVo;\n"
+ "public class CacheService {\n"
+ " private RedisUtil redisUtil;\n"
+ " private static final String KEY = \"demo:foo:\";\n"
+ " public FooVo load(String id) {\n"
+ " String raw = redisUtil.getString(KEY + id);\n"
+ " return JSON.parseObject(raw, FooVo.class);\n"
+ " }\n"
+ " public void save(String id, FooVo vo) {\n"
+ " redisUtil.insert(KEY + id, JSON.toJSONString(vo), 60);\n"
+ " }\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(vo);
index.addSource(source);
List<CacheReadHint> hints = new CacheReadHintDetector(index)
.detect("CacheService.java", source);
assertEquals(1, hints.size());
CacheReadHint hint = hints.get(0);
assertEquals("demo.model.FooVo", hint.getResolvedValueType());
assertFalse(hint.isRootArray());
assertEquals("demo:foo:*", hint.getResolvedKeyPattern());
}
@Test
void detectsParseArray() {
String vo = ""
+ "package demo.model;\n"
+ "public class ItemVo { private int rank; }\n";
String source = ""
+ "package demo;\n"
+ "import demo.model.ItemVo;\n"
+ "public class ListCache {\n"
+ " public List<ItemVo> load(String raw) {\n"
+ " return JSON.parseArray(raw, ItemVo.class);\n"
+ " }\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(vo);
index.addSource(source);
List<CacheReadHint> hints = new CacheReadHintDetector(index)
.detect("ListCache.java", source);
assertEquals(1, hints.size());
assertTrue(hints.get(0).isRootArray());
assertEquals("demo.model.ItemVo", hints.get(0).getResolvedValueType());
}
}

View File

@@ -0,0 +1,52 @@
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;
/** MQ-R 消费侧类型提示检测单测RocketMQ Listener / Kafka Listener / parse。 */
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())));
}
}

View File

@@ -0,0 +1,195 @@
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;
/**
* RocketMQ / Kafka 生产侧投递点检测单测MQ01~MQ05、MQ-K01~K04 及标量收敛)。
*/
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"));
}
@Test
void ignoresLongAndStringPayload() {
String source = ""
+ "package demo.mq;\n"
+ "import org.apache.rocketmq.spring.core.RocketMQTemplate;\n"
+ "public class BarePayloadProducer {\n"
+ " private RocketMQTemplate rocketMQTemplate;\n"
+ " public void send(String topic, Long count, String text) {\n"
+ " rocketMQTemplate.syncSend(topic, count);\n"
+ " rocketMQTemplate.asyncSend(topic, text);\n"
+ " }\n"
+ "}\n";
List<WritePoint> wps = new MqWritePointDetector(new SourceIndex(), allRocketPatterns())
.detect("BarePayloadProducer.java", source);
assertTrue(wps.isEmpty(), "标量 MQ payload 应忽略,实际: " + wps);
}
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));
}
}

View File

@@ -12,6 +12,9 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Redis 写入点检测单测W01~W05、标量忽略、局部 JSON unwrap、本仓类型门槛
*/
class RedisWritePointDetectorTest {
@Test
@@ -98,4 +101,121 @@ class RedisWritePointDetectorTest {
assertEquals(1, wps.size());
assertEquals("W01", wps.get(0).getPattern());
}
@Test
void ignoresStringVariableW04() {
String source = ""
+ "package demo;\n"
+ "public class TokenCache {\n"
+ " private RedisTemplate<String, String> redisTemplate;\n"
+ " public void save(String key, String tokenVar) {\n"
+ " redisTemplate.opsForValue().set(key, tokenVar, 60, TimeUnit.SECONDS);\n"
+ " }\n"
+ "}\n";
assertTrue(detect(source, new SourceIndex()).isEmpty());
}
@Test
void ignoresLongVariableAndStringGetter() {
String source = ""
+ "package demo;\n"
+ "public class CounterCache {\n"
+ " private RedisTemplate<String, Object> redisTemplate;\n"
+ " public void save(String key, Long count) {\n"
+ " redisTemplate.opsForValue().set(key, count, 60, TimeUnit.SECONDS);\n"
+ " redisTemplate.opsForValue().set(key, getContent(), 60, TimeUnit.SECONDS);\n"
+ " }\n"
+ " private String getContent() { return \"x\"; }\n"
+ "}\n";
assertTrue(detect(source, new SourceIndex()).isEmpty());
}
@Test
void unwrapsLocalJsonStringToW02() {
String vo = ""
+ "package demo.model;\n"
+ "public class DemoVo { private String name; }\n";
String source = ""
+ "package demo;\n"
+ "import demo.model.DemoVo;\n"
+ "public class DemoService {\n"
+ " private RedisTemplate<String, String> redisTemplate;\n"
+ " public void save(String key, DemoVo vo) {\n"
+ " String json = JSON.toJSONString(vo);\n"
+ " redisTemplate.opsForValue().set(key, json, 60, TimeUnit.SECONDS);\n"
+ " }\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(vo);
index.addSource(source);
List<WritePoint> wps = detect(source, index);
assertEquals(1, wps.size());
assertEquals("W02", wps.get(0).getPattern());
assertEquals("demo.model.DemoVo", wps.get(0).getResolvedValueType());
}
@Test
void unwrapsLocalJsonStringInsertToW01() {
String vo = ""
+ "package demo.model;\n"
+ "public class DemoVo { private String name; }\n";
String source = ""
+ "package demo;\n"
+ "import demo.model.DemoVo;\n"
+ "public class DemoService {\n"
+ " private RedisUtil redisUtil;\n"
+ " public void save(String key, DemoVo vo) {\n"
+ " String cache = JsonUtil.getObjectToString(vo);\n"
+ " redisUtil.insert(key, cache, 60);\n"
+ " }\n"
+ "}\n";
SourceIndex index = new SourceIndex();
index.addSource(vo);
index.addSource(source);
List<WritePoint> wps = detect(source, index);
assertEquals(1, wps.size());
assertEquals("W01", wps.get(0).getPattern());
assertEquals("demo.model.DemoVo", wps.get(0).getResolvedValueType());
}
@Test
void ignoresHashPutStringAndInsertStringVar() {
String source = ""
+ "package demo;\n"
+ "public class DemoService {\n"
+ " private RedisTemplate<String, Object> redisTemplate;\n"
+ " private RedisUtil redisUtil;\n"
+ " public void save(String key, String field, String nameStr, String token) {\n"
+ " redisTemplate.opsForHash().put(key, field, nameStr);\n"
+ " redisUtil.insert(key, token, 60);\n"
+ " }\n"
+ "}\n";
assertTrue(detect(source, new SourceIndex()).isEmpty());
}
@Test
void ignoresW04WhenTypeNotInSourceIndex() {
String source = ""
+ "package demo;\n"
+ "import external.ExternalDto;\n"
+ "public class DemoService {\n"
+ " private RedisTemplate<String, Object> redisTemplate;\n"
+ " public void save(String key, ExternalDto dto) {\n"
+ " redisTemplate.opsForValue().set(key, dto, 60, TimeUnit.SECONDS);\n"
+ " }\n"
+ "}\n";
assertTrue(detect(source, new SourceIndex()).isEmpty());
}
private static List<WritePoint> detect(String source, SourceIndex index) {
return new RedisWritePointDetector(index, allPatterns()).detect("DemoService.java", source);
}
private static Set<String> allPatterns() {
return new HashSet<>(Arrays.asList("W01", "W02", "W03", "W04", "W05"));
}
}

View File

@@ -11,6 +11,7 @@ import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** {@link SchemaDiffer} 包装层 / 字段迁移 / 增删等 Diff 行为单测。 */
class SchemaDifferTest {
@Test
@@ -40,6 +41,41 @@ class SchemaDifferTest {
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");
@@ -51,6 +87,57 @@ class SchemaDifferTest {
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 同为 STRINGjavaType 变化也必须检出(用户真实漏报场景)
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

View File

@@ -0,0 +1,54 @@
package com.codechecker.cache.git;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 责任人过滤name-only 路径收集与 {@code --author} 正则转义单测。
*/
class GitDiffScannerAuthorFilterTest {
@Test
void collectJavaPathsSkipsNonJavaAndBlanks() {
Set<String> files = GitDiffScanner.collectJavaPathsFromNameOnlyLog(Arrays.asList(
"a/src/main/java/A.java",
"",
"README.md",
"b/src/main/java/B.java",
"a/src/main/java/A.java"
));
assertEquals(Set.of(
"a/src/main/java/A.java",
"b/src/main/java/B.java"
), files);
}
@Test
void collectJavaPathsNormalizesBackslash() {
Set<String> files = GitDiffScanner.collectJavaPathsFromNameOnlyLog(Collections.singletonList(
"pkg\\Foo.java"
));
assertEquals(Set.of("pkg/Foo.java"), files);
}
@Test
void collectJavaPathsNullOrEmptyYieldEmpty() {
assertTrue(GitDiffScanner.collectJavaPathsFromNameOnlyLog(null).isEmpty());
assertTrue(GitDiffScanner.collectJavaPathsFromNameOnlyLog(Collections.emptyList()).isEmpty());
}
@Test
void escapeAuthorRegexEscapesMetacharacters() {
assertEquals("shitou", GitDiffScanner.escapeAuthorRegex("shitou"));
assertEquals("a\\.b", GitDiffScanner.escapeAuthorRegex("a.b"));
assertEquals("x\\+y", GitDiffScanner.escapeAuthorRegex("x+y"));
assertEquals("foo\\[bar\\]", GitDiffScanner.escapeAuthorRegex("foo[bar]"));
}
}

View File

@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/** Redis key 表达式静态模式推断单测常量、format、拼接等。 */
class RedisKeyResolverTest {
@Test
@@ -65,4 +66,91 @@ class RedisKeyResolverTest {
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);
}
}

View File

@@ -5,6 +5,7 @@ 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;
@@ -14,6 +15,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 企微 Markdown / 控制台报告渲染单测:字段染色、新增写入点整段绿、拆条与转义。
*/
class ReportBuilderTest {
@Test
@@ -56,12 +60,12 @@ class ReportBuilderTest {
key.getFieldDetails().add(addedErr);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[缓存结构变更]").toMarkdown(report);
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`"));
assertTrue(md.contains("> **位置**: SaasPeriodConfigMigrationRedisSupport#putCurrent:41"));
assertTrue(md.contains("> **类型**: MigrationCurrentVo"));
int oldSection = md.indexOf("> **value值由**");
int newSection = md.indexOf("> **变更为:**");
@@ -111,6 +115,205 @@ class ReportBuilderTest {
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();
@@ -133,13 +336,13 @@ class ReportBuilderTest {
key.getFieldDetails().add(detail);
report.getKeyChanges().add(key);
String console = new ReportBuilder("[缓存结构变更]").toConsole(report);
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("> **位置**: "));
assertTrue(console.contains("> **类型**: "));
assertTrue(console.contains("> **value值由**"));
assertTrue(console.contains("> **变更为:**"));
assertFalse(console.contains("`{\"x\":\"\"}`"));
@@ -161,13 +364,128 @@ class ReportBuilderTest {
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>`"));
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("- Key --> `req.getKey()` <font color=\"comment\">key 无法解析,建议 manual_mappings 补充</font>"));
assertTrue(md.contains("> **位置**: ClockInXxxService#export:128"));
assertTrue(md.contains("> **类型**: List<ClockInExportVo>"));
assertFalse(md.contains("`unknown-key`"));
}
@Test
void writePointAddedMqShowsFriendlyCopyAndWholeSkeletonGreen() {
String skeleton = "{\"objectId\":\"\",\"content\":\"\",\"type\":0,\"pushStatus\":0,"
+ "\"tenantId\":\"\",\"deleteMark\":0,\"executeTime\":0}";
CheckReport report = baseReport();
SchemaChange added = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
added.setMessage("新增 MQ 投递点value 类型: DutyImNotice");
added.setKeyPattern("duty_im_notice_topic");
report.getChanges().add(added);
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("duty_im_notice_topic");
key.setChannel("ROCKETMQ");
key.setWriteLocation("DutyDelayQueueImNoticeService#addMq:43");
key.setValueType("DutyImNotice");
key.setOldSkeletonJson("");
key.setNewSkeletonJson(skeleton);
key.getFieldDetails().add(added);
report.getKeyChanges().add(key);
String md = new ReportBuilder("【序列化结构变更】").toMarkdown(report);
assertTrue(md.contains("- Topic --> `duty_im_notice_topic`"), md);
assertTrue(md.contains("> **通道**: RocketMQ"), md);
assertTrue(md.contains("> **变更类型**: 新增写入点"), md);
assertTrue(md.contains("> **新增投递,消息体结构为:**"), md);
assertTrue(md.contains("<font color=\"info\">" + skeleton + "</font>"), md);
assertFalse(md.contains("value 新增为"), md);
String console = new ReportBuilder("【序列化结构变更】").toConsole(report);
assertTrue(console.contains("**新增 MQ 投递点**: value 类型: DutyImNotice"), console);
}
@Test
void writePointAddedRedisShowsFriendlyCopyAndWholeSkeletonGreen() {
String skeleton = "{\"taskId\":\"\",\"status\":\"\"}";
CheckReport report = baseReport();
SchemaChange added = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
added.setMessage("新增缓存写入点value 类型: MigrationCurrentVo");
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("saas:period-config:migration:current");
key.setWriteLocation("SaasPeriodConfigMigrationRedisSupport#putCurrent:41");
key.setValueType("MigrationCurrentVo");
key.setOldSkeletonJson("");
key.setNewSkeletonJson(skeleton);
key.getFieldDetails().add(added);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("> **变更类型**: 新增写入点"), md);
assertTrue(md.contains("> **首次写入该 Keyvalue 结构为:**"), md);
assertTrue(md.contains("<font color=\"info\">" + skeleton + "</font>"), md);
assertFalse(md.contains("value 新增为"), md);
}
@Test
void writePointRemovedHighlightsWholeOldSkeletonOrange() {
String skeleton = "{\"objectId\":\"\",\"content\":\"\"}";
CheckReport report = baseReport();
SchemaChange removed = new SchemaChange(ChangeType.WRITE_POINT_REMOVED);
removed.setMessage("删除 MQ 投递点,原 value 类型: DutyImNotice");
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("duty_im_notice_topic");
key.setChannel("ROCKETMQ");
key.setWriteLocation("DutyDelayQueueImNoticeService#addMq:43");
key.setValueType("DutyImNotice");
key.setOldSkeletonJson(skeleton);
key.setNewSkeletonJson("");
key.getFieldDetails().add(removed);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("> **变更类型**: 删除写入点"), md);
assertTrue(md.contains("> **原消息体结构:**"), md);
assertTrue(md.contains("<font color=\"warning\">" + skeleton + "</font>"), md);
assertTrue(md.contains("(投递点已删除)"), md);
}
@Test
void fieldLevelChangeStillUsesFragmentHighlightNotWholeWrap() {
CheckReport report = baseReport();
report.getKeyChanges().add(simpleKey("k1", "{\"a\":\"\"}", "{\"a\":\"\",\"b\":\"\"}", "b"));
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("> **value值由**"), md);
assertTrue(md.contains("> **变更为:**"), md);
assertTrue(md.contains("<font color=\"info\">\"b\":\"\"</font>"), md);
assertFalse(md.contains("<font color=\"info\">{\"a\":\"\",\"b\":\"\"}</font>"), md);
assertFalse(md.contains("变更类型"), md);
}
@Test
void unresolvedLongExpressionTruncated() {
StringBuilder expr = new StringBuilder("prefix.");
while (expr.length() < 100) {
expr.append("getVeryLongMethodName()");
}
CheckReport report = baseReport();
KeyStructureChange key = new KeyStructureChange();
key.setKeyPattern("unknown-key");
key.setKeyExpression(expr.toString());
key.setKeyUnresolved(true);
key.setOldSkeletonJson("{}");
key.setNewSkeletonJson("{\"a\":\"\"}");
SchemaChange added = new SchemaChange(ChangeType.FIELD_ADDED);
added.setFieldPath("a");
key.getFieldDetails().add(added);
report.getKeyChanges().add(key);
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
assertTrue(md.contains("...`"), "应截断并以 ... 结尾,实际:\n" + md);
assertTrue(md.contains("建议 manual_mappings 补充"), md);
assertFalse(md.contains(expr.toString()), "完整超长表达式不应出现");
}
@Test
void pathAwareNestedHighlight() {
String json = "{\"vo\":{\"dbName\":\"\",\"id\":\"\"},\"expiresAtMs\":0}";
@@ -185,7 +503,7 @@ class ReportBuilderTest {
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);
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"));
@@ -200,7 +518,7 @@ class ReportBuilderTest {
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);
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"));
@@ -209,13 +527,31 @@ class ReportBuilderTest {
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"));
assertTrue(messages.get(0).contains("## [序列化结构变更] jnpf-java-cloud"));
assertTrue(messages.get(1).contains("## [序列化结构变更] jnpf-java-cloud"));
String console = new ReportBuilder("[缓存结构变更]").toConsole(report);
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");

View File

@@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Java 类型 → 扁平 Schema 提取单测(注解忽略 / 重命名等)。 */
class JavaSchemaExtractorTest {
@Test
@@ -81,4 +82,21 @@ class JavaSchemaExtractorTest {
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());
}
}

View File

@@ -15,6 +15,7 @@ import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** 序列化骨架 JSON 渲染与受保护路径截断单测。 */
class SkeletonJsonRendererTest {
@Test
@@ -80,4 +81,31 @@ class SkeletonJsonRendererTest {
"截断后仍应保留改动相关字段: " + 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));
}
}

View File

@@ -0,0 +1,6 @@
package demo.kafka;
public class CheckItemDetailVo {
private String itemId;
private String itemName;
}

View File

@@ -0,0 +1,7 @@
package demo.kafka;
public class CheckItemDetailVo {
private String itemId;
private String itemName;
private Integer score;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,6 @@
package demo.kafka;
public class PatrolNotifyVo {
private String storeId;
private String status;
}

View File

@@ -0,0 +1,7 @@
package demo.mq;
public class DutyImNotice {
private String tenantId;
private String id;
private String content;
}

View File

@@ -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) {
}
}

View File

@@ -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);
}
}

View File

@@ -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";
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,6 @@
package demo.mq;
public class WalletDeductReq {
private String walletId;
private Long amount;
}

View File

@@ -0,0 +1,7 @@
package demo.mq;
public class WalletDeductReq {
private String walletId;
private Long amount;
private String remark;
}

View File

@@ -0,0 +1,13 @@
package com.fantaibao.iot.domain.constant;
/**
* 精简自业务仓 RecordingTodoConstants复现非序列化 String 缓存误报)。
*/
public final class RecordingTodoConstants {
private RecordingTodoConstants() {
}
/** 待办内容替换原文缓存的 Redis key 前缀 */
public static final String REDIS_KEY_PREFIX = "recording:todo:replace:";
}

View File

@@ -0,0 +1,22 @@
package com.fantaibao.iot.infrastructure.service.recording;
import com.fantaibao.iot.domain.constant.RecordingTodoConstants;
import org.springframework.data.redis.core.StringRedisTemplate;
/**
* 精简自业务仓 RecordingTodoDomainServiceImpl#replaceText
* 将待办原文 content/title纯 String写入 StringRedisTemplate无 JSON 序列化。
* 对应企微误报:类型 &lt;未解析&gt;、value 新增为 “{}”。
*/
public class RecordingTodoDomainServiceImpl {
private StringRedisTemplate stringRedisTemplate;
public void replaceText(String tenantId, String todoId, String originalContent, String originalTitle) {
String contentRedisKey = RecordingTodoConstants.REDIS_KEY_PREFIX + todoId + ":" + tenantId;
stringRedisTemplate.opsForValue().set(contentRedisKey, originalContent);
String titleRedisKey = RecordingTodoConstants.REDIS_KEY_PREFIX + "title:" + todoId + ":" + tenantId;
stringRedisTemplate.opsForValue().set(titleRedisKey, originalTitle);
}
}