Compare commits
14 Commits
dev
...
c40e4eff61
| Author | SHA1 | Date | |
|---|---|---|---|
| c40e4eff61 | |||
| f734455d6a | |||
| 20b86592cb | |||
| 99aae6dc77 | |||
| 7cbbfae503 | |||
| a41176c6ce | |||
| 875c0f3ccb | |||
| 69d581f034 | |||
| 182e49c4e1 | |||
| bef70dd032 | |||
| 4361fe767f | |||
| bb406cd354 | |||
| d884aa36ff | |||
| e88408e9b6 |
@@ -4,7 +4,7 @@
|
|||||||
# 说明:
|
# 说明:
|
||||||
# - 本配置文件为业务覆盖配置,会与 jar 内 default-config.yaml 深度合并
|
# - 本配置文件为业务覆盖配置,会与 jar 内 default-config.yaml 深度合并
|
||||||
# - 未声明的项沿用工具内置默认值(忽略规则、检测模式等)
|
# - 未声明的项沿用工具内置默认值(忽略规则、检测模式等)
|
||||||
# - 当前以 Redis 缓存写入检测为主;MQ(RocketMQ/Kafka)方案见 docs/MQ序列化结构检测方案.md
|
# - 当前覆盖 Redis 缓存 + MQ(RocketMQ/Kafka)生产侧投递与 MQ-R 读侧补强
|
||||||
|
|
||||||
# 总开关 true-执行检测 false-跳过检测(流水线直接通过,不发通知)
|
# 总开关 true-执行检测 false-跳过检测(流水线直接通过,不发通知)
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -33,3 +33,9 @@ manual_mappings:
|
|||||||
|
|
||||||
# 误报忽略(按需添加)
|
# 误报忽略(按需添加)
|
||||||
suppressions: []
|
suppressions: []
|
||||||
|
|
||||||
|
# MQ destination 忽略示例(按需取消注释)
|
||||||
|
# ignore:
|
||||||
|
# mq_destinations:
|
||||||
|
# - "benchmark:*"
|
||||||
|
# - "*:TEST"
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ jobs:
|
|||||||
|
|
||||||
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S' "${NEW_SHA}")
|
COMMIT_TIME=$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S' "${NEW_SHA}")
|
||||||
|
|
||||||
|
# 对比区间仍为 before→after 全量;--responsible-author 只把推送人非 merge 触及的文件作触发集
|
||||||
|
# (他人改动应已在其各自 push 时告警;纯 merge 推送 → 空触发集 → 不告警)
|
||||||
java -jar "/tmp/serialization-schema-checker-${SERIALIZATION_SCHEMA_CHECKER_VERSION}.jar" \
|
java -jar "/tmp/serialization-schema-checker-${SERIALIZATION_SCHEMA_CHECKER_VERSION}.jar" \
|
||||||
--config .gitea/config/serialization-schema-check-config.yaml \
|
--config .gitea/config/serialization-schema-check-config.yaml \
|
||||||
--repo-root . \
|
--repo-root . \
|
||||||
@@ -206,4 +208,5 @@ jobs:
|
|||||||
--new-sha "$NEW_SHA" \
|
--new-sha "$NEW_SHA" \
|
||||||
--branch "${{ gitea.ref_name }}" \
|
--branch "${{ gitea.ref_name }}" \
|
||||||
--modifier "${{ gitea.actor }}" \
|
--modifier "${{ gitea.actor }}" \
|
||||||
|
--responsible-author "${{ gitea.actor }}" \
|
||||||
--modify-time "$COMMIT_TIME"
|
--modify-time "$COMMIT_TIME"
|
||||||
|
|||||||
15
.idea/compiler.xml
generated
15
.idea/compiler.xml
generated
@@ -1,16 +1,27 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<annotationProcessing>
|
<annotationProcessing>
|
||||||
|
<profile default="true" name="Default" enabled="true" />
|
||||||
<profile name="Maven default annotation processors profile" enabled="true">
|
<profile name="Maven default annotation processors profile" enabled="true">
|
||||||
<sourceOutputDir name="target/generated-sources/annotations" />
|
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||||
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||||
<outputRelativeToContentRoot value="true" />
|
<outputRelativeToContentRoot value="true" />
|
||||||
<module name="serialization-schema-checker" />
|
<module name="serialization-schema-checker" />
|
||||||
</profile>
|
</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>
|
</annotationProcessing>
|
||||||
<bytecodeTargetLevel>
|
<bytecodeTargetLevel>
|
||||||
<module name="serialization-schema-checker" target="11" />
|
<module name="serialization-schema-checker" target="11" />
|
||||||
</bytecodeTargetLevel>
|
</bytecodeTargetLevel>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -15,7 +15,7 @@ Gitea Actions 触发
|
|||||||
↓
|
↓
|
||||||
java -jar 执行(对比 before → after,累计 diff)
|
java -jar 执行(对比 before → after,累计 diff)
|
||||||
↓
|
↓
|
||||||
有结构变更 → 企微通知(按 Key 骨架;删除橙/新增绿)
|
有结构变更 → 企微通知(按 Key 骨架;删除橙/新增绿/修改灰)
|
||||||
↓
|
↓
|
||||||
mode=block 且含任意结构变更 → exit 1(流水线失败)
|
mode=block 且含任意结构变更 → exit 1(流水线失败)
|
||||||
```
|
```
|
||||||
@@ -29,7 +29,7 @@ mode=block 且含任意结构变更 → exit 1(流水线失败)
|
|||||||
|
|
||||||
一次 push 推了多个 commit 时,只跑 **一次** 检测,覆盖整次 push 的累计代码差,**不会**因为「变更发生在中间 commit、最后一个 commit 没改相关文件」而漏检。
|
一次 push 推了多个 commit 时,只跑 **一次** 检测,覆盖整次 push 的累计代码差,**不会**因为「变更发生在中间 commit、最后一个 commit 没改相关文件」而漏检。
|
||||||
|
|
||||||
不需要全量历史:工作树是当前 tip;`before` 通过 `git fetch --depth 1 <sha>`(或 deepen)取到即可。
|
不需要全量历史:工作树是当前 tip;`before` 通过 `git fetch --depth 1 <sha>`(或 deepen)取到即可。责任人过滤依赖 `git log old..new`,流水线已通过 `ensure_range` deepen,保证区间提交可见。
|
||||||
|
|
||||||
边界:
|
边界:
|
||||||
|
|
||||||
@@ -38,6 +38,28 @@ mode=block 且含任意结构变更 → exit 1(流水线失败)
|
|||||||
- 中间 commit 改坏又被末 commit 改回 → 累计可能无告警(以最终结构为准)
|
- 中间 commit 改坏又被末 commit 改回 → 累计可能无告警(以最终结构为准)
|
||||||
- commit 计数:优先取 `gitea.event.commits` 长度;否则 deepen 到 `before` 为祖先后再 `git rev-list`(浅克隆下直接 `rev-list` 会少算)
|
- commit 计数:优先取 `gitea.event.commits` 长度;否则 deepen 到 `before` 为祖先后再 `git rev-list`(浅克隆下直接 `rev-list` 会少算)
|
||||||
|
|
||||||
|
### 1.2 责任人过滤(合并推送不背锅)
|
||||||
|
|
||||||
|
**前提**:A/B 各自 push 都会跑检测;B 的结构变更已在 B 的 push 告警。合并/代推时不应再让推送人背他人的锅,也不按人拆多条通知。
|
||||||
|
|
||||||
|
| 项 | 行为 |
|
||||||
|
|----|------|
|
||||||
|
| 对比区间 | 仍为整次 push 的 `before → after`(Schema 新旧用全量 diff 回填) |
|
||||||
|
| 触发集 | 仅 **责任人非 merge 提交** 触及的 Java 文件 ∩ 区间 diff |
|
||||||
|
| 责任人参数 | `--responsible-author`(流水线传 `gitea.actor`);`--modifier` 同为 actor,报告抬头「责任人」 |
|
||||||
|
| Author 匹配 | 对 `git log` 的 `%an <%ae>` **不区分大小写包含** actor(中文名/邮箱对不上时可能匹配失败) |
|
||||||
|
| `--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 }}" \
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 前置条件
|
## 2. 前置条件
|
||||||
@@ -83,10 +105,12 @@ git checkout -B "$BRANCH" "$NEW_SHA" # NEW_SHA = gitea.sha
|
|||||||
git fetch --depth 1 origin "$OLD_SHA" # 优先
|
git fetch --depth 1 origin "$OLD_SHA" # 优先
|
||||||
# 或 git fetch --deepen N # 兜底
|
# 或 git fetch --deepen N # 兜底
|
||||||
|
|
||||||
# 4) 执行
|
# 4) 执行(对比区间全量;责任人过滤触发集)
|
||||||
java -jar serialization-schema-checker-1.0.0.jar \
|
java -jar serialization-schema-checker-1.0.0.jar \
|
||||||
--old-sha "$OLD_SHA" \
|
--old-sha "$OLD_SHA" \
|
||||||
--new-sha "$NEW_SHA" \
|
--new-sha "$NEW_SHA" \
|
||||||
|
--modifier "${{ gitea.actor }}" \
|
||||||
|
--responsible-author "${{ gitea.actor }}" \
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -94,19 +118,19 @@ java -jar serialization-schema-checker-1.0.0.jar \
|
|||||||
|
|
||||||
## 5. 与现有流水线的关系
|
## 5. 与现有流水线的关系
|
||||||
|
|
||||||
| 流水线 | 作用 | 关系 |
|
| 流水线 | 作用 | 关系 |
|
||||||
|--------|------|------|
|
|--------|----------------|------|
|
||||||
| `demo.yaml` (AI代码质量分析) | AI Code Review | 并行,互不影响 |
|
| `demo.yaml` (AI代码质量分析) | AI Code Review | 并行,互不影响 |
|
||||||
| `code-check` (CodeChecker) | 通用变更检测 | **同模式**,可并列执行 |
|
| `code-check` (CodeChecker) | 通用变更检测 | **同模式**,可并列执行 |
|
||||||
| `serialization-schema-check` | 缓存结构检测 | 新增 |
|
| `serialization-schema-check` | 序列化结构检测 | 新增 |
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 工具发布流程(redisCheck 仓库)
|
## 6. 工具发布流程(schemaCheck 仓库)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在 redisCheck 仓库根目录
|
# 在 schemaCheck 仓库根目录
|
||||||
mvn clean package -DskipTests
|
mvn clean package -DskipTests
|
||||||
|
|
||||||
# 发布到 Nexus(需配置 settings.xml)
|
# 发布到 Nexus(需配置 settings.xml)
|
||||||
@@ -141,10 +165,13 @@ com/codechecker/serialization-schema-checker/1.0.0/
|
|||||||
| 无法获取 before / exit 2 | 浅克隆未取到对象、服务端禁 fetch SHA | 看日志中的 deepen;确认 Gitea 允许按 SHA fetch |
|
| 无法获取 before / exit 2 | 浅克隆未取到对象、服务端禁 fetch SHA | 看日志中的 deepen;确认 Gitea 允许按 SHA fetch |
|
||||||
| 下载 jar 失败 | Nexus 地址/版本错误 | 检查 env 变量 |
|
| 下载 jar 失败 | Nexus 地址/版本错误 | 检查 env 变量 |
|
||||||
| 未收到企微 | Secret 未配 / notify.enabled=false / webhook_url 空 | 检查配置 |
|
| 未收到企微 | Secret 未配 / notify.enabled=false / webhook_url 空 | 检查配置 |
|
||||||
| 大量误报 | 锁/计数器未过滤 | 补充 ignore.key_patterns |
|
| 大量 Redis 误报 | 锁/计数器未过滤 | 补充 ignore.key_patterns |
|
||||||
|
| 大量 MQ 误报 | 压测/临时 topic | 补充 ignore.mq_destinations |
|
||||||
| commit 数显示为 1(实际多个) | 浅克隆下 `rev-list` 看不到中间提交 | 已修复:优先事件 `commits` 长度;并 deepen 到 before 为祖先 |
|
| commit 数显示为 1(实际多个) | 浅克隆下 `rev-list` 看不到中间提交 | 已修复:优先事件 `commits` 长度;并 deepen 到 before 为祖先 |
|
||||||
| 漏报(模式/模块) | W0x 未开 / include_modules 过窄 | 确认 W01~W05;检查模块过滤 |
|
| 漏报(模式/模块) | W0x/MQ 未开 / include_modules 过窄 | 确认 W01~W05 与 mq_patterns(含 MQ03~05、MQ-K03/K04);检查 `mq_read_hints_enabled` 与模块过滤 |
|
||||||
| 类型展开不完整 | 类型在依赖 jar 中 | 补充 `manual_mappings.value_type` |
|
| 类型展开不完整 | 类型在依赖 jar 中 | 补充 `manual_mappings.value_type` |
|
||||||
|
| 合并推送仍告他人改动 | 未传 `--responsible-author` / jar 过旧 | 同步流水线模板;确认 jar ≥ 含责任人过滤的版本 |
|
||||||
|
| 责任人过滤后漏报自己的改动 | actor 与 `%an <%ae>` 对不上 | 看日志「责任人过滤…」;核对 commit author 是否含 actor 子串 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -156,6 +183,7 @@ com/codechecker/serialization-schema-checker/1.0.0/
|
|||||||
# OLD = 推送前 tip,NEW = 当前 tip(可用 origin/branch@{1} 或显式 sha)
|
# OLD = 推送前 tip,NEW = 当前 tip(可用 origin/branch@{1} 或显式 sha)
|
||||||
OLD_SHA=$(git rev-parse origin/$(git branch --show-current)~3) # 示例:假设 ahead 3
|
OLD_SHA=$(git rev-parse origin/$(git branch --show-current)~3) # 示例:假设 ahead 3
|
||||||
NEW_SHA=$(git rev-parse HEAD)
|
NEW_SHA=$(git rev-parse HEAD)
|
||||||
|
AUTHOR=$(git log -1 --format=%an)
|
||||||
|
|
||||||
java -jar /path/to/serialization-schema-checker-1.0.0.jar \
|
java -jar /path/to/serialization-schema-checker-1.0.0.jar \
|
||||||
--config .gitea/config/serialization-schema-check-config.yaml \
|
--config .gitea/config/serialization-schema-check-config.yaml \
|
||||||
@@ -163,12 +191,13 @@ java -jar /path/to/serialization-schema-checker-1.0.0.jar \
|
|||||||
--old-sha "$OLD_SHA" \
|
--old-sha "$OLD_SHA" \
|
||||||
--new-sha "$NEW_SHA" \
|
--new-sha "$NEW_SHA" \
|
||||||
--branch $(git branch --show-current) \
|
--branch $(git branch --show-current) \
|
||||||
--modifier "$(git log -1 --format=%an)" \
|
--modifier "$AUTHOR" \
|
||||||
|
--responsible-author "$AUTHOR" \
|
||||||
--modify-time "$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')" \
|
--modify-time "$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')" \
|
||||||
--dry-run
|
--dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
单 commit 自测仍可用 `--old-sha HEAD~1 --new-sha HEAD`。
|
单 commit 自测仍可用 `--old-sha HEAD~1 --new-sha HEAD`。未传 `--responsible-author` 时不做作者过滤(全量 diff 触发)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -176,6 +205,6 @@ java -jar /path/to/serialization-schema-checker-1.0.0.jar \
|
|||||||
|
|
||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [实施方案.md](./实施方案.md) | 缓存检测总体方案 |
|
| [实施方案.md](v1.0/redis序列化结构检测实施方案.md) | 缓存检测总体方案 |
|
||||||
| [配置说明.md](./配置说明.md) | YAML 配置项 |
|
| [配置说明.md](./配置说明.md) | YAML 配置项 |
|
||||||
| [MQ序列化结构检测方案.md](./MQ序列化结构检测方案.md) | MQ 消息体 Schema(RocketMQ + Kafka,方案已落地) |
|
| [MQ序列化结构检测方案.md](v1.0/MQ序列化结构检测实施方案.md) | MQ 消息体 Schema(RocketMQ + Kafka,方案已落地) |
|
||||||
|
|||||||
268
docs/V1.1/V1.1-新增写入点展示整改方案.md
Normal file
268
docs/V1.1/V1.1-新增写入点展示整改方案.md
Normal 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 新增为:` | `首次写入该 Key,value 结构为:` |
|
||||||
|
| 旧空、新有 | 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
|
||||||
|
> **变更类型**: 新增写入点
|
||||||
|
> **首次写入该 Key,value 结构为:** “<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_ADDED(MQ/Redis)展示断言;字段级染色回归 |
|
||||||
|
| `docs/配置说明.md` §5 | 同步新增/删除写入点文案与整段绿色约定 |
|
||||||
|
|
||||||
|
**不改**:`SchemaCheckAnalyzer` 建点逻辑、`SkeletonJsonRenderer`、检测器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 测试计划
|
||||||
|
|
||||||
|
| 用例 | 输入要点 | 期望 |
|
||||||
|
|------|----------|------|
|
||||||
|
| T1 | 仅 `WRITE_POINT_ADDED` + MQ + 非空 newJson(对齐 duty_im 样本) | 含「新增投递,消息体结构为」;含「变更类型: 新增写入点」;newJson 外包 `<font color="info">` |
|
||||||
|
| T2 | 仅 `WRITE_POINT_ADDED` + Redis | 含「首次写入该 Key,value 结构为」;整段绿 |
|
||||||
|
| 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 新增写入 | 同上 | `首次写入该 Key,value 结构为:“<font color="info">{…}</font>”` |
|
||||||
|
| 字段级变更 | `value值由` / `变更为` + 片段染色 | 保持 |
|
||||||
287
docs/V1.1/V1.1-非结构写入收敛整改方案.md
Normal file
287
docs/V1.1/V1.1-非结构写入收敛整改方案.md
Normal 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_patterns:lock / 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/W05;MQ 直传 payload | value/payload 声明类型 ∈ `{String, CharSequence, 原始/包装数字, Boolean, UUID, byte/Byte/byte[]}` → **不建点** |
|
||||||
|
| R-B 本仓可展开 | Redis W04/W05;MQ 直传对象 | `resolvedFqn == null` 或 `SourceIndex` 无此类 → **不建点** |
|
||||||
|
| R-C 空 Schema 闸门 | Analyzer:ADDED / 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-F(Redis + 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 点,类型 = VO,W02/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 DTO(S6)默认不监控
|
||||||
|
- [x] 代码落地 + `RecordingTodoReplaceScenarioTest` 期望 0 点
|
||||||
|
- [x] 同步 `配置说明.md`
|
||||||
|
- [x] 全量 `mvn test` 通过
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# MQ 消息体序列化结构变更检测 — 方案
|
# MQ 消息体序列化结构变更检测 — 方案
|
||||||
|
|
||||||
> 版本:v0.2
|
> 版本:v0.4
|
||||||
> 日期:2026-07-15
|
> 日期:2026-07-15
|
||||||
> 状态:**方案已落地(含 Kafka),开发未启动**
|
> 状态:**Phase M1 + M2 已实现**(生产侧 MQ01~05 / MQ-K01~K04 + 读侧 MQ-R)
|
||||||
> 关联:复用 `serialization-schema-checker` 的 Schema 提取、Diff、企微通知与 CI 框架
|
> 关联:复用 `serialization-schema-checker` 的 Schema 提取、Diff、企微通知与 CI 框架
|
||||||
> 业务样本仓:`jnpf-java-cloud`(**RocketMQ + Kafka**)
|
> 业务样本仓:`jnpf-java-cloud`(**RocketMQ + Kafka**)
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
在 push 时静态分析 **消息体类型的序列化 Schema** 是否相对对比区间发生变更,覆盖 **RocketMQ + Kafka**,并复用现有企微通知 / notify|block 能力。
|
在 push 时静态分析 **消息体类型的序列化 Schema** 是否相对对比区间发生变更,覆盖 **RocketMQ + Kafka**,并复用现有企微通知 / notify|block 能力。
|
||||||
|
|
||||||
### 1.3 非目标(本方案首版)
|
### 1.3 非目标
|
||||||
|
|
||||||
- 不连接真实 Broker,不拉取积压消息做运行时校验
|
- 不连接真实 Broker,不拉取积压消息做运行时校验
|
||||||
- 不解析依赖 jar 内消息类型(仅本仓 `src/main/java`)
|
- 不解析依赖 jar 内消息类型(仅本仓 `src/main/java`)
|
||||||
@@ -56,8 +56,9 @@
|
|||||||
| `rocketMQTemplate.syncSend(dest, dto)` | 高(如钱包扣费) | **纳入** |
|
| `rocketMQTemplate.syncSend(dest, dto)` | 高(如钱包扣费) | **纳入** |
|
||||||
| `asyncSend` / `syncSendOrderly` 等 | 中 | **纳入** |
|
| `asyncSend` / `syncSendOrderly` 等 | 中 | **纳入** |
|
||||||
| `convertAndSend` | 视封装而定 | **纳入** |
|
| `convertAndSend` | 视封装而定 | **纳入** |
|
||||||
| 先 `JSON.toJSONString` 再发 String | 较低 | unwrap 后取类型 |
|
| `MessageBuilder.withPayload` 再 send | 中(如 IM 延时消息) | **纳入**(MQ04) |
|
||||||
| 只发 `String` / `byte[]` / `MessageExt` | 有 | **默认忽略** |
|
| 先 `JSON.toJSONString` 再发 String | 较低 | unwrap 后取类型(MQ05) |
|
||||||
|
| 只发 `String` / `byte[]` / 无泛型 `Message` | 有 | **默认忽略** |
|
||||||
|
|
||||||
样本(资金钱包):
|
样本(资金钱包):
|
||||||
|
|
||||||
@@ -68,12 +69,14 @@ rocketMQTemplate.syncSend(CapitalMqConstants.TOPIC + ":" + tag, req); // WalletD
|
|||||||
public class WalletDeductConsumer implements RocketMQListener<WalletDeductReq> { ... }
|
public class WalletDeductConsumer implements RocketMQListener<WalletDeductReq> { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 Kafka(已确认需纳入)
|
### 2.2 Kafka
|
||||||
|
|
||||||
| 写法 | 出现情况 | 策略 |
|
| 写法 | 出现情况 | 策略 |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| `kafkaTemplate.send(topic, dto)` | 中(值班食安项等) | **纳入** |
|
| `kafkaTemplate.send(topic, dto)` | 中(值班食安项等) | **纳入** |
|
||||||
| `kafkaTemplate.send(topic, List<Xxx>)` | 中(巡店食安项列表) | **纳入**(rootArray) |
|
| `kafkaTemplate.send(topic, List<Xxx>)` | 中(巡店食安项列表) | **纳入**(rootArray) |
|
||||||
|
| `kafkaTemplate.send(ProducerRecord)` | 中 | **纳入**(MQ-K03) |
|
||||||
|
| 先 JSON 再 `send(topic, json)` | 较低 | unwrap(MQ-K04) |
|
||||||
| `@KafkaListener` + `String` + `JSONObject.parseObject(..., Xxx.class)` | 有(数据分析中差评) | **读侧补强 MQ-R** |
|
| `@KafkaListener` + `String` + `JSONObject.parseObject(..., Xxx.class)` | 有(数据分析中差评) | **读侧补强 MQ-R** |
|
||||||
| `KafkaTopicUtil` 仅创建 Topic | 有(租户) | **忽略**(无消息体) |
|
| `KafkaTopicUtil` 仅创建 Topic | 有(租户) | **忽略**(无消息体) |
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ public void handleMessage(String message) {
|
|||||||
|
|
||||||
动态 Topic(如按租户拼接)静态推断结果形如 `patrol-store-topic:*`,与 Redis key `*` 规则一致。
|
动态 Topic(如按租户拼接)静态推断结果形如 `patrol-store-topic:*`,与 Redis key `*` 规则一致。
|
||||||
|
|
||||||
### 2.3 「Key」等价物(destination)
|
### 2.3 「Key」等价物
|
||||||
|
|
||||||
| 中间件 | 聚合键形态 | 来源 |
|
| 中间件 | 聚合键形态 | 来源 |
|
||||||
|--------|------------|------|
|
|--------|------------|------|
|
||||||
@@ -111,13 +114,15 @@ public void handleMessage(String message) {
|
|||||||
|
|
||||||
无法解析时:展示表达式 + `<font color="comment">(destination 未解析)</font>`。
|
无法解析时:展示表达式 + `<font color="comment">(destination 未解析)</font>`。
|
||||||
|
|
||||||
### 2.4 读侧补强(类比 W06)
|
### 2.4 读侧补强
|
||||||
|
|
||||||
| 中间件 | 补强来源 |
|
| 中间件 | 补强来源 |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
| RocketMQ | `RocketMQListener<T>`、`onMessage(T)` + `@RocketMQMessageListener` |
|
| RocketMQ | `RocketMQListener<T>`、`onMessage(T)` + `@RocketMQMessageListener` |
|
||||||
| Kafka | `@KafkaListener` 方法参数类型;或方法内 `parseObject/parseArray(..., Xxx.class)`(与现有 W06 共享解析能力) |
|
| Kafka | `@KafkaListener` 方法参数类型;或方法内 `parseObject/parseArray(..., Xxx.class)`(与现有 W06 共享解析能力) |
|
||||||
|
|
||||||
|
开关:`detection.mq_read_hints_enabled`(默认 **true**)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 方案总览
|
## 3. 方案总览
|
||||||
@@ -135,8 +140,8 @@ public void handleMessage(String message) {
|
|||||||
```text
|
```text
|
||||||
Git Diff → 变更 Java 文件
|
Git Diff → 变更 Java 文件
|
||||||
├─ Redis:W01~W05 + W06 ← 已有
|
├─ Redis:W01~W05 + W06 ← 已有
|
||||||
└─ MQ:RocketMQ(MQ01~)+ Kafka(MQ-K*)
|
└─ MQ:RocketMQ(MQ01~05)+ Kafka(MQ-K01~K04)
|
||||||
+ Listener / parse 补强(MQ-R) ← 本方案
|
+ Listener / parse 补强(MQ-R) ← 已实现
|
||||||
↓
|
↓
|
||||||
同一套 TypeSchema / SchemaDiffer / Skeleton / WeCom
|
同一套 TypeSchema / SchemaDiffer / Skeleton / WeCom
|
||||||
```
|
```
|
||||||
@@ -161,7 +166,7 @@ Git Diff → 变更 Java 文件
|
|||||||
| MQ01 | `rocketMQTemplate.syncSend(dest, payload, …)` | dest、payload 类型 |
|
| MQ01 | `rocketMQTemplate.syncSend(dest, payload, …)` | dest、payload 类型 |
|
||||||
| MQ02 | `asyncSend` / `syncSendOrderly` / `sendOneWay` 等 | 同上 |
|
| MQ02 | `asyncSend` / `syncSendOrderly` / `sendOneWay` 等 | 同上 |
|
||||||
| MQ03 | `convertAndSend(dest, payload)` | 同上 |
|
| MQ03 | `convertAndSend(dest, payload)` | 同上 |
|
||||||
| MQ04 | `MessageBuilder.withPayload(obj)` 再 send | payload 类型 |
|
| MQ04 | `MessageBuilder.withPayload(obj)` 再 send,或 `Message<T>` | payload / `T` |
|
||||||
| MQ05 | 先 `toJSONString`/`getObjectToString` 再 send String | unwrap 后类型 |
|
| MQ05 | 先 `toJSONString`/`getObjectToString` 再 send String | unwrap 后类型 |
|
||||||
|
|
||||||
### 4.2 生产侧 — Kafka
|
### 4.2 生产侧 — Kafka
|
||||||
@@ -170,7 +175,7 @@ Git Diff → 变更 Java 文件
|
|||||||
|---------|------------|------|
|
|---------|------------|------|
|
||||||
| MQ-K01 | `kafkaTemplate.send(topic, payload)` | topic、payload 类型 |
|
| MQ-K01 | `kafkaTemplate.send(topic, payload)` | topic、payload 类型 |
|
||||||
| MQ-K02 | `kafkaTemplate.send(topic, key, payload)` | 同上(忽略分区 key) |
|
| MQ-K02 | `kafkaTemplate.send(topic, key, payload)` | 同上(忽略分区 key) |
|
||||||
| MQ-K03 | `send(ProducerRecord)` / `ListenableFuture` 封装若可解析 | topic + value 类型 |
|
| MQ-K03 | `send(ProducerRecord)` | topic + value 类型 |
|
||||||
| MQ-K04 | 先 JSON 序列化为 String 再 `send(topic, json)` | unwrap 后类型 |
|
| MQ-K04 | 先 JSON 序列化为 String 再 `send(topic, json)` | unwrap 后类型 |
|
||||||
|
|
||||||
Payload 为 `List<Xxx>` / `Collection` 时标记 **rootArray**,骨架为 JSON 数组(与 Redis List 一致)。
|
Payload 为 `List<Xxx>` / `Collection` 时标记 **rootArray**,骨架为 JSON 数组(与 Redis List 一致)。
|
||||||
@@ -187,7 +192,7 @@ Payload 为 `List<Xxx>` / `Collection` 时标记 **rootArray**,骨架为 JSON
|
|||||||
|---------|------|------|
|
|---------|------|------|
|
||||||
| MQ-R01 | `RocketMQListener<T>` / `@RocketMQMessageListener` | 补强同 destination 生产点 |
|
| MQ-R01 | `RocketMQListener<T>` / `@RocketMQMessageListener` | 补强同 destination 生产点 |
|
||||||
| MQ-R02 | `@KafkaListener` + 参数类型 `T`(非 String) | 补强同 topic |
|
| MQ-R02 | `@KafkaListener` + 参数类型 `T`(非 String) | 补强同 topic |
|
||||||
| MQ-R03 | Listener 内 `parseObject`/`parseArray(..., Xxx.class)` | 补强(可复用 W06 检测器) |
|
| MQ-R03 | Listener 内 `parseObject`/`parseArray(..., Xxx.class)` | 补强(复用 parse AST) |
|
||||||
|
|
||||||
开关:`detection.mq_read_hints_enabled`(默认 true)。
|
开关:`detection.mq_read_hints_enabled`(默认 true)。
|
||||||
|
|
||||||
@@ -220,7 +225,6 @@ Payload 为 `List<Xxx>` / `Collection` 时标记 **rootArray**,骨架为 JSON
|
|||||||
|
|
||||||
- 删除字段橙 `warning`;新增绿 `info`
|
- 删除字段橙 `warning`;新增绿 `info`
|
||||||
- destination 未解析时灰色提示
|
- destination 未解析时灰色提示
|
||||||
- 可选后缀:「请评估消费积压与兼容反序列化」
|
|
||||||
|
|
||||||
「通道」字段用于区分中间件;若模板求简,可省略通道仅靠 Topic 形态区分。
|
「通道」字段用于区分中间件;若模板求简,可省略通道仅靠 Topic 形态区分。
|
||||||
|
|
||||||
@@ -272,37 +276,36 @@ manual_mappings:
|
|||||||
|
|
||||||
## 7. 分阶段交付
|
## 7. 分阶段交付
|
||||||
|
|
||||||
### Phase M1 — MVP(RocketMQ + Kafka 基础投递)
|
### Phase M1 — MVP(RocketMQ + Kafka 基础投递)✅
|
||||||
|
|
||||||
| 任务 | 说明 |
|
| 任务 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| MQ01/MQ02 | RocketMQ `syncSend` / `asyncSend` |
|
| MQ01/MQ02 | RocketMQ `syncSend` / `asyncSend` |
|
||||||
| MQ-K01/MQ-K02 | Kafka `send(topic, payload)` / 三参 send |
|
| MQ-K01/MQ-K02 | Kafka `send(topic, payload)` / 三参 send |
|
||||||
| destination 推断 | 字面量、常量、拼接;Kafka 动态 topic → `*` |
|
| destination 推断 | 字面量、常量、拼接;Kafka 动态 topic → `*` |
|
||||||
| Schema Diff + 骨架通知 | 复用 ReportBuilder;可选「通道」行 |
|
| Schema Diff + 骨架通知 | 复用 ReportBuilder;「通道」行 |
|
||||||
| 夹具 | `fixtures/mq/rocket-wallet/`、`fixtures/mq/kafka-patrol/` |
|
| 夹具 | `fixtures/mq/rocket-wallet/`、`fixtures/mq/kafka-patrol/` |
|
||||||
| 配置 | `mq_patterns`(含 MQ-K*)、`ignore.mq_destinations` |
|
| 配置 | `mq_patterns`(含 MQ-K*)、`ignore.mq_destinations` |
|
||||||
|
| List 根数组骨架 | `List<CheckItemDetailVo>` 等(与 M2 需求合并交付) |
|
||||||
|
|
||||||
**验收**:
|
**验收**:
|
||||||
|
|
||||||
1. 删 `WalletDeductReq` 字段 → 企微出现 RocketMQ Topic 骨架变更
|
1. 删 `WalletDeductReq` 字段 → 企微出现 RocketMQ Topic 骨架变更
|
||||||
2. 删 `CheckItemDetailVo` 字段 → 企微出现 Kafka Topic 骨架变更
|
2. 删 `CheckItemDetailVo` 字段 → 企微出现 Kafka Topic 骨架变更
|
||||||
|
|
||||||
### Phase M2 — 增强
|
### Phase M2 — 增强 ✅
|
||||||
|
|
||||||
| 任务 | 说明 |
|
| 任务 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| MQ03~MQ05、MQ-K03/K04 | convertAndSend、MessageBuilder、JSON 字符串发送、ProducerRecord |
|
| MQ03~MQ05、MQ-K03/K04 | convertAndSend、MessageBuilder/`Message<T>`、JSON 字符串发送、ProducerRecord |
|
||||||
| MQ-R01~R03 | RocketMQ Listener + Kafka `@KafkaListener` / parse 补强 |
|
| MQ-R01~R03 | RocketMQ Listener + Kafka `@KafkaListener` / parse 补强 |
|
||||||
| List 根数组骨架 | 巡店 `List<CheckItemDetailVo>` 等 |
|
| 夹具 | `fixtures/mq/rocket-im/`、`fixtures/mq/kafka-record/` |
|
||||||
|
|
||||||
### Phase M3 — 运营
|
**验收**:
|
||||||
|
|
||||||
| 任务 | 说明 |
|
1. `MessageBuilder.withPayload(DutyImNotice)` + `asyncSend` → 命中 MQ04,改 VO 字段可告警
|
||||||
|------|------|
|
2. `kafkaTemplate.send(ProducerRecord)` → 命中 MQ-K03
|
||||||
| 积压风险提示文案 | 统一提示评估消费 lag / 积压 |
|
3. Listener / `parseObject` 可补强同 Topic 弱类型生产点
|
||||||
| 更多夹具 | 值班 Kafka、中差评 Listener、IM Favorite 等 |
|
|
||||||
| 分 webhook / 标题前缀 | 缓存 vs MQ 可选拆分 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -312,7 +315,7 @@ manual_mappings:
|
|||||||
|------|------|
|
|------|------|
|
||||||
| Kafka topic 按租户动态拼接 | 归一 `prefix:*`;`manual_mappings` |
|
| Kafka topic 按租户动态拼接 | 归一 `prefix:*`;`manual_mappings` |
|
||||||
| RocketMQ / Kafka 混用同一 VO | 各投递点独立告警(符合预期) |
|
| RocketMQ / Kafka 混用同一 VO | 各投递点独立告警(符合预期) |
|
||||||
| Listener 收 String、parse 在方法深处 | MQ-R03 + 复用 W06 AST |
|
| Listener 收 String、parse 在方法深处 | MQ-R03 + parse AST |
|
||||||
| 生产/消费跨模块对不齐 | 同仓索引 + destination 对齐;失败则仅写侧 |
|
| 生产/消费跨模块对不齐 | 同仓索引 + destination 对齐;失败则仅写侧 |
|
||||||
| Jackson / Fastjson / Kafka JsonSerializer 细节差 | 首版字段名;必要时方言或 mapping |
|
| Jackson / Fastjson / Kafka JsonSerializer 细节差 | 首版字段名;必要时方言或 mapping |
|
||||||
| 只改消费未改生产类型 | 不告警(工具职责是消息体 Schema) |
|
| 只改消费未改生产类型 | 不告警(工具职责是消息体 Schema) |
|
||||||
@@ -323,17 +326,14 @@ manual_mappings:
|
|||||||
|
|
||||||
| 项 | 结论 |
|
| 项 | 结论 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| 中间件范围 | **RocketMQ + Kafka**(本仓已确认);Rabbit 暂不纳入 |
|
| 中间件范围 | **RocketMQ + Kafka**|
|
||||||
| 对比区间 | `gitea.event.before` → `gitea.sha` |
|
| 对比区间 | `gitea.event.before` → `gitea.sha` |
|
||||||
| 阻断 | 与现网 `mode` 共用 |
|
|
||||||
| 级别 | 不引入 P0/P1/P2 产品展示 |
|
|
||||||
| 交付 | 同一 jar;patterns 区分 Redis / MQ(含 MQ-K*) |
|
| 交付 | 同一 jar;patterns 区分 Redis / MQ(含 MQ-K*) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 下一步
|
## 10. 下一步
|
||||||
|
|
||||||
1. 评审本方案(RocketMQ + Kafka 模式表)
|
1. ~~按 Phase M1 开发~~ **已完成**(MQ01/02 + MQ-K01/K02)
|
||||||
2. 按 **Phase M1** 开发(MQ01/02 + MQ-K01/K02)
|
2. ~~按 Phase M2 开发~~ **已完成**(MQ03~05、MQ-K03/K04、MQ-R)
|
||||||
3. 回写 `配置说明.md` / `CI集成说明.md` 正式配置项
|
3. 业务仓验收:`MessageBuilder` IM Topic、巡店/值班 Kafka、`capital-topic:WALLET_DEDUCT`
|
||||||
4. 验收 Topic 建议:`capital-topic:WALLET_DEDUCT`(RocketMQ)、巡店/值班 Kafka topic
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
> 日期:2026-07-15
|
> 日期:2026-07-15
|
||||||
> 技术栈:Java 11 + Maven + JavaParser
|
> 技术栈:Java 11 + Maven + JavaParser
|
||||||
> 目标仓库:`schemaCheck`(工具) / `jnpf-java-cloud`(被检测业务仓库)
|
> 目标仓库:`schemaCheck`(工具) / `jnpf-java-cloud`(被检测业务仓库)
|
||||||
> 当前阶段:**Phase 1 + Phase 2 已完成**;Phase 3 运营待做;**MQ 扩展方案已文档落地**(见 `docs/MQ序列化结构检测方案.md`)
|
> 当前阶段:**Phase 1 + Phase 2 已完成**;**MQ 扩展方案已文档落地**(见 `docs/MQ序列化结构检测方案.md`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,13 +161,11 @@ flowchart TB
|
|||||||
| 报告 | Jackson | 2.15.x | JSON/Markdown 报告序列化 |
|
| 报告 | Jackson | 2.15.x | JSON/Markdown 报告序列化 |
|
||||||
| 测试 | JUnit 5 | 5.10.x | 单元测试 + 夹具样本 |
|
| 测试 | JUnit 5 | 5.10.x | 单元测试 + 夹具样本 |
|
||||||
|
|
||||||
**不采用** Spoon / Eclipse JDT 的原因:JavaParser 足够覆盖第一版需求,依赖更轻,CLI 启动更快。
|
|
||||||
|
|
||||||
**Lombok 处理策略**:基于源码字段 + `@Data` 等注解推断序列化字段;对 `@Builder`、`@SuperBuilder` 等复杂场景标记为低置信度提示。后续可选集成 `lombok.ast` 或 delombok 预处理。
|
**Lombok 处理策略**:基于源码字段 + `@Data` 等注解推断序列化字段;对 `@Builder`、`@SuperBuilder` 等复杂场景标记为低置信度提示。后续可选集成 `lombok.ast` 或 delombok 预处理。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 工程结构(redisCheck 仓库)
|
## 5. 工程结构(schemaCheck 仓库)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
schemaCheck/
|
schemaCheck/
|
||||||
@@ -527,31 +525,6 @@ notify:
|
|||||||
| 企微高亮 | 删除橙 `warning` / 新增绿 `info`;位置+类型通用项 | ✅ |
|
| 企微高亮 | 删除橙 `warning` / 新增绿 `info`;位置+类型通用项 | ✅ |
|
||||||
| W06 读侧辅助 | `parseObject`/`parseArray` 等补强写入 value 类型 | ✅ |
|
| W06 读侧辅助 | `parseObject`/`parseArray` 等补强写入 value 类型 | ✅ |
|
||||||
|
|
||||||
### Phase 3 — 运营
|
|
||||||
|
|
||||||
| 任务 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| 报告落盘 | 可选输出 JSON 报告文件 |
|
|
||||||
| 误报反馈 | `suppressions` 按写入点 / change_types 精细忽略 |
|
|
||||||
| 更多业务场景覆盖 | 考勤、文件下载进度等 |
|
|
||||||
|
|
||||||
### Phase 4 — MQ 消息体结构检测(方案已落地,含 Kafka,开发待启)
|
|
||||||
|
|
||||||
业务仓同时存在:
|
|
||||||
|
|
||||||
- **RocketMQ**:`RocketMQTemplate.syncSend(topic:tag, dto)` / `RocketMQListener<T>`
|
|
||||||
- **Kafka**:`KafkaTemplate.send(topic, vo|List)` / `@KafkaListener` + `parseObject`
|
|
||||||
|
|
||||||
消息体字段变更会导致积压旧消息反序列化失败,风险模型与 Redis 同类。
|
|
||||||
|
|
||||||
| 任务 | 说明 | 状态 |
|
|
||||||
|------|------|------|
|
|
||||||
| 方案文档 | RocketMQ + Kafka 模式、destination、复用 Schema Diff/企微 | ✅ 见专用文档 |
|
|
||||||
| Phase M1 | RocketMQ syncSend + Kafka send + 骨架通知 + 夹具 | 待启 |
|
|
||||||
| Phase M2/M3 | Listener/parse 补强、更多投递形态、运营 | 待启 |
|
|
||||||
|
|
||||||
**专用方案:** [`docs/MQ序列化结构检测方案.md`](./MQ序列化结构检测方案.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. 测试策略
|
## 11. 测试策略
|
||||||
@@ -561,6 +534,8 @@ notify:
|
|||||||
- `SchemaDifferTest`:纯字段路径对比逻辑
|
- `SchemaDifferTest`:纯字段路径对比逻辑
|
||||||
- `JavaSchemaExtractorTest`:类字段展开、注解、内部类
|
- `JavaSchemaExtractorTest`:类字段展开、注解、内部类
|
||||||
- `RedisWritePointDetectorTest`:各种写入 AST 模式匹配
|
- `RedisWritePointDetectorTest`:各种写入 AST 模式匹配
|
||||||
|
- `MqWritePointDetectorTest`:RocketMQ/Kafka 投递(含 MQ04 MessageBuilder、MQ-K03 ProducerRecord)
|
||||||
|
- `MqReadHintDetectorTest`:MQ-R Listener / parseObject 补强
|
||||||
- `RedisKeyResolverTest`:常量、format、拼接推断
|
- `RedisKeyResolverTest`:常量、format、拼接推断
|
||||||
|
|
||||||
### 11.2 样本夹具测试(fixtures)
|
### 11.2 样本夹具测试(fixtures)
|
||||||
@@ -574,6 +549,10 @@ notify:
|
|||||||
| `fixtures/tenant/` | 包装结构变更(TenantVO → CacheEnvelope) |
|
| `fixtures/tenant/` | 包装结构变更(TenantVO → CacheEnvelope) |
|
||||||
| `fixtures/lock/` | 锁/计数器/token 应被忽略 |
|
| `fixtures/lock/` | 锁/计数器/token 应被忽略 |
|
||||||
| `fixtures/template/` | W04 Template 直写 |
|
| `fixtures/template/` | W04 Template 直写 |
|
||||||
|
| `fixtures/mq/rocket-wallet/` | RocketMQ syncSend(MQ01) |
|
||||||
|
| `fixtures/mq/rocket-im/` | MessageBuilder + asyncSend(MQ04)、convertAndSend(MQ03) |
|
||||||
|
| `fixtures/mq/kafka-patrol/` | Kafka List 根数组(MQ-K01) |
|
||||||
|
| `fixtures/mq/kafka-record/` | ProducerRecord(MQ-K03)、JSON 字符串 send(MQ-K04) |
|
||||||
|
|
||||||
### 11.3 端到端测试
|
### 11.3 端到端测试
|
||||||
|
|
||||||
@@ -605,7 +584,7 @@ notify:
|
|||||||
| 4 | 上线策略 | 先 `notify` ,稳定后手动切 `block` |
|
| 4 | 上线策略 | 先 `notify` ,稳定后手动切 `block` |
|
||||||
| 5 | 检测范围 | **仅 `src/main/java`**,不扫描测试代码 |
|
| 5 | 检测范围 | **仅 `src/main/java`**,不扫描测试代码 |
|
||||||
|
|
||||||
以上决策已纳入实施方案;**Phase 1 / Phase 2 已交付**。缓存侧可进入 Phase 3;MQ 侧以 [`MQ序列化结构检测方案.md`](./MQ序列化结构检测方案.md) 为准评审后开发。
|
以上决策已纳入实施方案;**Phase 1 / Phase 2 已交付**。缓存侧可进入 Phase 3;MQ 侧以 [`MQ序列化结构检测方案.md`](MQ序列化结构检测实施方案.md) 为准评审后开发。
|
||||||
|
|
||||||
相关文档:
|
相关文档:
|
||||||
|
|
||||||
198
docs/工程结构.md
Normal file
198
docs/工程结构.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# 序列化结构检测器 — 工程结构说明
|
||||||
|
|
||||||
|
> 工程:`serialization-schema-checker`(`com.codechecker:serialization-schema-checker`)
|
||||||
|
> 定位:基于 JavaParser 的 **Redis 缓存 / MQ(RocketMQ、Kafka)value 序列化结构变更** 静态检测工具
|
||||||
|
> 当前版本:`1.1.0` · Java 11 · 可执行 Fat Jar(maven-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、Lombok(provided)、JUnit 5(test) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`,便于单测与复用。
|
||||||
85
docs/配置说明.md
85
docs/配置说明.md
@@ -43,8 +43,11 @@ include_modules:
|
|||||||
由 `redisCheck` 仓库维护,随 jar 发布,默认包含:
|
由 `redisCheck` 仓库维护,随 jar 发布,默认包含:
|
||||||
|
|
||||||
- `detection.patterns`:**W01~W05**(JSON 字符串写入 + Template 直写 + Hash)
|
- `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.read_hints_enabled`:W06 读侧反序列化类型辅助(默认 true)
|
||||||
|
- `detection.mq_read_hints_enabled`:MQ-R 读侧 Listener / parse 补强(默认 true)
|
||||||
- `ignore.key_patterns`(锁 / 计数器 / token)
|
- `ignore.key_patterns`(锁 / 计数器 / token)
|
||||||
|
- `ignore.mq_destinations`(MQ destination 忽略,默认真空)
|
||||||
- `detection.min_confidence`、`max_field_depth`
|
- `detection.min_confidence`、`max_field_depth`
|
||||||
- `mode: notify`、`enabled: true`
|
- `mode: notify`、`enabled: true`
|
||||||
|
|
||||||
@@ -97,6 +100,9 @@ ignore:
|
|||||||
# 忽略的写入方法(类全名#方法名)
|
# 忽略的写入方法(类全名#方法名)
|
||||||
writer_methods: []
|
writer_methods: []
|
||||||
|
|
||||||
|
# 忽略的 MQ destination(topic 或 topic:tag,glob)
|
||||||
|
mq_destinations: []
|
||||||
|
|
||||||
# 检测规则
|
# 检测规则
|
||||||
detection:
|
detection:
|
||||||
# 启用的写入模式(默认已全部开启)
|
# 启用的写入模式(默认已全部开启)
|
||||||
@@ -107,9 +113,24 @@ detection:
|
|||||||
- W04 # redisTemplate 直写对象
|
- W04 # redisTemplate 直写对象
|
||||||
- W05 # opsForHash().put
|
- W05 # opsForHash().put
|
||||||
|
|
||||||
|
# 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:读侧反序列化类型辅助(不产生独立告警)
|
# W06:读侧反序列化类型辅助(不产生独立告警)
|
||||||
read_hints_enabled: true
|
read_hints_enabled: true
|
||||||
|
|
||||||
|
# MQ-R 读侧补强(Listener / parseObject)
|
||||||
|
mq_read_hints_enabled: true
|
||||||
|
|
||||||
# 类型推断最低置信度,低于此值标记为低置信度提示
|
# 类型推断最低置信度,低于此值标记为低置信度提示
|
||||||
min_confidence: 0.6
|
min_confidence: 0.6
|
||||||
|
|
||||||
@@ -205,8 +226,12 @@ mode: block
|
|||||||
| W01 | `redisUtil.insert(key, JSON.toJSONString(x), ttl)` | 已启用 |
|
| W01 | `redisUtil.insert(key, JSON.toJSONString(x), ttl)` | 已启用 |
|
||||||
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)` | 已启用 |
|
| W02 | `redisTemplate.opsForValue().set(key, JSON.toJSONString(x), ...)` | 已启用 |
|
||||||
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)` | 已启用 |
|
| W03 | `stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)` | 已启用 |
|
||||||
| W04 | `redisTemplate.opsForValue().set(key, obj, ...)` 直写对象 | 已启用(Phase 2) |
|
| W04 | `redisTemplate.opsForValue().set(key, obj, ...)` 直写对象 | 已启用;**V1.1** 忽略 `String`/数字/布尔等标量,且 value 须为本仓库可解析类型 |
|
||||||
| W05 | `redisTemplate.opsForHash().put(key, field, obj)` | 已启用(Phase 2) |
|
| 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 写入:
|
业务仓可通过只声明子集暂时关闭某些模式,例如仅保留 JSON 写入:
|
||||||
|
|
||||||
@@ -223,7 +248,8 @@ detection:
|
|||||||
|
|
||||||
- **不是写入模式**:不会单独因为「多了一处 parse」而告警
|
- **不是写入模式**:不会单独因为「多了一处 parse」而告警
|
||||||
- 能关联到 `redis get(key)` 时,还可补强 unresolved key
|
- 能关联到 `redis get(key)` 时,还可补强 unresolved key
|
||||||
- 覆盖优先级:`manual_mappings` > W06 > 写侧 AST
|
- 覆盖优先级:`manual_mappings` > W06 > 写侧 AST
|
||||||
|
- **仅补强 Redis 写入点**;MQ 投递点走 `mq_read_hints_enabled`(MQ-R)
|
||||||
|
|
||||||
关闭示例:
|
关闭示例:
|
||||||
|
|
||||||
@@ -232,6 +258,18 @@ detection:
|
|||||||
read_hints_enabled: false
|
read_hints_enabled: false
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3.5.2 detection.mq_patterns / ignore.mq_destinations(MQ)
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `mq_patterns` | RocketMQ:`MQ01`~`MQ05`;Kafka:`MQ-K01`~`MQ-K04`;默认已全部开启 |
|
||||||
|
| `mq_read_hints_enabled` | MQ-R 读侧补强(Listener / parse),**默认 true** |
|
||||||
|
| `ignore.mq_destinations` | 忽略 destination(glob),如 `benchmark:*` |
|
||||||
|
|
||||||
|
企微 MQ 块:`- Topic --> ...`,并带 `> **通道**: RocketMQ|Kafka`。
|
||||||
|
|
||||||
|
详见:[MQ序列化结构检测方案.md](v1.0/MQ序列化结构检测实施方案.md)
|
||||||
|
|
||||||
### 3.6 manual_mappings
|
### 3.6 manual_mappings
|
||||||
|
|
||||||
当自动推断不准确时使用。匹配优先级 **高于** 自动推断(按 `类全名#方法名` 覆盖 key 模式与 value 类型)。
|
当自动推断不准确时使用。匹配优先级 **高于** 自动推断(按 `类全名#方法名` 覆盖 key 模式与 value 类型)。
|
||||||
@@ -294,9 +332,11 @@ suppressions:
|
|||||||
| 字段删除(标在旧骨架) | 橙色 | `<font color="warning">…</font>` |
|
| 字段删除(标在旧骨架) | 橙色 | `<font color="warning">…</font>` |
|
||||||
| 字段新增 / 包装层(标在新骨架) | 绿色 | `<font color="info">…</font>` |
|
| 字段新增 / 包装层(标在新骨架) | 绿色 | `<font color="info">…</font>` |
|
||||||
| 路径迁移 | 旧橙 / 新绿 | 同上 |
|
| 路径迁移 | 旧橙 / 新绿 | 同上 |
|
||||||
| key 未解析提示 | 灰色 | `<font color="comment">(key 未解析)</font>` |
|
| **新增写入点**(整段新骨架) | 绿色 | 整段 `<font color="info">…</font>` |
|
||||||
|
| **删除写入点**(整段旧骨架) | 橙色 | 整段 `<font color="warning">…</font>` |
|
||||||
|
| key 未解析提示 | 灰色 | `<font color="comment">(key 无法解析,建议 manual_mappings 补充)</font>` |
|
||||||
|
|
||||||
### 5.3 示例(已解析 key)
|
### 5.3 示例(字段级变更)
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## [序列化结构变更] jnpf-java-cloud
|
## [序列化结构变更] jnpf-java-cloud
|
||||||
@@ -313,19 +353,36 @@ suppressions:
|
|||||||
> **变更为:** “…(仅新增/迁移字段片段带 <font color="info">绿色</font>)…”
|
> **变更为:** “…(仅新增/迁移字段片段带 <font color="info">绿色</font>)…”
|
||||||
```
|
```
|
||||||
|
|
||||||
实际发送时仅对改动属性片段染色:新增 → `info`(绿),删除 → `warning`(橙)。
|
字段级变更仍对改动属性片段染色:新增 → `info`(绿),删除 → `warning`(橙)。
|
||||||
|
|
||||||
### 5.4 示例(key 未解析)
|
### 5.4 示例(新增写入点 / 新增投递)
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
- Key --> `req.getKey()` <font color="comment">(key 未解析)</font>
|
- 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
|
||||||
|
> **变更类型**: 新增写入点
|
||||||
|
> **首次写入该 Key,value 结构为:** “<font color="info">{…}</font>”
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 示例(key 未解析)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Key --> `req.getKey()` <font color="comment">(key 无法解析,建议 manual_mappings 补充)</font>
|
||||||
> **位置**: `ClockInXxxService#export:128`
|
> **位置**: `ClockInXxxService#export:128`
|
||||||
> **类型**: `List<ClockInExportVo>`
|
> **类型**: `List<ClockInExportVo>`
|
||||||
> **value值由:** “{"a":""}”
|
> **value值由:** “{"a":""}”
|
||||||
> **变更为:** “…新增字段带绿色高亮…”
|
> **变更为:** “…新增字段带绿色高亮…”
|
||||||
```
|
```
|
||||||
|
|
||||||
未解析时按「写入位置 + key 表达式」拆分聚合,避免多个未知 key 串在一起。
|
未解析时按「写入位置 + key 表达式」拆分聚合;表达式过长(>80)会截断。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -344,12 +401,4 @@ notify:
|
|||||||
include_modules: [] # 全仓
|
include_modules: [] # 全仓
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 扩展:MQ 消息体检测(方案阶段)
|
|
||||||
|
|
||||||
MQ(**RocketMQ + Kafka**)消息体 Schema 变更监控方案已单独成文,**实现前不要求业务仓增配**。
|
|
||||||
|
|
||||||
详见:[MQ序列化结构检测方案.md](./MQ序列化结构检测方案.md)
|
|
||||||
|
|
||||||
届时预计新增:`detection.mq_patterns`(含 `MQ01~` 与 `MQ-K01~`)、`detection.mq_read_hints_enabled`、`ignore.mq_destinations`。
|
|
||||||
26
pom.xml
26
pom.xml
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>com.codechecker</groupId>
|
<groupId>com.codechecker</groupId>
|
||||||
<artifactId>serialization-schema-checker</artifactId>
|
<artifactId>serialization-schema-checker</artifactId>
|
||||||
<version>1.0.0</version>
|
<version>1.1.0</version>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<name>serialization-schema-checker</name>
|
<name>serialization-schema-checker</name>
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<jackson.version>2.15.4</jackson.version>
|
<jackson.version>2.15.4</jackson.version>
|
||||||
<picocli.version>4.7.5</picocli.version>
|
<picocli.version>4.7.5</picocli.version>
|
||||||
<junit.version>5.10.2</junit.version>
|
<junit.version>5.10.2</junit.version>
|
||||||
|
<lombok.version>1.18.34</lombok.version>
|
||||||
<maven.shade.version>3.5.1</maven.shade.version>
|
<maven.shade.version>3.5.1</maven.shade.version>
|
||||||
<maven.deploy.plugin.version>3.1.2</maven.deploy.plugin.version>
|
<maven.deploy.plugin.version>3.1.2</maven.deploy.plugin.version>
|
||||||
</properties>
|
</properties>
|
||||||
@@ -71,11 +72,34 @@
|
|||||||
<version>${junit.version}</version>
|
<version>${junit.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<finalName>serialization-schema-checker-${project.version}</finalName>
|
<finalName>serialization-schema-checker-${project.version}</finalName>
|
||||||
<plugins>
|
<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>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-deploy-plugin</artifactId>
|
<artifactId>maven-deploy-plugin</artifactId>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import java.util.stream.Stream;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描工作树中受包含/排除模块限制的 {@code src/main/java} 下的所有 Java 源文件。
|
* 扫描工作树中受包含/排除模块限制的 {@code src/main/java} 下的所有 Java 源文件。
|
||||||
|
* <p>返回路径 → 源码内容映射,供 {@link SchemaCheckAnalyzer} 构建新旧 {@code SourceIndex}。</p>
|
||||||
*/
|
*/
|
||||||
public class FileScanner {
|
public class FileScanner {
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ public final class GlobMatcher {
|
|||||||
private GlobMatcher() {
|
private GlobMatcher() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param glob glob 模式(支持 {@code *} / {@code **})
|
||||||
|
* @param input 待匹配字符串
|
||||||
|
* @return 是否整串匹配
|
||||||
|
*/
|
||||||
public static boolean matches(String glob, String input) {
|
public static boolean matches(String glob, String input) {
|
||||||
if (glob == null || input == null) {
|
if (glob == null || input == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.codechecker.cache.analyze;
|
|||||||
import com.codechecker.cache.config.CheckerConfig;
|
import com.codechecker.cache.config.CheckerConfig;
|
||||||
import com.codechecker.cache.detector.CacheReadHint;
|
import com.codechecker.cache.detector.CacheReadHint;
|
||||||
import com.codechecker.cache.detector.CacheReadHintDetector;
|
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.RedisWritePointDetector;
|
||||||
import com.codechecker.cache.detector.WritePoint;
|
import com.codechecker.cache.detector.WritePoint;
|
||||||
import com.codechecker.cache.diff.ChangeType;
|
import com.codechecker.cache.diff.ChangeType;
|
||||||
@@ -45,27 +47,70 @@ public class SchemaCheckAnalyzer {
|
|||||||
this.repoRoot = repoRoot;
|
this.repoRoot = repoRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比两个提交之间的序列化结构变更。
|
||||||
|
*
|
||||||
|
* @param oldSha 基准提交
|
||||||
|
* @param newSha 当前提交(通常与工作树一致)
|
||||||
|
* @return 检测报告(含字段明细与按 Key/Topic 聚合结果)
|
||||||
|
* @throws GitException git 命令失败时
|
||||||
|
*/
|
||||||
public CheckReport analyze(String oldSha, String newSha) throws GitException {
|
public CheckReport analyze(String oldSha, String newSha) throws GitException {
|
||||||
|
return analyze(oldSha, newSha, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比两个提交之间的序列化结构变更。
|
||||||
|
*
|
||||||
|
* @param oldSha 基准提交
|
||||||
|
* @param newSha 当前提交
|
||||||
|
* @param responsibleAuthor 责任人过滤(如 Gitea actor);非空时仅将「该作者非 merge 提交触及的文件」
|
||||||
|
* 作为检测触发集(他人已在各自 push 告警过的改动不再挂到合并推送人)
|
||||||
|
* @return 检测报告
|
||||||
|
* @throws GitException git 命令失败时
|
||||||
|
*/
|
||||||
|
public CheckReport analyze(String oldSha, String newSha, String responsibleAuthor) throws GitException {
|
||||||
GitDiffScanner scanner = new GitDiffScanner(repoRoot);
|
GitDiffScanner scanner = new GitDiffScanner(repoRoot);
|
||||||
|
|
||||||
List<String> changedRaw = scanner.changedJavaFiles(oldSha, newSha);
|
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());
|
FileScanner fileScanner = new FileScanner(repoRoot, config.getIncludeModules(), config.getExcludeModules());
|
||||||
for (String path : changedRaw) {
|
for (String path : changedRaw) {
|
||||||
if (path.contains("/src/main/java/") && fileScanner.moduleAllowed(path) && !isFileIgnored(path)) {
|
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] 责任人过滤: " + authorFilter
|
||||||
|
+ ",区间 diff 文件 " + allDiffFiles.size()
|
||||||
|
+ " → 责任人触及 " + changedFiles.size());
|
||||||
|
if (changedFiles.isEmpty()) {
|
||||||
|
System.out.println("[serialization-schema-checker] 责任人无非 merge 自有 Java 改动,"
|
||||||
|
+ "跳过结构告警(相关变更应已在原作者 push 时通知)。");
|
||||||
|
return buildReport(oldSha, newSha, Collections.emptyList(), Collections.emptyMap());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前工作树(= newSha 检出)内容
|
// 当前工作树(= newSha 检出)内容
|
||||||
Map<String, String> newContents = fileScanner.scan();
|
Map<String, String> newContents = fileScanner.scan();
|
||||||
|
|
||||||
// 旧版本内容:以工作树为基线,用 old 版本覆盖变更文件
|
// 旧版本内容:必须以「区间全部 diff 文件」回填,避免他人改动的类型在索引中新旧一致导致漏 diff
|
||||||
Map<String, String> oldContents = new LinkedHashMap<>(newContents);
|
Map<String, String> oldContents = new LinkedHashMap<>(newContents);
|
||||||
for (String path : changedFiles) {
|
for (String path : allDiffFiles) {
|
||||||
String oldContent = scanner.fileContentAt(oldSha, path);
|
String oldContent = scanner.fileContentAt(oldSha, path);
|
||||||
if (oldContent == null) {
|
if (oldContent == null) {
|
||||||
oldContents.remove(path); // 新增文件在旧版本不存在
|
oldContents.remove(path);
|
||||||
} else {
|
} else {
|
||||||
oldContents.put(path, oldContent);
|
oldContents.put(path, oldContent);
|
||||||
}
|
}
|
||||||
@@ -92,13 +137,21 @@ public class SchemaCheckAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Set<String> patterns = new HashSet<>(config.getDetection().getPatterns());
|
Set<String> patterns = new HashSet<>(config.getDetection().getPatterns());
|
||||||
|
Set<String> mqPatterns = new HashSet<>(config.getDetection().getMqPatterns());
|
||||||
RedisWritePointDetector detectorNew = new RedisWritePointDetector(newIndex, patterns);
|
RedisWritePointDetector detectorNew = new RedisWritePointDetector(newIndex, patterns);
|
||||||
RedisWritePointDetector detectorOld = new RedisWritePointDetector(oldIndex, 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 extractorNew = new JavaSchemaExtractor(newIndex, config.getDetection().getMaxFieldDepth());
|
||||||
JavaSchemaExtractor extractorOld = new JavaSchemaExtractor(oldIndex, config.getDetection().getMaxFieldDepth());
|
JavaSchemaExtractor extractorOld = new JavaSchemaExtractor(oldIndex, config.getDetection().getMaxFieldDepth());
|
||||||
SchemaDiffer differ = new SchemaDiffer();
|
SchemaDiffer differ = new SchemaDiffer();
|
||||||
SkeletonJsonRenderer skeletonRenderer = new SkeletonJsonRenderer();
|
SkeletonJsonRenderer skeletonRenderer = new SkeletonJsonRenderer();
|
||||||
|
|
||||||
|
List<CacheReadHint> mqHintsNew = collectMqReadHints(newContents, newIndex);
|
||||||
|
List<CacheReadHint> mqHintsOld = collectMqReadHints(oldContents, oldIndex);
|
||||||
|
|
||||||
List<SchemaChange> allChanges = new ArrayList<>();
|
List<SchemaChange> allChanges = new ArrayList<>();
|
||||||
Map<String, KeyStructureChange> keyChanges = new LinkedHashMap<>();
|
Map<String, KeyStructureChange> keyChanges = new LinkedHashMap<>();
|
||||||
|
|
||||||
@@ -110,13 +163,21 @@ public class SchemaCheckAnalyzer {
|
|||||||
boolean fileChanged = changedFiles.contains(path);
|
boolean fileChanged = changedFiles.contains(path);
|
||||||
String oldContent = fileChanged ? oldContents.get(path) : newContent;
|
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
|
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);
|
applyReadHints(newWps, path, newContent, newIndex);
|
||||||
if (oldContent != null) {
|
if (oldContent != null) {
|
||||||
applyReadHints(oldWps, path, oldContent, oldIndex);
|
applyReadHints(oldWps, path, oldContent, oldIndex);
|
||||||
}
|
}
|
||||||
|
applyMqReadHints(newWps, mqHintsNew);
|
||||||
|
applyMqReadHints(oldWps, mqHintsOld);
|
||||||
newWps.forEach(this::applyManualMappings);
|
newWps.forEach(this::applyManualMappings);
|
||||||
oldWps.forEach(this::applyManualMappings);
|
oldWps.forEach(this::applyManualMappings);
|
||||||
|
|
||||||
@@ -128,7 +189,7 @@ public class SchemaCheckAnalyzer {
|
|||||||
|
|
||||||
for (WritePoint nw : newWps) {
|
for (WritePoint nw : newWps) {
|
||||||
newSigs.add(nw.signature());
|
newSigs.add(nw.signature());
|
||||||
if (isKeyIgnored(nw.getResolvedKeyPattern()) || isWriterIgnored(nw)) {
|
if (isWritePointIgnored(nw) || isWriterIgnored(nw)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
WritePoint ow = oldBySig.get(nw.signature());
|
WritePoint ow = oldBySig.get(nw.signature());
|
||||||
@@ -149,11 +210,17 @@ public class SchemaCheckAnalyzer {
|
|||||||
skeletonRenderer.render(newSchema, protectedPaths(changes),
|
skeletonRenderer.render(newSchema, protectedPaths(changes),
|
||||||
SkeletonJsonRenderer.DEFAULT_MAX_LEN));
|
SkeletonJsonRenderer.DEFAULT_MAX_LEN));
|
||||||
} else if (fileChanged) {
|
} 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);
|
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_ADDED);
|
||||||
fillFromWritePoint(c, nw);
|
fillFromWritePoint(c, nw);
|
||||||
c.setMessage("新增缓存写入点,value 类型: " + displayType(nw));
|
c.setMessage(nw.isMq()
|
||||||
|
? "新增 MQ 投递点,value 类型: " + displayType(nw)
|
||||||
|
: "新增缓存写入点,value 类型: " + displayType(nw));
|
||||||
allChanges.add(c);
|
allChanges.add(c);
|
||||||
TypeSchema newSchema = extractorNew.extract(nw.getResolvedValueType(), nw.isRootArray());
|
|
||||||
mergeKeyChange(keyChanges, nw,
|
mergeKeyChange(keyChanges, nw,
|
||||||
Collections.singletonList(c),
|
Collections.singletonList(c),
|
||||||
"",
|
"",
|
||||||
@@ -166,12 +233,17 @@ public class SchemaCheckAnalyzer {
|
|||||||
if (fileChanged) {
|
if (fileChanged) {
|
||||||
for (WritePoint ow : oldWps) {
|
for (WritePoint ow : oldWps) {
|
||||||
if (!newSigs.contains(ow.signature())
|
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);
|
SchemaChange c = new SchemaChange(ChangeType.WRITE_POINT_REMOVED);
|
||||||
fillFromWritePoint(c, ow);
|
fillFromWritePoint(c, ow);
|
||||||
c.setMessage("删除缓存写入点,原 value 类型: " + displayType(ow));
|
c.setMessage(ow.isMq()
|
||||||
|
? "删除 MQ 投递点,原 value 类型: " + displayType(ow)
|
||||||
|
: "删除缓存写入点,原 value 类型: " + displayType(ow));
|
||||||
allChanges.add(c);
|
allChanges.add(c);
|
||||||
TypeSchema oldSchema = extractorOld.extract(ow.getResolvedValueType(), ow.isRootArray());
|
|
||||||
mergeKeyChange(keyChanges, ow,
|
mergeKeyChange(keyChanges, ow,
|
||||||
Collections.singletonList(c),
|
Collections.singletonList(c),
|
||||||
skeletonRenderer.render(oldSchema, protectedPaths(
|
skeletonRenderer.render(oldSchema, protectedPaths(
|
||||||
@@ -198,8 +270,12 @@ public class SchemaCheckAnalyzer {
|
|||||||
n.setWriteLocation(wp.location());
|
n.setWriteLocation(wp.location());
|
||||||
n.setValueType(displayType(wp));
|
n.setValueType(displayType(wp));
|
||||||
n.setKeyUnresolved(isUnresolvedKey(wp.getResolvedKeyPattern()));
|
n.setKeyUnresolved(isUnresolvedKey(wp.getResolvedKeyPattern()));
|
||||||
|
n.setChannel(wp.getChannel());
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
|
if (kc.getChannel() == null || kc.getChannel().isEmpty()) {
|
||||||
|
kc.setChannel(wp.getChannel());
|
||||||
|
}
|
||||||
if (kc.getWriteLocation() == null || kc.getWriteLocation().isEmpty()) {
|
if (kc.getWriteLocation() == null || kc.getWriteLocation().isEmpty()) {
|
||||||
kc.setWriteLocation(wp.location());
|
kc.setWriteLocation(wp.location());
|
||||||
}
|
}
|
||||||
@@ -229,11 +305,12 @@ public class SchemaCheckAnalyzer {
|
|||||||
|
|
||||||
/** 已解析 key 按模式聚合;未解析按「位置+表达式」拆分,避免串单。 */
|
/** 已解析 key 按模式聚合;未解析按「位置+表达式」拆分,避免串单。 */
|
||||||
private String aggregationKey(WritePoint wp) {
|
private String aggregationKey(WritePoint wp) {
|
||||||
|
String channel = wp.getChannel() == null ? WritePoint.CHANNEL_REDIS : wp.getChannel();
|
||||||
String pattern = wp.getResolvedKeyPattern();
|
String pattern = wp.getResolvedKeyPattern();
|
||||||
if (!isUnresolvedKey(pattern)) {
|
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) {
|
private boolean isUnresolvedKey(String keyPattern) {
|
||||||
@@ -333,8 +410,14 @@ public class SchemaCheckAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String changeDedupKey(SchemaChange c) {
|
private String changeDedupKey(SchemaChange c) {
|
||||||
return c.getChangeType() + "|" + c.getKeyPattern() + "|"
|
ChangeType t = c.getChangeType();
|
||||||
+ c.getWriteLocation() + "|" + c.getFieldPath();
|
// 新增/删除投递点:按位置区分
|
||||||
|
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) {
|
private void enrich(List<SchemaChange> changes, WritePoint wp, double confidence) {
|
||||||
@@ -358,9 +441,7 @@ public class SchemaCheckAnalyzer {
|
|||||||
if (isSuppressed(c)) {
|
if (isSuppressed(c)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String dedupKey = c.getChangeType() + "|" + c.getKeyPattern() + "|"
|
if (seen.add(changeDedupKey(c))) {
|
||||||
+ c.getWriteLocation() + "|" + c.getFieldPath();
|
|
||||||
if (seen.add(dedupKey)) {
|
|
||||||
result.add(c);
|
result.add(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,10 +529,39 @@ public class SchemaCheckAnalyzer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (WritePoint wp : writePoints) {
|
for (WritePoint wp : writePoints) {
|
||||||
|
if (wp.isMq()) {
|
||||||
|
continue; // MQ 投递点由 MQ-R 补强
|
||||||
|
}
|
||||||
enrichWritePointFromHints(wp, hints);
|
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) {
|
private void enrichWritePointFromHints(WritePoint wp, List<CacheReadHint> hints) {
|
||||||
boolean needType = wp.getResolvedValueType() == null || wp.getResolvedValueType().isEmpty()
|
boolean needType = wp.getResolvedValueType() == null || wp.getResolvedValueType().isEmpty()
|
||||||
|| wp.getConfidence() < config.getDetection().getMinConfidence();
|
|| wp.getConfidence() < config.getDetection().getMinConfidence();
|
||||||
@@ -555,6 +665,13 @@ public class SchemaCheckAnalyzer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isWritePointIgnored(WritePoint wp) {
|
||||||
|
if (wp.isMq()) {
|
||||||
|
return isMqDestinationIgnored(wp.getResolvedKeyPattern());
|
||||||
|
}
|
||||||
|
return isKeyIgnored(wp.getResolvedKeyPattern());
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isKeyIgnored(String keyPattern) {
|
private boolean isKeyIgnored(String keyPattern) {
|
||||||
if (keyPattern == null) {
|
if (keyPattern == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -567,6 +684,18 @@ public class SchemaCheckAnalyzer {
|
|||||||
return false;
|
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) {
|
private boolean isWriterIgnored(WritePoint wp) {
|
||||||
String sig = wp.getEnclosingClass() + "#" + wp.getEnclosingMethod();
|
String sig = wp.getEnclosingClass() + "#" + wp.getEnclosingMethod();
|
||||||
return config.getIgnore().getWriterMethods().contains(sig);
|
return config.getIgnore().getWriterMethods().contains(sig);
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import java.util.List;
|
|||||||
import java.util.concurrent.Callable;
|
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 = "serialization-schema-checker",
|
@Command(name = "serialization-schema-checker",
|
||||||
mixinStandardHelpOptions = true,
|
mixinStandardHelpOptions = true,
|
||||||
@@ -38,7 +40,7 @@ public class SerializationSchemaCheckerMain implements Callable<Integer> {
|
|||||||
@Option(names = "--branch", description = "分支名(用于报告展示)")
|
@Option(names = "--branch", description = "分支名(用于报告展示)")
|
||||||
private String branch;
|
private String branch;
|
||||||
|
|
||||||
@Option(names = "--modifier", description = "提交人")
|
@Option(names = "--modifier", description = "责任人/推送人(报告展示;建议与 --responsible-author 一致)")
|
||||||
private String modifier;
|
private String modifier;
|
||||||
|
|
||||||
@Option(names = "--modify-time", description = "提交时间")
|
@Option(names = "--modify-time", description = "提交时间")
|
||||||
@@ -50,6 +52,10 @@ public class SerializationSchemaCheckerMain implements Callable<Integer> {
|
|||||||
@Option(names = "--dry-run", description = "只输出报告,不发送企微通知")
|
@Option(names = "--dry-run", description = "只输出报告,不发送企微通知")
|
||||||
private boolean dryRun;
|
private boolean dryRun;
|
||||||
|
|
||||||
|
@Option(names = "--responsible-author",
|
||||||
|
description = "责任人过滤:仅检测该作者非 merge 提交触及的 Java 文件(通常传 Gitea actor)")
|
||||||
|
private String responsibleAuthor;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Integer call() {
|
public Integer call() {
|
||||||
try {
|
try {
|
||||||
@@ -67,10 +73,13 @@ public class SerializationSchemaCheckerMain implements Callable<Integer> {
|
|||||||
|
|
||||||
Path root = repoRoot.toAbsolutePath().normalize();
|
Path root = repoRoot.toAbsolutePath().normalize();
|
||||||
SchemaCheckAnalyzer analyzer = new SchemaCheckAnalyzer(config, root);
|
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.setBranch(branch);
|
||||||
report.setModifier(modifier);
|
report.setModifier(modifier != null ? modifier : author);
|
||||||
report.setModifyTime(modifyTime);
|
report.setModifyTime(modifyTime);
|
||||||
report.setRepository(repository != null ? repository : root.getFileName().toString());
|
report.setRepository(repository != null ? repository : root.getFileName().toString());
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.codechecker.cache.config;
|
package com.codechecker.cache.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -8,337 +10,123 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* 检测器运行配置。由 {@link ConfigLoader} 加载 jar 内默认配置并与业务配置深度合并后构建。
|
* 检测器运行配置。由 {@link ConfigLoader} 加载 jar 内默认配置并与业务配置深度合并后构建。
|
||||||
*/
|
*/
|
||||||
|
@Data
|
||||||
public class CheckerConfig {
|
public class CheckerConfig {
|
||||||
|
|
||||||
/** 总开关:false 时跳过检测与通知(流水线 exit 0) */
|
/** 总开关:false 时跳过检测与通知(流水线 exit 0) */
|
||||||
private boolean enabled = true;
|
private boolean enabled = true;
|
||||||
|
|
||||||
/** notify | block */
|
/** 运行模式:notify(仅通知)| block(有变更则失败退出) */
|
||||||
private String mode = "notify";
|
private String mode = "notify";
|
||||||
|
|
||||||
|
/** 是否扫描 test 源码目录 */
|
||||||
private boolean scanTestSources = false;
|
private boolean scanTestSources = false;
|
||||||
|
|
||||||
|
/** 额外源码根路径(相对仓库根);空则按约定扫描 main */
|
||||||
private List<String> sourceRoots = new ArrayList<>();
|
private List<String> sourceRoots = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 企微通知配置 */
|
||||||
private Notify notify = new Notify();
|
private Notify notify = new Notify();
|
||||||
|
|
||||||
|
/** 忽略规则(key / 文件 / 写入方法 / MQ destination) */
|
||||||
private Ignore ignore = new Ignore();
|
private Ignore ignore = new Ignore();
|
||||||
|
|
||||||
|
/** 检测模式与推断参数 */
|
||||||
private Detection detection = new Detection();
|
private Detection detection = new Detection();
|
||||||
|
|
||||||
|
/** 按 ChangeType 名覆盖默认严重级别,如 FIELD_ADDED → P0 */
|
||||||
private Map<String, String> severityOverrides = new LinkedHashMap<>();
|
private Map<String, String> severityOverrides = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/** 人工写入点映射(自动推断不准时补齐) */
|
||||||
private List<ManualMapping> manualMappings = new ArrayList<>();
|
private List<ManualMapping> manualMappings = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 已知误报抑制规则 */
|
||||||
private List<Suppression> suppressions = new ArrayList<>();
|
private List<Suppression> suppressions = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 仅检测这些模块(路径前缀/模块名);空表示不限制 */
|
||||||
private List<String> includeModules = new ArrayList<>();
|
private List<String> includeModules = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 排除这些模块,不参与检测 */
|
||||||
private List<String> excludeModules = 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;
|
|
||||||
/** W06:是否启用读侧反序列化类型辅助补强 */
|
|
||||||
private boolean readHintsEnabled = true;
|
|
||||||
|
|
||||||
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 boolean isReadHintsEnabled() {
|
|
||||||
return readHintsEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setReadHintsEnabled(boolean readHintsEnabled) {
|
|
||||||
this.readHintsEnabled = readHintsEnabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
public boolean isBlockMode() {
|
||||||
return "block".equalsIgnoreCase(mode);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public final class ConfigLoader {
|
|||||||
private ConfigLoader() {
|
private ConfigLoader() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载并合并配置。
|
||||||
|
*
|
||||||
|
* @param businessConfigPath 业务仓 YAML;为 {@code null} 或不存在时仅使用 jar 内默认配置
|
||||||
|
* @return 绑定后的运行配置
|
||||||
|
*/
|
||||||
public static CheckerConfig load(Path businessConfigPath) {
|
public static CheckerConfig load(Path businessConfigPath) {
|
||||||
Map<String, Object> merged = loadDefault();
|
Map<String, Object> merged = loadDefault();
|
||||||
if (businessConfigPath != null && Files.exists(businessConfigPath)) {
|
if (businessConfigPath != null && Files.exists(businessConfigPath)) {
|
||||||
@@ -94,13 +100,16 @@ public final class ConfigLoader {
|
|||||||
ig.setKeyPatterns(strList(ignore.get("key_patterns")));
|
ig.setKeyPatterns(strList(ignore.get("key_patterns")));
|
||||||
ig.setFilePatterns(strList(ignore.get("file_patterns")));
|
ig.setFilePatterns(strList(ignore.get("file_patterns")));
|
||||||
ig.setWriterMethods(strList(ignore.get("writer_methods")));
|
ig.setWriterMethods(strList(ignore.get("writer_methods")));
|
||||||
|
ig.setMqDestinations(strList(ignore.get("mq_destinations")));
|
||||||
|
|
||||||
Map<String, Object> detection = asMap(map.get("detection"));
|
Map<String, Object> detection = asMap(map.get("detection"));
|
||||||
CheckerConfig.Detection d = config.getDetection();
|
CheckerConfig.Detection d = config.getDetection();
|
||||||
d.setPatterns(strList(detection.get("patterns")));
|
d.setPatterns(strList(detection.get("patterns")));
|
||||||
|
d.setMqPatterns(strList(detection.get("mq_patterns")));
|
||||||
d.setMinConfidence(dbl(detection, "min_confidence", 0.6));
|
d.setMinConfidence(dbl(detection, "min_confidence", 0.6));
|
||||||
d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8));
|
d.setMaxFieldDepth((int) lng(detection, "max_field_depth", 8));
|
||||||
d.setReadHintsEnabled(bool(detection, "read_hints_enabled", true));
|
d.setReadHintsEnabled(bool(detection, "read_hints_enabled", true));
|
||||||
|
d.setMqReadHintsEnabled(bool(detection, "mq_read_hints_enabled", true));
|
||||||
|
|
||||||
Map<String, Object> severityOverrides = asMap(map.get("severity_overrides"));
|
Map<String, Object> severityOverrides = asMap(map.get("severity_overrides"));
|
||||||
Map<String, String> so = new LinkedHashMap<>();
|
Map<String, String> so = new LinkedHashMap<>();
|
||||||
|
|||||||
71
src/main/java/com/codechecker/cache/detector/BareValueTypes.java
vendored
Normal file
71
src/main/java/com/codechecker/cache/detector/BareValueTypes.java
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,92 +1,30 @@
|
|||||||
package com.codechecker.cache.detector;
|
package com.codechecker.cache.detector;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读侧反序列化类型提示(W06):从 parseObject / getJsonToBean 等推断「缓存 value 被当成什么类型用」。
|
* 读侧反序列化类型提示(W06):从 parseObject / getJsonToBean 等推断「缓存 value 被当成什么类型用」。
|
||||||
* 不单独产生告警,仅用于补强同文件/同 key 写入点的 value 类型。
|
* 不单独产生告警,仅用于补强同文件/同 key 写入点的 value 类型。
|
||||||
*/
|
*/
|
||||||
|
@Data
|
||||||
public class CacheReadHint {
|
public class CacheReadHint {
|
||||||
|
|
||||||
|
/** 源文件相对路径 */
|
||||||
private String filePath;
|
private String filePath;
|
||||||
|
/** 反序列化调用所在行号 */
|
||||||
private int lineNumber;
|
private int lineNumber;
|
||||||
|
/** 所在类 FQN */
|
||||||
private String enclosingClass;
|
private String enclosingClass;
|
||||||
|
/** 所在方法名 */
|
||||||
private String enclosingMethod;
|
private String enclosingMethod;
|
||||||
/** 推断出的 key 模式;无法关联 redis get 时为 null */
|
/** 推断出的 key 模式;无法关联 redis get 时为 null */
|
||||||
private String resolvedKeyPattern;
|
private String resolvedKeyPattern;
|
||||||
|
/** 源码中的 key 表达式原文 */
|
||||||
private String keyExpression;
|
private String keyExpression;
|
||||||
/** value 元素/对象 FQN */
|
/** value 元素/对象 FQN */
|
||||||
private String resolvedValueType;
|
private String resolvedValueType;
|
||||||
|
/** 是否按数组根(parseArray / getJsonToList 等)理解 value */
|
||||||
private boolean rootArray;
|
private boolean rootArray;
|
||||||
|
/** 提示置信度:仅有类型约 0.7,关联到 redis get 约 0.85 */
|
||||||
private double confidence = 0.7;
|
private double confidence = 0.7;
|
||||||
|
|
||||||
public String getFilePath() {
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setFilePath(String filePath) {
|
|
||||||
this.filePath = filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getLineNumber() {
|
|
||||||
return lineNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setLineNumber(int lineNumber) {
|
|
||||||
this.lineNumber = lineNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getEnclosingClass() {
|
|
||||||
return enclosingClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setEnclosingClass(String enclosingClass) {
|
|
||||||
this.enclosingClass = enclosingClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getEnclosingMethod() {
|
|
||||||
return enclosingMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setEnclosingMethod(String enclosingMethod) {
|
|
||||||
this.enclosingMethod = enclosingMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getResolvedKeyPattern() {
|
|
||||||
return resolvedKeyPattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setResolvedKeyPattern(String resolvedKeyPattern) {
|
|
||||||
this.resolvedKeyPattern = resolvedKeyPattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getKeyExpression() {
|
|
||||||
return keyExpression;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setKeyExpression(String keyExpression) {
|
|
||||||
this.keyExpression = keyExpression;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getResolvedValueType() {
|
|
||||||
return resolvedValueType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setResolvedValueType(String resolvedValueType) {
|
|
||||||
this.resolvedValueType = resolvedValueType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isRootArray() {
|
|
||||||
return rootArray;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setRootArray(boolean rootArray) {
|
|
||||||
this.rootArray = rootArray;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getConfidence() {
|
|
||||||
return confidence;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setConfidence(double confidence) {
|
|
||||||
this.confidence = confidence;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ public class CacheReadHintDetector {
|
|||||||
"parseObject", "parse", "getJsonToBean", "toJavaObject", "readValue"));
|
"parseObject", "parse", "getJsonToBean", "toJavaObject", "readValue"));
|
||||||
private static final Set<String> ARRAY_PARSE = new HashSet<>(Arrays.asList(
|
private static final Set<String> ARRAY_PARSE = new HashSet<>(Arrays.asList(
|
||||||
"parseArray", "getJsonToList", "parseArrayObject"));
|
"parseArray", "getJsonToList", "parseArrayObject"));
|
||||||
private static final Set<String> REDIS_GET = new HashSet<>(Arrays.asList(
|
|
||||||
"get", "getString", "opsForValue"));
|
|
||||||
|
|
||||||
private final SourceIndex index;
|
private final SourceIndex index;
|
||||||
private final RedisKeyResolver keyResolver;
|
private final RedisKeyResolver keyResolver;
|
||||||
@@ -48,6 +46,13 @@ public class CacheReadHintDetector {
|
|||||||
this.keyResolver = new RedisKeyResolver(index);
|
this.keyResolver = new RedisKeyResolver(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描读侧反序列化调用,提取可用于补强写入点的类型提示。
|
||||||
|
*
|
||||||
|
* @param filePath 仓库内相对路径
|
||||||
|
* @param content 源码全文
|
||||||
|
* @return 提示列表(不单独告警)
|
||||||
|
*/
|
||||||
public List<CacheReadHint> detect(String filePath, String content) {
|
public List<CacheReadHint> detect(String filePath, String content) {
|
||||||
List<CacheReadHint> result = new ArrayList<>();
|
List<CacheReadHint> result = new ArrayList<>();
|
||||||
if (content == null || content.isEmpty()) {
|
if (content == null || content.isEmpty()) {
|
||||||
@@ -69,6 +74,13 @@ public class CacheReadHintDetector {
|
|||||||
return result;
|
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) {
|
private CacheReadHint tryParseHint(MethodCallExpr mce, String filePath) {
|
||||||
String name = mce.getNameAsString();
|
String name = mce.getNameAsString();
|
||||||
boolean array = ARRAY_PARSE.contains(name);
|
boolean array = ARRAY_PARSE.contains(name);
|
||||||
@@ -98,6 +110,7 @@ public class CacheReadHintDetector {
|
|||||||
: enclosing.getFullyQualifiedName().orElse(enclosing.getNameAsString());
|
: enclosing.getFullyQualifiedName().orElse(enclosing.getNameAsString());
|
||||||
SourceIndex.IndexedType context = index.get(enclosingFqn);
|
SourceIndex.IndexedType context = index.get(enclosingFqn);
|
||||||
|
|
||||||
|
// 优先带包前缀解析(如 com.foo.Bar.class),失败再退回简单类名 + import/同包
|
||||||
String fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameWithScope(), context);
|
String fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameWithScope(), context);
|
||||||
if (fqn == null) {
|
if (fqn == null) {
|
||||||
fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameAsString(), context);
|
fqn = index.resolveFqn(((ClassOrInterfaceType) type).getNameAsString(), context);
|
||||||
@@ -116,12 +129,12 @@ public class CacheReadHintDetector {
|
|||||||
hint.setRootArray(array || "parseArray".equals(name) || "getJsonToList".equals(name));
|
hint.setRootArray(array || "parseArray".equals(name) || "getJsonToList".equals(name));
|
||||||
hint.setConfidence(0.7);
|
hint.setConfidence(0.7);
|
||||||
|
|
||||||
|
// 第 1 参:原始 JSON 字符串;能关联到 redis.get(key) 时补 key,便于与写入点按 key 匹配
|
||||||
Expression rawExpr = mce.getArgument(0);
|
Expression rawExpr = mce.getArgument(0);
|
||||||
Optional<RedisGetRef> getRef = findRedisGetForVar(rawExpr, mce);
|
Optional<RedisGetRef> getRef = findRedisGetForVar(rawExpr, mce);
|
||||||
if (getRef.isPresent()) {
|
if (getRef.isPresent()) {
|
||||||
hint.setKeyExpression(getRef.get().keyExpr.toString());
|
hint.setKeyExpression(getRef.get().keyExpr.toString());
|
||||||
hint.setResolvedKeyPattern(keyResolver.resolve(
|
hint.setResolvedKeyPattern(keyResolver.resolve(getRef.get().keyExpr, enclosing, context));
|
||||||
getRef.get().keyExpr, enclosing, context));
|
|
||||||
hint.setConfidence(0.85);
|
hint.setConfidence(0.85);
|
||||||
}
|
}
|
||||||
return hint;
|
return hint;
|
||||||
@@ -155,27 +168,30 @@ public class CacheReadHintDetector {
|
|||||||
return Optional.empty();
|
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) {
|
private Optional<RedisGetRef> extractGetKey(MethodCallExpr call) {
|
||||||
String name = call.getNameAsString();
|
String name = call.getNameAsString();
|
||||||
|
// 接收者字符串小写后做子串匹配,避免绑定具体类型
|
||||||
String scope = call.getScope().map(Expression::toString).orElse("").toLowerCase(Locale.ROOT);
|
String scope = call.getScope().map(Expression::toString).orElse("").toLowerCase(Locale.ROOT);
|
||||||
boolean redisScope = scope.contains("redis") || scope.contains("opsforvalue")
|
boolean redisScope = scope.contains("redis") || scope.contains("opsforvalue") || scope.contains("boundvalueops");
|
||||||
|| scope.contains("boundvalueops");
|
|
||||||
if ("get".equals(name) || "getString".equals(name)) {
|
if ("get".equals(name) || "getString".equals(name)) {
|
||||||
if (!redisScope && !REDIS_GET.contains(name)) {
|
// 非 Redis scope:仅放行 getString(RedisUtil 习惯);裸 get(如 map.get)丢弃
|
||||||
// getString 也常见于 RedisUtil
|
if (!redisScope && !"getString".equals(name)) {
|
||||||
if (!"getString".equals(name)) {
|
return Optional.empty();
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (call.getArguments().isEmpty()) {
|
if (call.getArguments().isEmpty()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
return Optional.of(new RedisGetRef(call.getArgument(0)));
|
return Optional.of(new RedisGetRef(call.getArgument(0)));
|
||||||
}
|
}
|
||||||
// redisTemplate.opsForValue().get(key)
|
|
||||||
if ("get".equals(name) && scope.contains("opsforvalue") && !call.getArguments().isEmpty()) {
|
|
||||||
return Optional.of(new RedisGetRef(call.getArgument(0)));
|
|
||||||
}
|
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
372
src/main/java/com/codechecker/cache/detector/MqReadHintDetector.java
vendored
Normal file
372
src/main/java/com/codechecker/cache/detector/MqReadHintDetector.java
vendored
Normal 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-R03:Listener 内 {@code parseObject/parseArray(..., Xxx.class)}</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class MqReadHintDetector {
|
||||||
|
|
||||||
|
private static final Set<String> OBJECT_PARSE = new HashSet<>(Arrays.asList(
|
||||||
|
"parseObject", "parse", "getJsonToBean", "toJavaObject", "readValue"));
|
||||||
|
private static final Set<String> ARRAY_PARSE = new HashSet<>(Arrays.asList(
|
||||||
|
"parseArray", "getJsonToList", "parseArrayObject"));
|
||||||
|
private static final Set<String> SKIP_PARAM_TYPES = new HashSet<>(Arrays.asList(
|
||||||
|
"String", "byte", "Byte", "ConsumerRecord", "MessageExt", "Message"));
|
||||||
|
|
||||||
|
private final SourceIndex index;
|
||||||
|
private final RedisKeyResolver keyResolver;
|
||||||
|
|
||||||
|
public MqReadHintDetector(SourceIndex index) {
|
||||||
|
this.index = index;
|
||||||
|
this.keyResolver = new RedisKeyResolver(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
589
src/main/java/com/codechecker/cache/detector/MqWritePointDetector.java
vendored
Normal file
589
src/main/java/com/codechecker/cache/detector/MqWritePointDetector.java
vendored
Normal 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>M1:MQ01/MQ02、MQ-K01/MQ-K02</li>
|
||||||
|
* <li>M2:MQ03 convertAndSend、MQ04 MessageBuilder/`Message<T>`、MQ05 JSON 字符串、
|
||||||
|
* MQ-K03 ProducerRecord、MQ-K04 JSON 字符串</li>
|
||||||
|
* </ul>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 payload:Message/MessageBuilder(MQ04)、JSON 字符串(MQ05/MQ-K04)、直传对象。
|
||||||
|
* 无法解析的 Message/纯 String 返回 null(忽略)。
|
||||||
|
*/
|
||||||
|
private PayloadResolution resolvePayload(Expression payloadArg, MethodCallExpr mce,
|
||||||
|
String messagePattern, String jsonPattern) {
|
||||||
|
if (payloadArg == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// MQ04:Message<T> 泛型 / MessageBuilder.withPayload
|
||||||
|
if (messagePattern != null && enabledPatterns.contains(messagePattern)) {
|
||||||
|
Expression fromMessage = unwrapMessagePayload(payloadArg, mce);
|
||||||
|
if (fromMessage != null) {
|
||||||
|
return new PayloadResolution(fromMessage, messagePattern);
|
||||||
|
}
|
||||||
|
if (isEnvelopeBare(payloadArg, mce)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else if (isEnvelopeBare(payloadArg, mce)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内联 JSON 序列化
|
||||||
|
Expression serialized = unwrapSerializer(payloadArg);
|
||||||
|
if (serialized != null) {
|
||||||
|
if (jsonPattern != null && enabledPatterns.contains(jsonPattern)) {
|
||||||
|
return new PayloadResolution(serialized, jsonPattern);
|
||||||
|
}
|
||||||
|
return new PayloadResolution(serialized, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 局部 String = toJSONString(...)
|
||||||
|
if (jsonPattern != null && enabledPatterns.contains(jsonPattern)) {
|
||||||
|
Expression fromLocalJson = unwrapJsonStringVar(payloadArg, mce);
|
||||||
|
if (fromLocalJson != null) {
|
||||||
|
return new PayloadResolution(fromLocalJson, jsonPattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTrivialValue(payloadArg) || isBareStringOrBytesPayload(payloadArg, mce)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new PayloadResolution(payloadArg, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression unwrapMessagePayload(Expression payloadArg, MethodCallExpr mce) {
|
||||||
|
Expression fromBuilder = unwrapMessageBuilderPayload(payloadArg, mce);
|
||||||
|
if (fromBuilder != null) {
|
||||||
|
return fromBuilder;
|
||||||
|
}
|
||||||
|
// Message<DutyImNotice> → DutyImNotice(无 withPayload 也可)
|
||||||
|
if (payloadArg instanceof NameExpr) {
|
||||||
|
Type declared = findVariableType(((NameExpr) payloadArg).getNameAsString(), mce);
|
||||||
|
if (declared instanceof ClassOrInterfaceType) {
|
||||||
|
ClassOrInterfaceType cit = (ClassOrInterfaceType) declared;
|
||||||
|
if (ENVELOPE_TYPES.contains(cit.getNameAsString())) {
|
||||||
|
Optional<Type> payloadType = cit.getTypeArguments()
|
||||||
|
.filter(a -> !a.isEmpty())
|
||||||
|
.map(a -> a.get(a.size() - 1));
|
||||||
|
if (payloadType.isPresent() && payloadType.get() instanceof ClassOrInterfaceType) {
|
||||||
|
// 用伪 ObjectCreation 表达类型不便;改为在 infer 前用 NameExpr 不够
|
||||||
|
// 返回一个标记:借助 CastExpr 包装类型信息
|
||||||
|
return new CastExpr(payloadType.get(), payloadArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression unwrapMessageBuilderPayload(Expression payloadArg, MethodCallExpr mce) {
|
||||||
|
Expression chain = payloadArg;
|
||||||
|
if (payloadArg instanceof NameExpr) {
|
||||||
|
String name = ((NameExpr) payloadArg).getNameAsString();
|
||||||
|
Optional<CallableDeclaration> callable = mce.findAncestor(CallableDeclaration.class);
|
||||||
|
if (callable.isPresent()) {
|
||||||
|
for (VariableDeclarator var : callable.get().findAll(VariableDeclarator.class)) {
|
||||||
|
if (var.getNameAsString().equals(name) && var.getInitializer().isPresent()) {
|
||||||
|
chain = var.getInitializer().get();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findWithPayloadArg(chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression findWithPayloadArg(Expression expr) {
|
||||||
|
Expression current = expr;
|
||||||
|
int guard = 0;
|
||||||
|
while (current instanceof MethodCallExpr && guard++ < 16) {
|
||||||
|
MethodCallExpr call = (MethodCallExpr) current;
|
||||||
|
if ("withPayload".equals(call.getNameAsString()) && !call.getArguments().isEmpty()) {
|
||||||
|
return call.getArgument(0);
|
||||||
|
}
|
||||||
|
if (!call.getScope().isPresent()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = call.getScope().get();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression unwrapJsonStringVar(Expression payloadArg, MethodCallExpr mce) {
|
||||||
|
if (!(payloadArg instanceof NameExpr)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String name = ((NameExpr) payloadArg).getNameAsString();
|
||||||
|
Type declared = findVariableType(name, mce);
|
||||||
|
if (declared != null) {
|
||||||
|
String simple = declared.isClassOrInterfaceType()
|
||||||
|
? declared.asClassOrInterfaceType().getNameAsString()
|
||||||
|
: declared.asString();
|
||||||
|
if (!"String".equals(simple)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Optional<CallableDeclaration> callable = mce.findAncestor(CallableDeclaration.class);
|
||||||
|
if (!callable.isPresent()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (VariableDeclarator var : callable.get().findAll(VariableDeclarator.class)) {
|
||||||
|
if (!var.getNameAsString().equals(name) || !var.getInitializer().isPresent()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return unwrapSerializer(var.getInitializer().get());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isEnvelopeBare(Expression typeExpr, MethodCallExpr contextCall) {
|
||||||
|
if (!(typeExpr instanceof NameExpr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Type declared = findVariableType(((NameExpr) typeExpr).getNameAsString(), contextCall);
|
||||||
|
if (declared == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String simple = declared.isClassOrInterfaceType()
|
||||||
|
? declared.asClassOrInterfaceType().getNameAsString()
|
||||||
|
: declared.asString();
|
||||||
|
return ENVELOPE_TYPES.contains(simple);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRocketMqScope(MethodCallExpr mce) {
|
||||||
|
String scope = mce.getScope().map(Expression::toString).orElse("").toLowerCase();
|
||||||
|
return scope.contains("rocketmqtemplate") || scope.contains("rocketmq");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isKafkaScope(MethodCallExpr mce) {
|
||||||
|
String scope = mce.getScope().map(Expression::toString).orElse("").toLowerCase();
|
||||||
|
return scope.contains("kafkatemplate") || "kafkatemplate".equals(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isBareStringOrBytesPayload(Expression typeExpr, MethodCallExpr contextCall) {
|
||||||
|
if (typeExpr instanceof StringLiteralExpr) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
|||||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||||
import com.github.javaparser.ast.body.Parameter;
|
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.body.VariableDeclarator;
|
||||||
import com.github.javaparser.ast.expr.BinaryExpr;
|
import com.github.javaparser.ast.expr.BinaryExpr;
|
||||||
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
|
import com.github.javaparser.ast.expr.BooleanLiteralExpr;
|
||||||
@@ -34,6 +33,8 @@ import java.util.Set;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从单个 Java 源文件中检测 Redis value 写入点(W01~W05)。
|
* 从单个 Java 源文件中检测 Redis value 写入点(W01~W05)。
|
||||||
|
* <p>
|
||||||
|
* V1.1 收敛:忽略无结构标量;局部 JSON 字符串 unwrap;W04/W05 仅保留本仓库可展开业务类型。
|
||||||
*/
|
*/
|
||||||
public class RedisWritePointDetector {
|
public class RedisWritePointDetector {
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ public class RedisWritePointDetector {
|
|||||||
"randomUUID", "toString", "valueOf"));
|
"randomUUID", "toString", "valueOf"));
|
||||||
private static final Set<String> COLLECTION_SIMPLE = new HashSet<>(Arrays.asList(
|
private static final Set<String> COLLECTION_SIMPLE = new HashSet<>(Arrays.asList(
|
||||||
"List", "ArrayList", "LinkedList", "Set", "HashSet", "Collection"));
|
"List", "ArrayList", "LinkedList", "Set", "HashSet", "Collection"));
|
||||||
|
private static final Set<String> DIRECT_OBJECT_PATTERNS = new HashSet<>(Arrays.asList("W04", "W05"));
|
||||||
|
|
||||||
private final SourceIndex index;
|
private final SourceIndex index;
|
||||||
private final Set<String> enabledPatterns;
|
private final Set<String> enabledPatterns;
|
||||||
@@ -59,6 +61,13 @@ public class RedisWritePointDetector {
|
|||||||
this.keyResolver = new RedisKeyResolver(index);
|
this.keyResolver = new RedisKeyResolver(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描单个 Java 源文件中的 Redis 写入点。
|
||||||
|
*
|
||||||
|
* @param filePath 仓库内相对路径(仅写入报告)
|
||||||
|
* @param content 源码全文
|
||||||
|
* @return 命中的写入点列表(可能为空)
|
||||||
|
*/
|
||||||
public List<WritePoint> detect(String filePath, String content) {
|
public List<WritePoint> detect(String filePath, String content) {
|
||||||
List<WritePoint> result = new ArrayList<>();
|
List<WritePoint> result = new ArrayList<>();
|
||||||
if (content == null || content.isEmpty()) {
|
if (content == null || content.isEmpty()) {
|
||||||
@@ -97,12 +106,24 @@ public class RedisWritePointDetector {
|
|||||||
|
|
||||||
Expression valueArg = mce.getArgument(1);
|
Expression valueArg = mce.getArgument(1);
|
||||||
Expression serialized = unwrapSerializer(valueArg);
|
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;
|
String pattern;
|
||||||
Expression typeExpr;
|
Expression typeExpr;
|
||||||
if (serialized != null) {
|
if (serialized != null) {
|
||||||
pattern = classifySerialized(method, valueArg);
|
pattern = classifySerialized(method, serializerName);
|
||||||
typeExpr = serialized;
|
typeExpr = serialized;
|
||||||
} else if (enabledPatterns.contains("W04") && !isTrivialValue(valueArg)) {
|
} else if (enabledPatterns.contains("W04")
|
||||||
|
&& !isTrivialValue(valueArg)
|
||||||
|
&& !isBareScalarTypeExpr(valueArg, mce)) {
|
||||||
pattern = "W04";
|
pattern = "W04";
|
||||||
typeExpr = valueArg;
|
typeExpr = valueArg;
|
||||||
} else {
|
} else {
|
||||||
@@ -111,6 +132,9 @@ public class RedisWritePointDetector {
|
|||||||
if (!enabledPatterns.contains(pattern)) {
|
if (!enabledPatterns.contains(pattern)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (isBareScalarTypeExpr(typeExpr, mce)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return buildWritePoint(mce, filePath, pattern, mce.getArgument(0), valueArg, typeExpr);
|
return buildWritePoint(mce, filePath, pattern, mce.getArgument(0), valueArg, typeExpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +150,13 @@ public class RedisWritePointDetector {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
Expression serialized = unwrapSerializer(valueArg);
|
Expression serialized = unwrapSerializer(valueArg);
|
||||||
|
if (serialized == null) {
|
||||||
|
serialized = unwrapJsonStringVar(valueArg, mce);
|
||||||
|
}
|
||||||
Expression typeExpr = serialized != null ? serialized : valueArg;
|
Expression typeExpr = serialized != null ? serialized : valueArg;
|
||||||
|
if (isBareScalarTypeExpr(typeExpr, mce)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return buildWritePoint(mce, filePath, "W05", mce.getArgument(0), valueArg, typeExpr);
|
return buildWritePoint(mce, filePath, "W05", mce.getArgument(0), valueArg, typeExpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,9 +182,23 @@ public class RedisWritePointDetector {
|
|||||||
} else {
|
} else {
|
||||||
wp.setConfidence(0.4);
|
wp.setConfidence(0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (DIRECT_OBJECT_PATTERNS.contains(pattern) && !hasResolvableBusinessType(inferred)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return wp;
|
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) {
|
private boolean isValueOpsScope(MethodCallExpr mce) {
|
||||||
String scope = mce.getScope().map(Expression::toString).orElse("");
|
String scope = mce.getScope().map(Expression::toString).orElse("");
|
||||||
String lower = scope.toLowerCase();
|
String lower = scope.toLowerCase();
|
||||||
@@ -192,9 +236,36 @@ public class RedisWritePointDetector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (expr instanceof ObjectCreationExpr) {
|
if (expr instanceof ObjectCreationExpr) {
|
||||||
ObjectCreationExpr oce = (ObjectCreationExpr) expr;
|
return "UUID".equals(((ObjectCreationExpr) expr).getType().getNameAsString());
|
||||||
String typeName = oce.getType().getNameAsString();
|
}
|
||||||
return "UUID".equals(typeName);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -209,9 +280,52 @@ public class RedisWritePointDetector {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String classifySerialized(String method, Expression valueArg) {
|
/** 局部 {@code String json = toJSONString(x)} 再写入:回溯到 {@code x}。 */
|
||||||
String serializer = valueArg instanceof MethodCallExpr
|
private Expression unwrapJsonStringVar(Expression valueArg, MethodCallExpr mce) {
|
||||||
? ((MethodCallExpr) valueArg).getNameAsString() : "";
|
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)) {
|
if ("insert".equals(method)) {
|
||||||
return "W01";
|
return "W01";
|
||||||
}
|
}
|
||||||
@@ -237,12 +351,10 @@ public class RedisWritePointDetector {
|
|||||||
return resolveTypeNode(((CastExpr) expr).getType(), context);
|
return resolveTypeNode(((CastExpr) expr).getType(), context);
|
||||||
}
|
}
|
||||||
if (expr instanceof ObjectCreationExpr) {
|
if (expr instanceof ObjectCreationExpr) {
|
||||||
ClassOrInterfaceType t = ((ObjectCreationExpr) expr).getType();
|
return resolveTypeNode(((ObjectCreationExpr) expr).getType(), context);
|
||||||
return resolveTypeNode(t, context);
|
|
||||||
}
|
}
|
||||||
if (expr instanceof NameExpr) {
|
if (expr instanceof NameExpr) {
|
||||||
String name = ((NameExpr) expr).getNameAsString();
|
Type declared = findVariableType(((NameExpr) expr).getNameAsString(), contextCall);
|
||||||
Type declared = findVariableType(name, contextCall);
|
|
||||||
if (declared != null) {
|
if (declared != null) {
|
||||||
return resolveTypeNode(declared, context);
|
return resolveTypeNode(declared, context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,56 @@
|
|||||||
package com.codechecker.cache.detector;
|
package com.codechecker.cache.detector;
|
||||||
|
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一个 Redis value 写入点的静态描述。
|
* 一个 Redis / MQ value 写入(投递)点的静态描述。
|
||||||
*/
|
*/
|
||||||
|
@Data
|
||||||
public class WritePoint {
|
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 String filePath;
|
||||||
|
/** 写入/投递调用所在行号 */
|
||||||
private int lineNumber;
|
private int lineNumber;
|
||||||
|
/** 所在类 FQN */
|
||||||
private String enclosingClass;
|
private String enclosingClass;
|
||||||
|
/** 所在方法名 */
|
||||||
private String enclosingMethod;
|
private String enclosingMethod;
|
||||||
|
/** 命中的检测模式编号,如 W01、MQ01、MQ-K01 */
|
||||||
private String pattern;
|
private String pattern;
|
||||||
|
|
||||||
|
/** 源码中的 key / destination 表达式 */
|
||||||
private String keyExpression;
|
private String keyExpression;
|
||||||
|
/** 解析后的 key / Topic 模式(如 demo:foo:*) */
|
||||||
private String resolvedKeyPattern;
|
private String resolvedKeyPattern;
|
||||||
|
|
||||||
|
/** 源码中的 value 表达式 */
|
||||||
private String valueExpression;
|
private String valueExpression;
|
||||||
|
/** 推断的 value 类型 FQN */
|
||||||
private String resolvedValueType;
|
private String resolvedValueType;
|
||||||
|
/** value 是否为数组根(List / 数组序列化) */
|
||||||
private boolean rootArray;
|
private boolean rootArray;
|
||||||
|
|
||||||
|
/** 类型/key 推断置信度,默认 1.0 */
|
||||||
private double confidence = 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 两个版本间配对同一写入点 */
|
/** 稳定标识:用于在 old/new 两个版本间配对同一写入点 */
|
||||||
public String signature() {
|
public String signature() {
|
||||||
return enclosingClass + "#" + enclosingMethod + "|" + normalizeKey();
|
return enclosingClass + "#" + enclosingMethod + "|" + normalizeKey();
|
||||||
@@ -29,94 +60,14 @@ public class WritePoint {
|
|||||||
return keyExpression == null ? "" : keyExpression.replaceAll("\\s+", "");
|
return keyExpression == null ? "" : keyExpression.replaceAll("\\s+", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getFilePath() {
|
/** @return 是否为 MQ 通道(RocketMQ / Kafka) */
|
||||||
return filePath;
|
public boolean isMq() {
|
||||||
}
|
return CHANNEL_ROCKETMQ.equals(channel) || CHANNEL_KAFKA.equals(channel);
|
||||||
|
|
||||||
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 展示用写入位置,形如 {@code SimpleClass#method:line}
|
||||||
|
*/
|
||||||
public String location() {
|
public String location() {
|
||||||
String simpleClass = enclosingClass;
|
String simpleClass = enclosingClass;
|
||||||
if (simpleClass != null && simpleClass.contains(".")) {
|
if (simpleClass != null && simpleClass.contains(".")) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.codechecker.cache.diff;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 结构变更类型及其默认严重级别。
|
* 结构变更类型及其默认严重级别。
|
||||||
|
* <p>用于 Diff 结果分类与报告展示标签;实际严重级别可被 {@code severity_overrides} 覆盖。</p>
|
||||||
|
* @author DD
|
||||||
*/
|
*/
|
||||||
public enum ChangeType {
|
public enum ChangeType {
|
||||||
FIELD_REMOVED(Severity.P0, "字段删除"),
|
FIELD_REMOVED(Severity.P0, "字段删除"),
|
||||||
|
|||||||
@@ -1,103 +1,37 @@
|
|||||||
package com.codechecker.cache.diff;
|
package com.codechecker.cache.diff;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一条结构变更记录。
|
* 一条结构变更记录:关联 key/destination、写入位置、字段路径与前后取值说明。
|
||||||
|
* <p>由 {@link SchemaDiffer} 或分析器在「新增/删除写入点」时生成,再进入报告明细。</p>
|
||||||
*/
|
*/
|
||||||
|
@Data
|
||||||
public class SchemaChange {
|
public class SchemaChange {
|
||||||
|
|
||||||
|
/** 严重级别(可由 severity_overrides 覆盖默认值) */
|
||||||
private Severity severity;
|
private Severity severity;
|
||||||
|
/** 变更类型(字段增删、包装层、写入点增删等) */
|
||||||
private ChangeType changeType;
|
private ChangeType changeType;
|
||||||
|
/** 解析后的 key / Topic 模式 */
|
||||||
private String keyPattern;
|
private String keyPattern;
|
||||||
|
/** 源码中的 key / destination 表达式 */
|
||||||
private String keyExpression;
|
private String keyExpression;
|
||||||
|
/** 写入位置,形如 Class#method:line */
|
||||||
private String writeLocation;
|
private String writeLocation;
|
||||||
|
/** 展示用 value 类型名 */
|
||||||
private String valueType;
|
private String valueType;
|
||||||
|
/** 字段路径(点分,数组用 []),写入点级变更可为空 */
|
||||||
private String fieldPath;
|
private String fieldPath;
|
||||||
|
/** 变更前取值说明(类型名、路径等,依 changeType 而定) */
|
||||||
private String oldValue;
|
private String oldValue;
|
||||||
|
/** 变更后取值说明 */
|
||||||
private String newValue;
|
private String newValue;
|
||||||
|
/** 人可读变更说明,用于报告明细 */
|
||||||
private String message;
|
private String message;
|
||||||
|
|
||||||
public SchemaChange(ChangeType changeType) {
|
public SchemaChange(ChangeType changeType) {
|
||||||
this.changeType = changeType;
|
this.changeType = changeType;
|
||||||
this.severity = changeType.getDefaultSeverity();
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
package com.codechecker.cache.diff;
|
package com.codechecker.cache.diff;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 变更严重级别。
|
* 变更严重级别(默认由 {@link ChangeType} 给出,可被配置覆盖)。
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #P0} — 高危:字段删除、类型变更、包装层/路径迁移等</li>
|
||||||
|
* <li>{@link #P1} — 中危:新增字段、删除写入点等</li>
|
||||||
|
* <li>{@link #P2} — 低危/提示:新增写入点、低置信度变更等</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public enum Severity {
|
public enum Severity {
|
||||||
|
/** 高危结构破坏 */
|
||||||
P0,
|
P0,
|
||||||
|
/** 中危兼容性风险 */
|
||||||
P1,
|
P1,
|
||||||
|
/** 低危或需人工确认 */
|
||||||
P2
|
P2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import java.io.InputStreamReader;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基于 git 命令的差异扫描与双版本文件内容读取。
|
* 基于 git 命令的差异扫描与双版本文件内容读取。
|
||||||
@@ -18,6 +21,9 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public class GitDiffScanner {
|
public class GitDiffScanner {
|
||||||
|
|
||||||
|
/** {@code git log --pretty} 中标识 commit 头的前缀(解析责任人触及文件时用)。 */
|
||||||
|
static final String COMMIT_HEADER_PREFIX = "COMMIT ";
|
||||||
|
|
||||||
private final Path repoRoot;
|
private final Path repoRoot;
|
||||||
|
|
||||||
public GitDiffScanner(Path repoRoot) {
|
public GitDiffScanner(Path repoRoot) {
|
||||||
@@ -40,6 +46,55 @@ public class GitDiffScanner {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 old..new 区间内、由责任人<strong>非 merge</strong>提交触及的 Java 文件。
|
||||||
|
* <p>用于「作者 push 已告警、合并推送人不再背锅」:只把责任人自己的改动纳入检测触发集。</p>
|
||||||
|
*
|
||||||
|
* @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<>();
|
||||||
|
}
|
||||||
|
List<String> lines = runLines(
|
||||||
|
"git", "log", "--no-merges", "--name-only",
|
||||||
|
"--pretty=format:" + COMMIT_HEADER_PREFIX + "%an <%ae>",
|
||||||
|
oldSha + ".." + newSha, "--", "*.java");
|
||||||
|
return parseAuthorTouchedJavaFiles(lines, authorFilter.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 {@link #javaFilesTouchedByAuthor} 的 git log 输出。
|
||||||
|
*/
|
||||||
|
static Set<String> parseAuthorTouchedJavaFiles(List<String> lines, String authorFilter) {
|
||||||
|
Set<String> result = new LinkedHashSet<>();
|
||||||
|
if (lines == null || authorFilter == null || authorFilter.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
String needle = authorFilter.toLowerCase(Locale.ROOT);
|
||||||
|
boolean take = false;
|
||||||
|
for (String raw : lines) {
|
||||||
|
if (raw == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String line = raw.trim();
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith(COMMIT_HEADER_PREFIX)) {
|
||||||
|
String identity = line.substring(COMMIT_HEADER_PREFIX.length()).toLowerCase(Locale.ROOT);
|
||||||
|
take = identity.contains(needle);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (take && line.endsWith(".java")) {
|
||||||
|
result.add(line.replace('\\', '/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取某提交下指定文件的内容;文件在该提交不存在时返回 null。
|
* 读取某提交下指定文件的内容;文件在该提交不存在时返回 null。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package com.codechecker.cache.git;
|
package com.codechecker.cache.git;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Git 操作异常。
|
* Git 命令执行失败时抛出的受检异常(如 {@code git diff}/{@code git show} 非零退出)。
|
||||||
*/
|
*/
|
||||||
public class GitException extends Exception {
|
public class GitException extends Exception {
|
||||||
|
|
||||||
|
/** @param message 失败说明(通常含命令与 stderr 摘要) */
|
||||||
public GitException(String message) {
|
public GitException(String message) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param message 失败说明 @param cause 底层原因 */
|
||||||
public GitException(String message, Throwable cause) {
|
public GitException(String message, Throwable cause) {
|
||||||
super(message, cause);
|
super(message, cause);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ public class RedisKeyResolver {
|
|||||||
this.index = index;
|
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,
|
public String resolve(Expression keyExpr, ClassOrInterfaceDeclaration enclosingClass,
|
||||||
SourceIndex.IndexedType context) {
|
SourceIndex.IndexedType context) {
|
||||||
String raw = resolveExpr(keyExpr, enclosingClass, context, 0);
|
String raw = resolveExpr(keyExpr, enclosingClass, context, 0);
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 企业微信群机器人通知(markdown 消息)。
|
* 企业微信群机器人通知:发送 markdown 消息(单条或多条按序发送)。
|
||||||
|
* <p>Webhook 为空时跳过;HTTP/errcode 失败时打 stderr 日志并返回 false。</p>
|
||||||
*/
|
*/
|
||||||
public class WeComNotifier {
|
public class WeComNotifier {
|
||||||
|
|
||||||
|
|||||||
@@ -2,113 +2,49 @@ package com.codechecker.cache.report;
|
|||||||
|
|
||||||
import com.codechecker.cache.diff.SchemaChange;
|
import com.codechecker.cache.diff.SchemaChange;
|
||||||
import com.codechecker.cache.diff.Severity;
|
import com.codechecker.cache.diff.Severity;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一次检测的完整结果。
|
* 一次检测的完整结果:元信息、字段级 {@link SchemaChange} 列表、按 Key/Topic 聚合的
|
||||||
|
* {@link KeyStructureChange},以及是否阻断流水线与建议退出码。
|
||||||
*/
|
*/
|
||||||
|
@Data
|
||||||
public class CheckReport {
|
public class CheckReport {
|
||||||
|
|
||||||
|
/** 仓库名(报告抬头展示) */
|
||||||
private String repository;
|
private String repository;
|
||||||
|
/** 分支名 */
|
||||||
private String branch;
|
private String branch;
|
||||||
|
/** 对比基准提交 SHA */
|
||||||
private String oldSha;
|
private String oldSha;
|
||||||
|
/** 当前提交 SHA */
|
||||||
private String newSha;
|
private String newSha;
|
||||||
|
/** 责任人 / 推送人(报告抬头展示;流水线责任人过滤时通常为 Gitea actor) */
|
||||||
private String modifier;
|
private String modifier;
|
||||||
|
/** 提交时间(展示用字符串) */
|
||||||
private String modifyTime;
|
private String modifyTime;
|
||||||
|
/** 运行模式:notify / block */
|
||||||
private String mode;
|
private String mode;
|
||||||
|
|
||||||
|
/** 字段级变更明细(去重、抑制后) */
|
||||||
private final List<SchemaChange> changes = new ArrayList<>();
|
private final List<SchemaChange> changes = new ArrayList<>();
|
||||||
|
/** 按 Key/Topic 聚合的结构变更摘要 */
|
||||||
private final List<KeyStructureChange> keyChanges = new ArrayList<>();
|
private final List<KeyStructureChange> keyChanges = new ArrayList<>();
|
||||||
|
/** block 模式下是否应阻断流水线 */
|
||||||
private boolean blocked;
|
private boolean blocked;
|
||||||
|
/** 建议进程退出码:0 通过,1 阻断,2 执行错误 */
|
||||||
private int exitCode;
|
private int exitCode;
|
||||||
|
|
||||||
|
/** @return 是否存在字段级或 Key 级结构变更 */
|
||||||
public boolean hasChanges() {
|
public boolean hasChanges() {
|
||||||
return !changes.isEmpty() || !keyChanges.isEmpty();
|
return !changes.isEmpty() || !keyChanges.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return 字段明细中指定严重级别的条数 */
|
||||||
public long count(Severity severity) {
|
public long count(Severity severity) {
|
||||||
return changes.stream().filter(c -> c.getSeverity() == severity).count();
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,97 +2,66 @@ package com.codechecker.cache.report;
|
|||||||
|
|
||||||
import com.codechecker.cache.diff.SchemaChange;
|
import com.codechecker.cache.diff.SchemaChange;
|
||||||
import com.codechecker.cache.diff.Severity;
|
import com.codechecker.cache.diff.Severity;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按 Redis key(或未知 key 时的写入点)聚合后的结构变更摘要。
|
* 按 Redis key / MQ Topic(或未知时的写入点)聚合后的结构变更摘要。
|
||||||
*/
|
*/
|
||||||
|
@Data
|
||||||
public class KeyStructureChange {
|
public class KeyStructureChange {
|
||||||
|
|
||||||
/** 解析到的 key 模式;未知时为 unknown-key */
|
/** 解析到的 key / destination 模式;未知时为 unknown-key */
|
||||||
private String keyPattern;
|
private String keyPattern;
|
||||||
/** 源码中的 key 表达式,如 req.getKey() */
|
/** 源码中的 key / destination 表达式 */
|
||||||
private String keyExpression;
|
private String keyExpression;
|
||||||
/** 写入位置 Class#method:line */
|
/** 写入位置 Class#method:line */
|
||||||
private String writeLocation;
|
private String writeLocation;
|
||||||
/** 展示用 value 类型,如 List<ClockInExportVo> */
|
/** 展示用 value 类型,如 List<ClockInExportVo> */
|
||||||
private String valueType;
|
private String valueType;
|
||||||
|
/** key 是否未能静态解析(报告中标灰提示) */
|
||||||
private boolean keyUnresolved;
|
private boolean keyUnresolved;
|
||||||
|
/** REDIS / ROCKETMQ / KAFKA */
|
||||||
|
@Setter(AccessLevel.NONE)
|
||||||
|
private String channel = "REDIS";
|
||||||
|
/** 变更前序列化骨架 JSON(已删除写入/投递时仅有此项) */
|
||||||
private String oldSkeletonJson;
|
private String oldSkeletonJson;
|
||||||
|
/** 变更后序列化骨架 JSON(新增写入/投递时仅有此项) */
|
||||||
private String newSkeletonJson;
|
private String newSkeletonJson;
|
||||||
|
/** 本聚合块最高严重级别 */
|
||||||
private Severity severity = Severity.P2;
|
private Severity severity = Severity.P2;
|
||||||
|
/** 归属本 Key/Topic 的字段级变更明细 */
|
||||||
private final List<SchemaChange> fieldDetails = new ArrayList<>();
|
private final List<SchemaChange> fieldDetails = new ArrayList<>();
|
||||||
|
|
||||||
public String getKeyPattern() {
|
public void setChannel(String channel) {
|
||||||
return keyPattern;
|
this.channel = channel == null || channel.isEmpty() ? "REDIS" : channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setKeyPattern(String keyPattern) {
|
/** @return 是否为 MQ 通道 */
|
||||||
this.keyPattern = keyPattern;
|
public boolean isMq() {
|
||||||
|
return "ROCKETMQ".equals(channel) || "KAFKA".equals(channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getKeyExpression() {
|
/** @return 企微展示用通道名(RocketMQ / Kafka / Redis) */
|
||||||
return keyExpression;
|
public String channelDisplay() {
|
||||||
}
|
if ("ROCKETMQ".equals(channel)) {
|
||||||
|
return "RocketMQ";
|
||||||
public void setKeyExpression(String keyExpression) {
|
}
|
||||||
this.keyExpression = keyExpression;
|
if ("KAFKA".equals(channel)) {
|
||||||
}
|
return "Kafka";
|
||||||
|
}
|
||||||
public String getWriteLocation() {
|
return "Redis";
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用更严重的级别抬升当前聚合严重度(ordinal 更小者优先)。
|
||||||
|
*
|
||||||
|
* @param candidate 候选级别;为 null 时忽略
|
||||||
|
*/
|
||||||
public void raiseSeverity(Severity candidate) {
|
public void raiseSeverity(Severity candidate) {
|
||||||
if (candidate == null) {
|
if (candidate == null) {
|
||||||
return;
|
return;
|
||||||
@@ -102,7 +71,7 @@ public class KeyStructureChange {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通知里 Key 行展示文本:未解析优先用表达式。 */
|
/** 通知里 Key/Topic 行展示文本:未解析优先用表达式。 */
|
||||||
public String displayKey() {
|
public String displayKey() {
|
||||||
if (keyUnresolved && keyExpression != null && !keyExpression.trim().isEmpty()) {
|
if (keyUnresolved && keyExpression != null && !keyExpression.trim().isEmpty()) {
|
||||||
return keyExpression.trim();
|
return keyExpression.trim();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.EnumMap;
|
import java.util.EnumMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -16,7 +17,8 @@ import java.util.Set;
|
|||||||
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
|
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>企微:按 key 展示位置/类型/序列化骨架变更;类型变更另附摘要行,不分 P0/P1/P2</li>
|
* <li>企微:按 key 展示位置/类型/序列化骨架变更;类型变更另附摘要行,不分 P0/P1/P2</li>
|
||||||
* <li>未解析 key 展示源码表达式 + 灰色「key 无法解析」提示</li>
|
* <li>新增/删除写入点:专用文案 + 骨架整段染色(绿/橙)</li>
|
||||||
|
* <li>未解析 key 展示截断后的源码表达式 + 灰色 mapping 提示</li>
|
||||||
* <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
|
* <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
|
||||||
* <li>CI:先打字段明细,再完整输出企微 Markdown(拆分后的每条)</li>
|
* <li>CI:先打字段明细,再完整输出企微 Markdown(拆分后的每条)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
@@ -25,6 +27,8 @@ public class ReportBuilder {
|
|||||||
|
|
||||||
/** 企微 markdown content 字节上限(UTF-8)。 */
|
/** 企微 markdown content 字节上限(UTF-8)。 */
|
||||||
public static final int WECOM_MARKDOWN_MAX_BYTES = 4096;
|
public static final int WECOM_MARKDOWN_MAX_BYTES = 4096;
|
||||||
|
/** 未解析 destination 表达式展示最大字符数(超出截断)。 */
|
||||||
|
static final int UNRESOLVED_KEY_DISPLAY_MAX = 80;
|
||||||
|
|
||||||
private final String titlePrefix;
|
private final String titlePrefix;
|
||||||
|
|
||||||
@@ -124,34 +128,71 @@ public class ReportBuilder {
|
|||||||
|
|
||||||
private String renderKeyBlock(KeyStructureChange kc) {
|
private String renderKeyBlock(KeyStructureChange kc) {
|
||||||
StringBuilder sb = new StringBuilder();
|
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());
|
appendMetaLines(sb, kc.getWriteLocation(), kc.getValueType());
|
||||||
|
|
||||||
String oldJson = nvl(kc.getOldSkeletonJson());
|
String oldJson = nvl(kc.getOldSkeletonJson());
|
||||||
String newJson = nvl(kc.getNewSkeletonJson());
|
String newJson = nvl(kc.getNewSkeletonJson());
|
||||||
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
|
boolean addedOnly = isOnlyChangeType(kc.getFieldDetails(), ChangeType.WRITE_POINT_ADDED);
|
||||||
Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(kc.getFieldDetails());
|
boolean removedOnly = isOnlyChangeType(kc.getFieldDetails(), ChangeType.WRITE_POINT_REMOVED);
|
||||||
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
|
|
||||||
Set<String> wrappersAdded = SkeletonAnnotator.pathsForWrapperAdded(kc.getFieldDetails());
|
if (oldJson.isEmpty() && !newJson.isEmpty() && addedOnly) {
|
||||||
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails());
|
sb.append(" > **变更类型**: 新增写入点\n");
|
||||||
String oldRendered = oldJson.isEmpty()
|
String label = kc.isMq() ? "新增投递,消息体结构为" : "首次写入该 Key,value 结构为";
|
||||||
? "" : "“" + SkeletonAnnotator.annotateOldForWecom(
|
sb.append(" > **").append(label).append(":** “")
|
||||||
oldJson, oldHighlight, typeGray, wrappersRemoved) + "”";
|
.append(SkeletonAnnotator.wrapEntire(newJson, SkeletonAnnotator.COLOR_ADD))
|
||||||
String newRendered = newJson.isEmpty()
|
.append("”\n");
|
||||||
? "" : "“" + SkeletonAnnotator.annotateNewForWecom(
|
} else if (!oldJson.isEmpty() && newJson.isEmpty() && removedOnly) {
|
||||||
newJson, newGreen, typeGray, wrappersAdded) + "”";
|
sb.append(" > **变更类型**: 删除写入点\n");
|
||||||
if (oldJson.isEmpty() && !newJson.isEmpty()) {
|
String label = kc.isMq() ? "原消息体结构" : "原 value 结构";
|
||||||
sb.append(" > **value 新增为:** ").append(newRendered).append('\n');
|
String suffix = kc.isMq() ? "(投递点已删除)" : "(写入点已删除)";
|
||||||
} else if (!oldJson.isEmpty() && newJson.isEmpty()) {
|
sb.append(" > **").append(label).append(":** “")
|
||||||
sb.append(" > **value 原结构:** ").append(oldRendered).append("(已删除写入)\n");
|
.append(SkeletonAnnotator.wrapEntire(oldJson, SkeletonAnnotator.COLOR_REMOVE))
|
||||||
|
.append("”").append(suffix).append('\n');
|
||||||
} else {
|
} else {
|
||||||
sb.append(" > **value值由:** ").append(oldRendered).append('\n');
|
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
|
||||||
sb.append(" > **变更为:** ").append(newRendered).append('\n');
|
Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(kc.getFieldDetails());
|
||||||
|
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
|
||||||
|
Set<String> wrappersAdded = SkeletonAnnotator.pathsForWrapperAdded(kc.getFieldDetails());
|
||||||
|
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails());
|
||||||
|
String oldRendered = oldJson.isEmpty()
|
||||||
|
? "" : "“" + SkeletonAnnotator.annotateOldForWecom(
|
||||||
|
oldJson, oldHighlight, typeGray, wrappersRemoved) + "”";
|
||||||
|
String newRendered = newJson.isEmpty()
|
||||||
|
? "" : "“" + SkeletonAnnotator.annotateNewForWecom(
|
||||||
|
newJson, newGreen, typeGray, wrappersAdded) + "”";
|
||||||
|
if (oldJson.isEmpty() && !newJson.isEmpty()) {
|
||||||
|
// 兜底:有新骨架但明细不是纯 WRITE_POINT_ADDED
|
||||||
|
sb.append(" > **首次写入,value 结构为:** ").append(newRendered).append('\n');
|
||||||
|
} else if (!oldJson.isEmpty() && newJson.isEmpty()) {
|
||||||
|
sb.append(" > **原 value 结构:** ").append(oldRendered)
|
||||||
|
.append(kc.isMq() ? "(投递点已删除)\n" : "(写入点已删除)\n");
|
||||||
|
} else {
|
||||||
|
sb.append(" > **value值由:** ").append(oldRendered).append('\n');
|
||||||
|
sb.append(" > **变更为:** ").append(newRendered).append('\n');
|
||||||
|
}
|
||||||
|
appendTypeChangeSummary(sb, kc.getFieldDetails());
|
||||||
}
|
}
|
||||||
appendTypeChangeSummary(sb, kc.getFieldDetails());
|
|
||||||
sb.append('\n');
|
sb.append('\n');
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 明细非空且全部为同一变更类型(用于整段新增/删除展示)。 */
|
||||||
|
private static boolean isOnlyChangeType(List<SchemaChange> details, ChangeType expected) {
|
||||||
|
if (details == null || details.isEmpty() || expected == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (SchemaChange c : details) {
|
||||||
|
if (c == null || c.getChangeType() != expected) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 类型变更单独一行摘要,避免仅靠骨架颜色/占位难以识别。
|
* 类型变更单独一行摘要,避免仅靠骨架颜色/占位难以识别。
|
||||||
* 例:{@code > **类型变更**: amount <font color="warning">BigDecimal → Integer</font>}
|
* 例:{@code > **类型变更**: amount <font color="warning">BigDecimal → Integer</font>}
|
||||||
@@ -161,6 +202,7 @@ public class ReportBuilder {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<String> parts = new ArrayList<>();
|
List<String> parts = new ArrayList<>();
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
for (SchemaChange c : details) {
|
for (SchemaChange c : details) {
|
||||||
if (c == null || c.getChangeType() != ChangeType.TYPE_CHANGED) {
|
if (c == null || c.getChangeType() != ChangeType.TYPE_CHANGED) {
|
||||||
continue;
|
continue;
|
||||||
@@ -171,6 +213,10 @@ public class ReportBuilder {
|
|||||||
}
|
}
|
||||||
String oldType = displayJavaType(c.getOldValue());
|
String oldType = displayJavaType(c.getOldValue());
|
||||||
String newType = displayJavaType(c.getNewValue());
|
String newType = displayJavaType(c.getNewValue());
|
||||||
|
String dedup = path + "|" + oldType + "|" + newType;
|
||||||
|
if (!seen.add(dedup)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
StringBuilder part = new StringBuilder();
|
StringBuilder part = new StringBuilder();
|
||||||
// 字段名用普通文本(避免反引号被企微渲染成色块)
|
// 字段名用普通文本(避免反引号被企微渲染成色块)
|
||||||
part.append(path);
|
part.append(path);
|
||||||
@@ -213,24 +259,52 @@ public class ReportBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
|
* Key / Topic 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
|
||||||
* 反引号内仍须转义 {@code *},否则企微会把 {@code *:*} 当成斜体吃掉通配符。
|
*/
|
||||||
|
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,
|
private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression,
|
||||||
boolean unresolved) {
|
boolean unresolved) {
|
||||||
String keyText = unresolved && keyExpression != null && !keyExpression.trim().isEmpty()
|
String keyText = unresolved && keyExpression != null && !keyExpression.trim().isEmpty()
|
||||||
? keyExpression.trim()
|
? truncateDisplay(keyExpression.trim())
|
||||||
: nvl(displayKey);
|
: nvl(displayKey);
|
||||||
if (keyText.isEmpty()) {
|
if (keyText.isEmpty()) {
|
||||||
keyText = "unknown-key";
|
keyText = "unknown-key";
|
||||||
}
|
}
|
||||||
sb.append("- Key --> `").append(escapeWeComCode(keyText)).append('`');
|
sb.append("- Key --> `").append(escapeWeComCode(keyText)).append('`');
|
||||||
if (unresolved) {
|
if (unresolved) {
|
||||||
sb.append(" <font color=\"comment\">(key 无法解析)</font>");
|
sb.append(" <font color=\"comment\">(key 无法解析,建议 manual_mappings 补充)</font>");
|
||||||
}
|
}
|
||||||
sb.append('\n');
|
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 *} 会触发斜体(即使包在反引号里)。
|
* 企微 markdown 代码片段内的转义:{@code *} 会触发斜体(即使包在反引号里)。
|
||||||
*/
|
*/
|
||||||
@@ -330,7 +404,8 @@ public class ReportBuilder {
|
|||||||
}
|
}
|
||||||
String[] knownPrefixes = {
|
String[] knownPrefixes = {
|
||||||
"删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "删除包装层 ",
|
"删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "删除包装层 ",
|
||||||
"新增缓存写入点,", "删除缓存写入点,原 value 类型: "
|
"新增缓存写入点,", "删除缓存写入点,原 value 类型: ",
|
||||||
|
"新增 MQ 投递点,", "删除 MQ 投递点,原 value 类型: "
|
||||||
};
|
};
|
||||||
for (String prefix : knownPrefixes) {
|
for (String prefix : knownPrefixes) {
|
||||||
if (trimmed.startsWith(prefix)) {
|
if (trimmed.startsWith(prefix)) {
|
||||||
|
|||||||
@@ -176,6 +176,17 @@ final class SkeletonAnnotator {
|
|||||||
return annotate(json, highlightPaths, color);
|
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},勿再整段包代码块 */
|
/** @deprecated 使用 {@link #annotateForWecom},勿再整段包代码块 */
|
||||||
static String annotateAsCodeBlock(String json, Set<String> highlightPaths) {
|
static String annotateAsCodeBlock(String json, Set<String> highlightPaths) {
|
||||||
return annotateForWecom(json, highlightPaths);
|
return annotateForWecom(json, highlightPaths);
|
||||||
|
|||||||
@@ -1,53 +1,20 @@
|
|||||||
package com.codechecker.cache.schema;
|
package com.codechecker.cache.schema;
|
||||||
|
|
||||||
import java.util.Objects;
|
import lombok.Value;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扁平化后的单个字段节点。path 使用点号分隔,数组元素以 {@code []} 标记。
|
* 扁平化后的单个字段节点。path 使用点号分隔,数组元素以 {@code []} 标记。
|
||||||
* 例如:{@code vo.linkList[].id}。
|
* 例如:{@code vo.linkList[].id}。
|
||||||
*/
|
*/
|
||||||
|
@Value
|
||||||
public class FieldSchema {
|
public class FieldSchema {
|
||||||
|
|
||||||
private final String path;
|
/** 字段路径,如 {@code vo.linkList[].id} */
|
||||||
private final JsonType jsonType;
|
String path;
|
||||||
private final String javaType;
|
/** 映射到的 JSON 类型(OBJECT / ARRAY / STRING 等) */
|
||||||
|
JsonType jsonType;
|
||||||
public FieldSchema(String path, JsonType jsonType, String javaType) {
|
/** 原始 Java 类型简单名或 FQN 片段 */
|
||||||
this.path = path;
|
String javaType;
|
||||||
this.jsonType = jsonType;
|
|
||||||
this.javaType = javaType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getPath() {
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
public JsonType getJsonType() {
|
|
||||||
return jsonType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getJavaType() {
|
|
||||||
return javaType;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean equals(Object o) {
|
|
||||||
if (this == o) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!(o instanceof FieldSchema)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
FieldSchema that = (FieldSchema) o;
|
|
||||||
return Objects.equals(path, that.path)
|
|
||||||
&& jsonType == that.jsonType
|
|
||||||
&& Objects.equals(javaType, that.javaType);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int hashCode() {
|
|
||||||
return Objects.hash(path, jsonType, javaType);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
package com.codechecker.cache.schema;
|
package com.codechecker.cache.schema;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 序列化后 JSON 值的粗粒度类型。
|
* 序列化后 JSON 值的粗粒度类型,用于 Schema 展开与骨架占位符选择。
|
||||||
*/
|
*/
|
||||||
public enum JsonType {
|
public enum JsonType {
|
||||||
|
/** JSON 对象 */
|
||||||
OBJECT,
|
OBJECT,
|
||||||
|
/** JSON 数组 */
|
||||||
ARRAY,
|
ARRAY,
|
||||||
|
/** Map 结构 */
|
||||||
MAP,
|
MAP,
|
||||||
|
/** 字符串 */
|
||||||
STRING,
|
STRING,
|
||||||
|
/** 数值 */
|
||||||
NUMBER,
|
NUMBER,
|
||||||
|
/** 布尔 */
|
||||||
BOOLEAN,
|
BOOLEAN,
|
||||||
|
/** 无法判定 */
|
||||||
UNKNOWN
|
UNKNOWN
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ public class SkeletonJsonRenderer {
|
|||||||
/** 单侧骨架默认最大长度(企微 markdown 总长约 4096)。 */
|
/** 单侧骨架默认最大长度(企微 markdown 总长约 4096)。 */
|
||||||
public static final int DEFAULT_MAX_LEN = 1500;
|
public static final int DEFAULT_MAX_LEN = 1500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染完整骨架 JSON,不做长度截断。
|
||||||
|
*
|
||||||
|
* @param schema 扁平 Schema;空或 null 时返回 {@code "{}"}
|
||||||
|
*/
|
||||||
public String render(TypeSchema schema) {
|
public String render(TypeSchema schema) {
|
||||||
return render(schema, null, Integer.MAX_VALUE);
|
return render(schema, null, Integer.MAX_VALUE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import com.github.javaparser.ast.CompilationUnit;
|
|||||||
import com.github.javaparser.ast.ImportDeclaration;
|
import com.github.javaparser.ast.ImportDeclaration;
|
||||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||||
import com.github.javaparser.ast.body.TypeDeclaration;
|
import com.github.javaparser.ast.body.TypeDeclaration;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@@ -172,33 +175,16 @@ public class SourceIndex {
|
|||||||
/**
|
/**
|
||||||
* 索引中的类型条目。
|
* 索引中的类型条目。
|
||||||
*/
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor(access = AccessLevel.PACKAGE)
|
||||||
public static final class IndexedType {
|
public static final class IndexedType {
|
||||||
|
/** 类型 AST 声明 */
|
||||||
private final ClassOrInterfaceDeclaration declaration;
|
private final ClassOrInterfaceDeclaration declaration;
|
||||||
|
/** 所在包名(无包则为空串) */
|
||||||
private final String packageName;
|
private final String packageName;
|
||||||
|
/** 文件 import 列表(含通配 import.*) */
|
||||||
private final List<String> imports;
|
private final List<String> imports;
|
||||||
|
/** 类型 FQN(含内部类,以 . 分隔) */
|
||||||
private final String 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,35 @@
|
|||||||
package com.codechecker.cache.schema;
|
package com.codechecker.cache.schema;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 某个 Redis value 类型展开后的扁平化 Schema:path -> {@link FieldSchema}。
|
* 某个 Redis value 类型展开后的扁平化 Schema:path -> {@link FieldSchema}。
|
||||||
*/
|
*/
|
||||||
|
@Getter
|
||||||
public class TypeSchema {
|
public class TypeSchema {
|
||||||
|
|
||||||
|
/** 根 value 类型 FQN(或展示名) */
|
||||||
private final String rootType;
|
private final String rootType;
|
||||||
|
/** Schema 展开置信度;类型解析不完整时会下调 */
|
||||||
|
@Setter
|
||||||
private double confidence = 1.0;
|
private double confidence = 1.0;
|
||||||
|
/** path → 字段节点(插入顺序保留) */
|
||||||
private final Map<String, FieldSchema> fields = new LinkedHashMap<>();
|
private final Map<String, FieldSchema> fields = new LinkedHashMap<>();
|
||||||
|
|
||||||
public TypeSchema(String rootType) {
|
public TypeSchema(String rootType) {
|
||||||
this.rootType = rootType;
|
this.rootType = rootType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getRootType() {
|
/** 按字段 path 放入(同 path 后者覆盖)。 */
|
||||||
return rootType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getConfidence() {
|
|
||||||
return confidence;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setConfidence(double confidence) {
|
|
||||||
this.confidence = confidence;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, FieldSchema> getFields() {
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void add(FieldSchema field) {
|
public void add(FieldSchema field) {
|
||||||
fields.put(field.getPath(), field);
|
fields.put(field.getPath(), field);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return 是否无任何可展开字段(空 Schema / 无法解析类型时为 true) */
|
||||||
public boolean isEmpty() {
|
public boolean isEmpty() {
|
||||||
return fields.isEmpty();
|
return fields.isEmpty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ ignore:
|
|||||||
- "**/test/**"
|
- "**/test/**"
|
||||||
# 忽略的写入方法(类全名#方法名)
|
# 忽略的写入方法(类全名#方法名)
|
||||||
writer_methods: []
|
writer_methods: []
|
||||||
|
# 忽略的 MQ destination(topic 或 topic:tag,glob)
|
||||||
|
mq_destinations: []
|
||||||
|
|
||||||
# 检测规则
|
# 检测规则
|
||||||
detection:
|
detection:
|
||||||
@@ -44,8 +46,21 @@ detection:
|
|||||||
- W03 # stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)
|
- W03 # stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(x), ...)
|
||||||
- W04 # redisTemplate.opsForValue().set(key, obj, ...)
|
- W04 # redisTemplate.opsForValue().set(key, obj, ...)
|
||||||
- W05 # redisTemplate.opsForHash().put(key, field, obj)
|
- W05 # redisTemplate.opsForHash().put(key, field, obj)
|
||||||
|
# MQ 生产侧投递检测(Phase M1 + M2)
|
||||||
|
mq_patterns:
|
||||||
|
- MQ01 # rocketMQTemplate.syncSend(dest, payload)
|
||||||
|
- MQ02 # asyncSend / syncSendOrderly / sendOneWay
|
||||||
|
- MQ03 # convertAndSend(dest, payload)
|
||||||
|
- MQ04 # MessageBuilder.withPayload / Message<T>
|
||||||
|
- MQ05 # 先 toJSONString 再 send String
|
||||||
|
- MQ-K01 # kafkaTemplate.send(topic, payload)
|
||||||
|
- MQ-K02 # kafkaTemplate.send(topic, key, payload)
|
||||||
|
- MQ-K03 # kafkaTemplate.send(ProducerRecord)
|
||||||
|
- MQ-K04 # 先 JSON 序列化为 String 再 send
|
||||||
# W06 读侧辅助:用 parseObject / getJsonToBean 等补强写入点 value 类型(非写入模式)
|
# W06 读侧辅助:用 parseObject / getJsonToBean 等补强写入点 value 类型(非写入模式)
|
||||||
read_hints_enabled: true
|
read_hints_enabled: true
|
||||||
|
# MQ-R 读侧补强(Listener / parseObject)
|
||||||
|
mq_read_hints_enabled: true
|
||||||
# 类型推断最低置信度,低于此值标记为低置信度提示
|
# 类型推断最低置信度,低于此值标记为低置信度提示
|
||||||
min_confidence: 0.6
|
min_confidence: 0.6
|
||||||
# 字段展开最大深度(防止循环引用)
|
# 字段展开最大深度(防止循环引用)
|
||||||
|
|||||||
188
src/test/java/com/codechecker/cache/MqScenarioTest.java
vendored
Normal file
188
src/test/java/com/codechecker/cache/MqScenarioTest.java
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/test/java/com/codechecker/cache/RecordingTodoReplaceScenarioTest.java
vendored
Normal file
39
src/test/java/com/codechecker/cache/RecordingTodoReplaceScenarioTest.java
vendored
Normal 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#replaceText:StringRedisTemplate 缓存原文 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** {@link GlobMatcher} 的 glob 规则单测({@code *} / {@code **} / 字面量)。 */
|
||||||
class GlobMatcherTest {
|
class GlobMatcherTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证默认配置加载与业务 YAML 深度合并(业务项优先)。
|
||||||
|
*/
|
||||||
class ConfigLoaderTest {
|
class ConfigLoaderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -19,6 +22,11 @@ class ConfigLoaderTest {
|
|||||||
assertTrue(config.isEnabled());
|
assertTrue(config.isEnabled());
|
||||||
assertEquals("notify", config.getMode());
|
assertEquals("notify", config.getMode());
|
||||||
assertTrue(config.getDetection().getPatterns().contains("W01"));
|
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());
|
assertFalse(config.isScanTestSources());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** W06 读侧反序列化类型提示检测单测。 */
|
||||||
class CacheReadHintDetectorTest {
|
class CacheReadHintDetectorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
52
src/test/java/com/codechecker/cache/detector/MqReadHintDetectorTest.java
vendored
Normal file
52
src/test/java/com/codechecker/cache/detector/MqReadHintDetectorTest.java
vendored
Normal 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())));
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/test/java/com/codechecker/cache/detector/MqWritePointDetectorTest.java
vendored
Normal file
195
src/test/java/com/codechecker/cache/detector/MqWritePointDetectorTest.java
vendored
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ import java.util.Set;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 写入点检测单测(W01~W05、标量忽略、局部 JSON unwrap、本仓类型门槛)。
|
||||||
|
*/
|
||||||
class RedisWritePointDetectorTest {
|
class RedisWritePointDetectorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -98,4 +101,121 @@ class RedisWritePointDetectorTest {
|
|||||||
assertEquals(1, wps.size());
|
assertEquals(1, wps.size());
|
||||||
assertEquals("W01", wps.get(0).getPattern());
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import java.util.stream.Collectors;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** {@link SchemaDiffer} 包装层 / 字段迁移 / 增删等 Diff 行为单测。 */
|
||||||
class SchemaDifferTest {
|
class SchemaDifferTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
66
src/test/java/com/codechecker/cache/git/GitDiffScannerAuthorFilterTest.java
vendored
Normal file
66
src/test/java/com/codechecker/cache/git/GitDiffScannerAuthorFilterTest.java
vendored
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link GitDiffScanner#parseAuthorTouchedJavaFiles} 责任人过滤解析单测。
|
||||||
|
*/
|
||||||
|
class GitDiffScannerAuthorFilterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void takesOnlyMatchingAuthorJavaFiles() {
|
||||||
|
Set<String> files = GitDiffScanner.parseAuthorTouchedJavaFiles(Arrays.asList(
|
||||||
|
"COMMIT Alice <alice@example.com>",
|
||||||
|
"a/src/main/java/A.java",
|
||||||
|
"a/src/main/java/Shared.java",
|
||||||
|
"COMMIT Bob <bob@example.com>",
|
||||||
|
"b/src/main/java/B.java",
|
||||||
|
"a/src/main/java/Shared.java"
|
||||||
|
), "alice");
|
||||||
|
|
||||||
|
assertEquals(Set.of(
|
||||||
|
"a/src/main/java/A.java",
|
||||||
|
"a/src/main/java/Shared.java"
|
||||||
|
), files);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchesActorAgainstEmailCaseInsensitive() {
|
||||||
|
Set<String> files = GitDiffScanner.parseAuthorTouchedJavaFiles(Arrays.asList(
|
||||||
|
"COMMIT 张三 <ZhangSan@corp.com>",
|
||||||
|
"pkg/Foo.java",
|
||||||
|
"COMMIT 李四 <lisi@corp.com>",
|
||||||
|
"pkg/Bar.java"
|
||||||
|
), "zhangsan");
|
||||||
|
|
||||||
|
assertEquals(Set.of("pkg/Foo.java"), files);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skipsNonJavaAndEmptyLines() {
|
||||||
|
Set<String> files = GitDiffScanner.parseAuthorTouchedJavaFiles(Arrays.asList(
|
||||||
|
"COMMIT alice <a@x.com>",
|
||||||
|
"",
|
||||||
|
"README.md",
|
||||||
|
"src/Main.java",
|
||||||
|
"COMMIT other <o@x.com>",
|
||||||
|
"src/Other.java"
|
||||||
|
), "alice");
|
||||||
|
|
||||||
|
assertEquals(Set.of("src/Main.java"), files);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyFilterOrLinesYieldEmpty() {
|
||||||
|
assertTrue(GitDiffScanner.parseAuthorTouchedJavaFiles(Collections.emptyList(), "alice").isEmpty());
|
||||||
|
assertTrue(GitDiffScanner.parseAuthorTouchedJavaFiles(
|
||||||
|
Collections.singletonList("COMMIT alice <a@x.com>"), "").isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/** Redis key 表达式静态模式推断单测(常量、format、拼接等)。 */
|
||||||
class RedisKeyResolverTest {
|
class RedisKeyResolverTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -15,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.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企微 Markdown / 控制台报告渲染单测:字段染色、新增写入点整段绿、拆条与转义。
|
||||||
|
*/
|
||||||
class ReportBuilderTest {
|
class ReportBuilderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -168,6 +171,37 @@ class ReportBuilderTest {
|
|||||||
"应包含类型变更摘要行,实际 markdown:\n" + md);
|
"应包含类型变更摘要行,实际 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
|
@Test
|
||||||
void wrapperAddedHighlightedBeforeNestedMovedFields() {
|
void wrapperAddedHighlightedBeforeNestedMovedFields() {
|
||||||
SchemaChange wrapper = new SchemaChange(ChangeType.WRAPPER_ADDED);
|
SchemaChange wrapper = new SchemaChange(ChangeType.WRAPPER_ADDED);
|
||||||
@@ -331,12 +365,127 @@ class ReportBuilderTest {
|
|||||||
report.getKeyChanges().add(key);
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
assertTrue(md.contains("- Key --> `req.getKey()` <font color=\"comment\">(key 无法解析)</font>"));
|
assertTrue(md.contains("- Key --> `req.getKey()` <font color=\"comment\">(key 无法解析,建议 manual_mappings 补充)</font>"));
|
||||||
assertTrue(md.contains("> **位置**: ClockInXxxService#export:128"));
|
assertTrue(md.contains("> **位置**: ClockInXxxService#export:128"));
|
||||||
assertTrue(md.contains("> **类型**: List<ClockInExportVo>"));
|
assertTrue(md.contains("> **类型**: List<ClockInExportVo>"));
|
||||||
assertFalse(md.contains("`unknown-key`"));
|
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("> **首次写入该 Key,value 结构为:**"), 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
|
@Test
|
||||||
void pathAwareNestedHighlight() {
|
void pathAwareNestedHighlight() {
|
||||||
String json = "{\"vo\":{\"dbName\":\"\",\"id\":\"\"},\"expiresAtMs\":0}";
|
String json = "{\"vo\":{\"dbName\":\"\",\"id\":\"\"},\"expiresAtMs\":0}";
|
||||||
|
|||||||
@@ -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.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** Java 类型 → 扁平 Schema 提取单测(注解忽略 / 重命名等)。 */
|
||||||
class JavaSchemaExtractorTest {
|
class JavaSchemaExtractorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import java.util.stream.Collectors;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/** 序列化骨架 JSON 渲染与受保护路径截断单测。 */
|
||||||
class SkeletonJsonRendererTest {
|
class SkeletonJsonRendererTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
public class CheckItemDetailVo {
|
||||||
|
private String itemId;
|
||||||
|
private String itemName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
public class CheckItemDetailVo {
|
||||||
|
private String itemId;
|
||||||
|
private String itemName;
|
||||||
|
private Integer score;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.kafka.core.KafkaTemplate;
|
||||||
|
|
||||||
|
public class PatrolService {
|
||||||
|
private KafkaTemplate kafkaTemplate;
|
||||||
|
private static final String TOPIC = "patrol-store-food-safe:%s";
|
||||||
|
|
||||||
|
public void sendFoodSafeData(String tenantId, List<CheckItemDetailVo> thousandsData) {
|
||||||
|
String topic = String.format(TOPIC, tenantId);
|
||||||
|
kafkaTemplate.send(topic, thousandsData);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
import org.springframework.kafka.annotation.KafkaListener;
|
||||||
|
|
||||||
|
public class PatrolNotifyListener {
|
||||||
|
@KafkaListener(topics = "patrol-notify-topic", groupId = "patrol-group")
|
||||||
|
public void handle(String message) {
|
||||||
|
PatrolNotifyVo vo = JSONObject.parseObject(message, PatrolNotifyVo.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
import org.apache.kafka.clients.producer.ProducerRecord;
|
||||||
|
import org.springframework.kafka.core.KafkaTemplate;
|
||||||
|
|
||||||
|
public class PatrolNotifyProducer {
|
||||||
|
private KafkaTemplate kafkaTemplate;
|
||||||
|
private static final String TOPIC = "patrol-notify-topic";
|
||||||
|
|
||||||
|
public void sendRecord(PatrolNotifyVo vo) {
|
||||||
|
ProducerRecord<String, PatrolNotifyVo> record = new ProducerRecord<>(TOPIC, vo);
|
||||||
|
kafkaTemplate.send(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendJson(PatrolNotifyVo vo) {
|
||||||
|
String json = JSON.toJSONString(vo);
|
||||||
|
kafkaTemplate.send(TOPIC, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package demo.kafka;
|
||||||
|
|
||||||
|
public class PatrolNotifyVo {
|
||||||
|
private String storeId;
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
public class DutyImNotice {
|
||||||
|
private String tenantId;
|
||||||
|
private String id;
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||||
|
|
||||||
|
@RocketMQMessageListener(topic = DutyImNoticeProducer.MQ_TOPIC, consumerGroup = "duty-im-group")
|
||||||
|
public class DutyImNoticeConsumer implements RocketMQListener<DutyImNotice> {
|
||||||
|
public void onMessage(DutyImNotice message) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||||
|
import org.springframework.messaging.Message;
|
||||||
|
import org.springframework.messaging.support.MessageBuilder;
|
||||||
|
|
||||||
|
public class DutyImNoticeProducer {
|
||||||
|
private RocketMQTemplate rocketMQTemplate;
|
||||||
|
public static final String MQ_TOPIC = "duty-im-notice-topic";
|
||||||
|
|
||||||
|
public void addMq(DutyImNotice message, long timestamp) {
|
||||||
|
String uniqueKey = message.getTenantId() + "_" + message.getId();
|
||||||
|
Message<DutyImNotice> mqMessage = MessageBuilder.withPayload(message)
|
||||||
|
.setHeader("KEYS", uniqueKey)
|
||||||
|
.setHeader("executeTime", timestamp)
|
||||||
|
.build();
|
||||||
|
rocketMQTemplate.asyncSend(MQ_TOPIC, mqMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void convertSend(DutyImNotice message) {
|
||||||
|
rocketMQTemplate.convertAndSend(MQ_TOPIC, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendJson(DutyImNotice message) {
|
||||||
|
String body = JSON.toJSONString(message);
|
||||||
|
rocketMQTemplate.syncSend(MQ_TOPIC, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
public final class CapitalMqConstants {
|
||||||
|
public static final String TOPIC = "capital-topic";
|
||||||
|
public static final String TAG_WALLET_DEDUCT = "WALLET_DEDUCT";
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||||
|
|
||||||
|
public class WalletDeductProducer {
|
||||||
|
private RocketMQTemplate rocketMQTemplate;
|
||||||
|
|
||||||
|
public void send(WalletDeductReq req) {
|
||||||
|
rocketMQTemplate.syncSend(CapitalMqConstants.TOPIC + ":" + CapitalMqConstants.TAG_WALLET_DEDUCT, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
public class WalletDeductReq {
|
||||||
|
private String walletId;
|
||||||
|
private Long amount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package demo.mq;
|
||||||
|
|
||||||
|
public class WalletDeductReq {
|
||||||
|
private String walletId;
|
||||||
|
private Long amount;
|
||||||
|
private String remark;
|
||||||
|
}
|
||||||
@@ -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:";
|
||||||
|
}
|
||||||
@@ -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 序列化。
|
||||||
|
* 对应企微误报:类型 <未解析>、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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user